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_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
903 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
904}
905
906pub async fn push_pinned(
915 repo: &Path,
916 remote: &str,
917 branch: &str,
918 expected: &str,
919) -> Result<GitOut> {
920 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
921 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
922 git_raw(repo, &["push", &lease, remote, &refspec]).await
923}
924
925pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
928 let out = git(repo, &["cherry", upstream, head]).await?;
929 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
930 for line in out.lines() {
931 if let Some(sha) = line.strip_prefix("+ ") {
932 unmatched.push(sha.trim().to_owned());
933 } else if let Some(sha) = line.strip_prefix("- ") {
934 matched.push(sha.trim().to_owned());
935 }
936 }
937 Ok((unmatched, matched))
938}
939
940#[derive(Debug, Clone, PartialEq, Eq)]
944pub struct CommitKey {
945 pub sha: String,
947 pub key: String,
949}
950
951pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
953 let out = git(
954 repo,
955 &[
956 "log",
957 "--no-merges",
958 "--reverse",
959 "--date=raw",
960 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
961 range,
962 ],
963 )
964 .await?;
965 Ok(out
966 .lines()
967 .filter_map(|l| l.split_once('\u{1f}'))
968 .map(|(sha, key)| CommitKey {
969 sha: sha.to_owned(),
970 key: key.to_owned(),
971 })
972 .collect())
973}
974
975#[derive(Debug, Clone, PartialEq, Eq)]
977pub enum RebaseStart {
978 Applied,
981 Conflicted(String),
984 Failed(String),
987}
988
989pub async fn rebase_start(
1004 repo: &Path,
1005 scratch: &Path,
1006 branch: &str,
1007 onto: &str,
1008) -> Result<RebaseStart> {
1009 worktree_remove(repo, scratch).await.ok();
1012 git_raw(
1013 repo,
1014 &[
1015 "worktree",
1016 "add",
1017 "--force",
1018 &scratch.to_string_lossy(),
1019 branch,
1020 ],
1021 )
1022 .await?;
1023
1024 let out = git_raw(scratch, &["rebase", onto]).await?;
1025 if out.ok() {
1026 worktree_remove(repo, scratch).await.ok();
1027 return Ok(RebaseStart::Applied);
1028 }
1029 let why = if out.stderr.trim().is_empty() {
1030 out.stdout.trim().to_owned()
1031 } else {
1032 out.stderr.trim().to_owned()
1033 };
1034 if rebase_in_progress(scratch).await {
1035 return Ok(RebaseStart::Conflicted(why));
1036 }
1037 worktree_remove(repo, scratch).await.ok();
1038 Ok(RebaseStart::Failed(why))
1039}
1040
1041pub async fn rebase_in_progress(worktree: &Path) -> bool {
1043 for name in ["rebase-merge", "rebase-apply"] {
1044 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1045 continue;
1046 };
1047 let p = Path::new(p.trim());
1048 let full = if p.is_absolute() {
1049 p.to_path_buf()
1050 } else {
1051 worktree.join(p)
1052 };
1053 if full.exists() {
1054 return true;
1055 }
1056 }
1057 false
1058}
1059
1060pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1062 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1063 Ok(out
1064 .lines()
1065 .map(str::trim)
1066 .filter(|l| !l.is_empty())
1067 .map(str::to_owned)
1068 .collect())
1069}
1070
1071pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1074 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1075 worktree_remove(repo, scratch).await.ok();
1076}
1077
1078pub async fn rebase_branch_in_temp(
1086 repo: &Path,
1087 scratch: &Path,
1088 branch: &str,
1089 onto: &str,
1090) -> Result<Option<String>> {
1091 match rebase_start(repo, scratch, branch, onto).await? {
1092 RebaseStart::Applied => Ok(None),
1093 RebaseStart::Failed(why) => Ok(Some(why)),
1094 RebaseStart::Conflicted(why) => {
1095 rebase_abort(repo, scratch).await;
1096 Ok(Some(why))
1097 }
1098 }
1099}
1100
1101pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1114 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1115 git(worktree, &["clean", "-fdx"]).await?;
1116 Ok(())
1117}
1118
1119pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1138 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1139 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1140}
1141
1142pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1144 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1145}
1146
1147pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1149 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1150 .await
1151 .is_ok_and(|o| o.ok())
1152}
1153
1154pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1159 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1160 match out.code {
1161 Some(0) => Ok(true),
1162 Some(1) => Ok(false),
1163 _ => bail!(
1164 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
1165 out.code,
1166 out.stderr
1167 ),
1168 }
1169}
1170
1171pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1173 let spec = format!("{rev}^{{commit}}");
1174 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1175 .await
1176 .ok()?;
1177 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1178}
1179
1180pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1182 let out = git(
1183 repo,
1184 &[
1185 "branch",
1186 "-a",
1187 "--contains",
1188 commit,
1189 "--format=%(refname:short)",
1190 ],
1191 )
1192 .await?;
1193 Ok(out
1194 .lines()
1195 .map(str::trim)
1196 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1197 .map(str::to_owned)
1198 .collect())
1199}
1200
1201pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1205 let out = git_raw(
1206 worktree,
1207 &[
1208 "-c",
1209 "user.name=magi candidate",
1210 "-c",
1211 "user.email=magi@localhost",
1212 "cherry-pick",
1213 "-x",
1214 commit,
1215 ],
1216 )
1217 .await?;
1218 if out.ok() {
1219 return Ok(());
1220 }
1221 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1222 bail!(
1223 "cherry-pick of {commit} failed: {}",
1224 if out.stderr.is_empty() {
1225 out.stdout
1226 } else {
1227 out.stderr
1228 }
1229 )
1230}
1231
1232pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1234 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1235}
1236
1237pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1239 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1240 .await
1241 .is_ok_and(|o| o.ok())
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247
1248 #[test]
1249 fn worktree_holder_is_read_from_the_porcelain_listing() {
1250 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1251 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1252 worktree /wt/detached\nHEAD ccc\ndetached\n";
1253 assert_eq!(
1254 parse_worktree_holder(listing, "magi/f82f/A"),
1255 Some(PathBuf::from("/wt/cand-A"))
1256 );
1257 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1258 assert_eq!(parse_worktree_holder(listing, "other"), None);
1259 }
1260
1261 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1262 let dir = tempfile::tempdir().unwrap();
1263 let repo = dir.path().join("repo");
1264 tokio::fs::create_dir_all(&repo).await.unwrap();
1265 git(&repo, &["init", "-b", "main"]).await.unwrap();
1266 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1267 git(&repo, &["config", "user.email", "test@example.com"])
1268 .await
1269 .unwrap();
1270 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1271 git(&repo, &["add", "-A"]).await.unwrap();
1272 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1273 (dir, repo)
1274 }
1275
1276 #[tokio::test]
1277 async fn restore_to_removes_nested_repositories_inside_submodules() {
1278 let (_parent_dir, repo) = scratch().await;
1279 let (_source_dir, source) = scratch().await;
1280 sh(
1281 &repo,
1282 &[
1283 "-c",
1284 "protocol.file.allow=always",
1285 "submodule",
1286 "add",
1287 &source.to_string_lossy(),
1288 "sub",
1289 ],
1290 );
1291 sh(&repo, &["commit", "-am", "add submodule"]);
1292 let pre = rev_parse(&repo, "HEAD").await.unwrap();
1293 let sub = repo.join("sub");
1294 sh(&sub, &["init", "nested"]);
1295 tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1296 .await
1297 .unwrap();
1298
1299 restore_to(&repo, &pre).await.unwrap();
1300
1301 assert!(!sub.join("nested").exists());
1302 assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1303 assert_eq!(
1304 rev_parse(&sub, "HEAD").await.unwrap(),
1305 rev_parse(&source, "HEAD").await.unwrap()
1306 );
1307 assert!(
1308 git(&repo, &["status", "--porcelain"])
1309 .await
1310 .unwrap()
1311 .is_empty()
1312 );
1313 }
1314
1315 #[tokio::test]
1316 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1317 let (_g, repo) = scratch().await;
1318
1319 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1321 tokio::fs::write(repo.join("b.txt"), "side\n")
1322 .await
1323 .unwrap();
1324 git(&repo, &["add", "-A"]).await.unwrap();
1325 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1326
1327 git(&repo, &["checkout", "main"]).await.unwrap();
1330 tokio::fs::write(repo.join("c.txt"), "main\n")
1331 .await
1332 .unwrap();
1333 git(&repo, &["add", "-A"]).await.unwrap();
1334 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1335
1336 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1337 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1338 .await
1339 .unwrap();
1340 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1341 assert_eq!(
1342 commits_ahead(&repo, "main", "side").await.unwrap(),
1343 1,
1344 "one commit, replayed onto the new base"
1345 );
1346 assert!(
1347 !scratch_tree.exists(),
1348 "the throwaway worktree is not left behind"
1349 );
1350
1351 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1353 tokio::fs::write(repo.join("a.txt"), "clash\n")
1354 .await
1355 .unwrap();
1356 git(&repo, &["add", "-A"]).await.unwrap();
1357 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1358 git(&repo, &["checkout", "main"]).await.unwrap();
1359 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1360 .await
1361 .unwrap();
1362 git(&repo, &["add", "-A"]).await.unwrap();
1363 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1364
1365 let before = rev_parse(&repo, "clash").await.unwrap();
1366 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1367 .await
1368 .unwrap()
1369 .expect("a same-line clash cannot be rebased silently");
1370 assert!(
1371 why.to_lowercase().contains("conflict"),
1372 "the reason is what git said, which is what a person needs: {why}"
1373 );
1374 assert_eq!(
1375 rev_parse(&repo, "clash").await.unwrap(),
1376 before,
1377 "a failed rebase leaves the branch exactly where it was"
1378 );
1379 assert!(!scratch_tree.exists(), "and cleans up after itself");
1380 }
1381
1382 #[tokio::test]
1383 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1384 let (_g, repo) = scratch().await;
1385 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1386 for name in ["b.txt", "c.txt"] {
1387 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1388 git(&repo, &["add", "-A"]).await.unwrap();
1389 git(
1390 &repo,
1391 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1392 )
1393 .await
1394 .unwrap();
1395 }
1396 git(&repo, &["checkout", "main"]).await.unwrap();
1397 let before = rev_parse(&repo, "main").await.unwrap();
1398
1399 let out = merge_squash(&repo, "side", "an explicit subject")
1400 .await
1401 .unwrap();
1402 assert!(out.ok(), "{}", out.stderr);
1403 assert_eq!(
1404 commits_ahead(&repo, &before, "main").await.unwrap(),
1405 1,
1406 "squash adds exactly one commit onto the tip, not one per candidate commit"
1407 );
1408 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1409 assert_eq!(
1410 subject, "an explicit subject",
1411 "the candidate's own placeholder subject must not survive: {subject}"
1412 );
1413 }
1414
1415 #[tokio::test]
1416 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1417 let (_g, repo) = scratch().await;
1418 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1419 tokio::fs::write(repo.join("b.txt"), "side\n")
1420 .await
1421 .unwrap();
1422 git(&repo, &["add", "-A"]).await.unwrap();
1423 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1424 git(&repo, &["checkout", "main"]).await.unwrap();
1425
1426 let before = rev_parse(&repo, "side").await.unwrap();
1427 let out = merge_ff_only(&repo, "side").await.unwrap();
1428 assert!(out.ok(), "{}", out.stderr);
1429 assert_eq!(
1430 rev_parse(&repo, "main").await.unwrap(),
1431 before,
1432 "a fast-forward moves the base tip to the branch, no merge commit"
1433 );
1434 }
1435
1436 #[tokio::test]
1437 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
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
1446 git(&repo, &["checkout", "main"]).await.unwrap();
1448 tokio::fs::write(repo.join("c.txt"), "main\n")
1449 .await
1450 .unwrap();
1451 git(&repo, &["add", "-A"]).await.unwrap();
1452 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1453
1454 let before = rev_parse(&repo, "main").await.unwrap();
1455 let out = merge_ff_only(&repo, "side").await.unwrap();
1456 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1457 assert_eq!(
1458 rev_parse(&repo, "main").await.unwrap(),
1459 before,
1460 "a refused fast-forward must not touch main"
1461 );
1462 }
1463
1464 #[tokio::test]
1465 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1466 let (guard, repo) = scratch().await;
1467
1468 git(&repo, &["branch", "side"]).await.unwrap();
1472 let side_wt = guard.path().join("side-wt");
1473 git(
1474 &repo,
1475 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1476 )
1477 .await
1478 .unwrap();
1479 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1480 .await
1481 .unwrap();
1482 git(&side_wt, &["add", "-A"]).await.unwrap();
1483 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1484
1485 git(&repo, &["checkout", "main"]).await.unwrap();
1487 tokio::fs::write(repo.join("c.txt"), "main\n")
1488 .await
1489 .unwrap();
1490 git(&repo, &["add", "-A"]).await.unwrap();
1491 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1492
1493 let scratch_tree = guard.path().join("rebase-scratch");
1495 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1496 .await
1497 .unwrap();
1498 assert!(clean.is_none());
1499
1500 assert_eq!(
1504 rev_parse(&side_wt, "HEAD").await.unwrap(),
1505 rev_parse(&repo, "side").await.unwrap(),
1506 "HEAD follows the moved ref"
1507 );
1508 assert!(
1509 !side_wt.join("c.txt").exists(),
1510 "stale until synced: main's new file has not reached this worktree's disk"
1511 );
1512
1513 sync_to_head(&side_wt).await.unwrap();
1514 assert!(side_wt.join("c.txt").is_file(), "synced now");
1515 assert!(
1516 side_wt.join("b.txt").is_file(),
1517 "the worktree's own committed work survives the sync"
1518 );
1519 assert!(is_clean(&side_wt).await.unwrap());
1520 }
1521
1522 #[tokio::test]
1523 async fn clean_repo_reports_clean_then_dirty() {
1524 let (_g, repo) = scratch().await;
1525 assert!(is_clean(&repo).await.unwrap());
1526 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1527 assert!(!is_clean(&repo).await.unwrap());
1528 }
1529
1530 async fn track(repo: &Path, name: &str, body: &str) {
1531 let p = repo.join(name);
1532 if let Some(d) = p.parent() {
1533 tokio::fs::create_dir_all(d).await.unwrap();
1534 }
1535 tokio::fs::write(&p, body).await.unwrap();
1536 git(repo, &["add", name]).await.unwrap();
1537 git(
1538 repo,
1539 &[
1540 "-c",
1541 "user.name=t",
1542 "-c",
1543 "user.email=t@localhost",
1544 "commit",
1545 "-q",
1546 "-m",
1547 "seed",
1548 ],
1549 )
1550 .await
1551 .unwrap();
1552 }
1553
1554 #[tokio::test]
1555 async fn rescue_withholds_a_foreign_lockfile() {
1556 let (_g, repo) = scratch().await;
1557 track(&repo, "web/bun.lock", "a\n").await;
1558 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1559 .await
1560 .unwrap();
1561 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1562 .await
1563 .unwrap();
1564
1565 let r = rescue_commit(&repo, "rescue").await.unwrap();
1566 assert!(r.committed);
1567 assert_eq!(
1568 r.withheld,
1569 [Stray {
1570 path: "web/pnpm-lock.yaml".to_owned(),
1571 manager: "pnpm".to_owned(),
1572 kept_by: "web/bun.lock".to_owned(),
1573 }]
1574 );
1575 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1576 .await
1577 .unwrap();
1578 assert!(files.contains("web/app.ts"), "{files}");
1579 assert!(!files.contains("pnpm-lock"), "{files}");
1580 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1581 }
1582
1583 #[tokio::test]
1584 async fn rescue_with_only_a_stray_commits_nothing() {
1585 let (_g, repo) = scratch().await;
1586 track(&repo, "bun.lock", "a\n").await;
1587 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1588 .await
1589 .unwrap();
1590 let r = rescue_commit(&repo, "rescue").await.unwrap();
1591 assert!(!r.committed);
1592 assert_eq!(r.withheld.len(), 1);
1593 }
1594
1595 #[tokio::test]
1596 async fn rescue_keeps_a_same_manager_lockfile_update() {
1597 let (_g, repo) = scratch().await;
1598 track(&repo, "bun.lock", "a\n").await;
1599 tokio::fs::write(repo.join("bun.lock"), "b\n")
1600 .await
1601 .unwrap();
1602 let r = rescue_commit(&repo, "rescue").await.unwrap();
1603 assert!(r.committed);
1604 assert!(r.withheld.is_empty());
1605 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1606 .await
1607 .unwrap();
1608 assert_eq!(files, "bun.lock");
1609 }
1610
1611 #[tokio::test]
1612 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1613 let (_g, repo) = scratch().await;
1614 track(&repo, "other/bun.lock", "a\n").await;
1615 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1616 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1617 .await
1618 .unwrap();
1619 let r = rescue_commit(&repo, "rescue").await.unwrap();
1620 assert!(r.committed);
1621 assert!(r.withheld.is_empty());
1622 }
1623
1624 #[test]
1625 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1626 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1627 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1628 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1629 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1630 assert_eq!(
1632 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1633 1
1634 );
1635 }
1636
1637 #[tokio::test]
1638 async fn worktree_lifecycle_and_diff() {
1639 let (guard, repo) = scratch().await;
1640 let base = rev_parse(&repo, "HEAD").await.unwrap();
1641 let wt = guard.path().join("wt-a");
1642 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1643 .await
1644 .unwrap();
1645 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1646 .await
1647 .unwrap();
1648
1649 assert!(commit_all(&wt, "candidate work").await.unwrap());
1650 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1651
1652 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1653 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1654 assert!(patch.contains("b.txt"), "patch was: {patch}");
1655 assert_eq!(
1656 changed_files(&wt, &base, "HEAD").await.unwrap(),
1657 ["b.txt".to_owned()]
1658 );
1659
1660 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1662 .await
1663 .unwrap();
1664 assert_eq!(author, "magi candidate <magi@localhost>");
1665
1666 assert!(worktree_remove(&repo, &wt).await.unwrap());
1667 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1668 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1669 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1670 }
1671
1672 #[tokio::test]
1673 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1674 let (guard, repo) = scratch().await;
1675 let base = rev_parse(&repo, "HEAD").await.unwrap();
1676 let wt = guard.path().join("wt-h");
1677 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1678 .await
1679 .unwrap();
1680 let hooks = guard.path().join("hooks");
1681 tokio::fs::create_dir_all(&hooks).await.unwrap();
1682
1683 assert!(enable_worktree_config(&repo).await.unwrap());
1684 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1685
1686 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1687 .await
1688 .unwrap();
1689 assert!(!in_wt.is_empty());
1690 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1691 .await
1692 .unwrap();
1693 assert!(
1694 !in_primary.ok(),
1695 "primary worktree must keep its own hooks: {in_primary:?}"
1696 );
1697
1698 disable_worktree_config(&repo).await.unwrap();
1699 }
1700
1701 #[tokio::test]
1702 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1703 let (_g, repo) = scratch().await;
1704
1705 acquire_worktree_config(&repo).await.unwrap();
1708 acquire_worktree_config(&repo).await.unwrap();
1709
1710 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1711 .await
1712 .unwrap();
1713 assert_eq!(on, "true");
1714
1715 release_worktree_config(&repo).await.unwrap();
1719 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1720 .await
1721 .unwrap();
1722 assert_eq!(
1723 still_on, "true",
1724 "a sibling run's release must not disable the setting for the one still working"
1725 );
1726
1727 release_worktree_config(&repo).await.unwrap();
1729 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1730 .await
1731 .unwrap();
1732 assert!(
1733 !after.ok(),
1734 "the last release must turn the setting back off: {after:?}"
1735 );
1736 }
1737
1738 #[tokio::test]
1739 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1740 let (_g, repo) = scratch().await;
1741 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1742 .await
1743 .unwrap();
1744
1745 acquire_worktree_config(&repo).await.unwrap();
1750 release_worktree_config(&repo).await.unwrap();
1751
1752 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1753 .await
1754 .unwrap();
1755 assert_eq!(still_on, "true");
1756 }
1757
1758 #[tokio::test]
1759 async fn local_exclude_is_idempotent() {
1760 let (_g, repo) = scratch().await;
1761 local_exclude(&repo, "/.magi/").await.unwrap();
1762 local_exclude(&repo, "/.magi/").await.unwrap();
1763 let path = repo.join(".git/info/exclude");
1764 let body = tokio::fs::read_to_string(&path).await.unwrap();
1765 assert_eq!(body.matches("/.magi/").count(), 1);
1766 }
1767
1768 fn sh(cwd: &Path, args: &[&str]) {
1769 let out = std::process::Command::new("git")
1770 .quiet()
1771 .args(args)
1772 .current_dir(cwd)
1773 .output()
1774 .unwrap();
1775 assert!(
1776 out.status.success(),
1777 "git {args:?}: {}",
1778 String::from_utf8_lossy(&out.stderr)
1779 );
1780 }
1781
1782 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1785 let (g, repo) = scratch().await;
1786 let bare = g.path().join("bare.git");
1787 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1788 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1789 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1790 sh(&repo, &["fetch", "origin"]);
1791 (g, repo)
1792 }
1793
1794 #[test]
1795 fn remote_head_branch_strips_only_the_matching_remote() {
1796 assert_eq!(
1797 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1798 Some("main")
1799 );
1800 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1801 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1802 }
1803
1804 #[tokio::test]
1805 async fn merge_base_branch_reads_origin_head_when_present() {
1806 let (_g, repo) = with_remote().await;
1807 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1808 assert_eq!(
1809 merge_base_branch(&repo, "origin", None).await.unwrap(),
1810 "trunk"
1811 );
1812 }
1813
1814 #[tokio::test]
1815 async fn merge_base_branch_recovers_a_missing_origin_head() {
1816 let (_g, repo) = with_remote().await;
1817 sh(&repo, &["remote", "set-head", "origin", "-d"]);
1819 let head = git_raw(
1820 &repo,
1821 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1822 )
1823 .await
1824 .unwrap();
1825 assert!(!head.ok(), "precondition: no origin/HEAD");
1826 assert_eq!(
1827 merge_base_branch(&repo, "origin", None).await.unwrap(),
1828 "trunk"
1829 );
1830 }
1831
1832 #[tokio::test]
1833 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1834 let (_g, repo) = scratch().await;
1835 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1836 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1837 }
1838
1839 #[tokio::test]
1840 async fn merge_base_branch_prefers_the_explicit_base() {
1841 let (_g, repo) = with_remote().await;
1842 assert_eq!(
1843 merge_base_branch(&repo, "origin", Some("release"))
1844 .await
1845 .unwrap(),
1846 "release"
1847 );
1848 }
1849
1850 #[tokio::test]
1851 async fn merge_base_branch_names_an_unfetched_default_branch() {
1852 let (g, repo) = scratch().await;
1855 let bare = g.path().join("bare.git");
1856 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1857 sh(
1858 &repo,
1859 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1860 );
1861 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1862 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1863 assert_eq!(
1864 merge_base_branch(&repo, "origin", None).await.unwrap(),
1865 "trunk"
1866 );
1867 }
1868
1869 #[test]
1870 fn symref_branch_reads_ls_remote_output() {
1871 assert_eq!(
1872 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1873 Some("main")
1874 );
1875 assert_eq!(symref_branch("abc\tHEAD\n"), None);
1876 }
1877}