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 let out = git_raw(
471 worktree,
472 &[
473 "-c",
474 "user.name=magi candidate",
475 "-c",
476 "user.email=magi@localhost",
477 "commit",
478 "--no-verify",
479 "-m",
480 message,
481 ],
482 )
483 .await?;
484 if !out.ok() {
485 bail!("rescue commit failed: {}", out.stderr);
486 }
487 Ok(true)
488}
489
490#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct Stray {
494 pub path: String,
496 pub manager: String,
498 pub kept_by: String,
501}
502
503#[derive(Debug, Default)]
505pub struct Rescue {
506 pub committed: bool,
508 pub withheld: Vec<Stray>,
510}
511
512fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
514 Some(match name {
515 "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
516 "yarn.lock" => ("node", "yarn"),
517 "pnpm-lock.yaml" => ("node", "pnpm"),
518 "bun.lock" | "bun.lockb" => ("node", "bun"),
519 "poetry.lock" => ("python", "poetry"),
520 "uv.lock" => ("python", "uv"),
521 "Pipfile.lock" => ("python", "pipenv"),
522 "pdm.lock" => ("python", "pdm"),
523 "Cargo.lock" => ("rust", "cargo"),
524 _ => return None,
525 })
526}
527
528fn split_dir(path: &str) -> (&str, &str) {
529 path.rsplit_once('/').unwrap_or(("", path))
530}
531
532pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
540 let mut out = Vec::new();
541 for path in untracked {
542 let (dir, name) = split_dir(path);
543 let Some((eco, manager)) = lock_kind(name) else {
544 continue;
545 };
546 let beside = |other: &String| split_dir(other).0 == dir;
547 let kept_by = if manager == "cargo" {
548 let has_manifest = tracked
549 .iter()
550 .chain(untracked)
551 .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
552 if has_manifest {
553 continue;
554 }
555 "no Cargo.toml in the directory".to_owned()
556 } else {
557 let Some(other) = tracked.iter().find(|p| {
558 beside(p)
559 && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
560 }) else {
561 continue;
562 };
563 other.clone()
564 };
565 out.push(Stray {
566 path: path.clone(),
567 manager: manager.to_owned(),
568 kept_by,
569 });
570 }
571 out
572}
573
574async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
575 let out = git(worktree, args).await?;
576 Ok(out
577 .split('\0')
578 .filter(|s| !s.is_empty())
579 .map(str::to_owned)
580 .collect())
581}
582
583pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
589 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
590 return Ok(Rescue::default());
591 }
592 let untracked = nul_list(
593 worktree,
594 &["ls-files", "-z", "--others", "--exclude-standard"],
595 )
596 .await?;
597 let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
598 let withheld = stray_lockfiles(&untracked, &tracked);
599
600 git(worktree, &["add", "-A"]).await?;
601 if !withheld.is_empty() {
602 let mut args = vec!["reset", "-q", "--"];
603 args.extend(withheld.iter().map(|s| s.path.as_str()));
604 git(worktree, &args).await?;
605 }
606 if git_raw(worktree, &["diff", "--cached", "--quiet"])
607 .await?
608 .ok()
609 {
610 return Ok(Rescue {
611 committed: false,
612 withheld,
613 });
614 }
615 let out = git_raw(
616 worktree,
617 &[
618 "-c",
619 "user.name=magi candidate",
620 "-c",
621 "user.email=magi@localhost",
622 "commit",
623 "--no-verify",
624 "-m",
625 message,
626 ],
627 )
628 .await?;
629 if !out.ok() {
630 bail!("rescue commit failed: {}", out.stderr);
631 }
632 Ok(Rescue {
633 committed: true,
634 withheld,
635 })
636}
637
638pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
643 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
644 if out.ok() && out.stdout.trim() == "true" {
645 return Ok(false);
646 }
647 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
648 Ok(true)
649}
650
651pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
653 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
654 Ok(())
655}
656
657struct WorktreeConfigRef {
660 count: usize,
662 we_enabled: bool,
668}
669
670static WORKTREE_CONFIG: std::sync::LazyLock<
677 std::sync::Mutex<
678 std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
679 >,
680> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
681
682fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
684 let mut map = WORKTREE_CONFIG
685 .lock()
686 .unwrap_or_else(std::sync::PoisonError::into_inner);
687 map.entry(repo.to_path_buf())
688 .or_insert_with(|| {
689 std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
690 count: 0,
691 we_enabled: false,
692 }))
693 })
694 .clone()
695}
696
697pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
714 let slot = worktree_config_slot(repo);
715 let mut entry = slot.lock().await;
716 entry.count += 1;
717 if entry.count == 1 {
718 entry.we_enabled = enable_worktree_config(repo).await?;
719 }
720 Ok(())
721}
722
723pub async fn release_worktree_config(repo: &Path) -> Result<()> {
729 let slot = worktree_config_slot(repo);
730 let mut entry = slot.lock().await;
731 entry.count = entry.count.saturating_sub(1);
732 if entry.count == 0 && entry.we_enabled {
733 disable_worktree_config(repo).await?;
734 entry.we_enabled = false;
735 }
736 Ok(())
737}
738
739pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
745 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
746 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
747 .await
748 .map(|_| ())
749}
750
751pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
753 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
754 let path = worktree.join(git_dir);
755 if let Some(parent) = path.parent() {
756 tokio::fs::create_dir_all(parent).await.ok();
757 }
758 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
759 if body.lines().any(|l| l.trim() == pattern) {
760 return Ok(());
761 }
762 if !body.is_empty() && !body.ends_with('\n') {
763 body.push('\n');
764 }
765 body.push_str(pattern);
766 body.push('\n');
767 tokio::fs::write(&path, body)
768 .await
769 .with_context(|| format!("write {}", path.display()))?;
770 Ok(())
771}
772
773pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
779 git_raw(
780 repo,
781 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
782 )
783 .await
784}
785
786pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
797 let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
798 if !staged.ok() {
799 return Ok(staged);
800 }
801 git_raw(repo, &["commit", "-m", message]).await
802}
803
804pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
814 git_raw(repo, &["merge", "--ff-only", branch]).await
815}
816
817pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
819 git_raw(repo, &["push", "-u", remote, branch]).await
820}
821
822pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
830 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
831}
832
833pub async fn push_pinned(
842 repo: &Path,
843 remote: &str,
844 branch: &str,
845 expected: &str,
846) -> Result<GitOut> {
847 let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
848 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
849 git_raw(repo, &["push", &lease, remote, &refspec]).await
850}
851
852pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
855 let out = git(repo, &["cherry", upstream, head]).await?;
856 let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
857 for line in out.lines() {
858 if let Some(sha) = line.strip_prefix("+ ") {
859 unmatched.push(sha.trim().to_owned());
860 } else if let Some(sha) = line.strip_prefix("- ") {
861 matched.push(sha.trim().to_owned());
862 }
863 }
864 Ok((unmatched, matched))
865}
866
867#[derive(Debug, Clone, PartialEq, Eq)]
871pub struct CommitKey {
872 pub sha: String,
874 pub key: String,
876}
877
878pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
880 let out = git(
881 repo,
882 &[
883 "log",
884 "--no-merges",
885 "--reverse",
886 "--date=raw",
887 "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
888 range,
889 ],
890 )
891 .await?;
892 Ok(out
893 .lines()
894 .filter_map(|l| l.split_once('\u{1f}'))
895 .map(|(sha, key)| CommitKey {
896 sha: sha.to_owned(),
897 key: key.to_owned(),
898 })
899 .collect())
900}
901
902#[derive(Debug, Clone, PartialEq, Eq)]
904pub enum RebaseStart {
905 Applied,
908 Conflicted(String),
911 Failed(String),
914}
915
916pub async fn rebase_start(
931 repo: &Path,
932 scratch: &Path,
933 branch: &str,
934 onto: &str,
935) -> Result<RebaseStart> {
936 worktree_remove(repo, scratch).await.ok();
939 git_raw(
940 repo,
941 &[
942 "worktree",
943 "add",
944 "--force",
945 &scratch.to_string_lossy(),
946 branch,
947 ],
948 )
949 .await?;
950
951 let out = git_raw(scratch, &["rebase", onto]).await?;
952 if out.ok() {
953 worktree_remove(repo, scratch).await.ok();
954 return Ok(RebaseStart::Applied);
955 }
956 let why = if out.stderr.trim().is_empty() {
957 out.stdout.trim().to_owned()
958 } else {
959 out.stderr.trim().to_owned()
960 };
961 if rebase_in_progress(scratch).await {
962 return Ok(RebaseStart::Conflicted(why));
963 }
964 worktree_remove(repo, scratch).await.ok();
965 Ok(RebaseStart::Failed(why))
966}
967
968pub async fn rebase_in_progress(worktree: &Path) -> bool {
970 for name in ["rebase-merge", "rebase-apply"] {
971 let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
972 continue;
973 };
974 let p = Path::new(p.trim());
975 let full = if p.is_absolute() {
976 p.to_path_buf()
977 } else {
978 worktree.join(p)
979 };
980 if full.exists() {
981 return true;
982 }
983 }
984 false
985}
986
987pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
989 let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
990 Ok(out
991 .lines()
992 .map(str::trim)
993 .filter(|l| !l.is_empty())
994 .map(str::to_owned)
995 .collect())
996}
997
998pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1001 git_raw(scratch, &["rebase", "--abort"]).await.ok();
1002 worktree_remove(repo, scratch).await.ok();
1003}
1004
1005pub async fn rebase_branch_in_temp(
1013 repo: &Path,
1014 scratch: &Path,
1015 branch: &str,
1016 onto: &str,
1017) -> Result<Option<String>> {
1018 match rebase_start(repo, scratch, branch, onto).await? {
1019 RebaseStart::Applied => Ok(None),
1020 RebaseStart::Failed(why) => Ok(Some(why)),
1021 RebaseStart::Conflicted(why) => {
1022 rebase_abort(repo, scratch).await;
1023 Ok(Some(why))
1024 }
1025 }
1026}
1027
1028pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1041 git(worktree, &["reset", "--hard", "HEAD"]).await?;
1042 git(worktree, &["clean", "-fdx"]).await?;
1043 Ok(())
1044}
1045
1046pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1065 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1066 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1067}
1068
1069pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1071 Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1072}
1073
1074pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1076 git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1077 .await
1078 .is_ok_and(|o| o.ok())
1079}
1080
1081pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1086 let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1087 match out.code {
1088 Some(0) => Ok(true),
1089 Some(1) => Ok(false),
1090 _ => bail!(
1091 "git merge-base --is-ancestor {ancestor} {of} failed (exit {:?}): {}",
1092 out.code,
1093 out.stderr
1094 ),
1095 }
1096}
1097
1098pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1100 let spec = format!("{rev}^{{commit}}");
1101 let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1102 .await
1103 .ok()?;
1104 (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1105}
1106
1107pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1109 let out = git(
1110 repo,
1111 &[
1112 "branch",
1113 "-a",
1114 "--contains",
1115 commit,
1116 "--format=%(refname:short)",
1117 ],
1118 )
1119 .await?;
1120 Ok(out
1121 .lines()
1122 .map(str::trim)
1123 .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1124 .map(str::to_owned)
1125 .collect())
1126}
1127
1128pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1132 let out = git_raw(
1133 worktree,
1134 &[
1135 "-c",
1136 "user.name=magi candidate",
1137 "-c",
1138 "user.email=magi@localhost",
1139 "cherry-pick",
1140 "-x",
1141 commit,
1142 ],
1143 )
1144 .await?;
1145 if out.ok() {
1146 return Ok(());
1147 }
1148 let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1149 bail!(
1150 "cherry-pick of {commit} failed: {}",
1151 if out.stderr.is_empty() {
1152 out.stdout
1153 } else {
1154 out.stderr
1155 }
1156 )
1157}
1158
1159pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1161 git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1162}
1163
1164pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1166 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1167 .await
1168 .is_ok_and(|o| o.ok())
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174
1175 #[test]
1176 fn worktree_holder_is_read_from_the_porcelain_listing() {
1177 let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1178 worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1179 worktree /wt/detached\nHEAD ccc\ndetached\n";
1180 assert_eq!(
1181 parse_worktree_holder(listing, "magi/f82f/A"),
1182 Some(PathBuf::from("/wt/cand-A"))
1183 );
1184 assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1185 assert_eq!(parse_worktree_holder(listing, "other"), None);
1186 }
1187
1188 async fn scratch() -> (tempfile::TempDir, PathBuf) {
1189 let dir = tempfile::tempdir().unwrap();
1190 let repo = dir.path().join("repo");
1191 tokio::fs::create_dir_all(&repo).await.unwrap();
1192 git(&repo, &["init", "-b", "main"]).await.unwrap();
1193 git(&repo, &["config", "user.name", "test"]).await.unwrap();
1194 git(&repo, &["config", "user.email", "test@example.com"])
1195 .await
1196 .unwrap();
1197 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1198 git(&repo, &["add", "-A"]).await.unwrap();
1199 git(&repo, &["commit", "-m", "init"]).await.unwrap();
1200 (dir, repo)
1201 }
1202
1203 #[tokio::test]
1204 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1205 let (_g, repo) = scratch().await;
1206
1207 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1209 tokio::fs::write(repo.join("b.txt"), "side\n")
1210 .await
1211 .unwrap();
1212 git(&repo, &["add", "-A"]).await.unwrap();
1213 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1214
1215 git(&repo, &["checkout", "main"]).await.unwrap();
1218 tokio::fs::write(repo.join("c.txt"), "main\n")
1219 .await
1220 .unwrap();
1221 git(&repo, &["add", "-A"]).await.unwrap();
1222 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1223
1224 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1225 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1226 .await
1227 .unwrap();
1228 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1229 assert_eq!(
1230 commits_ahead(&repo, "main", "side").await.unwrap(),
1231 1,
1232 "one commit, replayed onto the new base"
1233 );
1234 assert!(
1235 !scratch_tree.exists(),
1236 "the throwaway worktree is not left behind"
1237 );
1238
1239 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1241 tokio::fs::write(repo.join("a.txt"), "clash\n")
1242 .await
1243 .unwrap();
1244 git(&repo, &["add", "-A"]).await.unwrap();
1245 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1246 git(&repo, &["checkout", "main"]).await.unwrap();
1247 tokio::fs::write(repo.join("a.txt"), "main edit\n")
1248 .await
1249 .unwrap();
1250 git(&repo, &["add", "-A"]).await.unwrap();
1251 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1252
1253 let before = rev_parse(&repo, "clash").await.unwrap();
1254 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1255 .await
1256 .unwrap()
1257 .expect("a same-line clash cannot be rebased silently");
1258 assert!(
1259 why.to_lowercase().contains("conflict"),
1260 "the reason is what git said, which is what a person needs: {why}"
1261 );
1262 assert_eq!(
1263 rev_parse(&repo, "clash").await.unwrap(),
1264 before,
1265 "a failed rebase leaves the branch exactly where it was"
1266 );
1267 assert!(!scratch_tree.exists(), "and cleans up after itself");
1268 }
1269
1270 #[tokio::test]
1271 async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1272 let (_g, repo) = scratch().await;
1273 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1274 for name in ["b.txt", "c.txt"] {
1275 tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1276 git(&repo, &["add", "-A"]).await.unwrap();
1277 git(
1278 &repo,
1279 &["commit", "-m", "magi: candidate A (uncommitted work)"],
1280 )
1281 .await
1282 .unwrap();
1283 }
1284 git(&repo, &["checkout", "main"]).await.unwrap();
1285 let before = rev_parse(&repo, "main").await.unwrap();
1286
1287 let out = merge_squash(&repo, "side", "an explicit subject")
1288 .await
1289 .unwrap();
1290 assert!(out.ok(), "{}", out.stderr);
1291 assert_eq!(
1292 commits_ahead(&repo, &before, "main").await.unwrap(),
1293 1,
1294 "squash adds exactly one commit onto the tip, not one per candidate commit"
1295 );
1296 let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1297 assert_eq!(
1298 subject, "an explicit subject",
1299 "the candidate's own placeholder subject must not survive: {subject}"
1300 );
1301 }
1302
1303 #[tokio::test]
1304 async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1305 let (_g, repo) = scratch().await;
1306 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1307 tokio::fs::write(repo.join("b.txt"), "side\n")
1308 .await
1309 .unwrap();
1310 git(&repo, &["add", "-A"]).await.unwrap();
1311 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1312 git(&repo, &["checkout", "main"]).await.unwrap();
1313
1314 let before = rev_parse(&repo, "side").await.unwrap();
1315 let out = merge_ff_only(&repo, "side").await.unwrap();
1316 assert!(out.ok(), "{}", out.stderr);
1317 assert_eq!(
1318 rev_parse(&repo, "main").await.unwrap(),
1319 before,
1320 "a fast-forward moves the base tip to the branch, no merge commit"
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1326 let (_g, repo) = scratch().await;
1327 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1328 tokio::fs::write(repo.join("b.txt"), "side\n")
1329 .await
1330 .unwrap();
1331 git(&repo, &["add", "-A"]).await.unwrap();
1332 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1333
1334 git(&repo, &["checkout", "main"]).await.unwrap();
1336 tokio::fs::write(repo.join("c.txt"), "main\n")
1337 .await
1338 .unwrap();
1339 git(&repo, &["add", "-A"]).await.unwrap();
1340 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1341
1342 let before = rev_parse(&repo, "main").await.unwrap();
1343 let out = merge_ff_only(&repo, "side").await.unwrap();
1344 assert!(!out.ok(), "a divergent branch cannot fast-forward");
1345 assert_eq!(
1346 rev_parse(&repo, "main").await.unwrap(),
1347 before,
1348 "a refused fast-forward must not touch main"
1349 );
1350 }
1351
1352 #[tokio::test]
1353 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1354 let (guard, repo) = scratch().await;
1355
1356 git(&repo, &["branch", "side"]).await.unwrap();
1360 let side_wt = guard.path().join("side-wt");
1361 git(
1362 &repo,
1363 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1364 )
1365 .await
1366 .unwrap();
1367 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1368 .await
1369 .unwrap();
1370 git(&side_wt, &["add", "-A"]).await.unwrap();
1371 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1372
1373 git(&repo, &["checkout", "main"]).await.unwrap();
1375 tokio::fs::write(repo.join("c.txt"), "main\n")
1376 .await
1377 .unwrap();
1378 git(&repo, &["add", "-A"]).await.unwrap();
1379 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1380
1381 let scratch_tree = guard.path().join("rebase-scratch");
1383 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1384 .await
1385 .unwrap();
1386 assert!(clean.is_none());
1387
1388 assert_eq!(
1392 rev_parse(&side_wt, "HEAD").await.unwrap(),
1393 rev_parse(&repo, "side").await.unwrap(),
1394 "HEAD follows the moved ref"
1395 );
1396 assert!(
1397 !side_wt.join("c.txt").exists(),
1398 "stale until synced: main's new file has not reached this worktree's disk"
1399 );
1400
1401 sync_to_head(&side_wt).await.unwrap();
1402 assert!(side_wt.join("c.txt").is_file(), "synced now");
1403 assert!(
1404 side_wt.join("b.txt").is_file(),
1405 "the worktree's own committed work survives the sync"
1406 );
1407 assert!(is_clean(&side_wt).await.unwrap());
1408 }
1409
1410 #[tokio::test]
1411 async fn clean_repo_reports_clean_then_dirty() {
1412 let (_g, repo) = scratch().await;
1413 assert!(is_clean(&repo).await.unwrap());
1414 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1415 assert!(!is_clean(&repo).await.unwrap());
1416 }
1417
1418 async fn track(repo: &Path, name: &str, body: &str) {
1419 let p = repo.join(name);
1420 if let Some(d) = p.parent() {
1421 tokio::fs::create_dir_all(d).await.unwrap();
1422 }
1423 tokio::fs::write(&p, body).await.unwrap();
1424 git(repo, &["add", name]).await.unwrap();
1425 git(
1426 repo,
1427 &[
1428 "-c",
1429 "user.name=t",
1430 "-c",
1431 "user.email=t@localhost",
1432 "commit",
1433 "-q",
1434 "-m",
1435 "seed",
1436 ],
1437 )
1438 .await
1439 .unwrap();
1440 }
1441
1442 #[tokio::test]
1443 async fn rescue_withholds_a_foreign_lockfile() {
1444 let (_g, repo) = scratch().await;
1445 track(&repo, "web/bun.lock", "a\n").await;
1446 tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1447 .await
1448 .unwrap();
1449 tokio::fs::write(repo.join("web/app.ts"), "real\n")
1450 .await
1451 .unwrap();
1452
1453 let r = rescue_commit(&repo, "rescue").await.unwrap();
1454 assert!(r.committed);
1455 assert_eq!(
1456 r.withheld,
1457 [Stray {
1458 path: "web/pnpm-lock.yaml".to_owned(),
1459 manager: "pnpm".to_owned(),
1460 kept_by: "web/bun.lock".to_owned(),
1461 }]
1462 );
1463 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1464 .await
1465 .unwrap();
1466 assert!(files.contains("web/app.ts"), "{files}");
1467 assert!(!files.contains("pnpm-lock"), "{files}");
1468 assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1469 }
1470
1471 #[tokio::test]
1472 async fn rescue_with_only_a_stray_commits_nothing() {
1473 let (_g, repo) = scratch().await;
1474 track(&repo, "bun.lock", "a\n").await;
1475 tokio::fs::write(repo.join("yarn.lock"), "x\n")
1476 .await
1477 .unwrap();
1478 let r = rescue_commit(&repo, "rescue").await.unwrap();
1479 assert!(!r.committed);
1480 assert_eq!(r.withheld.len(), 1);
1481 }
1482
1483 #[tokio::test]
1484 async fn rescue_keeps_a_same_manager_lockfile_update() {
1485 let (_g, repo) = scratch().await;
1486 track(&repo, "bun.lock", "a\n").await;
1487 tokio::fs::write(repo.join("bun.lock"), "b\n")
1488 .await
1489 .unwrap();
1490 let r = rescue_commit(&repo, "rescue").await.unwrap();
1491 assert!(r.committed);
1492 assert!(r.withheld.is_empty());
1493 let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1494 .await
1495 .unwrap();
1496 assert_eq!(files, "bun.lock");
1497 }
1498
1499 #[tokio::test]
1500 async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1501 let (_g, repo) = scratch().await;
1502 track(&repo, "other/bun.lock", "a\n").await;
1503 tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1504 tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1505 .await
1506 .unwrap();
1507 let r = rescue_commit(&repo, "rescue").await.unwrap();
1508 assert!(r.committed);
1509 assert!(r.withheld.is_empty());
1510 }
1511
1512 #[test]
1513 fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1514 let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1515 assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1516 assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1517 assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1518 assert_eq!(
1520 stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1521 1
1522 );
1523 }
1524
1525 #[tokio::test]
1526 async fn worktree_lifecycle_and_diff() {
1527 let (guard, repo) = scratch().await;
1528 let base = rev_parse(&repo, "HEAD").await.unwrap();
1529 let wt = guard.path().join("wt-a");
1530 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1531 .await
1532 .unwrap();
1533 tokio::fs::write(wt.join("b.txt"), "candidate\n")
1534 .await
1535 .unwrap();
1536
1537 assert!(commit_all(&wt, "candidate work").await.unwrap());
1538 assert!(!commit_all(&wt, "nothing left").await.unwrap());
1539
1540 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1541 let patch = diff(&wt, &base, "HEAD").await.unwrap();
1542 assert!(patch.contains("b.txt"), "patch was: {patch}");
1543 assert_eq!(
1544 changed_files(&wt, &base, "HEAD").await.unwrap(),
1545 ["b.txt".to_owned()]
1546 );
1547
1548 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1550 .await
1551 .unwrap();
1552 assert_eq!(author, "magi candidate <magi@localhost>");
1553
1554 assert!(worktree_remove(&repo, &wt).await.unwrap());
1555 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1556 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1557 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1558 }
1559
1560 #[tokio::test]
1561 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1562 let (guard, repo) = scratch().await;
1563 let base = rev_parse(&repo, "HEAD").await.unwrap();
1564 let wt = guard.path().join("wt-h");
1565 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1566 .await
1567 .unwrap();
1568 let hooks = guard.path().join("hooks");
1569 tokio::fs::create_dir_all(&hooks).await.unwrap();
1570
1571 assert!(enable_worktree_config(&repo).await.unwrap());
1572 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1573
1574 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1575 .await
1576 .unwrap();
1577 assert!(!in_wt.is_empty());
1578 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1579 .await
1580 .unwrap();
1581 assert!(
1582 !in_primary.ok(),
1583 "primary worktree must keep its own hooks: {in_primary:?}"
1584 );
1585
1586 disable_worktree_config(&repo).await.unwrap();
1587 }
1588
1589 #[tokio::test]
1590 async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1591 let (_g, repo) = scratch().await;
1592
1593 acquire_worktree_config(&repo).await.unwrap();
1596 acquire_worktree_config(&repo).await.unwrap();
1597
1598 let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1599 .await
1600 .unwrap();
1601 assert_eq!(on, "true");
1602
1603 release_worktree_config(&repo).await.unwrap();
1607 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1608 .await
1609 .unwrap();
1610 assert_eq!(
1611 still_on, "true",
1612 "a sibling run's release must not disable the setting for the one still working"
1613 );
1614
1615 release_worktree_config(&repo).await.unwrap();
1617 let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1618 .await
1619 .unwrap();
1620 assert!(
1621 !after.ok(),
1622 "the last release must turn the setting back off: {after:?}"
1623 );
1624 }
1625
1626 #[tokio::test]
1627 async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1628 let (_g, repo) = scratch().await;
1629 git(&repo, &["config", "extensions.worktreeConfig", "true"])
1630 .await
1631 .unwrap();
1632
1633 acquire_worktree_config(&repo).await.unwrap();
1638 release_worktree_config(&repo).await.unwrap();
1639
1640 let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1641 .await
1642 .unwrap();
1643 assert_eq!(still_on, "true");
1644 }
1645
1646 #[tokio::test]
1647 async fn local_exclude_is_idempotent() {
1648 let (_g, repo) = scratch().await;
1649 local_exclude(&repo, "/.magi/").await.unwrap();
1650 local_exclude(&repo, "/.magi/").await.unwrap();
1651 let path = repo.join(".git/info/exclude");
1652 let body = tokio::fs::read_to_string(&path).await.unwrap();
1653 assert_eq!(body.matches("/.magi/").count(), 1);
1654 }
1655
1656 fn sh(cwd: &Path, args: &[&str]) {
1657 let out = std::process::Command::new("git")
1658 .quiet()
1659 .args(args)
1660 .current_dir(cwd)
1661 .output()
1662 .unwrap();
1663 assert!(
1664 out.status.success(),
1665 "git {args:?}: {}",
1666 String::from_utf8_lossy(&out.stderr)
1667 );
1668 }
1669
1670 async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1673 let (g, repo) = scratch().await;
1674 let bare = g.path().join("bare.git");
1675 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1676 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1677 sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1678 sh(&repo, &["fetch", "origin"]);
1679 (g, repo)
1680 }
1681
1682 #[test]
1683 fn remote_head_branch_strips_only_the_matching_remote() {
1684 assert_eq!(
1685 remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1686 Some("main")
1687 );
1688 assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1689 assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1690 }
1691
1692 #[tokio::test]
1693 async fn merge_base_branch_reads_origin_head_when_present() {
1694 let (_g, repo) = with_remote().await;
1695 sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1696 assert_eq!(
1697 merge_base_branch(&repo, "origin", None).await.unwrap(),
1698 "trunk"
1699 );
1700 }
1701
1702 #[tokio::test]
1703 async fn merge_base_branch_recovers_a_missing_origin_head() {
1704 let (_g, repo) = with_remote().await;
1705 sh(&repo, &["remote", "set-head", "origin", "-d"]);
1707 let head = git_raw(
1708 &repo,
1709 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1710 )
1711 .await
1712 .unwrap();
1713 assert!(!head.ok(), "precondition: no origin/HEAD");
1714 assert_eq!(
1715 merge_base_branch(&repo, "origin", None).await.unwrap(),
1716 "trunk"
1717 );
1718 }
1719
1720 #[tokio::test]
1721 async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1722 let (_g, repo) = scratch().await;
1723 let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1724 assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1725 }
1726
1727 #[tokio::test]
1728 async fn merge_base_branch_prefers_the_explicit_base() {
1729 let (_g, repo) = with_remote().await;
1730 assert_eq!(
1731 merge_base_branch(&repo, "origin", Some("release"))
1732 .await
1733 .unwrap(),
1734 "release"
1735 );
1736 }
1737
1738 #[tokio::test]
1739 async fn merge_base_branch_names_an_unfetched_default_branch() {
1740 let (g, repo) = scratch().await;
1743 let bare = g.path().join("bare.git");
1744 sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1745 sh(
1746 &repo,
1747 &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1748 );
1749 sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1750 assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1751 assert_eq!(
1752 merge_base_branch(&repo, "origin", None).await.unwrap(),
1753 "trunk"
1754 );
1755 }
1756
1757 #[test]
1758 fn symref_branch_reads_ls_remote_output() {
1759 assert_eq!(
1760 symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1761 Some("main")
1762 );
1763 assert_eq!(symref_branch("abc\tHEAD\n"), None);
1764 }
1765}