Skip to main content

review_queue/vcs/
git.rs

1//! Git backend.
2//!
3//! - `add_workspace`: `git worktree add --detach <ws> <base|commit>`, then for
4//!   `Checkout::Patches`, apply each patch bottom-to-top with `git apply --index --3way` and
5//!   commit it.
6//! - `update_workspace`: `git checkout --detach` onto the new base, then re-apply patches.
7//!   Every `add_workspace`/`update_workspace` call pins its resulting head under
8//!   `refs/review-queue/<name>/<version>` (`git update-ref`, never overwritten), so a prior
9//!   version stays reachable once a later call moves the workspace on.
10//! - `is_dirty`: `git status --porcelain` is non-empty, or HEAD isn't the expected id (the stack
11//!   tip) or one of its ancestors.
12//! - `position`: `git checkout --detach <commit>`, to sit on one patch of a stack.
13//! - `remove_workspace`: `git worktree remove`, plus every `refs/review-queue/<name>/*` ref.
14//!
15//! A failed patch application leaves the workspace in place (the caller should record
16//! `Status::ApplyFailed`) rather than being silently cleaned up.
17
18use std::io::Write;
19use std::path::Path;
20use std::process::Command;
21
22use anyhow::{Context, Result, bail};
23
24use crate::source::{Checkout, Patch};
25use crate::vcs::Vcs;
26
27pub struct GitVcs;
28
29impl GitVcs {
30    fn run(&self, dir: &Path, args: &[&str]) -> Result<String> {
31        let output = Command::new("git")
32            .current_dir(dir)
33            .args(args)
34            .output()
35            .with_context(|| format!("running `git {}` in {}", args.join(" "), dir.display()))?;
36        if !output.status.success() {
37            bail!(
38                "`git {}` in {} failed: {}",
39                args.join(" "),
40                dir.display(),
41                String::from_utf8_lossy(&output.stderr).trim(),
42            );
43        }
44        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
45    }
46
47    fn commit_exists(&self, repo: &Path, rev: &str) -> bool {
48        // Expected to fail (and print to stderr) whenever the commit isn't present yet, so
49        // stdio is captured rather than inherited to avoid spamming callers with normal misses.
50        Command::new("git")
51            .current_dir(repo)
52            .args(["cat-file", "-e", &format!("{rev}^{{commit}}")])
53            .output()
54            .map(|o| o.status.success())
55            .unwrap_or(false)
56    }
57
58    /// Make sure `commit` is present in `repo`'s object database, fetching it from `refspec` if
59    /// not. Tries fetching `commit` directly first (GitHub allows fetching arbitrary reachable
60    /// SHAs), falling back to `refspec` - an advertised ref, which every server must allow -
61    /// since not every git host permits the direct-by-SHA fetch.
62    fn ensure_ref_commit(&self, repo: &Path, refspec: &str, commit: &str) -> Result<()> {
63        if self.commit_exists(repo, commit) {
64            return Ok(());
65        }
66        if self.run(repo, &["fetch", "origin", commit]).is_err() {
67            self.run(repo, &["fetch", "origin", refspec])
68                .with_context(|| format!("fetching `{refspec}` into {}", repo.display()))?;
69        }
70        if !self.commit_exists(repo, commit) {
71            bail!(
72                "commit `{commit}` not found in {} after fetching `{refspec}`",
73                repo.display()
74            );
75        }
76        Ok(())
77    }
78
79    /// The canonical repo's default branch tip, used as the base when `Checkout::Patches` omits
80    /// one. Prefers the recorded remote HEAD symref; falls back to whatever the repo currently
81    /// has checked out.
82    fn default_branch_commit(&self, repo: &Path) -> Result<String> {
83        if let Ok(sym) = self.run(repo, &["symbolic-ref", "refs/remotes/origin/HEAD"]) {
84            return self.run(repo, &["rev-parse", &sym]);
85        }
86        self.run(repo, &["rev-parse", "HEAD"])
87    }
88
89    /// Resolve a `Checkout::Patches` base to a commit id present locally, fetching it if a base
90    /// was given but isn't present yet.
91    fn resolve_base(&self, repo: &Path, base: &Option<String>) -> Result<String> {
92        match base {
93            Some(b) => {
94                self.ensure_commit(repo, b)?;
95                self.run(repo, &["rev-parse", b])
96            }
97            None => self.default_branch_commit(repo),
98        }
99    }
100
101    fn apply_patch(&self, ws: &Path, patch: &Patch) -> Result<()> {
102        let mut file = tempfile::NamedTempFile::new().context("creating temp patch file")?;
103        file.write_all(patch.diff.as_bytes())
104            .context("writing temp patch file")?;
105        let patch_path = file.path().to_string_lossy().to_string();
106
107        self.run(ws, &["apply", "--index", "--3way", &patch_path])
108            .with_context(|| format!("applying patch `{}`", patch.title))?;
109
110        // -c sets the committer identity so a from-scratch checkout doesn't need global git
111        // config; --author preserves the patch's real author.
112        self.run(
113            ws,
114            &[
115                "-c",
116                "user.name=review-queue",
117                "-c",
118                "user.email=review-queue@localhost",
119                "commit",
120                "--author",
121                &patch.author,
122                "-m",
123                &patch.message,
124            ],
125        )
126        .with_context(|| format!("committing patch `{}`", patch.title))?;
127        Ok(())
128    }
129
130    fn apply_checkout(&self, ws: &Path, checkout: &Checkout) -> Result<()> {
131        match checkout {
132            Checkout::Ref { .. } => {} // nothing to apply; already at `commit`
133            Checkout::Patches { patches, .. } => {
134                for patch in patches {
135                    self.apply_patch(ws, patch)?;
136                }
137            }
138            Checkout::ExternalCommand { program, args, env } => {
139                self.run_external(ws, program, args, env)?
140            }
141        }
142        Ok(())
143    }
144
145    fn run_external(
146        &self,
147        ws: &Path,
148        program: &str,
149        args: &[String],
150        env: &[(String, String)],
151    ) -> Result<()> {
152        let output = Command::new(program)
153            .args(args)
154            .current_dir(ws)
155            .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
156            .output()
157            .with_context(|| format!("running `{program}`"))?;
158        if !output.status.success() {
159            bail!(
160                "`{program} {}` in {} failed: {}\n{}",
161                args.join(" "),
162                ws.display(),
163                crate::vcs::clean_output(&output.stderr),
164                crate::vcs::clean_output(&output.stdout),
165            );
166        }
167        Ok(())
168    }
169
170    /// The commit to start a worktree/checkout from, before `apply_checkout` runs.
171    /// `ExternalCommand` gets the same "default branch tip" starting point as an unset
172    /// `Patches` base - the command is expected to move the workspace wherever it actually
173    /// needs to be itself.
174    fn resolve_target(&self, repo: &Path, checkout: &Checkout) -> Result<String> {
175        match checkout {
176            Checkout::Ref {
177                refspec, commit, ..
178            } => {
179                self.ensure_ref_commit(repo, refspec, commit)?;
180                Ok(commit.clone())
181            }
182            Checkout::Patches { base, .. } => self.resolve_base(repo, base),
183            Checkout::ExternalCommand { .. } => self.resolve_base(repo, &None),
184        }
185    }
186
187    fn pin_ref(&self, repo: &Path, name: &str, version: &str, head: &str) -> Result<()> {
188        self.run(
189            repo,
190            &[
191                "update-ref",
192                &format!("refs/review-queue/{name}/{version}"),
193                head,
194            ],
195        )?;
196        Ok(())
197    }
198}
199
200impl Vcs for GitVcs {
201    fn ensure_commit(&self, repo: &Path, refspec_or_sha: &str) -> Result<()> {
202        if self.commit_exists(repo, refspec_or_sha) {
203            return Ok(());
204        }
205        self.run(repo, &["fetch", "origin", refspec_or_sha])
206            .with_context(|| format!("fetching `{refspec_or_sha}` into {}", repo.display()))?;
207        Ok(())
208    }
209
210    fn add_workspace(
211        &self,
212        repo: &Path,
213        ws: &Path,
214        checkout: &Checkout,
215        name: &str,
216        version: &str,
217    ) -> Result<String> {
218        let ws_str = ws.to_string_lossy().to_string();
219        // Refresh the default branch so `origin/HEAD` (the base for patch stacks) is current.
220        self.run(repo, &["fetch", "origin"])
221            .with_context(|| format!("fetching origin in {}", repo.display()))?;
222        let base = self.resolve_target(repo, checkout)?;
223
224        // A failed earlier attempt whose directory was since deleted leaves a registered-but-
225        // missing worktree, which makes `worktree add` at the same path refuse forever.
226        self.run(repo, &["worktree", "prune"])?;
227        self.run(repo, &["worktree", "add", "--detach", &ws_str, &base])
228            .with_context(|| format!("creating worktree at {}", ws.display()))?;
229
230        self.apply_checkout(ws, checkout)?;
231
232        let head = self.run(ws, &["rev-parse", "HEAD"])?;
233        self.pin_ref(repo, name, version, &head)?;
234        Ok(head)
235    }
236
237    fn update_workspace(
238        &self,
239        repo: &Path,
240        ws: &Path,
241        checkout: &Checkout,
242        name: &str,
243        version: &str,
244    ) -> Result<String> {
245        let base = self.resolve_target(repo, checkout)?;
246
247        self.run(ws, &["checkout", "--detach", &base])
248            .with_context(|| format!("checking out {base} in {}", ws.display()))?;
249
250        self.apply_checkout(ws, checkout)?;
251
252        let head = self.run(ws, &["rev-parse", "HEAD"])?;
253        self.pin_ref(repo, name, version, &head)?;
254        Ok(head)
255    }
256
257    fn is_dirty(&self, ws: &Path, expected_head: &str) -> Result<bool> {
258        let status = self.run(ws, &["status", "--porcelain"])?;
259        if !status.is_empty() {
260            return Ok(true);
261        }
262        let head = self.run(ws, &["rev-parse", "HEAD"])?;
263        if head == expected_head {
264            return Ok(false);
265        }
266        // Positioned on an earlier patch of the stack is fine; anything else isn't.
267        let is_ancestor = Command::new("git")
268            .current_dir(ws)
269            .args(["merge-base", "--is-ancestor", &head, expected_head])
270            .output()
271            .with_context(|| format!("running `git merge-base` in {}", ws.display()))?
272            .status
273            .success();
274        Ok(!is_ancestor)
275    }
276
277    fn position(&self, ws: &Path, commit: &str) -> Result<()> {
278        self.run(ws, &["checkout", "--detach", commit])
279            .with_context(|| format!("checking out {commit} in {}", ws.display()))?;
280        Ok(())
281    }
282
283    fn commits(&self, ws: &Path, tip: &str, limit: usize) -> Result<Vec<(String, String)>> {
284        let raw = self.run(
285            ws,
286            &[
287                "log",
288                "-n",
289                &limit.to_string(),
290                "--format=%H%x1f%B%x1e",
291                tip,
292            ],
293        )?;
294        Ok(super::parse_commit_records(&raw))
295    }
296
297    fn remove_workspace(&self, repo: &Path, ws: &Path, name: &str) -> Result<()> {
298        if ws.exists() {
299            let ws_str = ws.to_string_lossy().to_string();
300            self.run(repo, &["worktree", "remove", &ws_str])
301                .with_context(|| format!("removing worktree at {}", ws.display()))?;
302        } else {
303            // Already deleted from disk: drop the now-dangling worktree registration.
304            self.run(repo, &["worktree", "prune"])
305                .context("pruning stale worktrees")?;
306        }
307
308        let refs = self.run(
309            repo,
310            &[
311                "for-each-ref",
312                "--format=%(refname)",
313                &format!("refs/review-queue/{name}/"),
314            ],
315        )?;
316        for r in refs.lines().filter(|l| !l.is_empty()) {
317            self.run(repo, &["update-ref", "-d", r])?;
318        }
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::fs;
327    use tempfile::TempDir;
328
329    fn git(dir: &Path, args: &[&str]) {
330        let out = Command::new("git")
331            .current_dir(dir)
332            .args(args)
333            .output()
334            .unwrap();
335        assert!(
336            out.status.success(),
337            "git {args:?} failed: {}",
338            String::from_utf8_lossy(&out.stderr)
339        );
340    }
341
342    fn init_repo(dir: &Path) {
343        git(dir, &["init", "-q", "-b", "main"]);
344        git(dir, &["config", "user.name", "test"]);
345        git(dir, &["config", "user.email", "test@example.com"]);
346    }
347
348    fn commit_file(dir: &Path, name: &str, contents: &str, message: &str) -> String {
349        fs::write(dir.join(name), contents).unwrap();
350        git(dir, &["add", name]);
351        git(dir, &["commit", "-q", "-m", message]);
352        rev_parse(dir, "HEAD")
353    }
354
355    fn rev_parse(dir: &Path, rev: &str) -> String {
356        let out = Command::new("git")
357            .current_dir(dir)
358            .args(["rev-parse", rev])
359            .output()
360            .unwrap();
361        assert!(out.status.success());
362        String::from_utf8_lossy(&out.stdout).trim().to_string()
363    }
364
365    /// Produce a unified diff for a single new file, applicable with `git apply` regardless of
366    /// what's currently checked out (a real Phabricator raw diff looks like this).
367    fn add_file_patch(title: &str, filename: &str, contents: &str) -> Patch {
368        let diff = format!(
369            "diff --git a/{filename} b/{filename}\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/{filename}\n@@ -0,0 +1,{n} @@\n{body}",
370            n = contents.lines().count(),
371            body = contents
372                .lines()
373                .map(|l| format!("+{l}\n"))
374                .collect::<String>(),
375        );
376        Patch {
377            title: title.into(),
378            author: "Patch Author <patch@example.com>".into(),
379            message: title.into(),
380            diff,
381        }
382    }
383
384    /// A canonical repo cloned from a fresh upstream with one commit, plus the upstream dir
385    /// (kept around so tests can push more refs into it and re-fetch).
386    struct Fixture {
387        _tmp: TempDir,
388        upstream: std::path::PathBuf,
389        canon: std::path::PathBuf,
390        base: String,
391    }
392
393    fn fixture() -> Fixture {
394        let tmp = TempDir::new().unwrap();
395        let upstream = tmp.path().join("upstream");
396        fs::create_dir(&upstream).unwrap();
397        init_repo(&upstream);
398        let base = commit_file(&upstream, "README.md", "hello\n", "base");
399
400        let canon = tmp.path().join("canon");
401        git(
402            tmp.path(),
403            &[
404                "clone",
405                "-q",
406                upstream.to_str().unwrap(),
407                canon.to_str().unwrap(),
408            ],
409        );
410        git(&canon, &["config", "user.name", "test"]);
411        git(&canon, &["config", "user.email", "test@example.com"]);
412
413        Fixture {
414            _tmp: tmp,
415            upstream,
416            canon,
417            base,
418        }
419    }
420
421    #[test]
422    fn add_workspace_from_ref() {
423        let f = fixture();
424        // Simulate a GitHub PR ref: a branch in "upstream" exposed as refs/pull/1/head.
425        let branch_head = commit_file(&f.upstream, "pr.txt", "pr change\n", "pr change");
426        git(
427            &f.upstream,
428            &["update-ref", "refs/pull/1/head", &branch_head],
429        );
430
431        let vcs = GitVcs;
432        let ws = f._tmp.path().join("ws");
433        let checkout = Checkout::Ref {
434            refspec: "refs/pull/1/head".into(),
435            commit: branch_head.clone(),
436            fork: None,
437        };
438        let head = vcs
439            .add_workspace(&f.canon, &ws, &checkout, "github/123", "v1")
440            .unwrap();
441
442        assert_eq!(head, branch_head);
443        assert_eq!(rev_parse(&ws, "HEAD"), branch_head);
444        assert!(ws.join("pr.txt").exists());
445        assert_eq!(
446            rev_parse(&f.canon, "refs/review-queue/github/123/v1"),
447            branch_head
448        );
449    }
450
451    #[test]
452    fn add_workspace_from_patch_stack() {
453        let f = fixture();
454        let patch1 = add_file_patch("add a", "a.txt", "aaa\n");
455        let patch2 = add_file_patch("add b", "b.txt", "bbb\n");
456        let checkout = Checkout::Patches {
457            base: Some(f.base.clone()),
458            patches: vec![patch1, patch2],
459        };
460
461        let vcs = GitVcs;
462        let ws = f._tmp.path().join("ws");
463        let head = vcs
464            .add_workspace(&f.canon, &ws, &checkout, "moz/D1", "1")
465            .unwrap();
466
467        assert_eq!(rev_parse(&ws, "HEAD"), head);
468        assert!(ws.join("a.txt").exists());
469        assert!(ws.join("b.txt").exists());
470        // Two new commits over base.
471        assert_eq!(rev_parse(&ws, "HEAD~2"), f.base);
472        assert_eq!(rev_parse(&f.canon, "refs/review-queue/moz/D1/1"), head);
473    }
474
475    #[test]
476    fn position_moves_within_a_stack_without_counting_as_dirty() {
477        let f = fixture();
478        let checkout = Checkout::Patches {
479            base: Some(f.base.clone()),
480            patches: vec![
481                add_file_patch("add a", "a.txt", "aaa\n"),
482                add_file_patch("add b", "b.txt", "bbb\n"),
483            ],
484        };
485        let vcs = GitVcs;
486        let ws = f._tmp.path().join("ws");
487        let tip = vcs
488            .add_workspace(&f.canon, &ws, &checkout, "moz/D1", "1")
489            .unwrap();
490
491        let commits = vcs.commits(&ws, &tip, 10).unwrap();
492        assert_eq!(commits[0].0, tip);
493        assert_eq!(commits[0].1.trim(), "add b");
494        assert_eq!(commits[1].1.trim(), "add a");
495
496        vcs.position(&ws, &commits[1].0).unwrap();
497        assert!(!ws.join("b.txt").exists());
498        assert!(!vcs.is_dirty(&ws, &tip).unwrap());
499
500        vcs.position(&ws, &tip).unwrap();
501        assert!(ws.join("b.txt").exists());
502    }
503
504    #[test]
505    fn a_commit_outside_the_stack_is_dirty() {
506        let f = fixture();
507        let checkout = Checkout::Patches {
508            base: Some(f.base.clone()),
509            patches: vec![add_file_patch("add a", "a.txt", "aaa\n")],
510        };
511        let vcs = GitVcs;
512        let ws = f._tmp.path().join("ws");
513        let tip = vcs
514            .add_workspace(&f.canon, &ws, &checkout, "moz/D1", "1")
515            .unwrap();
516
517        git(&ws, &["config", "user.name", "test"]);
518        git(&ws, &["config", "user.email", "test@example.com"]);
519        fs::write(ws.join("mine.txt"), "x\n").unwrap();
520        git(&ws, &["add", "mine.txt"]);
521        git(&ws, &["commit", "-q", "-m", "local"]);
522
523        assert!(vcs.is_dirty(&ws, &tip).unwrap());
524    }
525
526    #[test]
527    fn add_workspace_from_patch_stack_with_no_base_uses_default_branch() {
528        let f = fixture();
529        git(&f.upstream, &["symbolic-ref", "HEAD", "refs/heads/main"]);
530        git(&f.canon, &["fetch", "-q"]);
531        git(
532            &f.canon,
533            &[
534                "symbolic-ref",
535                "refs/remotes/origin/HEAD",
536                "refs/remotes/origin/main",
537            ],
538        );
539
540        let checkout = Checkout::Patches {
541            base: None,
542            patches: vec![add_file_patch("add a", "a.txt", "aaa\n")],
543        };
544        let vcs = GitVcs;
545        let ws = f._tmp.path().join("ws");
546        let head = vcs
547            .add_workspace(&f.canon, &ws, &checkout, "moz/D2", "1")
548            .unwrap();
549
550        assert_eq!(rev_parse(&ws, "HEAD~1"), f.base);
551        assert_eq!(rev_parse(&f.canon, "refs/review-queue/moz/D2/1"), head);
552    }
553
554    #[test]
555    fn apply_failure_leaves_workspace_for_inspection() {
556        let f = fixture();
557        let mut bad = add_file_patch("conflict", "README.md", "will not apply\n");
558        // README.md already exists at base, so a "new file" patch for it must fail to apply.
559        bad.diff = bad.diff.replace("new file mode 100644\n", "");
560
561        let checkout = Checkout::Patches {
562            base: Some(f.base.clone()),
563            patches: vec![bad],
564        };
565        let vcs = GitVcs;
566        let ws = f._tmp.path().join("ws");
567        let result = vcs.add_workspace(&f.canon, &ws, &checkout, "moz/D3", "1");
568
569        assert!(result.is_err());
570        assert!(
571            ws.exists(),
572            "workspace should be left in place for inspection, not cleaned up"
573        );
574    }
575
576    #[test]
577    fn update_workspace_keeps_old_head_reachable() {
578        let f = fixture();
579        let vcs = GitVcs;
580        let ws = f._tmp.path().join("ws");
581
582        let checkout_v1 = Checkout::Patches {
583            base: Some(f.base.clone()),
584            patches: vec![add_file_patch("add a", "a.txt", "aaa\n")],
585        };
586        let head_v1 = vcs
587            .add_workspace(&f.canon, &ws, &checkout_v1, "moz/D4", "1")
588            .unwrap();
589
590        let checkout_v2 = Checkout::Patches {
591            base: Some(f.base.clone()),
592            patches: vec![
593                add_file_patch("add a", "a.txt", "aaa\n"),
594                add_file_patch("add b", "b.txt", "bbb\n"),
595            ],
596        };
597        let head_v2 = vcs
598            .update_workspace(&f.canon, &ws, &checkout_v2, "moz/D4", "2")
599            .unwrap();
600
601        assert_ne!(head_v1, head_v2);
602        assert_eq!(rev_parse(&ws, "HEAD"), head_v2);
603        assert!(ws.join("b.txt").exists());
604        // The old head is still reachable via its pinned ref, even though the workspace moved on.
605        assert_eq!(rev_parse(&f.canon, "refs/review-queue/moz/D4/1"), head_v1);
606        assert_eq!(rev_parse(&f.canon, "refs/review-queue/moz/D4/2"), head_v2);
607    }
608
609    #[test]
610    fn is_dirty_detects_local_changes_and_head_mismatch() {
611        let f = fixture();
612        let vcs = GitVcs;
613        let ws = f._tmp.path().join("ws");
614        let checkout = Checkout::Patches {
615            base: Some(f.base.clone()),
616            patches: vec![],
617        };
618        let head = vcs
619            .add_workspace(&f.canon, &ws, &checkout, "moz/D5", "1")
620            .unwrap();
621
622        assert!(!vcs.is_dirty(&ws, &head).unwrap());
623
624        fs::write(ws.join("untracked.txt"), "oops\n").unwrap();
625        assert!(vcs.is_dirty(&ws, &head).unwrap());
626
627        fs::remove_file(ws.join("untracked.txt")).unwrap();
628        assert!(!vcs.is_dirty(&ws, &head).unwrap());
629        assert!(
630            vcs.is_dirty(&ws, "0000000000000000000000000000000000000000")
631                .unwrap()
632        );
633    }
634
635    #[test]
636    fn remove_workspace_cleans_up_worktree_and_refs_without_touching_canon() {
637        let f = fixture();
638        let vcs = GitVcs;
639        let ws = f._tmp.path().join("ws");
640        let checkout = Checkout::Patches {
641            base: Some(f.base.clone()),
642            patches: vec![],
643        };
644        vcs.add_workspace(&f.canon, &ws, &checkout, "moz/D6", "1")
645            .unwrap();
646
647        let canon_head_before = rev_parse(&f.canon, "HEAD");
648
649        vcs.remove_workspace(&f.canon, &ws, "moz/D6").unwrap();
650
651        assert!(!ws.exists());
652        let refs = vcs
653            .run(&f.canon, &["for-each-ref", "refs/review-queue/moz/D6/"])
654            .unwrap();
655        assert!(
656            refs.is_empty(),
657            "expected all pinned refs to be deleted, found: {refs}"
658        );
659        assert_eq!(
660            rev_parse(&f.canon, "HEAD"),
661            canon_head_before,
662            "canonical repo must be untouched"
663        );
664    }
665
666    #[test]
667    fn add_workspace_with_external_command_runs_it_with_env_and_captures_head() {
668        let f = fixture();
669        let vcs = GitVcs;
670        let ws = f._tmp.path().join("ws");
671        let checkout = Checkout::ExternalCommand {
672            program: "sh".into(),
673            args: vec![
674                "-c".into(),
675                "echo \"$RQ_TEST_VAR\" > ext.txt && git add ext.txt && git commit -q -m ext".into(),
676            ],
677            env: vec![("RQ_TEST_VAR".into(), "hello-from-env".into())],
678        };
679
680        let head = vcs
681            .add_workspace(&f.canon, &ws, &checkout, "moz/D7", "1")
682            .unwrap();
683
684        assert_eq!(rev_parse(&ws, "HEAD"), head);
685        assert_eq!(
686            fs::read_to_string(ws.join("ext.txt")).unwrap().trim(),
687            "hello-from-env"
688        );
689        assert_eq!(rev_parse(&f.canon, "refs/review-queue/moz/D7/1"), head);
690    }
691
692    #[test]
693    fn external_command_failure_leaves_workspace_for_inspection() {
694        let f = fixture();
695        let vcs = GitVcs;
696        let ws = f._tmp.path().join("ws");
697        let checkout = Checkout::ExternalCommand {
698            program: "sh".into(),
699            args: vec!["-c".into(), "exit 7".into()],
700            env: vec![],
701        };
702
703        let err = vcs
704            .add_workspace(&f.canon, &ws, &checkout, "moz/D8", "1")
705            .unwrap_err();
706
707        assert!(
708            ws.exists(),
709            "workspace should be left in place for inspection, not cleaned up"
710        );
711        assert!(
712            err.to_string().contains("failed"),
713            "unexpected error: {err}"
714        );
715    }
716}