use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::Path;
use std::process::Command;
#[path = "merge_verify.rs"]
mod merge_verify;
pub(crate) use merge_verify::{run_post_merge_verify, run_verify_in_worktree};
pub(crate) fn resolve_repo_dir(repo_path: Option<&str>, worktree_path: Option<&str>) -> String {
if let Some(repo) = repo_path {
return repo.to_string();
}
if let Some(wt) = worktree_path
&& let Ok(out) = Command::new("git")
.args(["-C", wt, "rev-parse", "--show-toplevel"])
.output()
&& out.status.success()
{
let toplevel = String::from_utf8_lossy(&out.stdout).trim().to_string();
if let Ok(common) = Command::new("git")
.args(["-C", wt, "rev-parse", "--git-common-dir"])
.output()
&& common.status.success()
{
let common_dir =
String::from_utf8_lossy(&common.stdout).trim().to_string();
let common_path = Path::new(&common_dir);
if let Some(parent) = common_path.parent()
&& parent.join(".git").exists()
{
return parent.to_string_lossy().to_string();
}
}
return toplevel;
}
".".to_string()
}
pub(crate) fn commits_ahead(repo_dir: &str, branch: &str) -> u32 {
let out = Command::new("git")
.args(["-C", repo_dir, "rev-list", "--count", &format!("HEAD..{branch}")])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0),
_ => 0,
}
}
pub(crate) fn checkout_branch(repo_dir: &str, branch: &str) -> Result<()> {
let out = Command::new("git")
.args(["-C", repo_dir, "checkout", branch])
.output()
.context("Failed to run git checkout")?;
anyhow::ensure!(out.status.success(), "git checkout {} failed: {}", branch, String::from_utf8_lossy(&out.stderr));
Ok(())
}
pub(crate) fn auto_commit_uncommitted(wt_path: &str, branch: &str) -> bool {
let status = Command::new("git")
.args(["-C", wt_path, "status", "--porcelain"])
.output();
let has_changes = match status {
Ok(o) if o.status.success() => !o.stdout.is_empty(),
_ => return false,
};
if !has_changes {
return false;
}
aid_info!("[aid] Worktree has uncommitted changes — auto-committing on {branch}");
let _ = Command::new("git")
.args([
"-C",
wt_path,
"add",
"-A",
"--",
".",
":(exclude)target/",
":(exclude)node_modules/",
":(exclude).build/",
":(exclude)dist/",
":(exclude)__pycache__/",
":(exclude).aid-lock",
":(exclude).aid-*",
":(exclude)result-*.md",
":(exclude)result-*.json",
":(exclude)aid-batch-*.toml",
])
.output();
if !has_staged_changes(wt_path) {
return false;
}
let diff_stat = Command::new("git")
.args(["-C", wt_path, "diff", "--cached", "--stat", "--stat-width=60"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let file_count = diff_stat.lines().count().saturating_sub(1);
let message = if file_count == 1 {
let file = diff_stat
.lines()
.next()
.and_then(|line| line.split('|').next())
.map(str::trim)
.unwrap_or("1 file");
format!("chore: auto-commit changes to {file}")
} else if file_count > 1 {
format!("chore: auto-commit changes to {file_count} files")
} else {
"chore: auto-commit agent changes before merge".to_string()
};
let out = Command::new("git")
.args(["-C", wt_path, "commit", "-m", &message])
.output();
match out {
Ok(o) if o.status.success() => {
aid_info!("[aid] Auto-committed uncommitted changes");
true
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
aid_warn!(
"[aid] Warning: auto-commit failed: {}",
stderr.lines().next().unwrap_or("")
);
false
}
Err(e) => { aid_warn!("[aid] Warning: auto-commit failed: {e}"); false }
}
}
fn has_staged_changes(dir: &str) -> bool {
let out = Command::new("git")
.args(["-C", dir, "diff", "--cached", "--quiet"])
.output();
match out {
Ok(o) => o.status.code() == Some(1),
_ => false,
}
}
pub(crate) fn sync_cargo_lock_before_merge(repo_dir: &str, wt_path: &str, branch: &str) {
let src = Path::new(repo_dir).join("Cargo.lock");
if !src.exists() {
return;
}
let _ = Command::new("git")
.args(["-C", wt_path, "checkout", "HEAD", "--", "Cargo.lock"])
.output();
let dst = Path::new(wt_path).join("Cargo.lock");
if let Err(e) = fs::copy(&src, &dst) {
aid_warn!("[aid] Warning: failed to sync Cargo.lock for {branch}: {e}");
return;
}
let status = Command::new("git")
.args(["-C", wt_path, "status", "--porcelain", "--", "Cargo.lock"])
.output();
if !matches!(status, Ok(o) if o.status.success() && !o.stdout.is_empty()) {
return;
}
let _ = Command::new("git").args(["-C", wt_path, "add", "Cargo.lock"]).output();
if matches!(
Command::new("git")
.args(["-C", wt_path, "commit", "-m", "chore: sync Cargo.lock from main"])
.output(),
Ok(o) if o.status.success()
) {
aid_info!("[aid] Synced Cargo.lock from main before merge for {branch}");
}
}
pub(crate) fn git_merge_branch(repo_dir: &str, branch: &str) -> MergeResult {
let head_before = Command::new("git")
.args(["-C", repo_dir, "rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let stashed = stash_local_changes(repo_dir);
let output = Command::new("git")
.args(["-C", repo_dir, "merge", branch, "--no-edit"])
.output();
let merge_result = match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
if stdout.contains("Already up to date") {
MergeResult::AlreadyUpToDate
} else {
let head_after = Command::new("git")
.args(["-C", repo_dir, "rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
if head_before == head_after {
MergeResult::AlreadyUpToDate
} else {
MergeResult::Merged
}
}
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
MergeResult::Failed(stderr)
}
Err(e) => MergeResult::Failed(e.to_string()),
};
if stashed && pop_stash(repo_dir) {
aid_info!("[aid] Restored local changes");
} else if stashed {
aid_hint!("[aid] Your stashed local changes conflict with the merge. Resolve with: git stash pop");
}
merge_result
}
pub(crate) fn check_merge(repo_dir: &str, branch: &str) -> MergeCheckResult {
let ahead = commits_ahead(repo_dir, branch);
let stashed = stash_local_changes(repo_dir);
let output = Command::new("git")
.args(["-C", repo_dir, "merge", "--no-commit", "--no-ff", branch])
.output();
let result = match output {
Ok(out) if out.status.success() => MergeCheckResult::Ok(ahead),
Ok(_) => MergeCheckResult::Conflict(conflict_files(repo_dir)),
Err(err) => MergeCheckResult::Conflict(vec![err.to_string()]),
};
abort_merge(repo_dir);
if stashed && !pop_stash(repo_dir) {
aid_hint!("[aid] Your stashed local changes conflict with the merge check. Resolve with: git stash pop");
}
result
}
pub(crate) enum MergeResult {
Merged,
AlreadyUpToDate,
Failed(String),
}
pub(crate) enum MergeCheckResult {
Ok(u32),
Conflict(Vec<String>),
}
fn stash_local_changes(repo_dir: &str) -> bool {
let dirty = Command::new("git")
.args(["-C", repo_dir, "status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
if !dirty {
return false;
}
aid_info!("[aid] Stashing local changes before merge...");
match Command::new("git")
.args(["-C", repo_dir, "stash", "push", "-m", "aid: auto-stash before merge"])
.output()
{
Ok(o) if o.status.success() => true,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
aid_warn!("[aid] Warning: failed to stash local changes: {}", stderr.lines().next().unwrap_or(""));
false
}
Err(e) => { aid_warn!("[aid] Warning: failed to stash local changes: {e}"); false }
}
}
fn pop_stash(repo_dir: &str) -> bool { matches!(Command::new("git").args(["-C", repo_dir, "stash", "pop"]).output(), Ok(o) if o.status.success()) }
fn abort_merge(repo_dir: &str) { let _ = Command::new("git").args(["-C", repo_dir, "merge", "--abort"]).output(); }
fn conflict_files(repo_dir: &str) -> Vec<String> {
let output = Command::new("git")
.args(["-C", repo_dir, "diff", "--name-only", "--diff-filter=U"])
.output();
match output {
Ok(out) if out.status.success() => {
let files: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect();
if files.is_empty() { unknown_conflict_files() } else { files }
}
_ => unknown_conflict_files(),
}
}
fn unknown_conflict_files() -> Vec<String> { vec!["merge failed without reported conflict files".to_string()] }
pub fn is_safe_worktree_path(wt_path: &str) -> bool {
if !Path::new(wt_path).is_absolute() {
return false;
}
let canonical = match Path::new(wt_path).canonicalize() {
Ok(p) => p,
Err(_) => return crate::worktree::is_aid_managed_worktree_path(Path::new(wt_path)),
};
crate::worktree::is_aid_managed_worktree_path(&canonical)
}
pub fn remove_worktree(repo_dir: &str, wt_path: &str) -> Result<()> {
if !is_safe_worktree_path(wt_path) {
return Err(anyhow!(
"[aid] SAFETY: refusing to remove '{}' — not an aid worktree path. \
Only ~/.aid/worktrees/* and legacy /tmp/aid-wt-* paths are allowed.",
wt_path
));
}
let result = Command::new("git")
.args(["-C", repo_dir, "worktree", "remove", "--force", wt_path])
.output();
if matches!(result, Ok(ref out) if out.status.success()) {
aid_info!("[aid] Cleaned up worktree {wt_path}");
return Ok(());
}
let delete_path = Path::new(wt_path);
let git_file = delete_path.join(".git");
if !git_file.is_file() {
return Err(anyhow!(
"[aid] SAFETY: refusing fallback removal for '{}' — missing worktree .git file",
wt_path
));
}
let canonical = delete_path
.canonicalize()
.with_context(|| format!("failed to canonicalize worktree path '{wt_path}' before deletion"))?;
let canonical_str = canonical.to_string_lossy().to_string();
if !is_safe_worktree_path(&canonical_str) {
return Err(anyhow!(
"[aid] SAFETY: refusing fallback removal for '{}' — canonical path '{}' is outside aid-managed worktree paths",
wt_path,
canonical_str
));
}
match fs::remove_dir_all(&canonical) {
Ok(()) => {
aid_info!("[aid] Cleaned up worktree {wt_path}");
let _ = Command::new("git")
.args(["-C", repo_dir, "worktree", "prune"])
.output();
Ok(())
}
Err(e) => Err(e).with_context(|| format!("failed to remove worktree '{wt_path}'")),
}
}