1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use crate::proc::Quiet as _;
10use anyhow::{Context as _, Result, bail};
11use tokio::process::Command;
12
13#[derive(Debug)]
15pub struct GitOut {
16 pub code: Option<i32>,
18 pub stdout: String,
20 pub stderr: String,
22}
23
24impl GitOut {
25 pub fn ok(&self) -> bool {
27 self.code == Some(0)
28 }
29}
30
31pub async fn git_raw(cwd: &Path, args: &[&str]) -> Result<GitOut> {
34 let out = Command::new("git")
35 .args(args)
36 .current_dir(cwd)
37 .quiet()
38 .env("GIT_TERMINAL_PROMPT", "0")
41 .env("GIT_EDITOR", "true")
42 .stdin(Stdio::null())
43 .output()
44 .await
45 .with_context(|| format!("spawn git {}", args.join(" ")))?;
46 Ok(GitOut {
47 code: out.status.code(),
48 stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
49 stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
50 })
51}
52
53pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
55 let out = git_raw(cwd, args).await?;
56 if !out.ok() {
57 bail!(
58 "git {} failed in {} (exit {:?}): {}",
59 args.join(" "),
60 cwd.display(),
61 out.code,
62 if out.stderr.is_empty() {
63 out.stdout.as_str()
64 } else {
65 out.stderr.as_str()
66 }
67 );
68 }
69 Ok(out.stdout)
70}
71
72pub async fn toplevel(path: &Path) -> Result<PathBuf> {
74 let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
75 Ok(PathBuf::from(out))
76}
77
78pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
80 git(repo, &["rev-parse", rev]).await
81}
82
83pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
85 let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
86 Ok(if out.ok() && !out.stdout.is_empty() {
87 Some(out.stdout)
88 } else {
89 None
90 })
91}
92
93pub async fn is_clean(repo: &Path) -> Result<bool> {
95 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
96}
97
98pub async fn status_porcelain(repo: &Path) -> Result<String> {
100 git(repo, &["status", "--porcelain"]).await
101}
102
103pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
105 if let Some(parent) = path.parent() {
106 tokio::fs::create_dir_all(parent).await.ok();
107 }
108 let path_s = path.to_string_lossy().to_string();
109 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
110 .await
111 .map(|_| ())
112}
113
114pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
116 if let Some(parent) = path.parent() {
117 tokio::fs::create_dir_all(parent).await.ok();
118 }
119 let path_s = path.to_string_lossy().to_string();
120 git(repo, &["worktree", "add", "--detach", &path_s, rev])
121 .await
122 .map(|_| ())
123}
124
125pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
127 git(worktree, &["checkout", "--detach", rev]).await?;
128 git(worktree, &["reset", "--hard", rev]).await?;
129 git(worktree, &["clean", "-fdx"]).await?;
130 Ok(())
131}
132
133pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
136 let path_s = path.to_string_lossy().to_string();
137 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
138 if out.ok() {
139 return Ok(true);
140 }
141 git_raw(repo, &["worktree", "prune"]).await?;
143 Ok(false)
144}
145
146pub async fn remove_worktree_from_linked(dir: &Path) {
155 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
156 return;
157 };
158 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
159 return;
160 };
161 let admin = Path::new(admin);
164 let Some(common) = admin.parent().and_then(Path::parent) else {
165 return;
166 };
167 let common_s = common.to_string_lossy();
168 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
169}
170
171pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
173 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
174}
175
176pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
178 let refname = format!("refs/heads/{branch}");
179 Ok(
180 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
181 .await?
182 .ok(),
183 )
184}
185
186pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
188 let range = format!("{base}...{head}");
189 git(
190 worktree,
191 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
192 )
193 .await
194}
195
196pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
198 let range = format!("{base}...{head}");
199 git(worktree, &["diff", "--no-color", "--stat", &range]).await
200}
201
202pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
204 let range = format!("{base}...{head}");
205 let out = git(worktree, &["diff", "--name-only", &range]).await?;
206 Ok(out.lines().map(str::to_owned).collect())
207}
208
209pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
211 let range = format!("{base}..{head}");
212 git(
213 worktree,
214 &["log", "--reverse", "--format=%s%n%b%n--", &range],
215 )
216 .await
217}
218
219pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
221 let range = format!("{base}..{head}");
222 let out = git(worktree, &["rev-list", "--count", &range]).await?;
223 Ok(out.trim().parse().unwrap_or(0))
224}
225
226pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
233 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
234 return Ok(false);
235 }
236 git(worktree, &["add", "-A"]).await?;
237 let out = git_raw(
238 worktree,
239 &[
240 "-c",
241 "user.name=magi candidate",
242 "-c",
243 "user.email=magi@localhost",
244 "commit",
245 "--no-verify",
246 "-m",
247 message,
248 ],
249 )
250 .await?;
251 if !out.ok() {
252 bail!("rescue commit failed: {}", out.stderr);
253 }
254 Ok(true)
255}
256
257pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
262 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
263 if out.ok() && out.stdout.trim() == "true" {
264 return Ok(false);
265 }
266 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
267 Ok(true)
268}
269
270pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
272 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
273 Ok(())
274}
275
276struct WorktreeConfigRef {
279 count: usize,
281 we_enabled: bool,
287}
288
289static WORKTREE_CONFIG: std::sync::LazyLock<
296 std::sync::Mutex<
297 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
298 >,
299> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
300
301fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
303 let mut map = WORKTREE_CONFIG
304 .lock()
305 .unwrap_or_else(std::sync::PoisonError::into_inner);
306 map.entry(repo.to_path_buf())
307 .or_insert_with(|| {
308 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
309 count: 0,
310 we_enabled: false,
311 }))
312 })
313 .clone()
314}
315
316pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
333 let slot = worktree_config_slot(repo);
334 let mut entry = slot.lock().await;
335 entry.count += 1;
336 if entry.count == 1 {
337 entry.we_enabled = enable_worktree_config(repo).await?;
338 }
339 Ok(())
340}
341
342pub async fn release_worktree_config(repo: &Path) -> Result<()> {
348 let slot = worktree_config_slot(repo);
349 let mut entry = slot.lock().await;
350 entry.count = entry.count.saturating_sub(1);
351 if entry.count == 0 && entry.we_enabled {
352 disable_worktree_config(repo).await?;
353 entry.we_enabled = false;
354 }
355 Ok(())
356}
357
358pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
364 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
365 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
366 .await
367 .map(|_| ())
368}
369
370pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
372 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
373 let path = worktree.join(git_dir);
374 if let Some(parent) = path.parent() {
375 tokio::fs::create_dir_all(parent).await.ok();
376 }
377 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
378 if body.lines().any(|l| l.trim() == pattern) {
379 return Ok(());
380 }
381 if !body.is_empty() && !body.ends_with('\n') {
382 body.push('\n');
383 }
384 body.push_str(pattern);
385 body.push('\n');
386 tokio::fs::write(&path, body)
387 .await
388 .with_context(|| format!("write {}", path.display()))?;
389 Ok(())
390}
391
392pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
394 git_raw(
395 repo,
396 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
397 )
398 .await
399}
400
401pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
403 git_raw(repo, &["push", "-u", remote, branch]).await
404}
405
406pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
414 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
415}
416
417pub async fn rebase_branch_in_temp(
429 repo: &Path,
430 scratch: &Path,
431 branch: &str,
432 onto: &str,
433) -> Result<Option<String>> {
434 worktree_remove(repo, scratch).await.ok();
437 git_raw(
438 repo,
439 &[
440 "worktree",
441 "add",
442 "--force",
443 &scratch.to_string_lossy(),
444 branch,
445 ],
446 )
447 .await?;
448
449 let out = git_raw(scratch, &["rebase", onto]).await?;
450 if out.ok() {
451 worktree_remove(repo, scratch).await.ok();
452 return Ok(None);
453 }
454 git_raw(scratch, &["rebase", "--abort"]).await.ok();
456 let why = if out.stderr.trim().is_empty() {
457 out.stdout.trim().to_owned()
458 } else {
459 out.stderr.trim().to_owned()
460 };
461 worktree_remove(repo, scratch).await.ok();
462 Ok(Some(why))
463}
464
465pub async fn sync_to_head(worktree: &Path) -> Result<()> {
478 git(worktree, &["reset", "--hard", "HEAD"]).await?;
479 git(worktree, &["clean", "-fdx"]).await?;
480 Ok(())
481}
482
483pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
502 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
503 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
504}
505
506pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
508 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
509 .await
510 .is_ok_and(|o| o.ok())
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 async fn scratch() -> (tempfile::TempDir, PathBuf) {
518 let dir = tempfile::tempdir().unwrap();
519 let repo = dir.path().join("repo");
520 tokio::fs::create_dir_all(&repo).await.unwrap();
521 git(&repo, &["init", "-b", "main"]).await.unwrap();
522 git(&repo, &["config", "user.name", "test"]).await.unwrap();
523 git(&repo, &["config", "user.email", "test@example.com"])
524 .await
525 .unwrap();
526 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
527 git(&repo, &["add", "-A"]).await.unwrap();
528 git(&repo, &["commit", "-m", "init"]).await.unwrap();
529 (dir, repo)
530 }
531
532 #[tokio::test]
533 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
534 let (_g, repo) = scratch().await;
535
536 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
538 tokio::fs::write(repo.join("b.txt"), "side\n")
539 .await
540 .unwrap();
541 git(&repo, &["add", "-A"]).await.unwrap();
542 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
543
544 git(&repo, &["checkout", "main"]).await.unwrap();
547 tokio::fs::write(repo.join("c.txt"), "main\n")
548 .await
549 .unwrap();
550 git(&repo, &["add", "-A"]).await.unwrap();
551 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
552
553 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
554 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
555 .await
556 .unwrap();
557 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
558 assert_eq!(
559 commits_ahead(&repo, "main", "side").await.unwrap(),
560 1,
561 "one commit, replayed onto the new base"
562 );
563 assert!(
564 !scratch_tree.exists(),
565 "the throwaway worktree is not left behind"
566 );
567
568 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
570 tokio::fs::write(repo.join("a.txt"), "clash\n")
571 .await
572 .unwrap();
573 git(&repo, &["add", "-A"]).await.unwrap();
574 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
575 git(&repo, &["checkout", "main"]).await.unwrap();
576 tokio::fs::write(repo.join("a.txt"), "main edit\n")
577 .await
578 .unwrap();
579 git(&repo, &["add", "-A"]).await.unwrap();
580 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
581
582 let before = rev_parse(&repo, "clash").await.unwrap();
583 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
584 .await
585 .unwrap()
586 .expect("a same-line clash cannot be rebased silently");
587 assert!(
588 why.to_lowercase().contains("conflict"),
589 "the reason is what git said, which is what a person needs: {why}"
590 );
591 assert_eq!(
592 rev_parse(&repo, "clash").await.unwrap(),
593 before,
594 "a failed rebase leaves the branch exactly where it was"
595 );
596 assert!(!scratch_tree.exists(), "and cleans up after itself");
597 }
598
599 #[tokio::test]
600 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
601 let (guard, repo) = scratch().await;
602
603 git(&repo, &["branch", "side"]).await.unwrap();
607 let side_wt = guard.path().join("side-wt");
608 git(
609 &repo,
610 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
611 )
612 .await
613 .unwrap();
614 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
615 .await
616 .unwrap();
617 git(&side_wt, &["add", "-A"]).await.unwrap();
618 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
619
620 git(&repo, &["checkout", "main"]).await.unwrap();
622 tokio::fs::write(repo.join("c.txt"), "main\n")
623 .await
624 .unwrap();
625 git(&repo, &["add", "-A"]).await.unwrap();
626 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
627
628 let scratch_tree = guard.path().join("rebase-scratch");
630 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
631 .await
632 .unwrap();
633 assert!(clean.is_none());
634
635 assert_eq!(
639 rev_parse(&side_wt, "HEAD").await.unwrap(),
640 rev_parse(&repo, "side").await.unwrap(),
641 "HEAD follows the moved ref"
642 );
643 assert!(
644 !side_wt.join("c.txt").exists(),
645 "stale until synced: main's new file has not reached this worktree's disk"
646 );
647
648 sync_to_head(&side_wt).await.unwrap();
649 assert!(side_wt.join("c.txt").is_file(), "synced now");
650 assert!(
651 side_wt.join("b.txt").is_file(),
652 "the worktree's own committed work survives the sync"
653 );
654 assert!(is_clean(&side_wt).await.unwrap());
655 }
656
657 #[tokio::test]
658 async fn clean_repo_reports_clean_then_dirty() {
659 let (_g, repo) = scratch().await;
660 assert!(is_clean(&repo).await.unwrap());
661 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
662 assert!(!is_clean(&repo).await.unwrap());
663 }
664
665 #[tokio::test]
666 async fn worktree_lifecycle_and_diff() {
667 let (guard, repo) = scratch().await;
668 let base = rev_parse(&repo, "HEAD").await.unwrap();
669 let wt = guard.path().join("wt-a");
670 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
671 .await
672 .unwrap();
673 tokio::fs::write(wt.join("b.txt"), "candidate\n")
674 .await
675 .unwrap();
676
677 assert!(commit_all(&wt, "candidate work").await.unwrap());
678 assert!(!commit_all(&wt, "nothing left").await.unwrap());
679
680 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
681 let patch = diff(&wt, &base, "HEAD").await.unwrap();
682 assert!(patch.contains("b.txt"), "patch was: {patch}");
683 assert_eq!(
684 changed_files(&wt, &base, "HEAD").await.unwrap(),
685 ["b.txt".to_owned()]
686 );
687
688 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
690 .await
691 .unwrap();
692 assert_eq!(author, "magi candidate <magi@localhost>");
693
694 assert!(worktree_remove(&repo, &wt).await.unwrap());
695 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
696 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
697 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
698 }
699
700 #[tokio::test]
701 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
702 let (guard, repo) = scratch().await;
703 let base = rev_parse(&repo, "HEAD").await.unwrap();
704 let wt = guard.path().join("wt-h");
705 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
706 .await
707 .unwrap();
708 let hooks = guard.path().join("hooks");
709 tokio::fs::create_dir_all(&hooks).await.unwrap();
710
711 assert!(enable_worktree_config(&repo).await.unwrap());
712 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
713
714 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
715 .await
716 .unwrap();
717 assert!(!in_wt.is_empty());
718 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
719 .await
720 .unwrap();
721 assert!(
722 !in_primary.ok(),
723 "primary worktree must keep its own hooks: {in_primary:?}"
724 );
725
726 disable_worktree_config(&repo).await.unwrap();
727 }
728
729 #[tokio::test]
730 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
731 let (_g, repo) = scratch().await;
732
733 acquire_worktree_config(&repo).await.unwrap();
736 acquire_worktree_config(&repo).await.unwrap();
737
738 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
739 .await
740 .unwrap();
741 assert_eq!(on, "true");
742
743 release_worktree_config(&repo).await.unwrap();
747 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
748 .await
749 .unwrap();
750 assert_eq!(
751 still_on, "true",
752 "a sibling run's release must not disable the setting for the one still working"
753 );
754
755 release_worktree_config(&repo).await.unwrap();
757 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
758 .await
759 .unwrap();
760 assert!(
761 !after.ok(),
762 "the last release must turn the setting back off: {after:?}"
763 );
764 }
765
766 #[tokio::test]
767 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
768 let (_g, repo) = scratch().await;
769 git(&repo, &["config", "extensions.worktreeConfig", "true"])
770 .await
771 .unwrap();
772
773 acquire_worktree_config(&repo).await.unwrap();
778 release_worktree_config(&repo).await.unwrap();
779
780 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
781 .await
782 .unwrap();
783 assert_eq!(still_on, "true");
784 }
785
786 #[tokio::test]
787 async fn local_exclude_is_idempotent() {
788 let (_g, repo) = scratch().await;
789 local_exclude(&repo, "/.magi/").await.unwrap();
790 local_exclude(&repo, "/.magi/").await.unwrap();
791 let path = repo.join(".git/info/exclude");
792 let body = tokio::fs::read_to_string(&path).await.unwrap();
793 assert_eq!(body.matches("/.magi/").count(), 1);
794 }
795}