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)]
1030pub struct RebaseRecord {
1031 pub picks: Vec<String>,
1033 pub heads: Vec<String>,
1035}
1036
1037const REBASE_EXEC: &str = "git rev-parse HEAD >> \"$(git rev-parse --git-path magi-rebase-heads)\"";
1038
1039async fn rebase_file(scratch: &Path, name: &str) -> Result<PathBuf> {
1040 let path = git(scratch, &["rev-parse", "--git-path", name]).await?;
1041 let path = Path::new(path.trim());
1042 Ok(if path.is_absolute() {
1043 path.to_path_buf()
1044 } else {
1045 scratch.join(path)
1046 })
1047}
1048
1049async fn full_commit(scratch: &Path, sha: &str) -> Result<String> {
1050 anyhow::ensure!(
1051 !sha.is_empty() && sha.bytes().all(|b| b.is_ascii_hexdigit()),
1052 "invalid rebase commit id: {sha}"
1053 );
1054 rev_parse(scratch, &format!("{sha}^{{commit}}")).await
1055}
1056
1057async fn save_rebase_picks(scratch: &Path) -> Result<()> {
1060 let mut picks = Vec::new();
1061 for name in ["rebase-merge/done", "rebase-merge/git-rebase-todo"] {
1062 let text = std::fs::read_to_string(rebase_file(scratch, name).await?)?;
1063 for line in text.lines().map(str::trim) {
1064 if line.is_empty() || line.starts_with('#') {
1065 continue;
1066 }
1067 let mut words = line.split_whitespace();
1068 match words.next() {
1069 Some("pick" | "p") => {
1070 picks.push(
1071 full_commit(scratch, words.next().context("pick without commit")?).await?,
1072 );
1073 }
1074 Some("exec" | "x")
1075 if line
1076 .split_once(' ')
1077 .is_some_and(|(_, cmd)| cmd == REBASE_EXEC) => {}
1078 _ => bail!("unexpected rebase instruction: {line}"),
1079 }
1080 }
1081 }
1082 let path = rebase_file(scratch, "magi-rebase-picks").await?;
1083 let tmp = path.with_extension("tmp");
1084 std::fs::write(&tmp, picks.join("\n") + "\n")?;
1085 std::fs::rename(tmp, path)?;
1086 Ok(())
1087}
1088
1089pub async fn rebase_record(scratch: &Path) -> Result<RebaseRecord> {
1091 let mut lists = Vec::new();
1092 for name in ["magi-rebase-picks", "magi-rebase-heads"] {
1093 let text = std::fs::read_to_string(rebase_file(scratch, name).await?)
1094 .with_context(|| format!("read {name}"))?;
1095 let mut commits = Vec::new();
1096 for sha in text.lines() {
1097 let full = full_commit(scratch, sha).await?;
1098 anyhow::ensure!(full == sha, "non-full commit id in {name}");
1099 commits.push(full);
1100 }
1101 lists.push(commits);
1102 }
1103 let heads = lists.pop().unwrap();
1104 let picks = lists.pop().unwrap();
1105 anyhow::ensure!(
1106 !picks.is_empty() && picks.len() == heads.len(),
1107 "rebase pick/HEAD count mismatch"
1108 );
1109 Ok(RebaseRecord { picks, heads })
1110}
1111
1112pub fn rebase_said(text: &str) -> String {
1114 text.trim()
1115 .split(['\r', '\n'])
1116 .filter(|line| !line.trim_start().starts_with("Executing:"))
1117 .collect::<Vec<_>>()
1118 .join("\n")
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq)]
1123pub enum RebaseStart {
1124 Applied,
1127 Conflicted(String),
1130 Failed(String),
1133}
1134
1135pub async fn rebase_start(
1150 repo: &Path,
1151 scratch: &Path,
1152 branch: &str,
1153 onto: &str,
1154) -> Result<RebaseStart> {
1155 worktree_remove(repo, scratch).await.ok();
1158 git_raw(
1159 repo,
1160 &[
1161 "worktree",
1162 "add",
1163 "--force",
1164 &scratch.to_string_lossy(),
1165 branch,
1166 ],
1167 )
1168 .await?;
1169
1170 std::fs::write(rebase_file(scratch, "magi-rebase-heads").await?, "")?;
1171 let out = git_raw(
1172 scratch,
1173 &["rebase", "--empty=drop", "--exec", REBASE_EXEC, onto],
1174 )
1175 .await?;
1176 if out.ok() {
1177 worktree_remove(repo, scratch).await.ok();
1178 return Ok(RebaseStart::Applied);
1179 }
1180 let why = rebase_said(&if out.stderr.trim().is_empty() {
1181 out.stdout.trim().to_owned()
1182 } else {
1183 out.stderr.trim().to_owned()
1184 });
1185 if rebase_in_progress(scratch).await {
1186 if let Err(error) = save_rebase_picks(scratch).await {
1187 rebase_abort(repo, scratch).await;
1188 return Ok(RebaseStart::Failed(format!(
1189 "cannot record rebase picks: {error:#}"
1190 )));
1191 }
1192 return Ok(RebaseStart::Conflicted(why));
1193 }
1194 worktree_remove(repo, scratch).await.ok();
1195 Ok(RebaseStart::Failed(why))
1196}
1197
1198pub async fn rebase_in_progress(worktree: &Path) -> bool {
1200 for name in ["rebase-merge", "rebase-apply"] {
1201 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1202 continue;
1203 };
1204 let p = Path::new(p.trim());
1205 let full = if p.is_absolute() {
1206 p.to_path_buf()
1207 } else {
1208 worktree.join(p)
1209 };
1210 if full.exists() {
1211 return true;
1212 }
1213 }
1214 false
1215}
1216
1217pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1219 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1220 Ok(out
1221 .lines()
1222 .map(str::trim)
1223 .filter(|l| !l.is_empty())
1224 .map(str::to_owned)
1225 .collect())
1226}
1227
1228pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1231 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1232 worktree_remove(repo, scratch).await.ok();
1233}
1234
1235pub async fn rebase_branch_in_temp(
1243 repo: &Path,
1244 scratch: &Path,
1245 branch: &str,
1246 onto: &str,
1247) -> Result<Option<String>> {
1248 match rebase_start(repo, scratch, branch, onto).await? {
1249 RebaseStart::Applied => Ok(None),
1250 RebaseStart::Failed(why) => Ok(Some(why)),
1251 RebaseStart::Conflicted(why) => {
1252 rebase_abort(repo, scratch).await;
1253 Ok(Some(why))
1254 }
1255 }
1256}
1257
1258pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1271 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1272 git(worktree, &["clean", "-fdx"]).await?;
1273 Ok(())
1274}
1275
1276pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1295 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1296 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1297}
1298
1299pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1301 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1302}
1303
1304pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1306 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1307 .await
1308 .is_ok_and(|o| o.ok())
1309}
1310
1311pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1316 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1317 match out.code {
1318 Some(0) => Ok(true),
1319 Some(1) => Ok(false),
1320 _ => bail!(
1321 "git merge-base --is-ancestor {ancestor} {of} failed ({}): {}",
1322 crate::notices::exit_status(out.code),
1323 out.stderr
1324 ),
1325 }
1326}
1327
1328pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1330 let spec = format!("{rev}^{{commit}}");
1331 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1332 .await
1333 .ok()?;
1334 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1335}
1336
1337pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1339 let out = git(
1340 repo,
1341 &[
1342 "branch",
1343 "-a",
1344 "--contains",
1345 commit,
1346 "--format=%(refname:short)",
1347 ],
1348 )
1349 .await?;
1350 Ok(out
1351 .lines()
1352 .map(str::trim)
1353 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1354 .map(str::to_owned)
1355 .collect())
1356}
1357
1358pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1362 let out = git_raw(
1363 worktree,
1364 &[
1365 "-c",
1366 "user.name=magi candidate",
1367 "-c",
1368 "user.email=magi@localhost",
1369 "cherry-pick",
1370 "-x",
1371 commit,
1372 ],
1373 )
1374 .await?;
1375 if out.ok() {
1376 return Ok(());
1377 }
1378 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1379 bail!(
1380 "cherry-pick of {commit} failed: {}",
1381 if out.stderr.is_empty() {
1382 out.stdout
1383 } else {
1384 out.stderr
1385 }
1386 )
1387}
1388
1389pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1391 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1392}
1393
1394pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1396 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1397 .await
1398 .is_ok_and(|o| o.ok())
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403 use super::*;
1404
1405 #[test]
1406 fn worktree_holder_is_read_from_the_porcelain_listing() {
1407 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1408 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1409 worktree /wt/detached\nHEAD ccc\ndetached\n";
1410 assert_eq!(
1411 parse_worktree_holder(listing, "magi/f82f/A"),
1412 Some(PathBuf::from("/wt/cand-A"))
1413 );
1414 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1415 assert_eq!(parse_worktree_holder(listing, "other"), None);
1416 }
1417
1418 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1419 let dir = tempfile::tempdir().unwrap();
1420 let repo = dir.path().join("repo");
1421 tokio::fs::create_dir_all(&repo).await.unwrap();
1422 git(&repo, &["init", "-b", "main"]).await.unwrap();
1423 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1424 git(&repo, &["config", "user.email", "test@example.com"])
1425 .await
1426 .unwrap();
1427 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1428 git(&repo, &["add", "-A"]).await.unwrap();
1429 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1430 (dir, repo)
1431 }
1432
1433 #[tokio::test]
1434 async fn restore_to_removes_nested_repositories_inside_submodules() {
1435 let (_parent_dir, repo) = scratch().await;
1436 let (_source_dir, source) = scratch().await;
1437 sh(
1438 &repo,
1439 &[
1440 "-c",
1441 "protocol.file.allow=always",
1442 "submodule",
1443 "add",
1444 &source.to_string_lossy(),
1445 "sub",
1446 ],
1447 );
1448 sh(&repo, &["commit", "-am", "add submodule"]);
1449 let pre = rev_parse(&repo, "HEAD").await.unwrap();
1450 let sub = repo.join("sub");
1451 sh(&sub, &["init", "nested"]);
1452 tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1453 .await
1454 .unwrap();
1455
1456 restore_to(&repo, &pre).await.unwrap();
1457
1458 assert!(!sub.join("nested").exists());
1459 assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1460 assert_eq!(
1461 rev_parse(&sub, "HEAD").await.unwrap(),
1462 rev_parse(&source, "HEAD").await.unwrap()
1463 );
1464 assert!(
1465 git(&repo, &["status", "--porcelain"])
1466 .await
1467 .unwrap()
1468 .is_empty()
1469 );
1470 }
1471
1472 #[tokio::test]
1473 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1474 let (_g, repo) = scratch().await;
1475
1476 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1478 tokio::fs::write(repo.join("b.txt"), "side\n")
1479 .await
1480 .unwrap();
1481 git(&repo, &["add", "-A"]).await.unwrap();
1482 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1483
1484 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 = repo.parent().unwrap().join("rebase-scratch");
1494 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1495 .await
1496 .unwrap();
1497 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1498 assert_eq!(
1499 commits_ahead(&repo, "main", "side").await.unwrap(),
1500 1,
1501 "one commit, replayed onto the new base"
1502 );
1503 assert!(
1504 !scratch_tree.exists(),
1505 "the throwaway worktree is not left behind"
1506 );
1507
1508 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1510 tokio::fs::write(repo.join("a.txt"), "clash\n")
1511 .await
1512 .unwrap();
1513 git(&repo, &["add", "-A"]).await.unwrap();
1514 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1515 git(&repo, &["checkout", "main"]).await.unwrap();
1516 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1517 .await
1518 .unwrap();
1519 git(&repo, &["add", "-A"]).await.unwrap();
1520 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1521
1522 let before = rev_parse(&repo, "clash").await.unwrap();
1523 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1524 .await
1525 .unwrap()
1526 .expect("a same-line clash cannot be rebased silently");
1527 assert!(
1528 why.to_lowercase().contains("conflict"),
1529 "the reason is what git said, which is what a person needs: {why}"
1530 );
1531 assert_eq!(
1532 rev_parse(&repo, "clash").await.unwrap(),
1533 before,
1534 "a failed rebase leaves the branch exactly where it was"
1535 );
1536 assert!(!scratch_tree.exists(), "and cleans up after itself");
1537 }
1538
1539 #[tokio::test]
1540 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1541 let (_g, repo) = scratch().await;
1542 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1543 for name in ["b.txt", "c.txt"] {
1544 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1545 git(&repo, &["add", "-A"]).await.unwrap();
1546 git(
1547 &repo,
1548 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1549 )
1550 .await
1551 .unwrap();
1552 }
1553 git(&repo, &["checkout", "main"]).await.unwrap();
1554 let before = rev_parse(&repo, "main").await.unwrap();
1555
1556 let out = merge_squash(&repo, "side", "an explicit subject")
1557 .await
1558 .unwrap();
1559 assert!(out.ok(), "{}", out.stderr);
1560 assert_eq!(
1561 commits_ahead(&repo, &before, "main").await.unwrap(),
1562 1,
1563 "squash adds exactly one commit onto the tip, not one per candidate commit"
1564 );
1565 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1566 assert_eq!(
1567 subject, "an explicit subject",
1568 "the candidate's own placeholder subject must not survive: {subject}"
1569 );
1570 }
1571
1572 #[tokio::test]
1573 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1574 let (_g, repo) = scratch().await;
1575 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1576 tokio::fs::write(repo.join("b.txt"), "side\n")
1577 .await
1578 .unwrap();
1579 git(&repo, &["add", "-A"]).await.unwrap();
1580 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1581 git(&repo, &["checkout", "main"]).await.unwrap();
1582
1583 let before = rev_parse(&repo, "side").await.unwrap();
1584 let out = merge_ff_only(&repo, "side").await.unwrap();
1585 assert!(out.ok(), "{}", out.stderr);
1586 assert_eq!(
1587 rev_parse(&repo, "main").await.unwrap(),
1588 before,
1589 "a fast-forward moves the base tip to the branch, no merge commit"
1590 );
1591 }
1592
1593 #[tokio::test]
1594 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1595 let (_g, repo) = scratch().await;
1596 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1597 tokio::fs::write(repo.join("b.txt"), "side\n")
1598 .await
1599 .unwrap();
1600 git(&repo, &["add", "-A"]).await.unwrap();
1601 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1602
1603 git(&repo, &["checkout", "main"]).await.unwrap();
1605 tokio::fs::write(repo.join("c.txt"), "main\n")
1606 .await
1607 .unwrap();
1608 git(&repo, &["add", "-A"]).await.unwrap();
1609 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1610
1611 let before = rev_parse(&repo, "main").await.unwrap();
1612 let out = merge_ff_only(&repo, "side").await.unwrap();
1613 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1614 assert_eq!(
1615 rev_parse(&repo, "main").await.unwrap(),
1616 before,
1617 "a refused fast-forward must not touch main"
1618 );
1619 }
1620
1621 #[tokio::test]
1622 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1623 let (guard, repo) = scratch().await;
1624
1625 git(&repo, &["branch", "side"]).await.unwrap();
1629 let side_wt = guard.path().join("side-wt");
1630 git(
1631 &repo,
1632 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1633 )
1634 .await
1635 .unwrap();
1636 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1637 .await
1638 .unwrap();
1639 git(&side_wt, &["add", "-A"]).await.unwrap();
1640 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1641
1642 git(&repo, &["checkout", "main"]).await.unwrap();
1644 tokio::fs::write(repo.join("c.txt"), "main\n")
1645 .await
1646 .unwrap();
1647 git(&repo, &["add", "-A"]).await.unwrap();
1648 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1649
1650 let scratch_tree = guard.path().join("rebase-scratch");
1652 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1653 .await
1654 .unwrap();
1655 assert!(clean.is_none());
1656
1657 assert_eq!(
1661 rev_parse(&side_wt, "HEAD").await.unwrap(),
1662 rev_parse(&repo, "side").await.unwrap(),
1663 "HEAD follows the moved ref"
1664 );
1665 assert!(
1666 !side_wt.join("c.txt").exists(),
1667 "stale until synced: main's new file has not reached this worktree's disk"
1668 );
1669
1670 sync_to_head(&side_wt).await.unwrap();
1671 assert!(side_wt.join("c.txt").is_file(), "synced now");
1672 assert!(
1673 side_wt.join("b.txt").is_file(),
1674 "the worktree's own committed work survives the sync"
1675 );
1676 assert!(is_clean(&side_wt).await.unwrap());
1677 }
1678
1679 #[tokio::test]
1680 async fn clean_repo_reports_clean_then_dirty() {
1681 let (_g, repo) = scratch().await;
1682 assert!(is_clean(&repo).await.unwrap());
1683 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1684 assert!(!is_clean(&repo).await.unwrap());
1685 }
1686
1687 async fn track(repo: &Path, name: &str, body: &str) {
1688 let p = repo.join(name);
1689 if let Some(d) = p.parent() {
1690 tokio::fs::create_dir_all(d).await.unwrap();
1691 }
1692 tokio::fs::write(&p, body).await.unwrap();
1693 git(repo, &["add", name]).await.unwrap();
1694 git(
1695 repo,
1696 &[
1697 "-c",
1698 "user.name=t",
1699 "-c",
1700 "user.email=t@localhost",
1701 "commit",
1702 "-q",
1703 "-m",
1704 "seed",
1705 ],
1706 )
1707 .await
1708 .unwrap();
1709 }
1710
1711 #[tokio::test]
1712 async fn rescue_withholds_a_foreign_lockfile() {
1713 let (_g, repo) = scratch().await;
1714 track(&repo, "web/bun.lock", "a\n").await;
1715 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1716 .await
1717 .unwrap();
1718 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1719 .await
1720 .unwrap();
1721
1722 let r = rescue_commit(&repo, "rescue").await.unwrap();
1723 assert!(r.committed);
1724 assert_eq!(
1725 r.withheld,
1726 [Stray {
1727 path: "web/pnpm-lock.yaml".to_owned(),
1728 manager: "pnpm".to_owned(),
1729 kept_by: "web/bun.lock".to_owned(),
1730 }]
1731 );
1732 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1733 .await
1734 .unwrap();
1735 assert!(files.contains("web/app.ts"), "{files}");
1736 assert!(!files.contains("pnpm-lock"), "{files}");
1737 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1738 }
1739
1740 #[tokio::test]
1741 async fn rescue_with_only_a_stray_commits_nothing() {
1742 let (_g, repo) = scratch().await;
1743 track(&repo, "bun.lock", "a\n").await;
1744 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1745 .await
1746 .unwrap();
1747 let r = rescue_commit(&repo, "rescue").await.unwrap();
1748 assert!(!r.committed);
1749 assert_eq!(r.withheld.len(), 1);
1750 }
1751
1752 #[tokio::test]
1753 async fn rescue_keeps_a_same_manager_lockfile_update() {
1754 let (_g, repo) = scratch().await;
1755 track(&repo, "bun.lock", "a\n").await;
1756 tokio::fs::write(repo.join("bun.lock"), "b\n")
1757 .await
1758 .unwrap();
1759 let r = rescue_commit(&repo, "rescue").await.unwrap();
1760 assert!(r.committed);
1761 assert!(r.withheld.is_empty());
1762 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1763 .await
1764 .unwrap();
1765 assert_eq!(files, "bun.lock");
1766 }
1767
1768 #[tokio::test]
1769 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1770 let (_g, repo) = scratch().await;
1771 track(&repo, "other/bun.lock", "a\n").await;
1772 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1773 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1774 .await
1775 .unwrap();
1776 let r = rescue_commit(&repo, "rescue").await.unwrap();
1777 assert!(r.committed);
1778 assert!(r.withheld.is_empty());
1779 }
1780
1781 #[test]
1782 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1783 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1784 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1785 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1786 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1787 assert_eq!(
1789 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1790 1
1791 );
1792 }
1793
1794 #[tokio::test]
1795 async fn clean_removal_preserves_edits_while_scratch_removal_discards_them() {
1796 let (guard, repo) = scratch().await;
1797 let wt = guard.path().join("scratch-wt");
1798 worktree_add_detached(&repo, &wt, "HEAD").await.unwrap();
1799 tokio::fs::write(wt.join("built.txt"), "build output")
1800 .await
1801 .unwrap();
1802 assert!(!worktree_remove_clean(&repo, &wt).await.unwrap());
1803 assert!(wt.join("built.txt").exists());
1804 let listing = git(&repo, &["worktree", "list", "--porcelain"])
1805 .await
1806 .unwrap();
1807 let registered = listing
1808 .lines()
1809 .filter_map(|line| line.strip_prefix("worktree "))
1810 .map(PathBuf::from)
1811 .find(|path| path.canonicalize().unwrap() == wt.canonicalize().unwrap())
1812 .expect("the refused removal must retain the worktree registration");
1813 assert!(worktree_remove(&repo, &wt).await.unwrap());
1814 assert!(!wt.exists());
1815 let listing = git(&repo, &["worktree", "list", "--porcelain"])
1816 .await
1817 .unwrap();
1818 assert!(!listing.lines().any(|line| {
1819 line.strip_prefix("worktree ")
1820 .is_some_and(|path| Path::new(path) == registered)
1821 }));
1822 }
1823
1824 #[tokio::test]
1825 async fn worktree_lifecycle_and_diff() {
1826 let (guard, repo) = scratch().await;
1827 let base = rev_parse(&repo, "HEAD").await.unwrap();
1828 let wt = guard.path().join("wt-a");
1829 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1830 .await
1831 .unwrap();
1832 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1833 .await
1834 .unwrap();
1835
1836 assert!(commit_all(&wt, "candidate work").await.unwrap());
1837 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1838
1839 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1840 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1841 assert!(patch.contains("b.txt"), "patch was: {patch}");
1842 assert_eq!(
1843 changed_files(&wt, &base, "HEAD").await.unwrap(),
1844 ["b.txt".to_owned()]
1845 );
1846
1847 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1849 .await
1850 .unwrap();
1851 assert_eq!(author, "magi candidate <magi@localhost>");
1852
1853 assert!(worktree_remove(&repo, &wt).await.unwrap());
1854 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1855 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1856 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1857 }
1858
1859 #[tokio::test]
1860 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1861 let (guard, repo) = scratch().await;
1862 let base = rev_parse(&repo, "HEAD").await.unwrap();
1863 let wt = guard.path().join("wt-h");
1864 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1865 .await
1866 .unwrap();
1867 let hooks = guard.path().join("hooks");
1868 tokio::fs::create_dir_all(&hooks).await.unwrap();
1869
1870 assert!(enable_worktree_config(&repo).await.unwrap());
1871 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1872
1873 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1874 .await
1875 .unwrap();
1876 assert!(!in_wt.is_empty());
1877 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1878 .await
1879 .unwrap();
1880 assert!(
1881 !in_primary.ok(),
1882 "primary worktree must keep its own hooks: {in_primary:?}"
1883 );
1884
1885 disable_worktree_config(&repo).await.unwrap();
1886 }
1887
1888 #[tokio::test]
1889 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1890 let (_g, repo) = scratch().await;
1891
1892 acquire_worktree_config(&repo).await.unwrap();
1895 acquire_worktree_config(&repo).await.unwrap();
1896
1897 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1898 .await
1899 .unwrap();
1900 assert_eq!(on, "true");
1901
1902 release_worktree_config(&repo).await.unwrap();
1906 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1907 .await
1908 .unwrap();
1909 assert_eq!(
1910 still_on, "true",
1911 "a sibling run's release must not disable the setting for the one still working"
1912 );
1913
1914 release_worktree_config(&repo).await.unwrap();
1916 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1917 .await
1918 .unwrap();
1919 assert!(
1920 !after.ok(),
1921 "the last release must turn the setting back off: {after:?}"
1922 );
1923 }
1924
1925 #[tokio::test]
1926 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1927 let (_g, repo) = scratch().await;
1928 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1929 .await
1930 .unwrap();
1931
1932 acquire_worktree_config(&repo).await.unwrap();
1937 release_worktree_config(&repo).await.unwrap();
1938
1939 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1940 .await
1941 .unwrap();
1942 assert_eq!(still_on, "true");
1943 }
1944
1945 #[tokio::test]
1946 async fn local_exclude_is_idempotent() {
1947 let (_g, repo) = scratch().await;
1948 local_exclude(&repo, "/.magi/").await.unwrap();
1949 local_exclude(&repo, "/.magi/").await.unwrap();
1950 let path = repo.join(".git/info/exclude");
1951 let body = tokio::fs::read_to_string(&path).await.unwrap();
1952 assert_eq!(body.matches("/.magi/").count(), 1);
1953 }
1954
1955 fn sh(cwd: &Path, args: &[&str]) {
1956 let out = std::process::Command::new("git")
1957 .quiet()
1958 .args(args)
1959 .current_dir(cwd)
1960 .output()
1961 .unwrap();
1962 assert!(
1963 out.status.success(),
1964 "git {args:?}: {}",
1965 String::from_utf8_lossy(&out.stderr)
1966 );
1967 }
1968
1969 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1972 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(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1976 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1977 sh(&repo, &["fetch", "origin"]);
1978 (g, repo)
1979 }
1980
1981 #[test]
1982 fn remote_head_branch_strips_only_the_matching_remote() {
1983 assert_eq!(
1984 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1985 Some("main")
1986 );
1987 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1988 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1989 }
1990
1991 #[tokio::test]
1992 async fn merge_base_branch_reads_origin_head_when_present() {
1993 let (_g, repo) = with_remote().await;
1994 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1995 assert_eq!(
1996 merge_base_branch(&repo, "origin", None).await.unwrap(),
1997 "trunk"
1998 );
1999 }
2000
2001 #[tokio::test]
2002 async fn merge_base_branch_recovers_a_missing_origin_head() {
2003 let (_g, repo) = with_remote().await;
2004 sh(&repo, &["remote", "set-head", "origin", "-d"]);
2006 let head = git_raw(
2007 &repo,
2008 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
2009 )
2010 .await
2011 .unwrap();
2012 assert!(!head.ok(), "precondition: no origin/HEAD");
2013 assert_eq!(
2014 merge_base_branch(&repo, "origin", None).await.unwrap(),
2015 "trunk"
2016 );
2017 }
2018
2019 #[tokio::test]
2020 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
2021 let (_g, repo) = scratch().await;
2022 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
2023 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
2024 }
2025
2026 #[tokio::test]
2027 async fn merge_base_branch_prefers_the_explicit_base() {
2028 let (_g, repo) = with_remote().await;
2029 assert_eq!(
2030 merge_base_branch(&repo, "origin", Some("release"))
2031 .await
2032 .unwrap(),
2033 "release"
2034 );
2035 }
2036
2037 #[tokio::test]
2038 async fn merge_base_branch_names_an_unfetched_default_branch() {
2039 let (g, repo) = scratch().await;
2042 let bare = g.path().join("bare.git");
2043 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
2044 sh(
2045 &repo,
2046 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
2047 );
2048 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
2049 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
2050 assert_eq!(
2051 merge_base_branch(&repo, "origin", None).await.unwrap(),
2052 "trunk"
2053 );
2054 }
2055
2056 #[test]
2057 fn symref_branch_reads_ls_remote_output() {
2058 assert_eq!(
2059 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
2060 Some("main")
2061 );
2062 assert_eq!(symref_branch("abc\tHEAD\n"), None);
2063 }
2064}