use std::cell::RefCell;
use std::path::PathBuf;
use trusty_mpm::core::pr_cleanup::{CleanupRegistry, CleanupScope, OpenedPr};
use super::super::{GhRun, GhRunner};
use super::{PostMerge, merge_with_recorded_scope, post_merge_step, record_merge_scope};
use crate::cli::PrMergeArgs;
const REPO: &str = "bobmatnyc/trusty-tools";
fn merge_args(no_cleanup: bool, no_delete_branch: bool, auto: bool) -> PrMergeArgs {
PrMergeArgs {
pr: 8301,
auto,
no_delete_branch,
no_cleanup,
allow_failing: Vec::new(),
allow_no_checks: false,
repo: None,
}
}
fn registry_with_entry(dir: &std::path::Path) -> CleanupRegistry {
let reg = CleanupRegistry::under_root(dir);
reg.record_open(OpenedPr {
pr: 8301,
repo: REPO.to_string(),
repo_root: PathBuf::from("/repo"),
opened_at: chrono::Utc::now(),
cleaned_at: None,
scope: CleanupScope::Wide,
})
.expect("record");
reg
}
#[test]
fn post_merge_no_cleanup_never_reaches_after_merge() {
for (no_cleanup, no_delete_branch, auto) in [
(true, false, false),
(false, true, false),
(true, false, true),
] {
let args = merge_args(no_cleanup, no_delete_branch, auto);
assert_eq!(
post_merge_step(&args, REPO.to_string()),
PostMerge::Deferred,
"no_cleanup={no_cleanup} no_delete_branch={no_delete_branch} auto={auto}"
);
}
}
#[test]
fn post_merge_no_cleanup_defers_the_registry_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = registry_with_entry(dir.path());
record_merge_scope(
&merge_args(true, false, false),
"BobMatNYC/Trusty-Tools",
®,
)
.expect("record");
assert!(
reg.pending().is_empty(),
"a deferred entry must not be pending for the sweep: {:?}",
reg.entries()
);
assert_eq!(reg.entries()[0].scope, CleanupScope::Deferred);
}
#[test]
fn post_merge_cleanup_records_a_head_only_scope() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = registry_with_entry(dir.path());
let args = merge_args(false, false, false);
record_merge_scope(&args, REPO, ®).expect("record");
assert_eq!(reg.entries()[0].scope, CleanupScope::HeadOnly);
assert_eq!(reg.pending().len(), 1, "it is still swept, head-only");
assert_eq!(
post_merge_step(&args, REPO.to_string()),
PostMerge::Cleanup {
repo: REPO.to_string()
}
);
}
#[test]
fn post_merge_auto_leaves_the_entry_to_the_sweep() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = registry_with_entry(dir.path());
let args = merge_args(false, false, true);
record_merge_scope(&args, REPO, ®).expect("record");
assert_eq!(
post_merge_step(&args, REPO.to_string()),
PostMerge::AwaitSweep
);
assert_eq!(reg.entries()[0].scope, CleanupScope::HeadOnly);
}
struct RecordingGh {
calls: RefCell<Vec<String>>,
}
impl GhRunner for RecordingGh {
fn run(&self, args: &[String]) -> anyhow::Result<GhRun> {
self.calls.borrow_mut().push(args.join(" "));
Ok(GhRun {
success: false,
stdout: String::new(),
stderr: "RecordingGh answers nothing".to_string(),
})
}
}
#[test]
fn merge_aborts_before_merging_when_the_scope_cannot_be_recorded() {
let dir = tempfile::tempdir().expect("tempdir");
let blocker = dir.path().join("not-a-dir");
std::fs::write(&blocker, b"a regular file").expect("write blocker");
let reg = CleanupRegistry::under_root(blocker.join("root"));
let gh = RecordingGh {
calls: RefCell::new(Vec::new()),
};
let outcome = merge_with_recorded_scope(
&gh,
&merge_args(false, false, false),
|| Ok(REPO.to_string()),
®,
);
let err = outcome.expect_err("an unrecordable scope must fail the command");
assert!(err.to_string().contains("not merging #8301"), "{err:#}");
assert!(
err.to_string().contains("aside to proceed"),
"the error names the recovery: {err:#}"
);
assert!(
gh.calls.borrow().is_empty(),
"nothing may reach `gh` — no merge: {:?}",
gh.calls.borrow()
);
}
#[test]
fn merge_aborts_when_the_scope_marker_cannot_be_written() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = registry_with_entry(dir.path());
let marker = dir.path().join("pr-cleanup.json.scoped");
std::fs::remove_file(&marker).expect("premise: the seed write left a marker");
std::fs::create_dir(&marker).expect("occupy the marker path");
let gh = RecordingGh {
calls: RefCell::new(Vec::new()),
};
let outcome = merge_with_recorded_scope(
&gh,
&merge_args(false, false, false),
|| Ok(REPO.to_string()),
®,
);
let err = outcome.expect_err("a failed marker write must fail the command");
assert!(err.to_string().contains("not merging #8301"), "{err:#}");
assert!(gh.calls.borrow().is_empty(), "{:?}", gh.calls.borrow());
}
#[test]
fn record_merge_scope_warns_on_a_registry_an_older_writer_rewrote() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = registry_with_entry(dir.path());
std::fs::write(
reg.path(),
format!(
"{{\"entries\":[{{\"pr\":8301,\"repo\":\"{REPO}\",\"repo_root\":\"/repo\",\
\"opened_at\":\"2026-09-23T00:00:00Z\"}}]}}"
),
)
.expect("older write");
let warnings = record_merge_scope(&merge_args(true, false, false), REPO, ®).expect("record");
assert!(
warnings.iter().any(|w| w.contains("tm restart")),
"{warnings:?}"
);
assert_eq!(reg.entries()[0].scope, CleanupScope::Deferred);
}
#[tokio::test]
async fn daemon_claims_match_a_workspace_spelled_through_a_symlink() {
use trusty_mpm::core::pr_cleanup::ClaimEnder;
let ws = tempfile::tempdir().expect("workspace dir");
let tree = ws.path().join("tree");
std::fs::create_dir(&tree).expect("create the tree");
let alias = ws.path().join("alias");
std::os::unix::fs::symlink(&tree, &alias).expect("symlink the tree");
let body = serde_json::json!({
"sessions": [{
"id": "sess-8301-alias",
"name": "sess-8301-alias",
"state": "active",
"persisted_state": "active",
"workspace_path": alias.to_string_lossy(),
"cwd": alias.to_string_lossy(),
"created_at": "2026-09-24T00:00:00Z",
"unresumable": false,
"slot": 1,
"deleted": false,
}]
});
let app = axum::Router::new().route(
"/api/v1/sessions/managed",
axum::routing::get(move || {
let body = body.clone();
async move { axum::Json(body) }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback");
let addr = listener.local_addr().expect("bound addr");
let server = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let claims = super::DaemonClaims {
client: trusty_mpm::client::DaemonClient::with_client(
reqwest::Client::new(),
format!("http://{addr}"),
),
};
let holders = claims.claims_on(&tree).await.expect("claims on the tree");
server.abort();
assert_eq!(holders, ["sess-8301-alias"]);
}
#[tokio::test]
async fn daemon_claims_read_over_the_socket() {
use std::sync::Arc;
use trusty_mpm::core::paths::FrameworkPaths;
use trusty_mpm::core::pr_cleanup::ClaimEnder as _;
use trusty_mpm::daemon::state::DaemonState;
let dir = tempfile::tempdir().expect("tempdir");
let state = Arc::new(DaemonState::with_paths(&FrameworkPaths::under(dir.path())));
let socket = dir.path().join("sock").join("trusty-mpm.sock");
let bound = trusty_mpm::daemon::socket::bind(&socket)
.await
.expect("bind");
let (stop, shutdown) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(trusty_mpm::daemon::socket::serve_until_shutdown(
bound,
state,
async {
let _ = shutdown.await;
},
));
let claims = super::DaemonClaims {
client: trusty_mpm::client::DaemonClient::over_socket(&socket),
};
let found = claims.claims_on(dir.path()).await;
let _ = stop.send(());
assert_eq!(
found.expect("the socket answered the claim query"),
Vec::<String>::new()
);
}