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 fn remote_head_branch(remote: &str, symref: &str) -> Option<String> {
142 symref
143 .trim()
144 .strip_prefix(&format!("refs/remotes/{remote}/"))
145 .filter(|b| !b.is_empty() && *b != "HEAD")
146 .map(str::to_owned)
147}
148
149pub async fn ensure_remote_head(repo: &Path, remote: &str, base: &str) {
155 let head = format!("refs/remotes/{remote}/HEAD");
156 if git_raw(repo, &["symbolic-ref", "--quiet", &head])
157 .await
158 .is_ok_and(|o| o.ok())
159 {
160 return;
161 }
162 let target = format!("refs/remotes/{remote}/{base}");
163 let _ = git_raw(repo, &["symbolic-ref", &head, &target]).await;
164}
165
166fn symref_branch(out: &str) -> Option<String> {
169 out.lines()
170 .find_map(|l| l.strip_prefix("ref: refs/heads/"))
171 .and_then(|l| l.split_whitespace().next())
172 .map(str::to_owned)
173}
174
175pub async fn merge_base_branch(
182 repo: &Path,
183 remote: &str,
184 explicit: Option<&str>,
185) -> Result<String> {
186 if let Some(b) = explicit.map(str::trim).filter(|b| !b.is_empty()) {
187 return Ok(b.to_owned());
188 }
189 let head = format!("refs/remotes/{remote}/HEAD");
190 let read = || async {
191 let out = git_raw(repo, &["symbolic-ref", "--quiet", &head])
192 .await
193 .ok()?;
194 if out.ok() {
195 remote_head_branch(remote, &out.stdout)
196 } else {
197 None
198 }
199 };
200 if let Some(b) = read().await {
201 return Ok(b);
202 }
203 let set = git_raw(repo, &["remote", "set-head", remote, "-a"]).await;
204 if let Some(b) = read().await {
205 return Ok(b);
206 }
207 if let Ok(o) = git_raw(repo, &["ls-remote", "--symref", remote, "HEAD"]).await
212 && o.ok()
213 && let Some(b) = symref_branch(&o.stdout)
214 {
215 return Ok(b);
216 }
217 let why = match set {
218 Ok(o) if !o.ok() => o.stderr.lines().next().unwrap_or("").trim().to_owned(),
219 Ok(_) => "the remote reported no default branch".to_owned(),
220 Err(e) => e.to_string(),
221 };
222 bail!(
223 "cannot tell which branch of `{remote}` is the base ({why}): set [merge] base \
224 (and remote) in magi.toml, or run `git remote set-head {remote} -a`. magi does \
225 not fall back to the checked-out branch, which may be detached or stale"
226 )
227}
228
229pub async fn is_clean(repo: &Path) -> Result<bool> {
231 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
232}
233
234pub async fn status_porcelain(repo: &Path) -> Result<String> {
236 git(repo, &["status", "--porcelain"]).await
237}
238
239pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
241 if let Some(parent) = path.parent() {
242 tokio::fs::create_dir_all(parent).await.ok();
243 }
244 let path_s = path.to_string_lossy().to_string();
245 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
246 .await
247 .map(|_| ())
248}
249
250pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
252 if let Some(parent) = path.parent() {
253 tokio::fs::create_dir_all(parent).await.ok();
254 }
255 let path_s = path.to_string_lossy().to_string();
256 git(repo, &["worktree", "add", "--detach", &path_s, rev])
257 .await
258 .map(|_| ())
259}
260
261pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
263 git(worktree, &["checkout", "--detach", rev]).await?;
264 git(worktree, &["reset", "--hard", rev]).await?;
265 git(worktree, &["clean", "-fdx"]).await?;
266 Ok(())
267}
268
269pub async fn worktree_holding(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
274 let listing = git(repo, &["worktree", "list", "--porcelain"]).await?;
275 Ok(parse_worktree_holder(&listing, branch))
276}
277
278fn parse_worktree_holder(listing: &str, branch: &str) -> Option<PathBuf> {
279 let want = format!("refs/heads/{branch}");
280 let mut path: Option<PathBuf> = None;
281 for line in listing.lines() {
282 if let Some(p) = line.strip_prefix("worktree ") {
283 path = Some(PathBuf::from(p));
284 } else if line.strip_prefix("branch ") == Some(want.as_str()) {
285 return path;
286 }
287 }
288 None
289}
290
291pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
294 let path_s = path.to_string_lossy().to_string();
295 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
296 if out.ok() {
297 return Ok(true);
298 }
299 git_raw(repo, &["worktree", "prune"]).await?;
301 Ok(false)
302}
303
304pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
308 let path_s = path.to_string_lossy().to_string();
309 Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
310}
311
312pub async fn remove_worktree_from_linked(dir: &Path) {
321 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
322 return;
323 };
324 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
325 return;
326 };
327 let admin = Path::new(admin);
330 let Some(common) = admin.parent().and_then(Path::parent) else {
331 return;
332 };
333 let common_s = common.to_string_lossy();
334 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
335}
336
337pub async fn worktree_prune(repo: &Path) -> Result<()> {
346 git(repo, &["worktree", "prune"]).await.map(|_| ())
347}
348
349pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
351 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
352}
353
354pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
356 let refname = format!("refs/heads/{branch}");
357 Ok(
358 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
359 .await?
360 .ok(),
361 )
362}
363
364pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
367 let refname = format!("refs/heads/{branch}");
368 let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
369 match out.code {
370 Some(0) => Ok(true),
371 Some(2) => Ok(false),
372 code => bail!(
373 "git ls-remote {remote} failed (exit {code:?}): {}",
374 out.stderr
375 ),
376 }
377}
378
379pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
381 let range = format!("{base}...{head}");
382 git(
383 worktree,
384 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
385 )
386 .await
387}
388
389pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
391 let range = format!("{base}...{head}");
392 git(worktree, &["diff", "--no-color", "--stat", &range]).await
393}
394
395pub async fn changed_lines(worktree: &Path, base: &str, head: &str) -> Option<usize> {
399 let out = git_raw(worktree, &["diff", "--numstat", base, head])
400 .await
401 .ok()?;
402 if !out.ok() {
403 return None;
404 }
405 let mut total = 0usize;
406 for line in out.stdout.lines().filter(|l| !l.trim().is_empty()) {
407 let mut cols = line.split('\t');
408 let added: usize = cols.next()?.parse().ok()?;
409 let removed: usize = cols.next()?.parse().ok()?;
410 total = total.saturating_add(added).saturating_add(removed);
411 }
412 Some(total)
413}
414
415pub async fn restore_to(worktree: &Path, rev: &str) -> Result<()> {
419 git(worktree, &["reset", "--hard", "-q", rev]).await?;
420 git(worktree, &["clean", "-ffdq"]).await?;
423 git(
429 worktree,
430 &[
431 "submodule",
432 "--quiet",
433 "update",
434 "--recursive",
435 "--force",
436 "--checkout",
437 ],
438 )
439 .await?;
440 git(
441 worktree,
442 &[
443 "submodule",
444 "--quiet",
445 "foreach",
446 "--recursive",
447 "git clean -ffdq",
448 ],
449 )
450 .await?;
451 Ok(())
452}
453
454pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
456 let range = format!("{base}...{head}");
457 let out = git(worktree, &["diff", "--name-only", &range]).await?;
458 Ok(out.lines().map(str::to_owned).collect())
459}
460
461pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
465 let range = format!("{base}..{head}");
466 let out = git(
467 worktree,
468 &[
469 "log",
470 "--reverse",
471 "--no-color",
472 "--format=%s%x1f%b%x00",
473 &range,
474 ],
475 )
476 .await?;
477 Ok(out
478 .split('\0')
479 .filter_map(|rec| {
480 let rec = rec.trim_start_matches('\n');
481 if rec.trim().is_empty() {
482 return None;
483 }
484 let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
485 Some((subject.trim().to_owned(), body.trim().to_owned()))
486 })
487 .collect())
488}
489
490pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
492 let range = format!("{base}..{head}");
493 git(
494 worktree,
495 &["log", "--reverse", "--format=%s%n%b%n--", &range],
496 )
497 .await
498}
499
500pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
503 let range = format!("{base}..{head}");
504 let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
505 let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
506 parts.pop();
508 Ok(parts)
509}
510
511pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
513 let range = format!("{base}..{head}");
514 let out = git(worktree, &["rev-list", "--count", &range]).await?;
515 Ok(out.trim().parse().unwrap_or(0))
516}
517
518pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
525 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
526 return Ok(false);
527 }
528 git(worktree, &["add", "-A"]).await?;
529 if !crate::worktree_setup::withheld_paths(worktree)
531 .await
532 .is_empty()
533 {
534 crate::worktree_setup::unstage_products(worktree).await?;
535 if git_raw(worktree, &["diff", "--cached", "--quiet"])
536 .await?
537 .ok()
538 {
539 return Ok(false);
540 }
541 }
542 let out = git_raw(
543 worktree,
544 &[
545 "-c",
546 "user.name=magi candidate",
547 "-c",
548 "user.email=magi@localhost",
549 "commit",
550 "--no-verify",
551 "-m",
552 message,
553 ],
554 )
555 .await?;
556 if !out.ok() {
557 bail!("rescue commit failed: {}", out.stderr);
558 }
559 Ok(true)
560}
561
562#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct Stray {
566 pub path: String,
568 pub manager: String,
570 pub kept_by: String,
573}
574
575#[derive(Debug, Default)]
577pub struct Rescue {
578 pub committed: bool,
580 pub withheld: Vec<Stray>,
582}
583
584fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
586 Some(match name {
587 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
588 "yarn.lock" => ("node", "yarn"),
589 "pnpm-lock.yaml" => ("node", "pnpm"),
590 "bun.lock" | "bun.lockb" => ("node", "bun"),
591 "poetry.lock" => ("python", "poetry"),
592 "uv.lock" => ("python", "uv"),
593 "Pipfile.lock" => ("python", "pipenv"),
594 "pdm.lock" => ("python", "pdm"),
595 "Cargo.lock" => ("rust", "cargo"),
596 _ => return None,
597 })
598}
599
600fn split_dir(path: &str) -> (&str, &str) {
601 path.rsplit_once('/').unwrap_or(("", path))
602}
603
604pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
612 let mut out = Vec::new();
613 for path in untracked {
614 let (dir, name) = split_dir(path);
615 let Some((eco, manager)) = lock_kind(name) else {
616 continue;
617 };
618 let beside = |other: &String| split_dir(other).0 == dir;
619 let kept_by = if manager == "cargo" {
620 let has_manifest = tracked
621 .iter()
622 .chain(untracked)
623 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
624 if has_manifest {
625 continue;
626 }
627 "no Cargo.toml in the directory".to_owned()
628 } else {
629 let Some(other) = tracked.iter().find(|p| {
630 beside(p)
631 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
632 }) else {
633 continue;
634 };
635 other.clone()
636 };
637 out.push(Stray {
638 path: path.clone(),
639 manager: manager.to_owned(),
640 kept_by,
641 });
642 }
643 out
644}
645
646async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
647 let out = git(worktree, args).await?;
648 Ok(out
649 .split('\0')
650 .filter(|s| !s.is_empty())
651 .map(str::to_owned)
652 .collect())
653}
654
655pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
661 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
662 return Ok(Rescue::default());
663 }
664 let untracked = nul_list(
665 worktree,
666 &["ls-files", "-z", "--others", "--exclude-standard"],
667 )
668 .await?;
669 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
670 let withheld = stray_lockfiles(&untracked, &tracked);
671
672 git(worktree, &["add", "-A"]).await?;
673 if !withheld.is_empty() {
674 let mut args = vec!["reset", "-q", "--"];
675 args.extend(withheld.iter().map(|s| s.path.as_str()));
676 git(worktree, &args).await?;
677 }
678 crate::worktree_setup::unstage_products(worktree).await?;
679 if git_raw(worktree, &["diff", "--cached", "--quiet"])
680 .await?
681 .ok()
682 {
683 return Ok(Rescue {
684 committed: false,
685 withheld,
686 });
687 }
688 let out = git_raw(
689 worktree,
690 &[
691 "-c",
692 "user.name=magi candidate",
693 "-c",
694 "user.email=magi@localhost",
695 "commit",
696 "--no-verify",
697 "-m",
698 message,
699 ],
700 )
701 .await?;
702 if !out.ok() {
703 bail!("rescue commit failed: {}", out.stderr);
704 }
705 Ok(Rescue {
706 committed: true,
707 withheld,
708 })
709}
710
711pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
716 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
717 if out.ok() && out.stdout.trim() == "true" {
718 return Ok(false);
719 }
720 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
721 Ok(true)
722}
723
724pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
726 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
727 Ok(())
728}
729
730struct WorktreeConfigRef {
733 count: usize,
735 we_enabled: bool,
741}
742
743static WORKTREE_CONFIG: std::sync::LazyLock<
750 std::sync::Mutex<
751 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
752 >,
753> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
754
755fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
757 let mut map = WORKTREE_CONFIG
758 .lock()
759 .unwrap_or_else(std::sync::PoisonError::into_inner);
760 map.entry(repo.to_path_buf())
761 .or_insert_with(|| {
762 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
763 count: 0,
764 we_enabled: false,
765 }))
766 })
767 .clone()
768}
769
770pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
787 let slot = worktree_config_slot(repo);
788 let mut entry = slot.lock().await;
789 entry.count += 1;
790 if entry.count == 1 {
791 entry.we_enabled = enable_worktree_config(repo).await?;
792 }
793 Ok(())
794}
795
796pub async fn release_worktree_config(repo: &Path) -> Result<()> {
802 let slot = worktree_config_slot(repo);
803 let mut entry = slot.lock().await;
804 entry.count = entry.count.saturating_sub(1);
805 if entry.count == 0 && entry.we_enabled {
806 disable_worktree_config(repo).await?;
807 entry.we_enabled = false;
808 }
809 Ok(())
810}
811
812pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
818 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
819 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
820 .await
821 .map(|_| ())
822}
823
824pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
826 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
827 let path = worktree.join(git_dir);
828 if let Some(parent) = path.parent() {
829 tokio::fs::create_dir_all(parent).await.ok();
830 }
831 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
832 if body.lines().any(|l| l.trim() == pattern) {
833 return Ok(());
834 }
835 if !body.is_empty() && !body.ends_with('\n') {
836 body.push('\n');
837 }
838 body.push_str(pattern);
839 body.push('\n');
840 tokio::fs::write(&path, body)
841 .await
842 .with_context(|| format!("write {}", path.display()))?;
843 Ok(())
844}
845
846pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
852 git_raw(
853 repo,
854 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
855 )
856 .await
857}
858
859pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
870 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
871 if !staged.ok() {
872 return Ok(staged);
873 }
874 git_raw(repo, &["commit", "-m", message]).await
875}
876
877pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
887 git_raw(repo, &["merge", "--ff-only", branch]).await
888}
889
890pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
892 git_raw(repo, &["push", "-u", remote, branch]).await
893}
894
895pub async fn push_as(
898 repo: &Path,
899 remote: &str,
900 local: &str,
901 head: &str,
902 expected: &str,
903) -> Result<GitOut> {
904 let lease = format!("--force-with-lease=refs/heads/{head}:{expected}");
905 let refspec = format!("refs/heads/{local}:refs/heads/{head}");
906 git_raw(repo, &["push", &lease, remote, &refspec]).await
907}
908
909pub async fn remote_head(repo: &Path, remote: &str, branch: &str) -> Result<Option<String>> {
911 let reference = format!("refs/heads/{branch}");
912 let out = git(repo, &["ls-remote", "--refs", remote, &reference]).await?;
913 Ok(out.split_whitespace().next().map(str::to_owned))
914}
915
916pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
924 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
925}
926
927pub async fn push_pinned(
936 repo: &Path,
937 remote: &str,
938 branch: &str,
939 expected: &str,
940) -> Result<GitOut> {
941 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
942 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
943 git_raw(repo, &["push", &lease, remote, &refspec]).await
944}
945
946pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
949 let out = git(repo, &["cherry", upstream, head]).await?;
950 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
951 for line in out.lines() {
952 if let Some(sha) = line.strip_prefix("+ ") {
953 unmatched.push(sha.trim().to_owned());
954 } else if let Some(sha) = line.strip_prefix("- ") {
955 matched.push(sha.trim().to_owned());
956 }
957 }
958 Ok((unmatched, matched))
959}
960
961#[derive(Debug, Clone, PartialEq, Eq)]
965pub struct CommitKey {
966 pub sha: String,
968 pub key: String,
970}
971
972pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
974 let out = git(
975 repo,
976 &[
977 "log",
978 "--no-merges",
979 "--reverse",
980 "--date=raw",
981 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
982 range,
983 ],
984 )
985 .await?;
986 Ok(out
987 .lines()
988 .filter_map(|l| l.split_once('\u{1f}'))
989 .map(|(sha, key)| CommitKey {
990 sha: sha.to_owned(),
991 key: key.to_owned(),
992 })
993 .collect())
994}
995
996#[derive(Debug, Clone, PartialEq, Eq)]
998pub enum RebaseStart {
999 Applied,
1002 Conflicted(String),
1005 Failed(String),
1008}
1009
1010pub async fn rebase_start(
1025 repo: &Path,
1026 scratch: &Path,
1027 branch: &str,
1028 onto: &str,
1029) -> Result<RebaseStart> {
1030 worktree_remove(repo, scratch).await.ok();
1033 git_raw(
1034 repo,
1035 &[
1036 "worktree",
1037 "add",
1038 "--force",
1039 &scratch.to_string_lossy(),
1040 branch,
1041 ],
1042 )
1043 .await?;
1044
1045 let out = git_raw(scratch, &["rebase", onto]).await?;
1046 if out.ok() {
1047 worktree_remove(repo, scratch).await.ok();
1048 return Ok(RebaseStart::Applied);
1049 }
1050 let why = if out.stderr.trim().is_empty() {
1051 out.stdout.trim().to_owned()
1052 } else {
1053 out.stderr.trim().to_owned()
1054 };
1055 if rebase_in_progress(scratch).await {
1056 return Ok(RebaseStart::Conflicted(why));
1057 }
1058 worktree_remove(repo, scratch).await.ok();
1059 Ok(RebaseStart::Failed(why))
1060}
1061
1062pub async fn rebase_in_progress(worktree: &Path) -> bool {
1064 for name in ["rebase-merge", "rebase-apply"] {
1065 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1066 continue;
1067 };
1068 let p = Path::new(p.trim());
1069 let full = if p.is_absolute() {
1070 p.to_path_buf()
1071 } else {
1072 worktree.join(p)
1073 };
1074 if full.exists() {
1075 return true;
1076 }
1077 }
1078 false
1079}
1080
1081pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1083 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1084 Ok(out
1085 .lines()
1086 .map(str::trim)
1087 .filter(|l| !l.is_empty())
1088 .map(str::to_owned)
1089 .collect())
1090}
1091
1092pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1095 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1096 worktree_remove(repo, scratch).await.ok();
1097}
1098
1099pub async fn rebase_branch_in_temp(
1107 repo: &Path,
1108 scratch: &Path,
1109 branch: &str,
1110 onto: &str,
1111) -> Result<Option<String>> {
1112 match rebase_start(repo, scratch, branch, onto).await? {
1113 RebaseStart::Applied => Ok(None),
1114 RebaseStart::Failed(why) => Ok(Some(why)),
1115 RebaseStart::Conflicted(why) => {
1116 rebase_abort(repo, scratch).await;
1117 Ok(Some(why))
1118 }
1119 }
1120}
1121
1122pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1135 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1136 git(worktree, &["clean", "-fdx"]).await?;
1137 Ok(())
1138}
1139
1140pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1159 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1160 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1161}
1162
1163pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1165 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1166}
1167
1168pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1170 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1171 .await
1172 .is_ok_and(|o| o.ok())
1173}
1174
1175pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1180 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1181 match out.code {
1182 Some(0) => Ok(true),
1183 Some(1) => Ok(false),
1184 _ => bail!(
1185 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
1186 out.code,
1187 out.stderr
1188 ),
1189 }
1190}
1191
1192pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1194 let spec = format!("{rev}^{{commit}}");
1195 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1196 .await
1197 .ok()?;
1198 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1199}
1200
1201pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1203 let out = git(
1204 repo,
1205 &[
1206 "branch",
1207 "-a",
1208 "--contains",
1209 commit,
1210 "--format=%(refname:short)",
1211 ],
1212 )
1213 .await?;
1214 Ok(out
1215 .lines()
1216 .map(str::trim)
1217 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1218 .map(str::to_owned)
1219 .collect())
1220}
1221
1222pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1226 let out = git_raw(
1227 worktree,
1228 &[
1229 "-c",
1230 "user.name=magi candidate",
1231 "-c",
1232 "user.email=magi@localhost",
1233 "cherry-pick",
1234 "-x",
1235 commit,
1236 ],
1237 )
1238 .await?;
1239 if out.ok() {
1240 return Ok(());
1241 }
1242 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1243 bail!(
1244 "cherry-pick of {commit} failed: {}",
1245 if out.stderr.is_empty() {
1246 out.stdout
1247 } else {
1248 out.stderr
1249 }
1250 )
1251}
1252
1253pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1255 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1256}
1257
1258pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1260 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1261 .await
1262 .is_ok_and(|o| o.ok())
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268
1269 #[test]
1270 fn worktree_holder_is_read_from_the_porcelain_listing() {
1271 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1272 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1273 worktree /wt/detached\nHEAD ccc\ndetached\n";
1274 assert_eq!(
1275 parse_worktree_holder(listing, "magi/f82f/A"),
1276 Some(PathBuf::from("/wt/cand-A"))
1277 );
1278 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1279 assert_eq!(parse_worktree_holder(listing, "other"), None);
1280 }
1281
1282 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1283 let dir = tempfile::tempdir().unwrap();
1284 let repo = dir.path().join("repo");
1285 tokio::fs::create_dir_all(&repo).await.unwrap();
1286 git(&repo, &["init", "-b", "main"]).await.unwrap();
1287 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1288 git(&repo, &["config", "user.email", "test@example.com"])
1289 .await
1290 .unwrap();
1291 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1292 git(&repo, &["add", "-A"]).await.unwrap();
1293 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1294 (dir, repo)
1295 }
1296
1297 #[tokio::test]
1298 async fn restore_to_removes_nested_repositories_inside_submodules() {
1299 let (_parent_dir, repo) = scratch().await;
1300 let (_source_dir, source) = scratch().await;
1301 sh(
1302 &repo,
1303 &[
1304 "-c",
1305 "protocol.file.allow=always",
1306 "submodule",
1307 "add",
1308 &source.to_string_lossy(),
1309 "sub",
1310 ],
1311 );
1312 sh(&repo, &["commit", "-am", "add submodule"]);
1313 let pre = rev_parse(&repo, "HEAD").await.unwrap();
1314 let sub = repo.join("sub");
1315 sh(&sub, &["init", "nested"]);
1316 tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1317 .await
1318 .unwrap();
1319
1320 restore_to(&repo, &pre).await.unwrap();
1321
1322 assert!(!sub.join("nested").exists());
1323 assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1324 assert_eq!(
1325 rev_parse(&sub, "HEAD").await.unwrap(),
1326 rev_parse(&source, "HEAD").await.unwrap()
1327 );
1328 assert!(
1329 git(&repo, &["status", "--porcelain"])
1330 .await
1331 .unwrap()
1332 .is_empty()
1333 );
1334 }
1335
1336 #[tokio::test]
1337 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1338 let (_g, repo) = scratch().await;
1339
1340 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1342 tokio::fs::write(repo.join("b.txt"), "side\n")
1343 .await
1344 .unwrap();
1345 git(&repo, &["add", "-A"]).await.unwrap();
1346 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1347
1348 git(&repo, &["checkout", "main"]).await.unwrap();
1351 tokio::fs::write(repo.join("c.txt"), "main\n")
1352 .await
1353 .unwrap();
1354 git(&repo, &["add", "-A"]).await.unwrap();
1355 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1356
1357 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1358 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1359 .await
1360 .unwrap();
1361 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1362 assert_eq!(
1363 commits_ahead(&repo, "main", "side").await.unwrap(),
1364 1,
1365 "one commit, replayed onto the new base"
1366 );
1367 assert!(
1368 !scratch_tree.exists(),
1369 "the throwaway worktree is not left behind"
1370 );
1371
1372 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1374 tokio::fs::write(repo.join("a.txt"), "clash\n")
1375 .await
1376 .unwrap();
1377 git(&repo, &["add", "-A"]).await.unwrap();
1378 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1379 git(&repo, &["checkout", "main"]).await.unwrap();
1380 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1381 .await
1382 .unwrap();
1383 git(&repo, &["add", "-A"]).await.unwrap();
1384 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1385
1386 let before = rev_parse(&repo, "clash").await.unwrap();
1387 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1388 .await
1389 .unwrap()
1390 .expect("a same-line clash cannot be rebased silently");
1391 assert!(
1392 why.to_lowercase().contains("conflict"),
1393 "the reason is what git said, which is what a person needs: {why}"
1394 );
1395 assert_eq!(
1396 rev_parse(&repo, "clash").await.unwrap(),
1397 before,
1398 "a failed rebase leaves the branch exactly where it was"
1399 );
1400 assert!(!scratch_tree.exists(), "and cleans up after itself");
1401 }
1402
1403 #[tokio::test]
1404 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1405 let (_g, repo) = scratch().await;
1406 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1407 for name in ["b.txt", "c.txt"] {
1408 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1409 git(&repo, &["add", "-A"]).await.unwrap();
1410 git(
1411 &repo,
1412 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1413 )
1414 .await
1415 .unwrap();
1416 }
1417 git(&repo, &["checkout", "main"]).await.unwrap();
1418 let before = rev_parse(&repo, "main").await.unwrap();
1419
1420 let out = merge_squash(&repo, "side", "an explicit subject")
1421 .await
1422 .unwrap();
1423 assert!(out.ok(), "{}", out.stderr);
1424 assert_eq!(
1425 commits_ahead(&repo, &before, "main").await.unwrap(),
1426 1,
1427 "squash adds exactly one commit onto the tip, not one per candidate commit"
1428 );
1429 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1430 assert_eq!(
1431 subject, "an explicit subject",
1432 "the candidate's own placeholder subject must not survive: {subject}"
1433 );
1434 }
1435
1436 #[tokio::test]
1437 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1438 let (_g, repo) = scratch().await;
1439 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1440 tokio::fs::write(repo.join("b.txt"), "side\n")
1441 .await
1442 .unwrap();
1443 git(&repo, &["add", "-A"]).await.unwrap();
1444 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1445 git(&repo, &["checkout", "main"]).await.unwrap();
1446
1447 let before = rev_parse(&repo, "side").await.unwrap();
1448 let out = merge_ff_only(&repo, "side").await.unwrap();
1449 assert!(out.ok(), "{}", out.stderr);
1450 assert_eq!(
1451 rev_parse(&repo, "main").await.unwrap(),
1452 before,
1453 "a fast-forward moves the base tip to the branch, no merge commit"
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1459 let (_g, repo) = scratch().await;
1460 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1461 tokio::fs::write(repo.join("b.txt"), "side\n")
1462 .await
1463 .unwrap();
1464 git(&repo, &["add", "-A"]).await.unwrap();
1465 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1466
1467 git(&repo, &["checkout", "main"]).await.unwrap();
1469 tokio::fs::write(repo.join("c.txt"), "main\n")
1470 .await
1471 .unwrap();
1472 git(&repo, &["add", "-A"]).await.unwrap();
1473 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1474
1475 let before = rev_parse(&repo, "main").await.unwrap();
1476 let out = merge_ff_only(&repo, "side").await.unwrap();
1477 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1478 assert_eq!(
1479 rev_parse(&repo, "main").await.unwrap(),
1480 before,
1481 "a refused fast-forward must not touch main"
1482 );
1483 }
1484
1485 #[tokio::test]
1486 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1487 let (guard, repo) = scratch().await;
1488
1489 git(&repo, &["branch", "side"]).await.unwrap();
1493 let side_wt = guard.path().join("side-wt");
1494 git(
1495 &repo,
1496 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1497 )
1498 .await
1499 .unwrap();
1500 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1501 .await
1502 .unwrap();
1503 git(&side_wt, &["add", "-A"]).await.unwrap();
1504 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1505
1506 git(&repo, &["checkout", "main"]).await.unwrap();
1508 tokio::fs::write(repo.join("c.txt"), "main\n")
1509 .await
1510 .unwrap();
1511 git(&repo, &["add", "-A"]).await.unwrap();
1512 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1513
1514 let scratch_tree = guard.path().join("rebase-scratch");
1516 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1517 .await
1518 .unwrap();
1519 assert!(clean.is_none());
1520
1521 assert_eq!(
1525 rev_parse(&side_wt, "HEAD").await.unwrap(),
1526 rev_parse(&repo, "side").await.unwrap(),
1527 "HEAD follows the moved ref"
1528 );
1529 assert!(
1530 !side_wt.join("c.txt").exists(),
1531 "stale until synced: main's new file has not reached this worktree's disk"
1532 );
1533
1534 sync_to_head(&side_wt).await.unwrap();
1535 assert!(side_wt.join("c.txt").is_file(), "synced now");
1536 assert!(
1537 side_wt.join("b.txt").is_file(),
1538 "the worktree's own committed work survives the sync"
1539 );
1540 assert!(is_clean(&side_wt).await.unwrap());
1541 }
1542
1543 #[tokio::test]
1544 async fn clean_repo_reports_clean_then_dirty() {
1545 let (_g, repo) = scratch().await;
1546 assert!(is_clean(&repo).await.unwrap());
1547 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1548 assert!(!is_clean(&repo).await.unwrap());
1549 }
1550
1551 async fn track(repo: &Path, name: &str, body: &str) {
1552 let p = repo.join(name);
1553 if let Some(d) = p.parent() {
1554 tokio::fs::create_dir_all(d).await.unwrap();
1555 }
1556 tokio::fs::write(&p, body).await.unwrap();
1557 git(repo, &["add", name]).await.unwrap();
1558 git(
1559 repo,
1560 &[
1561 "-c",
1562 "user.name=t",
1563 "-c",
1564 "user.email=t@localhost",
1565 "commit",
1566 "-q",
1567 "-m",
1568 "seed",
1569 ],
1570 )
1571 .await
1572 .unwrap();
1573 }
1574
1575 #[tokio::test]
1576 async fn rescue_withholds_a_foreign_lockfile() {
1577 let (_g, repo) = scratch().await;
1578 track(&repo, "web/bun.lock", "a\n").await;
1579 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1580 .await
1581 .unwrap();
1582 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1583 .await
1584 .unwrap();
1585
1586 let r = rescue_commit(&repo, "rescue").await.unwrap();
1587 assert!(r.committed);
1588 assert_eq!(
1589 r.withheld,
1590 [Stray {
1591 path: "web/pnpm-lock.yaml".to_owned(),
1592 manager: "pnpm".to_owned(),
1593 kept_by: "web/bun.lock".to_owned(),
1594 }]
1595 );
1596 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1597 .await
1598 .unwrap();
1599 assert!(files.contains("web/app.ts"), "{files}");
1600 assert!(!files.contains("pnpm-lock"), "{files}");
1601 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1602 }
1603
1604 #[tokio::test]
1605 async fn rescue_with_only_a_stray_commits_nothing() {
1606 let (_g, repo) = scratch().await;
1607 track(&repo, "bun.lock", "a\n").await;
1608 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1609 .await
1610 .unwrap();
1611 let r = rescue_commit(&repo, "rescue").await.unwrap();
1612 assert!(!r.committed);
1613 assert_eq!(r.withheld.len(), 1);
1614 }
1615
1616 #[tokio::test]
1617 async fn rescue_keeps_a_same_manager_lockfile_update() {
1618 let (_g, repo) = scratch().await;
1619 track(&repo, "bun.lock", "a\n").await;
1620 tokio::fs::write(repo.join("bun.lock"), "b\n")
1621 .await
1622 .unwrap();
1623 let r = rescue_commit(&repo, "rescue").await.unwrap();
1624 assert!(r.committed);
1625 assert!(r.withheld.is_empty());
1626 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1627 .await
1628 .unwrap();
1629 assert_eq!(files, "bun.lock");
1630 }
1631
1632 #[tokio::test]
1633 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1634 let (_g, repo) = scratch().await;
1635 track(&repo, "other/bun.lock", "a\n").await;
1636 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1637 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1638 .await
1639 .unwrap();
1640 let r = rescue_commit(&repo, "rescue").await.unwrap();
1641 assert!(r.committed);
1642 assert!(r.withheld.is_empty());
1643 }
1644
1645 #[test]
1646 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1647 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1648 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1649 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1650 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1651 assert_eq!(
1653 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1654 1
1655 );
1656 }
1657
1658 #[tokio::test]
1659 async fn worktree_lifecycle_and_diff() {
1660 let (guard, repo) = scratch().await;
1661 let base = rev_parse(&repo, "HEAD").await.unwrap();
1662 let wt = guard.path().join("wt-a");
1663 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1664 .await
1665 .unwrap();
1666 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1667 .await
1668 .unwrap();
1669
1670 assert!(commit_all(&wt, "candidate work").await.unwrap());
1671 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1672
1673 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1674 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1675 assert!(patch.contains("b.txt"), "patch was: {patch}");
1676 assert_eq!(
1677 changed_files(&wt, &base, "HEAD").await.unwrap(),
1678 ["b.txt".to_owned()]
1679 );
1680
1681 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1683 .await
1684 .unwrap();
1685 assert_eq!(author, "magi candidate <magi@localhost>");
1686
1687 assert!(worktree_remove(&repo, &wt).await.unwrap());
1688 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1689 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1690 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1691 }
1692
1693 #[tokio::test]
1694 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1695 let (guard, repo) = scratch().await;
1696 let base = rev_parse(&repo, "HEAD").await.unwrap();
1697 let wt = guard.path().join("wt-h");
1698 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1699 .await
1700 .unwrap();
1701 let hooks = guard.path().join("hooks");
1702 tokio::fs::create_dir_all(&hooks).await.unwrap();
1703
1704 assert!(enable_worktree_config(&repo).await.unwrap());
1705 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1706
1707 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1708 .await
1709 .unwrap();
1710 assert!(!in_wt.is_empty());
1711 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1712 .await
1713 .unwrap();
1714 assert!(
1715 !in_primary.ok(),
1716 "primary worktree must keep its own hooks: {in_primary:?}"
1717 );
1718
1719 disable_worktree_config(&repo).await.unwrap();
1720 }
1721
1722 #[tokio::test]
1723 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1724 let (_g, repo) = scratch().await;
1725
1726 acquire_worktree_config(&repo).await.unwrap();
1729 acquire_worktree_config(&repo).await.unwrap();
1730
1731 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1732 .await
1733 .unwrap();
1734 assert_eq!(on, "true");
1735
1736 release_worktree_config(&repo).await.unwrap();
1740 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1741 .await
1742 .unwrap();
1743 assert_eq!(
1744 still_on, "true",
1745 "a sibling run's release must not disable the setting for the one still working"
1746 );
1747
1748 release_worktree_config(&repo).await.unwrap();
1750 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1751 .await
1752 .unwrap();
1753 assert!(
1754 !after.ok(),
1755 "the last release must turn the setting back off: {after:?}"
1756 );
1757 }
1758
1759 #[tokio::test]
1760 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1761 let (_g, repo) = scratch().await;
1762 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1763 .await
1764 .unwrap();
1765
1766 acquire_worktree_config(&repo).await.unwrap();
1771 release_worktree_config(&repo).await.unwrap();
1772
1773 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1774 .await
1775 .unwrap();
1776 assert_eq!(still_on, "true");
1777 }
1778
1779 #[tokio::test]
1780 async fn local_exclude_is_idempotent() {
1781 let (_g, repo) = scratch().await;
1782 local_exclude(&repo, "/.magi/").await.unwrap();
1783 local_exclude(&repo, "/.magi/").await.unwrap();
1784 let path = repo.join(".git/info/exclude");
1785 let body = tokio::fs::read_to_string(&path).await.unwrap();
1786 assert_eq!(body.matches("/.magi/").count(), 1);
1787 }
1788
1789 fn sh(cwd: &Path, args: &[&str]) {
1790 let out = std::process::Command::new("git")
1791 .quiet()
1792 .args(args)
1793 .current_dir(cwd)
1794 .output()
1795 .unwrap();
1796 assert!(
1797 out.status.success(),
1798 "git {args:?}: {}",
1799 String::from_utf8_lossy(&out.stderr)
1800 );
1801 }
1802
1803 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1806 let (g, repo) = scratch().await;
1807 let bare = g.path().join("bare.git");
1808 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1809 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1810 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1811 sh(&repo, &["fetch", "origin"]);
1812 (g, repo)
1813 }
1814
1815 #[test]
1816 fn remote_head_branch_strips_only_the_matching_remote() {
1817 assert_eq!(
1818 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1819 Some("main")
1820 );
1821 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1822 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1823 }
1824
1825 #[tokio::test]
1826 async fn merge_base_branch_reads_origin_head_when_present() {
1827 let (_g, repo) = with_remote().await;
1828 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1829 assert_eq!(
1830 merge_base_branch(&repo, "origin", None).await.unwrap(),
1831 "trunk"
1832 );
1833 }
1834
1835 #[tokio::test]
1836 async fn merge_base_branch_recovers_a_missing_origin_head() {
1837 let (_g, repo) = with_remote().await;
1838 sh(&repo, &["remote", "set-head", "origin", "-d"]);
1840 let head = git_raw(
1841 &repo,
1842 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1843 )
1844 .await
1845 .unwrap();
1846 assert!(!head.ok(), "precondition: no origin/HEAD");
1847 assert_eq!(
1848 merge_base_branch(&repo, "origin", None).await.unwrap(),
1849 "trunk"
1850 );
1851 }
1852
1853 #[tokio::test]
1854 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1855 let (_g, repo) = scratch().await;
1856 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1857 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1858 }
1859
1860 #[tokio::test]
1861 async fn merge_base_branch_prefers_the_explicit_base() {
1862 let (_g, repo) = with_remote().await;
1863 assert_eq!(
1864 merge_base_branch(&repo, "origin", Some("release"))
1865 .await
1866 .unwrap(),
1867 "release"
1868 );
1869 }
1870
1871 #[tokio::test]
1872 async fn merge_base_branch_names_an_unfetched_default_branch() {
1873 let (g, repo) = scratch().await;
1876 let bare = g.path().join("bare.git");
1877 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1878 sh(
1879 &repo,
1880 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1881 );
1882 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1883 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1884 assert_eq!(
1885 merge_base_branch(&repo, "origin", None).await.unwrap(),
1886 "trunk"
1887 );
1888 }
1889
1890 #[test]
1891 fn symref_branch_reads_ls_remote_output() {
1892 assert_eq!(
1893 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1894 Some("main")
1895 );
1896 assert_eq!(symref_branch("abc\tHEAD\n"), None);
1897 }
1898}