use super::decommission::WORKTREE_SENTINEL_FILE;
use super::manager::SessionManager;
use super::record::{ManagedSessionId, ManagedSessionState};
use super::tests::FakeTmuxDriver;
#[tokio::test]
async fn manager_decommission_removes_real_git_worktree() {
let dir = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake)
.await
.expect("manager");
let base_dir = crate::test_support::hermetic_temp_dir();
let base = base_dir.path().to_path_buf();
let git_init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(&base)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !git_init_ok {
eprintln!("manager_decommission_removes_real_git_worktree: git unavailable, skipping");
return;
}
let _ = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"config",
"user.email",
"ci@test.invalid",
])
.status();
let _ = std::process::Command::new("git")
.args(["-C", base.to_str().unwrap(), "config", "user.name", "CI"])
.status();
let commit_ok = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"commit",
"--allow-empty",
"-m",
"init",
])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !commit_ok {
eprintln!("manager_decommission_removes_real_git_worktree: git commit failed, skipping");
return;
}
push_to_bare_remote(&base);
let session_name = "test-session-1806";
let branch_name = crate::core::worktree_naming::worktree_branch_for(session_name);
let worktrees_dir = base.join(".worktrees");
std::fs::create_dir_all(&worktrees_dir).unwrap();
let worktree_path = worktrees_dir.join(session_name);
let add_ok = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"worktree",
"add",
"-b",
&branch_name,
])
.arg(&worktree_path)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(add_ok, "git worktree add must succeed in this test fixture");
std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(worktree_path.clone()),
None,
Some(worktree_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false, )
.await
.expect("create");
let managed_root = crate::test_support::hermetic_temp_dir();
let (tombstone, workspace_removed) = mgr
.decommission_with_root(&record.id, managed_root.path(), None, false)
.await
.expect("decommission");
assert!(
workspace_removed,
"workspace_removed must be true for an in-project git worktree (#1806)"
);
assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
assert!(
!worktree_path.exists(),
"the worktree directory must be removed from disk after decommission (#1806)"
);
let list_out = std::process::Command::new("git")
.args(["-C", base.to_str().unwrap(), "worktree", "list"])
.output()
.expect("git worktree list");
let list_stdout = String::from_utf8_lossy(&list_out.stdout);
assert!(
!list_stdout.contains(session_name),
"git worktree list must not reference the removed worktree; got: {list_stdout}"
);
let branch_out = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"branch",
"--list",
&branch_name,
])
.output()
.expect("git branch --list");
let branch_stdout = String::from_utf8_lossy(&branch_out.stdout);
assert!(
branch_stdout.trim().is_empty(),
"the session branch ref must be deleted after decommission; got: {branch_stdout}"
);
}
fn push_to_bare_remote(repo: &std::path::Path) {
let remote_dir = crate::test_support::hermetic_temp_dir();
let remote = remote_dir.path().to_path_buf();
let remote_str = remote.to_str().expect("utf8 remote path");
let repo_str = repo.to_str().expect("utf8 repo path");
let init_ok = std::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&remote)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(init_ok, "git init --bare must succeed for the test remote");
for args in [
vec!["-C", repo_str, "remote", "add", "origin", remote_str],
vec!["-C", repo_str, "push", "origin", "HEAD"],
vec!["-C", repo_str, "fetch", "origin"],
] {
let ok = std::process::Command::new("git")
.args(&args)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "git {args:?} must succeed");
}
}
#[tokio::test]
async fn manager_decommission_refuses_dirty_worktree() {
let dir = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake)
.await
.expect("manager");
let fixture = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let session_name = "test-session-dirty";
let worktree_path = fixture.add_worktree(session_name);
std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
std::fs::write(
worktree_path.join("uncommitted-work.txt"),
b"unsaved work\n",
)
.expect("write uncommitted file");
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(worktree_path.clone()),
None,
Some(worktree_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false, )
.await
.expect("create");
let managed_root = crate::test_support::hermetic_temp_dir();
let (tombstone, workspace_removed) = mgr
.decommission_with_root(&record.id, managed_root.path(), None, false)
.await
.expect("decommission");
assert!(
!workspace_removed,
"workspace_removed must be false when the worktree holds unsaved work"
);
assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
assert!(
worktree_path.exists(),
"a dirty worktree must survive on disk — decommission must refuse to delete it"
);
assert!(
worktree_path.join("uncommitted-work.txt").exists(),
"the uncommitted file itself must survive the refused decommission"
);
assert_eq!(
tombstone.workspace_path.as_deref(),
Some(worktree_path.as_path()),
"workspace_path must survive on the tombstone when removal was refused \
(#4344 review) — it is the only durable pointer back to the retained work"
);
}
#[tokio::test]
async fn prune_reports_dirty_worktree_retained() {
let dir = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake)
.await
.expect("manager");
let fixture = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let session_name = "test-session-prune-dirty";
let worktree_path = fixture.add_worktree(session_name);
std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
std::fs::write(
worktree_path.join("uncommitted-work.txt"),
b"unsaved work\n",
)
.expect("write uncommitted file");
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(worktree_path.clone()),
None,
Some(worktree_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false, )
.await
.expect("create");
mgr.stop(&record.id).await.expect("stop");
let outcome = mgr
.prune_managed(
crate::session_manager::PruneFilter::Stopped,
false,
false,
None,
)
.await
.expect("prune stopped");
assert_eq!(
outcome.count(),
1,
"the dirty stopped session is the only candidate"
);
let pruned = &outcome.sessions[0];
assert_eq!(
pruned.action,
crate::session_manager::PruneAction::DecommissionedWorktreeRetained,
"a dirty in-project worktree must report as retained, not a plain decommission; got {:?}",
pruned.action
);
assert_eq!(
pruned.retained_workspace_path.as_deref(),
Some(worktree_path.as_path()),
"the prune report must echo back WHERE the retained work lives"
);
assert!(
worktree_path.exists(),
"the dirty worktree must survive a prune sweep"
);
assert!(
worktree_path.join("uncommitted-work.txt").exists(),
"the uncommitted file must survive a prune sweep"
);
}