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
269#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct Stray {
273 pub path: String,
275 pub manager: String,
277 pub kept_by: String,
280}
281
282#[derive(Debug, Default)]
284pub struct Rescue {
285 pub committed: bool,
287 pub withheld: Vec<Stray>,
289}
290
291fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
293 Some(match name {
294 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
295 "yarn.lock" => ("node", "yarn"),
296 "pnpm-lock.yaml" => ("node", "pnpm"),
297 "bun.lock" | "bun.lockb" => ("node", "bun"),
298 "poetry.lock" => ("python", "poetry"),
299 "uv.lock" => ("python", "uv"),
300 "Pipfile.lock" => ("python", "pipenv"),
301 "pdm.lock" => ("python", "pdm"),
302 "Cargo.lock" => ("rust", "cargo"),
303 _ => return None,
304 })
305}
306
307fn split_dir(path: &str) -> (&str, &str) {
308 path.rsplit_once('/').unwrap_or(("", path))
309}
310
311pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
319 let mut out = Vec::new();
320 for path in untracked {
321 let (dir, name) = split_dir(path);
322 let Some((eco, manager)) = lock_kind(name) else {
323 continue;
324 };
325 let beside = |other: &String| split_dir(other).0 == dir;
326 let kept_by = if manager == "cargo" {
327 let has_manifest = tracked
328 .iter()
329 .chain(untracked)
330 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
331 if has_manifest {
332 continue;
333 }
334 "no Cargo.toml in the directory".to_owned()
335 } else {
336 let Some(other) = tracked.iter().find(|p| {
337 beside(p)
338 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
339 }) else {
340 continue;
341 };
342 other.clone()
343 };
344 out.push(Stray {
345 path: path.clone(),
346 manager: manager.to_owned(),
347 kept_by,
348 });
349 }
350 out
351}
352
353async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
354 let out = git(worktree, args).await?;
355 Ok(out
356 .split('\0')
357 .filter(|s| !s.is_empty())
358 .map(str::to_owned)
359 .collect())
360}
361
362pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
368 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
369 return Ok(Rescue::default());
370 }
371 let untracked = nul_list(
372 worktree,
373 &["ls-files", "-z", "--others", "--exclude-standard"],
374 )
375 .await?;
376 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
377 let withheld = stray_lockfiles(&untracked, &tracked);
378
379 git(worktree, &["add", "-A"]).await?;
380 if !withheld.is_empty() {
381 let mut args = vec!["reset", "-q", "--"];
382 args.extend(withheld.iter().map(|s| s.path.as_str()));
383 git(worktree, &args).await?;
384 }
385 if git_raw(worktree, &["diff", "--cached", "--quiet"])
386 .await?
387 .ok()
388 {
389 return Ok(Rescue {
390 committed: false,
391 withheld,
392 });
393 }
394 let out = git_raw(
395 worktree,
396 &[
397 "-c",
398 "user.name=magi candidate",
399 "-c",
400 "user.email=magi@localhost",
401 "commit",
402 "--no-verify",
403 "-m",
404 message,
405 ],
406 )
407 .await?;
408 if !out.ok() {
409 bail!("rescue commit failed: {}", out.stderr);
410 }
411 Ok(Rescue {
412 committed: true,
413 withheld,
414 })
415}
416
417pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
422 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
423 if out.ok() && out.stdout.trim() == "true" {
424 return Ok(false);
425 }
426 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
427 Ok(true)
428}
429
430pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
432 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
433 Ok(())
434}
435
436struct WorktreeConfigRef {
439 count: usize,
441 we_enabled: bool,
447}
448
449static WORKTREE_CONFIG: std::sync::LazyLock<
456 std::sync::Mutex<
457 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
458 >,
459> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
460
461fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
463 let mut map = WORKTREE_CONFIG
464 .lock()
465 .unwrap_or_else(std::sync::PoisonError::into_inner);
466 map.entry(repo.to_path_buf())
467 .or_insert_with(|| {
468 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
469 count: 0,
470 we_enabled: false,
471 }))
472 })
473 .clone()
474}
475
476pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
493 let slot = worktree_config_slot(repo);
494 let mut entry = slot.lock().await;
495 entry.count += 1;
496 if entry.count == 1 {
497 entry.we_enabled = enable_worktree_config(repo).await?;
498 }
499 Ok(())
500}
501
502pub async fn release_worktree_config(repo: &Path) -> Result<()> {
508 let slot = worktree_config_slot(repo);
509 let mut entry = slot.lock().await;
510 entry.count = entry.count.saturating_sub(1);
511 if entry.count == 0 && entry.we_enabled {
512 disable_worktree_config(repo).await?;
513 entry.we_enabled = false;
514 }
515 Ok(())
516}
517
518pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
524 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
525 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
526 .await
527 .map(|_| ())
528}
529
530pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
532 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
533 let path = worktree.join(git_dir);
534 if let Some(parent) = path.parent() {
535 tokio::fs::create_dir_all(parent).await.ok();
536 }
537 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
538 if body.lines().any(|l| l.trim() == pattern) {
539 return Ok(());
540 }
541 if !body.is_empty() && !body.ends_with('\n') {
542 body.push('\n');
543 }
544 body.push_str(pattern);
545 body.push('\n');
546 tokio::fs::write(&path, body)
547 .await
548 .with_context(|| format!("write {}", path.display()))?;
549 Ok(())
550}
551
552pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
558 git_raw(
559 repo,
560 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
561 )
562 .await
563}
564
565pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
576 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
577 if !staged.ok() {
578 return Ok(staged);
579 }
580 git_raw(repo, &["commit", "-m", message]).await
581}
582
583pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
593 git_raw(repo, &["merge", "--ff-only", branch]).await
594}
595
596pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
598 git_raw(repo, &["push", "-u", remote, branch]).await
599}
600
601pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
609 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
610}
611
612pub async fn rebase_branch_in_temp(
624 repo: &Path,
625 scratch: &Path,
626 branch: &str,
627 onto: &str,
628) -> Result<Option<String>> {
629 worktree_remove(repo, scratch).await.ok();
632 git_raw(
633 repo,
634 &[
635 "worktree",
636 "add",
637 "--force",
638 &scratch.to_string_lossy(),
639 branch,
640 ],
641 )
642 .await?;
643
644 let out = git_raw(scratch, &["rebase", onto]).await?;
645 if out.ok() {
646 worktree_remove(repo, scratch).await.ok();
647 return Ok(None);
648 }
649 git_raw(scratch, &["rebase", "--abort"]).await.ok();
651 let why = if out.stderr.trim().is_empty() {
652 out.stdout.trim().to_owned()
653 } else {
654 out.stderr.trim().to_owned()
655 };
656 worktree_remove(repo, scratch).await.ok();
657 Ok(Some(why))
658}
659
660pub async fn sync_to_head(worktree: &Path) -> Result<()> {
673 git(worktree, &["reset", "--hard", "HEAD"]).await?;
674 git(worktree, &["clean", "-fdx"]).await?;
675 Ok(())
676}
677
678pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
697 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
698 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
699}
700
701pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
703 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
704 .await
705 .is_ok_and(|o| o.ok())
706}
707
708pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
710 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
711}
712
713pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
715 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
716 .await
717 .is_ok_and(|o| o.ok())
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 async fn scratch() -> (tempfile::TempDir, PathBuf) {
725 let dir = tempfile::tempdir().unwrap();
726 let repo = dir.path().join("repo");
727 tokio::fs::create_dir_all(&repo).await.unwrap();
728 git(&repo, &["init", "-b", "main"]).await.unwrap();
729 git(&repo, &["config", "user.name", "test"]).await.unwrap();
730 git(&repo, &["config", "user.email", "test@example.com"])
731 .await
732 .unwrap();
733 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
734 git(&repo, &["add", "-A"]).await.unwrap();
735 git(&repo, &["commit", "-m", "init"]).await.unwrap();
736 (dir, repo)
737 }
738
739 #[tokio::test]
740 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
741 let (_g, repo) = scratch().await;
742
743 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
745 tokio::fs::write(repo.join("b.txt"), "side\n")
746 .await
747 .unwrap();
748 git(&repo, &["add", "-A"]).await.unwrap();
749 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
750
751 git(&repo, &["checkout", "main"]).await.unwrap();
754 tokio::fs::write(repo.join("c.txt"), "main\n")
755 .await
756 .unwrap();
757 git(&repo, &["add", "-A"]).await.unwrap();
758 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
759
760 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
761 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
762 .await
763 .unwrap();
764 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
765 assert_eq!(
766 commits_ahead(&repo, "main", "side").await.unwrap(),
767 1,
768 "one commit, replayed onto the new base"
769 );
770 assert!(
771 !scratch_tree.exists(),
772 "the throwaway worktree is not left behind"
773 );
774
775 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
777 tokio::fs::write(repo.join("a.txt"), "clash\n")
778 .await
779 .unwrap();
780 git(&repo, &["add", "-A"]).await.unwrap();
781 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
782 git(&repo, &["checkout", "main"]).await.unwrap();
783 tokio::fs::write(repo.join("a.txt"), "main edit\n")
784 .await
785 .unwrap();
786 git(&repo, &["add", "-A"]).await.unwrap();
787 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
788
789 let before = rev_parse(&repo, "clash").await.unwrap();
790 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
791 .await
792 .unwrap()
793 .expect("a same-line clash cannot be rebased silently");
794 assert!(
795 why.to_lowercase().contains("conflict"),
796 "the reason is what git said, which is what a person needs: {why}"
797 );
798 assert_eq!(
799 rev_parse(&repo, "clash").await.unwrap(),
800 before,
801 "a failed rebase leaves the branch exactly where it was"
802 );
803 assert!(!scratch_tree.exists(), "and cleans up after itself");
804 }
805
806 #[tokio::test]
807 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
808 let (_g, repo) = scratch().await;
809 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
810 for name in ["b.txt", "c.txt"] {
811 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
812 git(&repo, &["add", "-A"]).await.unwrap();
813 git(
814 &repo,
815 &["commit", "-m", "magi: candidate A (uncommitted work)"],
816 )
817 .await
818 .unwrap();
819 }
820 git(&repo, &["checkout", "main"]).await.unwrap();
821 let before = rev_parse(&repo, "main").await.unwrap();
822
823 let out = merge_squash(&repo, "side", "an explicit subject")
824 .await
825 .unwrap();
826 assert!(out.ok(), "{}", out.stderr);
827 assert_eq!(
828 commits_ahead(&repo, &before, "main").await.unwrap(),
829 1,
830 "squash adds exactly one commit onto the tip, not one per candidate commit"
831 );
832 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
833 assert_eq!(
834 subject, "an explicit subject",
835 "the candidate's own placeholder subject must not survive: {subject}"
836 );
837 }
838
839 #[tokio::test]
840 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
841 let (_g, repo) = scratch().await;
842 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
843 tokio::fs::write(repo.join("b.txt"), "side\n")
844 .await
845 .unwrap();
846 git(&repo, &["add", "-A"]).await.unwrap();
847 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
848 git(&repo, &["checkout", "main"]).await.unwrap();
849
850 let before = rev_parse(&repo, "side").await.unwrap();
851 let out = merge_ff_only(&repo, "side").await.unwrap();
852 assert!(out.ok(), "{}", out.stderr);
853 assert_eq!(
854 rev_parse(&repo, "main").await.unwrap(),
855 before,
856 "a fast-forward moves the base tip to the branch, no merge commit"
857 );
858 }
859
860 #[tokio::test]
861 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
862 let (_g, repo) = scratch().await;
863 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
864 tokio::fs::write(repo.join("b.txt"), "side\n")
865 .await
866 .unwrap();
867 git(&repo, &["add", "-A"]).await.unwrap();
868 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
869
870 git(&repo, &["checkout", "main"]).await.unwrap();
872 tokio::fs::write(repo.join("c.txt"), "main\n")
873 .await
874 .unwrap();
875 git(&repo, &["add", "-A"]).await.unwrap();
876 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
877
878 let before = rev_parse(&repo, "main").await.unwrap();
879 let out = merge_ff_only(&repo, "side").await.unwrap();
880 assert!(!out.ok(), "a divergent branch cannot fast-forward");
881 assert_eq!(
882 rev_parse(&repo, "main").await.unwrap(),
883 before,
884 "a refused fast-forward must not touch main"
885 );
886 }
887
888 #[tokio::test]
889 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
890 let (guard, repo) = scratch().await;
891
892 git(&repo, &["branch", "side"]).await.unwrap();
896 let side_wt = guard.path().join("side-wt");
897 git(
898 &repo,
899 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
900 )
901 .await
902 .unwrap();
903 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
904 .await
905 .unwrap();
906 git(&side_wt, &["add", "-A"]).await.unwrap();
907 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
908
909 git(&repo, &["checkout", "main"]).await.unwrap();
911 tokio::fs::write(repo.join("c.txt"), "main\n")
912 .await
913 .unwrap();
914 git(&repo, &["add", "-A"]).await.unwrap();
915 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
916
917 let scratch_tree = guard.path().join("rebase-scratch");
919 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
920 .await
921 .unwrap();
922 assert!(clean.is_none());
923
924 assert_eq!(
928 rev_parse(&side_wt, "HEAD").await.unwrap(),
929 rev_parse(&repo, "side").await.unwrap(),
930 "HEAD follows the moved ref"
931 );
932 assert!(
933 !side_wt.join("c.txt").exists(),
934 "stale until synced: main's new file has not reached this worktree's disk"
935 );
936
937 sync_to_head(&side_wt).await.unwrap();
938 assert!(side_wt.join("c.txt").is_file(), "synced now");
939 assert!(
940 side_wt.join("b.txt").is_file(),
941 "the worktree's own committed work survives the sync"
942 );
943 assert!(is_clean(&side_wt).await.unwrap());
944 }
945
946 #[tokio::test]
947 async fn clean_repo_reports_clean_then_dirty() {
948 let (_g, repo) = scratch().await;
949 assert!(is_clean(&repo).await.unwrap());
950 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
951 assert!(!is_clean(&repo).await.unwrap());
952 }
953
954 async fn track(repo: &Path, name: &str, body: &str) {
955 let p = repo.join(name);
956 if let Some(d) = p.parent() {
957 tokio::fs::create_dir_all(d).await.unwrap();
958 }
959 tokio::fs::write(&p, body).await.unwrap();
960 git(repo, &["add", name]).await.unwrap();
961 git(
962 repo,
963 &[
964 "-c",
965 "user.name=t",
966 "-c",
967 "user.email=t@localhost",
968 "commit",
969 "-q",
970 "-m",
971 "seed",
972 ],
973 )
974 .await
975 .unwrap();
976 }
977
978 #[tokio::test]
979 async fn rescue_withholds_a_foreign_lockfile() {
980 let (_g, repo) = scratch().await;
981 track(&repo, "web/bun.lock", "a\n").await;
982 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
983 .await
984 .unwrap();
985 tokio::fs::write(repo.join("web/app.ts"), "real\n")
986 .await
987 .unwrap();
988
989 let r = rescue_commit(&repo, "rescue").await.unwrap();
990 assert!(r.committed);
991 assert_eq!(
992 r.withheld,
993 [Stray {
994 path: "web/pnpm-lock.yaml".to_owned(),
995 manager: "pnpm".to_owned(),
996 kept_by: "web/bun.lock".to_owned(),
997 }]
998 );
999 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1000 .await
1001 .unwrap();
1002 assert!(files.contains("web/app.ts"), "{files}");
1003 assert!(!files.contains("pnpm-lock"), "{files}");
1004 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1005 }
1006
1007 #[tokio::test]
1008 async fn rescue_with_only_a_stray_commits_nothing() {
1009 let (_g, repo) = scratch().await;
1010 track(&repo, "bun.lock", "a\n").await;
1011 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1012 .await
1013 .unwrap();
1014 let r = rescue_commit(&repo, "rescue").await.unwrap();
1015 assert!(!r.committed);
1016 assert_eq!(r.withheld.len(), 1);
1017 }
1018
1019 #[tokio::test]
1020 async fn rescue_keeps_a_same_manager_lockfile_update() {
1021 let (_g, repo) = scratch().await;
1022 track(&repo, "bun.lock", "a\n").await;
1023 tokio::fs::write(repo.join("bun.lock"), "b\n")
1024 .await
1025 .unwrap();
1026 let r = rescue_commit(&repo, "rescue").await.unwrap();
1027 assert!(r.committed);
1028 assert!(r.withheld.is_empty());
1029 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1030 .await
1031 .unwrap();
1032 assert_eq!(files, "bun.lock");
1033 }
1034
1035 #[tokio::test]
1036 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1037 let (_g, repo) = scratch().await;
1038 track(&repo, "other/bun.lock", "a\n").await;
1039 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1040 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1041 .await
1042 .unwrap();
1043 let r = rescue_commit(&repo, "rescue").await.unwrap();
1044 assert!(r.committed);
1045 assert!(r.withheld.is_empty());
1046 }
1047
1048 #[test]
1049 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1050 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1051 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1052 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1053 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1054 assert_eq!(
1056 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1057 1
1058 );
1059 }
1060
1061 #[tokio::test]
1062 async fn worktree_lifecycle_and_diff() {
1063 let (guard, repo) = scratch().await;
1064 let base = rev_parse(&repo, "HEAD").await.unwrap();
1065 let wt = guard.path().join("wt-a");
1066 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1067 .await
1068 .unwrap();
1069 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1070 .await
1071 .unwrap();
1072
1073 assert!(commit_all(&wt, "candidate work").await.unwrap());
1074 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1075
1076 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1077 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1078 assert!(patch.contains("b.txt"), "patch was: {patch}");
1079 assert_eq!(
1080 changed_files(&wt, &base, "HEAD").await.unwrap(),
1081 ["b.txt".to_owned()]
1082 );
1083
1084 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1086 .await
1087 .unwrap();
1088 assert_eq!(author, "magi candidate <magi@localhost>");
1089
1090 assert!(worktree_remove(&repo, &wt).await.unwrap());
1091 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1092 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1093 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1094 }
1095
1096 #[tokio::test]
1097 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1098 let (guard, repo) = scratch().await;
1099 let base = rev_parse(&repo, "HEAD").await.unwrap();
1100 let wt = guard.path().join("wt-h");
1101 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1102 .await
1103 .unwrap();
1104 let hooks = guard.path().join("hooks");
1105 tokio::fs::create_dir_all(&hooks).await.unwrap();
1106
1107 assert!(enable_worktree_config(&repo).await.unwrap());
1108 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1109
1110 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1111 .await
1112 .unwrap();
1113 assert!(!in_wt.is_empty());
1114 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1115 .await
1116 .unwrap();
1117 assert!(
1118 !in_primary.ok(),
1119 "primary worktree must keep its own hooks: {in_primary:?}"
1120 );
1121
1122 disable_worktree_config(&repo).await.unwrap();
1123 }
1124
1125 #[tokio::test]
1126 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1127 let (_g, repo) = scratch().await;
1128
1129 acquire_worktree_config(&repo).await.unwrap();
1132 acquire_worktree_config(&repo).await.unwrap();
1133
1134 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1135 .await
1136 .unwrap();
1137 assert_eq!(on, "true");
1138
1139 release_worktree_config(&repo).await.unwrap();
1143 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1144 .await
1145 .unwrap();
1146 assert_eq!(
1147 still_on, "true",
1148 "a sibling run's release must not disable the setting for the one still working"
1149 );
1150
1151 release_worktree_config(&repo).await.unwrap();
1153 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1154 .await
1155 .unwrap();
1156 assert!(
1157 !after.ok(),
1158 "the last release must turn the setting back off: {after:?}"
1159 );
1160 }
1161
1162 #[tokio::test]
1163 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1164 let (_g, repo) = scratch().await;
1165 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1166 .await
1167 .unwrap();
1168
1169 acquire_worktree_config(&repo).await.unwrap();
1174 release_worktree_config(&repo).await.unwrap();
1175
1176 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1177 .await
1178 .unwrap();
1179 assert_eq!(still_on, "true");
1180 }
1181
1182 #[tokio::test]
1183 async fn local_exclude_is_idempotent() {
1184 let (_g, repo) = scratch().await;
1185 local_exclude(&repo, "/.magi/").await.unwrap();
1186 local_exclude(&repo, "/.magi/").await.unwrap();
1187 let path = repo.join(".git/info/exclude");
1188 let body = tokio::fs::read_to_string(&path).await.unwrap();
1189 assert_eq!(body.matches("/.magi/").count(), 1);
1190 }
1191}