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_holding(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
138 let listing = git(repo, &["worktree", "list", "--porcelain"]).await?;
139 Ok(parse_worktree_holder(&listing, branch))
140}
141
142fn parse_worktree_holder(listing: &str, branch: &str) -> Option<PathBuf> {
143 let want = format!("refs/heads/{branch}");
144 let mut path: Option<PathBuf> = None;
145 for line in listing.lines() {
146 if let Some(p) = line.strip_prefix("worktree ") {
147 path = Some(PathBuf::from(p));
148 } else if line.strip_prefix("branch ") == Some(want.as_str()) {
149 return path;
150 }
151 }
152 None
153}
154
155pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
158 let path_s = path.to_string_lossy().to_string();
159 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
160 if out.ok() {
161 return Ok(true);
162 }
163 git_raw(repo, &["worktree", "prune"]).await?;
165 Ok(false)
166}
167
168pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
172 let path_s = path.to_string_lossy().to_string();
173 Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
174}
175
176pub async fn remove_worktree_from_linked(dir: &Path) {
185 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
186 return;
187 };
188 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
189 return;
190 };
191 let admin = Path::new(admin);
194 let Some(common) = admin.parent().and_then(Path::parent) else {
195 return;
196 };
197 let common_s = common.to_string_lossy();
198 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
199}
200
201pub async fn worktree_prune(repo: &Path) -> Result<()> {
210 git(repo, &["worktree", "prune"]).await.map(|_| ())
211}
212
213pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
215 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
216}
217
218pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
220 let refname = format!("refs/heads/{branch}");
221 Ok(
222 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
223 .await?
224 .ok(),
225 )
226}
227
228pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
230 let range = format!("{base}...{head}");
231 git(
232 worktree,
233 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
234 )
235 .await
236}
237
238pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
240 let range = format!("{base}...{head}");
241 git(worktree, &["diff", "--no-color", "--stat", &range]).await
242}
243
244pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
246 let range = format!("{base}...{head}");
247 let out = git(worktree, &["diff", "--name-only", &range]).await?;
248 Ok(out.lines().map(str::to_owned).collect())
249}
250
251pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
253 let range = format!("{base}..{head}");
254 git(
255 worktree,
256 &["log", "--reverse", "--format=%s%n%b%n--", &range],
257 )
258 .await
259}
260
261pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
263 let range = format!("{base}..{head}");
264 let out = git(worktree, &["rev-list", "--count", &range]).await?;
265 Ok(out.trim().parse().unwrap_or(0))
266}
267
268pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
275 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
276 return Ok(false);
277 }
278 git(worktree, &["add", "-A"]).await?;
279 let out = git_raw(
280 worktree,
281 &[
282 "-c",
283 "user.name=magi candidate",
284 "-c",
285 "user.email=magi@localhost",
286 "commit",
287 "--no-verify",
288 "-m",
289 message,
290 ],
291 )
292 .await?;
293 if !out.ok() {
294 bail!("rescue commit failed: {}", out.stderr);
295 }
296 Ok(true)
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct Stray {
303 pub path: String,
305 pub manager: String,
307 pub kept_by: String,
310}
311
312#[derive(Debug, Default)]
314pub struct Rescue {
315 pub committed: bool,
317 pub withheld: Vec<Stray>,
319}
320
321fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
323 Some(match name {
324 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
325 "yarn.lock" => ("node", "yarn"),
326 "pnpm-lock.yaml" => ("node", "pnpm"),
327 "bun.lock" | "bun.lockb" => ("node", "bun"),
328 "poetry.lock" => ("python", "poetry"),
329 "uv.lock" => ("python", "uv"),
330 "Pipfile.lock" => ("python", "pipenv"),
331 "pdm.lock" => ("python", "pdm"),
332 "Cargo.lock" => ("rust", "cargo"),
333 _ => return None,
334 })
335}
336
337fn split_dir(path: &str) -> (&str, &str) {
338 path.rsplit_once('/').unwrap_or(("", path))
339}
340
341pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
349 let mut out = Vec::new();
350 for path in untracked {
351 let (dir, name) = split_dir(path);
352 let Some((eco, manager)) = lock_kind(name) else {
353 continue;
354 };
355 let beside = |other: &String| split_dir(other).0 == dir;
356 let kept_by = if manager == "cargo" {
357 let has_manifest = tracked
358 .iter()
359 .chain(untracked)
360 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
361 if has_manifest {
362 continue;
363 }
364 "no Cargo.toml in the directory".to_owned()
365 } else {
366 let Some(other) = tracked.iter().find(|p| {
367 beside(p)
368 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
369 }) else {
370 continue;
371 };
372 other.clone()
373 };
374 out.push(Stray {
375 path: path.clone(),
376 manager: manager.to_owned(),
377 kept_by,
378 });
379 }
380 out
381}
382
383async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
384 let out = git(worktree, args).await?;
385 Ok(out
386 .split('\0')
387 .filter(|s| !s.is_empty())
388 .map(str::to_owned)
389 .collect())
390}
391
392pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
398 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
399 return Ok(Rescue::default());
400 }
401 let untracked = nul_list(
402 worktree,
403 &["ls-files", "-z", "--others", "--exclude-standard"],
404 )
405 .await?;
406 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
407 let withheld = stray_lockfiles(&untracked, &tracked);
408
409 git(worktree, &["add", "-A"]).await?;
410 if !withheld.is_empty() {
411 let mut args = vec!["reset", "-q", "--"];
412 args.extend(withheld.iter().map(|s| s.path.as_str()));
413 git(worktree, &args).await?;
414 }
415 if git_raw(worktree, &["diff", "--cached", "--quiet"])
416 .await?
417 .ok()
418 {
419 return Ok(Rescue {
420 committed: false,
421 withheld,
422 });
423 }
424 let out = git_raw(
425 worktree,
426 &[
427 "-c",
428 "user.name=magi candidate",
429 "-c",
430 "user.email=magi@localhost",
431 "commit",
432 "--no-verify",
433 "-m",
434 message,
435 ],
436 )
437 .await?;
438 if !out.ok() {
439 bail!("rescue commit failed: {}", out.stderr);
440 }
441 Ok(Rescue {
442 committed: true,
443 withheld,
444 })
445}
446
447pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
452 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
453 if out.ok() && out.stdout.trim() == "true" {
454 return Ok(false);
455 }
456 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
457 Ok(true)
458}
459
460pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
462 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
463 Ok(())
464}
465
466struct WorktreeConfigRef {
469 count: usize,
471 we_enabled: bool,
477}
478
479static WORKTREE_CONFIG: std::sync::LazyLock<
486 std::sync::Mutex<
487 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
488 >,
489> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
490
491fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
493 let mut map = WORKTREE_CONFIG
494 .lock()
495 .unwrap_or_else(std::sync::PoisonError::into_inner);
496 map.entry(repo.to_path_buf())
497 .or_insert_with(|| {
498 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
499 count: 0,
500 we_enabled: false,
501 }))
502 })
503 .clone()
504}
505
506pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
523 let slot = worktree_config_slot(repo);
524 let mut entry = slot.lock().await;
525 entry.count += 1;
526 if entry.count == 1 {
527 entry.we_enabled = enable_worktree_config(repo).await?;
528 }
529 Ok(())
530}
531
532pub async fn release_worktree_config(repo: &Path) -> Result<()> {
538 let slot = worktree_config_slot(repo);
539 let mut entry = slot.lock().await;
540 entry.count = entry.count.saturating_sub(1);
541 if entry.count == 0 && entry.we_enabled {
542 disable_worktree_config(repo).await?;
543 entry.we_enabled = false;
544 }
545 Ok(())
546}
547
548pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
554 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
555 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
556 .await
557 .map(|_| ())
558}
559
560pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
562 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
563 let path = worktree.join(git_dir);
564 if let Some(parent) = path.parent() {
565 tokio::fs::create_dir_all(parent).await.ok();
566 }
567 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
568 if body.lines().any(|l| l.trim() == pattern) {
569 return Ok(());
570 }
571 if !body.is_empty() && !body.ends_with('\n') {
572 body.push('\n');
573 }
574 body.push_str(pattern);
575 body.push('\n');
576 tokio::fs::write(&path, body)
577 .await
578 .with_context(|| format!("write {}", path.display()))?;
579 Ok(())
580}
581
582pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
588 git_raw(
589 repo,
590 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
591 )
592 .await
593}
594
595pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
606 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
607 if !staged.ok() {
608 return Ok(staged);
609 }
610 git_raw(repo, &["commit", "-m", message]).await
611}
612
613pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
623 git_raw(repo, &["merge", "--ff-only", branch]).await
624}
625
626pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
628 git_raw(repo, &["push", "-u", remote, branch]).await
629}
630
631pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
639 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
640}
641
642pub async fn push_pinned(
651 repo: &Path,
652 remote: &str,
653 branch: &str,
654 expected: &str,
655) -> Result<GitOut> {
656 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
657 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
658 git_raw(repo, &["push", &lease, remote, &refspec]).await
659}
660
661pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
664 let out = git(repo, &["cherry", upstream, head]).await?;
665 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
666 for line in out.lines() {
667 if let Some(sha) = line.strip_prefix("+ ") {
668 unmatched.push(sha.trim().to_owned());
669 } else if let Some(sha) = line.strip_prefix("- ") {
670 matched.push(sha.trim().to_owned());
671 }
672 }
673 Ok((unmatched, matched))
674}
675
676pub async fn rebase_branch_in_temp(
688 repo: &Path,
689 scratch: &Path,
690 branch: &str,
691 onto: &str,
692) -> Result<Option<String>> {
693 worktree_remove(repo, scratch).await.ok();
696 git_raw(
697 repo,
698 &[
699 "worktree",
700 "add",
701 "--force",
702 &scratch.to_string_lossy(),
703 branch,
704 ],
705 )
706 .await?;
707
708 let out = git_raw(scratch, &["rebase", onto]).await?;
709 if out.ok() {
710 worktree_remove(repo, scratch).await.ok();
711 return Ok(None);
712 }
713 git_raw(scratch, &["rebase", "--abort"]).await.ok();
715 let why = if out.stderr.trim().is_empty() {
716 out.stdout.trim().to_owned()
717 } else {
718 out.stderr.trim().to_owned()
719 };
720 worktree_remove(repo, scratch).await.ok();
721 Ok(Some(why))
722}
723
724pub async fn sync_to_head(worktree: &Path) -> Result<()> {
737 git(worktree, &["reset", "--hard", "HEAD"]).await?;
738 git(worktree, &["clean", "-fdx"]).await?;
739 Ok(())
740}
741
742pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
761 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
762 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
763}
764
765pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
767 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
768 .await
769 .is_ok_and(|o| o.ok())
770}
771
772pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
777 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
778 match out.code {
779 Some(0) => Ok(true),
780 Some(1) => Ok(false),
781 _ => bail!(
782 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
783 out.code,
784 out.stderr
785 ),
786 }
787}
788
789pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
791 let spec = format!("{rev}^{{commit}}");
792 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
793 .await
794 .ok()?;
795 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
796}
797
798pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
800 let out = git(
801 repo,
802 &[
803 "branch",
804 "-a",
805 "--contains",
806 commit,
807 "--format=%(refname:short)",
808 ],
809 )
810 .await?;
811 Ok(out
812 .lines()
813 .map(str::trim)
814 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
815 .map(str::to_owned)
816 .collect())
817}
818
819pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
823 let out = git_raw(
824 worktree,
825 &[
826 "-c",
827 "user.name=magi candidate",
828 "-c",
829 "user.email=magi@localhost",
830 "cherry-pick",
831 "-x",
832 commit,
833 ],
834 )
835 .await?;
836 if out.ok() {
837 return Ok(());
838 }
839 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
840 bail!(
841 "cherry-pick of {commit} failed: {}",
842 if out.stderr.is_empty() {
843 out.stdout
844 } else {
845 out.stderr
846 }
847 )
848}
849
850pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
852 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
853}
854
855pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
857 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
858 .await
859 .is_ok_and(|o| o.ok())
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 #[test]
867 fn worktree_holder_is_read_from_the_porcelain_listing() {
868 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
869 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
870 worktree /wt/detached\nHEAD ccc\ndetached\n";
871 assert_eq!(
872 parse_worktree_holder(listing, "magi/f82f/A"),
873 Some(PathBuf::from("/wt/cand-A"))
874 );
875 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
876 assert_eq!(parse_worktree_holder(listing, "other"), None);
877 }
878
879 async fn scratch() -> (tempfile::TempDir, PathBuf) {
880 let dir = tempfile::tempdir().unwrap();
881 let repo = dir.path().join("repo");
882 tokio::fs::create_dir_all(&repo).await.unwrap();
883 git(&repo, &["init", "-b", "main"]).await.unwrap();
884 git(&repo, &["config", "user.name", "test"]).await.unwrap();
885 git(&repo, &["config", "user.email", "test@example.com"])
886 .await
887 .unwrap();
888 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
889 git(&repo, &["add", "-A"]).await.unwrap();
890 git(&repo, &["commit", "-m", "init"]).await.unwrap();
891 (dir, repo)
892 }
893
894 #[tokio::test]
895 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
896 let (_g, repo) = scratch().await;
897
898 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
900 tokio::fs::write(repo.join("b.txt"), "side\n")
901 .await
902 .unwrap();
903 git(&repo, &["add", "-A"]).await.unwrap();
904 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
905
906 git(&repo, &["checkout", "main"]).await.unwrap();
909 tokio::fs::write(repo.join("c.txt"), "main\n")
910 .await
911 .unwrap();
912 git(&repo, &["add", "-A"]).await.unwrap();
913 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
914
915 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
916 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
917 .await
918 .unwrap();
919 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
920 assert_eq!(
921 commits_ahead(&repo, "main", "side").await.unwrap(),
922 1,
923 "one commit, replayed onto the new base"
924 );
925 assert!(
926 !scratch_tree.exists(),
927 "the throwaway worktree is not left behind"
928 );
929
930 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
932 tokio::fs::write(repo.join("a.txt"), "clash\n")
933 .await
934 .unwrap();
935 git(&repo, &["add", "-A"]).await.unwrap();
936 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
937 git(&repo, &["checkout", "main"]).await.unwrap();
938 tokio::fs::write(repo.join("a.txt"), "main edit\n")
939 .await
940 .unwrap();
941 git(&repo, &["add", "-A"]).await.unwrap();
942 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
943
944 let before = rev_parse(&repo, "clash").await.unwrap();
945 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
946 .await
947 .unwrap()
948 .expect("a same-line clash cannot be rebased silently");
949 assert!(
950 why.to_lowercase().contains("conflict"),
951 "the reason is what git said, which is what a person needs: {why}"
952 );
953 assert_eq!(
954 rev_parse(&repo, "clash").await.unwrap(),
955 before,
956 "a failed rebase leaves the branch exactly where it was"
957 );
958 assert!(!scratch_tree.exists(), "and cleans up after itself");
959 }
960
961 #[tokio::test]
962 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
963 let (_g, repo) = scratch().await;
964 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
965 for name in ["b.txt", "c.txt"] {
966 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
967 git(&repo, &["add", "-A"]).await.unwrap();
968 git(
969 &repo,
970 &["commit", "-m", "magi: candidate A (uncommitted work)"],
971 )
972 .await
973 .unwrap();
974 }
975 git(&repo, &["checkout", "main"]).await.unwrap();
976 let before = rev_parse(&repo, "main").await.unwrap();
977
978 let out = merge_squash(&repo, "side", "an explicit subject")
979 .await
980 .unwrap();
981 assert!(out.ok(), "{}", out.stderr);
982 assert_eq!(
983 commits_ahead(&repo, &before, "main").await.unwrap(),
984 1,
985 "squash adds exactly one commit onto the tip, not one per candidate commit"
986 );
987 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
988 assert_eq!(
989 subject, "an explicit subject",
990 "the candidate's own placeholder subject must not survive: {subject}"
991 );
992 }
993
994 #[tokio::test]
995 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
996 let (_g, repo) = scratch().await;
997 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
998 tokio::fs::write(repo.join("b.txt"), "side\n")
999 .await
1000 .unwrap();
1001 git(&repo, &["add", "-A"]).await.unwrap();
1002 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1003 git(&repo, &["checkout", "main"]).await.unwrap();
1004
1005 let before = rev_parse(&repo, "side").await.unwrap();
1006 let out = merge_ff_only(&repo, "side").await.unwrap();
1007 assert!(out.ok(), "{}", out.stderr);
1008 assert_eq!(
1009 rev_parse(&repo, "main").await.unwrap(),
1010 before,
1011 "a fast-forward moves the base tip to the branch, no merge commit"
1012 );
1013 }
1014
1015 #[tokio::test]
1016 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1017 let (_g, repo) = scratch().await;
1018 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1019 tokio::fs::write(repo.join("b.txt"), "side\n")
1020 .await
1021 .unwrap();
1022 git(&repo, &["add", "-A"]).await.unwrap();
1023 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1024
1025 git(&repo, &["checkout", "main"]).await.unwrap();
1027 tokio::fs::write(repo.join("c.txt"), "main\n")
1028 .await
1029 .unwrap();
1030 git(&repo, &["add", "-A"]).await.unwrap();
1031 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1032
1033 let before = rev_parse(&repo, "main").await.unwrap();
1034 let out = merge_ff_only(&repo, "side").await.unwrap();
1035 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1036 assert_eq!(
1037 rev_parse(&repo, "main").await.unwrap(),
1038 before,
1039 "a refused fast-forward must not touch main"
1040 );
1041 }
1042
1043 #[tokio::test]
1044 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1045 let (guard, repo) = scratch().await;
1046
1047 git(&repo, &["branch", "side"]).await.unwrap();
1051 let side_wt = guard.path().join("side-wt");
1052 git(
1053 &repo,
1054 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1055 )
1056 .await
1057 .unwrap();
1058 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1059 .await
1060 .unwrap();
1061 git(&side_wt, &["add", "-A"]).await.unwrap();
1062 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1063
1064 git(&repo, &["checkout", "main"]).await.unwrap();
1066 tokio::fs::write(repo.join("c.txt"), "main\n")
1067 .await
1068 .unwrap();
1069 git(&repo, &["add", "-A"]).await.unwrap();
1070 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1071
1072 let scratch_tree = guard.path().join("rebase-scratch");
1074 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1075 .await
1076 .unwrap();
1077 assert!(clean.is_none());
1078
1079 assert_eq!(
1083 rev_parse(&side_wt, "HEAD").await.unwrap(),
1084 rev_parse(&repo, "side").await.unwrap(),
1085 "HEAD follows the moved ref"
1086 );
1087 assert!(
1088 !side_wt.join("c.txt").exists(),
1089 "stale until synced: main's new file has not reached this worktree's disk"
1090 );
1091
1092 sync_to_head(&side_wt).await.unwrap();
1093 assert!(side_wt.join("c.txt").is_file(), "synced now");
1094 assert!(
1095 side_wt.join("b.txt").is_file(),
1096 "the worktree's own committed work survives the sync"
1097 );
1098 assert!(is_clean(&side_wt).await.unwrap());
1099 }
1100
1101 #[tokio::test]
1102 async fn clean_repo_reports_clean_then_dirty() {
1103 let (_g, repo) = scratch().await;
1104 assert!(is_clean(&repo).await.unwrap());
1105 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1106 assert!(!is_clean(&repo).await.unwrap());
1107 }
1108
1109 async fn track(repo: &Path, name: &str, body: &str) {
1110 let p = repo.join(name);
1111 if let Some(d) = p.parent() {
1112 tokio::fs::create_dir_all(d).await.unwrap();
1113 }
1114 tokio::fs::write(&p, body).await.unwrap();
1115 git(repo, &["add", name]).await.unwrap();
1116 git(
1117 repo,
1118 &[
1119 "-c",
1120 "user.name=t",
1121 "-c",
1122 "user.email=t@localhost",
1123 "commit",
1124 "-q",
1125 "-m",
1126 "seed",
1127 ],
1128 )
1129 .await
1130 .unwrap();
1131 }
1132
1133 #[tokio::test]
1134 async fn rescue_withholds_a_foreign_lockfile() {
1135 let (_g, repo) = scratch().await;
1136 track(&repo, "web/bun.lock", "a\n").await;
1137 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1138 .await
1139 .unwrap();
1140 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1141 .await
1142 .unwrap();
1143
1144 let r = rescue_commit(&repo, "rescue").await.unwrap();
1145 assert!(r.committed);
1146 assert_eq!(
1147 r.withheld,
1148 [Stray {
1149 path: "web/pnpm-lock.yaml".to_owned(),
1150 manager: "pnpm".to_owned(),
1151 kept_by: "web/bun.lock".to_owned(),
1152 }]
1153 );
1154 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1155 .await
1156 .unwrap();
1157 assert!(files.contains("web/app.ts"), "{files}");
1158 assert!(!files.contains("pnpm-lock"), "{files}");
1159 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1160 }
1161
1162 #[tokio::test]
1163 async fn rescue_with_only_a_stray_commits_nothing() {
1164 let (_g, repo) = scratch().await;
1165 track(&repo, "bun.lock", "a\n").await;
1166 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1167 .await
1168 .unwrap();
1169 let r = rescue_commit(&repo, "rescue").await.unwrap();
1170 assert!(!r.committed);
1171 assert_eq!(r.withheld.len(), 1);
1172 }
1173
1174 #[tokio::test]
1175 async fn rescue_keeps_a_same_manager_lockfile_update() {
1176 let (_g, repo) = scratch().await;
1177 track(&repo, "bun.lock", "a\n").await;
1178 tokio::fs::write(repo.join("bun.lock"), "b\n")
1179 .await
1180 .unwrap();
1181 let r = rescue_commit(&repo, "rescue").await.unwrap();
1182 assert!(r.committed);
1183 assert!(r.withheld.is_empty());
1184 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1185 .await
1186 .unwrap();
1187 assert_eq!(files, "bun.lock");
1188 }
1189
1190 #[tokio::test]
1191 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1192 let (_g, repo) = scratch().await;
1193 track(&repo, "other/bun.lock", "a\n").await;
1194 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1195 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1196 .await
1197 .unwrap();
1198 let r = rescue_commit(&repo, "rescue").await.unwrap();
1199 assert!(r.committed);
1200 assert!(r.withheld.is_empty());
1201 }
1202
1203 #[test]
1204 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1205 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1206 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1207 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1208 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1209 assert_eq!(
1211 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1212 1
1213 );
1214 }
1215
1216 #[tokio::test]
1217 async fn worktree_lifecycle_and_diff() {
1218 let (guard, repo) = scratch().await;
1219 let base = rev_parse(&repo, "HEAD").await.unwrap();
1220 let wt = guard.path().join("wt-a");
1221 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1222 .await
1223 .unwrap();
1224 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1225 .await
1226 .unwrap();
1227
1228 assert!(commit_all(&wt, "candidate work").await.unwrap());
1229 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1230
1231 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1232 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1233 assert!(patch.contains("b.txt"), "patch was: {patch}");
1234 assert_eq!(
1235 changed_files(&wt, &base, "HEAD").await.unwrap(),
1236 ["b.txt".to_owned()]
1237 );
1238
1239 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1241 .await
1242 .unwrap();
1243 assert_eq!(author, "magi candidate <magi@localhost>");
1244
1245 assert!(worktree_remove(&repo, &wt).await.unwrap());
1246 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1247 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1248 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1249 }
1250
1251 #[tokio::test]
1252 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1253 let (guard, repo) = scratch().await;
1254 let base = rev_parse(&repo, "HEAD").await.unwrap();
1255 let wt = guard.path().join("wt-h");
1256 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1257 .await
1258 .unwrap();
1259 let hooks = guard.path().join("hooks");
1260 tokio::fs::create_dir_all(&hooks).await.unwrap();
1261
1262 assert!(enable_worktree_config(&repo).await.unwrap());
1263 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1264
1265 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1266 .await
1267 .unwrap();
1268 assert!(!in_wt.is_empty());
1269 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1270 .await
1271 .unwrap();
1272 assert!(
1273 !in_primary.ok(),
1274 "primary worktree must keep its own hooks: {in_primary:?}"
1275 );
1276
1277 disable_worktree_config(&repo).await.unwrap();
1278 }
1279
1280 #[tokio::test]
1281 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1282 let (_g, repo) = scratch().await;
1283
1284 acquire_worktree_config(&repo).await.unwrap();
1287 acquire_worktree_config(&repo).await.unwrap();
1288
1289 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1290 .await
1291 .unwrap();
1292 assert_eq!(on, "true");
1293
1294 release_worktree_config(&repo).await.unwrap();
1298 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1299 .await
1300 .unwrap();
1301 assert_eq!(
1302 still_on, "true",
1303 "a sibling run's release must not disable the setting for the one still working"
1304 );
1305
1306 release_worktree_config(&repo).await.unwrap();
1308 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1309 .await
1310 .unwrap();
1311 assert!(
1312 !after.ok(),
1313 "the last release must turn the setting back off: {after:?}"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1319 let (_g, repo) = scratch().await;
1320 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1321 .await
1322 .unwrap();
1323
1324 acquire_worktree_config(&repo).await.unwrap();
1329 release_worktree_config(&repo).await.unwrap();
1330
1331 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1332 .await
1333 .unwrap();
1334 assert_eq!(still_on, "true");
1335 }
1336
1337 #[tokio::test]
1338 async fn local_exclude_is_idempotent() {
1339 let (_g, repo) = scratch().await;
1340 local_exclude(&repo, "/.magi/").await.unwrap();
1341 local_exclude(&repo, "/.magi/").await.unwrap();
1342 let path = repo.join(".git/info/exclude");
1343 let body = tokio::fs::read_to_string(&path).await.unwrap();
1344 assert_eq!(body.matches("/.magi/").count(), 1);
1345 }
1346}