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