use std::path::Path;
use std::process::Command;
use std::time::Duration;
use zeph_config::WorktreeConfig;
use zeph_worktree::{DETACHED_BRANCH_SENTINEL, DefaultGitRunner, WorktreeError, WorktreeManager};
fn git(args: &[&str], cwd: &Path) -> std::process::Output {
Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("git must be on PATH for this integration test")
}
fn init_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
assert!(git(&["init", "-q"], path).status.success());
git(&["config", "user.email", "test@example.com"], path);
git(&["config", "user.name", "Test"], path);
std::fs::write(path.join("README.md"), "test\n").unwrap();
git(&["add", "."], path);
assert!(git(&["commit", "-q", "-m", "init"], path).status.success());
dir
}
fn config() -> WorktreeConfig {
WorktreeConfig {
enabled: true,
root: "worktrees".to_string(),
branch_prefix: "agent/".to_string(),
..WorktreeConfig::default()
}
}
#[tokio::test]
async fn prune_clears_manually_deleted_worktree_administrative_entry() {
let repo = init_repo();
let repo_root = repo.path().canonicalize().unwrap();
let creator = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let handle = creator
.create("clean-test-agent")
.await
.expect("create worktree");
assert!(handle.path.exists(), "worktree dir must exist after create");
std::fs::remove_dir_all(&handle.path).unwrap();
let list_before = git(&["worktree", "list", "--porcelain"], &repo_root);
let before_str = String::from_utf8_lossy(&list_before.stdout);
assert!(
before_str.contains(handle.path.to_string_lossy().as_ref()),
"git registry should still reference the deleted worktree before cleanup: {before_str}"
);
let cleaner = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let stale = cleaner.reconcile().await.expect("reconcile");
assert_eq!(
stale.len(),
1,
"expected exactly the deleted worktree to be discovered as stale"
);
assert_eq!(stale[0].handle.path, handle.path);
assert!(
stale[0].is_safe_to_force_remove(),
"a worktree whose directory was deleted directly must be reported as \
prunable by git, and therefore safe to force-remove"
);
cleaner.prune().await.expect("prune");
let list_after = git(&["worktree", "list", "--porcelain"], &repo_root);
let after_str = String::from_utf8_lossy(&list_after.stdout);
assert!(
!after_str.contains(handle.path.to_string_lossy().as_ref()),
"git registry must no longer reference the deleted worktree after prune: {after_str}"
);
}
#[tokio::test]
async fn clean_pipeline_end_to_end_clears_manually_deleted_worktree() {
let repo = init_repo();
let repo_root = repo.path().canonicalize().unwrap();
let creator = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let handle = creator
.create("clean-pipeline-agent")
.await
.expect("create worktree");
std::fs::remove_dir_all(&handle.path).unwrap();
let cleaner = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let stale = cleaner.reconcile().await.expect("reconcile");
assert_eq!(
stale.len(),
1,
"expected exactly the deleted worktree to be discovered as stale"
);
for s in &stale {
assert!(
s.is_safe_to_force_remove(),
"deleted worktree must be prunable"
);
if let Err(e) = cleaner.remove(&s.handle, false).await {
eprintln!("warning: failed to remove {}: {e}", s.handle.path.display());
}
}
cleaner.prune().await.expect("prune");
let list_after = git(&["worktree", "list", "--porcelain"], &repo_root);
let after_str = String::from_utf8_lossy(&list_after.stdout);
assert!(
!after_str.contains(handle.path.to_string_lossy().as_ref()),
"git registry must no longer reference the deleted worktree after clean: {after_str}"
);
}
#[tokio::test]
async fn zero_configured_timeout_still_allows_real_git_command_to_complete() {
let repo = init_repo();
let repo_root = repo.path().canonicalize().unwrap();
let git_timeout_secs: u64 = 0;
let runner = DefaultGitRunner::with_timeout(Duration::from_secs(git_timeout_secs));
let mgr = WorktreeManager::new(repo_root.clone(), config(), runner)
.await
.unwrap();
let result = mgr.reconcile().await;
assert!(
result.is_ok(),
"git_timeout_secs = 0 must not cause every git call to time out: {result:?}"
);
}
#[test]
fn detached_branch_sentinel_is_not_a_valid_git_ref_name() {
let status = Command::new("git")
.args(["check-ref-format", "--branch", DETACHED_BRANCH_SENTINEL])
.status()
.expect("git must be on PATH for this integration test");
assert!(
!status.success(),
"DETACHED_BRANCH_SENTINEL ({DETACHED_BRANCH_SENTINEL:?}) must be REJECTED by \
`git check-ref-format --branch` — otherwise a real branch could be given this exact \
name and collide with the sentinel (see #5936 review finding)"
);
}
#[tokio::test]
async fn remove_refuses_locked_worktree_with_single_force() {
let repo = init_repo();
let repo_root = repo.path().canonicalize().unwrap();
let mgr = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let handle = mgr.create("locked-agent").await.expect("create worktree");
let lock_out = git(
&["worktree", "lock", "--", &handle.path.to_string_lossy()],
&repo_root,
);
assert!(
lock_out.status.success(),
"git worktree lock must succeed: {lock_out:?}"
);
let err = mgr.remove(&handle, false).await.unwrap_err();
assert!(
matches!(err, WorktreeError::GitCommand { ref op, .. } if op == "worktree remove"),
"expected a `worktree remove` GitCommand failure, got: {err:?}"
);
assert!(
handle.path.exists(),
"a locked worktree's directory must survive `remove()`'s single --force"
);
git(
&["worktree", "unlock", "--", &handle.path.to_string_lossy()],
&repo_root,
);
}
#[tokio::test]
async fn clean_without_force_preserves_intact_worktree_with_uncommitted_changes() {
let repo = init_repo();
let repo_root = repo.path().canonicalize().unwrap();
let live_session = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let handle = live_session
.create("live-session-agent")
.await
.expect("create worktree");
let dirty_file = handle.path.join("dirty.txt");
std::fs::write(&dirty_file, "uncommitted work\n").unwrap();
let cleaner = WorktreeManager::new(repo_root.clone(), config(), DefaultGitRunner::new())
.await
.unwrap();
let stale = cleaner.reconcile().await.expect("reconcile");
assert_eq!(
stale.len(),
1,
"the live session's worktree is untracked by this process's handles"
);
assert!(
!stale[0].is_safe_to_force_remove(),
"an intact, non-prunable worktree must never be force-removable by default"
);
let force = false;
for s in &stale {
if !force && !s.is_safe_to_force_remove() {
continue;
}
cleaner.remove(&s.handle, false).await.expect("remove");
}
assert!(
handle.path.exists(),
"worktree directory must survive a non-force clean"
);
assert!(
dirty_file.exists(),
"uncommitted work must survive a non-force clean"
);
let stale = cleaner.reconcile().await.expect("reconcile");
assert_eq!(stale.len(), 1);
let force = true;
for s in &stale {
if !force && !s.is_safe_to_force_remove() {
continue;
}
cleaner.remove(&s.handle, false).await.expect("remove");
}
assert!(
!handle.path.exists(),
"worktree directory must be removed once the operator passes --force"
);
}