use super::*;
use crate::test_subprocess;
use crate::types::*;
use chrono::Local;
use std::env;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::TempDir;
fn git(repo: &Path, args: &[&str]) {
let s = Command::new("git")
.args(["-C", &repo.to_string_lossy()])
.args(args)
.output()
.unwrap();
assert!(
s.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&s.stderr)
);
}
fn unique(prefix: &str) -> String {
format!(
"{prefix}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
)
}
fn init_repo() -> TempDir {
let repo = TempDir::new().unwrap();
git(repo.path(), &["init", "-b", "main"]);
git(repo.path(), &["config", "user.email", "test@aid.dev"]);
git(repo.path(), &["config", "user.name", "Test"]);
std::fs::write(repo.path().join("init.txt"), "init\n").unwrap();
git(repo.path(), &["add", "init.txt"]);
git(repo.path(), &["commit", "-m", "init"]);
repo
}
fn create_worktree_with_commit(repo: &Path) -> (TempDir, String) {
let branch = unique("test-branch");
let wt = TempDir::new().unwrap();
git(
repo,
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join("agent-work.txt"), "agent output\n").unwrap();
git(wt.path(), &["add", "agent-work.txt"]);
git(wt.path(), &["commit", "-m", "agent: implement feature"]);
(wt, branch)
}
fn create_empty_worktree_branch(repo: &Path) -> (TempDir, String) {
let branch = unique("empty-branch");
let wt = TempDir::new().unwrap();
git(
repo,
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
(wt, branch)
}
fn create_conflict_worktree(repo: &Path, branch: &str) -> TempDir {
let wt = TempDir::new().unwrap();
git(
repo,
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
branch,
],
);
std::fs::write(wt.path().join("init.txt"), "branch version\n").unwrap();
git(wt.path(), &["add", "init.txt"]);
git(wt.path(), &["commit", "-m", "branch change"]);
std::fs::write(repo.join("init.txt"), "main version\n").unwrap();
git(repo, &["add", "init.txt"]);
git(repo, &["commit", "-m", "main change"]);
wt
}
fn worktree_status(repo: &Path) -> String {
let output = Command::new("git")
.args(["-C", &repo.to_string_lossy(), "status", "--short"])
.output()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn make_task_with_worktree(id: &str, repo: &Path, wt: &Path, branch: &str) -> Task {
Task {
id: TaskId(id.to_string()),
agent: AgentKind::Codex,
custom_agent_name: None,
prompt: "test".to_string(),
resolved_prompt: None,
category: None,
status: TaskStatus::Done,
parent_task_id: None,
workgroup_id: None,
caller_kind: None,
caller_session_id: None,
agent_session_id: None,
repo_path: Some(repo.to_string_lossy().to_string()),
worktree_path: Some(wt.to_string_lossy().to_string()),
worktree_branch: Some(branch.to_string()),
start_sha: None,
log_path: None,
output_path: None,
tokens: None,
prompt_tokens: None,
duration_ms: None,
model: None,
cost_usd: None,
exit_code: None,
created_at: Local::now(),
completed_at: None,
verify: None,
verify_status: VerifyStatus::Skipped,
pending_reason: None,
read_only: false,
budget: false,
audit_verdict: None,
audit_report_path: None,
delivery_assessment: None,
}
}
#[test]
fn commits_ahead_detects_branch_with_commits() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
assert!(commits_ahead(&repo.path().to_string_lossy(), &branch) > 0);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn commits_ahead_returns_zero_for_same_head() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("empty-branch");
git(repo.path(), &["branch", &branch]);
assert_eq!(commits_ahead(&repo.path().to_string_lossy(), &branch), 0);
}
#[test]
fn commits_ahead_returns_zero_for_missing_branch() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
assert_eq!(
commits_ahead(&repo.path().to_string_lossy(), "nonexistent"),
0
);
}
#[test]
fn auto_commit_uncommitted_commits_dirty_worktree() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("dirty-branch");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join("dirty.txt"), "uncommitted\n").unwrap();
let committed = auto_commit_uncommitted(&wt.path().to_string_lossy(), &branch);
assert!(committed);
assert!(commits_ahead(&repo.path().to_string_lossy(), &branch) > 0);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn auto_commit_message_includes_filename() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("message-branch");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join("named-file.txt"), "uncommitted\n").unwrap();
let committed = auto_commit_uncommitted(&wt.path().to_string_lossy(), &branch);
assert!(committed);
let log = Command::new("git")
.args([
"-C",
&wt.path().to_string_lossy(),
"log",
"-1",
"--pretty=%s",
])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&log.stdout).trim(),
"chore: auto-commit changes to named-file.txt"
);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn auto_commit_uncommitted_returns_false_for_clean_worktree() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
let committed = auto_commit_uncommitted(&wt.path().to_string_lossy(), &branch);
assert!(!committed);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn git_merge_branch_merges_committed_branch() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
let result = git_merge_branch(&repo.path().to_string_lossy(), &branch);
assert!(matches!(result, MergeResult::Merged));
assert!(repo.path().join("agent-work.txt").exists());
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn git_merge_branch_detects_already_up_to_date() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("noop-branch");
git(repo.path(), &["branch", &branch]);
let result = git_merge_branch(&repo.path().to_string_lossy(), &branch);
assert!(matches!(result, MergeResult::AlreadyUpToDate));
}
#[test]
fn checkout_branch_switches_head() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("target");
git(repo.path(), &["branch", &branch]);
checkout_branch(&repo.path().to_string_lossy(), &branch).unwrap();
let output = Command::new("git")
.args([
"-C",
&repo.path().to_string_lossy(),
"branch",
"--show-current",
])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), branch);
}
#[test]
fn git_merge_branch_detects_conflict() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("conflict-branch");
let wt = create_conflict_worktree(repo.path(), &branch);
let result = git_merge_branch(&repo.path().to_string_lossy(), &branch);
assert!(matches!(result, MergeResult::Failed(_)));
let _ = Command::new("git")
.args(["-C", &repo.path().to_string_lossy(), "merge", "--abort"])
.output();
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn check_merge_detects_clean_merge() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
let result = check_merge(&repo.path().to_string_lossy(), &branch);
assert!(matches!(result, MergeCheckResult::Ok(1)));
assert_eq!(worktree_status(repo.path()), "");
assert!(!repo.path().join("agent-work.txt").exists());
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn check_merge_detects_conflict() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("check-conflict");
let wt = create_conflict_worktree(repo.path(), &branch);
let result = check_merge(&repo.path().to_string_lossy(), &branch);
match result {
MergeCheckResult::Conflict(files) => assert_eq!(files, vec!["init.txt".to_string()]),
MergeCheckResult::Ok(commits) => {
panic!("expected conflict, got clean merge with {commits} commit(s)")
}
}
assert_eq!(worktree_status(repo.path()), "");
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn git_merge_branch_stashes_local_changes() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let local_file = repo.path().join("init.txt");
std::fs::write(&local_file, "local change\n").unwrap();
let (wt, branch) = create_worktree_with_commit(repo.path());
let result = git_merge_branch(&repo.path().to_string_lossy(), &branch);
assert!(matches!(result, MergeResult::Merged));
assert_eq!(
std::fs::read_to_string(local_file).unwrap(),
"local change\n"
);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn git_merge_branch_stashes_and_warns_on_pop_conflict() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("pop-conflict");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join("init.txt"), "branch change\n").unwrap();
git(wt.path(), &["add", "init.txt"]);
git(wt.path(), &["commit", "-m", "branch change"]);
std::fs::write(repo.path().join("init.txt"), "local change\n").unwrap();
let result = git_merge_branch(&repo.path().to_string_lossy(), &branch);
assert!(matches!(result, MergeResult::Merged));
let status = Command::new("git")
.args(["-C", &repo.path().to_string_lossy(), "status", "--short"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&status.stdout);
assert!(stdout.contains("UU init.txt"));
git(repo.path(), &["reset", "--hard", "HEAD~1"]);
git(repo.path(), &["stash", "drop"]);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn resolve_repo_dir_prefers_explicit_repo_path() {
let _permit = test_subprocess::acquire();
let result = resolve_repo_dir(Some("/explicit/repo"), Some("/tmp/worktree"));
assert_eq!(result, "/explicit/repo");
}
#[test]
fn resolve_repo_dir_detects_from_worktree() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, _branch) = create_worktree_with_commit(repo.path());
let result = resolve_repo_dir(None, Some(&wt.path().to_string_lossy()));
let canon_repo = repo.path().canonicalize().unwrap();
let canon_result = Path::new(&result).canonicalize().unwrap();
assert_eq!(canon_result, canon_repo);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn resolve_repo_dir_falls_back_to_dot() {
let _permit = test_subprocess::acquire();
let result = resolve_repo_dir(None, None);
assert_eq!(result, ".");
}
#[test]
fn sync_cargo_lock_before_merge_commits_updated_lockfile() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
std::fs::write(repo.path().join("Cargo.lock"), "version = 1\n").unwrap();
git(repo.path(), &["add", "Cargo.lock"]);
git(repo.path(), &["commit", "-m", "add lockfile"]);
let (wt, branch) = create_empty_worktree_branch(repo.path());
std::fs::write(repo.path().join("Cargo.lock"), "version = 2\n").unwrap();
git(repo.path(), &["add", "Cargo.lock"]);
git(repo.path(), &["commit", "-m", "update lockfile"]);
sync_cargo_lock_before_merge(
&repo.path().to_string_lossy(),
&wt.path().to_string_lossy(),
&branch,
);
assert_eq!(
std::fs::read_to_string(wt.path().join("Cargo.lock")).unwrap(),
"version = 2\n"
);
assert!(commits_ahead(&repo.path().to_string_lossy(), &branch) > 0);
let log = Command::new("git")
.args([
"-C",
&wt.path().to_string_lossy(),
"log",
"-1",
"--pretty=%s",
])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&log.stdout).trim(),
"chore: sync Cargo.lock from main"
);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn merge_single_succeeds_with_committed_worktree() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
let store = Store::open_memory().unwrap();
let task = make_task_with_worktree("t-merge-ok", repo.path(), wt.path(), &branch);
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-merge-ok", false, false, false, None);
assert!(result.is_ok(), "merge_single failed: {result:?}");
let loaded = store.get_task("t-merge-ok").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Merged);
assert!(repo.path().join("agent-work.txt").exists());
}
#[test]
fn merge_single_auto_commits_then_merges() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("uncommitted");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(
wt.path().join("uncommitted.txt"),
"agent forgot to commit\n",
)
.unwrap();
let store = Store::open_memory().unwrap();
let task = make_task_with_worktree("t-autocommit", repo.path(), wt.path(), &branch);
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-autocommit", false, false, false, None);
assert!(
result.is_ok(),
"merge_single should auto-commit and merge: {result:?}"
);
let loaded = store.get_task("t-autocommit").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Merged);
assert!(repo.path().join("uncommitted.txt").exists());
assert_eq!(
std::fs::read_to_string(repo.path().join("uncommitted.txt")).unwrap(),
"agent forgot to commit\n"
);
}
#[test]
fn merge_single_fails_when_no_commits_and_no_changes() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("empty");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
let store = Store::open_memory().unwrap();
let task = make_task_with_worktree("t-empty", repo.path(), wt.path(), &branch);
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-empty", false, false, false, None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("No commits to merge"),
"unexpected error: {err}"
);
let loaded = store.get_task("t-empty").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Done);
assert!(wt.path().exists());
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn merge_single_rejects_non_done_task() {
let _permit = test_subprocess::acquire();
let store = Store::open_memory().unwrap();
let mut task = make_task_with_worktree("t-running", Path::new("."), Path::new("/tmp"), "b");
task.status = TaskStatus::Running;
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-running", false, false, false, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("only DONE"));
}
#[test]
fn merge_single_rejects_failed_task_without_force() {
let _permit = test_subprocess::acquire();
let store = Store::open_memory().unwrap();
let mut task = make_task_with_worktree("t-failed", Path::new("."), Path::new("/tmp"), "b");
task.status = TaskStatus::Failed;
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-failed", false, false, false, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("only DONE"));
}
#[test]
fn merge_single_force_merges_failed_task_with_committed_branch() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
let store = Store::open_memory().unwrap();
let mut task = make_task_with_worktree("t-force-failed", repo.path(), wt.path(), &branch);
task.status = TaskStatus::Failed;
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-force-failed", false, false, true, None);
assert!(result.is_ok(), "force merge failed: {result:?}");
let loaded = store.get_task("t-force-failed").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Merged);
assert!(repo.path().join("agent-work.txt").exists());
let events = store.get_events("t-force-failed").unwrap();
assert!(events.iter().any(|event| {
event.event_kind == EventKind::Error
&& event.detail == "Force-merged task t-force-failed from status FAIL — verify/tests were not run"
}));
}
#[test]
fn merge_single_force_rejects_failed_task_without_commits() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_empty_worktree_branch(repo.path());
let store = Store::open_memory().unwrap();
let mut task = make_task_with_worktree("t-force-empty", repo.path(), wt.path(), &branch);
task.status = TaskStatus::Failed;
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-force-empty", false, false, true, None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("No commits to merge"), "unexpected error: {err}");
let loaded = store.get_task("t-force-empty").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Failed);
assert!(wt.path().exists());
git(repo.path(), &["worktree", "remove", "--force", &wt.path().to_string_lossy()]);
}
#[test]
fn merge_single_works_without_worktree_branch() {
let _permit = test_subprocess::acquire();
let store = Store::open_memory().unwrap();
let task = Task {
id: TaskId("t-inplace".to_string()),
agent: AgentKind::Codex,
custom_agent_name: None,
prompt: "test".to_string(),
resolved_prompt: None,
category: None,
status: TaskStatus::Done,
parent_task_id: None,
workgroup_id: None,
caller_kind: None,
caller_session_id: None,
agent_session_id: None,
repo_path: None,
worktree_path: None,
worktree_branch: None,
start_sha: None,
log_path: None,
output_path: None,
tokens: None,
prompt_tokens: None,
duration_ms: None,
model: None,
cost_usd: None,
exit_code: None,
created_at: Local::now(),
completed_at: None,
verify: None,
verify_status: VerifyStatus::Skipped,
pending_reason: None,
read_only: false,
budget: false,
audit_verdict: None,
audit_report_path: None,
delivery_assessment: None,
};
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-inplace", false, false, false, None);
assert!(result.is_ok());
let loaded = store.get_task("t-inplace").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Merged);
}
#[test]
fn merge_single_preserves_worktree_on_conflict() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("conflict");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join("init.txt"), "branch\n").unwrap();
git(wt.path(), &["add", "init.txt"]);
git(wt.path(), &["commit", "-m", "branch"]);
std::fs::write(repo.path().join("init.txt"), "main\n").unwrap();
git(repo.path(), &["add", "init.txt"]);
git(repo.path(), &["commit", "-m", "main"]);
let store = Store::open_memory().unwrap();
let task = make_task_with_worktree("t-conflict", repo.path(), wt.path(), &branch);
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-conflict", false, false, false, None);
assert!(result.is_err());
assert!(wt.path().exists());
let loaded = store.get_task("t-conflict").unwrap().unwrap();
assert_eq!(loaded.status, TaskStatus::Done);
let _ = Command::new("git")
.args(["-C", &repo.path().to_string_lossy(), "merge", "--abort"])
.output();
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn merge_single_without_repo_path_resolves_from_worktree() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (wt, branch) = create_worktree_with_commit(repo.path());
let store = Store::open_memory().unwrap();
let mut task = make_task_with_worktree("t-no-repo", repo.path(), wt.path(), &branch);
task.repo_path = None;
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-no-repo", false, false, false, None);
assert!(
result.is_ok(),
"merge should resolve repo from worktree: {result:?}"
);
assert!(repo.path().join("agent-work.txt").exists());
}
#[test]
fn merge_single_merges_into_target_branch() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let target = unique("target");
git(repo.path(), &["branch", &target]);
let (wt, branch) = create_worktree_with_commit(repo.path());
let store = Store::open_memory().unwrap();
let task = make_task_with_worktree("t-target", repo.path(), wt.path(), &branch);
store.insert_task(&task).unwrap();
let result = merge_single(&store, "t-target", false, false, false, Some(&target));
assert!(result.is_ok(), "merge_single failed: {result:?}");
let current = Command::new("git")
.args([
"-C",
&repo.path().to_string_lossy(),
"branch",
"--show-current",
])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(¤t.stdout).trim(), target);
assert!(repo.path().join("agent-work.txt").exists());
git(repo.path(), &["checkout", "main"]);
assert!(!repo.path().join("agent-work.txt").exists());
}
#[test]
fn merge_group_skips_empty_branches() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let (committed_wt, committed_branch) = create_worktree_with_commit(repo.path());
let (empty_wt, empty_branch) = create_empty_worktree_branch(repo.path());
let store = Store::open_memory().unwrap();
let group_id = "wg-merge-group";
let mut committed_task = make_task_with_worktree(
"t-merge-group",
repo.path(),
committed_wt.path(),
&committed_branch,
);
committed_task.workgroup_id = Some(group_id.to_string());
store.insert_task(&committed_task).unwrap();
let mut empty_task = make_task_with_worktree(
"t-empty-branch",
repo.path(),
empty_wt.path(),
&empty_branch,
);
empty_task.workgroup_id = Some(group_id.to_string());
store.insert_task(&empty_task).unwrap();
let result = merge_group(&store, group_id, false, false, None);
assert!(result.is_ok(), "merge_group failed: {result:?}");
let loaded_committed = store.get_task("t-merge-group").unwrap().unwrap();
assert_eq!(loaded_committed.status, TaskStatus::Merged);
assert!(repo.path().join("agent-work.txt").exists());
let loaded_empty = store.get_task("t-empty-branch").unwrap().unwrap();
assert_eq!(loaded_empty.status, TaskStatus::Done);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&empty_wt.path().to_string_lossy(),
],
);
}
#[test]
fn run_rejects_lanes_without_group() {
let store = Arc::new(Store::open_memory().unwrap());
let result = run(store, None, None, false, false, false, None, true);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "--lanes requires --group");
}
#[test]
fn run_rejects_unsupported_lanes_flags() {
let store = Arc::new(Store::open_memory().unwrap());
let check_result = run(
store.clone(),
None,
Some("wg-lanes"),
false,
true,
false,
None,
true,
);
assert!(check_result.is_err());
assert_eq!(
check_result.unwrap_err().to_string(),
"--lanes does not yet support --check (dry-run); run without --check to apply lanes"
);
let target_result = run(
store,
None,
Some("wg-lanes"),
false,
false,
false,
Some("release"),
true,
);
assert!(target_result.is_err());
assert_eq!(
target_result.unwrap_err().to_string(),
"--lanes cannot be combined with --target; lanes apply to the GitButler workspace of the main repo"
);
}
#[test]
fn run_rejects_lanes_when_gitbutler_env_is_disabled() {
let store = Arc::new(Store::open_memory().unwrap());
let mut task = make_task_with_worktree(
"t-lanes-disabled",
Path::new("."),
Path::new("/tmp"),
"lane-branch",
);
task.workgroup_id = Some("wg-lanes-disabled".to_string());
store.insert_task(&task).unwrap();
let previous = env::var("AID_GITBUTLER").ok();
unsafe {
env::set_var("AID_GITBUTLER", "0");
}
let result = run(
store,
None,
Some("wg-lanes-disabled"),
false,
false,
false,
None,
true,
);
match previous {
Some(value) => unsafe { env::set_var("AID_GITBUTLER", value) },
None => unsafe { env::remove_var("AID_GITBUTLER") },
}
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"GitButler integration disabled via AID_GITBUTLER=0"
);
}
#[test]
fn remove_worktree_cleans_up_properly() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("cleanup");
let wt_path = format!("/tmp/aid-wt-test-{branch}");
git(repo.path(), &["worktree", "add", &wt_path, "-b", &branch]);
remove_worktree(&repo.path().to_string_lossy(), &wt_path).unwrap();
assert!(!Path::new(&wt_path).exists());
let out = Command::new("git")
.args(["-C", &repo.path().to_string_lossy(), "worktree", "list"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.contains(&branch));
}
#[test]
fn run_verify_handles_auto_without_error() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
run_verify_in_worktree(&repo.path().to_string_lossy(), Some("auto"));
}
#[test]
fn run_post_merge_verify_warns_on_failure() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
run_post_merge_verify(
&repo.path().to_string_lossy(),
Some("git missing-subcommand"),
);
assert_eq!(worktree_status(repo.path()), "");
}
#[test]
fn sandbox_allows_aid_worktree_paths() {
let _permit = test_subprocess::acquire();
let home_path = crate::worktree::aid_worktree_root().join("demo").join("feat/foo");
assert!(is_safe_worktree_path(&home_path.to_string_lossy()));
assert!(is_safe_worktree_path("/tmp/aid-wt-feat-foo"));
assert!(is_safe_worktree_path("/tmp/aid-wt-fix/bar"));
assert!(is_safe_worktree_path("/private/tmp/aid-wt-test"));
}
#[test]
fn sandbox_blocks_non_worktree_paths() {
let _permit = test_subprocess::acquire();
assert!(!is_safe_worktree_path("/home/user/project"));
assert!(!is_safe_worktree_path("/Users/someone/Develop/myrepo"));
assert!(!is_safe_worktree_path("/tmp/other-dir"));
assert!(!is_safe_worktree_path("/tmp/aid-wt")); assert!(!is_safe_worktree_path("/tmp"));
assert!(!is_safe_worktree_path(""));
assert!(!is_safe_worktree_path("/"));
}
#[test]
fn remove_worktree_refuses_unsafe_path() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let unsafe_path = repo.path().join("subdir");
std::fs::create_dir_all(&unsafe_path).unwrap();
let result = remove_worktree(
&repo.path().to_string_lossy(),
&unsafe_path.to_string_lossy(),
);
assert!(result.is_err());
assert!(
unsafe_path.exists(),
"Sandbox guard failed: unsafe path was deleted!"
);
}
#[test]
fn approval_decision_defaults_to_merge() {
let _permit = test_subprocess::acquire();
let reply = "";
let decision = if reply.contains("Skip") {
ApprovalDecision::Skip
} else if reply.contains("Retry") {
ApprovalDecision::Retry
} else {
ApprovalDecision::Merge
};
assert!(matches!(decision, ApprovalDecision::Merge));
}
#[test]
fn auto_commit_skips_aid_lock_only_changes() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("aid-lock-only");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join(".aid-lock"), "pid=1234\n").unwrap();
let before = Command::new("git")
.args(["-C", &wt.path().to_string_lossy(), "rev-parse", "HEAD"])
.output()
.unwrap();
let before_sha = String::from_utf8_lossy(&before.stdout).trim().to_string();
let committed = auto_commit_uncommitted(&wt.path().to_string_lossy(), &branch);
assert!(!committed, "should not commit when only .aid-lock changed");
let after = Command::new("git")
.args(["-C", &wt.path().to_string_lossy(), "rev-parse", "HEAD"])
.output()
.unwrap();
let after_sha = String::from_utf8_lossy(&after.stdout).trim().to_string();
assert_eq!(
before_sha, after_sha,
"HEAD should not change when only .aid-lock is dirty"
);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}
#[test]
fn auto_commit_excludes_aid_lock_when_other_files_change() {
let _permit = test_subprocess::acquire();
let repo = init_repo();
let branch = unique("aid-lock-and-source");
let wt = TempDir::new().unwrap();
git(
repo.path(),
&[
"worktree",
"add",
&wt.path().to_string_lossy(),
"-b",
&branch,
],
);
std::fs::write(wt.path().join(".aid-lock"), "pid=9999\n").unwrap();
let before = Command::new("git")
.args(["-C", &wt.path().to_string_lossy(), "rev-parse", "HEAD"])
.output()
.unwrap();
let before_sha = String::from_utf8_lossy(&before.stdout).trim().to_string();
std::fs::create_dir_all(wt.path().join("src")).unwrap();
std::fs::write(wt.path().join("src/main.rs"), "fn main() {}\n").unwrap();
let committed = auto_commit_uncommitted(&wt.path().to_string_lossy(), &branch);
assert!(committed, "should commit when real source files changed");
let after = Command::new("git")
.args(["-C", &wt.path().to_string_lossy(), "rev-parse", "HEAD"])
.output()
.unwrap();
let after_sha = String::from_utf8_lossy(&after.stdout).trim().to_string();
assert_ne!(
before_sha, after_sha,
"HEAD should advance when source files changed"
);
let show = Command::new("git")
.args([
"-C",
&wt.path().to_string_lossy(),
"show",
"--stat",
"--format=",
"HEAD",
])
.output()
.unwrap();
let stat = String::from_utf8_lossy(&show.stdout);
assert!(
!stat.contains(".aid-lock"),
".aid-lock should not be in the commit, but was: {stat}"
);
assert!(
stat.contains("src/main.rs"),
"src/main.rs should be in the commit"
);
git(
repo.path(),
&[
"worktree",
"remove",
"--force",
&wt.path().to_string_lossy(),
],
);
}