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 worktree_prune(repo: &Path) -> Result<()> {
180 git(repo, &["worktree", "prune"]).await.map(|_| ())
181}
182
183pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
185 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
186}
187
188pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
190 let refname = format!("refs/heads/{branch}");
191 Ok(
192 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
193 .await?
194 .ok(),
195 )
196}
197
198pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
200 let range = format!("{base}...{head}");
201 git(
202 worktree,
203 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
204 )
205 .await
206}
207
208pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
210 let range = format!("{base}...{head}");
211 git(worktree, &["diff", "--no-color", "--stat", &range]).await
212}
213
214pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
216 let range = format!("{base}...{head}");
217 let out = git(worktree, &["diff", "--name-only", &range]).await?;
218 Ok(out.lines().map(str::to_owned).collect())
219}
220
221pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
223 let range = format!("{base}..{head}");
224 git(
225 worktree,
226 &["log", "--reverse", "--format=%s%n%b%n--", &range],
227 )
228 .await
229}
230
231pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
233 let range = format!("{base}..{head}");
234 let out = git(worktree, &["rev-list", "--count", &range]).await?;
235 Ok(out.trim().parse().unwrap_or(0))
236}
237
238pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
245 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
246 return Ok(false);
247 }
248 git(worktree, &["add", "-A"]).await?;
249 let out = git_raw(
250 worktree,
251 &[
252 "-c",
253 "user.name=magi candidate",
254 "-c",
255 "user.email=magi@localhost",
256 "commit",
257 "--no-verify",
258 "-m",
259 message,
260 ],
261 )
262 .await?;
263 if !out.ok() {
264 bail!("rescue commit failed: {}", out.stderr);
265 }
266 Ok(true)
267}
268
269pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
274 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
275 if out.ok() && out.stdout.trim() == "true" {
276 return Ok(false);
277 }
278 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
279 Ok(true)
280}
281
282pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
284 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
285 Ok(())
286}
287
288struct WorktreeConfigRef {
291 count: usize,
293 we_enabled: bool,
299}
300
301static WORKTREE_CONFIG: std::sync::LazyLock<
308 std::sync::Mutex<
309 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
310 >,
311> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
312
313fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
315 let mut map = WORKTREE_CONFIG
316 .lock()
317 .unwrap_or_else(std::sync::PoisonError::into_inner);
318 map.entry(repo.to_path_buf())
319 .or_insert_with(|| {
320 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
321 count: 0,
322 we_enabled: false,
323 }))
324 })
325 .clone()
326}
327
328pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
345 let slot = worktree_config_slot(repo);
346 let mut entry = slot.lock().await;
347 entry.count += 1;
348 if entry.count == 1 {
349 entry.we_enabled = enable_worktree_config(repo).await?;
350 }
351 Ok(())
352}
353
354pub async fn release_worktree_config(repo: &Path) -> Result<()> {
360 let slot = worktree_config_slot(repo);
361 let mut entry = slot.lock().await;
362 entry.count = entry.count.saturating_sub(1);
363 if entry.count == 0 && entry.we_enabled {
364 disable_worktree_config(repo).await?;
365 entry.we_enabled = false;
366 }
367 Ok(())
368}
369
370pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
376 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
377 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
378 .await
379 .map(|_| ())
380}
381
382pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
384 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
385 let path = worktree.join(git_dir);
386 if let Some(parent) = path.parent() {
387 tokio::fs::create_dir_all(parent).await.ok();
388 }
389 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
390 if body.lines().any(|l| l.trim() == pattern) {
391 return Ok(());
392 }
393 if !body.is_empty() && !body.ends_with('\n') {
394 body.push('\n');
395 }
396 body.push_str(pattern);
397 body.push('\n');
398 tokio::fs::write(&path, body)
399 .await
400 .with_context(|| format!("write {}", path.display()))?;
401 Ok(())
402}
403
404pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
410 git_raw(
411 repo,
412 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
413 )
414 .await
415}
416
417pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
428 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
429 if !staged.ok() {
430 return Ok(staged);
431 }
432 git_raw(repo, &["commit", "-m", message]).await
433}
434
435pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
445 git_raw(repo, &["merge", "--ff-only", branch]).await
446}
447
448pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
450 git_raw(repo, &["push", "-u", remote, branch]).await
451}
452
453pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
461 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
462}
463
464pub async fn rebase_branch_in_temp(
476 repo: &Path,
477 scratch: &Path,
478 branch: &str,
479 onto: &str,
480) -> Result<Option<String>> {
481 worktree_remove(repo, scratch).await.ok();
484 git_raw(
485 repo,
486 &[
487 "worktree",
488 "add",
489 "--force",
490 &scratch.to_string_lossy(),
491 branch,
492 ],
493 )
494 .await?;
495
496 let out = git_raw(scratch, &["rebase", onto]).await?;
497 if out.ok() {
498 worktree_remove(repo, scratch).await.ok();
499 return Ok(None);
500 }
501 git_raw(scratch, &["rebase", "--abort"]).await.ok();
503 let why = if out.stderr.trim().is_empty() {
504 out.stdout.trim().to_owned()
505 } else {
506 out.stderr.trim().to_owned()
507 };
508 worktree_remove(repo, scratch).await.ok();
509 Ok(Some(why))
510}
511
512pub async fn sync_to_head(worktree: &Path) -> Result<()> {
525 git(worktree, &["reset", "--hard", "HEAD"]).await?;
526 git(worktree, &["clean", "-fdx"]).await?;
527 Ok(())
528}
529
530pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
549 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
550 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
551}
552
553pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
555 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
556 .await
557 .is_ok_and(|o| o.ok())
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 async fn scratch() -> (tempfile::TempDir, PathBuf) {
565 let dir = tempfile::tempdir().unwrap();
566 let repo = dir.path().join("repo");
567 tokio::fs::create_dir_all(&repo).await.unwrap();
568 git(&repo, &["init", "-b", "main"]).await.unwrap();
569 git(&repo, &["config", "user.name", "test"]).await.unwrap();
570 git(&repo, &["config", "user.email", "test@example.com"])
571 .await
572 .unwrap();
573 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
574 git(&repo, &["add", "-A"]).await.unwrap();
575 git(&repo, &["commit", "-m", "init"]).await.unwrap();
576 (dir, repo)
577 }
578
579 #[tokio::test]
580 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
581 let (_g, repo) = scratch().await;
582
583 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
585 tokio::fs::write(repo.join("b.txt"), "side\n")
586 .await
587 .unwrap();
588 git(&repo, &["add", "-A"]).await.unwrap();
589 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
590
591 git(&repo, &["checkout", "main"]).await.unwrap();
594 tokio::fs::write(repo.join("c.txt"), "main\n")
595 .await
596 .unwrap();
597 git(&repo, &["add", "-A"]).await.unwrap();
598 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
599
600 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
601 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
602 .await
603 .unwrap();
604 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
605 assert_eq!(
606 commits_ahead(&repo, "main", "side").await.unwrap(),
607 1,
608 "one commit, replayed onto the new base"
609 );
610 assert!(
611 !scratch_tree.exists(),
612 "the throwaway worktree is not left behind"
613 );
614
615 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
617 tokio::fs::write(repo.join("a.txt"), "clash\n")
618 .await
619 .unwrap();
620 git(&repo, &["add", "-A"]).await.unwrap();
621 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
622 git(&repo, &["checkout", "main"]).await.unwrap();
623 tokio::fs::write(repo.join("a.txt"), "main edit\n")
624 .await
625 .unwrap();
626 git(&repo, &["add", "-A"]).await.unwrap();
627 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
628
629 let before = rev_parse(&repo, "clash").await.unwrap();
630 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
631 .await
632 .unwrap()
633 .expect("a same-line clash cannot be rebased silently");
634 assert!(
635 why.to_lowercase().contains("conflict"),
636 "the reason is what git said, which is what a person needs: {why}"
637 );
638 assert_eq!(
639 rev_parse(&repo, "clash").await.unwrap(),
640 before,
641 "a failed rebase leaves the branch exactly where it was"
642 );
643 assert!(!scratch_tree.exists(), "and cleans up after itself");
644 }
645
646 #[tokio::test]
647 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
648 let (_g, repo) = scratch().await;
649 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
650 for name in ["b.txt", "c.txt"] {
651 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
652 git(&repo, &["add", "-A"]).await.unwrap();
653 git(
654 &repo,
655 &["commit", "-m", "magi: candidate A (uncommitted work)"],
656 )
657 .await
658 .unwrap();
659 }
660 git(&repo, &["checkout", "main"]).await.unwrap();
661 let before = rev_parse(&repo, "main").await.unwrap();
662
663 let out = merge_squash(&repo, "side", "an explicit subject")
664 .await
665 .unwrap();
666 assert!(out.ok(), "{}", out.stderr);
667 assert_eq!(
668 commits_ahead(&repo, &before, "main").await.unwrap(),
669 1,
670 "squash adds exactly one commit onto the tip, not one per candidate commit"
671 );
672 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
673 assert_eq!(
674 subject, "an explicit subject",
675 "the candidate's own placeholder subject must not survive: {subject}"
676 );
677 }
678
679 #[tokio::test]
680 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
681 let (_g, repo) = scratch().await;
682 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
683 tokio::fs::write(repo.join("b.txt"), "side\n")
684 .await
685 .unwrap();
686 git(&repo, &["add", "-A"]).await.unwrap();
687 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
688 git(&repo, &["checkout", "main"]).await.unwrap();
689
690 let before = rev_parse(&repo, "side").await.unwrap();
691 let out = merge_ff_only(&repo, "side").await.unwrap();
692 assert!(out.ok(), "{}", out.stderr);
693 assert_eq!(
694 rev_parse(&repo, "main").await.unwrap(),
695 before,
696 "a fast-forward moves the base tip to the branch, no merge commit"
697 );
698 }
699
700 #[tokio::test]
701 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
702 let (_g, repo) = scratch().await;
703 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
704 tokio::fs::write(repo.join("b.txt"), "side\n")
705 .await
706 .unwrap();
707 git(&repo, &["add", "-A"]).await.unwrap();
708 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
709
710 git(&repo, &["checkout", "main"]).await.unwrap();
712 tokio::fs::write(repo.join("c.txt"), "main\n")
713 .await
714 .unwrap();
715 git(&repo, &["add", "-A"]).await.unwrap();
716 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
717
718 let before = rev_parse(&repo, "main").await.unwrap();
719 let out = merge_ff_only(&repo, "side").await.unwrap();
720 assert!(!out.ok(), "a divergent branch cannot fast-forward");
721 assert_eq!(
722 rev_parse(&repo, "main").await.unwrap(),
723 before,
724 "a refused fast-forward must not touch main"
725 );
726 }
727
728 #[tokio::test]
729 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
730 let (guard, repo) = scratch().await;
731
732 git(&repo, &["branch", "side"]).await.unwrap();
736 let side_wt = guard.path().join("side-wt");
737 git(
738 &repo,
739 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
740 )
741 .await
742 .unwrap();
743 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
744 .await
745 .unwrap();
746 git(&side_wt, &["add", "-A"]).await.unwrap();
747 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
748
749 git(&repo, &["checkout", "main"]).await.unwrap();
751 tokio::fs::write(repo.join("c.txt"), "main\n")
752 .await
753 .unwrap();
754 git(&repo, &["add", "-A"]).await.unwrap();
755 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
756
757 let scratch_tree = guard.path().join("rebase-scratch");
759 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
760 .await
761 .unwrap();
762 assert!(clean.is_none());
763
764 assert_eq!(
768 rev_parse(&side_wt, "HEAD").await.unwrap(),
769 rev_parse(&repo, "side").await.unwrap(),
770 "HEAD follows the moved ref"
771 );
772 assert!(
773 !side_wt.join("c.txt").exists(),
774 "stale until synced: main's new file has not reached this worktree's disk"
775 );
776
777 sync_to_head(&side_wt).await.unwrap();
778 assert!(side_wt.join("c.txt").is_file(), "synced now");
779 assert!(
780 side_wt.join("b.txt").is_file(),
781 "the worktree's own committed work survives the sync"
782 );
783 assert!(is_clean(&side_wt).await.unwrap());
784 }
785
786 #[tokio::test]
787 async fn clean_repo_reports_clean_then_dirty() {
788 let (_g, repo) = scratch().await;
789 assert!(is_clean(&repo).await.unwrap());
790 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
791 assert!(!is_clean(&repo).await.unwrap());
792 }
793
794 #[tokio::test]
795 async fn worktree_lifecycle_and_diff() {
796 let (guard, repo) = scratch().await;
797 let base = rev_parse(&repo, "HEAD").await.unwrap();
798 let wt = guard.path().join("wt-a");
799 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
800 .await
801 .unwrap();
802 tokio::fs::write(wt.join("b.txt"), "candidate\n")
803 .await
804 .unwrap();
805
806 assert!(commit_all(&wt, "candidate work").await.unwrap());
807 assert!(!commit_all(&wt, "nothing left").await.unwrap());
808
809 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
810 let patch = diff(&wt, &base, "HEAD").await.unwrap();
811 assert!(patch.contains("b.txt"), "patch was: {patch}");
812 assert_eq!(
813 changed_files(&wt, &base, "HEAD").await.unwrap(),
814 ["b.txt".to_owned()]
815 );
816
817 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
819 .await
820 .unwrap();
821 assert_eq!(author, "magi candidate <magi@localhost>");
822
823 assert!(worktree_remove(&repo, &wt).await.unwrap());
824 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
825 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
826 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
827 }
828
829 #[tokio::test]
830 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
831 let (guard, repo) = scratch().await;
832 let base = rev_parse(&repo, "HEAD").await.unwrap();
833 let wt = guard.path().join("wt-h");
834 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
835 .await
836 .unwrap();
837 let hooks = guard.path().join("hooks");
838 tokio::fs::create_dir_all(&hooks).await.unwrap();
839
840 assert!(enable_worktree_config(&repo).await.unwrap());
841 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
842
843 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
844 .await
845 .unwrap();
846 assert!(!in_wt.is_empty());
847 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
848 .await
849 .unwrap();
850 assert!(
851 !in_primary.ok(),
852 "primary worktree must keep its own hooks: {in_primary:?}"
853 );
854
855 disable_worktree_config(&repo).await.unwrap();
856 }
857
858 #[tokio::test]
859 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
860 let (_g, repo) = scratch().await;
861
862 acquire_worktree_config(&repo).await.unwrap();
865 acquire_worktree_config(&repo).await.unwrap();
866
867 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
868 .await
869 .unwrap();
870 assert_eq!(on, "true");
871
872 release_worktree_config(&repo).await.unwrap();
876 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
877 .await
878 .unwrap();
879 assert_eq!(
880 still_on, "true",
881 "a sibling run's release must not disable the setting for the one still working"
882 );
883
884 release_worktree_config(&repo).await.unwrap();
886 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
887 .await
888 .unwrap();
889 assert!(
890 !after.ok(),
891 "the last release must turn the setting back off: {after:?}"
892 );
893 }
894
895 #[tokio::test]
896 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
897 let (_g, repo) = scratch().await;
898 git(&repo, &["config", "extensions.worktreeConfig", "true"])
899 .await
900 .unwrap();
901
902 acquire_worktree_config(&repo).await.unwrap();
907 release_worktree_config(&repo).await.unwrap();
908
909 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
910 .await
911 .unwrap();
912 assert_eq!(still_on, "true");
913 }
914
915 #[tokio::test]
916 async fn local_exclude_is_idempotent() {
917 let (_g, repo) = scratch().await;
918 local_exclude(&repo, "/.magi/").await.unwrap();
919 local_exclude(&repo, "/.magi/").await.unwrap();
920 let path = repo.join(".git/info/exclude");
921 let body = tokio::fs::read_to_string(&path).await.unwrap();
922 assert_eq!(body.matches("/.magi/").count(), 1);
923 }
924}