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 #[cfg(test)]
35 crate::web::request_cost::git();
36 let out = Command::new("git")
37 .args(args)
38 .current_dir(cwd)
39 .quiet()
40 .env("GIT_TERMINAL_PROMPT", "0")
43 .env("GIT_EDITOR", "true")
44 .stdin(Stdio::null())
45 .output()
46 .await
47 .with_context(|| format!("spawn git {}", args.join(" ")))?;
48 Ok(GitOut {
49 code: out.status.code(),
50 stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
51 stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
52 })
53}
54
55pub async fn fetch_origin(cwd: &Path, timeout: std::time::Duration) -> Result<()> {
67 let url = git(cwd, &["remote", "get-url", "origin"]).await?;
68 #[cfg(test)]
69 crate::web::request_cost::git();
70 let fut = Command::new("git")
71 .args([
72 "fetch",
73 "--quiet",
74 "--no-tags",
75 "--no-recurse-submodules",
76 "--no-write-fetch-head",
77 "--",
78 url.as_str(),
79 "+refs/heads/*:refs/remotes/origin/*",
80 ])
81 .current_dir(cwd)
82 .quiet()
83 .env("GIT_TERMINAL_PROMPT", "0")
84 .stdin(Stdio::null())
85 .kill_on_drop(true)
86 .output();
87 let out = tokio::time::timeout(timeout, fut)
88 .await
89 .map_err(|_| anyhow::anyhow!("git fetch timed out after {}s", timeout.as_secs()))?
90 .context("spawn git fetch")?;
91 if !out.status.success() {
92 bail!(
93 "git fetch failed ({}): {}",
94 crate::notices::exit_status(out.status.code()),
95 String::from_utf8_lossy(&out.stderr).trim_end()
96 );
97 }
98 Ok(())
99}
100
101pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
103 let out = git_raw(cwd, args).await?;
104 if !out.ok() {
105 bail!(
106 "git {} failed in {} ({}): {}",
107 args.join(" "),
108 cwd.display(),
109 crate::notices::exit_status(out.code),
110 if out.stderr.is_empty() {
111 out.stdout.as_str()
112 } else {
113 out.stderr.as_str()
114 }
115 );
116 }
117 Ok(out.stdout)
118}
119
120pub async fn toplevel(path: &Path) -> Result<PathBuf> {
122 let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
123 Ok(PathBuf::from(out))
124}
125
126pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
128 git(repo, &["rev-parse", rev]).await
129}
130
131pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
133 let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
134 Ok(if out.ok() && !out.stdout.is_empty() {
135 Some(out.stdout)
136 } else {
137 None
138 })
139}
140
141pub fn remote_head_branch(remote: &str, symref: &str) -> Option<String> {
146 symref
147 .trim()
148 .strip_prefix(&format!("refs/remotes/{remote}/"))
149 .filter(|b| !b.is_empty() && *b != "HEAD")
150 .map(str::to_owned)
151}
152
153pub async fn ensure_remote_head(repo: &Path, remote: &str, base: &str) {
159 let head = format!("refs/remotes/{remote}/HEAD");
160 if git_raw(repo, &["symbolic-ref", "--quiet", &head])
161 .await
162 .is_ok_and(|o| o.ok())
163 {
164 return;
165 }
166 let target = format!("refs/remotes/{remote}/{base}");
167 let _ = git_raw(repo, &["symbolic-ref", &head, &target]).await;
168}
169
170fn symref_branch(out: &str) -> Option<String> {
173 out.lines()
174 .find_map(|l| l.strip_prefix("ref: refs/heads/"))
175 .and_then(|l| l.split_whitespace().next())
176 .map(str::to_owned)
177}
178
179pub async fn merge_base_branch(
186 repo: &Path,
187 remote: &str,
188 explicit: Option<&str>,
189) -> Result<String> {
190 if let Some(b) = explicit.map(str::trim).filter(|b| !b.is_empty()) {
191 return Ok(b.to_owned());
192 }
193 let head = format!("refs/remotes/{remote}/HEAD");
194 let read = || async {
195 let out = git_raw(repo, &["symbolic-ref", "--quiet", &head])
196 .await
197 .ok()?;
198 if out.ok() {
199 remote_head_branch(remote, &out.stdout)
200 } else {
201 None
202 }
203 };
204 if let Some(b) = read().await {
205 return Ok(b);
206 }
207 let set = git_raw(repo, &["remote", "set-head", remote, "-a"]).await;
208 if let Some(b) = read().await {
209 return Ok(b);
210 }
211 if let Ok(o) = git_raw(repo, &["ls-remote", "--symref", remote, "HEAD"]).await
216 && o.ok()
217 && let Some(b) = symref_branch(&o.stdout)
218 {
219 return Ok(b);
220 }
221 let why = match set {
222 Ok(o) if !o.ok() => o.stderr.lines().next().unwrap_or("").trim().to_owned(),
223 Ok(_) => "the remote reported no default branch".to_owned(),
224 Err(e) => e.to_string(),
225 };
226 bail!(
227 "cannot tell which branch of `{remote}` is the base ({why}): set [merge] base \
228 (and remote) in magi.toml, or run `git remote set-head {remote} -a`. magi does \
229 not fall back to the checked-out branch, which may be detached or stale"
230 )
231}
232
233pub async fn is_clean(repo: &Path) -> Result<bool> {
235 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
236}
237
238pub async fn status_porcelain(repo: &Path) -> Result<String> {
240 git(repo, &["status", "--porcelain"]).await
241}
242
243pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
245 if let Some(parent) = path.parent() {
246 tokio::fs::create_dir_all(parent).await.ok();
247 }
248 let path_s = path.to_string_lossy().to_string();
249 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
250 .await
251 .map(|_| ())
252}
253
254pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
256 if let Some(parent) = path.parent() {
257 tokio::fs::create_dir_all(parent).await.ok();
258 }
259 let path_s = path.to_string_lossy().to_string();
260 git(repo, &["worktree", "add", "--detach", &path_s, rev])
261 .await
262 .map(|_| ())
263}
264
265pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
267 git(worktree, &["checkout", "--detach", rev]).await?;
268 git(worktree, &["reset", "--hard", rev]).await?;
269 git(worktree, &["clean", "-fdx"]).await?;
270 Ok(())
271}
272
273pub async fn worktree_holding(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
278 let listing = git(repo, &["worktree", "list", "--porcelain"]).await?;
279 Ok(parse_worktree_holder(&listing, branch))
280}
281
282fn parse_worktree_holder(listing: &str, branch: &str) -> Option<PathBuf> {
283 let want = format!("refs/heads/{branch}");
284 let mut path: Option<PathBuf> = None;
285 for line in listing.lines() {
286 if let Some(p) = line.strip_prefix("worktree ") {
287 path = Some(PathBuf::from(p));
288 } else if line.strip_prefix("branch ") == Some(want.as_str()) {
289 return path;
290 }
291 }
292 None
293}
294
295pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
299 let path_s = path.to_string_lossy().to_string();
300 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
301 if out.ok() {
302 return Ok(true);
303 }
304 if !path.exists() {
306 git_raw(repo, &["worktree", "prune"]).await?;
307 }
308 Ok(false)
309}
310
311pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
315 let path_s = path.to_string_lossy().to_string();
316 Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
317}
318
319pub async fn remove_worktree_from_linked(dir: &Path) {
328 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
329 return;
330 };
331 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
332 return;
333 };
334 let admin = Path::new(admin);
337 let Some(common) = admin.parent().and_then(Path::parent) else {
338 return;
339 };
340 let common_s = common.to_string_lossy();
341 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
342}
343
344pub async fn worktree_prune(repo: &Path) -> Result<()> {
353 git(repo, &["worktree", "prune"]).await.map(|_| ())
354}
355
356pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
358 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
359}
360
361pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
363 let refname = format!("refs/heads/{branch}");
364 Ok(
365 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
366 .await?
367 .ok(),
368 )
369}
370
371pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
374 let refname = format!("refs/heads/{branch}");
375 let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
376 match out.code {
377 Some(0) => Ok(true),
378 Some(2) => Ok(false),
379 code => bail!(
380 "git ls-remote {remote} failed ({}): {}",
381 crate::notices::exit_status(code),
382 out.stderr
383 ),
384 }
385}
386
387pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
389 let range = format!("{base}...{head}");
390 git(
391 worktree,
392 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
393 )
394 .await
395}
396
397pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
399 let range = format!("{base}...{head}");
400 git(worktree, &["diff", "--no-color", "--stat", &range]).await
401}
402
403pub async fn changed_lines(worktree: &Path, base: &str, head: &str) -> Option<usize> {
407 let out = git_raw(worktree, &["diff", "--numstat", base, head])
408 .await
409 .ok()?;
410 if !out.ok() {
411 return None;
412 }
413 let mut total = 0usize;
414 for line in out.stdout.lines().filter(|l| !l.trim().is_empty()) {
415 let mut cols = line.split('\t');
416 let added: usize = cols.next()?.parse().ok()?;
417 let removed: usize = cols.next()?.parse().ok()?;
418 total = total.saturating_add(added).saturating_add(removed);
419 }
420 Some(total)
421}
422
423pub async fn restore_to(worktree: &Path, rev: &str) -> Result<()> {
427 git(worktree, &["reset", "--hard", "-q", rev]).await?;
428 git(worktree, &["clean", "-ffdq"]).await?;
431 git(
437 worktree,
438 &[
439 "submodule",
440 "--quiet",
441 "update",
442 "--recursive",
443 "--force",
444 "--checkout",
445 ],
446 )
447 .await?;
448 git(
449 worktree,
450 &[
451 "submodule",
452 "--quiet",
453 "foreach",
454 "--recursive",
455 "git clean -ffdq",
456 ],
457 )
458 .await?;
459 Ok(())
460}
461
462pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
464 let range = format!("{base}...{head}");
465 let out = git(worktree, &["diff", "--name-only", &range]).await?;
466 Ok(out.lines().map(str::to_owned).collect())
467}
468
469pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
473 let range = format!("{base}..{head}");
474 let out = git(
475 worktree,
476 &[
477 "log",
478 "--reverse",
479 "--no-color",
480 "--format=%s%x1f%b%x00",
481 &range,
482 ],
483 )
484 .await?;
485 Ok(out
486 .split('\0')
487 .filter_map(|rec| {
488 let rec = rec.trim_start_matches('\n');
489 if rec.trim().is_empty() {
490 return None;
491 }
492 let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
493 Some((subject.trim().to_owned(), body.trim().to_owned()))
494 })
495 .collect())
496}
497
498pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
500 let range = format!("{base}..{head}");
501 git(
502 worktree,
503 &["log", "--reverse", "--format=%s%n%b%n--", &range],
504 )
505 .await
506}
507
508pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
511 let range = format!("{base}..{head}");
512 let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
513 let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
514 parts.pop();
516 Ok(parts)
517}
518
519pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
521 let range = format!("{base}..{head}");
522 let out = git(worktree, &["rev-list", "--count", &range]).await?;
523 Ok(out.trim().parse().unwrap_or(0))
524}
525
526pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
533 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
534 return Ok(false);
535 }
536 git(worktree, &["add", "-A"]).await?;
537 if !crate::worktree_setup::withheld_paths(worktree)
539 .await
540 .is_empty()
541 {
542 crate::worktree_setup::unstage_products(worktree).await?;
543 if git_raw(worktree, &["diff", "--cached", "--quiet"])
544 .await?
545 .ok()
546 {
547 return Ok(false);
548 }
549 }
550 let out = git_raw(
551 worktree,
552 &[
553 "-c",
554 "user.name=magi candidate",
555 "-c",
556 "user.email=magi@localhost",
557 "commit",
558 "--no-verify",
559 "-m",
560 message,
561 ],
562 )
563 .await?;
564 if !out.ok() {
565 bail!("rescue commit failed: {}", out.stderr);
566 }
567 Ok(true)
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct Stray {
574 pub path: String,
576 pub manager: String,
578 pub kept_by: String,
581}
582
583#[derive(Debug, Default)]
585pub struct Rescue {
586 pub committed: bool,
588 pub withheld: Vec<Stray>,
590}
591
592fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
594 Some(match name {
595 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
596 "yarn.lock" => ("node", "yarn"),
597 "pnpm-lock.yaml" => ("node", "pnpm"),
598 "bun.lock" | "bun.lockb" => ("node", "bun"),
599 "poetry.lock" => ("python", "poetry"),
600 "uv.lock" => ("python", "uv"),
601 "Pipfile.lock" => ("python", "pipenv"),
602 "pdm.lock" => ("python", "pdm"),
603 "Cargo.lock" => ("rust", "cargo"),
604 _ => return None,
605 })
606}
607
608fn split_dir(path: &str) -> (&str, &str) {
609 path.rsplit_once('/').unwrap_or(("", path))
610}
611
612pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
620 let mut out = Vec::new();
621 for path in untracked {
622 let (dir, name) = split_dir(path);
623 let Some((eco, manager)) = lock_kind(name) else {
624 continue;
625 };
626 let beside = |other: &String| split_dir(other).0 == dir;
627 let kept_by = if manager == "cargo" {
628 let has_manifest = tracked
629 .iter()
630 .chain(untracked)
631 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
632 if has_manifest {
633 continue;
634 }
635 "no Cargo.toml in the directory".to_owned()
636 } else {
637 let Some(other) = tracked.iter().find(|p| {
638 beside(p)
639 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
640 }) else {
641 continue;
642 };
643 other.clone()
644 };
645 out.push(Stray {
646 path: path.clone(),
647 manager: manager.to_owned(),
648 kept_by,
649 });
650 }
651 out
652}
653
654async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
655 let out = git(worktree, args).await?;
656 Ok(out
657 .split('\0')
658 .filter(|s| !s.is_empty())
659 .map(str::to_owned)
660 .collect())
661}
662
663pub async fn snapshot_uncommitted(worktree: &Path, destination: &Path) -> Result<()> {
667 if destination.exists() {
668 if destination.join("complete").exists() {
669 return Ok(());
670 }
671 bail!("earlier recovery snapshot is incomplete; retain original worktree");
672 }
673 std::fs::create_dir_all(destination)?;
674 for (name, args) in [
675 ("status.txt", vec!["status", "--porcelain=v1"]),
676 ("staged.patch", vec!["diff", "--binary", "--cached"]),
677 ("unstaged.patch", vec!["diff", "--binary"]),
678 ] {
679 let out = Command::new("git")
682 .args(&args)
683 .current_dir(worktree)
684 .quiet()
685 .stdin(Stdio::null())
686 .output()
687 .await?;
688 if !out.status.success() {
689 bail!(
690 "recovery snapshot failed: {}",
691 String::from_utf8_lossy(&out.stderr)
692 );
693 }
694 std::fs::write(destination.join(name), out.stdout)?;
695 }
696 let paths = nul_list(
697 worktree,
698 &["ls-files", "-z", "--others", "--exclude-standard"],
699 )
700 .await?;
701 for name in paths {
702 let relative = Path::new(&name);
703 if relative
704 .components()
705 .any(|c| !matches!(c, std::path::Component::Normal(_)))
706 {
707 bail!("unsafe recovery path");
708 }
709 let source = worktree.join(relative);
710 let target = destination.join("untracked").join(relative);
711 std::fs::create_dir_all(target.parent().context("untracked recovery parent")?)?;
712 if std::fs::symlink_metadata(&source)?.file_type().is_symlink() {
713 bail!("untracked symlink requires manual recovery: {name}");
715 }
716 std::fs::copy(source, target)?;
717 }
718 std::fs::write(destination.join("complete"), "snapshot captured\n")?;
719 Ok(())
720}
721
722pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
728 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
729 return Ok(Rescue::default());
730 }
731 let untracked = nul_list(
732 worktree,
733 &["ls-files", "-z", "--others", "--exclude-standard"],
734 )
735 .await?;
736 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
737 let withheld = stray_lockfiles(&untracked, &tracked);
738
739 git(worktree, &["add", "-A"]).await?;
740 if !withheld.is_empty() {
741 let mut args = vec!["reset", "-q", "--"];
742 args.extend(withheld.iter().map(|s| s.path.as_str()));
743 git(worktree, &args).await?;
744 }
745 crate::worktree_setup::unstage_products(worktree).await?;
746 if git_raw(worktree, &["diff", "--cached", "--quiet"])
747 .await?
748 .ok()
749 {
750 return Ok(Rescue {
751 committed: false,
752 withheld,
753 });
754 }
755 let out = git_raw(
756 worktree,
757 &[
758 "-c",
759 "user.name=magi candidate",
760 "-c",
761 "user.email=magi@localhost",
762 "commit",
763 "--no-verify",
764 "-m",
765 message,
766 ],
767 )
768 .await?;
769 if !out.ok() {
770 bail!("rescue commit failed: {}", out.stderr);
771 }
772 Ok(Rescue {
773 committed: true,
774 withheld,
775 })
776}
777
778pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
783 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
784 if out.ok() && out.stdout.trim() == "true" {
785 return Ok(false);
786 }
787 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
788 Ok(true)
789}
790
791pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
793 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
794 Ok(())
795}
796
797struct WorktreeConfigRef {
800 count: usize,
802 we_enabled: bool,
808}
809
810static WORKTREE_CONFIG: std::sync::LazyLock<
817 std::sync::Mutex<
818 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
819 >,
820> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
821
822fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
824 let mut map = WORKTREE_CONFIG
825 .lock()
826 .unwrap_or_else(std::sync::PoisonError::into_inner);
827 map.entry(repo.to_path_buf())
828 .or_insert_with(|| {
829 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
830 count: 0,
831 we_enabled: false,
832 }))
833 })
834 .clone()
835}
836
837pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
854 let slot = worktree_config_slot(repo);
855 let mut entry = slot.lock().await;
856 entry.count += 1;
857 if entry.count == 1 {
858 entry.we_enabled = enable_worktree_config(repo).await?;
859 }
860 Ok(())
861}
862
863pub async fn release_worktree_config(repo: &Path) -> Result<()> {
869 let slot = worktree_config_slot(repo);
870 let mut entry = slot.lock().await;
871 entry.count = entry.count.saturating_sub(1);
872 if entry.count == 0 && entry.we_enabled {
873 disable_worktree_config(repo).await?;
874 entry.we_enabled = false;
875 }
876 Ok(())
877}
878
879pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
885 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
886 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
887 .await
888 .map(|_| ())
889}
890
891pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
893 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
894 let path = worktree.join(git_dir);
895 if let Some(parent) = path.parent() {
896 tokio::fs::create_dir_all(parent).await.ok();
897 }
898 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
899 if body.lines().any(|l| l.trim() == pattern) {
900 return Ok(());
901 }
902 if !body.is_empty() && !body.ends_with('\n') {
903 body.push('\n');
904 }
905 body.push_str(pattern);
906 body.push('\n');
907 tokio::fs::write(&path, body)
908 .await
909 .with_context(|| format!("write {}", path.display()))?;
910 Ok(())
911}
912
913pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
919 git_raw(
920 repo,
921 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
922 )
923 .await
924}
925
926pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
937 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
938 if !staged.ok() {
939 return Ok(staged);
940 }
941 git_raw(repo, &["commit", "-m", message]).await
942}
943
944pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
954 git_raw(repo, &["merge", "--ff-only", branch]).await
955}
956
957pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
959 git_raw(repo, &["push", "-u", remote, branch]).await
960}
961
962pub async fn push_as(
965 repo: &Path,
966 remote: &str,
967 local: &str,
968 head: &str,
969 expected: &str,
970) -> Result<GitOut> {
971 let lease = format!("--force-with-lease=refs/heads/{head}:{expected}");
972 let refspec = format!("refs/heads/{local}:refs/heads/{head}");
973 git_raw(repo, &["push", &lease, remote, &refspec]).await
974}
975
976pub async fn remote_head(repo: &Path, remote: &str, branch: &str) -> Result<Option<String>> {
978 let reference = format!("refs/heads/{branch}");
979 let out = git(repo, &["ls-remote", "--refs", remote, &reference]).await?;
980 Ok(out.split_whitespace().next().map(str::to_owned))
981}
982
983pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
991 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
992}
993
994pub async fn push_pinned(
1003 repo: &Path,
1004 remote: &str,
1005 branch: &str,
1006 expected: &str,
1007) -> Result<GitOut> {
1008 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
1009 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1010 git_raw(repo, &["push", &lease, remote, &refspec]).await
1011}
1012
1013pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
1016 let out = git(repo, &["cherry", upstream, head]).await?;
1017 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
1018 for line in out.lines() {
1019 if let Some(sha) = line.strip_prefix("+ ") {
1020 unmatched.push(sha.trim().to_owned());
1021 } else if let Some(sha) = line.strip_prefix("- ") {
1022 matched.push(sha.trim().to_owned());
1023 }
1024 }
1025 Ok((unmatched, matched))
1026}
1027
1028#[derive(Debug, Clone, PartialEq, Eq)]
1032pub struct CommitKey {
1033 pub sha: String,
1035 pub key: String,
1037}
1038
1039pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
1041 let out = git(
1042 repo,
1043 &[
1044 "log",
1045 "--no-merges",
1046 "--reverse",
1047 "--date=raw",
1048 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
1049 range,
1050 ],
1051 )
1052 .await?;
1053 Ok(out
1054 .lines()
1055 .filter_map(|l| l.split_once('\u{1f}'))
1056 .map(|(sha, key)| CommitKey {
1057 sha: sha.to_owned(),
1058 key: key.to_owned(),
1059 })
1060 .collect())
1061}
1062
1063#[derive(Debug, Clone, PartialEq, Eq)]
1065pub enum RebaseStart {
1066 Applied,
1069 Conflicted(String),
1072 Failed(String),
1075}
1076
1077pub async fn rebase_start(
1092 repo: &Path,
1093 scratch: &Path,
1094 branch: &str,
1095 onto: &str,
1096) -> Result<RebaseStart> {
1097 worktree_remove(repo, scratch).await.ok();
1100 git_raw(
1101 repo,
1102 &[
1103 "worktree",
1104 "add",
1105 "--force",
1106 &scratch.to_string_lossy(),
1107 branch,
1108 ],
1109 )
1110 .await?;
1111
1112 let out = git_raw(scratch, &["rebase", onto]).await?;
1113 if out.ok() {
1114 worktree_remove(repo, scratch).await.ok();
1115 return Ok(RebaseStart::Applied);
1116 }
1117 let why = if out.stderr.trim().is_empty() {
1118 out.stdout.trim().to_owned()
1119 } else {
1120 out.stderr.trim().to_owned()
1121 };
1122 if rebase_in_progress(scratch).await {
1123 return Ok(RebaseStart::Conflicted(why));
1124 }
1125 worktree_remove(repo, scratch).await.ok();
1126 Ok(RebaseStart::Failed(why))
1127}
1128
1129pub async fn rebase_in_progress(worktree: &Path) -> bool {
1131 for name in ["rebase-merge", "rebase-apply"] {
1132 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1133 continue;
1134 };
1135 let p = Path::new(p.trim());
1136 let full = if p.is_absolute() {
1137 p.to_path_buf()
1138 } else {
1139 worktree.join(p)
1140 };
1141 if full.exists() {
1142 return true;
1143 }
1144 }
1145 false
1146}
1147
1148pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1150 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1151 Ok(out
1152 .lines()
1153 .map(str::trim)
1154 .filter(|l| !l.is_empty())
1155 .map(str::to_owned)
1156 .collect())
1157}
1158
1159pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1162 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1163 worktree_remove(repo, scratch).await.ok();
1164}
1165
1166pub async fn rebase_branch_in_temp(
1174 repo: &Path,
1175 scratch: &Path,
1176 branch: &str,
1177 onto: &str,
1178) -> Result<Option<String>> {
1179 match rebase_start(repo, scratch, branch, onto).await? {
1180 RebaseStart::Applied => Ok(None),
1181 RebaseStart::Failed(why) => Ok(Some(why)),
1182 RebaseStart::Conflicted(why) => {
1183 rebase_abort(repo, scratch).await;
1184 Ok(Some(why))
1185 }
1186 }
1187}
1188
1189pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1202 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1203 git(worktree, &["clean", "-fdx"]).await?;
1204 Ok(())
1205}
1206
1207pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1226 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1227 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1228}
1229
1230pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1232 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1233}
1234
1235pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1237 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1238 .await
1239 .is_ok_and(|o| o.ok())
1240}
1241
1242pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1247 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1248 match out.code {
1249 Some(0) => Ok(true),
1250 Some(1) => Ok(false),
1251 _ => bail!(
1252 "git merge-base --is-ancestor {ancestor} {of} failed ({}): {}",
1253 crate::notices::exit_status(out.code),
1254 out.stderr
1255 ),
1256 }
1257}
1258
1259pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1261 let spec = format!("{rev}^{{commit}}");
1262 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1263 .await
1264 .ok()?;
1265 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1266}
1267
1268pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1270 let out = git(
1271 repo,
1272 &[
1273 "branch",
1274 "-a",
1275 "--contains",
1276 commit,
1277 "--format=%(refname:short)",
1278 ],
1279 )
1280 .await?;
1281 Ok(out
1282 .lines()
1283 .map(str::trim)
1284 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1285 .map(str::to_owned)
1286 .collect())
1287}
1288
1289pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1293 let out = git_raw(
1294 worktree,
1295 &[
1296 "-c",
1297 "user.name=magi candidate",
1298 "-c",
1299 "user.email=magi@localhost",
1300 "cherry-pick",
1301 "-x",
1302 commit,
1303 ],
1304 )
1305 .await?;
1306 if out.ok() {
1307 return Ok(());
1308 }
1309 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1310 bail!(
1311 "cherry-pick of {commit} failed: {}",
1312 if out.stderr.is_empty() {
1313 out.stdout
1314 } else {
1315 out.stderr
1316 }
1317 )
1318}
1319
1320pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1322 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1323}
1324
1325pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1327 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1328 .await
1329 .is_ok_and(|o| o.ok())
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::*;
1335
1336 #[test]
1337 fn worktree_holder_is_read_from_the_porcelain_listing() {
1338 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1339 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1340 worktree /wt/detached\nHEAD ccc\ndetached\n";
1341 assert_eq!(
1342 parse_worktree_holder(listing, "magi/f82f/A"),
1343 Some(PathBuf::from("/wt/cand-A"))
1344 );
1345 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1346 assert_eq!(parse_worktree_holder(listing, "other"), None);
1347 }
1348
1349 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1350 let dir = tempfile::tempdir().unwrap();
1351 let repo = dir.path().join("repo");
1352 tokio::fs::create_dir_all(&repo).await.unwrap();
1353 git(&repo, &["init", "-b", "main"]).await.unwrap();
1354 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1355 git(&repo, &["config", "user.email", "test@example.com"])
1356 .await
1357 .unwrap();
1358 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1359 git(&repo, &["add", "-A"]).await.unwrap();
1360 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1361 (dir, repo)
1362 }
1363
1364 #[tokio::test]
1365 async fn restore_to_removes_nested_repositories_inside_submodules() {
1366 let (_parent_dir, repo) = scratch().await;
1367 let (_source_dir, source) = scratch().await;
1368 sh(
1369 &repo,
1370 &[
1371 "-c",
1372 "protocol.file.allow=always",
1373 "submodule",
1374 "add",
1375 &source.to_string_lossy(),
1376 "sub",
1377 ],
1378 );
1379 sh(&repo, &["commit", "-am", "add submodule"]);
1380 let pre = rev_parse(&repo, "HEAD").await.unwrap();
1381 let sub = repo.join("sub");
1382 sh(&sub, &["init", "nested"]);
1383 tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1384 .await
1385 .unwrap();
1386
1387 restore_to(&repo, &pre).await.unwrap();
1388
1389 assert!(!sub.join("nested").exists());
1390 assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1391 assert_eq!(
1392 rev_parse(&sub, "HEAD").await.unwrap(),
1393 rev_parse(&source, "HEAD").await.unwrap()
1394 );
1395 assert!(
1396 git(&repo, &["status", "--porcelain"])
1397 .await
1398 .unwrap()
1399 .is_empty()
1400 );
1401 }
1402
1403 #[tokio::test]
1404 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1405 let (_g, repo) = scratch().await;
1406
1407 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1409 tokio::fs::write(repo.join("b.txt"), "side\n")
1410 .await
1411 .unwrap();
1412 git(&repo, &["add", "-A"]).await.unwrap();
1413 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1414
1415 git(&repo, &["checkout", "main"]).await.unwrap();
1418 tokio::fs::write(repo.join("c.txt"), "main\n")
1419 .await
1420 .unwrap();
1421 git(&repo, &["add", "-A"]).await.unwrap();
1422 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1423
1424 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1425 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1426 .await
1427 .unwrap();
1428 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1429 assert_eq!(
1430 commits_ahead(&repo, "main", "side").await.unwrap(),
1431 1,
1432 "one commit, replayed onto the new base"
1433 );
1434 assert!(
1435 !scratch_tree.exists(),
1436 "the throwaway worktree is not left behind"
1437 );
1438
1439 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1441 tokio::fs::write(repo.join("a.txt"), "clash\n")
1442 .await
1443 .unwrap();
1444 git(&repo, &["add", "-A"]).await.unwrap();
1445 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1446 git(&repo, &["checkout", "main"]).await.unwrap();
1447 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1448 .await
1449 .unwrap();
1450 git(&repo, &["add", "-A"]).await.unwrap();
1451 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1452
1453 let before = rev_parse(&repo, "clash").await.unwrap();
1454 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1455 .await
1456 .unwrap()
1457 .expect("a same-line clash cannot be rebased silently");
1458 assert!(
1459 why.to_lowercase().contains("conflict"),
1460 "the reason is what git said, which is what a person needs: {why}"
1461 );
1462 assert_eq!(
1463 rev_parse(&repo, "clash").await.unwrap(),
1464 before,
1465 "a failed rebase leaves the branch exactly where it was"
1466 );
1467 assert!(!scratch_tree.exists(), "and cleans up after itself");
1468 }
1469
1470 #[tokio::test]
1471 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1472 let (_g, repo) = scratch().await;
1473 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1474 for name in ["b.txt", "c.txt"] {
1475 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1476 git(&repo, &["add", "-A"]).await.unwrap();
1477 git(
1478 &repo,
1479 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1480 )
1481 .await
1482 .unwrap();
1483 }
1484 git(&repo, &["checkout", "main"]).await.unwrap();
1485 let before = rev_parse(&repo, "main").await.unwrap();
1486
1487 let out = merge_squash(&repo, "side", "an explicit subject")
1488 .await
1489 .unwrap();
1490 assert!(out.ok(), "{}", out.stderr);
1491 assert_eq!(
1492 commits_ahead(&repo, &before, "main").await.unwrap(),
1493 1,
1494 "squash adds exactly one commit onto the tip, not one per candidate commit"
1495 );
1496 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1497 assert_eq!(
1498 subject, "an explicit subject",
1499 "the candidate's own placeholder subject must not survive: {subject}"
1500 );
1501 }
1502
1503 #[tokio::test]
1504 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1505 let (_g, repo) = scratch().await;
1506 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1507 tokio::fs::write(repo.join("b.txt"), "side\n")
1508 .await
1509 .unwrap();
1510 git(&repo, &["add", "-A"]).await.unwrap();
1511 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1512 git(&repo, &["checkout", "main"]).await.unwrap();
1513
1514 let before = rev_parse(&repo, "side").await.unwrap();
1515 let out = merge_ff_only(&repo, "side").await.unwrap();
1516 assert!(out.ok(), "{}", out.stderr);
1517 assert_eq!(
1518 rev_parse(&repo, "main").await.unwrap(),
1519 before,
1520 "a fast-forward moves the base tip to the branch, no merge commit"
1521 );
1522 }
1523
1524 #[tokio::test]
1525 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1526 let (_g, repo) = scratch().await;
1527 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1528 tokio::fs::write(repo.join("b.txt"), "side\n")
1529 .await
1530 .unwrap();
1531 git(&repo, &["add", "-A"]).await.unwrap();
1532 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1533
1534 git(&repo, &["checkout", "main"]).await.unwrap();
1536 tokio::fs::write(repo.join("c.txt"), "main\n")
1537 .await
1538 .unwrap();
1539 git(&repo, &["add", "-A"]).await.unwrap();
1540 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1541
1542 let before = rev_parse(&repo, "main").await.unwrap();
1543 let out = merge_ff_only(&repo, "side").await.unwrap();
1544 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1545 assert_eq!(
1546 rev_parse(&repo, "main").await.unwrap(),
1547 before,
1548 "a refused fast-forward must not touch main"
1549 );
1550 }
1551
1552 #[tokio::test]
1553 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1554 let (guard, repo) = scratch().await;
1555
1556 git(&repo, &["branch", "side"]).await.unwrap();
1560 let side_wt = guard.path().join("side-wt");
1561 git(
1562 &repo,
1563 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1564 )
1565 .await
1566 .unwrap();
1567 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1568 .await
1569 .unwrap();
1570 git(&side_wt, &["add", "-A"]).await.unwrap();
1571 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1572
1573 git(&repo, &["checkout", "main"]).await.unwrap();
1575 tokio::fs::write(repo.join("c.txt"), "main\n")
1576 .await
1577 .unwrap();
1578 git(&repo, &["add", "-A"]).await.unwrap();
1579 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1580
1581 let scratch_tree = guard.path().join("rebase-scratch");
1583 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1584 .await
1585 .unwrap();
1586 assert!(clean.is_none());
1587
1588 assert_eq!(
1592 rev_parse(&side_wt, "HEAD").await.unwrap(),
1593 rev_parse(&repo, "side").await.unwrap(),
1594 "HEAD follows the moved ref"
1595 );
1596 assert!(
1597 !side_wt.join("c.txt").exists(),
1598 "stale until synced: main's new file has not reached this worktree's disk"
1599 );
1600
1601 sync_to_head(&side_wt).await.unwrap();
1602 assert!(side_wt.join("c.txt").is_file(), "synced now");
1603 assert!(
1604 side_wt.join("b.txt").is_file(),
1605 "the worktree's own committed work survives the sync"
1606 );
1607 assert!(is_clean(&side_wt).await.unwrap());
1608 }
1609
1610 #[tokio::test]
1611 async fn clean_repo_reports_clean_then_dirty() {
1612 let (_g, repo) = scratch().await;
1613 assert!(is_clean(&repo).await.unwrap());
1614 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1615 assert!(!is_clean(&repo).await.unwrap());
1616 }
1617
1618 async fn track(repo: &Path, name: &str, body: &str) {
1619 let p = repo.join(name);
1620 if let Some(d) = p.parent() {
1621 tokio::fs::create_dir_all(d).await.unwrap();
1622 }
1623 tokio::fs::write(&p, body).await.unwrap();
1624 git(repo, &["add", name]).await.unwrap();
1625 git(
1626 repo,
1627 &[
1628 "-c",
1629 "user.name=t",
1630 "-c",
1631 "user.email=t@localhost",
1632 "commit",
1633 "-q",
1634 "-m",
1635 "seed",
1636 ],
1637 )
1638 .await
1639 .unwrap();
1640 }
1641
1642 #[tokio::test]
1643 async fn rescue_withholds_a_foreign_lockfile() {
1644 let (_g, repo) = scratch().await;
1645 track(&repo, "web/bun.lock", "a\n").await;
1646 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1647 .await
1648 .unwrap();
1649 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1650 .await
1651 .unwrap();
1652
1653 let r = rescue_commit(&repo, "rescue").await.unwrap();
1654 assert!(r.committed);
1655 assert_eq!(
1656 r.withheld,
1657 [Stray {
1658 path: "web/pnpm-lock.yaml".to_owned(),
1659 manager: "pnpm".to_owned(),
1660 kept_by: "web/bun.lock".to_owned(),
1661 }]
1662 );
1663 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1664 .await
1665 .unwrap();
1666 assert!(files.contains("web/app.ts"), "{files}");
1667 assert!(!files.contains("pnpm-lock"), "{files}");
1668 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1669 }
1670
1671 #[tokio::test]
1672 async fn rescue_with_only_a_stray_commits_nothing() {
1673 let (_g, repo) = scratch().await;
1674 track(&repo, "bun.lock", "a\n").await;
1675 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1676 .await
1677 .unwrap();
1678 let r = rescue_commit(&repo, "rescue").await.unwrap();
1679 assert!(!r.committed);
1680 assert_eq!(r.withheld.len(), 1);
1681 }
1682
1683 #[tokio::test]
1684 async fn rescue_keeps_a_same_manager_lockfile_update() {
1685 let (_g, repo) = scratch().await;
1686 track(&repo, "bun.lock", "a\n").await;
1687 tokio::fs::write(repo.join("bun.lock"), "b\n")
1688 .await
1689 .unwrap();
1690 let r = rescue_commit(&repo, "rescue").await.unwrap();
1691 assert!(r.committed);
1692 assert!(r.withheld.is_empty());
1693 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1694 .await
1695 .unwrap();
1696 assert_eq!(files, "bun.lock");
1697 }
1698
1699 #[tokio::test]
1700 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1701 let (_g, repo) = scratch().await;
1702 track(&repo, "other/bun.lock", "a\n").await;
1703 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1704 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1705 .await
1706 .unwrap();
1707 let r = rescue_commit(&repo, "rescue").await.unwrap();
1708 assert!(r.committed);
1709 assert!(r.withheld.is_empty());
1710 }
1711
1712 #[test]
1713 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1714 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1715 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1716 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1717 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1718 assert_eq!(
1720 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1721 1
1722 );
1723 }
1724
1725 #[tokio::test]
1726 async fn clean_removal_preserves_edits_while_scratch_removal_discards_them() {
1727 let (guard, repo) = scratch().await;
1728 let wt = guard.path().join("scratch-wt");
1729 worktree_add_detached(&repo, &wt, "HEAD").await.unwrap();
1730 tokio::fs::write(wt.join("built.txt"), "build output")
1731 .await
1732 .unwrap();
1733 assert!(!worktree_remove_clean(&repo, &wt).await.unwrap());
1734 assert!(wt.join("built.txt").exists());
1735 let listing = git(&repo, &["worktree", "list", "--porcelain"])
1736 .await
1737 .unwrap();
1738 let registered = listing
1739 .lines()
1740 .filter_map(|line| line.strip_prefix("worktree "))
1741 .map(PathBuf::from)
1742 .find(|path| path.canonicalize().unwrap() == wt.canonicalize().unwrap())
1743 .expect("the refused removal must retain the worktree registration");
1744 assert!(worktree_remove(&repo, &wt).await.unwrap());
1745 assert!(!wt.exists());
1746 let listing = git(&repo, &["worktree", "list", "--porcelain"])
1747 .await
1748 .unwrap();
1749 assert!(!listing.lines().any(|line| {
1750 line.strip_prefix("worktree ")
1751 .is_some_and(|path| Path::new(path) == registered)
1752 }));
1753 }
1754
1755 #[tokio::test]
1756 async fn worktree_lifecycle_and_diff() {
1757 let (guard, repo) = scratch().await;
1758 let base = rev_parse(&repo, "HEAD").await.unwrap();
1759 let wt = guard.path().join("wt-a");
1760 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1761 .await
1762 .unwrap();
1763 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1764 .await
1765 .unwrap();
1766
1767 assert!(commit_all(&wt, "candidate work").await.unwrap());
1768 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1769
1770 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1771 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1772 assert!(patch.contains("b.txt"), "patch was: {patch}");
1773 assert_eq!(
1774 changed_files(&wt, &base, "HEAD").await.unwrap(),
1775 ["b.txt".to_owned()]
1776 );
1777
1778 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1780 .await
1781 .unwrap();
1782 assert_eq!(author, "magi candidate <magi@localhost>");
1783
1784 assert!(worktree_remove(&repo, &wt).await.unwrap());
1785 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1786 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1787 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1788 }
1789
1790 #[tokio::test]
1791 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1792 let (guard, repo) = scratch().await;
1793 let base = rev_parse(&repo, "HEAD").await.unwrap();
1794 let wt = guard.path().join("wt-h");
1795 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1796 .await
1797 .unwrap();
1798 let hooks = guard.path().join("hooks");
1799 tokio::fs::create_dir_all(&hooks).await.unwrap();
1800
1801 assert!(enable_worktree_config(&repo).await.unwrap());
1802 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1803
1804 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1805 .await
1806 .unwrap();
1807 assert!(!in_wt.is_empty());
1808 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1809 .await
1810 .unwrap();
1811 assert!(
1812 !in_primary.ok(),
1813 "primary worktree must keep its own hooks: {in_primary:?}"
1814 );
1815
1816 disable_worktree_config(&repo).await.unwrap();
1817 }
1818
1819 #[tokio::test]
1820 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1821 let (_g, repo) = scratch().await;
1822
1823 acquire_worktree_config(&repo).await.unwrap();
1826 acquire_worktree_config(&repo).await.unwrap();
1827
1828 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1829 .await
1830 .unwrap();
1831 assert_eq!(on, "true");
1832
1833 release_worktree_config(&repo).await.unwrap();
1837 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1838 .await
1839 .unwrap();
1840 assert_eq!(
1841 still_on, "true",
1842 "a sibling run's release must not disable the setting for the one still working"
1843 );
1844
1845 release_worktree_config(&repo).await.unwrap();
1847 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1848 .await
1849 .unwrap();
1850 assert!(
1851 !after.ok(),
1852 "the last release must turn the setting back off: {after:?}"
1853 );
1854 }
1855
1856 #[tokio::test]
1857 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1858 let (_g, repo) = scratch().await;
1859 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1860 .await
1861 .unwrap();
1862
1863 acquire_worktree_config(&repo).await.unwrap();
1868 release_worktree_config(&repo).await.unwrap();
1869
1870 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1871 .await
1872 .unwrap();
1873 assert_eq!(still_on, "true");
1874 }
1875
1876 #[tokio::test]
1877 async fn local_exclude_is_idempotent() {
1878 let (_g, repo) = scratch().await;
1879 local_exclude(&repo, "/.magi/").await.unwrap();
1880 local_exclude(&repo, "/.magi/").await.unwrap();
1881 let path = repo.join(".git/info/exclude");
1882 let body = tokio::fs::read_to_string(&path).await.unwrap();
1883 assert_eq!(body.matches("/.magi/").count(), 1);
1884 }
1885
1886 fn sh(cwd: &Path, args: &[&str]) {
1887 let out = std::process::Command::new("git")
1888 .quiet()
1889 .args(args)
1890 .current_dir(cwd)
1891 .output()
1892 .unwrap();
1893 assert!(
1894 out.status.success(),
1895 "git {args:?}: {}",
1896 String::from_utf8_lossy(&out.stderr)
1897 );
1898 }
1899
1900 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1903 let (g, repo) = scratch().await;
1904 let bare = g.path().join("bare.git");
1905 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1906 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1907 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1908 sh(&repo, &["fetch", "origin"]);
1909 (g, repo)
1910 }
1911
1912 #[test]
1913 fn remote_head_branch_strips_only_the_matching_remote() {
1914 assert_eq!(
1915 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1916 Some("main")
1917 );
1918 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1919 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1920 }
1921
1922 #[tokio::test]
1923 async fn merge_base_branch_reads_origin_head_when_present() {
1924 let (_g, repo) = with_remote().await;
1925 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1926 assert_eq!(
1927 merge_base_branch(&repo, "origin", None).await.unwrap(),
1928 "trunk"
1929 );
1930 }
1931
1932 #[tokio::test]
1933 async fn merge_base_branch_recovers_a_missing_origin_head() {
1934 let (_g, repo) = with_remote().await;
1935 sh(&repo, &["remote", "set-head", "origin", "-d"]);
1937 let head = git_raw(
1938 &repo,
1939 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1940 )
1941 .await
1942 .unwrap();
1943 assert!(!head.ok(), "precondition: no origin/HEAD");
1944 assert_eq!(
1945 merge_base_branch(&repo, "origin", None).await.unwrap(),
1946 "trunk"
1947 );
1948 }
1949
1950 #[tokio::test]
1951 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1952 let (_g, repo) = scratch().await;
1953 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1954 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1955 }
1956
1957 #[tokio::test]
1958 async fn merge_base_branch_prefers_the_explicit_base() {
1959 let (_g, repo) = with_remote().await;
1960 assert_eq!(
1961 merge_base_branch(&repo, "origin", Some("release"))
1962 .await
1963 .unwrap(),
1964 "release"
1965 );
1966 }
1967
1968 #[tokio::test]
1969 async fn merge_base_branch_names_an_unfetched_default_branch() {
1970 let (g, repo) = scratch().await;
1973 let bare = g.path().join("bare.git");
1974 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1975 sh(
1976 &repo,
1977 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1978 );
1979 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1980 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1981 assert_eq!(
1982 merge_base_branch(&repo, "origin", None).await.unwrap(),
1983 "trunk"
1984 );
1985 }
1986
1987 #[test]
1988 fn symref_branch_reads_ls_remote_output() {
1989 assert_eq!(
1990 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1991 Some("main")
1992 );
1993 assert_eq!(symref_branch("abc\tHEAD\n"), None);
1994 }
1995}