use std::path::{Path, PathBuf};
use std::process::Command;
use super::decommission::{WORKTREE_SENTINEL_FILE, remove_session_worktree};
use super::decommission_force::ProvisioningDirt;
use super::decommission_owned::{
owned_workspace_keep_reason, remove_owned_workspace, remove_owned_workspace_with,
};
use super::manager::SessionManager;
use super::record::{ManagedSessionId, ManagedSessionState};
use super::worktree_git_fixture::{GitWorktreeFixture, deny_all};
use super::worktree_ignored_output::kept_unversioned_content;
use super::worktree_ownership::sentinel_payload_bytes;
use super::worktree_ownership_location::write_sentinel_bytes;
use super::worktree_safety::DirtyWorktreePolicy;
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn write(root: &Path, rel: &str) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
let body = if rel == WORKTREE_SENTINEL_FILE {
""
} else {
"x\n"
};
std::fs::write(path, body).expect("write");
}
async fn owned_session(store: &Path, ws: &Path) -> (SessionManager, ManagedSessionId) {
let mgr = SessionManager::new(store, crate::session_manager::tests::fake_with_pane())
.await
.expect("manager");
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"regression: #8663".into(),
Some(ws.to_path_buf()),
None,
Some(ws.to_path_buf()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true, )
.await
.expect("create");
(mgr, record.id)
}
async fn assert_decommission_keeps(managed_root: &Path, ws: &Path) {
let store = crate::test_support::hermetic_temp_dir();
let (mgr, id) = owned_session(store.path(), ws).await;
let (tomb, removed) = mgr
.decommission_with_root(&id, managed_root, None)
.await
.expect("a refusal is not an error");
assert!(!removed, "decommission must not report a removal");
assert!(ws.exists(), "the workspace must stay: {}", ws.display());
assert_eq!(tomb.state, ManagedSessionState::Decommissioned);
assert_eq!(
tomb.workspace_path.as_deref(),
Some(ws),
"a kept workspace keeps its pointer"
);
}
fn non_git_workspace(root: &Path, leaf: &str) -> PathBuf {
let ws = root.join("owner").join("repo").join(leaf);
std::fs::create_dir_all(&ws).expect("mkdir workspace");
ws
}
fn clean_owned_workspace(root: &Path, leaf: &str) -> PathBuf {
let ws = non_git_workspace(root, leaf);
for rel in [
WORKTREE_SENTINEL_FILE,
".claude/settings.json",
"target/debug/app",
] {
write(&ws, rel);
}
ws
}
#[tokio::test]
async fn owned_workspace_a_live_process_stands_in_is_kept_9444() {
let root = crate::test_support::hermetic_temp_dir();
let ws = clean_owned_workspace(root.path(), "live-9444");
let holder = super::worktree_git_fixture::StandingProcess::in_dir(&ws.join("target/debug"));
let verdict = remove_owned_workspace(
&ManagedSessionId::new(),
None,
&ws,
ProvisioningDirt::Refuse,
)
.await
.expect("a refusal is not an error");
assert!(!verdict.removed, "removed a workspace a process stands in");
assert!(ws.join("target/debug/app").exists());
let reason = verdict.kept_reason.unwrap_or_default();
assert!(holder.named_in(&reason), "{reason}");
}
#[tokio::test]
async fn owned_workspace_is_kept_when_the_holder_probe_fails_9444() {
let root = crate::test_support::hermetic_temp_dir();
let ws = clean_owned_workspace(root.path(), "probe-9444");
let verdict = remove_owned_workspace_with(
&ManagedSessionId::new(),
None,
&ws,
ProvisioningDirt::Refuse,
super::worktree_git_fixture::probe_cannot_answer,
)
.await
.expect("a refusal is not an error");
assert!(!verdict.removed, "removed on an unanswered probe");
assert!(ws.exists());
let reason = verdict.kept_reason.unwrap_or_default();
assert!(reason.contains("could not run `lsof`"), "{reason}");
}
#[tokio::test]
async fn owned_worktree_with_an_unpushed_commit_is_kept() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("owned-8663-commit");
write(&wt, "work.rs");
git(&wt, &["add", "work.rs"]);
git(&wt, &["commit", "-q", "-m", "agent work"]);
let reason = owned_workspace_keep_reason(
&wt,
&ManagedSessionId::new(),
None,
ProvisioningDirt::Refuse,
)
.expect("an unpushed commit keeps the workspace");
assert!(reason.contains(&wt.display().to_string()), "{reason}");
assert!(reason.contains("1 unpushed commit"), "{reason}");
assert_decommission_keeps(&fx.repos_root, &wt).await;
}
#[tokio::test]
async fn owned_worktree_with_untracked_results_is_kept() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("owned-8663-results");
write(&wt, "results/out.json");
let verdict = remove_owned_workspace(
&ManagedSessionId::new(),
None,
&wt,
ProvisioningDirt::Refuse,
)
.await
.expect("a refusal is not an error");
assert!(!verdict.removed);
let reason = verdict.kept_reason.expect("the kept reason is reported");
assert!(reason.contains("results/"), "{reason}");
assert!(wt.join("results/out.json").exists());
assert_decommission_keeps(&fx.repos_root, &wt).await;
}
#[tokio::test]
async fn owned_worktree_with_gitignored_results_is_kept() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("owned-8663-ignored");
std::fs::write(fx.repo.join(".git/info/exclude"), "results/\n").expect("exclude");
write(&wt, "results/out.json");
let reason = owned_workspace_keep_reason(
&wt,
&ManagedSessionId::new(),
None,
ProvisioningDirt::Refuse,
)
.expect("ignored output keeps the workspace");
assert!(reason.contains("gitignored"), "{reason}");
assert_decommission_keeps(&fx.repos_root, &wt).await;
}
#[test]
fn owned_worktree_force_excuses_only_provisioning_files() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("owned-8663-force");
let me = ManagedSessionId::new();
write_sentinel_bytes(&wt, &sentinel_payload_bytes(me)).expect("write the owner marker");
write(&wt, "CLAUDE.md");
assert!(owned_workspace_keep_reason(&wt, &me, None, ProvisioningDirt::Refuse).is_some());
let forced = || owned_workspace_keep_reason(&wt, &me, None, ProvisioningDirt::Discard);
assert!(forced().is_some(), "no ledger vouches for CLAUDE.md");
std::fs::remove_file(wt.join("CLAUDE.md")).expect("start over as a fresh launch");
let before = crate::session_manager::provisioning_ledger::snapshot(&wt);
write(&wt, "CLAUDE.md");
crate::session_manager::provisioning_ledger::record(&wt, &before).expect("record the ledger");
assert_eq!(forced(), None);
write(&wt, "notes.md");
let reason = owned_workspace_keep_reason(&wt, &me, None, ProvisioningDirt::Discard)
.expect("--force never excuses user files");
assert!(reason.contains("notes.md"), "{reason}");
}
#[test]
fn owned_worktree_force_excuses_task_md_only_when_it_equals_the_task() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("owned-8688-task");
let me = ManagedSessionId::new();
write_sentinel_bytes(&wt, &sentinel_payload_bytes(me)).expect("write the owner marker");
let task = "Fix the bug\n";
std::fs::write(wt.join("TASK.md"), task).expect("write TASK.md");
let forced = |task| owned_workspace_keep_reason(&wt, &me, task, ProvisioningDirt::Discard);
assert_eq!(forced(Some(task)), None);
let reason = forced(None).expect("no task vouches for TASK.md");
assert!(reason.contains("?? TASK.md"), "{reason}");
std::fs::write(wt.join("TASK.md"), format!("{task}- a note\n")).expect("edit TASK.md");
let reason = forced(Some(task)).expect("an edited TASK.md keeps the worktree");
assert!(reason.contains("?? TASK.md"), "{reason}");
}
#[tokio::test]
async fn owned_non_git_workspace_with_user_files_is_kept() {
let root = crate::test_support::hermetic_temp_dir();
let ws = non_git_workspace(root.path(), "user-files");
write(&ws, WORKTREE_SENTINEL_FILE);
write(&ws, "notes.md");
let reason = owned_workspace_keep_reason(
&ws,
&ManagedSessionId::new(),
None,
ProvisioningDirt::Refuse,
)
.expect("a user file keeps the workspace");
assert!(reason.contains(&ws.display().to_string()), "{reason}");
assert!(reason.contains("notes.md"), "{reason}");
assert_decommission_keeps(root.path(), &ws).await;
}
#[tokio::test]
async fn clean_owned_workspace_is_still_removed() {
let root = crate::test_support::hermetic_temp_dir();
let ws = non_git_workspace(root.path(), "clean");
for rel in [
WORKTREE_SENTINEL_FILE,
".trusty-mpm/state.json",
".claude/settings.json",
"target/debug/app",
"node_modules/pkg/index.js",
] {
write(&ws, rel);
}
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("owned-8663-clean");
std::fs::write(fx.repo.join(".git/info/exclude"), "target/\n").expect("exclude");
write(&wt, "target/debug/app");
for (managed_root, path) in [(root.path(), &ws), (fx.repos_root.as_path(), &wt)] {
let store = crate::test_support::hermetic_temp_dir();
let (mgr, id) = owned_session(store.path(), path).await;
let (tomb, removed) = mgr
.decommission_with_root(&id, managed_root, None)
.await
.expect("decommission");
assert!(
removed,
"a clean owned workspace is removed: {}",
path.display()
);
assert!(!path.exists());
assert_eq!(tomb.workspace_path, None);
}
}
#[tokio::test]
async fn owned_workspace_is_kept_when_the_content_check_fails() {
let root = crate::test_support::hermetic_temp_dir();
let ws = non_git_workspace(root.path(), "unreadable");
write(&ws, "data/run.json");
let _restore = deny_all(&ws.join("data"));
let reason = owned_workspace_keep_reason(
&ws,
&ManagedSessionId::new(),
None,
ProvisioningDirt::Refuse,
)
.expect("an unreadable directory keeps the workspace");
assert!(reason.contains("content check failed"), "{reason}");
assert_decommission_keeps(root.path(), &ws).await;
}
fn leftover(root: &Path) -> PathBuf {
let dir = root.join("leftover-8663");
std::fs::create_dir_all(&dir).expect("mkdir");
write(&dir, WORKTREE_SENTINEL_FILE);
dir
}
#[test]
fn unclaimed_directory_with_results_is_kept() {
let tmp = crate::test_support::hermetic_temp_dir();
let dir = leftover(tmp.path());
write(&dir, "results/x");
let outcome = remove_session_worktree(&dir, "test", DirtyWorktreePolicy::Skip);
let reason = outcome.reason().expect("kept").to_string();
assert!(reason.contains("results"), "{reason}");
assert!(dir.join("results/x").exists(), "the results must survive");
}
#[test]
fn unclaimed_directory_holding_only_harness_files_is_removed() {
let tmp = crate::test_support::hermetic_temp_dir();
let dir = leftover(tmp.path());
write(&dir, ".trusty-mpm/state.json");
write(&dir, "target/debug/app");
let outcome = remove_session_worktree(&dir, "test", DirtyWorktreePolicy::Skip);
assert!(outcome.removed(), "{outcome:?}");
assert!(!dir.exists());
}
#[test]
fn unclaimed_directory_is_kept_when_the_content_check_fails() {
let tmp = crate::test_support::hermetic_temp_dir();
let dir = leftover(tmp.path());
write(&dir, ".claude/settings.json");
let _restore = deny_all(&dir.join(".claude"));
for policy in [DirtyWorktreePolicy::Skip, DirtyWorktreePolicy::ForceDiscard] {
let outcome = remove_session_worktree(&dir, "test", policy);
let reason = outcome.reason().expect("kept").to_string();
assert!(reason.contains("content check failed"), "{reason}");
assert!(dir.join(WORKTREE_SENTINEL_FILE).exists(), "nothing deleted");
}
}
#[test]
fn force_discard_removes_an_unclaimed_directory_with_results() {
let tmp = crate::test_support::hermetic_temp_dir();
let dir = leftover(tmp.path());
write(&dir, "results/x");
let outcome = remove_session_worktree(&dir, "test", DirtyWorktreePolicy::ForceDiscard);
assert!(outcome.removed(), "{outcome:?}");
assert!(!dir.exists());
}
#[test]
fn unversioned_content_keeps_run_output_and_excuses_harness_files() {
let tmp = crate::test_support::hermetic_temp_dir();
let dir = tmp.path().join("ws");
for rel in [
WORKTREE_SENTINEL_FILE,
".trusty-mpm/log",
".claude/settings.local.json",
"dist/app.js",
"__pycache__/m.pyc",
] {
write(&dir, rel);
}
assert_eq!(kept_unversioned_content(&dir), Ok(None));
write(&dir, "results/a.json");
write(&dir, "results/b.json");
let kept = kept_unversioned_content(&dir)
.expect("readable")
.expect("results are kept");
assert_eq!((kept.files, kept.first.as_str()), (2, "results"));
}