branchless/
testing.rs

1//! Testing utilities.
2//!
3//! This is inside `src` rather than `tests` since we use this code in some unit
4//! tests.
5
6use std::collections::{BTreeMap, HashMap};
7use std::ffi::OsString;
8use std::fs;
9use std::io::Write;
10use std::ops::Deref;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use crate::core::config::env_vars::{
15    get_git_exec_path, get_path_to_git, should_use_separate_command_binary, TEST_GIT,
16    TEST_SEPARATE_COMMAND_BINARIES,
17};
18use crate::git::{GitRunInfo, GitVersion, NonZeroOid, Repo};
19use crate::util::get_sh;
20use color_eyre::Help;
21use eyre::Context;
22use itertools::Itertools;
23use lazy_static::lazy_static;
24use once_cell::sync::OnceCell;
25use regex::{Captures, Regex};
26use tempfile::TempDir;
27use tracing::{instrument, warn};
28
29const DUMMY_NAME: &str = "Testy McTestface";
30const DUMMY_EMAIL: &str = "test@example.com";
31const DUMMY_DATE: &str = "Wed 29 Oct 12:34:56 2020 PDT";
32
33/// Wrapper around the Git executable, for testing.
34#[derive(Clone, Debug)]
35pub struct Git {
36    /// The path to the repository on disk. The directory itself must exist,
37    /// although it might not have a `.git` folder in it. (Use `Git::init_repo`
38    /// to initialize it.)
39    pub repo_path: PathBuf,
40
41    /// The path to the Git executable on disk. This is important since we test
42    /// against multiple Git versions.
43    pub path_to_git: PathBuf,
44
45    /// The `GIT_EXEC_PATH` environment variable value to use for testing.
46    pub git_exec_path: PathBuf,
47}
48
49/// Options for `Git::init_repo_with_options`.
50#[derive(Debug)]
51pub struct GitInitOptions {
52    /// If `true`, then `init_repo_with_options` makes an initial commit with
53    /// some content.
54    pub make_initial_commit: bool,
55
56    /// If `true`, run `git branchless init` as part of initialization process.
57    pub run_branchless_init: bool,
58}
59
60impl Default for GitInitOptions {
61    fn default() -> Self {
62        GitInitOptions {
63            make_initial_commit: true,
64            run_branchless_init: true,
65        }
66    }
67}
68
69/// Options for `Git::run_with_options`.
70#[derive(Debug, Default)]
71pub struct GitRunOptions {
72    /// The timestamp of the command. Mostly useful for `git commit`. This should
73    /// be a number like 0, 1, 2, 3...
74    pub time: isize,
75
76    /// The exit code that `Git` should return.
77    pub expected_exit_code: i32,
78
79    /// The input to write to the child process's stdin.
80    pub input: Option<String>,
81
82    /// Additional environment variables to start the process with.
83    pub env: HashMap<String, String>,
84}
85
86impl Git {
87    /// Constructor.
88    pub fn new(path_to_git: PathBuf, repo_path: PathBuf, git_exec_path: PathBuf) -> Self {
89        Git {
90            repo_path,
91            path_to_git,
92            git_exec_path,
93        }
94    }
95
96    /// Replace dynamic strings in the output, for testing purposes.
97    pub fn preprocess_output(&self, stdout: String) -> eyre::Result<String> {
98        let path_to_git = self
99            .path_to_git
100            .to_str()
101            .ok_or_else(|| eyre::eyre!("Could not convert path to Git to string"))?;
102        let output = stdout.replace(path_to_git, "<git-executable>");
103
104        // NB: tests which run on Windows are unlikely to succeed due to this
105        // `canonicalize` call.
106        let repo_path = std::fs::canonicalize(&self.repo_path)?;
107
108        let repo_path = repo_path
109            .to_str()
110            .ok_or_else(|| eyre::eyre!("Could not convert repo path to string"))?;
111        let output = output.replace(repo_path, "<repo-path>");
112
113        lazy_static! {
114            // Simulate clearing the terminal line by searching for the
115            // appropriate sequences of characters and removing the line
116            // preceding them.
117            //
118            // - `\r`: Interactive progress displays may update the same line
119            // multiple times with a carriage return before emitting the final
120            // newline.
121            // - `\x1B[K`: Window pseudo console may emit EL 'Erase in Line' VT
122            // sequences.
123            static ref CLEAR_LINE_RE: Regex = Regex::new(r"(^|\n).*(\r|\x1B\[K)").unwrap();
124        }
125        let output = CLEAR_LINE_RE
126            .replace_all(&output, |captures: &Captures| {
127                // Restore the leading newline, if any.
128                captures[1].to_string()
129            })
130            .into_owned();
131
132        lazy_static! {
133            // Convert non-empty, blank lines to empty, blank lines to make the
134            // command output easier to use in snapshot assertions.
135            //
136            // Some git output includes lines made up of only whitespace, which
137            // is hard to work with as inline snapshots because editors often
138            // trim such trailing whitespace automatically. In particular,
139            // `git show` prints 4 spaces instead of just an empty line for the
140            // lines between commit message paragraphs. (ie "    \n" instead of
141            // just "\n".)
142            //
143            // This regex targets the output of `git show`, but it could be
144            // generalized in the future if other cases come up.
145            static ref WHITESPACE_ONLY_LINE_RE: Regex = Regex::new(r"(?m)^ {4}$").unwrap();
146        }
147        let output = WHITESPACE_ONLY_LINE_RE
148            .replace_all(&output, "")
149            .into_owned();
150
151        Ok(output)
152    }
153
154    /// Get the `PATH` environment variable to use for testing.
155    pub fn get_path_for_env(&self) -> OsString {
156        let cargo_bin_path = assert_cmd::cargo::cargo_bin("git-branchless");
157        let branchless_path = cargo_bin_path
158            .parent()
159            .expect("Unable to find git-branchless path parent");
160        let bash = get_sh().expect("bash missing?");
161        let bash_path = bash.parent().unwrap();
162        std::env::join_paths(vec![
163            // For Git to be able to launch `git-branchless`.
164            branchless_path.as_os_str(),
165            // For our hooks to be able to call back into `git`.
166            self.git_exec_path.as_os_str(),
167            // For branchless to manually invoke bash when needed.
168            bash_path.as_os_str(),
169        ])
170        .expect("joining paths")
171    }
172
173    /// Get the environment variables needed to run git in the test environment.
174    pub fn get_base_env(&self, time: isize) -> Vec<(OsString, OsString)> {
175        // Required for determinism, as these values will be baked into the commit
176        // hash.
177        let date: OsString = format!("{DUMMY_DATE} -{time:0>2}").into();
178
179        // Fake "editor" which accepts the default contents of any commit
180        // messages. Usually, we can set this with `git commit -m`, but we have
181        // no such option for things such as `git rebase`, which may call `git
182        // commit` later as a part of their execution.
183        //
184        // ":" is understood by `git` to skip editing.
185        let git_editor = OsString::from(":");
186
187        let new_path = self.get_path_for_env();
188        let envs = vec![
189            ("GIT_CONFIG_NOSYSTEM", OsString::from("1")),
190            ("GIT_AUTHOR_DATE", date.clone()),
191            ("GIT_COMMITTER_DATE", date),
192            ("GIT_EDITOR", git_editor),
193            ("GIT_EXEC_PATH", self.git_exec_path.as_os_str().into()),
194            ("LC_ALL", "C".into()),
195            ("PATH", new_path),
196            (TEST_GIT, self.path_to_git.as_os_str().into()),
197            (
198                TEST_SEPARATE_COMMAND_BINARIES,
199                std::env::var_os(TEST_SEPARATE_COMMAND_BINARIES).unwrap_or_default(),
200            ),
201        ];
202
203        envs.into_iter()
204            .map(|(key, value)| (OsString::from(key), value))
205            .collect()
206    }
207
208    #[track_caller]
209    #[instrument]
210    fn run_with_options_inner(
211        &self,
212        args: &[&str],
213        options: &GitRunOptions,
214    ) -> eyre::Result<(String, String)> {
215        let GitRunOptions {
216            time,
217            expected_exit_code,
218            input,
219            env,
220        } = options;
221
222        let env: BTreeMap<_, _> = self
223            .get_base_env(*time)
224            .into_iter()
225            .chain(
226                env.iter()
227                    .map(|(k, v)| (OsString::from(k), OsString::from(v))),
228            )
229            .collect();
230        let mut command = Command::new(&self.path_to_git);
231        command
232            .current_dir(&self.repo_path)
233            .args(args)
234            .env_clear()
235            .envs(&env);
236
237        let result = if let Some(input) = input {
238            let mut child = command
239                .stdin(Stdio::piped())
240                .stdout(Stdio::piped())
241                .stderr(Stdio::piped())
242                .spawn()?;
243            write!(child.stdin.take().unwrap(), "{}", &input)?;
244            child.wait_with_output().wrap_err_with(|| {
245                format!(
246                    "Running git
247                    Executable: {:?}
248                    Args: {:?}
249                    Stdin: {:?}
250                    Env: <not shown>",
251                    &self.path_to_git, &args, input
252                )
253            })?
254        } else {
255            command.output().wrap_err_with(|| {
256                format!(
257                    "Running git
258                    Executable: {:?}
259                    Args: {:?}
260                    Env: <not shown>",
261                    &self.path_to_git, &args
262                )
263            })?
264        };
265
266        let exit_code = result
267            .status
268            .code()
269            .expect("Failed to read exit code from Git process");
270        let result = if exit_code != *expected_exit_code {
271            eyre::bail!(
272                "Git command {:?} {:?} exited with unexpected code {} (expected {})
273env:
274{:#?}
275stdout:
276{}
277stderr:
278{}",
279                &self.path_to_git,
280                &args,
281                exit_code,
282                expected_exit_code,
283                &env,
284                &String::from_utf8_lossy(&result.stdout),
285                &String::from_utf8_lossy(&result.stderr),
286            )
287        } else {
288            result
289        };
290        let stdout = String::from_utf8(result.stdout)?;
291        let stdout = self.preprocess_output(stdout)?;
292        let stderr = String::from_utf8(result.stderr)?;
293        let stderr = self.preprocess_output(stderr)?;
294        Ok((stdout, stderr))
295    }
296
297    /// Run a Git command.
298    #[track_caller]
299    pub fn run_with_options<S: AsRef<str> + std::fmt::Debug>(
300        &self,
301        args: &[S],
302        options: &GitRunOptions,
303    ) -> eyre::Result<(String, String)> {
304        self.run_with_options_inner(
305            args.iter().map(|arg| arg.as_ref()).collect_vec().as_slice(),
306            options,
307        )
308    }
309
310    /// Run a Git command.
311    #[track_caller]
312    pub fn run<S: AsRef<str> + std::fmt::Debug>(
313        &self,
314        args: &[S],
315    ) -> eyre::Result<(String, String)> {
316        if let Some(first_arg) = args.first() {
317            if first_arg.as_ref() == "branchless" {
318                eyre::bail!(
319                    r#"Refusing to invoke `branchless` via `git.run(&["branchless", ...])`; instead, call `git.branchless(&[...])`"#
320                );
321            }
322        }
323
324        self.run_with_options(args, &Default::default())
325    }
326
327    /// Render the smartlog for the repository.
328    #[instrument]
329    pub fn smartlog(&self) -> eyre::Result<String> {
330        let (stdout, _stderr) = self.branchless("smartlog", &[])?;
331        Ok(stdout)
332    }
333
334    /// Convenience method to call `branchless_with_options` with the default
335    /// options.
336    #[track_caller]
337    #[instrument]
338    pub fn branchless(&self, subcommand: &str, args: &[&str]) -> eyre::Result<(String, String)> {
339        self.branchless_with_options(subcommand, args, &Default::default())
340    }
341
342    /// Locate the git-branchless binary and run a git-branchless subcommand
343    /// with the provided `GitRunOptions`. These subcommands are located using
344    /// `should_use_separate_command_binary`.
345    #[track_caller]
346    #[instrument]
347    pub fn branchless_with_options(
348        &self,
349        subcommand: &str,
350        args: &[&str],
351        options: &GitRunOptions,
352    ) -> eyre::Result<(String, String)> {
353        let mut git_run_args = Vec::new();
354        if should_use_separate_command_binary(subcommand) {
355            git_run_args.push(format!("branchless-{subcommand}"));
356        } else {
357            git_run_args.push("branchless".to_string());
358            git_run_args.push(subcommand.to_string());
359        }
360        git_run_args.extend(args.iter().map(|arg| arg.to_string()));
361
362        let result = self.run_with_options(&git_run_args, options);
363
364        if !should_use_separate_command_binary(subcommand) {
365            let main_command_exe = assert_cmd::cargo::cargo_bin("git-branchless");
366            let subcommand_exe =
367                assert_cmd::cargo::cargo_bin(format!("git-branchless-{subcommand}"));
368            if main_command_exe.exists() && subcommand_exe.exists() {
369                let main_command_mtime = main_command_exe.metadata()?.modified()?;
370                let subcommand_mtime = subcommand_exe.metadata()?.modified()?;
371                if subcommand_mtime > main_command_mtime {
372                    result.suggestion(format!(
373                        "\
374The modified time for {main_command_exe:?} was before the modified time for
375{subcommand_exe:?}, which may indicate that you made changes to the subcommand
376without building the main executable. This may cause spurious test failures
377because the main executable code is out of date.
378
379If so, you should either explicitly run: cargo -p git-branchless
380to build the main executable before running this test; or, if it's okay to skip
381building the main executable and test only the subcommand executable, you
382can set the environment variable
383`{TEST_SEPARATE_COMMAND_BINARIES}={subcommand}` to directly invoke it.\
384"
385                    ))
386                } else {
387                    result
388                }
389            } else {
390                result
391            }
392        } else {
393            result.suggestion(format!(
394                "\
395If you have set the {TEST_SEPARATE_COMMAND_BINARIES} environment variable, then \
396the git-branchless-{subcommand} binary is NOT automatically built or updated when \
397running integration tests for other binaries (see \
398https://github.com/rust-lang/cargo/issues/4316 for more details).
399
400Make sure that git-branchless-{subcommand} has been built before running \
401integration tests. You can build it with: cargo build -p git-branchless-{subcommand}
402
403If you have not set the {TEST_SEPARATE_COMMAND_BINARIES} environment variable, \
404then you can only run tests in the main `git-branchless` and \
405`git-branchless-lib` crates.\
406        ",
407            ))
408        }
409    }
410
411    /// Set up a Git repo in the directory and initialize git-branchless to work
412    /// with it.
413    #[instrument]
414    pub fn init_repo_with_options(&self, options: &GitInitOptions) -> eyre::Result<()> {
415        self.run(&["init"])?;
416        self.run(&["config", "user.name", DUMMY_NAME])?;
417        self.run(&["config", "user.email", DUMMY_EMAIL])?;
418        self.run(&["config", "core.abbrev", "7"])?;
419
420        if options.make_initial_commit {
421            self.commit_file("initial", 0)?;
422        }
423
424        // Non-deterministic metadata (depends on current time).
425        self.run(&[
426            "config",
427            "branchless.commitDescriptors.relativeTime",
428            "false",
429        ])?;
430        self.run(&["config", "branchless.restack.preserveTimestamps", "true"])?;
431
432        // Disable warnings of the following form on Windows:
433        //
434        // ```
435        // warning: LF will be replaced by CRLF in initial.txt.
436        // The file will have its original line endings in your working directory
437        // ```
438        self.run(&["config", "core.autocrlf", "false"])?;
439
440        if options.run_branchless_init {
441            self.branchless("init", &[])?;
442        }
443
444        Ok(())
445    }
446
447    /// Set up a Git repo in the directory and initialize git-branchless to work
448    /// with it.
449    pub fn init_repo(&self) -> eyre::Result<()> {
450        self.init_repo_with_options(&Default::default())
451    }
452
453    /// Clone this repository into the `target` repository (which must not have
454    /// been initialized).
455    pub fn clone_repo_into(&self, target: &Git, additional_args: &[&str]) -> eyre::Result<()> {
456        let remote = format!("file://{}", self.repo_path.to_str().unwrap());
457        let args = {
458            let mut args = vec![
459                "clone",
460                // For Windows in CI.
461                "-c",
462                "core.autocrlf=false",
463                &remote,
464                target.repo_path.to_str().unwrap(),
465            ];
466            args.extend(additional_args.iter());
467            args
468        };
469
470        let (_stdout, _stderr) = self.run(args.as_slice())?;
471
472        // Configuration options are not inherited from the original repo, so
473        // set them in the cloned repo.
474        let new_repo = Git {
475            repo_path: target.repo_path.clone(),
476            ..self.clone()
477        };
478        new_repo.init_repo_with_options(&GitInitOptions {
479            make_initial_commit: false,
480            run_branchless_init: false,
481        })?;
482
483        Ok(())
484    }
485
486    /// Write the provided contents to the provided file in the repository root.
487    /// For historical reasons, the name is suffixed with `.txt` (this is
488    /// technical debt).
489    pub fn write_file_txt(&self, name: &str, contents: &str) -> eyre::Result<()> {
490        let name = format!("{name}.txt");
491        self.write_file(&name, contents)
492    }
493
494    /// Write the provided contents to the provided file in the repository root.
495    pub fn write_file(&self, name: &str, contents: &str) -> eyre::Result<()> {
496        let path = self.repo_path.join(name);
497        if let Some(dir) = path.parent() {
498            std::fs::create_dir_all(self.repo_path.join(dir))?;
499        }
500        std::fs::write(&path, contents)?;
501        Ok(())
502    }
503
504    /// Delete the provided file in the repository root.
505    pub fn delete_file(&self, name: &str) -> eyre::Result<()> {
506        let file_path = self.repo_path.join(format!("{name}.txt"));
507        fs::remove_file(file_path)?;
508        Ok(())
509    }
510
511    /// Delete the provided file in the repository root.
512    pub fn set_file_permissions(
513        &self,
514        name: &str,
515        permissions: fs::Permissions,
516    ) -> eyre::Result<()> {
517        let file_path = self.repo_path.join(format!("{name}.txt"));
518        fs::set_permissions(file_path, permissions)?;
519        Ok(())
520    }
521
522    /// Get a diff of a commit with the a/b file header removed. Only works for commits
523    /// with a single file.
524    #[instrument]
525    pub fn get_trimmed_diff(&self, file: &str, commit: &str) -> eyre::Result<String> {
526        let (stdout, _stderr) = self.run(&["show", "--pretty=format:", commit])?;
527        let split_on = format!("+++ b/{file}\n");
528        match stdout.as_str().split_once(split_on.as_str()) {
529            Some((_, diff)) => Ok(diff.to_string()),
530            None => eyre::bail!("Error trimming diff. Could not split on '{split_on}'"),
531        }
532    }
533
534    /// Commit a file with given contents and message. The `time` argument is
535    /// used to set the commit timestamp, which is factored into the commit
536    /// hash. The filename is always appended to the message prefix.
537    #[track_caller]
538    #[instrument]
539    pub fn commit_file_with_contents_and_message(
540        &self,
541        name: &str,
542        time: isize,
543        contents: &str,
544        message_prefix: &str,
545    ) -> eyre::Result<NonZeroOid> {
546        let message = format!("{message_prefix} {name}.txt");
547        self.write_file_txt(name, contents)?;
548        self.run(&["add", "."])?;
549        self.run_with_options(
550            &["commit", "-m", &message],
551            &GitRunOptions {
552                time,
553                ..Default::default()
554            },
555        )?;
556
557        let repo = self.get_repo()?;
558        let oid = repo
559            .get_head_info()?
560            .oid
561            .expect("Could not find OID for just-created commit");
562        Ok(oid)
563    }
564
565    /// Commit a file with given contents and a default message. The `time`
566    /// argument is used to set the commit timestamp, which is factored into the
567    /// commit hash.
568    #[track_caller]
569    #[instrument]
570    pub fn commit_file_with_contents(
571        &self,
572        name: &str,
573        time: isize,
574        contents: &str,
575    ) -> eyre::Result<NonZeroOid> {
576        self.commit_file_with_contents_and_message(name, time, contents, "create")
577    }
578
579    /// Commit a file with default contents. The `time` argument is used to set
580    /// the commit timestamp, which is factored into the commit hash.
581    #[track_caller]
582    #[instrument]
583    pub fn commit_file(&self, name: &str, time: isize) -> eyre::Result<NonZeroOid> {
584        self.commit_file_with_contents(name, time, &format!("{name} contents\n"))
585    }
586
587    /// Detach HEAD. This is useful to call to make sure that no branch is
588    /// checked out, and therefore that future commit operations don't move any
589    /// branches.
590    #[instrument]
591    pub fn detach_head(&self) -> eyre::Result<()> {
592        self.run(&["checkout", "--detach"])?;
593        Ok(())
594    }
595
596    /// Get a `Repo` object for this repository.
597    #[instrument]
598    pub fn get_repo(&self) -> eyre::Result<Repo> {
599        let repo = Repo::from_dir(&self.repo_path)?;
600        Ok(repo)
601    }
602
603    /// Get the version of the Git executable.
604    #[instrument]
605    pub fn get_version(&self) -> eyre::Result<GitVersion> {
606        let (version_str, _stderr) = self.run(&["version"])?;
607        let version = version_str.parse()?;
608        Ok(version)
609    }
610
611    /// Get the `GitRunInfo` to use for this repository.
612    #[instrument]
613    pub fn get_git_run_info(&self) -> GitRunInfo {
614        GitRunInfo {
615            path_to_git: self.path_to_git.clone(),
616            working_directory: self.repo_path.clone(),
617            env: self.get_base_env(0).into_iter().collect(),
618        }
619    }
620
621    /// Determine if the Git executable supports the `reference-transaction`
622    /// hook.
623    #[instrument]
624    pub fn supports_reference_transactions(&self) -> eyre::Result<bool> {
625        let version = self.get_version()?;
626        Ok(version >= GitVersion(2, 29, 0))
627    }
628
629    /// Determine if the `--committer-date-is-author-date` option to `git rebase
630    /// -i` is respected.
631    ///
632    /// This affects whether we can rely on the timestamps being preserved
633    /// during a rebase when `branchless.restack.preserveTimestamps` is set.
634    pub fn supports_committer_date_is_author_date(&self) -> eyre::Result<bool> {
635        // The `--committer-date-is-author-date` option was previously passed
636        // only to the `am` rebase back-end, until Git v2.29, when it became
637        // available for merge back-end rebases as well.
638        //
639        // See https://git-scm.com/docs/git-rebase/2.28.0
640        //
641        // > These flags are passed to git am to easily change the dates of the
642        // > rebased commits (see git-am[1]).
643        // >
644        // > See also INCOMPATIBLE OPTIONS below.
645        //
646        // See https://git-scm.com/docs/git-rebase/2.29.0
647        //
648        // > Instead of using the current time as the committer date, use the
649        // > author date of the commit being rebased as the committer date. This
650        // > option implies --force-rebase.
651        let version = self.get_version()?;
652        Ok(version >= GitVersion(2, 29, 0))
653    }
654
655    /// The `log.excludeDecoration` configuration option was introduced in Git
656    /// v2.27.
657    pub fn supports_log_exclude_decoration(&self) -> eyre::Result<bool> {
658        let version = self.get_version()?;
659        Ok(version >= GitVersion(2, 27, 0))
660    }
661
662    /// Git v2.44 produces `AUTO_MERGE` refs as part of some operations, which
663    /// changes the event log according to the `reference-transaction` hook.
664    pub fn produces_auto_merge_refs(&self) -> eyre::Result<bool> {
665        let version = self.get_version()?;
666        Ok(version >= GitVersion(2, 44, 0))
667    }
668
669    /// Resolve a file during a merge or rebase conflict with the provided
670    /// contents.
671    #[instrument]
672    pub fn resolve_file(&self, name: &str, contents: &str) -> eyre::Result<()> {
673        let file_path = self.repo_path.join(format!("{name}.txt"));
674        std::fs::write(&file_path, contents)?;
675        let file_path = match file_path.to_str() {
676            None => eyre::bail!("Could not convert file path to string: {:?}", file_path),
677            Some(file_path) => file_path,
678        };
679        self.run(&["add", file_path])?;
680        Ok(())
681    }
682
683    /// Clear the event log on disk. Currently-existing commits will not have
684    /// been observed by the new event log (once it's created by another
685    /// command).
686    #[instrument]
687    pub fn clear_event_log(&self) -> eyre::Result<()> {
688        let event_log_path = self.repo_path.join(".git/branchless/db.sqlite3");
689        std::fs::remove_file(event_log_path)?;
690        Ok(())
691    }
692}
693
694/// Wrapper around a `Git` instance which cleans up the repository once dropped.
695pub struct GitWrapper {
696    repo_dir: TempDir,
697    git: Git,
698}
699
700impl Deref for GitWrapper {
701    type Target = Git;
702
703    fn deref(&self) -> &Self::Target {
704        &self.git
705    }
706}
707
708/// From https://stackoverflow.com/a/65192210
709/// License: CC-BY-SA 4.0
710fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
711    fs::create_dir_all(&dst)?;
712    for entry in fs::read_dir(src)? {
713        let entry = entry?;
714        let ty = entry.file_type()?;
715        if ty.is_dir() {
716            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
717        } else {
718            fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
719        }
720    }
721    Ok(())
722}
723
724impl GitWrapper {
725    /// Make a copy of the repo on disk. This can be used to reuse testing
726    /// setup.  This is *not* the same as running `git clone`; it's used to save
727    /// initialization time as part of testing optimization.
728    ///
729    /// The copied repo will be deleted once the returned value has been dropped.
730    pub fn duplicate_repo(&self) -> eyre::Result<Self> {
731        let repo_dir = tempfile::tempdir()?;
732        copy_dir_all(&self.repo_dir, &repo_dir)?;
733        let git = Git {
734            repo_path: repo_dir.path().to_path_buf(),
735            ..self.git.clone()
736        };
737        Ok(Self { repo_dir, git })
738    }
739}
740
741static COLOR_EYRE_INSTALL: OnceCell<()> = OnceCell::new();
742
743/// Create a temporary directory for testing and a `Git` instance to use with it.
744pub fn make_git() -> eyre::Result<GitWrapper> {
745    COLOR_EYRE_INSTALL.get_or_try_init(color_eyre::install)?;
746
747    let repo_dir = tempfile::tempdir()?;
748    let path_to_git = get_path_to_git()?;
749    let git_exec_path = get_git_exec_path()?;
750    let git = Git::new(path_to_git, repo_dir.path().to_path_buf(), git_exec_path);
751    Ok(GitWrapper { repo_dir, git })
752}
753
754/// Represents a pair of directories that will be cleaned up after this value
755/// dropped. The two directories need to be `init`ed and `clone`ed by the
756/// caller, respectively.
757pub struct GitWrapperWithRemoteRepo {
758    /// Guard to clean up the containing temporary directory. Make sure to bind
759    /// this to a local variable not named `_`.
760    pub temp_dir: TempDir,
761
762    /// The wrapper around the original repository.
763    pub original_repo: Git,
764
765    /// The wrapper around the cloned repository.
766    pub cloned_repo: Git,
767}
768
769/// Create a [`GitWrapperWithRemoteRepo`].
770pub fn make_git_with_remote_repo() -> eyre::Result<GitWrapperWithRemoteRepo> {
771    let path_to_git = get_path_to_git()?;
772    let git_exec_path = get_git_exec_path()?;
773    let temp_dir = tempfile::tempdir()?;
774    let original_repo_path = temp_dir.path().join("original");
775    std::fs::create_dir_all(&original_repo_path)?;
776    let original_repo = Git::new(
777        path_to_git.clone(),
778        original_repo_path,
779        git_exec_path.clone(),
780    );
781    let cloned_repo_path = temp_dir.path().join("cloned");
782    let cloned_repo = Git::new(path_to_git, cloned_repo_path, git_exec_path);
783
784    Ok(GitWrapperWithRemoteRepo {
785        temp_dir,
786        original_repo,
787        cloned_repo,
788    })
789}
790
791/// Represents a Git worktree for an existing Git repository on disk.
792pub struct GitWorktreeWrapper {
793    /// Guard to clean up the containing temporary directory. Make sure to bind
794    /// this to a local variable not named `_`.
795    pub temp_dir: TempDir,
796
797    /// A wrapper around the worktree.
798    pub worktree: Git,
799}
800
801/// Create a new worktree for the provided repository.
802pub fn make_git_worktree(git: &Git, worktree_name: &str) -> eyre::Result<GitWorktreeWrapper> {
803    let temp_dir = tempfile::tempdir()?;
804    let worktree_path = temp_dir.path().join(worktree_name);
805    git.run(&[
806        "worktree",
807        "add",
808        "--detach",
809        worktree_path.to_str().unwrap(),
810    ])?;
811    let worktree = Git {
812        repo_path: worktree_path,
813        ..git.clone()
814    };
815    Ok(GitWorktreeWrapper { temp_dir, worktree })
816}
817
818/// Find and extract the command to disable the hint mentioned in the output.
819/// Returns the arguments to `git` which would disable the hint.
820pub fn extract_hint_command(stdout: &str) -> Vec<String> {
821    let hint_command = stdout
822        .split_once("disable this hint by running: ")
823        .map(|(_first, second)| second)
824        .unwrap()
825        .split('\n')
826        .next()
827        .unwrap();
828    hint_command
829        .split(' ')
830        .skip(1) // "git"
831        .filter(|s| s != &"--global")
832        .map(|s| s.to_owned())
833        .collect_vec()
834}
835
836/// Remove some of the output from `git rebase`, as it seems to be
837/// non-deterministic as to whether or not it appears.
838pub fn remove_rebase_lines(output: String) -> String {
839    output
840        .lines()
841        .filter(|line| !line.contains("First, rewinding head") && !line.contains("Applying:"))
842        .filter(|line| {
843            // See https://github.com/arxanas/git-branchless/issues/87.  Before
844            // Git v2.33 (`next` branch), the "Auto-merging" line appears
845            // *after* the "CONFLICT" line for a given file (which doesn't make
846            // sense -- how can there be a conflict before merging has started)?
847            // The development version of Git v2.33 fixes this and places the
848            // "Auto-merging" line *before* the "CONFLICT" line. To avoid having
849            // to deal with multiple possible output formats, just remove the
850            // line in question.
851            !line.contains("Auto-merging")
852            // Message changed between Git versions (due to be included in Git
853            // v2.42) in
854            // https://github.com/git/git/commit/d92304ff5cfdca463e9ecd1345807d0b46d6af33.
855            && !line.contains("use \"git pull\"")
856        })
857        .flat_map(|line| [line, "\n"])
858        .collect()
859}
860
861/// Remove whitespace from the end of each line in the provided string.
862pub fn trim_lines(output: String) -> String {
863    output
864        .lines()
865        .flat_map(|line| vec![line.trim_end(), "\n"].into_iter())
866        .collect()
867}
868
869/// Remove lines which are not present or different between Git versions.
870pub fn remove_nondeterministic_lines(output: String) -> String {
871    output
872        .lines()
873        .filter(|line| {
874            // This line is not present in some Git versions.
875            !line.contains("Fetching")
876                // This line is produced in a different order in some Git versions.
877                && !line.contains("Your branch is up to date")
878                // This line is only sometimes produced in CI for some reason? I
879                // don't understand how it would only sometimes print this
880                // message, but it does.
881                && !line.contains("Switched to branch")
882                // Hint text is more likely to change between Git versions.
883                && !line.contains("hint:")
884                // There are weird non-deterministic failures for test
885                // `test_sync_no_delete_main_branch` where an extra newline is
886                // printed, such as in
887                // https://github.com/arxanas/git-branchless/actions/runs/5609690113/jobs/10263760651?pr=1002
888                && !line.is_empty()
889        })
890        .flat_map(|line| [line, "\n"])
891        .collect()
892}
893
894/// Utilities for testing in a virtual terminal (PTY).
895pub mod pty {
896    use std::sync::{mpsc::channel, Arc, Mutex};
897    use std::thread;
898    use std::time::Duration;
899
900    use eyre::eyre;
901    use portable_pty::{native_pty_system, CommandBuilder, ExitStatus, PtySize};
902
903    use super::Git;
904
905    /// Terminal escape code corresponding to pressing the up arrow key.
906    pub const UP_ARROW: &str = "\x1b[A";
907
908    /// Terminal escape code corresponding to pressing the down arrow key.
909    pub const DOWN_ARROW: &str = "\x1b[B";
910
911    /// An action to take as part of the PTY test script.
912    pub enum PtyAction<'a> {
913        /// Input the provided string as keystrokes to the terminal.
914        Write(&'a str),
915
916        /// Wait until the terminal display shows the provided string anywhere
917        /// on the screen.
918        WaitUntilContains(&'a str),
919    }
920
921    /// Run the provided script in the context of a virtual terminal.
922    #[track_caller]
923    pub fn run_in_pty(
924        git: &Git,
925        branchless_subcommand: &str,
926        args: &[&str],
927        inputs: &[PtyAction],
928    ) -> eyre::Result<ExitStatus> {
929        // Use the native pty implementation for the system
930        let pty_system = native_pty_system();
931        let pty_size = PtySize::default();
932        let pty = pty_system
933            .openpty(pty_size)
934            .map_err(|e| eyre!("Could not open pty: {}", e))?;
935        let mut pty_master = pty
936            .master
937            .take_writer()
938            .map_err(|e| eyre!("Could not take PTY master writer: {e}"))?;
939
940        // Spawn a git instance in the pty.
941        let mut cmd = CommandBuilder::new(&git.path_to_git);
942        cmd.env_clear();
943        for (k, v) in git.get_base_env(0) {
944            cmd.env(k, v);
945        }
946        cmd.env("TERM", "xterm");
947        cmd.arg("branchless");
948        cmd.arg(branchless_subcommand);
949        cmd.args(args);
950        cmd.cwd(&git.repo_path);
951
952        let mut child = pty
953            .slave
954            .spawn_command(cmd)
955            .map_err(|e| eyre!("Could not spawn child: {}", e))?;
956
957        let reader = pty
958            .master
959            .try_clone_reader()
960            .map_err(|e| eyre!("Could not clone reader: {}", e))?;
961        let reader = Arc::new(Mutex::new(reader));
962
963        let parser = vt100::Parser::new(pty_size.rows, pty_size.cols, 0);
964        let parser = Arc::new(Mutex::new(parser));
965
966        for action in inputs {
967            match action {
968                PtyAction::WaitUntilContains(value) => {
969                    let (finished_tx, finished_rx) = channel();
970
971                    let wait_thread = {
972                        let parser = Arc::clone(&parser);
973                        let reader = Arc::clone(&reader);
974                        let value = value.to_string();
975                        thread::spawn(move || -> anyhow::Result<()> {
976                            loop {
977                                // Drop the `parser` lock after this, since we may block
978                                // on `reader.read` below, and the caller may want to
979                                // check the screen contents of `parser`.
980                                {
981                                    let parser = parser.lock().unwrap();
982                                    if parser.screen().contents().contains(&value) {
983                                        break;
984                                    }
985                                }
986
987                                let mut reader = reader.lock().unwrap();
988                                const BUF_SIZE: usize = 4096;
989                                let mut buffer = [0; BUF_SIZE];
990                                let n = reader.read(&mut buffer)?;
991                                assert!(n < BUF_SIZE, "filled up PTY buffer by reading {n} bytes",);
992
993                                {
994                                    let mut parser = parser.lock().unwrap();
995                                    parser.process(&buffer[..n]);
996                                }
997                            }
998
999                            finished_tx.send(()).unwrap();
1000                            Ok(())
1001                        })
1002                    };
1003
1004                    if finished_rx.recv_timeout(Duration::from_secs(5)).is_err() {
1005                        panic!(
1006                            "\
1007Timed out waiting for virtual terminal to show string: {:?}
1008Screen contents:
1009-----
1010{}
1011-----
1012",
1013                            value,
1014                            parser.lock().unwrap().screen().contents(),
1015                        );
1016                    }
1017
1018                    wait_thread.join().unwrap().unwrap();
1019                }
1020
1021                PtyAction::Write(value) => {
1022                    if let Ok(Some(exit_status)) = child.try_wait() {
1023                        panic!(
1024                            "\
1025Tried to write {value:?} to PTY, but the process has already exited with status {exit_status:?}. Screen contents:
1026-----
1027{}
1028-----
1029", parser.lock().unwrap().screen().contents(),
1030                        );
1031                    }
1032                    write!(pty_master, "{value}")?;
1033                    pty_master.flush()?;
1034                }
1035            }
1036        }
1037
1038        let read_remainder_of_pty_output_thread = thread::spawn({
1039            let reader = Arc::clone(&reader);
1040            move || {
1041                let mut reader = reader.lock().unwrap();
1042                let mut buffer = Vec::new();
1043                reader.read_to_end(&mut buffer).expect("finish reading pty");
1044                String::from_utf8(buffer).unwrap()
1045            }
1046        });
1047        let exit_status = child.wait()?;
1048
1049        let _ = read_remainder_of_pty_output_thread;
1050        // Useful for debugging, but seems to deadlock on some tests:
1051        // let remainder_of_pty_output = read_remainder_of_pty_output_thread.join().unwrap();
1052        // assert!(
1053        //     !remainder_of_pty_output.contains("panic"),
1054        //     "Panic in PTY thread:\n{}",
1055        //     console::strip_ansi_codes(&remainder_of_pty_output)
1056        // );
1057
1058        Ok(exit_status)
1059    }
1060}