use std::path::{Path, PathBuf};
use std::process::Stdio;
use crate::proc::Quiet as _;
use anyhow::{Context as _, Result, bail};
use tokio::process::Command;
#[derive(Debug)]
pub struct GitOut {
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl GitOut {
pub fn ok(&self) -> bool {
self.code == Some(0)
}
}
pub async fn git_raw(cwd: &Path, args: &[&str]) -> Result<GitOut> {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.quiet()
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_EDITOR", "true")
.stdin(Stdio::null())
.output()
.await
.with_context(|| format!("spawn git {}", args.join(" ")))?;
Ok(GitOut {
code: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
})
}
pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
let out = git_raw(cwd, args).await?;
if !out.ok() {
bail!(
"git {} failed in {} (exit {:?}): {}",
args.join(" "),
cwd.display(),
out.code,
if out.stderr.is_empty() {
out.stdout.as_str()
} else {
out.stderr.as_str()
}
);
}
Ok(out.stdout)
}
pub async fn toplevel(path: &Path) -> Result<PathBuf> {
let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
Ok(PathBuf::from(out))
}
pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
git(repo, &["rev-parse", rev]).await
}
pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
Ok(if out.ok() && !out.stdout.is_empty() {
Some(out.stdout)
} else {
None
})
}
pub async fn is_clean(repo: &Path) -> Result<bool> {
Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
}
pub async fn status_porcelain(repo: &Path) -> Result<String> {
git(repo, &["status", "--porcelain"]).await
}
pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
let path_s = path.to_string_lossy().to_string();
git(repo, &["worktree", "add", "-b", branch, &path_s, base])
.await
.map(|_| ())
}
pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
let path_s = path.to_string_lossy().to_string();
git(repo, &["worktree", "add", "--detach", &path_s, rev])
.await
.map(|_| ())
}
pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
git(worktree, &["checkout", "--detach", rev]).await?;
git(worktree, &["reset", "--hard", rev]).await?;
git(worktree, &["clean", "-fdx"]).await?;
Ok(())
}
pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
let path_s = path.to_string_lossy().to_string();
let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
if out.ok() {
return Ok(true);
}
git_raw(repo, &["worktree", "prune"]).await?;
Ok(false)
}
pub async fn remove_worktree_from_linked(dir: &Path) {
let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
return;
};
let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
return;
};
let admin = Path::new(admin);
let Some(common) = admin.parent().and_then(Path::parent) else {
return;
};
let common_s = common.to_string_lossy();
let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
}
pub async fn worktree_prune(repo: &Path) -> Result<()> {
git(repo, &["worktree", "prune"]).await.map(|_| ())
}
pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
}
pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
let refname = format!("refs/heads/{branch}");
Ok(
git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
.await?
.ok(),
)
}
pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
let range = format!("{base}...{head}");
git(
worktree,
&["diff", "--no-color", "--no-ext-diff", "-M", &range],
)
.await
}
pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
let range = format!("{base}...{head}");
git(worktree, &["diff", "--no-color", "--stat", &range]).await
}
pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
let range = format!("{base}...{head}");
let out = git(worktree, &["diff", "--name-only", &range]).await?;
Ok(out.lines().map(str::to_owned).collect())
}
pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
let range = format!("{base}..{head}");
git(
worktree,
&["log", "--reverse", "--format=%s%n%b%n--", &range],
)
.await
}
pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
let range = format!("{base}..{head}");
let out = git(worktree, &["rev-list", "--count", &range]).await?;
Ok(out.trim().parse().unwrap_or(0))
}
pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
return Ok(false);
}
git(worktree, &["add", "-A"]).await?;
let out = git_raw(
worktree,
&[
"-c",
"user.name=magi candidate",
"-c",
"user.email=magi@localhost",
"commit",
"--no-verify",
"-m",
message,
],
)
.await?;
if !out.ok() {
bail!("rescue commit failed: {}", out.stderr);
}
Ok(true)
}
pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
if out.ok() && out.stdout.trim() == "true" {
return Ok(false);
}
git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
Ok(true)
}
pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
Ok(())
}
struct WorktreeConfigRef {
count: usize,
we_enabled: bool,
}
static WORKTREE_CONFIG: std::sync::LazyLock<
std::sync::Mutex<
std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
let mut map = WORKTREE_CONFIG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.entry(repo.to_path_buf())
.or_insert_with(|| {
std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
count: 0,
we_enabled: false,
}))
})
.clone()
}
pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
let slot = worktree_config_slot(repo);
let mut entry = slot.lock().await;
entry.count += 1;
if entry.count == 1 {
entry.we_enabled = enable_worktree_config(repo).await?;
}
Ok(())
}
pub async fn release_worktree_config(repo: &Path) -> Result<()> {
let slot = worktree_config_slot(repo);
let mut entry = slot.lock().await;
entry.count = entry.count.saturating_sub(1);
if entry.count == 0 && entry.we_enabled {
disable_worktree_config(repo).await?;
entry.we_enabled = false;
}
Ok(())
}
pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
let dir = hooks_dir.to_string_lossy().replace('\\', "/");
git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
.await
.map(|_| ())
}
pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
let path = worktree.join(git_dir);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
if body.lines().any(|l| l.trim() == pattern) {
return Ok(());
}
if !body.is_empty() && !body.ends_with('\n') {
body.push('\n');
}
body.push_str(pattern);
body.push('\n');
tokio::fs::write(&path, body)
.await
.with_context(|| format!("write {}", path.display()))?;
Ok(())
}
pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
git_raw(
repo,
&["merge", "--no-ff", "--no-edit", "-m", message, branch],
)
.await
}
pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
if !staged.ok() {
return Ok(staged);
}
git_raw(repo, &["commit", "-m", message]).await
}
pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
git_raw(repo, &["merge", "--ff-only", branch]).await
}
pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
git_raw(repo, &["push", "-u", remote, branch]).await
}
pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
}
pub async fn rebase_branch_in_temp(
repo: &Path,
scratch: &Path,
branch: &str,
onto: &str,
) -> Result<Option<String>> {
worktree_remove(repo, scratch).await.ok();
git_raw(
repo,
&[
"worktree",
"add",
"--force",
&scratch.to_string_lossy(),
branch,
],
)
.await?;
let out = git_raw(scratch, &["rebase", onto]).await?;
if out.ok() {
worktree_remove(repo, scratch).await.ok();
return Ok(None);
}
git_raw(scratch, &["rebase", "--abort"]).await.ok();
let why = if out.stderr.trim().is_empty() {
out.stdout.trim().to_owned()
} else {
out.stderr.trim().to_owned()
};
worktree_remove(repo, scratch).await.ok();
Ok(Some(why))
}
pub async fn sync_to_head(worktree: &Path) -> Result<()> {
git(worktree, &["reset", "--hard", "HEAD"]).await?;
git(worktree, &["clean", "-fdx"]).await?;
Ok(())
}
pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
}
pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
.await
.is_ok_and(|o| o.ok())
}
#[cfg(test)]
mod tests {
use super::*;
async fn scratch() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
tokio::fs::create_dir_all(&repo).await.unwrap();
git(&repo, &["init", "-b", "main"]).await.unwrap();
git(&repo, &["config", "user.name", "test"]).await.unwrap();
git(&repo, &["config", "user.email", "test@example.com"])
.await
.unwrap();
tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "init"]).await.unwrap();
(dir, repo)
}
#[tokio::test]
async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
let (_g, repo) = scratch().await;
git(&repo, &["checkout", "-b", "side"]).await.unwrap();
tokio::fs::write(repo.join("b.txt"), "side\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "side work"]).await.unwrap();
git(&repo, &["checkout", "main"]).await.unwrap();
tokio::fs::write(repo.join("c.txt"), "main\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
.await
.unwrap();
assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
assert_eq!(
commits_ahead(&repo, "main", "side").await.unwrap(),
1,
"one commit, replayed onto the new base"
);
assert!(
!scratch_tree.exists(),
"the throwaway worktree is not left behind"
);
git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
tokio::fs::write(repo.join("a.txt"), "clash\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "clash"]).await.unwrap();
git(&repo, &["checkout", "main"]).await.unwrap();
tokio::fs::write(repo.join("a.txt"), "main edit\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
let before = rev_parse(&repo, "clash").await.unwrap();
let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
.await
.unwrap()
.expect("a same-line clash cannot be rebased silently");
assert!(
why.to_lowercase().contains("conflict"),
"the reason is what git said, which is what a person needs: {why}"
);
assert_eq!(
rev_parse(&repo, "clash").await.unwrap(),
before,
"a failed rebase leaves the branch exactly where it was"
);
assert!(!scratch_tree.exists(), "and cleans up after itself");
}
#[tokio::test]
async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
let (_g, repo) = scratch().await;
git(&repo, &["checkout", "-b", "side"]).await.unwrap();
for name in ["b.txt", "c.txt"] {
tokio::fs::write(repo.join(name), "side\n").await.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(
&repo,
&["commit", "-m", "magi: candidate A (uncommitted work)"],
)
.await
.unwrap();
}
git(&repo, &["checkout", "main"]).await.unwrap();
let before = rev_parse(&repo, "main").await.unwrap();
let out = merge_squash(&repo, "side", "an explicit subject")
.await
.unwrap();
assert!(out.ok(), "{}", out.stderr);
assert_eq!(
commits_ahead(&repo, &before, "main").await.unwrap(),
1,
"squash adds exactly one commit onto the tip, not one per candidate commit"
);
let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
assert_eq!(
subject, "an explicit subject",
"the candidate's own placeholder subject must not survive: {subject}"
);
}
#[tokio::test]
async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
let (_g, repo) = scratch().await;
git(&repo, &["checkout", "-b", "side"]).await.unwrap();
tokio::fs::write(repo.join("b.txt"), "side\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "side work"]).await.unwrap();
git(&repo, &["checkout", "main"]).await.unwrap();
let before = rev_parse(&repo, "side").await.unwrap();
let out = merge_ff_only(&repo, "side").await.unwrap();
assert!(out.ok(), "{}", out.stderr);
assert_eq!(
rev_parse(&repo, "main").await.unwrap(),
before,
"a fast-forward moves the base tip to the branch, no merge commit"
);
}
#[tokio::test]
async fn merge_ff_only_refuses_to_write_a_merge_commit() {
let (_g, repo) = scratch().await;
git(&repo, &["checkout", "-b", "side"]).await.unwrap();
tokio::fs::write(repo.join("b.txt"), "side\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "side work"]).await.unwrap();
git(&repo, &["checkout", "main"]).await.unwrap();
tokio::fs::write(repo.join("c.txt"), "main\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
let before = rev_parse(&repo, "main").await.unwrap();
let out = merge_ff_only(&repo, "side").await.unwrap();
assert!(!out.ok(), "a divergent branch cannot fast-forward");
assert_eq!(
rev_parse(&repo, "main").await.unwrap(),
before,
"a refused fast-forward must not touch main"
);
}
#[tokio::test]
async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
let (guard, repo) = scratch().await;
git(&repo, &["branch", "side"]).await.unwrap();
let side_wt = guard.path().join("side-wt");
git(
&repo,
&["worktree", "add", &side_wt.to_string_lossy(), "side"],
)
.await
.unwrap();
tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
.await
.unwrap();
git(&side_wt, &["add", "-A"]).await.unwrap();
git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
git(&repo, &["checkout", "main"]).await.unwrap();
tokio::fs::write(repo.join("c.txt"), "main\n")
.await
.unwrap();
git(&repo, &["add", "-A"]).await.unwrap();
git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
let scratch_tree = guard.path().join("rebase-scratch");
let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
.await
.unwrap();
assert!(clean.is_none());
assert_eq!(
rev_parse(&side_wt, "HEAD").await.unwrap(),
rev_parse(&repo, "side").await.unwrap(),
"HEAD follows the moved ref"
);
assert!(
!side_wt.join("c.txt").exists(),
"stale until synced: main's new file has not reached this worktree's disk"
);
sync_to_head(&side_wt).await.unwrap();
assert!(side_wt.join("c.txt").is_file(), "synced now");
assert!(
side_wt.join("b.txt").is_file(),
"the worktree's own committed work survives the sync"
);
assert!(is_clean(&side_wt).await.unwrap());
}
#[tokio::test]
async fn clean_repo_reports_clean_then_dirty() {
let (_g, repo) = scratch().await;
assert!(is_clean(&repo).await.unwrap());
tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
assert!(!is_clean(&repo).await.unwrap());
}
#[tokio::test]
async fn worktree_lifecycle_and_diff() {
let (guard, repo) = scratch().await;
let base = rev_parse(&repo, "HEAD").await.unwrap();
let wt = guard.path().join("wt-a");
worktree_add_branch(&repo, &wt, "magi/test/a", &base)
.await
.unwrap();
tokio::fs::write(wt.join("b.txt"), "candidate\n")
.await
.unwrap();
assert!(commit_all(&wt, "candidate work").await.unwrap());
assert!(!commit_all(&wt, "nothing left").await.unwrap());
assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
let patch = diff(&wt, &base, "HEAD").await.unwrap();
assert!(patch.contains("b.txt"), "patch was: {patch}");
assert_eq!(
changed_files(&wt, &base, "HEAD").await.unwrap(),
["b.txt".to_owned()]
);
let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
.await
.unwrap();
assert_eq!(author, "magi candidate <magi@localhost>");
assert!(worktree_remove(&repo, &wt).await.unwrap());
assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
}
#[tokio::test]
async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
let (guard, repo) = scratch().await;
let base = rev_parse(&repo, "HEAD").await.unwrap();
let wt = guard.path().join("wt-h");
worktree_add_branch(&repo, &wt, "magi/test/h", &base)
.await
.unwrap();
let hooks = guard.path().join("hooks");
tokio::fs::create_dir_all(&hooks).await.unwrap();
assert!(enable_worktree_config(&repo).await.unwrap());
set_worktree_hooks_path(&wt, &hooks).await.unwrap();
let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
.await
.unwrap();
assert!(!in_wt.is_empty());
let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
.await
.unwrap();
assert!(
!in_primary.ok(),
"primary worktree must keep its own hooks: {in_primary:?}"
);
disable_worktree_config(&repo).await.unwrap();
}
#[tokio::test]
async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
let (_g, repo) = scratch().await;
acquire_worktree_config(&repo).await.unwrap();
acquire_worktree_config(&repo).await.unwrap();
let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
.await
.unwrap();
assert_eq!(on, "true");
release_worktree_config(&repo).await.unwrap();
let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
.await
.unwrap();
assert_eq!(
still_on, "true",
"a sibling run's release must not disable the setting for the one still working"
);
release_worktree_config(&repo).await.unwrap();
let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
.await
.unwrap();
assert!(
!after.ok(),
"the last release must turn the setting back off: {after:?}"
);
}
#[tokio::test]
async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
let (_g, repo) = scratch().await;
git(&repo, &["config", "extensions.worktreeConfig", "true"])
.await
.unwrap();
acquire_worktree_config(&repo).await.unwrap();
release_worktree_config(&repo).await.unwrap();
let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
.await
.unwrap();
assert_eq!(still_on, "true");
}
#[tokio::test]
async fn local_exclude_is_idempotent() {
let (_g, repo) = scratch().await;
local_exclude(&repo, "/.magi/").await.unwrap();
local_exclude(&repo, "/.magi/").await.unwrap();
let path = repo.join(".git/info/exclude");
let body = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(body.matches("/.magi/").count(), 1);
}
}