1use 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 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 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 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 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 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 { .. } => {} 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 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 self.run(repo, &["fetch", "origin"])
221 .with_context(|| format!("fetching origin in {}", repo.display()))?;
222 let base = self.resolve_target(repo, checkout)?;
223
224 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 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 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 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 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 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 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 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 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}