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 let out = Command::new("git")
35 .args(args)
36 .current_dir(cwd)
37 .quiet()
38 .env("GIT_TERMINAL_PROMPT", "0")
41 .env("GIT_EDITOR", "true")
42 .stdin(Stdio::null())
43 .output()
44 .await
45 .with_context(|| format!("spawn git {}", args.join(" ")))?;
46 Ok(GitOut {
47 code: out.status.code(),
48 stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
49 stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
50 })
51}
52
53pub async fn fetch_origin(cwd: &Path, timeout: std::time::Duration) -> Result<()> {
65 let url = git(cwd, &["remote", "get-url", "origin"]).await?;
66 let fut = Command::new("git")
67 .args([
68 "fetch",
69 "--quiet",
70 "--no-tags",
71 "--no-recurse-submodules",
72 "--no-write-fetch-head",
73 "--",
74 url.as_str(),
75 "+refs/heads/*:refs/remotes/origin/*",
76 ])
77 .current_dir(cwd)
78 .quiet()
79 .env("GIT_TERMINAL_PROMPT", "0")
80 .stdin(Stdio::null())
81 .kill_on_drop(true)
82 .output();
83 let out = tokio::time::timeout(timeout, fut)
84 .await
85 .map_err(|_| anyhow::anyhow!("git fetch timed out after {}s", timeout.as_secs()))?
86 .context("spawn git fetch")?;
87 if !out.status.success() {
88 bail!(
89 "git fetch failed (exit {:?}): {}",
90 out.status.code(),
91 String::from_utf8_lossy(&out.stderr).trim_end()
92 );
93 }
94 Ok(())
95}
96
97pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
99 let out = git_raw(cwd, args).await?;
100 if !out.ok() {
101 bail!(
102 "git {} failed in {} (exit {:?}): {}",
103 args.join(" "),
104 cwd.display(),
105 out.code,
106 if out.stderr.is_empty() {
107 out.stdout.as_str()
108 } else {
109 out.stderr.as_str()
110 }
111 );
112 }
113 Ok(out.stdout)
114}
115
116pub async fn toplevel(path: &Path) -> Result<PathBuf> {
118 let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
119 Ok(PathBuf::from(out))
120}
121
122pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
124 git(repo, &["rev-parse", rev]).await
125}
126
127pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
129 let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
130 Ok(if out.ok() && !out.stdout.is_empty() {
131 Some(out.stdout)
132 } else {
133 None
134 })
135}
136
137pub fn remote_head_branch(remote: &str, symref: &str) -> Option<String> {
142 symref
143 .trim()
144 .strip_prefix(&format!("refs/remotes/{remote}/"))
145 .filter(|b| !b.is_empty() && *b != "HEAD")
146 .map(str::to_owned)
147}
148
149pub async fn ensure_remote_head(repo: &Path, remote: &str, base: &str) {
155 let head = format!("refs/remotes/{remote}/HEAD");
156 if git_raw(repo, &["symbolic-ref", "--quiet", &head])
157 .await
158 .is_ok_and(|o| o.ok())
159 {
160 return;
161 }
162 let target = format!("refs/remotes/{remote}/{base}");
163 let _ = git_raw(repo, &["symbolic-ref", &head, &target]).await;
164}
165
166fn symref_branch(out: &str) -> Option<String> {
169 out.lines()
170 .find_map(|l| l.strip_prefix("ref: refs/heads/"))
171 .and_then(|l| l.split_whitespace().next())
172 .map(str::to_owned)
173}
174
175pub async fn merge_base_branch(
182 repo: &Path,
183 remote: &str,
184 explicit: Option<&str>,
185) -> Result<String> {
186 if let Some(b) = explicit.map(str::trim).filter(|b| !b.is_empty()) {
187 return Ok(b.to_owned());
188 }
189 let head = format!("refs/remotes/{remote}/HEAD");
190 let read = || async {
191 let out = git_raw(repo, &["symbolic-ref", "--quiet", &head])
192 .await
193 .ok()?;
194 if out.ok() {
195 remote_head_branch(remote, &out.stdout)
196 } else {
197 None
198 }
199 };
200 if let Some(b) = read().await {
201 return Ok(b);
202 }
203 let set = git_raw(repo, &["remote", "set-head", remote, "-a"]).await;
204 if let Some(b) = read().await {
205 return Ok(b);
206 }
207 if let Ok(o) = git_raw(repo, &["ls-remote", "--symref", remote, "HEAD"]).await
212 && o.ok()
213 && let Some(b) = symref_branch(&o.stdout)
214 {
215 return Ok(b);
216 }
217 let why = match set {
218 Ok(o) if !o.ok() => o.stderr.lines().next().unwrap_or("").trim().to_owned(),
219 Ok(_) => "the remote reported no default branch".to_owned(),
220 Err(e) => e.to_string(),
221 };
222 bail!(
223 "cannot tell which branch of `{remote}` is the base ({why}): set [merge] base \
224 (and remote) in magi.toml, or run `git remote set-head {remote} -a`. magi does \
225 not fall back to the checked-out branch, which may be detached or stale"
226 )
227}
228
229pub async fn is_clean(repo: &Path) -> Result<bool> {
231 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
232}
233
234pub async fn status_porcelain(repo: &Path) -> Result<String> {
236 git(repo, &["status", "--porcelain"]).await
237}
238
239pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
241 if let Some(parent) = path.parent() {
242 tokio::fs::create_dir_all(parent).await.ok();
243 }
244 let path_s = path.to_string_lossy().to_string();
245 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
246 .await
247 .map(|_| ())
248}
249
250pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
252 if let Some(parent) = path.parent() {
253 tokio::fs::create_dir_all(parent).await.ok();
254 }
255 let path_s = path.to_string_lossy().to_string();
256 git(repo, &["worktree", "add", "--detach", &path_s, rev])
257 .await
258 .map(|_| ())
259}
260
261pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
263 git(worktree, &["checkout", "--detach", rev]).await?;
264 git(worktree, &["reset", "--hard", rev]).await?;
265 git(worktree, &["clean", "-fdx"]).await?;
266 Ok(())
267}
268
269pub async fn worktree_holding(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
274 let listing = git(repo, &["worktree", "list", "--porcelain"]).await?;
275 Ok(parse_worktree_holder(&listing, branch))
276}
277
278fn parse_worktree_holder(listing: &str, branch: &str) -> Option<PathBuf> {
279 let want = format!("refs/heads/{branch}");
280 let mut path: Option<PathBuf> = None;
281 for line in listing.lines() {
282 if let Some(p) = line.strip_prefix("worktree ") {
283 path = Some(PathBuf::from(p));
284 } else if line.strip_prefix("branch ") == Some(want.as_str()) {
285 return path;
286 }
287 }
288 None
289}
290
291pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
294 let path_s = path.to_string_lossy().to_string();
295 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
296 if out.ok() {
297 return Ok(true);
298 }
299 git_raw(repo, &["worktree", "prune"]).await?;
301 Ok(false)
302}
303
304pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
308 let path_s = path.to_string_lossy().to_string();
309 Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
310}
311
312pub async fn remove_worktree_from_linked(dir: &Path) {
321 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
322 return;
323 };
324 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
325 return;
326 };
327 let admin = Path::new(admin);
330 let Some(common) = admin.parent().and_then(Path::parent) else {
331 return;
332 };
333 let common_s = common.to_string_lossy();
334 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
335}
336
337pub async fn worktree_prune(repo: &Path) -> Result<()> {
346 git(repo, &["worktree", "prune"]).await.map(|_| ())
347}
348
349pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
351 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
352}
353
354pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
356 let refname = format!("refs/heads/{branch}");
357 Ok(
358 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
359 .await?
360 .ok(),
361 )
362}
363
364pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
367 let refname = format!("refs/heads/{branch}");
368 let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
369 match out.code {
370 Some(0) => Ok(true),
371 Some(2) => Ok(false),
372 code => bail!(
373 "git ls-remote {remote} failed (exit {code:?}): {}",
374 out.stderr
375 ),
376 }
377}
378
379pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
381 let range = format!("{base}...{head}");
382 git(
383 worktree,
384 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
385 )
386 .await
387}
388
389pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
391 let range = format!("{base}...{head}");
392 git(worktree, &["diff", "--no-color", "--stat", &range]).await
393}
394
395pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
397 let range = format!("{base}...{head}");
398 let out = git(worktree, &["diff", "--name-only", &range]).await?;
399 Ok(out.lines().map(str::to_owned).collect())
400}
401
402pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
406 let range = format!("{base}..{head}");
407 let out = git(
408 worktree,
409 &[
410 "log",
411 "--reverse",
412 "--no-color",
413 "--format=%s%x1f%b%x00",
414 &range,
415 ],
416 )
417 .await?;
418 Ok(out
419 .split('\0')
420 .filter_map(|rec| {
421 let rec = rec.trim_start_matches('\n');
422 if rec.trim().is_empty() {
423 return None;
424 }
425 let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
426 Some((subject.trim().to_owned(), body.trim().to_owned()))
427 })
428 .collect())
429}
430
431pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
433 let range = format!("{base}..{head}");
434 git(
435 worktree,
436 &["log", "--reverse", "--format=%s%n%b%n--", &range],
437 )
438 .await
439}
440
441pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
444 let range = format!("{base}..{head}");
445 let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
446 let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
447 parts.pop();
449 Ok(parts)
450}
451
452pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
454 let range = format!("{base}..{head}");
455 let out = git(worktree, &["rev-list", "--count", &range]).await?;
456 Ok(out.trim().parse().unwrap_or(0))
457}
458
459pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
466 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
467 return Ok(false);
468 }
469 git(worktree, &["add", "-A"]).await?;
470 if !crate::worktree_setup::withheld_paths(worktree)
472 .await
473 .is_empty()
474 {
475 crate::worktree_setup::unstage_products(worktree).await?;
476 if git_raw(worktree, &["diff", "--cached", "--quiet"])
477 .await?
478 .ok()
479 {
480 return Ok(false);
481 }
482 }
483 let out = git_raw(
484 worktree,
485 &[
486 "-c",
487 "user.name=magi candidate",
488 "-c",
489 "user.email=magi@localhost",
490 "commit",
491 "--no-verify",
492 "-m",
493 message,
494 ],
495 )
496 .await?;
497 if !out.ok() {
498 bail!("rescue commit failed: {}", out.stderr);
499 }
500 Ok(true)
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct Stray {
507 pub path: String,
509 pub manager: String,
511 pub kept_by: String,
514}
515
516#[derive(Debug, Default)]
518pub struct Rescue {
519 pub committed: bool,
521 pub withheld: Vec<Stray>,
523}
524
525fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
527 Some(match name {
528 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
529 "yarn.lock" => ("node", "yarn"),
530 "pnpm-lock.yaml" => ("node", "pnpm"),
531 "bun.lock" | "bun.lockb" => ("node", "bun"),
532 "poetry.lock" => ("python", "poetry"),
533 "uv.lock" => ("python", "uv"),
534 "Pipfile.lock" => ("python", "pipenv"),
535 "pdm.lock" => ("python", "pdm"),
536 "Cargo.lock" => ("rust", "cargo"),
537 _ => return None,
538 })
539}
540
541fn split_dir(path: &str) -> (&str, &str) {
542 path.rsplit_once('/').unwrap_or(("", path))
543}
544
545pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
553 let mut out = Vec::new();
554 for path in untracked {
555 let (dir, name) = split_dir(path);
556 let Some((eco, manager)) = lock_kind(name) else {
557 continue;
558 };
559 let beside = |other: &String| split_dir(other).0 == dir;
560 let kept_by = if manager == "cargo" {
561 let has_manifest = tracked
562 .iter()
563 .chain(untracked)
564 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
565 if has_manifest {
566 continue;
567 }
568 "no Cargo.toml in the directory".to_owned()
569 } else {
570 let Some(other) = tracked.iter().find(|p| {
571 beside(p)
572 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
573 }) else {
574 continue;
575 };
576 other.clone()
577 };
578 out.push(Stray {
579 path: path.clone(),
580 manager: manager.to_owned(),
581 kept_by,
582 });
583 }
584 out
585}
586
587async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
588 let out = git(worktree, args).await?;
589 Ok(out
590 .split('\0')
591 .filter(|s| !s.is_empty())
592 .map(str::to_owned)
593 .collect())
594}
595
596pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
602 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
603 return Ok(Rescue::default());
604 }
605 let untracked = nul_list(
606 worktree,
607 &["ls-files", "-z", "--others", "--exclude-standard"],
608 )
609 .await?;
610 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
611 let withheld = stray_lockfiles(&untracked, &tracked);
612
613 git(worktree, &["add", "-A"]).await?;
614 if !withheld.is_empty() {
615 let mut args = vec!["reset", "-q", "--"];
616 args.extend(withheld.iter().map(|s| s.path.as_str()));
617 git(worktree, &args).await?;
618 }
619 crate::worktree_setup::unstage_products(worktree).await?;
620 if git_raw(worktree, &["diff", "--cached", "--quiet"])
621 .await?
622 .ok()
623 {
624 return Ok(Rescue {
625 committed: false,
626 withheld,
627 });
628 }
629 let out = git_raw(
630 worktree,
631 &[
632 "-c",
633 "user.name=magi candidate",
634 "-c",
635 "user.email=magi@localhost",
636 "commit",
637 "--no-verify",
638 "-m",
639 message,
640 ],
641 )
642 .await?;
643 if !out.ok() {
644 bail!("rescue commit failed: {}", out.stderr);
645 }
646 Ok(Rescue {
647 committed: true,
648 withheld,
649 })
650}
651
652pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
657 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
658 if out.ok() && out.stdout.trim() == "true" {
659 return Ok(false);
660 }
661 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
662 Ok(true)
663}
664
665pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
667 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
668 Ok(())
669}
670
671struct WorktreeConfigRef {
674 count: usize,
676 we_enabled: bool,
682}
683
684static WORKTREE_CONFIG: std::sync::LazyLock<
691 std::sync::Mutex<
692 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
693 >,
694> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
695
696fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
698 let mut map = WORKTREE_CONFIG
699 .lock()
700 .unwrap_or_else(std::sync::PoisonError::into_inner);
701 map.entry(repo.to_path_buf())
702 .or_insert_with(|| {
703 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
704 count: 0,
705 we_enabled: false,
706 }))
707 })
708 .clone()
709}
710
711pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
728 let slot = worktree_config_slot(repo);
729 let mut entry = slot.lock().await;
730 entry.count += 1;
731 if entry.count == 1 {
732 entry.we_enabled = enable_worktree_config(repo).await?;
733 }
734 Ok(())
735}
736
737pub async fn release_worktree_config(repo: &Path) -> Result<()> {
743 let slot = worktree_config_slot(repo);
744 let mut entry = slot.lock().await;
745 entry.count = entry.count.saturating_sub(1);
746 if entry.count == 0 && entry.we_enabled {
747 disable_worktree_config(repo).await?;
748 entry.we_enabled = false;
749 }
750 Ok(())
751}
752
753pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
759 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
760 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
761 .await
762 .map(|_| ())
763}
764
765pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
767 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
768 let path = worktree.join(git_dir);
769 if let Some(parent) = path.parent() {
770 tokio::fs::create_dir_all(parent).await.ok();
771 }
772 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
773 if body.lines().any(|l| l.trim() == pattern) {
774 return Ok(());
775 }
776 if !body.is_empty() && !body.ends_with('\n') {
777 body.push('\n');
778 }
779 body.push_str(pattern);
780 body.push('\n');
781 tokio::fs::write(&path, body)
782 .await
783 .with_context(|| format!("write {}", path.display()))?;
784 Ok(())
785}
786
787pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
793 git_raw(
794 repo,
795 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
796 )
797 .await
798}
799
800pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
811 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
812 if !staged.ok() {
813 return Ok(staged);
814 }
815 git_raw(repo, &["commit", "-m", message]).await
816}
817
818pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
828 git_raw(repo, &["merge", "--ff-only", branch]).await
829}
830
831pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
833 git_raw(repo, &["push", "-u", remote, branch]).await
834}
835
836pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
844 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
845}
846
847pub async fn push_pinned(
856 repo: &Path,
857 remote: &str,
858 branch: &str,
859 expected: &str,
860) -> Result<GitOut> {
861 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
862 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
863 git_raw(repo, &["push", &lease, remote, &refspec]).await
864}
865
866pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
869 let out = git(repo, &["cherry", upstream, head]).await?;
870 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
871 for line in out.lines() {
872 if let Some(sha) = line.strip_prefix("+ ") {
873 unmatched.push(sha.trim().to_owned());
874 } else if let Some(sha) = line.strip_prefix("- ") {
875 matched.push(sha.trim().to_owned());
876 }
877 }
878 Ok((unmatched, matched))
879}
880
881#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct CommitKey {
886 pub sha: String,
888 pub key: String,
890}
891
892pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
894 let out = git(
895 repo,
896 &[
897 "log",
898 "--no-merges",
899 "--reverse",
900 "--date=raw",
901 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
902 range,
903 ],
904 )
905 .await?;
906 Ok(out
907 .lines()
908 .filter_map(|l| l.split_once('\u{1f}'))
909 .map(|(sha, key)| CommitKey {
910 sha: sha.to_owned(),
911 key: key.to_owned(),
912 })
913 .collect())
914}
915
916#[derive(Debug, Clone, PartialEq, Eq)]
918pub enum RebaseStart {
919 Applied,
922 Conflicted(String),
925 Failed(String),
928}
929
930pub async fn rebase_start(
945 repo: &Path,
946 scratch: &Path,
947 branch: &str,
948 onto: &str,
949) -> Result<RebaseStart> {
950 worktree_remove(repo, scratch).await.ok();
953 git_raw(
954 repo,
955 &[
956 "worktree",
957 "add",
958 "--force",
959 &scratch.to_string_lossy(),
960 branch,
961 ],
962 )
963 .await?;
964
965 let out = git_raw(scratch, &["rebase", onto]).await?;
966 if out.ok() {
967 worktree_remove(repo, scratch).await.ok();
968 return Ok(RebaseStart::Applied);
969 }
970 let why = if out.stderr.trim().is_empty() {
971 out.stdout.trim().to_owned()
972 } else {
973 out.stderr.trim().to_owned()
974 };
975 if rebase_in_progress(scratch).await {
976 return Ok(RebaseStart::Conflicted(why));
977 }
978 worktree_remove(repo, scratch).await.ok();
979 Ok(RebaseStart::Failed(why))
980}
981
982pub async fn rebase_in_progress(worktree: &Path) -> bool {
984 for name in ["rebase-merge", "rebase-apply"] {
985 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
986 continue;
987 };
988 let p = Path::new(p.trim());
989 let full = if p.is_absolute() {
990 p.to_path_buf()
991 } else {
992 worktree.join(p)
993 };
994 if full.exists() {
995 return true;
996 }
997 }
998 false
999}
1000
1001pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1003 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1004 Ok(out
1005 .lines()
1006 .map(str::trim)
1007 .filter(|l| !l.is_empty())
1008 .map(str::to_owned)
1009 .collect())
1010}
1011
1012pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1015 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1016 worktree_remove(repo, scratch).await.ok();
1017}
1018
1019pub async fn rebase_branch_in_temp(
1027 repo: &Path,
1028 scratch: &Path,
1029 branch: &str,
1030 onto: &str,
1031) -> Result<Option<String>> {
1032 match rebase_start(repo, scratch, branch, onto).await? {
1033 RebaseStart::Applied => Ok(None),
1034 RebaseStart::Failed(why) => Ok(Some(why)),
1035 RebaseStart::Conflicted(why) => {
1036 rebase_abort(repo, scratch).await;
1037 Ok(Some(why))
1038 }
1039 }
1040}
1041
1042pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1055 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1056 git(worktree, &["clean", "-fdx"]).await?;
1057 Ok(())
1058}
1059
1060pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1079 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1080 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1081}
1082
1083pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1085 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1086}
1087
1088pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1090 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1091 .await
1092 .is_ok_and(|o| o.ok())
1093}
1094
1095pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1100 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1101 match out.code {
1102 Some(0) => Ok(true),
1103 Some(1) => Ok(false),
1104 _ => bail!(
1105 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
1106 out.code,
1107 out.stderr
1108 ),
1109 }
1110}
1111
1112pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1114 let spec = format!("{rev}^{{commit}}");
1115 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1116 .await
1117 .ok()?;
1118 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1119}
1120
1121pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1123 let out = git(
1124 repo,
1125 &[
1126 "branch",
1127 "-a",
1128 "--contains",
1129 commit,
1130 "--format=%(refname:short)",
1131 ],
1132 )
1133 .await?;
1134 Ok(out
1135 .lines()
1136 .map(str::trim)
1137 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1138 .map(str::to_owned)
1139 .collect())
1140}
1141
1142pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1146 let out = git_raw(
1147 worktree,
1148 &[
1149 "-c",
1150 "user.name=magi candidate",
1151 "-c",
1152 "user.email=magi@localhost",
1153 "cherry-pick",
1154 "-x",
1155 commit,
1156 ],
1157 )
1158 .await?;
1159 if out.ok() {
1160 return Ok(());
1161 }
1162 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1163 bail!(
1164 "cherry-pick of {commit} failed: {}",
1165 if out.stderr.is_empty() {
1166 out.stdout
1167 } else {
1168 out.stderr
1169 }
1170 )
1171}
1172
1173pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1175 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1176}
1177
1178pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1180 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1181 .await
1182 .is_ok_and(|o| o.ok())
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::*;
1188
1189 #[test]
1190 fn worktree_holder_is_read_from_the_porcelain_listing() {
1191 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1192 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1193 worktree /wt/detached\nHEAD ccc\ndetached\n";
1194 assert_eq!(
1195 parse_worktree_holder(listing, "magi/f82f/A"),
1196 Some(PathBuf::from("/wt/cand-A"))
1197 );
1198 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1199 assert_eq!(parse_worktree_holder(listing, "other"), None);
1200 }
1201
1202 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1203 let dir = tempfile::tempdir().unwrap();
1204 let repo = dir.path().join("repo");
1205 tokio::fs::create_dir_all(&repo).await.unwrap();
1206 git(&repo, &["init", "-b", "main"]).await.unwrap();
1207 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1208 git(&repo, &["config", "user.email", "test@example.com"])
1209 .await
1210 .unwrap();
1211 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1212 git(&repo, &["add", "-A"]).await.unwrap();
1213 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1214 (dir, repo)
1215 }
1216
1217 #[tokio::test]
1218 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1219 let (_g, repo) = scratch().await;
1220
1221 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1223 tokio::fs::write(repo.join("b.txt"), "side\n")
1224 .await
1225 .unwrap();
1226 git(&repo, &["add", "-A"]).await.unwrap();
1227 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1228
1229 git(&repo, &["checkout", "main"]).await.unwrap();
1232 tokio::fs::write(repo.join("c.txt"), "main\n")
1233 .await
1234 .unwrap();
1235 git(&repo, &["add", "-A"]).await.unwrap();
1236 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1237
1238 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1239 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1240 .await
1241 .unwrap();
1242 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1243 assert_eq!(
1244 commits_ahead(&repo, "main", "side").await.unwrap(),
1245 1,
1246 "one commit, replayed onto the new base"
1247 );
1248 assert!(
1249 !scratch_tree.exists(),
1250 "the throwaway worktree is not left behind"
1251 );
1252
1253 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1255 tokio::fs::write(repo.join("a.txt"), "clash\n")
1256 .await
1257 .unwrap();
1258 git(&repo, &["add", "-A"]).await.unwrap();
1259 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1260 git(&repo, &["checkout", "main"]).await.unwrap();
1261 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1262 .await
1263 .unwrap();
1264 git(&repo, &["add", "-A"]).await.unwrap();
1265 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1266
1267 let before = rev_parse(&repo, "clash").await.unwrap();
1268 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1269 .await
1270 .unwrap()
1271 .expect("a same-line clash cannot be rebased silently");
1272 assert!(
1273 why.to_lowercase().contains("conflict"),
1274 "the reason is what git said, which is what a person needs: {why}"
1275 );
1276 assert_eq!(
1277 rev_parse(&repo, "clash").await.unwrap(),
1278 before,
1279 "a failed rebase leaves the branch exactly where it was"
1280 );
1281 assert!(!scratch_tree.exists(), "and cleans up after itself");
1282 }
1283
1284 #[tokio::test]
1285 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1286 let (_g, repo) = scratch().await;
1287 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1288 for name in ["b.txt", "c.txt"] {
1289 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1290 git(&repo, &["add", "-A"]).await.unwrap();
1291 git(
1292 &repo,
1293 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1294 )
1295 .await
1296 .unwrap();
1297 }
1298 git(&repo, &["checkout", "main"]).await.unwrap();
1299 let before = rev_parse(&repo, "main").await.unwrap();
1300
1301 let out = merge_squash(&repo, "side", "an explicit subject")
1302 .await
1303 .unwrap();
1304 assert!(out.ok(), "{}", out.stderr);
1305 assert_eq!(
1306 commits_ahead(&repo, &before, "main").await.unwrap(),
1307 1,
1308 "squash adds exactly one commit onto the tip, not one per candidate commit"
1309 );
1310 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1311 assert_eq!(
1312 subject, "an explicit subject",
1313 "the candidate's own placeholder subject must not survive: {subject}"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1319 let (_g, repo) = scratch().await;
1320 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1321 tokio::fs::write(repo.join("b.txt"), "side\n")
1322 .await
1323 .unwrap();
1324 git(&repo, &["add", "-A"]).await.unwrap();
1325 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1326 git(&repo, &["checkout", "main"]).await.unwrap();
1327
1328 let before = rev_parse(&repo, "side").await.unwrap();
1329 let out = merge_ff_only(&repo, "side").await.unwrap();
1330 assert!(out.ok(), "{}", out.stderr);
1331 assert_eq!(
1332 rev_parse(&repo, "main").await.unwrap(),
1333 before,
1334 "a fast-forward moves the base tip to the branch, no merge commit"
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1340 let (_g, repo) = scratch().await;
1341 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1342 tokio::fs::write(repo.join("b.txt"), "side\n")
1343 .await
1344 .unwrap();
1345 git(&repo, &["add", "-A"]).await.unwrap();
1346 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1347
1348 git(&repo, &["checkout", "main"]).await.unwrap();
1350 tokio::fs::write(repo.join("c.txt"), "main\n")
1351 .await
1352 .unwrap();
1353 git(&repo, &["add", "-A"]).await.unwrap();
1354 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1355
1356 let before = rev_parse(&repo, "main").await.unwrap();
1357 let out = merge_ff_only(&repo, "side").await.unwrap();
1358 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1359 assert_eq!(
1360 rev_parse(&repo, "main").await.unwrap(),
1361 before,
1362 "a refused fast-forward must not touch main"
1363 );
1364 }
1365
1366 #[tokio::test]
1367 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1368 let (guard, repo) = scratch().await;
1369
1370 git(&repo, &["branch", "side"]).await.unwrap();
1374 let side_wt = guard.path().join("side-wt");
1375 git(
1376 &repo,
1377 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1378 )
1379 .await
1380 .unwrap();
1381 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1382 .await
1383 .unwrap();
1384 git(&side_wt, &["add", "-A"]).await.unwrap();
1385 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1386
1387 git(&repo, &["checkout", "main"]).await.unwrap();
1389 tokio::fs::write(repo.join("c.txt"), "main\n")
1390 .await
1391 .unwrap();
1392 git(&repo, &["add", "-A"]).await.unwrap();
1393 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1394
1395 let scratch_tree = guard.path().join("rebase-scratch");
1397 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1398 .await
1399 .unwrap();
1400 assert!(clean.is_none());
1401
1402 assert_eq!(
1406 rev_parse(&side_wt, "HEAD").await.unwrap(),
1407 rev_parse(&repo, "side").await.unwrap(),
1408 "HEAD follows the moved ref"
1409 );
1410 assert!(
1411 !side_wt.join("c.txt").exists(),
1412 "stale until synced: main's new file has not reached this worktree's disk"
1413 );
1414
1415 sync_to_head(&side_wt).await.unwrap();
1416 assert!(side_wt.join("c.txt").is_file(), "synced now");
1417 assert!(
1418 side_wt.join("b.txt").is_file(),
1419 "the worktree's own committed work survives the sync"
1420 );
1421 assert!(is_clean(&side_wt).await.unwrap());
1422 }
1423
1424 #[tokio::test]
1425 async fn clean_repo_reports_clean_then_dirty() {
1426 let (_g, repo) = scratch().await;
1427 assert!(is_clean(&repo).await.unwrap());
1428 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1429 assert!(!is_clean(&repo).await.unwrap());
1430 }
1431
1432 async fn track(repo: &Path, name: &str, body: &str) {
1433 let p = repo.join(name);
1434 if let Some(d) = p.parent() {
1435 tokio::fs::create_dir_all(d).await.unwrap();
1436 }
1437 tokio::fs::write(&p, body).await.unwrap();
1438 git(repo, &["add", name]).await.unwrap();
1439 git(
1440 repo,
1441 &[
1442 "-c",
1443 "user.name=t",
1444 "-c",
1445 "user.email=t@localhost",
1446 "commit",
1447 "-q",
1448 "-m",
1449 "seed",
1450 ],
1451 )
1452 .await
1453 .unwrap();
1454 }
1455
1456 #[tokio::test]
1457 async fn rescue_withholds_a_foreign_lockfile() {
1458 let (_g, repo) = scratch().await;
1459 track(&repo, "web/bun.lock", "a\n").await;
1460 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1461 .await
1462 .unwrap();
1463 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1464 .await
1465 .unwrap();
1466
1467 let r = rescue_commit(&repo, "rescue").await.unwrap();
1468 assert!(r.committed);
1469 assert_eq!(
1470 r.withheld,
1471 [Stray {
1472 path: "web/pnpm-lock.yaml".to_owned(),
1473 manager: "pnpm".to_owned(),
1474 kept_by: "web/bun.lock".to_owned(),
1475 }]
1476 );
1477 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1478 .await
1479 .unwrap();
1480 assert!(files.contains("web/app.ts"), "{files}");
1481 assert!(!files.contains("pnpm-lock"), "{files}");
1482 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1483 }
1484
1485 #[tokio::test]
1486 async fn rescue_with_only_a_stray_commits_nothing() {
1487 let (_g, repo) = scratch().await;
1488 track(&repo, "bun.lock", "a\n").await;
1489 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1490 .await
1491 .unwrap();
1492 let r = rescue_commit(&repo, "rescue").await.unwrap();
1493 assert!(!r.committed);
1494 assert_eq!(r.withheld.len(), 1);
1495 }
1496
1497 #[tokio::test]
1498 async fn rescue_keeps_a_same_manager_lockfile_update() {
1499 let (_g, repo) = scratch().await;
1500 track(&repo, "bun.lock", "a\n").await;
1501 tokio::fs::write(repo.join("bun.lock"), "b\n")
1502 .await
1503 .unwrap();
1504 let r = rescue_commit(&repo, "rescue").await.unwrap();
1505 assert!(r.committed);
1506 assert!(r.withheld.is_empty());
1507 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1508 .await
1509 .unwrap();
1510 assert_eq!(files, "bun.lock");
1511 }
1512
1513 #[tokio::test]
1514 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1515 let (_g, repo) = scratch().await;
1516 track(&repo, "other/bun.lock", "a\n").await;
1517 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1518 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1519 .await
1520 .unwrap();
1521 let r = rescue_commit(&repo, "rescue").await.unwrap();
1522 assert!(r.committed);
1523 assert!(r.withheld.is_empty());
1524 }
1525
1526 #[test]
1527 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1528 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1529 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1530 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1531 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1532 assert_eq!(
1534 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1535 1
1536 );
1537 }
1538
1539 #[tokio::test]
1540 async fn worktree_lifecycle_and_diff() {
1541 let (guard, repo) = scratch().await;
1542 let base = rev_parse(&repo, "HEAD").await.unwrap();
1543 let wt = guard.path().join("wt-a");
1544 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1545 .await
1546 .unwrap();
1547 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1548 .await
1549 .unwrap();
1550
1551 assert!(commit_all(&wt, "candidate work").await.unwrap());
1552 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1553
1554 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1555 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1556 assert!(patch.contains("b.txt"), "patch was: {patch}");
1557 assert_eq!(
1558 changed_files(&wt, &base, "HEAD").await.unwrap(),
1559 ["b.txt".to_owned()]
1560 );
1561
1562 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1564 .await
1565 .unwrap();
1566 assert_eq!(author, "magi candidate <magi@localhost>");
1567
1568 assert!(worktree_remove(&repo, &wt).await.unwrap());
1569 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1570 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1571 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1572 }
1573
1574 #[tokio::test]
1575 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1576 let (guard, repo) = scratch().await;
1577 let base = rev_parse(&repo, "HEAD").await.unwrap();
1578 let wt = guard.path().join("wt-h");
1579 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1580 .await
1581 .unwrap();
1582 let hooks = guard.path().join("hooks");
1583 tokio::fs::create_dir_all(&hooks).await.unwrap();
1584
1585 assert!(enable_worktree_config(&repo).await.unwrap());
1586 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1587
1588 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1589 .await
1590 .unwrap();
1591 assert!(!in_wt.is_empty());
1592 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1593 .await
1594 .unwrap();
1595 assert!(
1596 !in_primary.ok(),
1597 "primary worktree must keep its own hooks: {in_primary:?}"
1598 );
1599
1600 disable_worktree_config(&repo).await.unwrap();
1601 }
1602
1603 #[tokio::test]
1604 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1605 let (_g, repo) = scratch().await;
1606
1607 acquire_worktree_config(&repo).await.unwrap();
1610 acquire_worktree_config(&repo).await.unwrap();
1611
1612 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1613 .await
1614 .unwrap();
1615 assert_eq!(on, "true");
1616
1617 release_worktree_config(&repo).await.unwrap();
1621 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1622 .await
1623 .unwrap();
1624 assert_eq!(
1625 still_on, "true",
1626 "a sibling run's release must not disable the setting for the one still working"
1627 );
1628
1629 release_worktree_config(&repo).await.unwrap();
1631 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1632 .await
1633 .unwrap();
1634 assert!(
1635 !after.ok(),
1636 "the last release must turn the setting back off: {after:?}"
1637 );
1638 }
1639
1640 #[tokio::test]
1641 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1642 let (_g, repo) = scratch().await;
1643 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1644 .await
1645 .unwrap();
1646
1647 acquire_worktree_config(&repo).await.unwrap();
1652 release_worktree_config(&repo).await.unwrap();
1653
1654 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1655 .await
1656 .unwrap();
1657 assert_eq!(still_on, "true");
1658 }
1659
1660 #[tokio::test]
1661 async fn local_exclude_is_idempotent() {
1662 let (_g, repo) = scratch().await;
1663 local_exclude(&repo, "/.magi/").await.unwrap();
1664 local_exclude(&repo, "/.magi/").await.unwrap();
1665 let path = repo.join(".git/info/exclude");
1666 let body = tokio::fs::read_to_string(&path).await.unwrap();
1667 assert_eq!(body.matches("/.magi/").count(), 1);
1668 }
1669
1670 fn sh(cwd: &Path, args: &[&str]) {
1671 let out = std::process::Command::new("git")
1672 .quiet()
1673 .args(args)
1674 .current_dir(cwd)
1675 .output()
1676 .unwrap();
1677 assert!(
1678 out.status.success(),
1679 "git {args:?}: {}",
1680 String::from_utf8_lossy(&out.stderr)
1681 );
1682 }
1683
1684 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1687 let (g, repo) = scratch().await;
1688 let bare = g.path().join("bare.git");
1689 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1690 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1691 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1692 sh(&repo, &["fetch", "origin"]);
1693 (g, repo)
1694 }
1695
1696 #[test]
1697 fn remote_head_branch_strips_only_the_matching_remote() {
1698 assert_eq!(
1699 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1700 Some("main")
1701 );
1702 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1703 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1704 }
1705
1706 #[tokio::test]
1707 async fn merge_base_branch_reads_origin_head_when_present() {
1708 let (_g, repo) = with_remote().await;
1709 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1710 assert_eq!(
1711 merge_base_branch(&repo, "origin", None).await.unwrap(),
1712 "trunk"
1713 );
1714 }
1715
1716 #[tokio::test]
1717 async fn merge_base_branch_recovers_a_missing_origin_head() {
1718 let (_g, repo) = with_remote().await;
1719 sh(&repo, &["remote", "set-head", "origin", "-d"]);
1721 let head = git_raw(
1722 &repo,
1723 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1724 )
1725 .await
1726 .unwrap();
1727 assert!(!head.ok(), "precondition: no origin/HEAD");
1728 assert_eq!(
1729 merge_base_branch(&repo, "origin", None).await.unwrap(),
1730 "trunk"
1731 );
1732 }
1733
1734 #[tokio::test]
1735 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1736 let (_g, repo) = scratch().await;
1737 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1738 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1739 }
1740
1741 #[tokio::test]
1742 async fn merge_base_branch_prefers_the_explicit_base() {
1743 let (_g, repo) = with_remote().await;
1744 assert_eq!(
1745 merge_base_branch(&repo, "origin", Some("release"))
1746 .await
1747 .unwrap(),
1748 "release"
1749 );
1750 }
1751
1752 #[tokio::test]
1753 async fn merge_base_branch_names_an_unfetched_default_branch() {
1754 let (g, repo) = scratch().await;
1757 let bare = g.path().join("bare.git");
1758 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1759 sh(
1760 &repo,
1761 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1762 );
1763 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1764 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1765 assert_eq!(
1766 merge_base_branch(&repo, "origin", None).await.unwrap(),
1767 "trunk"
1768 );
1769 }
1770
1771 #[test]
1772 fn symref_branch_reads_ls_remote_output() {
1773 assert_eq!(
1774 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1775 Some("main")
1776 );
1777 assert_eq!(symref_branch("abc\tHEAD\n"), None);
1778 }
1779}