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 commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
270 let range = format!("{base}..{head}");
271 let out = git(
272 worktree,
273 &[
274 "log",
275 "--reverse",
276 "--no-color",
277 "--format=%s%x1f%b%x00",
278 &range,
279 ],
280 )
281 .await?;
282 Ok(out
283 .split('\0')
284 .filter_map(|rec| {
285 let rec = rec.trim_start_matches('\n');
286 if rec.trim().is_empty() {
287 return None;
288 }
289 let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
290 Some((subject.trim().to_owned(), body.trim().to_owned()))
291 })
292 .collect())
293}
294
295pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
297 let range = format!("{base}..{head}");
298 git(
299 worktree,
300 &["log", "--reverse", "--format=%s%n%b%n--", &range],
301 )
302 .await
303}
304
305pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
308 let range = format!("{base}..{head}");
309 let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
310 let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
311 parts.pop();
313 Ok(parts)
314}
315
316pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
318 let range = format!("{base}..{head}");
319 let out = git(worktree, &["rev-list", "--count", &range]).await?;
320 Ok(out.trim().parse().unwrap_or(0))
321}
322
323pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
330 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
331 return Ok(false);
332 }
333 git(worktree, &["add", "-A"]).await?;
334 let out = git_raw(
335 worktree,
336 &[
337 "-c",
338 "user.name=magi candidate",
339 "-c",
340 "user.email=magi@localhost",
341 "commit",
342 "--no-verify",
343 "-m",
344 message,
345 ],
346 )
347 .await?;
348 if !out.ok() {
349 bail!("rescue commit failed: {}", out.stderr);
350 }
351 Ok(true)
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct Stray {
358 pub path: String,
360 pub manager: String,
362 pub kept_by: String,
365}
366
367#[derive(Debug, Default)]
369pub struct Rescue {
370 pub committed: bool,
372 pub withheld: Vec<Stray>,
374}
375
376fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
378 Some(match name {
379 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
380 "yarn.lock" => ("node", "yarn"),
381 "pnpm-lock.yaml" => ("node", "pnpm"),
382 "bun.lock" | "bun.lockb" => ("node", "bun"),
383 "poetry.lock" => ("python", "poetry"),
384 "uv.lock" => ("python", "uv"),
385 "Pipfile.lock" => ("python", "pipenv"),
386 "pdm.lock" => ("python", "pdm"),
387 "Cargo.lock" => ("rust", "cargo"),
388 _ => return None,
389 })
390}
391
392fn split_dir(path: &str) -> (&str, &str) {
393 path.rsplit_once('/').unwrap_or(("", path))
394}
395
396pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
404 let mut out = Vec::new();
405 for path in untracked {
406 let (dir, name) = split_dir(path);
407 let Some((eco, manager)) = lock_kind(name) else {
408 continue;
409 };
410 let beside = |other: &String| split_dir(other).0 == dir;
411 let kept_by = if manager == "cargo" {
412 let has_manifest = tracked
413 .iter()
414 .chain(untracked)
415 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
416 if has_manifest {
417 continue;
418 }
419 "no Cargo.toml in the directory".to_owned()
420 } else {
421 let Some(other) = tracked.iter().find(|p| {
422 beside(p)
423 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
424 }) else {
425 continue;
426 };
427 other.clone()
428 };
429 out.push(Stray {
430 path: path.clone(),
431 manager: manager.to_owned(),
432 kept_by,
433 });
434 }
435 out
436}
437
438async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
439 let out = git(worktree, args).await?;
440 Ok(out
441 .split('\0')
442 .filter(|s| !s.is_empty())
443 .map(str::to_owned)
444 .collect())
445}
446
447pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
453 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
454 return Ok(Rescue::default());
455 }
456 let untracked = nul_list(
457 worktree,
458 &["ls-files", "-z", "--others", "--exclude-standard"],
459 )
460 .await?;
461 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
462 let withheld = stray_lockfiles(&untracked, &tracked);
463
464 git(worktree, &["add", "-A"]).await?;
465 if !withheld.is_empty() {
466 let mut args = vec!["reset", "-q", "--"];
467 args.extend(withheld.iter().map(|s| s.path.as_str()));
468 git(worktree, &args).await?;
469 }
470 if git_raw(worktree, &["diff", "--cached", "--quiet"])
471 .await?
472 .ok()
473 {
474 return Ok(Rescue {
475 committed: false,
476 withheld,
477 });
478 }
479 let out = git_raw(
480 worktree,
481 &[
482 "-c",
483 "user.name=magi candidate",
484 "-c",
485 "user.email=magi@localhost",
486 "commit",
487 "--no-verify",
488 "-m",
489 message,
490 ],
491 )
492 .await?;
493 if !out.ok() {
494 bail!("rescue commit failed: {}", out.stderr);
495 }
496 Ok(Rescue {
497 committed: true,
498 withheld,
499 })
500}
501
502pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
507 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
508 if out.ok() && out.stdout.trim() == "true" {
509 return Ok(false);
510 }
511 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
512 Ok(true)
513}
514
515pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
517 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
518 Ok(())
519}
520
521struct WorktreeConfigRef {
524 count: usize,
526 we_enabled: bool,
532}
533
534static WORKTREE_CONFIG: std::sync::LazyLock<
541 std::sync::Mutex<
542 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
543 >,
544> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
545
546fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
548 let mut map = WORKTREE_CONFIG
549 .lock()
550 .unwrap_or_else(std::sync::PoisonError::into_inner);
551 map.entry(repo.to_path_buf())
552 .or_insert_with(|| {
553 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
554 count: 0,
555 we_enabled: false,
556 }))
557 })
558 .clone()
559}
560
561pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
578 let slot = worktree_config_slot(repo);
579 let mut entry = slot.lock().await;
580 entry.count += 1;
581 if entry.count == 1 {
582 entry.we_enabled = enable_worktree_config(repo).await?;
583 }
584 Ok(())
585}
586
587pub async fn release_worktree_config(repo: &Path) -> Result<()> {
593 let slot = worktree_config_slot(repo);
594 let mut entry = slot.lock().await;
595 entry.count = entry.count.saturating_sub(1);
596 if entry.count == 0 && entry.we_enabled {
597 disable_worktree_config(repo).await?;
598 entry.we_enabled = false;
599 }
600 Ok(())
601}
602
603pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
609 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
610 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
611 .await
612 .map(|_| ())
613}
614
615pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
617 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
618 let path = worktree.join(git_dir);
619 if let Some(parent) = path.parent() {
620 tokio::fs::create_dir_all(parent).await.ok();
621 }
622 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
623 if body.lines().any(|l| l.trim() == pattern) {
624 return Ok(());
625 }
626 if !body.is_empty() && !body.ends_with('\n') {
627 body.push('\n');
628 }
629 body.push_str(pattern);
630 body.push('\n');
631 tokio::fs::write(&path, body)
632 .await
633 .with_context(|| format!("write {}", path.display()))?;
634 Ok(())
635}
636
637pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
643 git_raw(
644 repo,
645 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
646 )
647 .await
648}
649
650pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
661 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
662 if !staged.ok() {
663 return Ok(staged);
664 }
665 git_raw(repo, &["commit", "-m", message]).await
666}
667
668pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
678 git_raw(repo, &["merge", "--ff-only", branch]).await
679}
680
681pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
683 git_raw(repo, &["push", "-u", remote, branch]).await
684}
685
686pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
694 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
695}
696
697pub async fn push_pinned(
706 repo: &Path,
707 remote: &str,
708 branch: &str,
709 expected: &str,
710) -> Result<GitOut> {
711 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
712 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
713 git_raw(repo, &["push", &lease, remote, &refspec]).await
714}
715
716pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
719 let out = git(repo, &["cherry", upstream, head]).await?;
720 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
721 for line in out.lines() {
722 if let Some(sha) = line.strip_prefix("+ ") {
723 unmatched.push(sha.trim().to_owned());
724 } else if let Some(sha) = line.strip_prefix("- ") {
725 matched.push(sha.trim().to_owned());
726 }
727 }
728 Ok((unmatched, matched))
729}
730
731#[derive(Debug, Clone, PartialEq, Eq)]
733pub enum RebaseStart {
734 Applied,
737 Conflicted(String),
740 Failed(String),
743}
744
745pub async fn rebase_start(
760 repo: &Path,
761 scratch: &Path,
762 branch: &str,
763 onto: &str,
764) -> Result<RebaseStart> {
765 worktree_remove(repo, scratch).await.ok();
768 git_raw(
769 repo,
770 &[
771 "worktree",
772 "add",
773 "--force",
774 &scratch.to_string_lossy(),
775 branch,
776 ],
777 )
778 .await?;
779
780 let out = git_raw(scratch, &["rebase", onto]).await?;
781 if out.ok() {
782 worktree_remove(repo, scratch).await.ok();
783 return Ok(RebaseStart::Applied);
784 }
785 let why = if out.stderr.trim().is_empty() {
786 out.stdout.trim().to_owned()
787 } else {
788 out.stderr.trim().to_owned()
789 };
790 if rebase_in_progress(scratch).await {
791 return Ok(RebaseStart::Conflicted(why));
792 }
793 worktree_remove(repo, scratch).await.ok();
794 Ok(RebaseStart::Failed(why))
795}
796
797pub async fn rebase_in_progress(worktree: &Path) -> bool {
799 for name in ["rebase-merge", "rebase-apply"] {
800 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
801 continue;
802 };
803 let p = Path::new(p.trim());
804 let full = if p.is_absolute() {
805 p.to_path_buf()
806 } else {
807 worktree.join(p)
808 };
809 if full.exists() {
810 return true;
811 }
812 }
813 false
814}
815
816pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
818 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
819 Ok(out
820 .lines()
821 .map(str::trim)
822 .filter(|l| !l.is_empty())
823 .map(str::to_owned)
824 .collect())
825}
826
827pub async fn rebase_abort(repo: &Path, scratch: &Path) {
830 git_raw(scratch, &["rebase", "--abort"]).await.ok();
831 worktree_remove(repo, scratch).await.ok();
832}
833
834pub async fn rebase_branch_in_temp(
842 repo: &Path,
843 scratch: &Path,
844 branch: &str,
845 onto: &str,
846) -> Result<Option<String>> {
847 match rebase_start(repo, scratch, branch, onto).await? {
848 RebaseStart::Applied => Ok(None),
849 RebaseStart::Failed(why) => Ok(Some(why)),
850 RebaseStart::Conflicted(why) => {
851 rebase_abort(repo, scratch).await;
852 Ok(Some(why))
853 }
854 }
855}
856
857pub async fn sync_to_head(worktree: &Path) -> Result<()> {
870 git(worktree, &["reset", "--hard", "HEAD"]).await?;
871 git(worktree, &["clean", "-fdx"]).await?;
872 Ok(())
873}
874
875pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
894 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
895 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
896}
897
898pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
900 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
901 .await
902 .is_ok_and(|o| o.ok())
903}
904
905pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
910 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
911 match out.code {
912 Some(0) => Ok(true),
913 Some(1) => Ok(false),
914 _ => bail!(
915 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
916 out.code,
917 out.stderr
918 ),
919 }
920}
921
922pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
924 let spec = format!("{rev}^{{commit}}");
925 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
926 .await
927 .ok()?;
928 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
929}
930
931pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
933 let out = git(
934 repo,
935 &[
936 "branch",
937 "-a",
938 "--contains",
939 commit,
940 "--format=%(refname:short)",
941 ],
942 )
943 .await?;
944 Ok(out
945 .lines()
946 .map(str::trim)
947 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
948 .map(str::to_owned)
949 .collect())
950}
951
952pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
956 let out = git_raw(
957 worktree,
958 &[
959 "-c",
960 "user.name=magi candidate",
961 "-c",
962 "user.email=magi@localhost",
963 "cherry-pick",
964 "-x",
965 commit,
966 ],
967 )
968 .await?;
969 if out.ok() {
970 return Ok(());
971 }
972 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
973 bail!(
974 "cherry-pick of {commit} failed: {}",
975 if out.stderr.is_empty() {
976 out.stdout
977 } else {
978 out.stderr
979 }
980 )
981}
982
983pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
985 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
986}
987
988pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
990 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
991 .await
992 .is_ok_and(|o| o.ok())
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998
999 #[test]
1000 fn worktree_holder_is_read_from_the_porcelain_listing() {
1001 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1002 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1003 worktree /wt/detached\nHEAD ccc\ndetached\n";
1004 assert_eq!(
1005 parse_worktree_holder(listing, "magi/f82f/A"),
1006 Some(PathBuf::from("/wt/cand-A"))
1007 );
1008 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1009 assert_eq!(parse_worktree_holder(listing, "other"), None);
1010 }
1011
1012 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1013 let dir = tempfile::tempdir().unwrap();
1014 let repo = dir.path().join("repo");
1015 tokio::fs::create_dir_all(&repo).await.unwrap();
1016 git(&repo, &["init", "-b", "main"]).await.unwrap();
1017 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1018 git(&repo, &["config", "user.email", "test@example.com"])
1019 .await
1020 .unwrap();
1021 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1022 git(&repo, &["add", "-A"]).await.unwrap();
1023 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1024 (dir, repo)
1025 }
1026
1027 #[tokio::test]
1028 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1029 let (_g, repo) = scratch().await;
1030
1031 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1033 tokio::fs::write(repo.join("b.txt"), "side\n")
1034 .await
1035 .unwrap();
1036 git(&repo, &["add", "-A"]).await.unwrap();
1037 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1038
1039 git(&repo, &["checkout", "main"]).await.unwrap();
1042 tokio::fs::write(repo.join("c.txt"), "main\n")
1043 .await
1044 .unwrap();
1045 git(&repo, &["add", "-A"]).await.unwrap();
1046 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1047
1048 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1049 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1050 .await
1051 .unwrap();
1052 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1053 assert_eq!(
1054 commits_ahead(&repo, "main", "side").await.unwrap(),
1055 1,
1056 "one commit, replayed onto the new base"
1057 );
1058 assert!(
1059 !scratch_tree.exists(),
1060 "the throwaway worktree is not left behind"
1061 );
1062
1063 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1065 tokio::fs::write(repo.join("a.txt"), "clash\n")
1066 .await
1067 .unwrap();
1068 git(&repo, &["add", "-A"]).await.unwrap();
1069 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1070 git(&repo, &["checkout", "main"]).await.unwrap();
1071 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1072 .await
1073 .unwrap();
1074 git(&repo, &["add", "-A"]).await.unwrap();
1075 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1076
1077 let before = rev_parse(&repo, "clash").await.unwrap();
1078 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1079 .await
1080 .unwrap()
1081 .expect("a same-line clash cannot be rebased silently");
1082 assert!(
1083 why.to_lowercase().contains("conflict"),
1084 "the reason is what git said, which is what a person needs: {why}"
1085 );
1086 assert_eq!(
1087 rev_parse(&repo, "clash").await.unwrap(),
1088 before,
1089 "a failed rebase leaves the branch exactly where it was"
1090 );
1091 assert!(!scratch_tree.exists(), "and cleans up after itself");
1092 }
1093
1094 #[tokio::test]
1095 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1096 let (_g, repo) = scratch().await;
1097 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1098 for name in ["b.txt", "c.txt"] {
1099 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1100 git(&repo, &["add", "-A"]).await.unwrap();
1101 git(
1102 &repo,
1103 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1104 )
1105 .await
1106 .unwrap();
1107 }
1108 git(&repo, &["checkout", "main"]).await.unwrap();
1109 let before = rev_parse(&repo, "main").await.unwrap();
1110
1111 let out = merge_squash(&repo, "side", "an explicit subject")
1112 .await
1113 .unwrap();
1114 assert!(out.ok(), "{}", out.stderr);
1115 assert_eq!(
1116 commits_ahead(&repo, &before, "main").await.unwrap(),
1117 1,
1118 "squash adds exactly one commit onto the tip, not one per candidate commit"
1119 );
1120 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1121 assert_eq!(
1122 subject, "an explicit subject",
1123 "the candidate's own placeholder subject must not survive: {subject}"
1124 );
1125 }
1126
1127 #[tokio::test]
1128 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1129 let (_g, repo) = scratch().await;
1130 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1131 tokio::fs::write(repo.join("b.txt"), "side\n")
1132 .await
1133 .unwrap();
1134 git(&repo, &["add", "-A"]).await.unwrap();
1135 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1136 git(&repo, &["checkout", "main"]).await.unwrap();
1137
1138 let before = rev_parse(&repo, "side").await.unwrap();
1139 let out = merge_ff_only(&repo, "side").await.unwrap();
1140 assert!(out.ok(), "{}", out.stderr);
1141 assert_eq!(
1142 rev_parse(&repo, "main").await.unwrap(),
1143 before,
1144 "a fast-forward moves the base tip to the branch, no merge commit"
1145 );
1146 }
1147
1148 #[tokio::test]
1149 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1150 let (_g, repo) = scratch().await;
1151 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1152 tokio::fs::write(repo.join("b.txt"), "side\n")
1153 .await
1154 .unwrap();
1155 git(&repo, &["add", "-A"]).await.unwrap();
1156 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1157
1158 git(&repo, &["checkout", "main"]).await.unwrap();
1160 tokio::fs::write(repo.join("c.txt"), "main\n")
1161 .await
1162 .unwrap();
1163 git(&repo, &["add", "-A"]).await.unwrap();
1164 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1165
1166 let before = rev_parse(&repo, "main").await.unwrap();
1167 let out = merge_ff_only(&repo, "side").await.unwrap();
1168 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1169 assert_eq!(
1170 rev_parse(&repo, "main").await.unwrap(),
1171 before,
1172 "a refused fast-forward must not touch main"
1173 );
1174 }
1175
1176 #[tokio::test]
1177 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1178 let (guard, repo) = scratch().await;
1179
1180 git(&repo, &["branch", "side"]).await.unwrap();
1184 let side_wt = guard.path().join("side-wt");
1185 git(
1186 &repo,
1187 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1188 )
1189 .await
1190 .unwrap();
1191 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1192 .await
1193 .unwrap();
1194 git(&side_wt, &["add", "-A"]).await.unwrap();
1195 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1196
1197 git(&repo, &["checkout", "main"]).await.unwrap();
1199 tokio::fs::write(repo.join("c.txt"), "main\n")
1200 .await
1201 .unwrap();
1202 git(&repo, &["add", "-A"]).await.unwrap();
1203 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1204
1205 let scratch_tree = guard.path().join("rebase-scratch");
1207 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1208 .await
1209 .unwrap();
1210 assert!(clean.is_none());
1211
1212 assert_eq!(
1216 rev_parse(&side_wt, "HEAD").await.unwrap(),
1217 rev_parse(&repo, "side").await.unwrap(),
1218 "HEAD follows the moved ref"
1219 );
1220 assert!(
1221 !side_wt.join("c.txt").exists(),
1222 "stale until synced: main's new file has not reached this worktree's disk"
1223 );
1224
1225 sync_to_head(&side_wt).await.unwrap();
1226 assert!(side_wt.join("c.txt").is_file(), "synced now");
1227 assert!(
1228 side_wt.join("b.txt").is_file(),
1229 "the worktree's own committed work survives the sync"
1230 );
1231 assert!(is_clean(&side_wt).await.unwrap());
1232 }
1233
1234 #[tokio::test]
1235 async fn clean_repo_reports_clean_then_dirty() {
1236 let (_g, repo) = scratch().await;
1237 assert!(is_clean(&repo).await.unwrap());
1238 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1239 assert!(!is_clean(&repo).await.unwrap());
1240 }
1241
1242 async fn track(repo: &Path, name: &str, body: &str) {
1243 let p = repo.join(name);
1244 if let Some(d) = p.parent() {
1245 tokio::fs::create_dir_all(d).await.unwrap();
1246 }
1247 tokio::fs::write(&p, body).await.unwrap();
1248 git(repo, &["add", name]).await.unwrap();
1249 git(
1250 repo,
1251 &[
1252 "-c",
1253 "user.name=t",
1254 "-c",
1255 "user.email=t@localhost",
1256 "commit",
1257 "-q",
1258 "-m",
1259 "seed",
1260 ],
1261 )
1262 .await
1263 .unwrap();
1264 }
1265
1266 #[tokio::test]
1267 async fn rescue_withholds_a_foreign_lockfile() {
1268 let (_g, repo) = scratch().await;
1269 track(&repo, "web/bun.lock", "a\n").await;
1270 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1271 .await
1272 .unwrap();
1273 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1274 .await
1275 .unwrap();
1276
1277 let r = rescue_commit(&repo, "rescue").await.unwrap();
1278 assert!(r.committed);
1279 assert_eq!(
1280 r.withheld,
1281 [Stray {
1282 path: "web/pnpm-lock.yaml".to_owned(),
1283 manager: "pnpm".to_owned(),
1284 kept_by: "web/bun.lock".to_owned(),
1285 }]
1286 );
1287 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1288 .await
1289 .unwrap();
1290 assert!(files.contains("web/app.ts"), "{files}");
1291 assert!(!files.contains("pnpm-lock"), "{files}");
1292 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1293 }
1294
1295 #[tokio::test]
1296 async fn rescue_with_only_a_stray_commits_nothing() {
1297 let (_g, repo) = scratch().await;
1298 track(&repo, "bun.lock", "a\n").await;
1299 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1300 .await
1301 .unwrap();
1302 let r = rescue_commit(&repo, "rescue").await.unwrap();
1303 assert!(!r.committed);
1304 assert_eq!(r.withheld.len(), 1);
1305 }
1306
1307 #[tokio::test]
1308 async fn rescue_keeps_a_same_manager_lockfile_update() {
1309 let (_g, repo) = scratch().await;
1310 track(&repo, "bun.lock", "a\n").await;
1311 tokio::fs::write(repo.join("bun.lock"), "b\n")
1312 .await
1313 .unwrap();
1314 let r = rescue_commit(&repo, "rescue").await.unwrap();
1315 assert!(r.committed);
1316 assert!(r.withheld.is_empty());
1317 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1318 .await
1319 .unwrap();
1320 assert_eq!(files, "bun.lock");
1321 }
1322
1323 #[tokio::test]
1324 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1325 let (_g, repo) = scratch().await;
1326 track(&repo, "other/bun.lock", "a\n").await;
1327 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1328 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1329 .await
1330 .unwrap();
1331 let r = rescue_commit(&repo, "rescue").await.unwrap();
1332 assert!(r.committed);
1333 assert!(r.withheld.is_empty());
1334 }
1335
1336 #[test]
1337 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1338 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1339 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1340 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1341 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1342 assert_eq!(
1344 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1345 1
1346 );
1347 }
1348
1349 #[tokio::test]
1350 async fn worktree_lifecycle_and_diff() {
1351 let (guard, repo) = scratch().await;
1352 let base = rev_parse(&repo, "HEAD").await.unwrap();
1353 let wt = guard.path().join("wt-a");
1354 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1355 .await
1356 .unwrap();
1357 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1358 .await
1359 .unwrap();
1360
1361 assert!(commit_all(&wt, "candidate work").await.unwrap());
1362 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1363
1364 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1365 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1366 assert!(patch.contains("b.txt"), "patch was: {patch}");
1367 assert_eq!(
1368 changed_files(&wt, &base, "HEAD").await.unwrap(),
1369 ["b.txt".to_owned()]
1370 );
1371
1372 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1374 .await
1375 .unwrap();
1376 assert_eq!(author, "magi candidate <magi@localhost>");
1377
1378 assert!(worktree_remove(&repo, &wt).await.unwrap());
1379 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1380 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1381 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1382 }
1383
1384 #[tokio::test]
1385 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1386 let (guard, repo) = scratch().await;
1387 let base = rev_parse(&repo, "HEAD").await.unwrap();
1388 let wt = guard.path().join("wt-h");
1389 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1390 .await
1391 .unwrap();
1392 let hooks = guard.path().join("hooks");
1393 tokio::fs::create_dir_all(&hooks).await.unwrap();
1394
1395 assert!(enable_worktree_config(&repo).await.unwrap());
1396 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1397
1398 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1399 .await
1400 .unwrap();
1401 assert!(!in_wt.is_empty());
1402 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1403 .await
1404 .unwrap();
1405 assert!(
1406 !in_primary.ok(),
1407 "primary worktree must keep its own hooks: {in_primary:?}"
1408 );
1409
1410 disable_worktree_config(&repo).await.unwrap();
1411 }
1412
1413 #[tokio::test]
1414 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1415 let (_g, repo) = scratch().await;
1416
1417 acquire_worktree_config(&repo).await.unwrap();
1420 acquire_worktree_config(&repo).await.unwrap();
1421
1422 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1423 .await
1424 .unwrap();
1425 assert_eq!(on, "true");
1426
1427 release_worktree_config(&repo).await.unwrap();
1431 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1432 .await
1433 .unwrap();
1434 assert_eq!(
1435 still_on, "true",
1436 "a sibling run's release must not disable the setting for the one still working"
1437 );
1438
1439 release_worktree_config(&repo).await.unwrap();
1441 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1442 .await
1443 .unwrap();
1444 assert!(
1445 !after.ok(),
1446 "the last release must turn the setting back off: {after:?}"
1447 );
1448 }
1449
1450 #[tokio::test]
1451 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1452 let (_g, repo) = scratch().await;
1453 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1454 .await
1455 .unwrap();
1456
1457 acquire_worktree_config(&repo).await.unwrap();
1462 release_worktree_config(&repo).await.unwrap();
1463
1464 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1465 .await
1466 .unwrap();
1467 assert_eq!(still_on, "true");
1468 }
1469
1470 #[tokio::test]
1471 async fn local_exclude_is_idempotent() {
1472 let (_g, repo) = scratch().await;
1473 local_exclude(&repo, "/.magi/").await.unwrap();
1474 local_exclude(&repo, "/.magi/").await.unwrap();
1475 let path = repo.join(".git/info/exclude");
1476 let body = tokio::fs::read_to_string(&path).await.unwrap();
1477 assert_eq!(body.matches("/.magi/").count(), 1);
1478 }
1479}