Skip to main content

git_branchless_test/
lib.rs

1//! Run a user-provided command on each of a set of provided commits. This is
2//! useful to run checks on all commits in the current stack with caching,
3//! parallelization, out-of-tree execution, etc.
4
5#![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/// How verbose of output to produce.
85#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
86pub enum Verbosity {
87    /// Do not include test output at all.
88    None,
89
90    /// Include truncated test output.
91    PartialOutput,
92
93    /// Include the full test output.
94    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/// The options for testing before they've assumed default values or been
108/// validated.
109#[derive(Debug)]
110pub struct RawTestOptions {
111    /// The command to execute, if any.
112    pub exec: Option<String>,
113
114    /// The command alias to execute, if any.
115    pub command: Option<String>,
116
117    /// Whether or not to execute as a "dry-run", i.e. don't rewrite any commits
118    /// if `true`.
119    pub dry_run: bool,
120
121    /// The execution strategy to use.
122    pub strategy: Option<TestExecutionStrategy>,
123
124    /// Search for the first commit that fails the test command, rather than
125    /// running on all commits.
126    pub search: Option<TestSearchStrategy>,
127
128    /// Shorthand for the binary search strategy.
129    pub bisect: bool,
130
131    /// Don't read or write to the cache when executing the test commands.
132    pub no_cache: bool,
133
134    /// Whether to run interactively.
135    pub interactive: bool,
136
137    /// The number of jobs to run in parallel.
138    pub jobs: Option<usize>,
139
140    /// The requested verbosity of the test output.
141    pub verbosity: Verbosity,
142
143    /// Whether to amend commits with the changes produced by the executed
144    /// command.
145    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/// The values from a `RawTestOptions` but with defaults provided. See
204/// [`RawTestOptions`] for details on these options.
205#[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    /// Resolve any missing test options with their defaults from the environment.
221    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                // NB: match on the strategy passed on the command-line here, not the resolved strategy.
345                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: _, // always implied
397            } = 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/// `test` command.
474#[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/// Run the command provided in `options` on each of the commits in `revset`.
580#[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/// Ensure that no commit operation is currently underway (such as a merge or
680/// rebase), and start a rebase.  In the event that the test invocation is
681/// interrupted, this will prevent the user from starting another commit
682/// operation without first running `git rebase --abort` to get back to their
683/// original commit.
684#[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            // Do nothing.
754        }
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/// The result of running a test.
785#[derive(Debug)]
786pub struct TestOutput {
787    /// Only used for `--no-cache` invocations, to ensure that we don't clobber
788    /// an existing cached entry.
789    pub temp_dir: Option<TempDir>,
790
791    /// The path to the file containing the serialized test result information
792    /// (such as the exit code).
793    pub result_path: PathBuf,
794
795    /// The path to the file containing the stdout of the test command.
796    pub stdout_path: PathBuf,
797
798    /// The path to the file containing the stderr of the test command.
799    pub stderr_path: PathBuf,
800
801    /// The resulting status of the test.
802    pub test_status: TestStatus,
803}
804
805/// The possible results of attempting to run a test.
806#[derive(Clone, Debug)]
807pub enum TestStatus {
808    /// Attempting to set up the working directory for the repository failed.
809    CheckoutFailed,
810
811    /// Invoking the test command failed.
812    SpawnTestFailed(String),
813
814    /// The test command was invoked successfully, but was terminated by a signal, rather than
815    /// returning an exit code normally.
816    TerminatedBySignal,
817
818    /// It appears that some other process is already running the test for a commit with the given
819    /// tree. (If that process crashed, then the test may need to be re-run.)
820    AlreadyInProgress,
821
822    /// Attempting to read cached data failed.
823    ReadCacheFailed(String),
824
825    /// The test command indicated that the commit should be skipped for testing.
826    Indeterminate {
827        /// The exit code of the command.
828        exit_code: i32,
829    },
830
831    /// The test command indicated that the process should be aborted entirely.
832    Abort {
833        /// The exit code of the command.
834        exit_code: i32,
835    },
836
837    /// The test failed and returned the provided (non-zero) exit code.
838    Failed {
839        /// Whether or not the result was cached (indicating that we didn't
840        /// actually re-run the test).
841        cached: bool,
842
843        /// The exit code of the process.
844        exit_code: i32,
845
846        /// Whether the test was run interactively (the user executed the
847        /// command via `--interactive`).
848        interactive: bool,
849    },
850
851    /// The test passed and returned a successful exit code.
852    Passed {
853        /// Whether or not the result was cached (indicating that we didn't
854        /// actually re-run the test).
855        cached: bool,
856
857        /// Information about the working copy state after running the test command.
858        fix_info: FixInfo,
859
860        /// Whether the test was run interactively (the user executed the
861        /// command via `--interactive`).
862        interactive: bool,
863    },
864}
865
866/// Information about the working copy state after running the test command.
867#[derive(Clone, Debug)]
868pub struct FixInfo {
869    /// The resulting commit which was checked out as `HEAD`. This is usually
870    /// the same commit as was being tested, but the test command could amend or
871    /// switch to a different commit.
872    pub head_commit_oid: Option<NonZeroOid>,
873
874    /// The resulting contents of the working copy after the command was
875    /// executed (if taking a working copy snapshot succeeded and there were no
876    /// merge conflicts, etc.).
877    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    /// Produce a friendly description of the test status.
910    #[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/// An error produced when testing is aborted due to a certain commit.
1026#[derive(Debug)]
1027pub struct TestingAbortedError {
1028    /// The commit which aborted testing.
1029    pub commit_oid: NonZeroOid,
1030
1031    /// The exit code of the test command when run on that commit.
1032    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            // FIXME: don't read entire file into memory
1065            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/// The results of running all tests.
1207#[derive(Debug)]
1208pub struct TestResults {
1209    /// If a search strategy was provided, the calculated bounds on the input
1210    /// commit set.
1211    pub search_bounds: search::Bounds<NonZeroOid>,
1212
1213    /// The test output for each commit.
1214    pub test_outputs: IndexMap<NonZeroOid, TestOutput>,
1215
1216    /// If testing was aborted, the corresponding error.
1217    pub testing_aborted_error: Option<TestingAbortedError>,
1218}
1219
1220/// Run tests on the provided set of commits.
1221#[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: _,      // Used only in `make_test_files`.
1291        is_dry_run: _,     // Used only in `apply_fixes`.
1292        is_interactive: _, // Used in `test_commit`.
1293        num_jobs,
1294        verbosity: _,   // Verbosity used by caller to print results.
1295        fix_options: _, // Whether to apply fixes is checked by `test_commit`, after the working directory is set up.
1296    } = &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                // Create the progress entries in the multiprogress meter without starting them.
1378                // They'll be resumed later in the loop below.
1379                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            // We rely on `result_rx.recv()` returning `Err` once all the threads have exited
1452            // (whether that reason should be panicking or having finished all work). We have to
1453            // drop our local reference to ensure that we don't deadlock, since otherwise there
1454            // would still be a live receiver for the sender.
1455            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    // The results may be returned in an arbitrary order if they were produced
1487    // in parallel, so recover the input order to produce deterministic output.
1488    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                            // Collect `Err` below.
1583                            return true;
1584                        }
1585                    };
1586
1587                    // At this point, `scheduled_jobs` should only contain
1588                    // completed jobs.
1589                    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            // If there is work to be done, then block on the next result to
1640            // be received from a worker. This is okay because we won't
1641            // adjust the work queue until we've received the next result.
1642            // (If we wanted to support cancellation or adjusting the number
1643            // of running workers then this wouldn't be sufficient.)
1644            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        // Can happen during a dry-run; just produce our rewritten commits which
2129        // haven't been rebased on top of each other yet.
2130        // FIXME: it should be possible to execute the rebase plan but not
2131        // commit the branch moves so that we can preview it.
2132        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                // Shouldn't happen.
2190                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: _, // Used in `test_commit`.
2225        execution_strategy,
2226        search_strategy: _, // Caller handles which commits to test.
2227        use_cache: _,       // Used only in `make_test_files`.
2228        is_dry_run: _,      // Used only in `apply_fixes`.
2229        is_interactive: _,  // Used in `test_commit`.
2230        num_jobs: _,        // Caller handles job management.
2231        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: _, // Drop lock.
2261                        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 the file exists but was empty, this indicates that a previous
2425        // attempt did not complete successfully. However, we successfully took
2426        // the lock, so it should be the case that we are the exclusive writers
2427        // to the contents of this directory (i.e. the previous attempt is not
2428        // still running), so it's safe to proceed and overwrite these files.
2429        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)] // fields are not read except by `Debug` implementation`
2515#[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                // Don't show the `git reset` operation among the progress bars,
2561                // as we only want to see the testing status.
2562                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, // Make sure not to drop lock.
2658        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        // NB: use `println` here instead of
2696        // `writeln!(effects.get_output_stream(), ...)` because the effects are
2697        // suppressed in interactive mode.
2698        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                        // FIXME: surface information about the fix that failed to be applied.
2777                        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/// Show test output for the command provided in `options` for each of the
2843/// commits in `revset`.
2844#[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/// Delete cached test output for the commits in `revset`.
2941#[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}