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 = super::tests::fake_with_pane();
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)
.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 = super::tests::fake_with_pane();
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)
.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();
*fake.pane_id_override.lock().unwrap() = Some("%1".into());
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,
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"
);
}
use super::decommission::remove_session_worktree;
use super::worktree_git_fixture::{GitWorktreeFixture, deny_all};
#[test]
fn remove_refuses_a_stale_worktree_pointer() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("stale-pointer-e2e-4732");
std::fs::write(wt.join("precious.txt"), "never committed\n").expect("write precious file");
std::fs::remove_dir_all(fx.repo.join(".git").join("worktrees")).expect("drop admin dir");
let outcome = remove_session_worktree(
&wt,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(
wt.join("precious.txt").exists(),
"a stale pointer must not cost the working tree: {outcome:?}"
);
assert!(
!outcome.removed(),
"and must report NOT removed: {outcome:?}"
);
}
#[test]
fn remove_refuses_an_unreadable_git_entry() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("unreadable-git-e2e-4732");
std::fs::write(wt.join("precious.txt"), "never committed\n").expect("write precious file");
let _restore = deny_all(&wt.join(".git"));
let outcome = remove_session_worktree(
&wt,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(
wt.join("precious.txt").exists(),
"an unreadable .git must not cost the working tree: {outcome:?}"
);
assert!(
!outcome.removed(),
"and must report NOT removed: {outcome:?}"
);
}
#[test]
fn remove_refuses_a_worktree_with_a_broken_git_file() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("broken-git-e2e-4732");
std::fs::write(wt.join("precious.txt"), "never committed\n").expect("write precious file");
std::fs::write(wt.join(".git"), "gitdir: /nonexistent/xyz\n").expect("corrupt .git");
let outcome = remove_session_worktree(
&wt,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(
wt.join("precious.txt").exists(),
"a broken .git must not cost the working tree: {outcome:?}"
);
assert!(
!outcome.removed(),
"and must report NOT removed: {outcome:?}"
);
}
#[test]
fn remove_cleans_up_a_directory_no_repository_claims() {
let tmp = crate::test_support::hermetic_temp_dir();
let leftover = tmp.path().join("leftover-4732");
std::fs::create_dir_all(&leftover).expect("mkdir");
std::fs::write(leftover.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
let outcome = remove_session_worktree(
&leftover,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(outcome.removed(), "{outcome:?}");
assert!(!leftover.exists(), "the leftover directory must be gone");
}
#[test]
fn remove_cleans_up_an_unregistered_leftover_inside_a_repo() {
let fx = GitWorktreeFixture::new();
let leftover = fx.repo.join(".worktrees").join("unregistered-e2e-4732");
std::fs::create_dir_all(&leftover).expect("mkdir");
let outcome = remove_session_worktree(
&leftover,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(outcome.removed(), "{outcome:?}");
assert!(!leftover.exists(), "the leftover directory must be gone");
}
#[test]
fn remove_still_removes_a_healthy_worktree() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("healthy-4732");
let outcome = remove_session_worktree(
&wt,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(outcome.removed(), "{outcome:?}");
assert!(!wt.exists(), "the worktree directory must be gone");
let listed = std::process::Command::new("git")
.arg("-C")
.arg(&fx.repo)
.args(["worktree", "list", "--porcelain"])
.output()
.expect("git worktree list");
assert!(
!String::from_utf8_lossy(&listed.stdout).contains("healthy-4732"),
"the registry entry must be pruned too"
);
let branches = std::process::Command::new("git")
.arg("-C")
.arg(&fx.repo)
.args(["branch", "--list", "session/healthy-4732"])
.output()
.expect("git branch --list");
assert!(
String::from_utf8_lossy(&branches.stdout).trim().is_empty(),
"the session branch must be deleted too"
);
}
fn init_repo_with_commit(dir: &std::path::Path) -> bool {
let git = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
};
std::fs::create_dir_all(dir).expect("create repo dir");
if !git(&["init", "--quiet"]) {
return false;
}
assert!(git(&["config", "user.email", "ci@test.invalid"]));
assert!(git(&["config", "user.name", "CI"]));
assert!(git(&["config", "commit.gpgsign", "false"]));
std::fs::write(dir.join("README.md"), "seed\n").expect("write README");
assert!(git(&["add", "README.md"]));
assert!(git(&["commit", "--quiet", "-m", "seed"]));
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("repo");
let remote = dir.with_file_name(format!("{name}-remote.git"));
let bare = std::process::Command::new("git")
.args(["init", "--quiet", "--bare"])
.arg(&remote)
.status()
.is_ok_and(|s| s.success());
assert!(bare, "fixture: bare remote");
assert!(git(&["remote", "add", "origin", &remote.to_string_lossy()]));
assert!(git(&["push", "--quiet", "origin", "HEAD"]));
assert!(git(&["fetch", "--quiet", "origin"]));
true
}
fn worktree_list(dir: &std::path::Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["worktree", "list", "--porcelain"])
.output()
.expect("git worktree list");
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[tokio::test]
async fn decommission_prunes_the_base_repo_worktree_registry() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(dir.path(), super::tests::fake_with_pane())
.await
.expect("manager");
let managed_root = crate::test_support::hermetic_temp_dir();
let base = managed_root.path().join("owner").join("repo");
if !init_repo_with_commit(&base) {
eprintln!("decommission_prunes_the_base_repo_worktree_registry: git unavailable, skipping");
return;
}
let leaf = "tm-5949-session";
let ws = base.join(".worktrees").join(leaf);
let added = std::process::Command::new("git")
.arg("-C")
.arg(&base)
.args([
"worktree",
"add",
"--quiet",
"-b",
"session/tm-5949-session",
])
.arg(&ws)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(added, "fixture: the worktree must be registered");
assert!(
worktree_list(&base).contains(leaf),
"fixture invariant: git must list the worktree before decommission"
);
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"regression: #5949 in-process decommission".into(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true,
)
.await
.expect("create");
let (tombstone, workspace_removed) = mgr
.decommission_with_root(&record.id, managed_root.path(), None)
.await
.expect("decommission");
assert!(
workspace_removed,
"fixture invariant: an owned, existing workspace must be removed"
);
assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
assert!(!ws.exists(), "the workspace must be gone: {}", ws.display());
assert!(
!worktree_list(&base).contains(leaf),
"decommission left a stale worktree entry for {leaf} — the bookkeeping \
repair did not run below the caller (#5949)"
);
}
#[tokio::test]
async fn registry_root_to_repair_ignores_a_standalone_owned_clone() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(dir.path(), super::tests::fake_with_pane())
.await
.expect("manager");
let managed_root = crate::test_support::hermetic_temp_dir();
let clone = managed_root.path().join("owner").join("standalone");
if !init_repo_with_commit(&clone) {
eprintln!(
"registry_root_to_repair_ignores_a_standalone_owned_clone: git unavailable, skipping"
);
return;
}
let standalone = mgr
.create_with_id(
ManagedSessionId::new(),
"standalone clone".into(),
Some(clone.clone()),
None,
Some(clone.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true,
)
.await
.expect("create standalone");
assert!(
super::decommission::registry_root_to_repair(&standalone).is_none(),
"a checkout that owns itself has no external registry to repair"
);
let ws = clone.join(".worktrees").join("tm-5949-leaf");
let added = std::process::Command::new("git")
.arg("-C")
.arg(&clone)
.args(["worktree", "add", "--quiet", "-b", "session/tm-5949-leaf"])
.arg(&ws)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(added, "fixture: the worktree must be registered");
let linked = mgr
.create_with_id(
ManagedSessionId::new(),
"linked worktree".into(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true,
)
.await
.expect("create linked");
let resolved = super::decommission::registry_root_to_repair(&linked)
.expect("a linked worktree resolves to its owning checkout");
let expected = std::fs::canonicalize(&clone).unwrap_or(clone);
assert_eq!(
resolved, expected,
"the repair must target the checkout git says owns the worktree"
);
}