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> {
398 git_raw(
399 repo,
400 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
401 )
402 .await
403}
404
405pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
416 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
417 if !staged.ok() {
418 return Ok(staged);
419 }
420 git_raw(repo, &["commit", "-m", message]).await
421}
422
423pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
433 git_raw(repo, &["merge", "--ff-only", branch]).await
434}
435
436pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
438 git_raw(repo, &["push", "-u", remote, branch]).await
439}
440
441pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
449 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
450}
451
452pub async fn rebase_branch_in_temp(
464 repo: &Path,
465 scratch: &Path,
466 branch: &str,
467 onto: &str,
468) -> Result<Option<String>> {
469 worktree_remove(repo, scratch).await.ok();
472 git_raw(
473 repo,
474 &[
475 "worktree",
476 "add",
477 "--force",
478 &scratch.to_string_lossy(),
479 branch,
480 ],
481 )
482 .await?;
483
484 let out = git_raw(scratch, &["rebase", onto]).await?;
485 if out.ok() {
486 worktree_remove(repo, scratch).await.ok();
487 return Ok(None);
488 }
489 git_raw(scratch, &["rebase", "--abort"]).await.ok();
491 let why = if out.stderr.trim().is_empty() {
492 out.stdout.trim().to_owned()
493 } else {
494 out.stderr.trim().to_owned()
495 };
496 worktree_remove(repo, scratch).await.ok();
497 Ok(Some(why))
498}
499
500pub async fn sync_to_head(worktree: &Path) -> Result<()> {
513 git(worktree, &["reset", "--hard", "HEAD"]).await?;
514 git(worktree, &["clean", "-fdx"]).await?;
515 Ok(())
516}
517
518pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
537 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
538 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
539}
540
541pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
543 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
544 .await
545 .is_ok_and(|o| o.ok())
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 async fn scratch() -> (tempfile::TempDir, PathBuf) {
553 let dir = tempfile::tempdir().unwrap();
554 let repo = dir.path().join("repo");
555 tokio::fs::create_dir_all(&repo).await.unwrap();
556 git(&repo, &["init", "-b", "main"]).await.unwrap();
557 git(&repo, &["config", "user.name", "test"]).await.unwrap();
558 git(&repo, &["config", "user.email", "test@example.com"])
559 .await
560 .unwrap();
561 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
562 git(&repo, &["add", "-A"]).await.unwrap();
563 git(&repo, &["commit", "-m", "init"]).await.unwrap();
564 (dir, repo)
565 }
566
567 #[tokio::test]
568 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
569 let (_g, repo) = scratch().await;
570
571 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
573 tokio::fs::write(repo.join("b.txt"), "side\n")
574 .await
575 .unwrap();
576 git(&repo, &["add", "-A"]).await.unwrap();
577 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
578
579 git(&repo, &["checkout", "main"]).await.unwrap();
582 tokio::fs::write(repo.join("c.txt"), "main\n")
583 .await
584 .unwrap();
585 git(&repo, &["add", "-A"]).await.unwrap();
586 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
587
588 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
589 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
590 .await
591 .unwrap();
592 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
593 assert_eq!(
594 commits_ahead(&repo, "main", "side").await.unwrap(),
595 1,
596 "one commit, replayed onto the new base"
597 );
598 assert!(
599 !scratch_tree.exists(),
600 "the throwaway worktree is not left behind"
601 );
602
603 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
605 tokio::fs::write(repo.join("a.txt"), "clash\n")
606 .await
607 .unwrap();
608 git(&repo, &["add", "-A"]).await.unwrap();
609 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
610 git(&repo, &["checkout", "main"]).await.unwrap();
611 tokio::fs::write(repo.join("a.txt"), "main edit\n")
612 .await
613 .unwrap();
614 git(&repo, &["add", "-A"]).await.unwrap();
615 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
616
617 let before = rev_parse(&repo, "clash").await.unwrap();
618 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
619 .await
620 .unwrap()
621 .expect("a same-line clash cannot be rebased silently");
622 assert!(
623 why.to_lowercase().contains("conflict"),
624 "the reason is what git said, which is what a person needs: {why}"
625 );
626 assert_eq!(
627 rev_parse(&repo, "clash").await.unwrap(),
628 before,
629 "a failed rebase leaves the branch exactly where it was"
630 );
631 assert!(!scratch_tree.exists(), "and cleans up after itself");
632 }
633
634 #[tokio::test]
635 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
636 let (_g, repo) = scratch().await;
637 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
638 for name in ["b.txt", "c.txt"] {
639 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
640 git(&repo, &["add", "-A"]).await.unwrap();
641 git(
642 &repo,
643 &["commit", "-m", "magi: candidate A (uncommitted work)"],
644 )
645 .await
646 .unwrap();
647 }
648 git(&repo, &["checkout", "main"]).await.unwrap();
649 let before = rev_parse(&repo, "main").await.unwrap();
650
651 let out = merge_squash(&repo, "side", "an explicit subject")
652 .await
653 .unwrap();
654 assert!(out.ok(), "{}", out.stderr);
655 assert_eq!(
656 commits_ahead(&repo, &before, "main").await.unwrap(),
657 1,
658 "squash adds exactly one commit onto the tip, not one per candidate commit"
659 );
660 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
661 assert_eq!(
662 subject, "an explicit subject",
663 "the candidate's own placeholder subject must not survive: {subject}"
664 );
665 }
666
667 #[tokio::test]
668 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
669 let (_g, repo) = scratch().await;
670 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
671 tokio::fs::write(repo.join("b.txt"), "side\n")
672 .await
673 .unwrap();
674 git(&repo, &["add", "-A"]).await.unwrap();
675 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
676 git(&repo, &["checkout", "main"]).await.unwrap();
677
678 let before = rev_parse(&repo, "side").await.unwrap();
679 let out = merge_ff_only(&repo, "side").await.unwrap();
680 assert!(out.ok(), "{}", out.stderr);
681 assert_eq!(
682 rev_parse(&repo, "main").await.unwrap(),
683 before,
684 "a fast-forward moves the base tip to the branch, no merge commit"
685 );
686 }
687
688 #[tokio::test]
689 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
690 let (_g, repo) = scratch().await;
691 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
692 tokio::fs::write(repo.join("b.txt"), "side\n")
693 .await
694 .unwrap();
695 git(&repo, &["add", "-A"]).await.unwrap();
696 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
697
698 git(&repo, &["checkout", "main"]).await.unwrap();
700 tokio::fs::write(repo.join("c.txt"), "main\n")
701 .await
702 .unwrap();
703 git(&repo, &["add", "-A"]).await.unwrap();
704 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
705
706 let before = rev_parse(&repo, "main").await.unwrap();
707 let out = merge_ff_only(&repo, "side").await.unwrap();
708 assert!(!out.ok(), "a divergent branch cannot fast-forward");
709 assert_eq!(
710 rev_parse(&repo, "main").await.unwrap(),
711 before,
712 "a refused fast-forward must not touch main"
713 );
714 }
715
716 #[tokio::test]
717 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
718 let (guard, repo) = scratch().await;
719
720 git(&repo, &["branch", "side"]).await.unwrap();
724 let side_wt = guard.path().join("side-wt");
725 git(
726 &repo,
727 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
728 )
729 .await
730 .unwrap();
731 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
732 .await
733 .unwrap();
734 git(&side_wt, &["add", "-A"]).await.unwrap();
735 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
736
737 git(&repo, &["checkout", "main"]).await.unwrap();
739 tokio::fs::write(repo.join("c.txt"), "main\n")
740 .await
741 .unwrap();
742 git(&repo, &["add", "-A"]).await.unwrap();
743 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
744
745 let scratch_tree = guard.path().join("rebase-scratch");
747 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
748 .await
749 .unwrap();
750 assert!(clean.is_none());
751
752 assert_eq!(
756 rev_parse(&side_wt, "HEAD").await.unwrap(),
757 rev_parse(&repo, "side").await.unwrap(),
758 "HEAD follows the moved ref"
759 );
760 assert!(
761 !side_wt.join("c.txt").exists(),
762 "stale until synced: main's new file has not reached this worktree's disk"
763 );
764
765 sync_to_head(&side_wt).await.unwrap();
766 assert!(side_wt.join("c.txt").is_file(), "synced now");
767 assert!(
768 side_wt.join("b.txt").is_file(),
769 "the worktree's own committed work survives the sync"
770 );
771 assert!(is_clean(&side_wt).await.unwrap());
772 }
773
774 #[tokio::test]
775 async fn clean_repo_reports_clean_then_dirty() {
776 let (_g, repo) = scratch().await;
777 assert!(is_clean(&repo).await.unwrap());
778 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
779 assert!(!is_clean(&repo).await.unwrap());
780 }
781
782 #[tokio::test]
783 async fn worktree_lifecycle_and_diff() {
784 let (guard, repo) = scratch().await;
785 let base = rev_parse(&repo, "HEAD").await.unwrap();
786 let wt = guard.path().join("wt-a");
787 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
788 .await
789 .unwrap();
790 tokio::fs::write(wt.join("b.txt"), "candidate\n")
791 .await
792 .unwrap();
793
794 assert!(commit_all(&wt, "candidate work").await.unwrap());
795 assert!(!commit_all(&wt, "nothing left").await.unwrap());
796
797 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
798 let patch = diff(&wt, &base, "HEAD").await.unwrap();
799 assert!(patch.contains("b.txt"), "patch was: {patch}");
800 assert_eq!(
801 changed_files(&wt, &base, "HEAD").await.unwrap(),
802 ["b.txt".to_owned()]
803 );
804
805 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
807 .await
808 .unwrap();
809 assert_eq!(author, "magi candidate <magi@localhost>");
810
811 assert!(worktree_remove(&repo, &wt).await.unwrap());
812 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
813 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
814 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
815 }
816
817 #[tokio::test]
818 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
819 let (guard, repo) = scratch().await;
820 let base = rev_parse(&repo, "HEAD").await.unwrap();
821 let wt = guard.path().join("wt-h");
822 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
823 .await
824 .unwrap();
825 let hooks = guard.path().join("hooks");
826 tokio::fs::create_dir_all(&hooks).await.unwrap();
827
828 assert!(enable_worktree_config(&repo).await.unwrap());
829 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
830
831 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
832 .await
833 .unwrap();
834 assert!(!in_wt.is_empty());
835 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
836 .await
837 .unwrap();
838 assert!(
839 !in_primary.ok(),
840 "primary worktree must keep its own hooks: {in_primary:?}"
841 );
842
843 disable_worktree_config(&repo).await.unwrap();
844 }
845
846 #[tokio::test]
847 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
848 let (_g, repo) = scratch().await;
849
850 acquire_worktree_config(&repo).await.unwrap();
853 acquire_worktree_config(&repo).await.unwrap();
854
855 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
856 .await
857 .unwrap();
858 assert_eq!(on, "true");
859
860 release_worktree_config(&repo).await.unwrap();
864 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
865 .await
866 .unwrap();
867 assert_eq!(
868 still_on, "true",
869 "a sibling run's release must not disable the setting for the one still working"
870 );
871
872 release_worktree_config(&repo).await.unwrap();
874 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
875 .await
876 .unwrap();
877 assert!(
878 !after.ok(),
879 "the last release must turn the setting back off: {after:?}"
880 );
881 }
882
883 #[tokio::test]
884 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
885 let (_g, repo) = scratch().await;
886 git(&repo, &["config", "extensions.worktreeConfig", "true"])
887 .await
888 .unwrap();
889
890 acquire_worktree_config(&repo).await.unwrap();
895 release_worktree_config(&repo).await.unwrap();
896
897 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
898 .await
899 .unwrap();
900 assert_eq!(still_on, "true");
901 }
902
903 #[tokio::test]
904 async fn local_exclude_is_idempotent() {
905 let (_g, repo) = scratch().await;
906 local_exclude(&repo, "/.magi/").await.unwrap();
907 local_exclude(&repo, "/.magi/").await.unwrap();
908 let path = repo.join(".git/info/exclude");
909 let body = tokio::fs::read_to_string(&path).await.unwrap();
910 assert_eq!(body.matches("/.magi/").count(), 1);
911 }
912}