Skip to main content

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