1#![warn(missing_docs)]
6#![warn(
7 clippy::all,
8 clippy::as_conversions,
9 clippy::clone_on_ref_ptr,
10 clippy::dbg_macro
11)]
12#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)]
13
14mod worker;
15
16use std::collections::{HashMap, HashSet};
17use std::fmt::Write as _;
18use std::fs::File;
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21use std::sync::Arc;
22use std::time::SystemTime;
23
24use bstr::ByteSlice;
25use clap::ValueEnum;
26use crossbeam::channel::{Receiver, RecvError};
27use cursive::theme::{BaseColor, Effect, Style};
28use cursive::utils::markup::StyledString;
29
30use eyre::WrapErr;
31use fslock::LockFile;
32use git_branchless_invoke::CommandContext;
33use indexmap::IndexMap;
34use itertools::Itertools;
35use lazy_static::lazy_static;
36use lib::core::check_out::CheckOutCommitOptions;
37use lib::core::config::{
38 Hint, get_hint_enabled, get_hint_string, get_restack_preserve_timestamps,
39 print_hint_suppression_notice,
40};
41use lib::core::dag::{CommitSet, Dag, sorted_commit_set};
42use lib::core::effects::{Effects, OperationIcon, OperationType, icons};
43use lib::core::eventlog::{
44 BRANCHLESS_TRANSACTION_ID_ENV_VAR, EventLogDb, EventReplayer, EventTransactionId,
45};
46use lib::core::formatting::{Glyphs, Pluralize, StyledStringBuilder};
47use lib::core::repo_ext::RepoExt;
48use lib::core::rewrite::{
49 BuildRebasePlanOptions, ExecuteRebasePlanOptions, ExecuteRebasePlanResult, RebaseCommand,
50 RebasePlan, RebasePlanBuilder, RebasePlanPermissions, RepoResource, execute_rebase_plan,
51};
52use lib::git::{
53 Commit, ConfigRead, GitRunInfo, GitRunResult, MaybeZeroOid, NonZeroOid, Repo,
54 SerializedNonZeroOid, SerializedTestResult, TEST_ABORT_EXIT_CODE, TEST_INDETERMINATE_EXIT_CODE,
55 TEST_SUCCESS_EXIT_CODE, TestCommand, WorkingCopyChangesType, get_latest_test_command_path,
56 get_test_locks_dir, get_test_tree_dir, get_test_worktrees_dir, make_test_command_slug,
57};
58use lib::try_exit_code;
59use lib::util::{ExitCode, EyreExitOr, get_sh};
60use rayon::ThreadPoolBuilder;
61use scm_bisect::basic::{BasicSourceControlGraph, BasicStrategy, BasicStrategyKind};
62use scm_bisect::search;
63use tempfile::TempDir;
64use thiserror::Error;
65use tracing::{debug, info, instrument, warn};
66
67use git_branchless_opts::{
68 MoveOptions, ResolveRevsetOptions, Revset, TestArgs, TestExecutionStrategy, TestSearchStrategy,
69 TestSubcommand,
70};
71use git_branchless_revset::resolve_commits;
72
73use crate::worker::{JobResult, WorkQueue, WorkerId, worker};
74
75lazy_static! {
76 static ref STYLE_SUCCESS: Style =
77 Style::merge(&[BaseColor::Green.light().into(), Effect::Bold.into()]);
78 static ref STYLE_FAILURE: Style =
79 Style::merge(&[BaseColor::Red.light().into(), Effect::Bold.into()]);
80 static ref STYLE_SKIPPED: Style =
81 Style::merge(&[BaseColor::Yellow.light().into(), Effect::Bold.into()]);
82}
83
84#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
86pub enum Verbosity {
87 None,
89
90 PartialOutput,
92
93 FullOutput,
95}
96
97impl From<u8> for Verbosity {
98 fn from(value: u8) -> Self {
99 match value {
100 0 => Self::None,
101 1 => Self::PartialOutput,
102 _ => Self::FullOutput,
103 }
104 }
105}
106
107#[derive(Debug)]
110pub struct RawTestOptions {
111 pub exec: Option<String>,
113
114 pub command: Option<String>,
116
117 pub dry_run: bool,
120
121 pub strategy: Option<TestExecutionStrategy>,
123
124 pub search: Option<TestSearchStrategy>,
127
128 pub bisect: bool,
130
131 pub no_cache: bool,
133
134 pub interactive: bool,
136
137 pub jobs: Option<usize>,
139
140 pub verbosity: Verbosity,
142
143 pub apply_fixes: bool,
146}
147
148fn resolve_test_command_alias(
149 effects: &Effects,
150 repo: &Repo,
151 alias: Option<&str>,
152) -> EyreExitOr<String> {
153 let config = repo.get_readonly_config()?;
154 let config_key = format!("branchless.test.alias.{}", alias.unwrap_or("default"));
155 let config_value: Option<String> = config.get(config_key).unwrap_or_default();
156 if let Some(command) = config_value {
157 return Ok(Ok(command));
158 }
159
160 match alias {
161 Some(alias) => {
162 writeln!(
163 effects.get_output_stream(),
164 "\
165The test command alias {alias:?} was not defined.
166
167To create it, run: git config branchless.test.alias.{alias} <command>
168Or use the -x/--exec flag instead to run a test command without first creating an alias."
169 )?;
170 }
171 None => {
172 writeln!(
173 effects.get_output_stream(),
174 "\
175Could not determine test command to run. No test command was provided with -c/--command or
176-x/--exec, and the configuration value 'branchless.test.alias.default' was not set.
177
178To configure a default test command, run: git config branchless.test.alias.default <command>
179To run a specific test command, run: git test run -x <command>
180To run a specific command alias, run: git test run -c <alias>",
181 )?;
182 }
183 }
184
185 let aliases = config.list("branchless.test.alias.*")?;
186 if !aliases.is_empty() {
187 writeln!(
188 effects.get_output_stream(),
189 "\nThese are the currently-configured command aliases:"
190 )?;
191 for (name, command) in aliases {
192 writeln!(
193 effects.get_output_stream(),
194 "{} {name} = {command:?}",
195 effects.get_glyphs().bullet_point,
196 )?;
197 }
198 }
199
200 Ok(Err(ExitCode(1)))
201}
202
203#[allow(missing_docs)]
206#[derive(Debug)]
207pub struct ResolvedTestOptions {
208 pub command: TestCommand,
209 pub execution_strategy: TestExecutionStrategy,
210 pub search_strategy: Option<TestSearchStrategy>,
211 pub is_dry_run: bool,
212 pub use_cache: bool,
213 pub is_interactive: bool,
214 pub num_jobs: usize,
215 pub verbosity: Verbosity,
216 pub fix_options: Option<(ExecuteRebasePlanOptions, RebasePlanPermissions)>,
217}
218
219impl ResolvedTestOptions {
220 pub fn resolve(
222 now: SystemTime,
223 effects: &Effects,
224 dag: &Dag,
225 repo: &Repo,
226 event_tx_id: EventTransactionId,
227 commits: &CommitSet,
228 move_options: Option<&MoveOptions>,
229 options: &RawTestOptions,
230 ) -> EyreExitOr<Self> {
231 let config = repo.get_readonly_config()?;
232 let RawTestOptions {
233 exec: command,
234 command: command_alias,
235 dry_run,
236 strategy,
237 search,
238 bisect,
239 no_cache,
240 interactive,
241 jobs,
242 verbosity,
243 apply_fixes,
244 } = options;
245 let resolved_command = match (command, command_alias) {
246 (Some(command), None) => command.to_owned(),
247 (None, None) => match (interactive, std::env::var("SHELL")) {
248 (true, Ok(shell)) => shell,
249 _ => match resolve_test_command_alias(effects, repo, None)? {
250 Ok(command) => command,
251 Err(exit_code) => {
252 return Ok(Err(exit_code));
253 }
254 },
255 },
256 (None, Some(command_alias)) => {
257 match resolve_test_command_alias(effects, repo, Some(command_alias))? {
258 Ok(command) => command,
259 Err(exit_code) => {
260 return Ok(Err(exit_code));
261 }
262 }
263 }
264 (Some(command), Some(command_alias)) => unreachable!(
265 "Command ({:?}) and command alias ({:?}) are conflicting options",
266 command, command_alias
267 ),
268 };
269 let configured_execution_strategy = match strategy {
270 Some(strategy) => *strategy,
271 None => {
272 let strategy_config_key = "branchless.test.strategy";
273 let strategy: Option<String> = config.get(strategy_config_key)?;
274 match strategy {
275 None => TestExecutionStrategy::WorkingCopy,
276 Some(strategy) => match TestExecutionStrategy::from_str(&strategy, true) {
277 Ok(strategy) => strategy,
278 Err(_) => {
279 writeln!(
280 effects.get_output_stream(),
281 "Invalid value for config value {strategy_config_key}: {strategy}"
282 )?;
283 writeln!(
284 effects.get_output_stream(),
285 "Expected one of: {}",
286 TestExecutionStrategy::value_variants()
287 .iter()
288 .filter_map(|variant| variant.to_possible_value())
289 .map(|value| value.get_name().to_owned())
290 .join(", ")
291 )?;
292 return Ok(Err(ExitCode(1)));
293 }
294 },
295 }
296 }
297 };
298
299 let jobs_config_key = "branchless.test.jobs";
300 let configured_jobs: Option<i32> = config.get(jobs_config_key)?;
301 let configured_jobs = match configured_jobs {
302 None => None,
303 Some(configured_jobs) => match usize::try_from(configured_jobs) {
304 Ok(configured_jobs) => Some(configured_jobs),
305 Err(err) => {
306 writeln!(
307 effects.get_output_stream(),
308 "Invalid value for config value for {jobs_config_key} ({configured_jobs}): {err}"
309 )?;
310 return Ok(Err(ExitCode(1)));
311 }
312 },
313 };
314 let (resolved_num_jobs, resolved_execution_strategy, resolved_interactive) = match jobs {
315 None => match (strategy, *interactive) {
316 (Some(TestExecutionStrategy::WorkingCopy), interactive) => {
317 (1, TestExecutionStrategy::WorkingCopy, interactive)
318 }
319 (Some(TestExecutionStrategy::Worktree), true) => {
320 (1, TestExecutionStrategy::Worktree, true)
321 }
322 (Some(TestExecutionStrategy::Worktree), false) => (
323 configured_jobs.unwrap_or(1),
324 TestExecutionStrategy::Worktree,
325 false,
326 ),
327 (None, true) => (1, configured_execution_strategy, true),
328 (None, false) => (
329 configured_jobs.unwrap_or(1),
330 configured_execution_strategy,
331 false,
332 ),
333 },
334 Some(1) => (1, configured_execution_strategy, *interactive),
335 Some(jobs) => {
336 if *interactive {
337 writeln!(
338 effects.get_output_stream(),
339 "\
340The --jobs option cannot be used with the --interactive option."
341 )?;
342 return Ok(Err(ExitCode(1)));
343 }
344 match strategy {
346 None | Some(TestExecutionStrategy::Worktree) => {
347 (*jobs, TestExecutionStrategy::Worktree, false)
348 }
349 Some(TestExecutionStrategy::WorkingCopy) => {
350 writeln!(
351 effects.get_output_stream(),
352 "\
353The --jobs option can only be used with --strategy worktree, but --strategy working-copy was provided instead."
354 )?;
355 return Ok(Err(ExitCode(1)));
356 }
357 }
358 }
359 };
360
361 if resolved_interactive != *interactive {
362 writeln!(
363 effects.get_output_stream(),
364 "\
365BUG: Expected resolved_interactive ({resolved_interactive:?}) to match interactive ({interactive:?}). If it doesn't match, then multiple interactive jobs might inadvertently be launched in parallel."
366 )?;
367 return Ok(Err(ExitCode(1)));
368 }
369
370 let resolved_num_jobs = if resolved_num_jobs == 0 {
371 num_cpus::get_physical()
372 } else {
373 resolved_num_jobs
374 };
375 assert!(resolved_num_jobs > 0);
376
377 let fix_options = if *apply_fixes {
378 let move_options = match move_options {
379 Some(move_options) => move_options,
380 None => {
381 writeln!(
382 effects.get_output_stream(),
383 "BUG: fixes were requested to be applied, but no `BuildRebasePlanOptions` were provided."
384 )?;
385 return Ok(Err(ExitCode(1)));
386 }
387 };
388 let MoveOptions {
389 force_rewrite_public_commits,
390 force_in_memory: _,
391 force_on_disk,
392 detect_duplicate_commits_via_patch_id,
393 resolve_merge_conflicts,
394 dump_rebase_constraints,
395 dump_rebase_plan,
396 reparent: _, } = move_options;
398
399 if *resolve_merge_conflicts {
400 writeln!(
401 effects.get_output_stream(),
402 "Ignoring --merge since --reparent is always implied when fixing commits."
403 )?;
404 }
405
406 let force_in_memory = true;
407 if *force_on_disk {
408 writeln!(
409 effects.get_output_stream(),
410 "The --on-disk option cannot be provided for fixes. Use the --in-memory option instead."
411 )?;
412 return Ok(Err(ExitCode(1)));
413 }
414
415 let build_options = BuildRebasePlanOptions {
416 force_rewrite_public_commits: *force_rewrite_public_commits,
417 dump_rebase_constraints: *dump_rebase_constraints,
418 dump_rebase_plan: *dump_rebase_plan,
419 detect_duplicate_commits_via_patch_id: *detect_duplicate_commits_via_patch_id,
420 };
421 let execute_options = ExecuteRebasePlanOptions {
422 now,
423 event_tx_id,
424 preserve_timestamps: get_restack_preserve_timestamps(repo)?,
425 force_in_memory,
426 force_on_disk: *force_on_disk,
427 dry_run: false,
428 resolve_merge_conflicts: *resolve_merge_conflicts,
429 check_out_commit_options: CheckOutCommitOptions {
430 render_smartlog: false,
431 ..Default::default()
432 },
433 };
434 let permissions =
435 match RebasePlanPermissions::verify_rewrite_set(dag, build_options, commits)? {
436 Ok(permissions) => permissions,
437 Err(err) => {
438 err.describe(effects, repo, dag)?;
439 return Ok(Err(ExitCode(1)));
440 }
441 };
442 Some((execute_options, permissions))
443 } else {
444 None
445 };
446
447 let resolved_search_strategy = if *bisect {
448 Some(TestSearchStrategy::Binary)
449 } else {
450 *search
451 };
452
453 let resolved_test_options = ResolvedTestOptions {
454 command: TestCommand::String(resolved_command),
455 execution_strategy: resolved_execution_strategy,
456 search_strategy: resolved_search_strategy,
457 use_cache: !no_cache,
458 is_dry_run: *dry_run,
459 is_interactive: resolved_interactive,
460 num_jobs: resolved_num_jobs,
461 verbosity: *verbosity,
462 fix_options,
463 };
464 debug!(?resolved_test_options, "Resolved test options");
465 Ok(Ok(resolved_test_options))
466 }
467
468 fn make_command_slug(&self) -> String {
469 make_test_command_slug(self.command.to_string())
470 }
471}
472
473#[instrument]
475pub fn command_main(ctx: CommandContext, args: TestArgs) -> EyreExitOr<()> {
476 let CommandContext {
477 effects,
478 git_run_info,
479 } = ctx;
480 let TestArgs { subcommand } = args;
481 match subcommand {
482 TestSubcommand::Clean {
483 revset,
484 resolve_revset_options,
485 } => subcommand_clean(&effects, revset, &resolve_revset_options),
486
487 TestSubcommand::Run {
488 exec: command,
489 command: command_alias,
490 revset,
491 resolve_revset_options,
492 verbosity,
493 strategy,
494 search,
495 bisect,
496 no_cache,
497 interactive,
498 jobs,
499 } => subcommand_run(
500 &effects,
501 &git_run_info,
502 &RawTestOptions {
503 exec: command,
504 command: command_alias,
505 dry_run: false,
506 strategy,
507 search,
508 bisect,
509 no_cache,
510 interactive,
511 jobs,
512 verbosity: Verbosity::from(verbosity),
513 apply_fixes: false,
514 },
515 revset,
516 &resolve_revset_options,
517 None,
518 ),
519
520 TestSubcommand::Show {
521 exec: command,
522 command: command_alias,
523 revset,
524 resolve_revset_options,
525 verbosity,
526 } => subcommand_show(
527 &effects,
528 &RawTestOptions {
529 exec: command,
530 command: command_alias,
531 dry_run: false,
532 strategy: None,
533 search: None,
534 bisect: false,
535 no_cache: false,
536 interactive: false,
537 jobs: None,
538 verbosity: Verbosity::from(verbosity),
539 apply_fixes: false,
540 },
541 revset,
542 &resolve_revset_options,
543 ),
544
545 TestSubcommand::Fix {
546 exec: command,
547 command: command_alias,
548 dry_run,
549 revset,
550 resolve_revset_options,
551 verbosity,
552 strategy,
553 no_cache,
554 jobs,
555 move_options,
556 } => subcommand_run(
557 &effects,
558 &git_run_info,
559 &RawTestOptions {
560 exec: command,
561 command: command_alias,
562 dry_run,
563 strategy,
564 search: None,
565 bisect: false,
566 no_cache,
567 interactive: false,
568 jobs,
569 verbosity: Verbosity::from(verbosity),
570 apply_fixes: true,
571 },
572 revset,
573 &resolve_revset_options,
574 Some(&move_options),
575 ),
576 }
577}
578
579#[instrument]
581fn subcommand_run(
582 effects: &Effects,
583 git_run_info: &GitRunInfo,
584 options: &RawTestOptions,
585 revset: Revset,
586 resolve_revset_options: &ResolveRevsetOptions,
587 move_options: Option<&MoveOptions>,
588) -> EyreExitOr<()> {
589 let now = SystemTime::now();
590 let repo = Repo::from_current_dir()?;
591 let conn = repo.get_db_conn()?;
592 let event_log_db = EventLogDb::new(&conn)?;
593 let event_tx_id = event_log_db.make_transaction_id(now, "test run")?;
594 let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
595 let event_cursor = event_replayer.make_default_cursor();
596 let references_snapshot = repo.get_references_snapshot()?;
597 let mut dag = Dag::open_and_sync(
598 effects,
599 &repo,
600 &event_replayer,
601 event_cursor,
602 &references_snapshot,
603 )?;
604
605 let commit_set = match resolve_commits(
606 effects,
607 &repo,
608 &mut dag,
609 std::slice::from_ref(&revset),
610 resolve_revset_options,
611 ) {
612 Ok(mut commit_sets) => commit_sets.pop().unwrap(),
613 Err(err) => {
614 err.describe(effects)?;
615 return Ok(Err(ExitCode(1)));
616 }
617 };
618
619 let options = try_exit_code!(ResolvedTestOptions::resolve(
620 now,
621 effects,
622 &dag,
623 &repo,
624 event_tx_id,
625 &commit_set,
626 move_options,
627 options,
628 )?);
629
630 let commits = sorted_commit_set(&repo, &dag, &commit_set)?;
631 let test_results = try_exit_code!(run_tests(
632 now,
633 effects,
634 git_run_info,
635 &dag,
636 &repo,
637 &event_log_db,
638 &revset,
639 &commits,
640 &options,
641 )?);
642
643 try_exit_code!(print_summary(
644 effects,
645 &dag,
646 &repo,
647 &revset,
648 &options.command,
649 &test_results,
650 options.search_strategy.is_some(),
651 options.fix_options.is_some(),
652 &options.verbosity,
653 )?);
654
655 if let Some((execute_options, permissions)) = &options.fix_options {
656 try_exit_code!(apply_fixes(
657 effects,
658 git_run_info,
659 &mut dag,
660 &repo,
661 &event_log_db,
662 execute_options,
663 permissions.clone(),
664 options.is_dry_run,
665 &options.command,
666 &test_results,
667 )?);
668 }
669
670 Ok(Ok(()))
671}
672
673#[must_use]
674#[derive(Debug)]
675struct AbortTrap {
676 is_active: bool,
677}
678
679#[instrument]
685fn set_abort_trap(
686 now: SystemTime,
687 effects: &Effects,
688 git_run_info: &GitRunInfo,
689 repo: &Repo,
690 event_log_db: &EventLogDb,
691 event_tx_id: EventTransactionId,
692 strategy: TestExecutionStrategy,
693) -> EyreExitOr<AbortTrap> {
694 match strategy {
695 TestExecutionStrategy::Worktree => return Ok(Ok(AbortTrap { is_active: false })),
696 TestExecutionStrategy::WorkingCopy => {}
697 }
698
699 if let Some(operation_type) = repo.get_current_operation_type() {
700 writeln!(
701 effects.get_output_stream(),
702 "A {operation_type} operation is already in progress."
703 )?;
704 writeln!(
705 effects.get_output_stream(),
706 "Run git {operation_type} --continue or git {operation_type} --abort to resolve it and proceed."
707 )?;
708 return Ok(Err(ExitCode(1)));
709 }
710
711 let head_info = repo.get_head_info()?;
712 let head_oid = match head_info.oid {
713 Some(head_oid) => head_oid,
714 None => {
715 writeln!(
716 effects.get_output_stream(),
717 "No commit is currently checked out; cannot start on-disk rebase."
718 )?;
719 writeln!(
720 effects.get_output_stream(),
721 "Check out a commit and try again."
722 )?;
723 return Ok(Err(ExitCode(1)));
724 }
725 };
726
727 let rebase_plan = RebasePlan {
728 first_dest_oid: head_oid,
729 commands: vec![RebaseCommand::Break],
730 };
731 match execute_rebase_plan(
732 effects,
733 git_run_info,
734 repo,
735 event_log_db,
736 &rebase_plan,
737 &ExecuteRebasePlanOptions {
738 now,
739 event_tx_id,
740 preserve_timestamps: true,
741 force_in_memory: false,
742 force_on_disk: true,
743 dry_run: false,
744 resolve_merge_conflicts: false,
745 check_out_commit_options: CheckOutCommitOptions {
746 render_smartlog: false,
747 ..Default::default()
748 },
749 },
750 )? {
751 ExecuteRebasePlanResult::Succeeded { rewritten_oids: _ }
752 | ExecuteRebasePlanResult::WouldSucceed => {
753 }
755 ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info } => {
756 writeln!(
757 effects.get_output_stream(),
758 "BUG: Encountered unexpected merge failure: {failed_merge_info:?}"
759 )?;
760 return Ok(Err(ExitCode(1)));
761 }
762 ExecuteRebasePlanResult::Failed { exit_code } => {
763 return Ok(Err(exit_code));
764 }
765 }
766
767 Ok(Ok(AbortTrap { is_active: true }))
768}
769
770#[instrument]
771fn clear_abort_trap(
772 effects: &Effects,
773 git_run_info: &GitRunInfo,
774 event_tx_id: EventTransactionId,
775 abort_trap: AbortTrap,
776) -> EyreExitOr<()> {
777 let AbortTrap { is_active } = abort_trap;
778 if is_active {
779 try_exit_code!(git_run_info.run(effects, Some(event_tx_id), &["rebase", "--abort"])?);
780 }
781 Ok(Ok(()))
782}
783
784#[derive(Debug)]
786pub struct TestOutput {
787 pub temp_dir: Option<TempDir>,
790
791 pub result_path: PathBuf,
794
795 pub stdout_path: PathBuf,
797
798 pub stderr_path: PathBuf,
800
801 pub test_status: TestStatus,
803}
804
805#[derive(Clone, Debug)]
807pub enum TestStatus {
808 CheckoutFailed,
810
811 SpawnTestFailed(String),
813
814 TerminatedBySignal,
817
818 AlreadyInProgress,
821
822 ReadCacheFailed(String),
824
825 Indeterminate {
827 exit_code: i32,
829 },
830
831 Abort {
833 exit_code: i32,
835 },
836
837 Failed {
839 cached: bool,
842
843 exit_code: i32,
845
846 interactive: bool,
849 },
850
851 Passed {
853 cached: bool,
856
857 fix_info: FixInfo,
859
860 interactive: bool,
863 },
864}
865
866#[derive(Clone, Debug)]
868pub struct FixInfo {
869 pub head_commit_oid: Option<NonZeroOid>,
873
874 pub snapshot_tree_oid: Option<NonZeroOid>,
878}
879
880impl TestStatus {
881 #[instrument]
882 fn get_icon(&self) -> &'static str {
883 match self {
884 TestStatus::CheckoutFailed
885 | TestStatus::SpawnTestFailed(_)
886 | TestStatus::AlreadyInProgress
887 | TestStatus::ReadCacheFailed(_)
888 | TestStatus::TerminatedBySignal
889 | TestStatus::Indeterminate { .. } => icons::EXCLAMATION,
890 TestStatus::Failed { .. } | TestStatus::Abort { .. } => icons::CROSS,
891 TestStatus::Passed { .. } => icons::CHECKMARK,
892 }
893 }
894
895 #[instrument]
896 fn get_style(&self) -> Style {
897 match self {
898 TestStatus::CheckoutFailed
899 | TestStatus::SpawnTestFailed(_)
900 | TestStatus::AlreadyInProgress
901 | TestStatus::ReadCacheFailed(_)
902 | TestStatus::TerminatedBySignal
903 | TestStatus::Indeterminate { .. } => *STYLE_SKIPPED,
904 TestStatus::Failed { .. } | TestStatus::Abort { .. } => *STYLE_FAILURE,
905 TestStatus::Passed { .. } => *STYLE_SUCCESS,
906 }
907 }
908
909 #[instrument]
911 pub fn describe(
912 &self,
913 glyphs: &Glyphs,
914 commit: &Commit,
915 apply_fixes: bool,
916 ) -> eyre::Result<StyledString> {
917 let description = match self {
918 TestStatus::CheckoutFailed => StyledStringBuilder::new()
919 .append_styled("Failed to check out: ", self.get_style())
920 .append(commit.friendly_describe(glyphs)?)
921 .build(),
922
923 TestStatus::SpawnTestFailed(err) => StyledStringBuilder::new()
924 .append_styled(
925 format!("Failed to spawn command: {err}: "),
926 self.get_style(),
927 )
928 .append(commit.friendly_describe(glyphs)?)
929 .build(),
930
931 TestStatus::TerminatedBySignal => StyledStringBuilder::new()
932 .append_styled("Command terminated by signal: ", self.get_style())
933 .append(commit.friendly_describe(glyphs)?)
934 .build(),
935
936 TestStatus::AlreadyInProgress => StyledStringBuilder::new()
937 .append_styled("Command already in progress? ", self.get_style())
938 .append(commit.friendly_describe(glyphs)?)
939 .build(),
940
941 TestStatus::ReadCacheFailed(_) => StyledStringBuilder::new()
942 .append_styled("Could not read cached command result: ", self.get_style())
943 .append(commit.friendly_describe(glyphs)?)
944 .build(),
945
946 TestStatus::Indeterminate { exit_code } => StyledStringBuilder::new()
947 .append_styled(
948 format!("Exit code indicated to skip this commit (exit code {exit_code}): "),
949 self.get_style(),
950 )
951 .append(commit.friendly_describe(glyphs)?)
952 .build(),
953
954 TestStatus::Abort { exit_code } => StyledStringBuilder::new()
955 .append_styled(
956 format!("Exit code indicated to abort command (exit code {exit_code}): "),
957 self.get_style(),
958 )
959 .append(commit.friendly_describe(glyphs)?)
960 .build(),
961
962 TestStatus::Failed {
963 cached,
964 interactive,
965 exit_code,
966 } => {
967 let mut descriptors = Vec::new();
968 if *cached {
969 descriptors.push("cached".to_string());
970 }
971 descriptors.push(format!("exit code {exit_code}"));
972 if *interactive {
973 descriptors.push("interactive".to_string());
974 }
975 let descriptors = descriptors.join(", ");
976 StyledStringBuilder::new()
977 .append_styled(format!("Failed ({descriptors}): "), self.get_style())
978 .append(commit.friendly_describe(glyphs)?)
979 .build()
980 }
981
982 TestStatus::Passed {
983 cached,
984 interactive,
985 fix_info:
986 FixInfo {
987 head_commit_oid: _,
988 snapshot_tree_oid,
989 },
990 } => {
991 let mut descriptors = Vec::new();
992 if *cached {
993 descriptors.push("cached".to_string());
994 }
995 match (snapshot_tree_oid, commit.get_tree_oid()) {
996 (Some(snapshot_tree_oid), MaybeZeroOid::NonZero(original_tree_oid)) => {
997 if *snapshot_tree_oid != original_tree_oid {
998 descriptors.push(if apply_fixes {
999 "fixed".to_string()
1000 } else {
1001 "fixable".to_string()
1002 });
1003 }
1004 }
1005 (None, _) | (_, MaybeZeroOid::Zero) => {}
1006 }
1007 if *interactive {
1008 descriptors.push("interactive".to_string());
1009 }
1010 let descriptors = if descriptors.is_empty() {
1011 "".to_string()
1012 } else {
1013 format!(" ({})", descriptors.join(", "))
1014 };
1015 StyledStringBuilder::new()
1016 .append_styled(format!("Passed{descriptors}: "), self.get_style())
1017 .append(commit.friendly_describe(glyphs)?)
1018 .build()
1019 }
1020 };
1021 Ok(description)
1022 }
1023}
1024
1025#[derive(Debug)]
1027pub struct TestingAbortedError {
1028 pub commit_oid: NonZeroOid,
1030
1031 pub exit_code: i32,
1033}
1034
1035impl TestOutput {
1036 #[instrument]
1037 fn describe(
1038 &self,
1039 effects: &Effects,
1040 commit: &Commit,
1041 apply_fixes: bool,
1042 verbosity: Verbosity,
1043 ) -> eyre::Result<StyledString> {
1044 let description = StyledStringBuilder::new()
1045 .append_styled(self.test_status.get_icon(), self.test_status.get_style())
1046 .append_plain(" ")
1047 .append(
1048 self.test_status
1049 .describe(effects.get_glyphs(), commit, apply_fixes)?,
1050 )
1051 .build();
1052
1053 if verbosity == Verbosity::None {
1054 return Ok(StyledStringBuilder::from_lines(vec![description]));
1055 }
1056
1057 fn abbreviate_lines(path: &Path, verbosity: Verbosity) -> Vec<StyledString> {
1058 let should_show_all_lines = match verbosity {
1059 Verbosity::None => return Vec::new(),
1060 Verbosity::PartialOutput => false,
1061 Verbosity::FullOutput => true,
1062 };
1063
1064 let contents = match std::fs::read_to_string(path) {
1066 Ok(contents) => contents,
1067 Err(_) => {
1068 return vec![
1069 StyledStringBuilder::new()
1070 .append_plain("<failed to read file>")
1071 .build(),
1072 ];
1073 }
1074 };
1075
1076 const NUM_CONTEXT_LINES: usize = 5;
1077 let lines = contents.lines().collect_vec();
1078 let num_missing_lines = lines.len().saturating_sub(2 * NUM_CONTEXT_LINES);
1079 let num_missing_lines_message = format!("<{num_missing_lines} more lines>");
1080 let lines = if lines.is_empty() {
1081 vec!["<no output>"]
1082 } else if num_missing_lines == 0 || should_show_all_lines {
1083 lines
1084 } else {
1085 [
1086 &lines[..NUM_CONTEXT_LINES],
1087 &[num_missing_lines_message.as_str()],
1088 &lines[lines.len() - NUM_CONTEXT_LINES..],
1089 ]
1090 .concat()
1091 };
1092 lines
1093 .into_iter()
1094 .map(|line| StyledStringBuilder::new().append_plain(line).build())
1095 .collect()
1096 }
1097
1098 let interactive = match self.test_status {
1099 TestStatus::CheckoutFailed
1100 | TestStatus::SpawnTestFailed(_)
1101 | TestStatus::TerminatedBySignal
1102 | TestStatus::AlreadyInProgress
1103 | TestStatus::ReadCacheFailed(_)
1104 | TestStatus::Indeterminate { .. }
1105 | TestStatus::Abort { .. } => false,
1106 TestStatus::Failed { interactive, .. } | TestStatus::Passed { interactive, .. } => {
1107 interactive
1108 }
1109 };
1110
1111 let stdout_lines = {
1112 let mut lines = Vec::new();
1113 if !interactive {
1114 lines.push(
1115 StyledStringBuilder::new()
1116 .append_styled("Stdout: ", Effect::Bold)
1117 .append_plain(self.stdout_path.to_string_lossy())
1118 .build(),
1119 );
1120 lines.extend(abbreviate_lines(&self.stdout_path, verbosity));
1121 }
1122 lines
1123 };
1124 let stderr_lines = {
1125 let mut lines = Vec::new();
1126 if !interactive {
1127 lines.push(
1128 StyledStringBuilder::new()
1129 .append_styled("Stderr: ", Effect::Bold)
1130 .append_plain(self.stderr_path.to_string_lossy())
1131 .build(),
1132 );
1133 lines.extend(abbreviate_lines(&self.stderr_path, verbosity));
1134 }
1135 lines
1136 };
1137
1138 Ok(StyledStringBuilder::from_lines(
1139 [
1140 &[description],
1141 stdout_lines.as_slice(),
1142 stderr_lines.as_slice(),
1143 ]
1144 .concat(),
1145 ))
1146 }
1147}
1148
1149fn shell_escape(s: impl AsRef<str>) -> String {
1150 let s = s.as_ref();
1151 let mut escaped = String::new();
1152 escaped.push('"');
1153 for c in s.chars() {
1154 match c {
1155 '"' => escaped.push_str(r#"\""#),
1156 '\\' => escaped.push_str(r"\\\\"),
1157 c => escaped.push(c),
1158 }
1159 }
1160 escaped.push('"');
1161 escaped
1162}
1163
1164#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1165struct TestJob {
1166 commit_oid: NonZeroOid,
1167 operation_type: OperationType,
1168}
1169
1170#[derive(Debug, Error)]
1171enum SearchGraphError {
1172 #[error(transparent)]
1173 Dag(#[from] eden_dag::Error),
1174
1175 #[error(transparent)]
1176 Other(#[from] eyre::Error),
1177}
1178
1179#[derive(Debug)]
1180struct SearchGraph<'a> {
1181 dag: &'a Dag,
1182 commit_set: CommitSet,
1183}
1184
1185impl BasicSourceControlGraph for SearchGraph<'_> {
1186 type Node = NonZeroOid;
1187 type Error = SearchGraphError;
1188
1189 #[instrument]
1190 fn ancestors(&self, node: Self::Node) -> Result<HashSet<Self::Node>, Self::Error> {
1191 let ancestors = self.dag.query_ancestors(CommitSet::from(node))?;
1192 let ancestors = ancestors.intersection(&self.commit_set);
1193 let ancestors = self.dag.commit_set_to_vec(&ancestors)?;
1194 Ok(ancestors.into_iter().collect())
1195 }
1196
1197 #[instrument]
1198 fn descendants(&self, node: Self::Node) -> Result<HashSet<Self::Node>, Self::Error> {
1199 let descendants = self.dag.query_descendants(CommitSet::from(node))?;
1200 let descendants = descendants.intersection(&self.commit_set);
1201 let descendants = self.dag.commit_set_to_vec(&descendants)?;
1202 Ok(descendants.into_iter().collect())
1203 }
1204}
1205
1206#[derive(Debug)]
1208pub struct TestResults {
1209 pub search_bounds: search::Bounds<NonZeroOid>,
1212
1213 pub test_outputs: IndexMap<NonZeroOid, TestOutput>,
1215
1216 pub testing_aborted_error: Option<TestingAbortedError>,
1218}
1219
1220#[instrument]
1222pub fn run_tests(
1223 now: SystemTime,
1224 effects: &Effects,
1225 git_run_info: &GitRunInfo,
1226 dag: &Dag,
1227 repo: &Repo,
1228 event_log_db: &EventLogDb,
1229 revset: &Revset,
1230 commits: &[Commit],
1231 options: &ResolvedTestOptions,
1232) -> EyreExitOr<TestResults> {
1233 let event_tx_id = EventTransactionId::Suppressed;
1234 let abort_trap = match set_abort_trap(
1235 now,
1236 effects,
1237 git_run_info,
1238 repo,
1239 event_log_db,
1240 event_tx_id,
1241 options.execution_strategy,
1242 )? {
1243 Ok(abort_trap) => abort_trap,
1244 Err(exit_code) => return Ok(Err(exit_code)),
1245 };
1246 let test_results: Result<_, _> = {
1247 let effects = if options.is_interactive {
1248 effects.suppress()
1249 } else {
1250 effects.clone()
1251 };
1252 run_tests_inner(
1253 &effects,
1254 git_run_info,
1255 dag,
1256 repo,
1257 event_log_db,
1258 event_tx_id,
1259 revset,
1260 commits,
1261 options,
1262 )
1263 };
1264
1265 try_exit_code!(clear_abort_trap(
1266 effects,
1267 git_run_info,
1268 event_tx_id,
1269 abort_trap
1270 )?);
1271 test_results
1272}
1273
1274#[instrument]
1275fn run_tests_inner(
1276 effects: &Effects,
1277 git_run_info: &GitRunInfo,
1278 dag: &Dag,
1279 repo: &Repo,
1280 event_log_db: &EventLogDb,
1281 event_tx_id: EventTransactionId,
1282 revset: &Revset,
1283 commits: &[Commit],
1284 options: &ResolvedTestOptions,
1285) -> EyreExitOr<TestResults> {
1286 let ResolvedTestOptions {
1287 command,
1288 execution_strategy,
1289 search_strategy,
1290 use_cache: _, is_dry_run: _, is_interactive: _, num_jobs,
1294 verbosity: _, fix_options: _, } = &options;
1297
1298 let shell_path = match get_sh() {
1299 Some(shell_path) => shell_path,
1300 None => {
1301 writeln!(
1302 effects.get_output_stream(),
1303 "{}",
1304 effects.get_glyphs().render(
1305 StyledStringBuilder::new()
1306 .append_styled(
1307 "Error: Could not determine path to shell.",
1308 BaseColor::Red.light()
1309 )
1310 .build()
1311 )?
1312 )?;
1313 return Ok(Err(ExitCode(1)));
1314 }
1315 };
1316
1317 if let Some(strategy_value) = execution_strategy.to_possible_value() {
1318 writeln!(
1319 effects.get_output_stream(),
1320 "Using command execution strategy: {}",
1321 effects.get_glyphs().render(
1322 StyledStringBuilder::new()
1323 .append_styled(strategy_value.get_name(), Effect::Bold)
1324 .build()
1325 )?,
1326 )?;
1327 }
1328
1329 if let Some(strategy_value) = search_strategy.and_then(|opt| opt.to_possible_value()) {
1330 writeln!(
1331 effects.get_output_stream(),
1332 "Using test search strategy: {}",
1333 effects.get_glyphs().render(
1334 StyledStringBuilder::new()
1335 .append_styled(strategy_value.get_name(), Effect::Bold)
1336 .build()
1337 )?,
1338 )?;
1339 }
1340 let search_strategy = match search_strategy {
1341 None => None,
1342 Some(TestSearchStrategy::Linear) => Some(BasicStrategyKind::Linear),
1343 Some(TestSearchStrategy::Reverse) => Some(BasicStrategyKind::LinearReverse),
1344 Some(TestSearchStrategy::Binary) => Some(BasicStrategyKind::Binary),
1345 };
1346 let search_strategy = search_strategy.map(BasicStrategy::new);
1347
1348 let latest_test_command_path = get_latest_test_command_path(repo)?;
1349 if let Some(parent) = latest_test_command_path.parent() {
1350 if let Err(err) = std::fs::create_dir_all(parent) {
1351 warn!(
1352 ?err,
1353 ?latest_test_command_path,
1354 "Failed to create containing directory for latest test command"
1355 );
1356 }
1357 }
1358 if let Err(err) = std::fs::write(&latest_test_command_path, command.to_string()) {
1359 warn!(
1360 ?err,
1361 ?latest_test_command_path,
1362 "Failed to write latest test command to disk"
1363 );
1364 }
1365
1366 let EventLoopOutput {
1367 search,
1368 test_outputs: test_outputs_unordered,
1369 testing_aborted_error,
1370 } = {
1371 let (effects, progress) =
1372 effects.start_operation(OperationType::RunTests(Arc::new(command.to_string())));
1373 progress.notify_progress(0, commits.len());
1374 let commit_jobs = {
1375 let mut results = IndexMap::new();
1376 for commit in commits {
1377 let commit_description = effects
1380 .get_glyphs()
1381 .render(commit.friendly_describe(effects.get_glyphs())?)?;
1382 let operation_type =
1383 OperationType::RunTestOnCommit(Arc::new(commit_description.clone()));
1384 let (_effects, progress) = effects.start_operation(operation_type.clone());
1385 progress.notify_status(
1386 OperationIcon::InProgress,
1387 format!("Waiting to run on {commit_description}"),
1388 );
1389 results.insert(
1390 commit.get_oid(),
1391 TestJob {
1392 commit_oid: commit.get_oid(),
1393 operation_type,
1394 },
1395 );
1396 }
1397 results
1398 };
1399
1400 let graph = SearchGraph {
1401 dag,
1402 commit_set: commits.iter().map(|c| c.get_oid()).collect(),
1403 };
1404 let search = search::Search::new(graph, commits.iter().map(|c| c.get_oid()));
1405
1406 let work_queue = WorkQueue::new();
1407 let repo_dir = repo.get_path();
1408 crossbeam::thread::scope(|scope| -> eyre::Result<_> {
1409 let (result_tx, result_rx) = crossbeam::channel::unbounded();
1410 let workers: HashMap<WorkerId, crossbeam::thread::ScopedJoinHandle<()>> = {
1411 let mut result = HashMap::new();
1412 for worker_id in 1..=*num_jobs {
1413 let effects = &effects;
1414 let progress = &progress;
1415 let shell_path = &shell_path;
1416 let work_queue = work_queue.clone();
1417 let result_tx = result_tx.clone();
1418 let setup = move || -> eyre::Result<Repo> {
1419 let repo = Repo::from_dir(repo_dir)?;
1420 Ok(repo)
1421 };
1422 let f = move |job: TestJob, repo: &Repo| -> eyre::Result<TestOutput> {
1423 let TestJob {
1424 commit_oid,
1425 operation_type,
1426 } = job;
1427 let commit = repo.find_commit_or_fail(commit_oid)?;
1428 run_test(
1429 effects,
1430 operation_type,
1431 git_run_info,
1432 shell_path,
1433 repo,
1434 event_tx_id,
1435 options,
1436 worker_id,
1437 &commit,
1438 )
1439 };
1440 result.insert(
1441 worker_id,
1442 scope.spawn(move |_scope| {
1443 worker(progress, worker_id, work_queue, result_tx, setup, f);
1444 debug!("Exiting spawned thread closure");
1445 }),
1446 );
1447 }
1448 result
1449 };
1450
1451 drop(result_tx);
1456
1457 let test_results = event_loop(
1458 commit_jobs,
1459 search,
1460 search_strategy.clone(),
1461 *num_jobs,
1462 work_queue.clone(),
1463 result_rx,
1464 );
1465 work_queue.close();
1466 let test_results = test_results?;
1467
1468 if test_results.testing_aborted_error.is_none() && search_strategy.is_none() {
1469 debug!("Waiting for workers");
1470 progress.notify_status(OperationIcon::InProgress, "Waiting for workers");
1471 for (worker_id, worker) in workers {
1472 worker
1473 .join()
1474 .map_err(|_err| eyre::eyre!("Waiting for worker {worker_id} to exit"))?;
1475 }
1476 }
1477
1478 debug!("About to return from thread scope");
1479 Ok(test_results)
1480 })
1481 .map_err(|_| eyre::eyre!("Could not spawn workers"))?
1482 .wrap_err("Failed waiting on workers")?
1483 };
1484 debug!("Returned from thread scope");
1485
1486 let test_outputs_ordered: IndexMap<NonZeroOid, TestOutput> = {
1489 let mut test_outputs_unordered = test_outputs_unordered;
1490 let mut test_outputs_ordered = IndexMap::new();
1491 for commit_oid in commits.iter().map(|commit| commit.get_oid()) {
1492 match test_outputs_unordered.remove(&commit_oid) {
1493 Some(result) => {
1494 test_outputs_ordered.insert(commit_oid, result);
1495 }
1496 None => {
1497 if search_strategy.is_none() && testing_aborted_error.is_none() {
1498 warn!(?commit_oid, "No result was returned for commit");
1499 }
1500 }
1501 }
1502 }
1503 if !test_outputs_unordered.is_empty() {
1504 warn!(
1505 ?test_outputs_unordered,
1506 ?commits,
1507 "There were extra results for commits not appearing in the input list"
1508 );
1509 }
1510 test_outputs_ordered
1511 };
1512
1513 Ok(Ok(TestResults {
1514 search_bounds: match search_strategy {
1515 None => Default::default(),
1516 Some(search_strategy) => search.search(&search_strategy)?.bounds,
1517 },
1518 test_outputs: test_outputs_ordered,
1519 testing_aborted_error,
1520 }))
1521}
1522
1523struct EventLoopOutput<'a> {
1524 search: search::Search<SearchGraph<'a>>,
1525 test_outputs: HashMap<NonZeroOid, TestOutput>,
1526 testing_aborted_error: Option<TestingAbortedError>,
1527}
1528
1529fn event_loop(
1530 commit_jobs: IndexMap<NonZeroOid, TestJob>,
1531 mut search: search::Search<SearchGraph>,
1532 search_strategy: Option<BasicStrategy>,
1533 num_jobs: usize,
1534 work_queue: WorkQueue<TestJob>,
1535 result_rx: Receiver<JobResult<TestJob, TestOutput>>,
1536) -> eyre::Result<EventLoopOutput> {
1537 #[derive(Debug)]
1538 enum ScheduledJob {
1539 Scheduled(TestJob),
1540 Complete(TestOutput),
1541 }
1542 let mut scheduled_jobs: HashMap<NonZeroOid, ScheduledJob> = Default::default();
1543 let mut testing_aborted_error = None;
1544
1545 if search_strategy.is_none() {
1546 let jobs_to_schedule = commit_jobs
1547 .keys()
1548 .map(|commit_oid| commit_jobs[commit_oid].clone())
1549 .collect_vec();
1550 debug!(
1551 ?jobs_to_schedule,
1552 "Scheduling all jobs (since no search strategy was specified)"
1553 );
1554 for job in &jobs_to_schedule {
1555 scheduled_jobs.insert(job.commit_oid, ScheduledJob::Scheduled(job.clone()));
1556 }
1557 work_queue.set(jobs_to_schedule);
1558 }
1559
1560 loop {
1561 if let Some(err) = &testing_aborted_error {
1562 debug!(?err, "Testing aborted");
1563 break;
1564 }
1565
1566 if let Some(search_strategy) = &search_strategy {
1567 scheduled_jobs = scheduled_jobs
1568 .into_iter()
1569 .filter_map(|(commit_oid, scheduled_job)| match scheduled_job {
1570 ScheduledJob::Scheduled(_) => None,
1571 scheduled_job @ ScheduledJob::Complete(_) => Some((commit_oid, scheduled_job)),
1572 })
1573 .collect();
1574
1575 let solution = search.search(search_strategy)?;
1576 let next_to_search: Vec<_> = solution
1577 .next_to_search
1578 .filter(|commit_oid| {
1579 let commit_oid = match commit_oid {
1580 Ok(commit_oid) => commit_oid,
1581 Err(_) => {
1582 return true;
1584 }
1585 };
1586
1587 match scheduled_jobs.get(commit_oid) {
1590 Some(ScheduledJob::Complete(_)) => false,
1591 Some(ScheduledJob::Scheduled(_)) => {
1592 warn!(
1593 ?commit_oid,
1594 "Left-over scheduled job; this should have already been filtered out."
1595 );
1596 true
1597 }
1598 None => true,
1599 }
1600 })
1601 .take(num_jobs)
1602 .try_collect()?;
1603 if next_to_search.is_empty() {
1604 debug!("Search completed, exiting.");
1605 break;
1606 }
1607 let jobs_to_schedule = next_to_search
1608 .into_iter()
1609 .map(|commit_oid| commit_jobs[&commit_oid].clone())
1610 .collect_vec();
1611 debug!(
1612 ?search_strategy,
1613 ?jobs_to_schedule,
1614 "Jobs to schedule for search"
1615 );
1616 for job in &jobs_to_schedule {
1617 if let Some(previous_job) =
1618 scheduled_jobs.insert(job.commit_oid, ScheduledJob::Scheduled(job.clone()))
1619 {
1620 warn!(?job, ?previous_job, "Overwriting previously-scheduled job");
1621 }
1622 }
1623 work_queue.set(jobs_to_schedule);
1624 }
1625
1626 let message = {
1627 let jobs_in_progress = scheduled_jobs
1628 .values()
1629 .filter_map(|scheduled_job| match scheduled_job {
1630 ScheduledJob::Scheduled(job) => Some(job),
1631 ScheduledJob::Complete(_) => None,
1632 })
1633 .collect_vec();
1634 if jobs_in_progress.is_empty() {
1635 debug!("No more in-progress jobs to wait on, exiting");
1636 break;
1637 }
1638
1639 debug!(?jobs_in_progress, "Event loop waiting for new job result");
1645 let result = result_rx.recv();
1646 debug!(?result, "Event loop got new job result");
1647 result
1648 };
1649 let (job, test_output) = match message {
1650 Err(RecvError) => {
1651 debug!("No more job results could be received because result_rx closed");
1652 break;
1653 }
1654
1655 Ok(JobResult::Error(worker_id, job, error_message)) => {
1656 let TestJob {
1657 commit_oid,
1658 operation_type: _,
1659 } = job;
1660 eyre::bail!(
1661 "Worker {worker_id} failed when processing commit {commit_oid}: {error_message}"
1662 );
1663 }
1664
1665 Ok(JobResult::Done(job, test_output)) => (job, test_output),
1666 };
1667
1668 let TestJob {
1669 commit_oid,
1670 operation_type: _,
1671 } = job;
1672 let (maybe_testing_aborted_error, search_status) = match &test_output.test_status {
1673 TestStatus::CheckoutFailed
1674 | TestStatus::SpawnTestFailed(_)
1675 | TestStatus::TerminatedBySignal
1676 | TestStatus::AlreadyInProgress
1677 | TestStatus::ReadCacheFailed(_)
1678 | TestStatus::Indeterminate { .. } => (None, search::Status::Indeterminate),
1679
1680 TestStatus::Abort { exit_code } => (
1681 Some(TestingAbortedError {
1682 commit_oid,
1683 exit_code: *exit_code,
1684 }),
1685 search::Status::Indeterminate,
1686 ),
1687
1688 TestStatus::Failed {
1689 cached: _,
1690 interactive: _,
1691 exit_code: _,
1692 } => (None, search::Status::Failure),
1693
1694 TestStatus::Passed {
1695 cached: _,
1696 fix_info: _,
1697 interactive: _,
1698 } => (None, search::Status::Success),
1699 };
1700 if search_strategy.is_some() {
1701 search.notify(commit_oid, search_status)?;
1702 }
1703 if scheduled_jobs
1704 .insert(commit_oid, ScheduledJob::Complete(test_output))
1705 .is_none()
1706 {
1707 warn!(
1708 ?commit_oid,
1709 "Received test result for commit that was not scheduled"
1710 );
1711 }
1712
1713 if let Some(err) = maybe_testing_aborted_error {
1714 testing_aborted_error = Some(err);
1715 }
1716 }
1717
1718 let test_outputs = scheduled_jobs
1719 .into_iter()
1720 .filter_map(|(commit_oid, scheduled_job)| match scheduled_job {
1721 ScheduledJob::Scheduled(_) => None,
1722 ScheduledJob::Complete(test_output) => Some((commit_oid, test_output)),
1723 })
1724 .collect();
1725 Ok(EventLoopOutput {
1726 search,
1727 test_outputs,
1728 testing_aborted_error,
1729 })
1730}
1731
1732#[instrument]
1733fn print_summary(
1734 effects: &Effects,
1735 dag: &Dag,
1736 repo: &Repo,
1737 revset: &Revset,
1738 command: &TestCommand,
1739 test_results: &TestResults,
1740 is_search: bool,
1741 apply_fixes: bool,
1742 verbosity: &Verbosity,
1743) -> EyreExitOr<()> {
1744 let mut num_passed = 0;
1745 let mut num_failed = 0;
1746 let mut num_skipped = 0;
1747 let mut num_cached_results = 0;
1748 for (commit_oid, test_output) in &test_results.test_outputs {
1749 let commit = repo.find_commit_or_fail(*commit_oid)?;
1750 write!(
1751 effects.get_output_stream(),
1752 "{}",
1753 effects.get_glyphs().render(test_output.describe(
1754 effects,
1755 &commit,
1756 apply_fixes,
1757 *verbosity,
1758 )?)?
1759 )?;
1760 match test_output.test_status {
1761 TestStatus::CheckoutFailed
1762 | TestStatus::SpawnTestFailed(_)
1763 | TestStatus::AlreadyInProgress
1764 | TestStatus::ReadCacheFailed(_)
1765 | TestStatus::TerminatedBySignal
1766 | TestStatus::Indeterminate { .. } => num_skipped += 1,
1767
1768 TestStatus::Abort { .. } => {
1769 num_failed += 1;
1770 }
1771 TestStatus::Failed {
1772 cached,
1773 exit_code: _,
1774 interactive: _,
1775 } => {
1776 num_failed += 1;
1777 if cached {
1778 num_cached_results += 1;
1779 }
1780 }
1781 TestStatus::Passed {
1782 cached,
1783 fix_info: _,
1784 interactive: _,
1785 } => {
1786 num_passed += 1;
1787 if cached {
1788 num_cached_results += 1;
1789 }
1790 }
1791 }
1792 }
1793
1794 writeln!(
1795 effects.get_output_stream(),
1796 "Ran command on {}: {}",
1797 Pluralize {
1798 determiner: None,
1799 amount: test_results.test_outputs.len(),
1800 unit: ("commit", "commits")
1801 },
1802 effects.get_glyphs().render(
1803 StyledStringBuilder::new()
1804 .append_styled(command.to_string(), Effect::Bold)
1805 .build()
1806 )?,
1807 )?;
1808
1809 let passed = effects.get_glyphs().render(
1810 StyledStringBuilder::new()
1811 .append_styled(format!("{num_passed} passed"), *STYLE_SUCCESS)
1812 .build(),
1813 )?;
1814 let failed = effects.get_glyphs().render(
1815 StyledStringBuilder::new()
1816 .append_styled(format!("{num_failed} failed"), *STYLE_FAILURE)
1817 .build(),
1818 )?;
1819 let skipped = effects.get_glyphs().render(
1820 StyledStringBuilder::new()
1821 .append_styled(format!("{num_skipped} skipped"), *STYLE_SKIPPED)
1822 .build(),
1823 )?;
1824 writeln!(effects.get_output_stream(), "{passed}, {failed}, {skipped}")?;
1825
1826 if is_search {
1827 let success_commits: CommitSet =
1828 test_results.search_bounds.success.iter().copied().collect();
1829 let success_commits = sorted_commit_set(repo, dag, &success_commits)?;
1830 if success_commits.is_empty() {
1831 writeln!(
1832 effects.get_output_stream(),
1833 "There were no passing commits in the provided set."
1834 )?;
1835 } else {
1836 writeln!(
1837 effects.get_output_stream(),
1838 "Last passing {commits}:",
1839 commits = if success_commits.len() == 1 {
1840 "commit"
1841 } else {
1842 "commits"
1843 },
1844 )?;
1845 for commit in success_commits {
1846 writeln!(
1847 effects.get_output_stream(),
1848 "{} {}",
1849 effects.get_glyphs().bullet_point,
1850 effects
1851 .get_glyphs()
1852 .render(commit.friendly_describe(effects.get_glyphs())?)?
1853 )?;
1854 }
1855 }
1856
1857 let failure_commits: CommitSet =
1858 test_results.search_bounds.failure.iter().copied().collect();
1859 let failure_commits = sorted_commit_set(repo, dag, &failure_commits)?;
1860 if failure_commits.is_empty() {
1861 writeln!(
1862 effects.get_output_stream(),
1863 "There were no failing commits in the provided set."
1864 )?;
1865 } else {
1866 writeln!(
1867 effects.get_output_stream(),
1868 "First failing {commits}:",
1869 commits = if failure_commits.len() == 1 {
1870 "commit"
1871 } else {
1872 "commits"
1873 },
1874 )?;
1875 for commit in failure_commits {
1876 writeln!(
1877 effects.get_output_stream(),
1878 "{} {}",
1879 effects.get_glyphs().bullet_point,
1880 effects
1881 .get_glyphs()
1882 .render(commit.friendly_describe(effects.get_glyphs())?)?
1883 )?;
1884 }
1885 }
1886 }
1887
1888 if num_cached_results > 0 && get_hint_enabled(repo, Hint::CleanCachedTestResults)? {
1889 writeln!(
1890 effects.get_output_stream(),
1891 "{}: there {}",
1892 effects.get_glyphs().render(get_hint_string())?,
1893 Pluralize {
1894 determiner: Some(("was", "were")),
1895 amount: num_cached_results,
1896 unit: ("cached test result", "cached test results")
1897 }
1898 )?;
1899 writeln!(
1900 effects.get_output_stream(),
1901 "{}: to clear these cached results, run: git test clean {}",
1902 effects.get_glyphs().render(get_hint_string())?,
1903 shell_escape(revset.to_string()),
1904 )?;
1905 print_hint_suppression_notice(effects, Hint::CleanCachedTestResults)?;
1906 }
1907
1908 if let Some(testing_aborted_error) = &test_results.testing_aborted_error {
1909 let TestingAbortedError {
1910 commit_oid,
1911 exit_code,
1912 } = testing_aborted_error;
1913 let commit = repo.find_commit_or_fail(*commit_oid)?;
1914 writeln!(
1915 effects.get_output_stream(),
1916 "Aborted running commands with exit code {} at commit: {}",
1917 exit_code,
1918 effects
1919 .get_glyphs()
1920 .render(commit.friendly_describe(effects.get_glyphs())?)?
1921 )?;
1922 return Ok(Err(ExitCode(1)));
1923 }
1924
1925 if is_search {
1926 Ok(Ok(()))
1927 } else if num_failed > 0 || num_skipped > 0 {
1928 Ok(Err(ExitCode(1)))
1929 } else {
1930 Ok(Ok(()))
1931 }
1932}
1933
1934#[instrument(skip(permissions))]
1935fn apply_fixes(
1936 effects: &Effects,
1937 git_run_info: &GitRunInfo,
1938 dag: &mut Dag,
1939 repo: &Repo,
1940 event_log_db: &EventLogDb,
1941 execute_options: &ExecuteRebasePlanOptions,
1942 permissions: RebasePlanPermissions,
1943 dry_run: bool,
1944 command: &TestCommand,
1945 test_results: &TestResults,
1946) -> EyreExitOr<()> {
1947 let fixed_tree_oids: Vec<(NonZeroOid, NonZeroOid)> = test_results
1948 .test_outputs
1949 .iter()
1950 .filter_map(|(commit_oid, test_output)| match test_output.test_status {
1951 TestStatus::Passed {
1952 cached: _,
1953 fix_info:
1954 FixInfo {
1955 head_commit_oid: _,
1956 snapshot_tree_oid: Some(snapshot_tree_oid),
1957 },
1958 interactive: _,
1959 } => Some((*commit_oid, snapshot_tree_oid)),
1960
1961 TestStatus::Passed {
1962 cached: _,
1963 fix_info:
1964 FixInfo {
1965 head_commit_oid: _,
1966 snapshot_tree_oid: None,
1967 },
1968 interactive: _,
1969 }
1970 | TestStatus::CheckoutFailed
1971 | TestStatus::SpawnTestFailed(_)
1972 | TestStatus::TerminatedBySignal
1973 | TestStatus::AlreadyInProgress
1974 | TestStatus::ReadCacheFailed(_)
1975 | TestStatus::Indeterminate { .. }
1976 | TestStatus::Failed { .. }
1977 | TestStatus::Abort { .. } => None,
1978 })
1979 .collect();
1980
1981 #[derive(Debug)]
1982 struct Fix {
1983 original_commit_oid: NonZeroOid,
1984 original_commit_parent_oids: Vec<NonZeroOid>,
1985 fixed_commit_oid: NonZeroOid,
1986 }
1987 let fixes: Vec<Fix> = {
1988 let mut fixes = Vec::new();
1989 for (original_commit_oid, fixed_tree_oid) in fixed_tree_oids {
1990 let original_commit = repo.find_commit_or_fail(original_commit_oid)?;
1991 let original_tree_oid = original_commit.get_tree_oid();
1992 let commit_message = original_commit.get_message_raw();
1993 let commit_message = commit_message.to_str().with_context(|| {
1994 eyre::eyre!(
1995 "Could not decode commit message for commit: {:?}",
1996 original_commit_oid
1997 )
1998 })?;
1999 let parents: Vec<Commit> = original_commit
2000 .get_parent_oids()
2001 .into_iter()
2002 .map(|parent_oid| repo.find_commit_or_fail(parent_oid))
2003 .try_collect()?;
2004 let fixed_tree = repo.find_tree_or_fail(fixed_tree_oid)?;
2005 let fixed_commit_oid = repo.create_commit(
2006 None,
2007 &original_commit.get_author(),
2008 &original_commit.get_committer(),
2009 commit_message,
2010 &fixed_tree,
2011 parents.iter().collect(),
2012 )?;
2013 if original_commit_oid == fixed_commit_oid {
2014 continue;
2015 }
2016
2017 let fix = Fix {
2018 original_commit_oid,
2019 original_commit_parent_oids: original_commit.get_parent_oids(),
2020 fixed_commit_oid,
2021 };
2022 debug!(
2023 ?fix,
2024 ?original_tree_oid,
2025 ?fixed_tree_oid,
2026 "Generated fix to apply"
2027 );
2028 fixes.push(fix);
2029 }
2030 fixes
2031 };
2032
2033 dag.sync_from_oids(
2034 effects,
2035 repo,
2036 CommitSet::empty(),
2037 fixes
2038 .iter()
2039 .map(|fix| {
2040 let Fix {
2041 original_commit_oid: _,
2042 original_commit_parent_oids: _,
2043 fixed_commit_oid,
2044 } = fix;
2045 fixed_commit_oid
2046 })
2047 .copied()
2048 .collect(),
2049 )?;
2050
2051 let rebase_plan = {
2052 let mut builder = RebasePlanBuilder::new(dag, permissions);
2053 for fix in &fixes {
2054 let Fix {
2055 original_commit_oid,
2056 original_commit_parent_oids,
2057 fixed_commit_oid,
2058 } = fix;
2059 builder.replace_commit(*original_commit_oid, *fixed_commit_oid)?;
2060 builder.move_subtree(*original_commit_oid, original_commit_parent_oids.clone())?;
2061 }
2062
2063 let original_oids: CommitSet = fixes
2064 .iter()
2065 .map(|fix| {
2066 let Fix {
2067 original_commit_oid,
2068 original_commit_parent_oids: _,
2069 fixed_commit_oid: _,
2070 } = fix;
2071 original_commit_oid
2072 })
2073 .copied()
2074 .collect();
2075 let descendant_oids = dag.query_descendants(original_oids.clone())?;
2076 let descendant_oids = dag
2077 .filter_visible_commits(descendant_oids)?
2078 .difference(&original_oids);
2079 for descendant_oid in dag.commit_set_to_vec(&descendant_oids)? {
2080 let descendant_commit = repo.find_commit_or_fail(descendant_oid)?;
2081 builder.replace_commit(descendant_oid, descendant_oid)?;
2082 builder.move_subtree(descendant_oid, descendant_commit.get_parent_oids())?;
2083 }
2084
2085 let thread_pool = ThreadPoolBuilder::new().build()?;
2086 let repo_pool = RepoResource::new_pool(repo)?;
2087 builder.build(effects, &thread_pool, &repo_pool)?
2088 };
2089
2090 let rebase_plan = match rebase_plan {
2091 Ok(Some(plan)) => plan,
2092 Ok(None) => {
2093 writeln!(effects.get_output_stream(), "No commits to fix.")?;
2094 return Ok(Ok(()));
2095 }
2096 Err(err) => {
2097 err.describe(effects, repo, dag)?;
2098 return Ok(Err(ExitCode(1)));
2099 }
2100 };
2101
2102 let rewritten_oids = if dry_run {
2103 Default::default()
2104 } else {
2105 match execute_rebase_plan(
2106 effects,
2107 git_run_info,
2108 repo,
2109 event_log_db,
2110 &rebase_plan,
2111 execute_options,
2112 )? {
2113 ExecuteRebasePlanResult::Succeeded { rewritten_oids } => rewritten_oids,
2114 ExecuteRebasePlanResult::WouldSucceed => return Ok(Ok(())),
2115 ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info } => {
2116 writeln!(
2117 effects.get_output_stream(),
2118 "BUG: encountered merge conflicts during git test fix, but we should not be applying any patches: {failed_merge_info:?}"
2119 )?;
2120 return Ok(Err(ExitCode(1)));
2121 }
2122 ExecuteRebasePlanResult::Failed { exit_code } => return Ok(Err(exit_code)),
2123 }
2124 };
2125 let rewritten_oids = match rewritten_oids {
2126 Some(rewritten_oids) => rewritten_oids,
2127
2128 None => fixes
2133 .iter()
2134 .map(|fix| {
2135 let Fix {
2136 original_commit_oid,
2137 original_commit_parent_oids: _,
2138 fixed_commit_oid,
2139 } = fix;
2140 (
2141 *original_commit_oid,
2142 MaybeZeroOid::NonZero(*fixed_commit_oid),
2143 )
2144 })
2145 .collect(),
2146 };
2147
2148 writeln!(
2149 effects.get_output_stream(),
2150 "Fixed {} with {}:",
2151 Pluralize {
2152 determiner: None,
2153 amount: fixes.len(),
2154 unit: ("commit", "commits")
2155 },
2156 effects.get_glyphs().render(
2157 StyledStringBuilder::new()
2158 .append_styled(command.to_string(), Effect::Bold)
2159 .build()
2160 )?,
2161 )?;
2162 for fix in fixes {
2163 let Fix {
2164 original_commit_oid,
2165 original_commit_parent_oids: _,
2166 fixed_commit_oid,
2167 } = fix;
2168 let original_commit = repo.find_commit_or_fail(original_commit_oid)?;
2169 let fixed_commit_oid = rewritten_oids
2170 .get(&original_commit_oid)
2171 .copied()
2172 .unwrap_or(MaybeZeroOid::NonZero(fixed_commit_oid));
2173 match fixed_commit_oid {
2174 MaybeZeroOid::NonZero(fixed_commit_oid) => {
2175 let fixed_commit = repo.find_commit_or_fail(fixed_commit_oid)?;
2176 writeln!(
2177 effects.get_output_stream(),
2178 "{} -> {}",
2179 effects
2180 .get_glyphs()
2181 .render(original_commit.friendly_describe_oid(effects.get_glyphs())?)?,
2182 effects
2183 .get_glyphs()
2184 .render(fixed_commit.friendly_describe(effects.get_glyphs())?)?
2185 )?;
2186 }
2187
2188 MaybeZeroOid::Zero => {
2189 writeln!(
2191 effects.get_output_stream(),
2192 "(deleted) {}",
2193 effects
2194 .get_glyphs()
2195 .render(original_commit.friendly_describe_oid(effects.get_glyphs())?)?,
2196 )?;
2197 }
2198 }
2199 }
2200
2201 if dry_run {
2202 writeln!(
2203 effects.get_output_stream(),
2204 "(This was a dry-run, so no commits were rewritten. Re-run without the --dry-run option to apply fixes.)"
2205 )?;
2206 }
2207
2208 Ok(Ok(()))
2209}
2210
2211#[instrument]
2212fn run_test(
2213 effects: &Effects,
2214 operation_type: OperationType,
2215 git_run_info: &GitRunInfo,
2216 shell_path: &Path,
2217 repo: &Repo,
2218 event_tx_id: EventTransactionId,
2219 options: &ResolvedTestOptions,
2220 worker_id: WorkerId,
2221 commit: &Commit,
2222) -> eyre::Result<TestOutput> {
2223 let ResolvedTestOptions {
2224 command: _, execution_strategy,
2226 search_strategy: _, use_cache: _, is_dry_run: _, is_interactive: _, num_jobs: _, verbosity: _,
2232 fix_options,
2233 } = options;
2234 let (effects, progress) = effects.start_operation(operation_type);
2235 progress.notify_status(
2236 OperationIcon::InProgress,
2237 format!(
2238 "Preparing {}",
2239 effects
2240 .get_glyphs()
2241 .render(commit.friendly_describe(effects.get_glyphs())?)?
2242 ),
2243 );
2244
2245 let test_output = match make_test_files(repo, commit, options)? {
2246 TestFilesResult::Cached(test_output) => test_output,
2247 TestFilesResult::NotCached(test_files) => {
2248 match prepare_working_directory(
2249 git_run_info,
2250 repo,
2251 event_tx_id,
2252 commit,
2253 *execution_strategy,
2254 worker_id,
2255 )? {
2256 Err(err) => {
2257 info!(?err, "Failed to prepare working directory for testing");
2258 let TestFiles {
2259 temp_dir,
2260 lock_file: _, result_path,
2262 result_file: _,
2263 stdout_path,
2264 stdout_file: _,
2265 stderr_path,
2266 stderr_file: _,
2267 } = test_files;
2268 TestOutput {
2269 temp_dir,
2270 result_path,
2271 stdout_path,
2272 stderr_path,
2273 test_status: TestStatus::CheckoutFailed,
2274 }
2275 }
2276 Ok(PreparedWorkingDirectory {
2277 lock_file: mut working_directory_lock_file,
2278 path,
2279 }) => {
2280 progress.notify_status(
2281 OperationIcon::InProgress,
2282 format!(
2283 "Running on {}",
2284 effects
2285 .get_glyphs()
2286 .render(commit.friendly_describe(effects.get_glyphs())?)?
2287 ),
2288 );
2289
2290 let result = test_commit(
2291 &effects,
2292 git_run_info,
2293 repo,
2294 event_tx_id,
2295 test_files,
2296 &path,
2297 shell_path,
2298 options,
2299 commit,
2300 )?;
2301 working_directory_lock_file
2302 .unlock()
2303 .wrap_err_with(|| format!("Unlocking working directory at {path:?}"))?;
2304 drop(working_directory_lock_file);
2305 result
2306 }
2307 }
2308 }
2309 };
2310
2311 let description = StyledStringBuilder::new()
2312 .append(test_output.test_status.describe(
2313 effects.get_glyphs(),
2314 commit,
2315 fix_options.is_some(),
2316 )?)
2317 .build();
2318 progress.notify_status(
2319 match test_output.test_status {
2320 TestStatus::CheckoutFailed
2321 | TestStatus::SpawnTestFailed(_)
2322 | TestStatus::AlreadyInProgress
2323 | TestStatus::ReadCacheFailed(_)
2324 | TestStatus::Indeterminate { .. } => OperationIcon::Warning,
2325
2326 TestStatus::TerminatedBySignal
2327 | TestStatus::Failed { .. }
2328 | TestStatus::Abort { .. } => OperationIcon::Failure,
2329
2330 TestStatus::Passed { .. } => OperationIcon::Success,
2331 },
2332 effects.get_glyphs().render(description)?,
2333 );
2334 Ok(test_output)
2335}
2336
2337#[derive(Debug)]
2338struct TestFiles {
2339 temp_dir: Option<TempDir>,
2340 lock_file: LockFile,
2341 result_path: PathBuf,
2342 result_file: File,
2343 stdout_path: PathBuf,
2344 stdout_file: File,
2345 stderr_path: PathBuf,
2346 stderr_file: File,
2347}
2348
2349#[derive(Debug)]
2350enum TestFilesResult {
2351 Cached(TestOutput),
2352 NotCached(TestFiles),
2353}
2354
2355#[instrument]
2356fn make_test_files(
2357 repo: &Repo,
2358 commit: &Commit,
2359 options: &ResolvedTestOptions,
2360) -> eyre::Result<TestFilesResult> {
2361 if !options.use_cache {
2362 let temp_dir = tempfile::tempdir().context("Creating temporary directory")?;
2363 let lock_path = temp_dir.path().join("pid.lock");
2364 let mut lock_file = LockFile::open(&lock_path)
2365 .wrap_err_with(|| format!("Opening lock file {lock_path:?}"))?;
2366 if !lock_file.try_lock_with_pid()? {
2367 warn!(
2368 ?temp_dir,
2369 ?lock_file,
2370 "Could not acquire lock despite being in a temporary directory"
2371 );
2372 }
2373
2374 let result_path = temp_dir.path().join("result");
2375 let stdout_path = temp_dir.path().join("stdout");
2376 let stderr_path = temp_dir.path().join("stderr");
2377 let result_file = File::create(&result_path)
2378 .wrap_err_with(|| format!("Opening result file {result_path:?}"))?;
2379 let stdout_file = File::create(&stdout_path)
2380 .wrap_err_with(|| format!("Opening stdout file {stdout_path:?}"))?;
2381 let stderr_file = File::create(&stderr_path)
2382 .wrap_err_with(|| format!("Opening stderr file {stderr_path:?}"))?;
2383 return Ok(TestFilesResult::NotCached(TestFiles {
2384 temp_dir: Some(temp_dir),
2385 lock_file,
2386 result_path,
2387 result_file,
2388 stdout_path,
2389 stdout_file,
2390 stderr_path,
2391 stderr_file,
2392 }));
2393 }
2394
2395 let tree_dir = get_test_tree_dir(repo, commit)?;
2396 std::fs::create_dir_all(&tree_dir)
2397 .wrap_err_with(|| format!("Creating tree directory {tree_dir:?}"))?;
2398
2399 let command_dir = tree_dir.join(options.make_command_slug());
2400 std::fs::create_dir_all(&command_dir)
2401 .wrap_err_with(|| format!("Creating command directory {command_dir:?}"))?;
2402
2403 let result_path = command_dir.join("result");
2404 let stdout_path = command_dir.join("stdout");
2405 let stderr_path = command_dir.join("stderr");
2406 let lock_path = command_dir.join("pid.lock");
2407
2408 let mut lock_file =
2409 LockFile::open(&lock_path).wrap_err_with(|| format!("Opening lock file {lock_path:?}"))?;
2410 if !lock_file
2411 .try_lock_with_pid()
2412 .wrap_err_with(|| format!("Locking file {lock_path:?}"))?
2413 {
2414 return Ok(TestFilesResult::Cached(TestOutput {
2415 temp_dir: None,
2416 result_path,
2417 stdout_path,
2418 stderr_path,
2419 test_status: TestStatus::AlreadyInProgress,
2420 }));
2421 }
2422
2423 if let Ok(contents) = std::fs::read_to_string(&result_path) {
2424 if !contents.is_empty() {
2430 let serialized_result: Result<SerializedTestResult, _> =
2431 serde_json::from_str(&contents);
2432 let test_status = match serialized_result {
2433 Ok(SerializedTestResult {
2434 command: _,
2435 exit_code: 0,
2436 head_commit_oid,
2437 snapshot_tree_oid,
2438 interactive,
2439 }) => TestStatus::Passed {
2440 cached: true,
2441 fix_info: FixInfo {
2442 head_commit_oid: head_commit_oid.map(|SerializedNonZeroOid(oid)| oid),
2443 snapshot_tree_oid: snapshot_tree_oid.map(|SerializedNonZeroOid(oid)| oid),
2444 },
2445
2446 interactive,
2447 },
2448
2449 Ok(SerializedTestResult {
2450 command: _,
2451 exit_code,
2452 head_commit_oid: _,
2453 snapshot_tree_oid: _,
2454 interactive: _,
2455 }) if exit_code == TEST_INDETERMINATE_EXIT_CODE => {
2456 TestStatus::Indeterminate { exit_code }
2457 }
2458
2459 Ok(SerializedTestResult {
2460 command: _,
2461 exit_code,
2462 head_commit_oid: _,
2463 snapshot_tree_oid: _,
2464 interactive: _,
2465 }) if exit_code == TEST_ABORT_EXIT_CODE => TestStatus::Abort { exit_code },
2466
2467 Ok(SerializedTestResult {
2468 command: _,
2469 exit_code,
2470 head_commit_oid: _,
2471 snapshot_tree_oid: _,
2472 interactive,
2473 }) => TestStatus::Failed {
2474 cached: true,
2475 exit_code,
2476 interactive,
2477 },
2478 Err(err) => TestStatus::ReadCacheFailed(err.to_string()),
2479 };
2480 return Ok(TestFilesResult::Cached(TestOutput {
2481 temp_dir: None,
2482 result_path,
2483 stdout_path,
2484 stderr_path,
2485 test_status,
2486 }));
2487 }
2488 }
2489
2490 let result_file = File::create(&result_path)
2491 .wrap_err_with(|| format!("Opening result file {result_path:?}"))?;
2492 let stdout_file = File::create(&stdout_path)
2493 .wrap_err_with(|| format!("Opening stdout file {stdout_path:?}"))?;
2494 let stderr_file = File::create(&stderr_path)
2495 .wrap_err_with(|| format!("Opening stderr file {stderr_path:?}"))?;
2496 Ok(TestFilesResult::NotCached(TestFiles {
2497 temp_dir: None,
2498 lock_file,
2499 result_path,
2500 result_file,
2501 stdout_path,
2502 stdout_file,
2503 stderr_path,
2504 stderr_file,
2505 }))
2506}
2507
2508#[derive(Debug)]
2509struct PreparedWorkingDirectory {
2510 lock_file: LockFile,
2511 path: PathBuf,
2512}
2513
2514#[allow(dead_code)] #[derive(Debug)]
2516enum PrepareWorkingDirectoryError {
2517 LockFailed(PathBuf),
2518 NoWorkingCopy,
2519 CheckoutFailed(NonZeroOid),
2520 CreateWorktreeFailed(PathBuf),
2521}
2522
2523#[instrument]
2524fn prepare_working_directory(
2525 git_run_info: &GitRunInfo,
2526 repo: &Repo,
2527 event_tx_id: EventTransactionId,
2528 commit: &Commit,
2529 strategy: TestExecutionStrategy,
2530 worker_id: WorkerId,
2531) -> eyre::Result<Result<PreparedWorkingDirectory, PrepareWorkingDirectoryError>> {
2532 let test_lock_dir_path = get_test_locks_dir(repo)?;
2533 std::fs::create_dir_all(&test_lock_dir_path)
2534 .wrap_err_with(|| format!("Creating test lock dir path: {test_lock_dir_path:?}"))?;
2535
2536 let lock_file_name = match strategy {
2537 TestExecutionStrategy::WorkingCopy => "working-copy.lock".to_string(),
2538 TestExecutionStrategy::Worktree => {
2539 format!("worktree-{worker_id}.lock")
2540 }
2541 };
2542 let lock_path = test_lock_dir_path.join(lock_file_name);
2543 let mut lock_file = LockFile::open(&lock_path)
2544 .wrap_err_with(|| format!("Opening working copy lock at {lock_path:?}"))?;
2545 if !lock_file
2546 .try_lock_with_pid()
2547 .wrap_err_with(|| format!("Locking working copy with {lock_path:?}"))?
2548 {
2549 return Ok(Err(PrepareWorkingDirectoryError::LockFailed(lock_path)));
2550 }
2551
2552 match strategy {
2553 TestExecutionStrategy::WorkingCopy => {
2554 let working_copy_path = match repo.get_working_copy_path() {
2555 None => return Ok(Err(PrepareWorkingDirectoryError::NoWorkingCopy)),
2556 Some(working_copy_path) => working_copy_path.to_owned(),
2557 };
2558
2559 let GitRunResult { exit_code, stdout: _, stderr: _ } =
2560 git_run_info.run_silent(
2563 repo,
2564 Some(event_tx_id),
2565 &["reset", "--hard", &commit.get_oid().to_string()],
2566 Default::default()
2567 ).context("Checking out commit to prepare working directory")?;
2568 if exit_code.is_success() {
2569 Ok(Ok(PreparedWorkingDirectory {
2570 lock_file,
2571 path: working_copy_path,
2572 }))
2573 } else {
2574 Ok(Err(PrepareWorkingDirectoryError::CheckoutFailed(
2575 commit.get_oid(),
2576 )))
2577 }
2578 }
2579
2580 TestExecutionStrategy::Worktree => {
2581 let parent_dir = get_test_worktrees_dir(repo)?;
2582 std::fs::create_dir_all(&parent_dir)
2583 .wrap_err_with(|| format!("Creating worktree parent dir at {parent_dir:?}"))?;
2584
2585 let worktree_dir_name = format!("testing-worktree-{worker_id}");
2586 let worktree_dir = parent_dir.join(worktree_dir_name);
2587 let worktree_dir_str = match worktree_dir.to_str() {
2588 Some(worktree_dir) => worktree_dir,
2589 None => {
2590 return Ok(Err(PrepareWorkingDirectoryError::CreateWorktreeFailed(
2591 worktree_dir,
2592 )));
2593 }
2594 };
2595
2596 if !worktree_dir.exists() {
2597 let GitRunResult {
2598 exit_code,
2599 stdout: _,
2600 stderr: _,
2601 } = git_run_info.run_silent(
2602 repo,
2603 Some(event_tx_id),
2604 &["worktree", "add", worktree_dir_str, "--force", "--detach"],
2605 Default::default(),
2606 )?;
2607 if !exit_code.is_success() {
2608 return Ok(Err(PrepareWorkingDirectoryError::CreateWorktreeFailed(
2609 worktree_dir,
2610 )));
2611 }
2612 }
2613
2614 let GitRunResult {
2615 exit_code,
2616 stdout: _,
2617 stderr: _,
2618 } = git_run_info.run_silent(
2619 repo,
2620 Some(event_tx_id),
2621 &[
2622 "-C",
2623 worktree_dir_str,
2624 "checkout",
2625 "--force",
2626 &commit.get_oid().to_string(),
2627 ],
2628 Default::default(),
2629 )?;
2630 if !exit_code.is_success() {
2631 return Ok(Err(PrepareWorkingDirectoryError::CheckoutFailed(
2632 commit.get_oid(),
2633 )));
2634 }
2635 Ok(Ok(PreparedWorkingDirectory {
2636 lock_file,
2637 path: worktree_dir,
2638 }))
2639 }
2640 }
2641}
2642
2643#[instrument]
2644fn test_commit(
2645 effects: &Effects,
2646 git_run_info: &GitRunInfo,
2647 repo: &Repo,
2648 event_tx_id: EventTransactionId,
2649 test_files: TestFiles,
2650 working_directory: &Path,
2651 shell_path: &Path,
2652 options: &ResolvedTestOptions,
2653 commit: &Commit,
2654) -> eyre::Result<TestOutput> {
2655 let TestFiles {
2656 temp_dir,
2657 lock_file: _lock_file, result_path,
2659 result_file,
2660 stdout_path,
2661 stdout_file,
2662 stderr_path,
2663 stderr_file,
2664 } = test_files;
2665
2666 let mut command = Command::new(shell_path);
2667 command
2668 .arg("-c")
2669 .arg(options.command.to_string())
2670 .current_dir(working_directory)
2671 .env(BRANCHLESS_TRANSACTION_ID_ENV_VAR, event_tx_id.to_string())
2672 .env("BRANCHLESS_TEST_COMMIT", commit.get_oid().to_string())
2673 .env("BRANCHLESS_TEST_COMMAND", options.command.to_string());
2674
2675 if options.is_interactive {
2676 let commit_desc = effects
2677 .get_glyphs()
2678 .render(commit.friendly_describe(effects.get_glyphs())?)?;
2679 let passed = "passed";
2680 let exit0 = effects
2681 .get_glyphs()
2682 .render(StyledString::styled("exit 0", *STYLE_SUCCESS))?;
2683 let failed = "failed";
2684 let exit1 = effects
2685 .get_glyphs()
2686 .render(StyledString::styled("exit 1", *STYLE_FAILURE))?;
2687 let skipped = "skipped";
2688 let exit125 = effects
2689 .get_glyphs()
2690 .render(StyledString::styled("exit 125", *STYLE_SKIPPED))?;
2691 let exit127 = effects
2692 .get_glyphs()
2693 .render(StyledString::styled("exit 127", *STYLE_FAILURE))?;
2694
2695 println!(
2699 "\
2700You are now at: {commit_desc}
2701To mark this commit as {passed},run: {exit0}
2702To mark this commit as {failed}, run: {exit1}
2703To mark this commit as {skipped}, run: {exit125}
2704To abort testing entirely, run: {exit127}",
2705 );
2706 match options.execution_strategy {
2707 TestExecutionStrategy::WorkingCopy => {}
2708 TestExecutionStrategy::Worktree => {
2709 let warning = effects
2710 .get_glyphs()
2711 .render(StyledString::styled(
2712 "Warning: You are in a worktree. Your changes will not be propagated between the worktree and the main repository.",
2713 *STYLE_SKIPPED
2714 ))?;
2715 println!("{warning}");
2716 println!("To save your changes, create a new branch or note the commit hash.");
2717 println!(
2718 "To incorporate the changes from the main repository, switch to the main repository's current commit or branch."
2719 );
2720 }
2721 }
2722 } else {
2723 command
2724 .stdin(Stdio::null())
2725 .stdout(stdout_file)
2726 .stderr(stderr_file);
2727 }
2728
2729 let exit_code = match command.status() {
2730 Ok(status) => status.code(),
2731 Err(err) => {
2732 return Ok(TestOutput {
2733 temp_dir,
2734 result_path,
2735 stdout_path,
2736 stderr_path,
2737 test_status: TestStatus::SpawnTestFailed(err.to_string()),
2738 });
2739 }
2740 };
2741 let exit_code = match exit_code {
2742 Some(exit_code) => exit_code,
2743 None => {
2744 return Ok(TestOutput {
2745 temp_dir,
2746 result_path,
2747 stdout_path,
2748 stderr_path,
2749 test_status: TestStatus::TerminatedBySignal,
2750 });
2751 }
2752 };
2753 let test_status = match exit_code {
2754 TEST_SUCCESS_EXIT_CODE => {
2755 let fix_info = {
2756 let repo = Repo::from_dir(working_directory)?;
2757 let (head_commit_oid, snapshot) = {
2758 let index = repo.get_index()?;
2759 let head_info = repo.get_head_info()?;
2760 let (snapshot, _status) = repo.get_status(
2761 &effects.suppress(),
2762 git_run_info,
2763 &index,
2764 &head_info,
2765 Some(event_tx_id),
2766 )?;
2767 (head_info.oid, snapshot)
2768 };
2769 let snapshot_tree_oid = match snapshot.get_working_copy_changes_type()? {
2770 WorkingCopyChangesType::None | WorkingCopyChangesType::Unstaged => {
2771 let fixed_tree_oid: MaybeZeroOid = snapshot.commit_unstaged.get_tree_oid();
2772 fixed_tree_oid.into()
2773 }
2774 changes_type @ (WorkingCopyChangesType::Staged
2775 | WorkingCopyChangesType::Conflicts) => {
2776 warn!(
2778 ?changes_type,
2779 "There were staged changes or conflicts in the resulting working copy"
2780 );
2781 None
2782 }
2783 };
2784 FixInfo {
2785 head_commit_oid,
2786 snapshot_tree_oid,
2787 }
2788 };
2789 TestStatus::Passed {
2790 cached: false,
2791 fix_info,
2792 interactive: options.is_interactive,
2793 }
2794 }
2795
2796 exit_code @ TEST_INDETERMINATE_EXIT_CODE => TestStatus::Indeterminate { exit_code },
2797 exit_code @ TEST_ABORT_EXIT_CODE => TestStatus::Abort { exit_code },
2798
2799 exit_code => TestStatus::Failed {
2800 cached: false,
2801 exit_code,
2802 interactive: options.is_interactive,
2803 },
2804 };
2805
2806 let fix_info = match &test_status {
2807 TestStatus::Passed {
2808 cached: _,
2809 fix_info,
2810 interactive: _,
2811 } => Some(fix_info),
2812 TestStatus::CheckoutFailed
2813 | TestStatus::SpawnTestFailed(_)
2814 | TestStatus::TerminatedBySignal
2815 | TestStatus::AlreadyInProgress
2816 | TestStatus::ReadCacheFailed(_)
2817 | TestStatus::Failed { .. }
2818 | TestStatus::Abort { .. }
2819 | TestStatus::Indeterminate { .. } => None,
2820 };
2821 let serialized_test_result = SerializedTestResult {
2822 command: options.command.clone(),
2823 exit_code,
2824 head_commit_oid: fix_info
2825 .and_then(|fix_info| fix_info.head_commit_oid.map(SerializedNonZeroOid)),
2826 snapshot_tree_oid: fix_info
2827 .and_then(|fix_info| fix_info.snapshot_tree_oid.map(SerializedNonZeroOid)),
2828 interactive: options.is_interactive,
2829 };
2830 serde_json::to_writer_pretty(result_file, &serialized_test_result)
2831 .wrap_err_with(|| format!("Writing test status {test_status:?} to {result_path:?}"))?;
2832
2833 Ok(TestOutput {
2834 temp_dir,
2835 result_path,
2836 stdout_path,
2837 stderr_path,
2838 test_status,
2839 })
2840}
2841
2842#[instrument]
2845fn subcommand_show(
2846 effects: &Effects,
2847 options: &RawTestOptions,
2848 revset: Revset,
2849 resolve_revset_options: &ResolveRevsetOptions,
2850) -> EyreExitOr<()> {
2851 let now = SystemTime::now();
2852 let repo = Repo::from_current_dir()?;
2853 let conn = repo.get_db_conn()?;
2854 let event_log_db = EventLogDb::new(&conn)?;
2855 let event_tx_id = event_log_db.make_transaction_id(now, "test show")?;
2856 let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
2857 let event_cursor = event_replayer.make_default_cursor();
2858 let references_snapshot = repo.get_references_snapshot()?;
2859 let mut dag = Dag::open_and_sync(
2860 effects,
2861 &repo,
2862 &event_replayer,
2863 event_cursor,
2864 &references_snapshot,
2865 )?;
2866
2867 let commit_set =
2868 match resolve_commits(effects, &repo, &mut dag, &[revset], resolve_revset_options) {
2869 Ok(mut commit_sets) => commit_sets.pop().unwrap(),
2870 Err(err) => {
2871 err.describe(effects)?;
2872 return Ok(Err(ExitCode(1)));
2873 }
2874 };
2875
2876 let options = try_exit_code!(ResolvedTestOptions::resolve(
2877 now,
2878 effects,
2879 &dag,
2880 &repo,
2881 event_tx_id,
2882 &commit_set,
2883 None,
2884 options,
2885 )?);
2886
2887 let commits = sorted_commit_set(&repo, &dag, &commit_set)?;
2888 for commit in commits {
2889 let test_files = make_test_files(&repo, &commit, &options)?;
2890 match test_files {
2891 TestFilesResult::NotCached(_) => {
2892 writeln!(
2893 effects.get_output_stream(),
2894 "No cached test data for {}",
2895 effects
2896 .get_glyphs()
2897 .render(commit.friendly_describe(effects.get_glyphs())?)?
2898 )?;
2899 }
2900 TestFilesResult::Cached(test_output) => {
2901 write!(
2902 effects.get_output_stream(),
2903 "{}",
2904 effects.get_glyphs().render(test_output.describe(
2905 effects,
2906 &commit,
2907 false,
2908 options.verbosity
2909 )?)?,
2910 )?;
2911 }
2912 }
2913 }
2914
2915 if get_hint_enabled(&repo, Hint::TestShowVerbose)? {
2916 match options.verbosity {
2917 Verbosity::None => {
2918 writeln!(
2919 effects.get_output_stream(),
2920 "{}: to see more detailed output, re-run with -v/--verbose",
2921 effects.get_glyphs().render(get_hint_string())?,
2922 )?;
2923 print_hint_suppression_notice(effects, Hint::TestShowVerbose)?;
2924 }
2925 Verbosity::PartialOutput => {
2926 writeln!(
2927 effects.get_output_stream(),
2928 "{}: to see more detailed output, re-run with -vv/--verbose --verbose",
2929 effects.get_glyphs().render(get_hint_string())?,
2930 )?;
2931 print_hint_suppression_notice(effects, Hint::TestShowVerbose)?;
2932 }
2933 Verbosity::FullOutput => {}
2934 }
2935 }
2936
2937 Ok(Ok(()))
2938}
2939
2940#[instrument]
2942pub fn subcommand_clean(
2943 effects: &Effects,
2944 revset: Revset,
2945 resolve_revset_options: &ResolveRevsetOptions,
2946) -> EyreExitOr<()> {
2947 let repo = Repo::from_current_dir()?;
2948 let conn = repo.get_db_conn()?;
2949 let event_log_db = EventLogDb::new(&conn)?;
2950 let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
2951 let event_cursor = event_replayer.make_default_cursor();
2952 let references_snapshot = repo.get_references_snapshot()?;
2953 let mut dag = Dag::open_and_sync(
2954 effects,
2955 &repo,
2956 &event_replayer,
2957 event_cursor,
2958 &references_snapshot,
2959 )?;
2960
2961 let commit_set =
2962 match resolve_commits(effects, &repo, &mut dag, &[revset], resolve_revset_options) {
2963 Ok(mut commit_sets) => commit_sets.pop().unwrap(),
2964 Err(err) => {
2965 err.describe(effects)?;
2966 return Ok(Err(ExitCode(1)));
2967 }
2968 };
2969
2970 let mut num_cleaned_commits = 0;
2971 for commit in sorted_commit_set(&repo, &dag, &commit_set)? {
2972 let tree_dir = get_test_tree_dir(&repo, &commit)?;
2973 if tree_dir.exists() {
2974 writeln!(
2975 effects.get_output_stream(),
2976 "Cleaning results for {}",
2977 effects
2978 .get_glyphs()
2979 .render(commit.friendly_describe(effects.get_glyphs())?)?,
2980 )?;
2981 std::fs::remove_dir_all(&tree_dir)
2982 .with_context(|| format!("Cleaning test dir: {tree_dir:?}"))?;
2983 num_cleaned_commits += 1;
2984 } else {
2985 writeln!(
2986 effects.get_output_stream(),
2987 "Nothing to clean for {}",
2988 effects
2989 .get_glyphs()
2990 .render(commit.friendly_describe(effects.get_glyphs())?)?,
2991 )?;
2992 }
2993 }
2994 writeln!(
2995 effects.get_output_stream(),
2996 "Cleaned {}.",
2997 Pluralize {
2998 determiner: None,
2999 amount: num_cleaned_commits,
3000 unit: ("cached test result", "cached test results")
3001 }
3002 )?;
3003 Ok(Ok(()))
3004}
3005
3006#[cfg(test)]
3007mod tests {
3008 use lib::testing::make_git;
3009
3010 use super::*;
3011
3012 #[test]
3013 fn test_lock_prepared_working_directory() -> eyre::Result<()> {
3014 let git = make_git()?;
3015 git.init_repo()?;
3016
3017 let git_run_info = git.get_git_run_info();
3018 let repo = git.get_repo()?;
3019 let conn = repo.get_db_conn()?;
3020 let event_log_db = EventLogDb::new(&conn)?;
3021 let event_tx_id = event_log_db.make_transaction_id(SystemTime::now(), "test")?;
3022 let head_oid = repo.get_head_info()?.oid.unwrap();
3023 let head_commit = repo.find_commit_or_fail(head_oid)?;
3024 let worker_id = 1;
3025
3026 let _prepared_working_copy = prepare_working_directory(
3027 &git_run_info,
3028 &repo,
3029 event_tx_id,
3030 &head_commit,
3031 TestExecutionStrategy::WorkingCopy,
3032 worker_id,
3033 )?
3034 .unwrap();
3035 assert!(matches!(
3036 prepare_working_directory(
3037 &git_run_info,
3038 &repo,
3039 event_tx_id,
3040 &head_commit,
3041 TestExecutionStrategy::WorkingCopy,
3042 worker_id
3043 )?,
3044 Err(PrepareWorkingDirectoryError::LockFailed(_))
3045 ));
3046
3047 let _prepared_worktree = prepare_working_directory(
3048 &git_run_info,
3049 &repo,
3050 event_tx_id,
3051 &head_commit,
3052 TestExecutionStrategy::Worktree,
3053 worker_id,
3054 )?
3055 .unwrap();
3056 assert!(matches!(
3057 prepare_working_directory(
3058 &git_run_info,
3059 &repo,
3060 event_tx_id,
3061 &head_commit,
3062 TestExecutionStrategy::Worktree,
3063 worker_id
3064 )?,
3065 Err(PrepareWorkingDirectoryError::LockFailed(_))
3066 ));
3067
3068 Ok(())
3069 }
3070}