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 fetch_origin(cwd: &Path, timeout: std::time::Duration) -> Result<()> {
65 let url = git(cwd, &["remote", "get-url", "origin"]).await?;
66 let fut = Command::new("git")
67 .args([
68 "fetch",
69 "--quiet",
70 "--no-tags",
71 "--no-recurse-submodules",
72 "--no-write-fetch-head",
73 "--",
74 url.as_str(),
75 "+refs/heads/*:refs/remotes/origin/*",
76 ])
77 .current_dir(cwd)
78 .quiet()
79 .env("GIT_TERMINAL_PROMPT", "0")
80 .stdin(Stdio::null())
81 .kill_on_drop(true)
82 .output();
83 let out = tokio::time::timeout(timeout, fut)
84 .await
85 .map_err(|_| anyhow::anyhow!("git fetch timed out after {}s", timeout.as_secs()))?
86 .context("spawn git fetch")?;
87 if !out.status.success() {
88 bail!(
89 "git fetch failed (exit {:?}): {}",
90 out.status.code(),
91 String::from_utf8_lossy(&out.stderr).trim_end()
92 );
93 }
94 Ok(())
95}
96
97pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
99 let out = git_raw(cwd, args).await?;
100 if !out.ok() {
101 bail!(
102 "git {} failed in {} (exit {:?}): {}",
103 args.join(" "),
104 cwd.display(),
105 out.code,
106 if out.stderr.is_empty() {
107 out.stdout.as_str()
108 } else {
109 out.stderr.as_str()
110 }
111 );
112 }
113 Ok(out.stdout)
114}
115
116pub async fn toplevel(path: &Path) -> Result<PathBuf> {
118 let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
119 Ok(PathBuf::from(out))
120}
121
122pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
124 git(repo, &["rev-parse", rev]).await
125}
126
127pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
129 let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
130 Ok(if out.ok() && !out.stdout.is_empty() {
131 Some(out.stdout)
132 } else {
133 None
134 })
135}
136
137pub async fn is_clean(repo: &Path) -> Result<bool> {
139 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
140}
141
142pub async fn status_porcelain(repo: &Path) -> Result<String> {
144 git(repo, &["status", "--porcelain"]).await
145}
146
147pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
149 if let Some(parent) = path.parent() {
150 tokio::fs::create_dir_all(parent).await.ok();
151 }
152 let path_s = path.to_string_lossy().to_string();
153 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
154 .await
155 .map(|_| ())
156}
157
158pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
160 if let Some(parent) = path.parent() {
161 tokio::fs::create_dir_all(parent).await.ok();
162 }
163 let path_s = path.to_string_lossy().to_string();
164 git(repo, &["worktree", "add", "--detach", &path_s, rev])
165 .await
166 .map(|_| ())
167}
168
169pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
171 git(worktree, &["checkout", "--detach", rev]).await?;
172 git(worktree, &["reset", "--hard", rev]).await?;
173 git(worktree, &["clean", "-fdx"]).await?;
174 Ok(())
175}
176
177pub async fn worktree_holding(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
182 let listing = git(repo, &["worktree", "list", "--porcelain"]).await?;
183 Ok(parse_worktree_holder(&listing, branch))
184}
185
186fn parse_worktree_holder(listing: &str, branch: &str) -> Option<PathBuf> {
187 let want = format!("refs/heads/{branch}");
188 let mut path: Option<PathBuf> = None;
189 for line in listing.lines() {
190 if let Some(p) = line.strip_prefix("worktree ") {
191 path = Some(PathBuf::from(p));
192 } else if line.strip_prefix("branch ") == Some(want.as_str()) {
193 return path;
194 }
195 }
196 None
197}
198
199pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
202 let path_s = path.to_string_lossy().to_string();
203 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
204 if out.ok() {
205 return Ok(true);
206 }
207 git_raw(repo, &["worktree", "prune"]).await?;
209 Ok(false)
210}
211
212pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
216 let path_s = path.to_string_lossy().to_string();
217 Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
218}
219
220pub async fn remove_worktree_from_linked(dir: &Path) {
229 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
230 return;
231 };
232 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
233 return;
234 };
235 let admin = Path::new(admin);
238 let Some(common) = admin.parent().and_then(Path::parent) else {
239 return;
240 };
241 let common_s = common.to_string_lossy();
242 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
243}
244
245pub async fn worktree_prune(repo: &Path) -> Result<()> {
254 git(repo, &["worktree", "prune"]).await.map(|_| ())
255}
256
257pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
259 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
260}
261
262pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
264 let refname = format!("refs/heads/{branch}");
265 Ok(
266 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
267 .await?
268 .ok(),
269 )
270}
271
272pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
275 let refname = format!("refs/heads/{branch}");
276 let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
277 match out.code {
278 Some(0) => Ok(true),
279 Some(2) => Ok(false),
280 code => bail!(
281 "git ls-remote {remote} failed (exit {code:?}): {}",
282 out.stderr
283 ),
284 }
285}
286
287pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
289 let range = format!("{base}...{head}");
290 git(
291 worktree,
292 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
293 )
294 .await
295}
296
297pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
299 let range = format!("{base}...{head}");
300 git(worktree, &["diff", "--no-color", "--stat", &range]).await
301}
302
303pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
305 let range = format!("{base}...{head}");
306 let out = git(worktree, &["diff", "--name-only", &range]).await?;
307 Ok(out.lines().map(str::to_owned).collect())
308}
309
310pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
314 let range = format!("{base}..{head}");
315 let out = git(
316 worktree,
317 &[
318 "log",
319 "--reverse",
320 "--no-color",
321 "--format=%s%x1f%b%x00",
322 &range,
323 ],
324 )
325 .await?;
326 Ok(out
327 .split('\0')
328 .filter_map(|rec| {
329 let rec = rec.trim_start_matches('\n');
330 if rec.trim().is_empty() {
331 return None;
332 }
333 let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
334 Some((subject.trim().to_owned(), body.trim().to_owned()))
335 })
336 .collect())
337}
338
339pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
341 let range = format!("{base}..{head}");
342 git(
343 worktree,
344 &["log", "--reverse", "--format=%s%n%b%n--", &range],
345 )
346 .await
347}
348
349pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
352 let range = format!("{base}..{head}");
353 let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
354 let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
355 parts.pop();
357 Ok(parts)
358}
359
360pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
362 let range = format!("{base}..{head}");
363 let out = git(worktree, &["rev-list", "--count", &range]).await?;
364 Ok(out.trim().parse().unwrap_or(0))
365}
366
367pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
374 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
375 return Ok(false);
376 }
377 git(worktree, &["add", "-A"]).await?;
378 let out = git_raw(
379 worktree,
380 &[
381 "-c",
382 "user.name=magi candidate",
383 "-c",
384 "user.email=magi@localhost",
385 "commit",
386 "--no-verify",
387 "-m",
388 message,
389 ],
390 )
391 .await?;
392 if !out.ok() {
393 bail!("rescue commit failed: {}", out.stderr);
394 }
395 Ok(true)
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct Stray {
402 pub path: String,
404 pub manager: String,
406 pub kept_by: String,
409}
410
411#[derive(Debug, Default)]
413pub struct Rescue {
414 pub committed: bool,
416 pub withheld: Vec<Stray>,
418}
419
420fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
422 Some(match name {
423 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
424 "yarn.lock" => ("node", "yarn"),
425 "pnpm-lock.yaml" => ("node", "pnpm"),
426 "bun.lock" | "bun.lockb" => ("node", "bun"),
427 "poetry.lock" => ("python", "poetry"),
428 "uv.lock" => ("python", "uv"),
429 "Pipfile.lock" => ("python", "pipenv"),
430 "pdm.lock" => ("python", "pdm"),
431 "Cargo.lock" => ("rust", "cargo"),
432 _ => return None,
433 })
434}
435
436fn split_dir(path: &str) -> (&str, &str) {
437 path.rsplit_once('/').unwrap_or(("", path))
438}
439
440pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
448 let mut out = Vec::new();
449 for path in untracked {
450 let (dir, name) = split_dir(path);
451 let Some((eco, manager)) = lock_kind(name) else {
452 continue;
453 };
454 let beside = |other: &String| split_dir(other).0 == dir;
455 let kept_by = if manager == "cargo" {
456 let has_manifest = tracked
457 .iter()
458 .chain(untracked)
459 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
460 if has_manifest {
461 continue;
462 }
463 "no Cargo.toml in the directory".to_owned()
464 } else {
465 let Some(other) = tracked.iter().find(|p| {
466 beside(p)
467 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
468 }) else {
469 continue;
470 };
471 other.clone()
472 };
473 out.push(Stray {
474 path: path.clone(),
475 manager: manager.to_owned(),
476 kept_by,
477 });
478 }
479 out
480}
481
482async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
483 let out = git(worktree, args).await?;
484 Ok(out
485 .split('\0')
486 .filter(|s| !s.is_empty())
487 .map(str::to_owned)
488 .collect())
489}
490
491pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
497 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
498 return Ok(Rescue::default());
499 }
500 let untracked = nul_list(
501 worktree,
502 &["ls-files", "-z", "--others", "--exclude-standard"],
503 )
504 .await?;
505 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
506 let withheld = stray_lockfiles(&untracked, &tracked);
507
508 git(worktree, &["add", "-A"]).await?;
509 if !withheld.is_empty() {
510 let mut args = vec!["reset", "-q", "--"];
511 args.extend(withheld.iter().map(|s| s.path.as_str()));
512 git(worktree, &args).await?;
513 }
514 if git_raw(worktree, &["diff", "--cached", "--quiet"])
515 .await?
516 .ok()
517 {
518 return Ok(Rescue {
519 committed: false,
520 withheld,
521 });
522 }
523 let out = git_raw(
524 worktree,
525 &[
526 "-c",
527 "user.name=magi candidate",
528 "-c",
529 "user.email=magi@localhost",
530 "commit",
531 "--no-verify",
532 "-m",
533 message,
534 ],
535 )
536 .await?;
537 if !out.ok() {
538 bail!("rescue commit failed: {}", out.stderr);
539 }
540 Ok(Rescue {
541 committed: true,
542 withheld,
543 })
544}
545
546pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
551 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
552 if out.ok() && out.stdout.trim() == "true" {
553 return Ok(false);
554 }
555 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
556 Ok(true)
557}
558
559pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
561 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
562 Ok(())
563}
564
565struct WorktreeConfigRef {
568 count: usize,
570 we_enabled: bool,
576}
577
578static WORKTREE_CONFIG: std::sync::LazyLock<
585 std::sync::Mutex<
586 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
587 >,
588> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
589
590fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
592 let mut map = WORKTREE_CONFIG
593 .lock()
594 .unwrap_or_else(std::sync::PoisonError::into_inner);
595 map.entry(repo.to_path_buf())
596 .or_insert_with(|| {
597 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
598 count: 0,
599 we_enabled: false,
600 }))
601 })
602 .clone()
603}
604
605pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
622 let slot = worktree_config_slot(repo);
623 let mut entry = slot.lock().await;
624 entry.count += 1;
625 if entry.count == 1 {
626 entry.we_enabled = enable_worktree_config(repo).await?;
627 }
628 Ok(())
629}
630
631pub async fn release_worktree_config(repo: &Path) -> Result<()> {
637 let slot = worktree_config_slot(repo);
638 let mut entry = slot.lock().await;
639 entry.count = entry.count.saturating_sub(1);
640 if entry.count == 0 && entry.we_enabled {
641 disable_worktree_config(repo).await?;
642 entry.we_enabled = false;
643 }
644 Ok(())
645}
646
647pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
653 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
654 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
655 .await
656 .map(|_| ())
657}
658
659pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
661 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
662 let path = worktree.join(git_dir);
663 if let Some(parent) = path.parent() {
664 tokio::fs::create_dir_all(parent).await.ok();
665 }
666 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
667 if body.lines().any(|l| l.trim() == pattern) {
668 return Ok(());
669 }
670 if !body.is_empty() && !body.ends_with('\n') {
671 body.push('\n');
672 }
673 body.push_str(pattern);
674 body.push('\n');
675 tokio::fs::write(&path, body)
676 .await
677 .with_context(|| format!("write {}", path.display()))?;
678 Ok(())
679}
680
681pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
687 git_raw(
688 repo,
689 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
690 )
691 .await
692}
693
694pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
705 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
706 if !staged.ok() {
707 return Ok(staged);
708 }
709 git_raw(repo, &["commit", "-m", message]).await
710}
711
712pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
722 git_raw(repo, &["merge", "--ff-only", branch]).await
723}
724
725pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
727 git_raw(repo, &["push", "-u", remote, branch]).await
728}
729
730pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
738 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
739}
740
741pub async fn push_pinned(
750 repo: &Path,
751 remote: &str,
752 branch: &str,
753 expected: &str,
754) -> Result<GitOut> {
755 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
756 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
757 git_raw(repo, &["push", &lease, remote, &refspec]).await
758}
759
760pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
763 let out = git(repo, &["cherry", upstream, head]).await?;
764 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
765 for line in out.lines() {
766 if let Some(sha) = line.strip_prefix("+ ") {
767 unmatched.push(sha.trim().to_owned());
768 } else if let Some(sha) = line.strip_prefix("- ") {
769 matched.push(sha.trim().to_owned());
770 }
771 }
772 Ok((unmatched, matched))
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
779pub struct CommitKey {
780 pub sha: String,
782 pub key: String,
784}
785
786pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
788 let out = git(
789 repo,
790 &[
791 "log",
792 "--no-merges",
793 "--reverse",
794 "--date=raw",
795 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
796 range,
797 ],
798 )
799 .await?;
800 Ok(out
801 .lines()
802 .filter_map(|l| l.split_once('\u{1f}'))
803 .map(|(sha, key)| CommitKey {
804 sha: sha.to_owned(),
805 key: key.to_owned(),
806 })
807 .collect())
808}
809
810#[derive(Debug, Clone, PartialEq, Eq)]
812pub enum RebaseStart {
813 Applied,
816 Conflicted(String),
819 Failed(String),
822}
823
824pub async fn rebase_start(
839 repo: &Path,
840 scratch: &Path,
841 branch: &str,
842 onto: &str,
843) -> Result<RebaseStart> {
844 worktree_remove(repo, scratch).await.ok();
847 git_raw(
848 repo,
849 &[
850 "worktree",
851 "add",
852 "--force",
853 &scratch.to_string_lossy(),
854 branch,
855 ],
856 )
857 .await?;
858
859 let out = git_raw(scratch, &["rebase", onto]).await?;
860 if out.ok() {
861 worktree_remove(repo, scratch).await.ok();
862 return Ok(RebaseStart::Applied);
863 }
864 let why = if out.stderr.trim().is_empty() {
865 out.stdout.trim().to_owned()
866 } else {
867 out.stderr.trim().to_owned()
868 };
869 if rebase_in_progress(scratch).await {
870 return Ok(RebaseStart::Conflicted(why));
871 }
872 worktree_remove(repo, scratch).await.ok();
873 Ok(RebaseStart::Failed(why))
874}
875
876pub async fn rebase_in_progress(worktree: &Path) -> bool {
878 for name in ["rebase-merge", "rebase-apply"] {
879 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
880 continue;
881 };
882 let p = Path::new(p.trim());
883 let full = if p.is_absolute() {
884 p.to_path_buf()
885 } else {
886 worktree.join(p)
887 };
888 if full.exists() {
889 return true;
890 }
891 }
892 false
893}
894
895pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
897 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
898 Ok(out
899 .lines()
900 .map(str::trim)
901 .filter(|l| !l.is_empty())
902 .map(str::to_owned)
903 .collect())
904}
905
906pub async fn rebase_abort(repo: &Path, scratch: &Path) {
909 git_raw(scratch, &["rebase", "--abort"]).await.ok();
910 worktree_remove(repo, scratch).await.ok();
911}
912
913pub async fn rebase_branch_in_temp(
921 repo: &Path,
922 scratch: &Path,
923 branch: &str,
924 onto: &str,
925) -> Result<Option<String>> {
926 match rebase_start(repo, scratch, branch, onto).await? {
927 RebaseStart::Applied => Ok(None),
928 RebaseStart::Failed(why) => Ok(Some(why)),
929 RebaseStart::Conflicted(why) => {
930 rebase_abort(repo, scratch).await;
931 Ok(Some(why))
932 }
933 }
934}
935
936pub async fn sync_to_head(worktree: &Path) -> Result<()> {
949 git(worktree, &["reset", "--hard", "HEAD"]).await?;
950 git(worktree, &["clean", "-fdx"]).await?;
951 Ok(())
952}
953
954pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
973 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
974 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
975}
976
977pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
979 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
980}
981
982pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
984 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
985 .await
986 .is_ok_and(|o| o.ok())
987}
988
989pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
994 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
995 match out.code {
996 Some(0) => Ok(true),
997 Some(1) => Ok(false),
998 _ => bail!(
999 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
1000 out.code,
1001 out.stderr
1002 ),
1003 }
1004}
1005
1006pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1008 let spec = format!("{rev}^{{commit}}");
1009 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1010 .await
1011 .ok()?;
1012 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1013}
1014
1015pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1017 let out = git(
1018 repo,
1019 &[
1020 "branch",
1021 "-a",
1022 "--contains",
1023 commit,
1024 "--format=%(refname:short)",
1025 ],
1026 )
1027 .await?;
1028 Ok(out
1029 .lines()
1030 .map(str::trim)
1031 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1032 .map(str::to_owned)
1033 .collect())
1034}
1035
1036pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1040 let out = git_raw(
1041 worktree,
1042 &[
1043 "-c",
1044 "user.name=magi candidate",
1045 "-c",
1046 "user.email=magi@localhost",
1047 "cherry-pick",
1048 "-x",
1049 commit,
1050 ],
1051 )
1052 .await?;
1053 if out.ok() {
1054 return Ok(());
1055 }
1056 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1057 bail!(
1058 "cherry-pick of {commit} failed: {}",
1059 if out.stderr.is_empty() {
1060 out.stdout
1061 } else {
1062 out.stderr
1063 }
1064 )
1065}
1066
1067pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1069 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1070}
1071
1072pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1074 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1075 .await
1076 .is_ok_and(|o| o.ok())
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082
1083 #[test]
1084 fn worktree_holder_is_read_from_the_porcelain_listing() {
1085 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1086 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1087 worktree /wt/detached\nHEAD ccc\ndetached\n";
1088 assert_eq!(
1089 parse_worktree_holder(listing, "magi/f82f/A"),
1090 Some(PathBuf::from("/wt/cand-A"))
1091 );
1092 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1093 assert_eq!(parse_worktree_holder(listing, "other"), None);
1094 }
1095
1096 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1097 let dir = tempfile::tempdir().unwrap();
1098 let repo = dir.path().join("repo");
1099 tokio::fs::create_dir_all(&repo).await.unwrap();
1100 git(&repo, &["init", "-b", "main"]).await.unwrap();
1101 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1102 git(&repo, &["config", "user.email", "test@example.com"])
1103 .await
1104 .unwrap();
1105 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1106 git(&repo, &["add", "-A"]).await.unwrap();
1107 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1108 (dir, repo)
1109 }
1110
1111 #[tokio::test]
1112 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1113 let (_g, repo) = scratch().await;
1114
1115 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1117 tokio::fs::write(repo.join("b.txt"), "side\n")
1118 .await
1119 .unwrap();
1120 git(&repo, &["add", "-A"]).await.unwrap();
1121 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1122
1123 git(&repo, &["checkout", "main"]).await.unwrap();
1126 tokio::fs::write(repo.join("c.txt"), "main\n")
1127 .await
1128 .unwrap();
1129 git(&repo, &["add", "-A"]).await.unwrap();
1130 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1131
1132 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1133 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1134 .await
1135 .unwrap();
1136 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1137 assert_eq!(
1138 commits_ahead(&repo, "main", "side").await.unwrap(),
1139 1,
1140 "one commit, replayed onto the new base"
1141 );
1142 assert!(
1143 !scratch_tree.exists(),
1144 "the throwaway worktree is not left behind"
1145 );
1146
1147 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1149 tokio::fs::write(repo.join("a.txt"), "clash\n")
1150 .await
1151 .unwrap();
1152 git(&repo, &["add", "-A"]).await.unwrap();
1153 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1154 git(&repo, &["checkout", "main"]).await.unwrap();
1155 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1156 .await
1157 .unwrap();
1158 git(&repo, &["add", "-A"]).await.unwrap();
1159 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1160
1161 let before = rev_parse(&repo, "clash").await.unwrap();
1162 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1163 .await
1164 .unwrap()
1165 .expect("a same-line clash cannot be rebased silently");
1166 assert!(
1167 why.to_lowercase().contains("conflict"),
1168 "the reason is what git said, which is what a person needs: {why}"
1169 );
1170 assert_eq!(
1171 rev_parse(&repo, "clash").await.unwrap(),
1172 before,
1173 "a failed rebase leaves the branch exactly where it was"
1174 );
1175 assert!(!scratch_tree.exists(), "and cleans up after itself");
1176 }
1177
1178 #[tokio::test]
1179 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1180 let (_g, repo) = scratch().await;
1181 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1182 for name in ["b.txt", "c.txt"] {
1183 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1184 git(&repo, &["add", "-A"]).await.unwrap();
1185 git(
1186 &repo,
1187 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1188 )
1189 .await
1190 .unwrap();
1191 }
1192 git(&repo, &["checkout", "main"]).await.unwrap();
1193 let before = rev_parse(&repo, "main").await.unwrap();
1194
1195 let out = merge_squash(&repo, "side", "an explicit subject")
1196 .await
1197 .unwrap();
1198 assert!(out.ok(), "{}", out.stderr);
1199 assert_eq!(
1200 commits_ahead(&repo, &before, "main").await.unwrap(),
1201 1,
1202 "squash adds exactly one commit onto the tip, not one per candidate commit"
1203 );
1204 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1205 assert_eq!(
1206 subject, "an explicit subject",
1207 "the candidate's own placeholder subject must not survive: {subject}"
1208 );
1209 }
1210
1211 #[tokio::test]
1212 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1213 let (_g, repo) = scratch().await;
1214 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1215 tokio::fs::write(repo.join("b.txt"), "side\n")
1216 .await
1217 .unwrap();
1218 git(&repo, &["add", "-A"]).await.unwrap();
1219 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1220 git(&repo, &["checkout", "main"]).await.unwrap();
1221
1222 let before = rev_parse(&repo, "side").await.unwrap();
1223 let out = merge_ff_only(&repo, "side").await.unwrap();
1224 assert!(out.ok(), "{}", out.stderr);
1225 assert_eq!(
1226 rev_parse(&repo, "main").await.unwrap(),
1227 before,
1228 "a fast-forward moves the base tip to the branch, no merge commit"
1229 );
1230 }
1231
1232 #[tokio::test]
1233 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1234 let (_g, repo) = scratch().await;
1235 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1236 tokio::fs::write(repo.join("b.txt"), "side\n")
1237 .await
1238 .unwrap();
1239 git(&repo, &["add", "-A"]).await.unwrap();
1240 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1241
1242 git(&repo, &["checkout", "main"]).await.unwrap();
1244 tokio::fs::write(repo.join("c.txt"), "main\n")
1245 .await
1246 .unwrap();
1247 git(&repo, &["add", "-A"]).await.unwrap();
1248 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1249
1250 let before = rev_parse(&repo, "main").await.unwrap();
1251 let out = merge_ff_only(&repo, "side").await.unwrap();
1252 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1253 assert_eq!(
1254 rev_parse(&repo, "main").await.unwrap(),
1255 before,
1256 "a refused fast-forward must not touch main"
1257 );
1258 }
1259
1260 #[tokio::test]
1261 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1262 let (guard, repo) = scratch().await;
1263
1264 git(&repo, &["branch", "side"]).await.unwrap();
1268 let side_wt = guard.path().join("side-wt");
1269 git(
1270 &repo,
1271 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1272 )
1273 .await
1274 .unwrap();
1275 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1276 .await
1277 .unwrap();
1278 git(&side_wt, &["add", "-A"]).await.unwrap();
1279 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1280
1281 git(&repo, &["checkout", "main"]).await.unwrap();
1283 tokio::fs::write(repo.join("c.txt"), "main\n")
1284 .await
1285 .unwrap();
1286 git(&repo, &["add", "-A"]).await.unwrap();
1287 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1288
1289 let scratch_tree = guard.path().join("rebase-scratch");
1291 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1292 .await
1293 .unwrap();
1294 assert!(clean.is_none());
1295
1296 assert_eq!(
1300 rev_parse(&side_wt, "HEAD").await.unwrap(),
1301 rev_parse(&repo, "side").await.unwrap(),
1302 "HEAD follows the moved ref"
1303 );
1304 assert!(
1305 !side_wt.join("c.txt").exists(),
1306 "stale until synced: main's new file has not reached this worktree's disk"
1307 );
1308
1309 sync_to_head(&side_wt).await.unwrap();
1310 assert!(side_wt.join("c.txt").is_file(), "synced now");
1311 assert!(
1312 side_wt.join("b.txt").is_file(),
1313 "the worktree's own committed work survives the sync"
1314 );
1315 assert!(is_clean(&side_wt).await.unwrap());
1316 }
1317
1318 #[tokio::test]
1319 async fn clean_repo_reports_clean_then_dirty() {
1320 let (_g, repo) = scratch().await;
1321 assert!(is_clean(&repo).await.unwrap());
1322 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1323 assert!(!is_clean(&repo).await.unwrap());
1324 }
1325
1326 async fn track(repo: &Path, name: &str, body: &str) {
1327 let p = repo.join(name);
1328 if let Some(d) = p.parent() {
1329 tokio::fs::create_dir_all(d).await.unwrap();
1330 }
1331 tokio::fs::write(&p, body).await.unwrap();
1332 git(repo, &["add", name]).await.unwrap();
1333 git(
1334 repo,
1335 &[
1336 "-c",
1337 "user.name=t",
1338 "-c",
1339 "user.email=t@localhost",
1340 "commit",
1341 "-q",
1342 "-m",
1343 "seed",
1344 ],
1345 )
1346 .await
1347 .unwrap();
1348 }
1349
1350 #[tokio::test]
1351 async fn rescue_withholds_a_foreign_lockfile() {
1352 let (_g, repo) = scratch().await;
1353 track(&repo, "web/bun.lock", "a\n").await;
1354 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1355 .await
1356 .unwrap();
1357 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1358 .await
1359 .unwrap();
1360
1361 let r = rescue_commit(&repo, "rescue").await.unwrap();
1362 assert!(r.committed);
1363 assert_eq!(
1364 r.withheld,
1365 [Stray {
1366 path: "web/pnpm-lock.yaml".to_owned(),
1367 manager: "pnpm".to_owned(),
1368 kept_by: "web/bun.lock".to_owned(),
1369 }]
1370 );
1371 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1372 .await
1373 .unwrap();
1374 assert!(files.contains("web/app.ts"), "{files}");
1375 assert!(!files.contains("pnpm-lock"), "{files}");
1376 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1377 }
1378
1379 #[tokio::test]
1380 async fn rescue_with_only_a_stray_commits_nothing() {
1381 let (_g, repo) = scratch().await;
1382 track(&repo, "bun.lock", "a\n").await;
1383 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1384 .await
1385 .unwrap();
1386 let r = rescue_commit(&repo, "rescue").await.unwrap();
1387 assert!(!r.committed);
1388 assert_eq!(r.withheld.len(), 1);
1389 }
1390
1391 #[tokio::test]
1392 async fn rescue_keeps_a_same_manager_lockfile_update() {
1393 let (_g, repo) = scratch().await;
1394 track(&repo, "bun.lock", "a\n").await;
1395 tokio::fs::write(repo.join("bun.lock"), "b\n")
1396 .await
1397 .unwrap();
1398 let r = rescue_commit(&repo, "rescue").await.unwrap();
1399 assert!(r.committed);
1400 assert!(r.withheld.is_empty());
1401 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1402 .await
1403 .unwrap();
1404 assert_eq!(files, "bun.lock");
1405 }
1406
1407 #[tokio::test]
1408 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1409 let (_g, repo) = scratch().await;
1410 track(&repo, "other/bun.lock", "a\n").await;
1411 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1412 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1413 .await
1414 .unwrap();
1415 let r = rescue_commit(&repo, "rescue").await.unwrap();
1416 assert!(r.committed);
1417 assert!(r.withheld.is_empty());
1418 }
1419
1420 #[test]
1421 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1422 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1423 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1424 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1425 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1426 assert_eq!(
1428 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1429 1
1430 );
1431 }
1432
1433 #[tokio::test]
1434 async fn worktree_lifecycle_and_diff() {
1435 let (guard, repo) = scratch().await;
1436 let base = rev_parse(&repo, "HEAD").await.unwrap();
1437 let wt = guard.path().join("wt-a");
1438 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1439 .await
1440 .unwrap();
1441 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1442 .await
1443 .unwrap();
1444
1445 assert!(commit_all(&wt, "candidate work").await.unwrap());
1446 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1447
1448 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1449 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1450 assert!(patch.contains("b.txt"), "patch was: {patch}");
1451 assert_eq!(
1452 changed_files(&wt, &base, "HEAD").await.unwrap(),
1453 ["b.txt".to_owned()]
1454 );
1455
1456 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1458 .await
1459 .unwrap();
1460 assert_eq!(author, "magi candidate <magi@localhost>");
1461
1462 assert!(worktree_remove(&repo, &wt).await.unwrap());
1463 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1464 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1465 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1466 }
1467
1468 #[tokio::test]
1469 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1470 let (guard, repo) = scratch().await;
1471 let base = rev_parse(&repo, "HEAD").await.unwrap();
1472 let wt = guard.path().join("wt-h");
1473 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1474 .await
1475 .unwrap();
1476 let hooks = guard.path().join("hooks");
1477 tokio::fs::create_dir_all(&hooks).await.unwrap();
1478
1479 assert!(enable_worktree_config(&repo).await.unwrap());
1480 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1481
1482 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1483 .await
1484 .unwrap();
1485 assert!(!in_wt.is_empty());
1486 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1487 .await
1488 .unwrap();
1489 assert!(
1490 !in_primary.ok(),
1491 "primary worktree must keep its own hooks: {in_primary:?}"
1492 );
1493
1494 disable_worktree_config(&repo).await.unwrap();
1495 }
1496
1497 #[tokio::test]
1498 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1499 let (_g, repo) = scratch().await;
1500
1501 acquire_worktree_config(&repo).await.unwrap();
1504 acquire_worktree_config(&repo).await.unwrap();
1505
1506 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1507 .await
1508 .unwrap();
1509 assert_eq!(on, "true");
1510
1511 release_worktree_config(&repo).await.unwrap();
1515 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1516 .await
1517 .unwrap();
1518 assert_eq!(
1519 still_on, "true",
1520 "a sibling run's release must not disable the setting for the one still working"
1521 );
1522
1523 release_worktree_config(&repo).await.unwrap();
1525 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1526 .await
1527 .unwrap();
1528 assert!(
1529 !after.ok(),
1530 "the last release must turn the setting back off: {after:?}"
1531 );
1532 }
1533
1534 #[tokio::test]
1535 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1536 let (_g, repo) = scratch().await;
1537 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1538 .await
1539 .unwrap();
1540
1541 acquire_worktree_config(&repo).await.unwrap();
1546 release_worktree_config(&repo).await.unwrap();
1547
1548 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1549 .await
1550 .unwrap();
1551 assert_eq!(still_on, "true");
1552 }
1553
1554 #[tokio::test]
1555 async fn local_exclude_is_idempotent() {
1556 let (_g, repo) = scratch().await;
1557 local_exclude(&repo, "/.magi/").await.unwrap();
1558 local_exclude(&repo, "/.magi/").await.unwrap();
1559 let path = repo.join(".git/info/exclude");
1560 let body = tokio::fs::read_to_string(&path).await.unwrap();
1561 assert_eq!(body.matches("/.magi/").count(), 1);
1562 }
1563}