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> {
298 let path_s = path.to_string_lossy().to_string();
299 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
300 if out.ok() {
301 return Ok(true);
302 }
303 git_raw(repo, &["worktree", "prune"]).await?;
305 Ok(false)
306}
307
308pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
312 let path_s = path.to_string_lossy().to_string();
313 Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
314}
315
316pub async fn remove_worktree_from_linked(dir: &Path) {
325 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
326 return;
327 };
328 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
329 return;
330 };
331 let admin = Path::new(admin);
334 let Some(common) = admin.parent().and_then(Path::parent) else {
335 return;
336 };
337 let common_s = common.to_string_lossy();
338 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
339}
340
341pub async fn worktree_prune(repo: &Path) -> Result<()> {
350 git(repo, &["worktree", "prune"]).await.map(|_| ())
351}
352
353pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
355 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
356}
357
358pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
360 let refname = format!("refs/heads/{branch}");
361 Ok(
362 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
363 .await?
364 .ok(),
365 )
366}
367
368pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
371 let refname = format!("refs/heads/{branch}");
372 let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
373 match out.code {
374 Some(0) => Ok(true),
375 Some(2) => Ok(false),
376 code => bail!(
377 "git ls-remote {remote} failed ({}): {}",
378 crate::notices::exit_status(code),
379 out.stderr
380 ),
381 }
382}
383
384pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
386 let range = format!("{base}...{head}");
387 git(
388 worktree,
389 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
390 )
391 .await
392}
393
394pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
396 let range = format!("{base}...{head}");
397 git(worktree, &["diff", "--no-color", "--stat", &range]).await
398}
399
400pub async fn changed_lines(worktree: &Path, base: &str, head: &str) -> Option<usize> {
404 let out = git_raw(worktree, &["diff", "--numstat", base, head])
405 .await
406 .ok()?;
407 if !out.ok() {
408 return None;
409 }
410 let mut total = 0usize;
411 for line in out.stdout.lines().filter(|l| !l.trim().is_empty()) {
412 let mut cols = line.split('\t');
413 let added: usize = cols.next()?.parse().ok()?;
414 let removed: usize = cols.next()?.parse().ok()?;
415 total = total.saturating_add(added).saturating_add(removed);
416 }
417 Some(total)
418}
419
420pub async fn restore_to(worktree: &Path, rev: &str) -> Result<()> {
424 git(worktree, &["reset", "--hard", "-q", rev]).await?;
425 git(worktree, &["clean", "-ffdq"]).await?;
428 git(
434 worktree,
435 &[
436 "submodule",
437 "--quiet",
438 "update",
439 "--recursive",
440 "--force",
441 "--checkout",
442 ],
443 )
444 .await?;
445 git(
446 worktree,
447 &[
448 "submodule",
449 "--quiet",
450 "foreach",
451 "--recursive",
452 "git clean -ffdq",
453 ],
454 )
455 .await?;
456 Ok(())
457}
458
459pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
461 let range = format!("{base}...{head}");
462 let out = git(worktree, &["diff", "--name-only", &range]).await?;
463 Ok(out.lines().map(str::to_owned).collect())
464}
465
466pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
470 let range = format!("{base}..{head}");
471 let out = git(
472 worktree,
473 &[
474 "log",
475 "--reverse",
476 "--no-color",
477 "--format=%s%x1f%b%x00",
478 &range,
479 ],
480 )
481 .await?;
482 Ok(out
483 .split('\0')
484 .filter_map(|rec| {
485 let rec = rec.trim_start_matches('\n');
486 if rec.trim().is_empty() {
487 return None;
488 }
489 let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
490 Some((subject.trim().to_owned(), body.trim().to_owned()))
491 })
492 .collect())
493}
494
495pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
497 let range = format!("{base}..{head}");
498 git(
499 worktree,
500 &["log", "--reverse", "--format=%s%n%b%n--", &range],
501 )
502 .await
503}
504
505pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
508 let range = format!("{base}..{head}");
509 let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
510 let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
511 parts.pop();
513 Ok(parts)
514}
515
516pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
518 let range = format!("{base}..{head}");
519 let out = git(worktree, &["rev-list", "--count", &range]).await?;
520 Ok(out.trim().parse().unwrap_or(0))
521}
522
523pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
530 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
531 return Ok(false);
532 }
533 git(worktree, &["add", "-A"]).await?;
534 if !crate::worktree_setup::withheld_paths(worktree)
536 .await
537 .is_empty()
538 {
539 crate::worktree_setup::unstage_products(worktree).await?;
540 if git_raw(worktree, &["diff", "--cached", "--quiet"])
541 .await?
542 .ok()
543 {
544 return Ok(false);
545 }
546 }
547 let out = git_raw(
548 worktree,
549 &[
550 "-c",
551 "user.name=magi candidate",
552 "-c",
553 "user.email=magi@localhost",
554 "commit",
555 "--no-verify",
556 "-m",
557 message,
558 ],
559 )
560 .await?;
561 if !out.ok() {
562 bail!("rescue commit failed: {}", out.stderr);
563 }
564 Ok(true)
565}
566
567#[derive(Debug, Clone, PartialEq, Eq)]
570pub struct Stray {
571 pub path: String,
573 pub manager: String,
575 pub kept_by: String,
578}
579
580#[derive(Debug, Default)]
582pub struct Rescue {
583 pub committed: bool,
585 pub withheld: Vec<Stray>,
587}
588
589fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
591 Some(match name {
592 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
593 "yarn.lock" => ("node", "yarn"),
594 "pnpm-lock.yaml" => ("node", "pnpm"),
595 "bun.lock" | "bun.lockb" => ("node", "bun"),
596 "poetry.lock" => ("python", "poetry"),
597 "uv.lock" => ("python", "uv"),
598 "Pipfile.lock" => ("python", "pipenv"),
599 "pdm.lock" => ("python", "pdm"),
600 "Cargo.lock" => ("rust", "cargo"),
601 _ => return None,
602 })
603}
604
605fn split_dir(path: &str) -> (&str, &str) {
606 path.rsplit_once('/').unwrap_or(("", path))
607}
608
609pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
617 let mut out = Vec::new();
618 for path in untracked {
619 let (dir, name) = split_dir(path);
620 let Some((eco, manager)) = lock_kind(name) else {
621 continue;
622 };
623 let beside = |other: &String| split_dir(other).0 == dir;
624 let kept_by = if manager == "cargo" {
625 let has_manifest = tracked
626 .iter()
627 .chain(untracked)
628 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
629 if has_manifest {
630 continue;
631 }
632 "no Cargo.toml in the directory".to_owned()
633 } else {
634 let Some(other) = tracked.iter().find(|p| {
635 beside(p)
636 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
637 }) else {
638 continue;
639 };
640 other.clone()
641 };
642 out.push(Stray {
643 path: path.clone(),
644 manager: manager.to_owned(),
645 kept_by,
646 });
647 }
648 out
649}
650
651async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
652 let out = git(worktree, args).await?;
653 Ok(out
654 .split('\0')
655 .filter(|s| !s.is_empty())
656 .map(str::to_owned)
657 .collect())
658}
659
660pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
666 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
667 return Ok(Rescue::default());
668 }
669 let untracked = nul_list(
670 worktree,
671 &["ls-files", "-z", "--others", "--exclude-standard"],
672 )
673 .await?;
674 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
675 let withheld = stray_lockfiles(&untracked, &tracked);
676
677 git(worktree, &["add", "-A"]).await?;
678 if !withheld.is_empty() {
679 let mut args = vec!["reset", "-q", "--"];
680 args.extend(withheld.iter().map(|s| s.path.as_str()));
681 git(worktree, &args).await?;
682 }
683 crate::worktree_setup::unstage_products(worktree).await?;
684 if git_raw(worktree, &["diff", "--cached", "--quiet"])
685 .await?
686 .ok()
687 {
688 return Ok(Rescue {
689 committed: false,
690 withheld,
691 });
692 }
693 let out = git_raw(
694 worktree,
695 &[
696 "-c",
697 "user.name=magi candidate",
698 "-c",
699 "user.email=magi@localhost",
700 "commit",
701 "--no-verify",
702 "-m",
703 message,
704 ],
705 )
706 .await?;
707 if !out.ok() {
708 bail!("rescue commit failed: {}", out.stderr);
709 }
710 Ok(Rescue {
711 committed: true,
712 withheld,
713 })
714}
715
716pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
721 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
722 if out.ok() && out.stdout.trim() == "true" {
723 return Ok(false);
724 }
725 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
726 Ok(true)
727}
728
729pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
731 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
732 Ok(())
733}
734
735struct WorktreeConfigRef {
738 count: usize,
740 we_enabled: bool,
746}
747
748static WORKTREE_CONFIG: std::sync::LazyLock<
755 std::sync::Mutex<
756 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
757 >,
758> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
759
760fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
762 let mut map = WORKTREE_CONFIG
763 .lock()
764 .unwrap_or_else(std::sync::PoisonError::into_inner);
765 map.entry(repo.to_path_buf())
766 .or_insert_with(|| {
767 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
768 count: 0,
769 we_enabled: false,
770 }))
771 })
772 .clone()
773}
774
775pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
792 let slot = worktree_config_slot(repo);
793 let mut entry = slot.lock().await;
794 entry.count += 1;
795 if entry.count == 1 {
796 entry.we_enabled = enable_worktree_config(repo).await?;
797 }
798 Ok(())
799}
800
801pub async fn release_worktree_config(repo: &Path) -> Result<()> {
807 let slot = worktree_config_slot(repo);
808 let mut entry = slot.lock().await;
809 entry.count = entry.count.saturating_sub(1);
810 if entry.count == 0 && entry.we_enabled {
811 disable_worktree_config(repo).await?;
812 entry.we_enabled = false;
813 }
814 Ok(())
815}
816
817pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
823 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
824 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
825 .await
826 .map(|_| ())
827}
828
829pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
831 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
832 let path = worktree.join(git_dir);
833 if let Some(parent) = path.parent() {
834 tokio::fs::create_dir_all(parent).await.ok();
835 }
836 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
837 if body.lines().any(|l| l.trim() == pattern) {
838 return Ok(());
839 }
840 if !body.is_empty() && !body.ends_with('\n') {
841 body.push('\n');
842 }
843 body.push_str(pattern);
844 body.push('\n');
845 tokio::fs::write(&path, body)
846 .await
847 .with_context(|| format!("write {}", path.display()))?;
848 Ok(())
849}
850
851pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
857 git_raw(
858 repo,
859 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
860 )
861 .await
862}
863
864pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
875 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
876 if !staged.ok() {
877 return Ok(staged);
878 }
879 git_raw(repo, &["commit", "-m", message]).await
880}
881
882pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
892 git_raw(repo, &["merge", "--ff-only", branch]).await
893}
894
895pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
897 git_raw(repo, &["push", "-u", remote, branch]).await
898}
899
900pub async fn push_as(
903 repo: &Path,
904 remote: &str,
905 local: &str,
906 head: &str,
907 expected: &str,
908) -> Result<GitOut> {
909 let lease = format!("--force-with-lease=refs/heads/{head}:{expected}");
910 let refspec = format!("refs/heads/{local}:refs/heads/{head}");
911 git_raw(repo, &["push", &lease, remote, &refspec]).await
912}
913
914pub async fn remote_head(repo: &Path, remote: &str, branch: &str) -> Result<Option<String>> {
916 let reference = format!("refs/heads/{branch}");
917 let out = git(repo, &["ls-remote", "--refs", remote, &reference]).await?;
918 Ok(out.split_whitespace().next().map(str::to_owned))
919}
920
921pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
929 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
930}
931
932pub async fn push_pinned(
941 repo: &Path,
942 remote: &str,
943 branch: &str,
944 expected: &str,
945) -> Result<GitOut> {
946 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
947 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
948 git_raw(repo, &["push", &lease, remote, &refspec]).await
949}
950
951pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
954 let out = git(repo, &["cherry", upstream, head]).await?;
955 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
956 for line in out.lines() {
957 if let Some(sha) = line.strip_prefix("+ ") {
958 unmatched.push(sha.trim().to_owned());
959 } else if let Some(sha) = line.strip_prefix("- ") {
960 matched.push(sha.trim().to_owned());
961 }
962 }
963 Ok((unmatched, matched))
964}
965
966#[derive(Debug, Clone, PartialEq, Eq)]
970pub struct CommitKey {
971 pub sha: String,
973 pub key: String,
975}
976
977pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
979 let out = git(
980 repo,
981 &[
982 "log",
983 "--no-merges",
984 "--reverse",
985 "--date=raw",
986 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
987 range,
988 ],
989 )
990 .await?;
991 Ok(out
992 .lines()
993 .filter_map(|l| l.split_once('\u{1f}'))
994 .map(|(sha, key)| CommitKey {
995 sha: sha.to_owned(),
996 key: key.to_owned(),
997 })
998 .collect())
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq)]
1003pub enum RebaseStart {
1004 Applied,
1007 Conflicted(String),
1010 Failed(String),
1013}
1014
1015pub async fn rebase_start(
1030 repo: &Path,
1031 scratch: &Path,
1032 branch: &str,
1033 onto: &str,
1034) -> Result<RebaseStart> {
1035 worktree_remove(repo, scratch).await.ok();
1038 git_raw(
1039 repo,
1040 &[
1041 "worktree",
1042 "add",
1043 "--force",
1044 &scratch.to_string_lossy(),
1045 branch,
1046 ],
1047 )
1048 .await?;
1049
1050 let out = git_raw(scratch, &["rebase", onto]).await?;
1051 if out.ok() {
1052 worktree_remove(repo, scratch).await.ok();
1053 return Ok(RebaseStart::Applied);
1054 }
1055 let why = if out.stderr.trim().is_empty() {
1056 out.stdout.trim().to_owned()
1057 } else {
1058 out.stderr.trim().to_owned()
1059 };
1060 if rebase_in_progress(scratch).await {
1061 return Ok(RebaseStart::Conflicted(why));
1062 }
1063 worktree_remove(repo, scratch).await.ok();
1064 Ok(RebaseStart::Failed(why))
1065}
1066
1067pub async fn rebase_in_progress(worktree: &Path) -> bool {
1069 for name in ["rebase-merge", "rebase-apply"] {
1070 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1071 continue;
1072 };
1073 let p = Path::new(p.trim());
1074 let full = if p.is_absolute() {
1075 p.to_path_buf()
1076 } else {
1077 worktree.join(p)
1078 };
1079 if full.exists() {
1080 return true;
1081 }
1082 }
1083 false
1084}
1085
1086pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1088 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1089 Ok(out
1090 .lines()
1091 .map(str::trim)
1092 .filter(|l| !l.is_empty())
1093 .map(str::to_owned)
1094 .collect())
1095}
1096
1097pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1100 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1101 worktree_remove(repo, scratch).await.ok();
1102}
1103
1104pub async fn rebase_branch_in_temp(
1112 repo: &Path,
1113 scratch: &Path,
1114 branch: &str,
1115 onto: &str,
1116) -> Result<Option<String>> {
1117 match rebase_start(repo, scratch, branch, onto).await? {
1118 RebaseStart::Applied => Ok(None),
1119 RebaseStart::Failed(why) => Ok(Some(why)),
1120 RebaseStart::Conflicted(why) => {
1121 rebase_abort(repo, scratch).await;
1122 Ok(Some(why))
1123 }
1124 }
1125}
1126
1127pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1140 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1141 git(worktree, &["clean", "-fdx"]).await?;
1142 Ok(())
1143}
1144
1145pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1164 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1165 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1166}
1167
1168pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1170 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1171}
1172
1173pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1175 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1176 .await
1177 .is_ok_and(|o| o.ok())
1178}
1179
1180pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1185 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1186 match out.code {
1187 Some(0) => Ok(true),
1188 Some(1) => Ok(false),
1189 _ => bail!(
1190 "git merge-base --is-ancestor {ancestor} {of} failed ({}): {}",
1191 crate::notices::exit_status(out.code),
1192 out.stderr
1193 ),
1194 }
1195}
1196
1197pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1199 let spec = format!("{rev}^{{commit}}");
1200 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1201 .await
1202 .ok()?;
1203 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1204}
1205
1206pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1208 let out = git(
1209 repo,
1210 &[
1211 "branch",
1212 "-a",
1213 "--contains",
1214 commit,
1215 "--format=%(refname:short)",
1216 ],
1217 )
1218 .await?;
1219 Ok(out
1220 .lines()
1221 .map(str::trim)
1222 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1223 .map(str::to_owned)
1224 .collect())
1225}
1226
1227pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1231 let out = git_raw(
1232 worktree,
1233 &[
1234 "-c",
1235 "user.name=magi candidate",
1236 "-c",
1237 "user.email=magi@localhost",
1238 "cherry-pick",
1239 "-x",
1240 commit,
1241 ],
1242 )
1243 .await?;
1244 if out.ok() {
1245 return Ok(());
1246 }
1247 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1248 bail!(
1249 "cherry-pick of {commit} failed: {}",
1250 if out.stderr.is_empty() {
1251 out.stdout
1252 } else {
1253 out.stderr
1254 }
1255 )
1256}
1257
1258pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1260 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1261}
1262
1263pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1265 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1266 .await
1267 .is_ok_and(|o| o.ok())
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273
1274 #[test]
1275 fn worktree_holder_is_read_from_the_porcelain_listing() {
1276 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1277 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1278 worktree /wt/detached\nHEAD ccc\ndetached\n";
1279 assert_eq!(
1280 parse_worktree_holder(listing, "magi/f82f/A"),
1281 Some(PathBuf::from("/wt/cand-A"))
1282 );
1283 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1284 assert_eq!(parse_worktree_holder(listing, "other"), None);
1285 }
1286
1287 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1288 let dir = tempfile::tempdir().unwrap();
1289 let repo = dir.path().join("repo");
1290 tokio::fs::create_dir_all(&repo).await.unwrap();
1291 git(&repo, &["init", "-b", "main"]).await.unwrap();
1292 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1293 git(&repo, &["config", "user.email", "test@example.com"])
1294 .await
1295 .unwrap();
1296 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1297 git(&repo, &["add", "-A"]).await.unwrap();
1298 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1299 (dir, repo)
1300 }
1301
1302 #[tokio::test]
1303 async fn restore_to_removes_nested_repositories_inside_submodules() {
1304 let (_parent_dir, repo) = scratch().await;
1305 let (_source_dir, source) = scratch().await;
1306 sh(
1307 &repo,
1308 &[
1309 "-c",
1310 "protocol.file.allow=always",
1311 "submodule",
1312 "add",
1313 &source.to_string_lossy(),
1314 "sub",
1315 ],
1316 );
1317 sh(&repo, &["commit", "-am", "add submodule"]);
1318 let pre = rev_parse(&repo, "HEAD").await.unwrap();
1319 let sub = repo.join("sub");
1320 sh(&sub, &["init", "nested"]);
1321 tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1322 .await
1323 .unwrap();
1324
1325 restore_to(&repo, &pre).await.unwrap();
1326
1327 assert!(!sub.join("nested").exists());
1328 assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1329 assert_eq!(
1330 rev_parse(&sub, "HEAD").await.unwrap(),
1331 rev_parse(&source, "HEAD").await.unwrap()
1332 );
1333 assert!(
1334 git(&repo, &["status", "--porcelain"])
1335 .await
1336 .unwrap()
1337 .is_empty()
1338 );
1339 }
1340
1341 #[tokio::test]
1342 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1343 let (_g, repo) = scratch().await;
1344
1345 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1347 tokio::fs::write(repo.join("b.txt"), "side\n")
1348 .await
1349 .unwrap();
1350 git(&repo, &["add", "-A"]).await.unwrap();
1351 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1352
1353 git(&repo, &["checkout", "main"]).await.unwrap();
1356 tokio::fs::write(repo.join("c.txt"), "main\n")
1357 .await
1358 .unwrap();
1359 git(&repo, &["add", "-A"]).await.unwrap();
1360 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1361
1362 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1363 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1364 .await
1365 .unwrap();
1366 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1367 assert_eq!(
1368 commits_ahead(&repo, "main", "side").await.unwrap(),
1369 1,
1370 "one commit, replayed onto the new base"
1371 );
1372 assert!(
1373 !scratch_tree.exists(),
1374 "the throwaway worktree is not left behind"
1375 );
1376
1377 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1379 tokio::fs::write(repo.join("a.txt"), "clash\n")
1380 .await
1381 .unwrap();
1382 git(&repo, &["add", "-A"]).await.unwrap();
1383 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1384 git(&repo, &["checkout", "main"]).await.unwrap();
1385 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1386 .await
1387 .unwrap();
1388 git(&repo, &["add", "-A"]).await.unwrap();
1389 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1390
1391 let before = rev_parse(&repo, "clash").await.unwrap();
1392 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1393 .await
1394 .unwrap()
1395 .expect("a same-line clash cannot be rebased silently");
1396 assert!(
1397 why.to_lowercase().contains("conflict"),
1398 "the reason is what git said, which is what a person needs: {why}"
1399 );
1400 assert_eq!(
1401 rev_parse(&repo, "clash").await.unwrap(),
1402 before,
1403 "a failed rebase leaves the branch exactly where it was"
1404 );
1405 assert!(!scratch_tree.exists(), "and cleans up after itself");
1406 }
1407
1408 #[tokio::test]
1409 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1410 let (_g, repo) = scratch().await;
1411 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1412 for name in ["b.txt", "c.txt"] {
1413 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1414 git(&repo, &["add", "-A"]).await.unwrap();
1415 git(
1416 &repo,
1417 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1418 )
1419 .await
1420 .unwrap();
1421 }
1422 git(&repo, &["checkout", "main"]).await.unwrap();
1423 let before = rev_parse(&repo, "main").await.unwrap();
1424
1425 let out = merge_squash(&repo, "side", "an explicit subject")
1426 .await
1427 .unwrap();
1428 assert!(out.ok(), "{}", out.stderr);
1429 assert_eq!(
1430 commits_ahead(&repo, &before, "main").await.unwrap(),
1431 1,
1432 "squash adds exactly one commit onto the tip, not one per candidate commit"
1433 );
1434 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1435 assert_eq!(
1436 subject, "an explicit subject",
1437 "the candidate's own placeholder subject must not survive: {subject}"
1438 );
1439 }
1440
1441 #[tokio::test]
1442 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1443 let (_g, repo) = scratch().await;
1444 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1445 tokio::fs::write(repo.join("b.txt"), "side\n")
1446 .await
1447 .unwrap();
1448 git(&repo, &["add", "-A"]).await.unwrap();
1449 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1450 git(&repo, &["checkout", "main"]).await.unwrap();
1451
1452 let before = rev_parse(&repo, "side").await.unwrap();
1453 let out = merge_ff_only(&repo, "side").await.unwrap();
1454 assert!(out.ok(), "{}", out.stderr);
1455 assert_eq!(
1456 rev_parse(&repo, "main").await.unwrap(),
1457 before,
1458 "a fast-forward moves the base tip to the branch, no merge commit"
1459 );
1460 }
1461
1462 #[tokio::test]
1463 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1464 let (_g, repo) = scratch().await;
1465 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1466 tokio::fs::write(repo.join("b.txt"), "side\n")
1467 .await
1468 .unwrap();
1469 git(&repo, &["add", "-A"]).await.unwrap();
1470 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1471
1472 git(&repo, &["checkout", "main"]).await.unwrap();
1474 tokio::fs::write(repo.join("c.txt"), "main\n")
1475 .await
1476 .unwrap();
1477 git(&repo, &["add", "-A"]).await.unwrap();
1478 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1479
1480 let before = rev_parse(&repo, "main").await.unwrap();
1481 let out = merge_ff_only(&repo, "side").await.unwrap();
1482 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1483 assert_eq!(
1484 rev_parse(&repo, "main").await.unwrap(),
1485 before,
1486 "a refused fast-forward must not touch main"
1487 );
1488 }
1489
1490 #[tokio::test]
1491 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1492 let (guard, repo) = scratch().await;
1493
1494 git(&repo, &["branch", "side"]).await.unwrap();
1498 let side_wt = guard.path().join("side-wt");
1499 git(
1500 &repo,
1501 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1502 )
1503 .await
1504 .unwrap();
1505 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1506 .await
1507 .unwrap();
1508 git(&side_wt, &["add", "-A"]).await.unwrap();
1509 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1510
1511 git(&repo, &["checkout", "main"]).await.unwrap();
1513 tokio::fs::write(repo.join("c.txt"), "main\n")
1514 .await
1515 .unwrap();
1516 git(&repo, &["add", "-A"]).await.unwrap();
1517 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1518
1519 let scratch_tree = guard.path().join("rebase-scratch");
1521 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1522 .await
1523 .unwrap();
1524 assert!(clean.is_none());
1525
1526 assert_eq!(
1530 rev_parse(&side_wt, "HEAD").await.unwrap(),
1531 rev_parse(&repo, "side").await.unwrap(),
1532 "HEAD follows the moved ref"
1533 );
1534 assert!(
1535 !side_wt.join("c.txt").exists(),
1536 "stale until synced: main's new file has not reached this worktree's disk"
1537 );
1538
1539 sync_to_head(&side_wt).await.unwrap();
1540 assert!(side_wt.join("c.txt").is_file(), "synced now");
1541 assert!(
1542 side_wt.join("b.txt").is_file(),
1543 "the worktree's own committed work survives the sync"
1544 );
1545 assert!(is_clean(&side_wt).await.unwrap());
1546 }
1547
1548 #[tokio::test]
1549 async fn clean_repo_reports_clean_then_dirty() {
1550 let (_g, repo) = scratch().await;
1551 assert!(is_clean(&repo).await.unwrap());
1552 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1553 assert!(!is_clean(&repo).await.unwrap());
1554 }
1555
1556 async fn track(repo: &Path, name: &str, body: &str) {
1557 let p = repo.join(name);
1558 if let Some(d) = p.parent() {
1559 tokio::fs::create_dir_all(d).await.unwrap();
1560 }
1561 tokio::fs::write(&p, body).await.unwrap();
1562 git(repo, &["add", name]).await.unwrap();
1563 git(
1564 repo,
1565 &[
1566 "-c",
1567 "user.name=t",
1568 "-c",
1569 "user.email=t@localhost",
1570 "commit",
1571 "-q",
1572 "-m",
1573 "seed",
1574 ],
1575 )
1576 .await
1577 .unwrap();
1578 }
1579
1580 #[tokio::test]
1581 async fn rescue_withholds_a_foreign_lockfile() {
1582 let (_g, repo) = scratch().await;
1583 track(&repo, "web/bun.lock", "a\n").await;
1584 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1585 .await
1586 .unwrap();
1587 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1588 .await
1589 .unwrap();
1590
1591 let r = rescue_commit(&repo, "rescue").await.unwrap();
1592 assert!(r.committed);
1593 assert_eq!(
1594 r.withheld,
1595 [Stray {
1596 path: "web/pnpm-lock.yaml".to_owned(),
1597 manager: "pnpm".to_owned(),
1598 kept_by: "web/bun.lock".to_owned(),
1599 }]
1600 );
1601 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1602 .await
1603 .unwrap();
1604 assert!(files.contains("web/app.ts"), "{files}");
1605 assert!(!files.contains("pnpm-lock"), "{files}");
1606 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1607 }
1608
1609 #[tokio::test]
1610 async fn rescue_with_only_a_stray_commits_nothing() {
1611 let (_g, repo) = scratch().await;
1612 track(&repo, "bun.lock", "a\n").await;
1613 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1614 .await
1615 .unwrap();
1616 let r = rescue_commit(&repo, "rescue").await.unwrap();
1617 assert!(!r.committed);
1618 assert_eq!(r.withheld.len(), 1);
1619 }
1620
1621 #[tokio::test]
1622 async fn rescue_keeps_a_same_manager_lockfile_update() {
1623 let (_g, repo) = scratch().await;
1624 track(&repo, "bun.lock", "a\n").await;
1625 tokio::fs::write(repo.join("bun.lock"), "b\n")
1626 .await
1627 .unwrap();
1628 let r = rescue_commit(&repo, "rescue").await.unwrap();
1629 assert!(r.committed);
1630 assert!(r.withheld.is_empty());
1631 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1632 .await
1633 .unwrap();
1634 assert_eq!(files, "bun.lock");
1635 }
1636
1637 #[tokio::test]
1638 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1639 let (_g, repo) = scratch().await;
1640 track(&repo, "other/bun.lock", "a\n").await;
1641 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1642 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1643 .await
1644 .unwrap();
1645 let r = rescue_commit(&repo, "rescue").await.unwrap();
1646 assert!(r.committed);
1647 assert!(r.withheld.is_empty());
1648 }
1649
1650 #[test]
1651 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1652 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1653 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1654 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1655 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1656 assert_eq!(
1658 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1659 1
1660 );
1661 }
1662
1663 #[tokio::test]
1664 async fn worktree_lifecycle_and_diff() {
1665 let (guard, repo) = scratch().await;
1666 let base = rev_parse(&repo, "HEAD").await.unwrap();
1667 let wt = guard.path().join("wt-a");
1668 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1669 .await
1670 .unwrap();
1671 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1672 .await
1673 .unwrap();
1674
1675 assert!(commit_all(&wt, "candidate work").await.unwrap());
1676 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1677
1678 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1679 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1680 assert!(patch.contains("b.txt"), "patch was: {patch}");
1681 assert_eq!(
1682 changed_files(&wt, &base, "HEAD").await.unwrap(),
1683 ["b.txt".to_owned()]
1684 );
1685
1686 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1688 .await
1689 .unwrap();
1690 assert_eq!(author, "magi candidate <magi@localhost>");
1691
1692 assert!(worktree_remove(&repo, &wt).await.unwrap());
1693 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1694 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1695 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1696 }
1697
1698 #[tokio::test]
1699 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1700 let (guard, repo) = scratch().await;
1701 let base = rev_parse(&repo, "HEAD").await.unwrap();
1702 let wt = guard.path().join("wt-h");
1703 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1704 .await
1705 .unwrap();
1706 let hooks = guard.path().join("hooks");
1707 tokio::fs::create_dir_all(&hooks).await.unwrap();
1708
1709 assert!(enable_worktree_config(&repo).await.unwrap());
1710 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1711
1712 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1713 .await
1714 .unwrap();
1715 assert!(!in_wt.is_empty());
1716 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1717 .await
1718 .unwrap();
1719 assert!(
1720 !in_primary.ok(),
1721 "primary worktree must keep its own hooks: {in_primary:?}"
1722 );
1723
1724 disable_worktree_config(&repo).await.unwrap();
1725 }
1726
1727 #[tokio::test]
1728 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1729 let (_g, repo) = scratch().await;
1730
1731 acquire_worktree_config(&repo).await.unwrap();
1734 acquire_worktree_config(&repo).await.unwrap();
1735
1736 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1737 .await
1738 .unwrap();
1739 assert_eq!(on, "true");
1740
1741 release_worktree_config(&repo).await.unwrap();
1745 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1746 .await
1747 .unwrap();
1748 assert_eq!(
1749 still_on, "true",
1750 "a sibling run's release must not disable the setting for the one still working"
1751 );
1752
1753 release_worktree_config(&repo).await.unwrap();
1755 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1756 .await
1757 .unwrap();
1758 assert!(
1759 !after.ok(),
1760 "the last release must turn the setting back off: {after:?}"
1761 );
1762 }
1763
1764 #[tokio::test]
1765 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1766 let (_g, repo) = scratch().await;
1767 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1768 .await
1769 .unwrap();
1770
1771 acquire_worktree_config(&repo).await.unwrap();
1776 release_worktree_config(&repo).await.unwrap();
1777
1778 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1779 .await
1780 .unwrap();
1781 assert_eq!(still_on, "true");
1782 }
1783
1784 #[tokio::test]
1785 async fn local_exclude_is_idempotent() {
1786 let (_g, repo) = scratch().await;
1787 local_exclude(&repo, "/.magi/").await.unwrap();
1788 local_exclude(&repo, "/.magi/").await.unwrap();
1789 let path = repo.join(".git/info/exclude");
1790 let body = tokio::fs::read_to_string(&path).await.unwrap();
1791 assert_eq!(body.matches("/.magi/").count(), 1);
1792 }
1793
1794 fn sh(cwd: &Path, args: &[&str]) {
1795 let out = std::process::Command::new("git")
1796 .quiet()
1797 .args(args)
1798 .current_dir(cwd)
1799 .output()
1800 .unwrap();
1801 assert!(
1802 out.status.success(),
1803 "git {args:?}: {}",
1804 String::from_utf8_lossy(&out.stderr)
1805 );
1806 }
1807
1808 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1811 let (g, repo) = scratch().await;
1812 let bare = g.path().join("bare.git");
1813 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1814 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1815 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1816 sh(&repo, &["fetch", "origin"]);
1817 (g, repo)
1818 }
1819
1820 #[test]
1821 fn remote_head_branch_strips_only_the_matching_remote() {
1822 assert_eq!(
1823 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1824 Some("main")
1825 );
1826 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1827 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1828 }
1829
1830 #[tokio::test]
1831 async fn merge_base_branch_reads_origin_head_when_present() {
1832 let (_g, repo) = with_remote().await;
1833 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1834 assert_eq!(
1835 merge_base_branch(&repo, "origin", None).await.unwrap(),
1836 "trunk"
1837 );
1838 }
1839
1840 #[tokio::test]
1841 async fn merge_base_branch_recovers_a_missing_origin_head() {
1842 let (_g, repo) = with_remote().await;
1843 sh(&repo, &["remote", "set-head", "origin", "-d"]);
1845 let head = git_raw(
1846 &repo,
1847 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1848 )
1849 .await
1850 .unwrap();
1851 assert!(!head.ok(), "precondition: no origin/HEAD");
1852 assert_eq!(
1853 merge_base_branch(&repo, "origin", None).await.unwrap(),
1854 "trunk"
1855 );
1856 }
1857
1858 #[tokio::test]
1859 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1860 let (_g, repo) = scratch().await;
1861 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1862 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1863 }
1864
1865 #[tokio::test]
1866 async fn merge_base_branch_prefers_the_explicit_base() {
1867 let (_g, repo) = with_remote().await;
1868 assert_eq!(
1869 merge_base_branch(&repo, "origin", Some("release"))
1870 .await
1871 .unwrap(),
1872 "release"
1873 );
1874 }
1875
1876 #[tokio::test]
1877 async fn merge_base_branch_names_an_unfetched_default_branch() {
1878 let (g, repo) = scratch().await;
1881 let bare = g.path().join("bare.git");
1882 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1883 sh(
1884 &repo,
1885 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1886 );
1887 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1888 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1889 assert_eq!(
1890 merge_base_branch(&repo, "origin", None).await.unwrap(),
1891 "trunk"
1892 );
1893 }
1894
1895 #[test]
1896 fn symref_branch_reads_ls_remote_output() {
1897 assert_eq!(
1898 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1899 Some("main")
1900 );
1901 assert_eq!(symref_branch("abc\tHEAD\n"), None);
1902 }
1903}