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 remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
231 let refname = format!("refs/heads/{branch}");
232 let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
233 match out.code {
234 Some(0) => Ok(true),
235 Some(2) => Ok(false),
236 code => bail!(
237 "git ls-remote {remote} failed (exit {code:?}): {}",
238 out.stderr
239 ),
240 }
241}
242
243pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
245 let range = format!("{base}...{head}");
246 git(
247 worktree,
248 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
249 )
250 .await
251}
252
253pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
255 let range = format!("{base}...{head}");
256 git(worktree, &["diff", "--no-color", "--stat", &range]).await
257}
258
259pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
261 let range = format!("{base}...{head}");
262 let out = git(worktree, &["diff", "--name-only", &range]).await?;
263 Ok(out.lines().map(str::to_owned).collect())
264}
265
266pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
268 let range = format!("{base}..{head}");
269 git(
270 worktree,
271 &["log", "--reverse", "--format=%s%n%b%n--", &range],
272 )
273 .await
274}
275
276pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
278 let range = format!("{base}..{head}");
279 let out = git(worktree, &["rev-list", "--count", &range]).await?;
280 Ok(out.trim().parse().unwrap_or(0))
281}
282
283pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
290 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
291 return Ok(false);
292 }
293 git(worktree, &["add", "-A"]).await?;
294 let out = git_raw(
295 worktree,
296 &[
297 "-c",
298 "user.name=magi candidate",
299 "-c",
300 "user.email=magi@localhost",
301 "commit",
302 "--no-verify",
303 "-m",
304 message,
305 ],
306 )
307 .await?;
308 if !out.ok() {
309 bail!("rescue commit failed: {}", out.stderr);
310 }
311 Ok(true)
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct Stray {
318 pub path: String,
320 pub manager: String,
322 pub kept_by: String,
325}
326
327#[derive(Debug, Default)]
329pub struct Rescue {
330 pub committed: bool,
332 pub withheld: Vec<Stray>,
334}
335
336fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
338 Some(match name {
339 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
340 "yarn.lock" => ("node", "yarn"),
341 "pnpm-lock.yaml" => ("node", "pnpm"),
342 "bun.lock" | "bun.lockb" => ("node", "bun"),
343 "poetry.lock" => ("python", "poetry"),
344 "uv.lock" => ("python", "uv"),
345 "Pipfile.lock" => ("python", "pipenv"),
346 "pdm.lock" => ("python", "pdm"),
347 "Cargo.lock" => ("rust", "cargo"),
348 _ => return None,
349 })
350}
351
352fn split_dir(path: &str) -> (&str, &str) {
353 path.rsplit_once('/').unwrap_or(("", path))
354}
355
356pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
364 let mut out = Vec::new();
365 for path in untracked {
366 let (dir, name) = split_dir(path);
367 let Some((eco, manager)) = lock_kind(name) else {
368 continue;
369 };
370 let beside = |other: &String| split_dir(other).0 == dir;
371 let kept_by = if manager == "cargo" {
372 let has_manifest = tracked
373 .iter()
374 .chain(untracked)
375 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
376 if has_manifest {
377 continue;
378 }
379 "no Cargo.toml in the directory".to_owned()
380 } else {
381 let Some(other) = tracked.iter().find(|p| {
382 beside(p)
383 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
384 }) else {
385 continue;
386 };
387 other.clone()
388 };
389 out.push(Stray {
390 path: path.clone(),
391 manager: manager.to_owned(),
392 kept_by,
393 });
394 }
395 out
396}
397
398async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
399 let out = git(worktree, args).await?;
400 Ok(out
401 .split('\0')
402 .filter(|s| !s.is_empty())
403 .map(str::to_owned)
404 .collect())
405}
406
407pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
413 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
414 return Ok(Rescue::default());
415 }
416 let untracked = nul_list(
417 worktree,
418 &["ls-files", "-z", "--others", "--exclude-standard"],
419 )
420 .await?;
421 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
422 let withheld = stray_lockfiles(&untracked, &tracked);
423
424 git(worktree, &["add", "-A"]).await?;
425 if !withheld.is_empty() {
426 let mut args = vec!["reset", "-q", "--"];
427 args.extend(withheld.iter().map(|s| s.path.as_str()));
428 git(worktree, &args).await?;
429 }
430 if git_raw(worktree, &["diff", "--cached", "--quiet"])
431 .await?
432 .ok()
433 {
434 return Ok(Rescue {
435 committed: false,
436 withheld,
437 });
438 }
439 let out = git_raw(
440 worktree,
441 &[
442 "-c",
443 "user.name=magi candidate",
444 "-c",
445 "user.email=magi@localhost",
446 "commit",
447 "--no-verify",
448 "-m",
449 message,
450 ],
451 )
452 .await?;
453 if !out.ok() {
454 bail!("rescue commit failed: {}", out.stderr);
455 }
456 Ok(Rescue {
457 committed: true,
458 withheld,
459 })
460}
461
462pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
467 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
468 if out.ok() && out.stdout.trim() == "true" {
469 return Ok(false);
470 }
471 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
472 Ok(true)
473}
474
475pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
477 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
478 Ok(())
479}
480
481struct WorktreeConfigRef {
484 count: usize,
486 we_enabled: bool,
492}
493
494static WORKTREE_CONFIG: std::sync::LazyLock<
501 std::sync::Mutex<
502 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
503 >,
504> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
505
506fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
508 let mut map = WORKTREE_CONFIG
509 .lock()
510 .unwrap_or_else(std::sync::PoisonError::into_inner);
511 map.entry(repo.to_path_buf())
512 .or_insert_with(|| {
513 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
514 count: 0,
515 we_enabled: false,
516 }))
517 })
518 .clone()
519}
520
521pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
538 let slot = worktree_config_slot(repo);
539 let mut entry = slot.lock().await;
540 entry.count += 1;
541 if entry.count == 1 {
542 entry.we_enabled = enable_worktree_config(repo).await?;
543 }
544 Ok(())
545}
546
547pub async fn release_worktree_config(repo: &Path) -> Result<()> {
553 let slot = worktree_config_slot(repo);
554 let mut entry = slot.lock().await;
555 entry.count = entry.count.saturating_sub(1);
556 if entry.count == 0 && entry.we_enabled {
557 disable_worktree_config(repo).await?;
558 entry.we_enabled = false;
559 }
560 Ok(())
561}
562
563pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
569 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
570 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
571 .await
572 .map(|_| ())
573}
574
575pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
577 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
578 let path = worktree.join(git_dir);
579 if let Some(parent) = path.parent() {
580 tokio::fs::create_dir_all(parent).await.ok();
581 }
582 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
583 if body.lines().any(|l| l.trim() == pattern) {
584 return Ok(());
585 }
586 if !body.is_empty() && !body.ends_with('\n') {
587 body.push('\n');
588 }
589 body.push_str(pattern);
590 body.push('\n');
591 tokio::fs::write(&path, body)
592 .await
593 .with_context(|| format!("write {}", path.display()))?;
594 Ok(())
595}
596
597pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
603 git_raw(
604 repo,
605 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
606 )
607 .await
608}
609
610pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
621 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
622 if !staged.ok() {
623 return Ok(staged);
624 }
625 git_raw(repo, &["commit", "-m", message]).await
626}
627
628pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
638 git_raw(repo, &["merge", "--ff-only", branch]).await
639}
640
641pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
643 git_raw(repo, &["push", "-u", remote, branch]).await
644}
645
646pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
654 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
655}
656
657pub async fn push_pinned(
666 repo: &Path,
667 remote: &str,
668 branch: &str,
669 expected: &str,
670) -> Result<GitOut> {
671 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
672 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
673 git_raw(repo, &["push", &lease, remote, &refspec]).await
674}
675
676pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
679 let out = git(repo, &["cherry", upstream, head]).await?;
680 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
681 for line in out.lines() {
682 if let Some(sha) = line.strip_prefix("+ ") {
683 unmatched.push(sha.trim().to_owned());
684 } else if let Some(sha) = line.strip_prefix("- ") {
685 matched.push(sha.trim().to_owned());
686 }
687 }
688 Ok((unmatched, matched))
689}
690
691#[derive(Debug, Clone, PartialEq, Eq)]
693pub enum RebaseStart {
694 Applied,
697 Conflicted(String),
700 Failed(String),
703}
704
705pub async fn rebase_start(
720 repo: &Path,
721 scratch: &Path,
722 branch: &str,
723 onto: &str,
724) -> Result<RebaseStart> {
725 worktree_remove(repo, scratch).await.ok();
728 git_raw(
729 repo,
730 &[
731 "worktree",
732 "add",
733 "--force",
734 &scratch.to_string_lossy(),
735 branch,
736 ],
737 )
738 .await?;
739
740 let out = git_raw(scratch, &["rebase", onto]).await?;
741 if out.ok() {
742 worktree_remove(repo, scratch).await.ok();
743 return Ok(RebaseStart::Applied);
744 }
745 let why = if out.stderr.trim().is_empty() {
746 out.stdout.trim().to_owned()
747 } else {
748 out.stderr.trim().to_owned()
749 };
750 if rebase_in_progress(scratch).await {
751 return Ok(RebaseStart::Conflicted(why));
752 }
753 worktree_remove(repo, scratch).await.ok();
754 Ok(RebaseStart::Failed(why))
755}
756
757pub async fn rebase_in_progress(worktree: &Path) -> bool {
759 for name in ["rebase-merge", "rebase-apply"] {
760 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
761 continue;
762 };
763 let p = Path::new(p.trim());
764 let full = if p.is_absolute() {
765 p.to_path_buf()
766 } else {
767 worktree.join(p)
768 };
769 if full.exists() {
770 return true;
771 }
772 }
773 false
774}
775
776pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
778 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
779 Ok(out
780 .lines()
781 .map(str::trim)
782 .filter(|l| !l.is_empty())
783 .map(str::to_owned)
784 .collect())
785}
786
787pub async fn rebase_abort(repo: &Path, scratch: &Path) {
790 git_raw(scratch, &["rebase", "--abort"]).await.ok();
791 worktree_remove(repo, scratch).await.ok();
792}
793
794pub async fn rebase_branch_in_temp(
802 repo: &Path,
803 scratch: &Path,
804 branch: &str,
805 onto: &str,
806) -> Result<Option<String>> {
807 match rebase_start(repo, scratch, branch, onto).await? {
808 RebaseStart::Applied => Ok(None),
809 RebaseStart::Failed(why) => Ok(Some(why)),
810 RebaseStart::Conflicted(why) => {
811 rebase_abort(repo, scratch).await;
812 Ok(Some(why))
813 }
814 }
815}
816
817pub async fn sync_to_head(worktree: &Path) -> Result<()> {
830 git(worktree, &["reset", "--hard", "HEAD"]).await?;
831 git(worktree, &["clean", "-fdx"]).await?;
832 Ok(())
833}
834
835pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
854 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
855 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
856}
857
858pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
860 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
861 .await
862 .is_ok_and(|o| o.ok())
863}
864
865pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
870 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
871 match out.code {
872 Some(0) => Ok(true),
873 Some(1) => Ok(false),
874 _ => bail!(
875 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
876 out.code,
877 out.stderr
878 ),
879 }
880}
881
882pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
884 let spec = format!("{rev}^{{commit}}");
885 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
886 .await
887 .ok()?;
888 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
889}
890
891pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
893 let out = git(
894 repo,
895 &[
896 "branch",
897 "-a",
898 "--contains",
899 commit,
900 "--format=%(refname:short)",
901 ],
902 )
903 .await?;
904 Ok(out
905 .lines()
906 .map(str::trim)
907 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
908 .map(str::to_owned)
909 .collect())
910}
911
912pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
916 let out = git_raw(
917 worktree,
918 &[
919 "-c",
920 "user.name=magi candidate",
921 "-c",
922 "user.email=magi@localhost",
923 "cherry-pick",
924 "-x",
925 commit,
926 ],
927 )
928 .await?;
929 if out.ok() {
930 return Ok(());
931 }
932 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
933 bail!(
934 "cherry-pick of {commit} failed: {}",
935 if out.stderr.is_empty() {
936 out.stdout
937 } else {
938 out.stderr
939 }
940 )
941}
942
943pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
945 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
946}
947
948pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
950 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
951 .await
952 .is_ok_and(|o| o.ok())
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958
959 #[test]
960 fn worktree_holder_is_read_from_the_porcelain_listing() {
961 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
962 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
963 worktree /wt/detached\nHEAD ccc\ndetached\n";
964 assert_eq!(
965 parse_worktree_holder(listing, "magi/f82f/A"),
966 Some(PathBuf::from("/wt/cand-A"))
967 );
968 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
969 assert_eq!(parse_worktree_holder(listing, "other"), None);
970 }
971
972 async fn scratch() -> (tempfile::TempDir, PathBuf) {
973 let dir = tempfile::tempdir().unwrap();
974 let repo = dir.path().join("repo");
975 tokio::fs::create_dir_all(&repo).await.unwrap();
976 git(&repo, &["init", "-b", "main"]).await.unwrap();
977 git(&repo, &["config", "user.name", "test"]).await.unwrap();
978 git(&repo, &["config", "user.email", "test@example.com"])
979 .await
980 .unwrap();
981 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
982 git(&repo, &["add", "-A"]).await.unwrap();
983 git(&repo, &["commit", "-m", "init"]).await.unwrap();
984 (dir, repo)
985 }
986
987 #[tokio::test]
988 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
989 let (_g, repo) = scratch().await;
990
991 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
993 tokio::fs::write(repo.join("b.txt"), "side\n")
994 .await
995 .unwrap();
996 git(&repo, &["add", "-A"]).await.unwrap();
997 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
998
999 git(&repo, &["checkout", "main"]).await.unwrap();
1002 tokio::fs::write(repo.join("c.txt"), "main\n")
1003 .await
1004 .unwrap();
1005 git(&repo, &["add", "-A"]).await.unwrap();
1006 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1007
1008 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1009 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1010 .await
1011 .unwrap();
1012 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1013 assert_eq!(
1014 commits_ahead(&repo, "main", "side").await.unwrap(),
1015 1,
1016 "one commit, replayed onto the new base"
1017 );
1018 assert!(
1019 !scratch_tree.exists(),
1020 "the throwaway worktree is not left behind"
1021 );
1022
1023 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1025 tokio::fs::write(repo.join("a.txt"), "clash\n")
1026 .await
1027 .unwrap();
1028 git(&repo, &["add", "-A"]).await.unwrap();
1029 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1030 git(&repo, &["checkout", "main"]).await.unwrap();
1031 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1032 .await
1033 .unwrap();
1034 git(&repo, &["add", "-A"]).await.unwrap();
1035 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1036
1037 let before = rev_parse(&repo, "clash").await.unwrap();
1038 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1039 .await
1040 .unwrap()
1041 .expect("a same-line clash cannot be rebased silently");
1042 assert!(
1043 why.to_lowercase().contains("conflict"),
1044 "the reason is what git said, which is what a person needs: {why}"
1045 );
1046 assert_eq!(
1047 rev_parse(&repo, "clash").await.unwrap(),
1048 before,
1049 "a failed rebase leaves the branch exactly where it was"
1050 );
1051 assert!(!scratch_tree.exists(), "and cleans up after itself");
1052 }
1053
1054 #[tokio::test]
1055 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1056 let (_g, repo) = scratch().await;
1057 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1058 for name in ["b.txt", "c.txt"] {
1059 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1060 git(&repo, &["add", "-A"]).await.unwrap();
1061 git(
1062 &repo,
1063 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1064 )
1065 .await
1066 .unwrap();
1067 }
1068 git(&repo, &["checkout", "main"]).await.unwrap();
1069 let before = rev_parse(&repo, "main").await.unwrap();
1070
1071 let out = merge_squash(&repo, "side", "an explicit subject")
1072 .await
1073 .unwrap();
1074 assert!(out.ok(), "{}", out.stderr);
1075 assert_eq!(
1076 commits_ahead(&repo, &before, "main").await.unwrap(),
1077 1,
1078 "squash adds exactly one commit onto the tip, not one per candidate commit"
1079 );
1080 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1081 assert_eq!(
1082 subject, "an explicit subject",
1083 "the candidate's own placeholder subject must not survive: {subject}"
1084 );
1085 }
1086
1087 #[tokio::test]
1088 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1089 let (_g, repo) = scratch().await;
1090 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1091 tokio::fs::write(repo.join("b.txt"), "side\n")
1092 .await
1093 .unwrap();
1094 git(&repo, &["add", "-A"]).await.unwrap();
1095 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1096 git(&repo, &["checkout", "main"]).await.unwrap();
1097
1098 let before = rev_parse(&repo, "side").await.unwrap();
1099 let out = merge_ff_only(&repo, "side").await.unwrap();
1100 assert!(out.ok(), "{}", out.stderr);
1101 assert_eq!(
1102 rev_parse(&repo, "main").await.unwrap(),
1103 before,
1104 "a fast-forward moves the base tip to the branch, no merge commit"
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1110 let (_g, repo) = scratch().await;
1111 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1112 tokio::fs::write(repo.join("b.txt"), "side\n")
1113 .await
1114 .unwrap();
1115 git(&repo, &["add", "-A"]).await.unwrap();
1116 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1117
1118 git(&repo, &["checkout", "main"]).await.unwrap();
1120 tokio::fs::write(repo.join("c.txt"), "main\n")
1121 .await
1122 .unwrap();
1123 git(&repo, &["add", "-A"]).await.unwrap();
1124 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1125
1126 let before = rev_parse(&repo, "main").await.unwrap();
1127 let out = merge_ff_only(&repo, "side").await.unwrap();
1128 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1129 assert_eq!(
1130 rev_parse(&repo, "main").await.unwrap(),
1131 before,
1132 "a refused fast-forward must not touch main"
1133 );
1134 }
1135
1136 #[tokio::test]
1137 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1138 let (guard, repo) = scratch().await;
1139
1140 git(&repo, &["branch", "side"]).await.unwrap();
1144 let side_wt = guard.path().join("side-wt");
1145 git(
1146 &repo,
1147 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1148 )
1149 .await
1150 .unwrap();
1151 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1152 .await
1153 .unwrap();
1154 git(&side_wt, &["add", "-A"]).await.unwrap();
1155 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1156
1157 git(&repo, &["checkout", "main"]).await.unwrap();
1159 tokio::fs::write(repo.join("c.txt"), "main\n")
1160 .await
1161 .unwrap();
1162 git(&repo, &["add", "-A"]).await.unwrap();
1163 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1164
1165 let scratch_tree = guard.path().join("rebase-scratch");
1167 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1168 .await
1169 .unwrap();
1170 assert!(clean.is_none());
1171
1172 assert_eq!(
1176 rev_parse(&side_wt, "HEAD").await.unwrap(),
1177 rev_parse(&repo, "side").await.unwrap(),
1178 "HEAD follows the moved ref"
1179 );
1180 assert!(
1181 !side_wt.join("c.txt").exists(),
1182 "stale until synced: main's new file has not reached this worktree's disk"
1183 );
1184
1185 sync_to_head(&side_wt).await.unwrap();
1186 assert!(side_wt.join("c.txt").is_file(), "synced now");
1187 assert!(
1188 side_wt.join("b.txt").is_file(),
1189 "the worktree's own committed work survives the sync"
1190 );
1191 assert!(is_clean(&side_wt).await.unwrap());
1192 }
1193
1194 #[tokio::test]
1195 async fn clean_repo_reports_clean_then_dirty() {
1196 let (_g, repo) = scratch().await;
1197 assert!(is_clean(&repo).await.unwrap());
1198 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1199 assert!(!is_clean(&repo).await.unwrap());
1200 }
1201
1202 async fn track(repo: &Path, name: &str, body: &str) {
1203 let p = repo.join(name);
1204 if let Some(d) = p.parent() {
1205 tokio::fs::create_dir_all(d).await.unwrap();
1206 }
1207 tokio::fs::write(&p, body).await.unwrap();
1208 git(repo, &["add", name]).await.unwrap();
1209 git(
1210 repo,
1211 &[
1212 "-c",
1213 "user.name=t",
1214 "-c",
1215 "user.email=t@localhost",
1216 "commit",
1217 "-q",
1218 "-m",
1219 "seed",
1220 ],
1221 )
1222 .await
1223 .unwrap();
1224 }
1225
1226 #[tokio::test]
1227 async fn rescue_withholds_a_foreign_lockfile() {
1228 let (_g, repo) = scratch().await;
1229 track(&repo, "web/bun.lock", "a\n").await;
1230 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1231 .await
1232 .unwrap();
1233 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1234 .await
1235 .unwrap();
1236
1237 let r = rescue_commit(&repo, "rescue").await.unwrap();
1238 assert!(r.committed);
1239 assert_eq!(
1240 r.withheld,
1241 [Stray {
1242 path: "web/pnpm-lock.yaml".to_owned(),
1243 manager: "pnpm".to_owned(),
1244 kept_by: "web/bun.lock".to_owned(),
1245 }]
1246 );
1247 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1248 .await
1249 .unwrap();
1250 assert!(files.contains("web/app.ts"), "{files}");
1251 assert!(!files.contains("pnpm-lock"), "{files}");
1252 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1253 }
1254
1255 #[tokio::test]
1256 async fn rescue_with_only_a_stray_commits_nothing() {
1257 let (_g, repo) = scratch().await;
1258 track(&repo, "bun.lock", "a\n").await;
1259 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1260 .await
1261 .unwrap();
1262 let r = rescue_commit(&repo, "rescue").await.unwrap();
1263 assert!(!r.committed);
1264 assert_eq!(r.withheld.len(), 1);
1265 }
1266
1267 #[tokio::test]
1268 async fn rescue_keeps_a_same_manager_lockfile_update() {
1269 let (_g, repo) = scratch().await;
1270 track(&repo, "bun.lock", "a\n").await;
1271 tokio::fs::write(repo.join("bun.lock"), "b\n")
1272 .await
1273 .unwrap();
1274 let r = rescue_commit(&repo, "rescue").await.unwrap();
1275 assert!(r.committed);
1276 assert!(r.withheld.is_empty());
1277 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1278 .await
1279 .unwrap();
1280 assert_eq!(files, "bun.lock");
1281 }
1282
1283 #[tokio::test]
1284 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1285 let (_g, repo) = scratch().await;
1286 track(&repo, "other/bun.lock", "a\n").await;
1287 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1288 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1289 .await
1290 .unwrap();
1291 let r = rescue_commit(&repo, "rescue").await.unwrap();
1292 assert!(r.committed);
1293 assert!(r.withheld.is_empty());
1294 }
1295
1296 #[test]
1297 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1298 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1299 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1300 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1301 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1302 assert_eq!(
1304 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1305 1
1306 );
1307 }
1308
1309 #[tokio::test]
1310 async fn worktree_lifecycle_and_diff() {
1311 let (guard, repo) = scratch().await;
1312 let base = rev_parse(&repo, "HEAD").await.unwrap();
1313 let wt = guard.path().join("wt-a");
1314 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1315 .await
1316 .unwrap();
1317 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1318 .await
1319 .unwrap();
1320
1321 assert!(commit_all(&wt, "candidate work").await.unwrap());
1322 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1323
1324 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1325 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1326 assert!(patch.contains("b.txt"), "patch was: {patch}");
1327 assert_eq!(
1328 changed_files(&wt, &base, "HEAD").await.unwrap(),
1329 ["b.txt".to_owned()]
1330 );
1331
1332 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1334 .await
1335 .unwrap();
1336 assert_eq!(author, "magi candidate <magi@localhost>");
1337
1338 assert!(worktree_remove(&repo, &wt).await.unwrap());
1339 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1340 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1341 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1342 }
1343
1344 #[tokio::test]
1345 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1346 let (guard, repo) = scratch().await;
1347 let base = rev_parse(&repo, "HEAD").await.unwrap();
1348 let wt = guard.path().join("wt-h");
1349 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1350 .await
1351 .unwrap();
1352 let hooks = guard.path().join("hooks");
1353 tokio::fs::create_dir_all(&hooks).await.unwrap();
1354
1355 assert!(enable_worktree_config(&repo).await.unwrap());
1356 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1357
1358 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1359 .await
1360 .unwrap();
1361 assert!(!in_wt.is_empty());
1362 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1363 .await
1364 .unwrap();
1365 assert!(
1366 !in_primary.ok(),
1367 "primary worktree must keep its own hooks: {in_primary:?}"
1368 );
1369
1370 disable_worktree_config(&repo).await.unwrap();
1371 }
1372
1373 #[tokio::test]
1374 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1375 let (_g, repo) = scratch().await;
1376
1377 acquire_worktree_config(&repo).await.unwrap();
1380 acquire_worktree_config(&repo).await.unwrap();
1381
1382 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1383 .await
1384 .unwrap();
1385 assert_eq!(on, "true");
1386
1387 release_worktree_config(&repo).await.unwrap();
1391 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1392 .await
1393 .unwrap();
1394 assert_eq!(
1395 still_on, "true",
1396 "a sibling run's release must not disable the setting for the one still working"
1397 );
1398
1399 release_worktree_config(&repo).await.unwrap();
1401 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1402 .await
1403 .unwrap();
1404 assert!(
1405 !after.ok(),
1406 "the last release must turn the setting back off: {after:?}"
1407 );
1408 }
1409
1410 #[tokio::test]
1411 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1412 let (_g, repo) = scratch().await;
1413 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1414 .await
1415 .unwrap();
1416
1417 acquire_worktree_config(&repo).await.unwrap();
1422 release_worktree_config(&repo).await.unwrap();
1423
1424 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1425 .await
1426 .unwrap();
1427 assert_eq!(still_on, "true");
1428 }
1429
1430 #[tokio::test]
1431 async fn local_exclude_is_idempotent() {
1432 let (_g, repo) = scratch().await;
1433 local_exclude(&repo, "/.magi/").await.unwrap();
1434 local_exclude(&repo, "/.magi/").await.unwrap();
1435 let path = repo.join(".git/info/exclude");
1436 let body = tokio::fs::read_to_string(&path).await.unwrap();
1437 assert_eq!(body.matches("/.magi/").count(), 1);
1438 }
1439}