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", "-z"]).await?;
1220 Ok(out
1221 .split('\0')
1222 .filter(|l| !l.is_empty())
1223 .map(str::to_owned)
1224 .collect())
1225}
1226
1227pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1230 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1231 worktree_remove(repo, scratch).await.ok();
1232}
1233
1234pub async fn rebase_branch_in_temp(
1242 repo: &Path,
1243 scratch: &Path,
1244 branch: &str,
1245 onto: &str,
1246) -> Result<Option<String>> {
1247 match rebase_start(repo, scratch, branch, onto).await? {
1248 RebaseStart::Applied => Ok(None),
1249 RebaseStart::Failed(why) => Ok(Some(why)),
1250 RebaseStart::Conflicted(why) => {
1251 rebase_abort(repo, scratch).await;
1252 Ok(Some(why))
1253 }
1254 }
1255}
1256
1257pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1270 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1271 git(worktree, &["clean", "-fdx"]).await?;
1272 Ok(())
1273}
1274
1275pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1294 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1295 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1296}
1297
1298pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1300 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1301}
1302
1303pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1305 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1306 .await
1307 .is_ok_and(|o| o.ok())
1308}
1309
1310pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1315 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1316 match out.code {
1317 Some(0) => Ok(true),
1318 Some(1) => Ok(false),
1319 _ => bail!(
1320 "git merge-base --is-ancestor {ancestor} {of} failed ({}): {}",
1321 crate::notices::exit_status(out.code),
1322 out.stderr
1323 ),
1324 }
1325}
1326
1327pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1329 let spec = format!("{rev}^{{commit}}");
1330 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1331 .await
1332 .ok()?;
1333 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1334}
1335
1336pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1338 let out = git(
1339 repo,
1340 &[
1341 "branch",
1342 "-a",
1343 "--contains",
1344 commit,
1345 "--format=%(refname:short)",
1346 ],
1347 )
1348 .await?;
1349 Ok(out
1350 .lines()
1351 .map(str::trim)
1352 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1353 .map(str::to_owned)
1354 .collect())
1355}
1356
1357pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1361 let out = git_raw(
1362 worktree,
1363 &[
1364 "-c",
1365 "user.name=magi candidate",
1366 "-c",
1367 "user.email=magi@localhost",
1368 "cherry-pick",
1369 "-x",
1370 commit,
1371 ],
1372 )
1373 .await?;
1374 if out.ok() {
1375 return Ok(());
1376 }
1377 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1378 bail!(
1379 "cherry-pick of {commit} failed: {}",
1380 if out.stderr.is_empty() {
1381 out.stdout
1382 } else {
1383 out.stderr
1384 }
1385 )
1386}
1387
1388pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1390 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1391}
1392
1393pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1395 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1396 .await
1397 .is_ok_and(|o| o.ok())
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403
1404 #[test]
1405 fn worktree_holder_is_read_from_the_porcelain_listing() {
1406 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1407 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1408 worktree /wt/detached\nHEAD ccc\ndetached\n";
1409 assert_eq!(
1410 parse_worktree_holder(listing, "magi/f82f/A"),
1411 Some(PathBuf::from("/wt/cand-A"))
1412 );
1413 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1414 assert_eq!(parse_worktree_holder(listing, "other"), None);
1415 }
1416
1417 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1418 let dir = tempfile::tempdir().unwrap();
1419 let repo = dir.path().join("repo");
1420 tokio::fs::create_dir_all(&repo).await.unwrap();
1421 git(&repo, &["init", "-b", "main"]).await.unwrap();
1422 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1423 git(&repo, &["config", "user.email", "test@example.com"])
1424 .await
1425 .unwrap();
1426 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1427 git(&repo, &["add", "-A"]).await.unwrap();
1428 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1429 (dir, repo)
1430 }
1431
1432 #[tokio::test]
1433 async fn restore_to_removes_nested_repositories_inside_submodules() {
1434 let (_parent_dir, repo) = scratch().await;
1435 let (_source_dir, source) = scratch().await;
1436 sh(
1437 &repo,
1438 &[
1439 "-c",
1440 "protocol.file.allow=always",
1441 "submodule",
1442 "add",
1443 &source.to_string_lossy(),
1444 "sub",
1445 ],
1446 );
1447 sh(&repo, &["commit", "-am", "add submodule"]);
1448 let pre = rev_parse(&repo, "HEAD").await.unwrap();
1449 let sub = repo.join("sub");
1450 sh(&sub, &["init", "nested"]);
1451 tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1452 .await
1453 .unwrap();
1454
1455 restore_to(&repo, &pre).await.unwrap();
1456
1457 assert!(!sub.join("nested").exists());
1458 assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1459 assert_eq!(
1460 rev_parse(&sub, "HEAD").await.unwrap(),
1461 rev_parse(&source, "HEAD").await.unwrap()
1462 );
1463 assert!(
1464 git(&repo, &["status", "--porcelain"])
1465 .await
1466 .unwrap()
1467 .is_empty()
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1473 let (_g, repo) = scratch().await;
1474
1475 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1477 tokio::fs::write(repo.join("b.txt"), "side\n")
1478 .await
1479 .unwrap();
1480 git(&repo, &["add", "-A"]).await.unwrap();
1481 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1482
1483 git(&repo, &["checkout", "main"]).await.unwrap();
1486 tokio::fs::write(repo.join("c.txt"), "main\n")
1487 .await
1488 .unwrap();
1489 git(&repo, &["add", "-A"]).await.unwrap();
1490 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1491
1492 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1493 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1494 .await
1495 .unwrap();
1496 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1497 assert_eq!(
1498 commits_ahead(&repo, "main", "side").await.unwrap(),
1499 1,
1500 "one commit, replayed onto the new base"
1501 );
1502 assert!(
1503 !scratch_tree.exists(),
1504 "the throwaway worktree is not left behind"
1505 );
1506
1507 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1509 tokio::fs::write(repo.join("a.txt"), "clash\n")
1510 .await
1511 .unwrap();
1512 git(&repo, &["add", "-A"]).await.unwrap();
1513 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1514 git(&repo, &["checkout", "main"]).await.unwrap();
1515 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1516 .await
1517 .unwrap();
1518 git(&repo, &["add", "-A"]).await.unwrap();
1519 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1520
1521 let before = rev_parse(&repo, "clash").await.unwrap();
1522 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1523 .await
1524 .unwrap()
1525 .expect("a same-line clash cannot be rebased silently");
1526 assert!(
1527 why.to_lowercase().contains("conflict"),
1528 "the reason is what git said, which is what a person needs: {why}"
1529 );
1530 assert_eq!(
1531 rev_parse(&repo, "clash").await.unwrap(),
1532 before,
1533 "a failed rebase leaves the branch exactly where it was"
1534 );
1535 assert!(!scratch_tree.exists(), "and cleans up after itself");
1536 }
1537
1538 #[tokio::test]
1539 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1540 let (_g, repo) = scratch().await;
1541 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1542 for name in ["b.txt", "c.txt"] {
1543 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1544 git(&repo, &["add", "-A"]).await.unwrap();
1545 git(
1546 &repo,
1547 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1548 )
1549 .await
1550 .unwrap();
1551 }
1552 git(&repo, &["checkout", "main"]).await.unwrap();
1553 let before = rev_parse(&repo, "main").await.unwrap();
1554
1555 let out = merge_squash(&repo, "side", "an explicit subject")
1556 .await
1557 .unwrap();
1558 assert!(out.ok(), "{}", out.stderr);
1559 assert_eq!(
1560 commits_ahead(&repo, &before, "main").await.unwrap(),
1561 1,
1562 "squash adds exactly one commit onto the tip, not one per candidate commit"
1563 );
1564 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1565 assert_eq!(
1566 subject, "an explicit subject",
1567 "the candidate's own placeholder subject must not survive: {subject}"
1568 );
1569 }
1570
1571 #[tokio::test]
1572 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1573 let (_g, repo) = scratch().await;
1574 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1575 tokio::fs::write(repo.join("b.txt"), "side\n")
1576 .await
1577 .unwrap();
1578 git(&repo, &["add", "-A"]).await.unwrap();
1579 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1580 git(&repo, &["checkout", "main"]).await.unwrap();
1581
1582 let before = rev_parse(&repo, "side").await.unwrap();
1583 let out = merge_ff_only(&repo, "side").await.unwrap();
1584 assert!(out.ok(), "{}", out.stderr);
1585 assert_eq!(
1586 rev_parse(&repo, "main").await.unwrap(),
1587 before,
1588 "a fast-forward moves the base tip to the branch, no merge commit"
1589 );
1590 }
1591
1592 #[tokio::test]
1593 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1594 let (_g, repo) = scratch().await;
1595 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1596 tokio::fs::write(repo.join("b.txt"), "side\n")
1597 .await
1598 .unwrap();
1599 git(&repo, &["add", "-A"]).await.unwrap();
1600 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1601
1602 git(&repo, &["checkout", "main"]).await.unwrap();
1604 tokio::fs::write(repo.join("c.txt"), "main\n")
1605 .await
1606 .unwrap();
1607 git(&repo, &["add", "-A"]).await.unwrap();
1608 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1609
1610 let before = rev_parse(&repo, "main").await.unwrap();
1611 let out = merge_ff_only(&repo, "side").await.unwrap();
1612 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1613 assert_eq!(
1614 rev_parse(&repo, "main").await.unwrap(),
1615 before,
1616 "a refused fast-forward must not touch main"
1617 );
1618 }
1619
1620 #[tokio::test]
1621 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1622 let (guard, repo) = scratch().await;
1623
1624 git(&repo, &["branch", "side"]).await.unwrap();
1628 let side_wt = guard.path().join("side-wt");
1629 git(
1630 &repo,
1631 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1632 )
1633 .await
1634 .unwrap();
1635 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1636 .await
1637 .unwrap();
1638 git(&side_wt, &["add", "-A"]).await.unwrap();
1639 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1640
1641 git(&repo, &["checkout", "main"]).await.unwrap();
1643 tokio::fs::write(repo.join("c.txt"), "main\n")
1644 .await
1645 .unwrap();
1646 git(&repo, &["add", "-A"]).await.unwrap();
1647 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1648
1649 let scratch_tree = guard.path().join("rebase-scratch");
1651 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1652 .await
1653 .unwrap();
1654 assert!(clean.is_none());
1655
1656 assert_eq!(
1660 rev_parse(&side_wt, "HEAD").await.unwrap(),
1661 rev_parse(&repo, "side").await.unwrap(),
1662 "HEAD follows the moved ref"
1663 );
1664 assert!(
1665 !side_wt.join("c.txt").exists(),
1666 "stale until synced: main's new file has not reached this worktree's disk"
1667 );
1668
1669 sync_to_head(&side_wt).await.unwrap();
1670 assert!(side_wt.join("c.txt").is_file(), "synced now");
1671 assert!(
1672 side_wt.join("b.txt").is_file(),
1673 "the worktree's own committed work survives the sync"
1674 );
1675 assert!(is_clean(&side_wt).await.unwrap());
1676 }
1677
1678 #[tokio::test]
1679 async fn clean_repo_reports_clean_then_dirty() {
1680 let (_g, repo) = scratch().await;
1681 assert!(is_clean(&repo).await.unwrap());
1682 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1683 assert!(!is_clean(&repo).await.unwrap());
1684 }
1685
1686 async fn track(repo: &Path, name: &str, body: &str) {
1687 let p = repo.join(name);
1688 if let Some(d) = p.parent() {
1689 tokio::fs::create_dir_all(d).await.unwrap();
1690 }
1691 tokio::fs::write(&p, body).await.unwrap();
1692 git(repo, &["add", name]).await.unwrap();
1693 git(
1694 repo,
1695 &[
1696 "-c",
1697 "user.name=t",
1698 "-c",
1699 "user.email=t@localhost",
1700 "commit",
1701 "-q",
1702 "-m",
1703 "seed",
1704 ],
1705 )
1706 .await
1707 .unwrap();
1708 }
1709
1710 #[tokio::test]
1711 async fn rescue_withholds_a_foreign_lockfile() {
1712 let (_g, repo) = scratch().await;
1713 track(&repo, "web/bun.lock", "a\n").await;
1714 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1715 .await
1716 .unwrap();
1717 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1718 .await
1719 .unwrap();
1720
1721 let r = rescue_commit(&repo, "rescue").await.unwrap();
1722 assert!(r.committed);
1723 assert_eq!(
1724 r.withheld,
1725 [Stray {
1726 path: "web/pnpm-lock.yaml".to_owned(),
1727 manager: "pnpm".to_owned(),
1728 kept_by: "web/bun.lock".to_owned(),
1729 }]
1730 );
1731 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1732 .await
1733 .unwrap();
1734 assert!(files.contains("web/app.ts"), "{files}");
1735 assert!(!files.contains("pnpm-lock"), "{files}");
1736 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1737 }
1738
1739 #[tokio::test]
1740 async fn rescue_with_only_a_stray_commits_nothing() {
1741 let (_g, repo) = scratch().await;
1742 track(&repo, "bun.lock", "a\n").await;
1743 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1744 .await
1745 .unwrap();
1746 let r = rescue_commit(&repo, "rescue").await.unwrap();
1747 assert!(!r.committed);
1748 assert_eq!(r.withheld.len(), 1);
1749 }
1750
1751 #[tokio::test]
1752 async fn rescue_keeps_a_same_manager_lockfile_update() {
1753 let (_g, repo) = scratch().await;
1754 track(&repo, "bun.lock", "a\n").await;
1755 tokio::fs::write(repo.join("bun.lock"), "b\n")
1756 .await
1757 .unwrap();
1758 let r = rescue_commit(&repo, "rescue").await.unwrap();
1759 assert!(r.committed);
1760 assert!(r.withheld.is_empty());
1761 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1762 .await
1763 .unwrap();
1764 assert_eq!(files, "bun.lock");
1765 }
1766
1767 #[tokio::test]
1768 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1769 let (_g, repo) = scratch().await;
1770 track(&repo, "other/bun.lock", "a\n").await;
1771 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1772 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1773 .await
1774 .unwrap();
1775 let r = rescue_commit(&repo, "rescue").await.unwrap();
1776 assert!(r.committed);
1777 assert!(r.withheld.is_empty());
1778 }
1779
1780 #[test]
1781 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1782 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1783 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1784 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1785 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1786 assert_eq!(
1788 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1789 1
1790 );
1791 }
1792
1793 #[tokio::test]
1794 async fn clean_removal_preserves_edits_while_scratch_removal_discards_them() {
1795 let (guard, repo) = scratch().await;
1796 let wt = guard.path().join("scratch-wt");
1797 worktree_add_detached(&repo, &wt, "HEAD").await.unwrap();
1798 tokio::fs::write(wt.join("built.txt"), "build output")
1799 .await
1800 .unwrap();
1801 assert!(!worktree_remove_clean(&repo, &wt).await.unwrap());
1802 assert!(wt.join("built.txt").exists());
1803 let listing = git(&repo, &["worktree", "list", "--porcelain"])
1804 .await
1805 .unwrap();
1806 let registered = listing
1807 .lines()
1808 .filter_map(|line| line.strip_prefix("worktree "))
1809 .map(PathBuf::from)
1810 .find(|path| path.canonicalize().unwrap() == wt.canonicalize().unwrap())
1811 .expect("the refused removal must retain the worktree registration");
1812 assert!(worktree_remove(&repo, &wt).await.unwrap());
1813 assert!(!wt.exists());
1814 let listing = git(&repo, &["worktree", "list", "--porcelain"])
1815 .await
1816 .unwrap();
1817 assert!(!listing.lines().any(|line| {
1818 line.strip_prefix("worktree ")
1819 .is_some_and(|path| Path::new(path) == registered)
1820 }));
1821 }
1822
1823 #[tokio::test]
1824 async fn worktree_lifecycle_and_diff() {
1825 let (guard, repo) = scratch().await;
1826 let base = rev_parse(&repo, "HEAD").await.unwrap();
1827 let wt = guard.path().join("wt-a");
1828 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1829 .await
1830 .unwrap();
1831 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1832 .await
1833 .unwrap();
1834
1835 assert!(commit_all(&wt, "candidate work").await.unwrap());
1836 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1837
1838 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1839 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1840 assert!(patch.contains("b.txt"), "patch was: {patch}");
1841 assert_eq!(
1842 changed_files(&wt, &base, "HEAD").await.unwrap(),
1843 ["b.txt".to_owned()]
1844 );
1845
1846 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1848 .await
1849 .unwrap();
1850 assert_eq!(author, "magi candidate <magi@localhost>");
1851
1852 assert!(worktree_remove(&repo, &wt).await.unwrap());
1853 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1854 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1855 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1856 }
1857
1858 #[tokio::test]
1859 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1860 let (guard, repo) = scratch().await;
1861 let base = rev_parse(&repo, "HEAD").await.unwrap();
1862 let wt = guard.path().join("wt-h");
1863 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1864 .await
1865 .unwrap();
1866 let hooks = guard.path().join("hooks");
1867 tokio::fs::create_dir_all(&hooks).await.unwrap();
1868
1869 assert!(enable_worktree_config(&repo).await.unwrap());
1870 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1871
1872 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1873 .await
1874 .unwrap();
1875 assert!(!in_wt.is_empty());
1876 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1877 .await
1878 .unwrap();
1879 assert!(
1880 !in_primary.ok(),
1881 "primary worktree must keep its own hooks: {in_primary:?}"
1882 );
1883
1884 disable_worktree_config(&repo).await.unwrap();
1885 }
1886
1887 #[tokio::test]
1888 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1889 let (_g, repo) = scratch().await;
1890
1891 acquire_worktree_config(&repo).await.unwrap();
1894 acquire_worktree_config(&repo).await.unwrap();
1895
1896 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1897 .await
1898 .unwrap();
1899 assert_eq!(on, "true");
1900
1901 release_worktree_config(&repo).await.unwrap();
1905 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1906 .await
1907 .unwrap();
1908 assert_eq!(
1909 still_on, "true",
1910 "a sibling run's release must not disable the setting for the one still working"
1911 );
1912
1913 release_worktree_config(&repo).await.unwrap();
1915 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1916 .await
1917 .unwrap();
1918 assert!(
1919 !after.ok(),
1920 "the last release must turn the setting back off: {after:?}"
1921 );
1922 }
1923
1924 #[tokio::test]
1925 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1926 let (_g, repo) = scratch().await;
1927 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1928 .await
1929 .unwrap();
1930
1931 acquire_worktree_config(&repo).await.unwrap();
1936 release_worktree_config(&repo).await.unwrap();
1937
1938 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1939 .await
1940 .unwrap();
1941 assert_eq!(still_on, "true");
1942 }
1943
1944 #[tokio::test]
1945 async fn local_exclude_is_idempotent() {
1946 let (_g, repo) = scratch().await;
1947 local_exclude(&repo, "/.magi/").await.unwrap();
1948 local_exclude(&repo, "/.magi/").await.unwrap();
1949 let path = repo.join(".git/info/exclude");
1950 let body = tokio::fs::read_to_string(&path).await.unwrap();
1951 assert_eq!(body.matches("/.magi/").count(), 1);
1952 }
1953
1954 fn sh(cwd: &Path, args: &[&str]) {
1955 let out = std::process::Command::new("git")
1956 .quiet()
1957 .args(args)
1958 .current_dir(cwd)
1959 .output()
1960 .unwrap();
1961 assert!(
1962 out.status.success(),
1963 "git {args:?}: {}",
1964 String::from_utf8_lossy(&out.stderr)
1965 );
1966 }
1967
1968 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1971 let (g, repo) = scratch().await;
1972 let bare = g.path().join("bare.git");
1973 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1974 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1975 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1976 sh(&repo, &["fetch", "origin"]);
1977 (g, repo)
1978 }
1979
1980 #[test]
1981 fn remote_head_branch_strips_only_the_matching_remote() {
1982 assert_eq!(
1983 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1984 Some("main")
1985 );
1986 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1987 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1988 }
1989
1990 #[tokio::test]
1991 async fn merge_base_branch_reads_origin_head_when_present() {
1992 let (_g, repo) = with_remote().await;
1993 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1994 assert_eq!(
1995 merge_base_branch(&repo, "origin", None).await.unwrap(),
1996 "trunk"
1997 );
1998 }
1999
2000 #[tokio::test]
2001 async fn merge_base_branch_recovers_a_missing_origin_head() {
2002 let (_g, repo) = with_remote().await;
2003 sh(&repo, &["remote", "set-head", "origin", "-d"]);
2005 let head = git_raw(
2006 &repo,
2007 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
2008 )
2009 .await
2010 .unwrap();
2011 assert!(!head.ok(), "precondition: no origin/HEAD");
2012 assert_eq!(
2013 merge_base_branch(&repo, "origin", None).await.unwrap(),
2014 "trunk"
2015 );
2016 }
2017
2018 #[tokio::test]
2019 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
2020 let (_g, repo) = scratch().await;
2021 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
2022 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
2023 }
2024
2025 #[tokio::test]
2026 async fn merge_base_branch_prefers_the_explicit_base() {
2027 let (_g, repo) = with_remote().await;
2028 assert_eq!(
2029 merge_base_branch(&repo, "origin", Some("release"))
2030 .await
2031 .unwrap(),
2032 "release"
2033 );
2034 }
2035
2036 #[tokio::test]
2037 async fn merge_base_branch_names_an_unfetched_default_branch() {
2038 let (g, repo) = scratch().await;
2041 let bare = g.path().join("bare.git");
2042 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
2043 sh(
2044 &repo,
2045 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
2046 );
2047 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
2048 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
2049 assert_eq!(
2050 merge_base_branch(&repo, "origin", None).await.unwrap(),
2051 "trunk"
2052 );
2053 }
2054
2055 #[test]
2056 fn symref_branch_reads_ls_remote_output() {
2057 assert_eq!(
2058 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
2059 Some("main")
2060 );
2061 assert_eq!(symref_branch("abc\tHEAD\n"), None);
2062 }
2063}