use std::path::{Path, PathBuf};
use trusty_mpm::client::DaemonClient;
use trusty_mpm::core::pr_cleanup::{
ClaimEnder, CleanupRegistry, CleanupRequest, CleanupScope, RealGit, RealLanding,
};
use trusty_mpm::session_manager::worktree_ignored_output::inspect_dirt_with_ignored_output;
use super::{EXIT_BLOCKED, EXIT_OK, RealGhRunner, repo_slug};
use crate::cli::PrCleanupArgs;
struct DaemonClaims {
client: DaemonClient,
}
#[async_trait::async_trait]
impl ClaimEnder for DaemonClaims {
async fn claims_on(&self, path: &Path) -> anyhow::Result<Vec<String>> {
let sessions = self.client.list_managed_sessions().await?;
Ok(sessions
.into_iter()
.filter(|s| {
s.workspace_path
.as_deref()
.is_some_and(|w| trusty_common::identifies_same_path(Path::new(w), path))
})
.map(|s| s.id)
.collect())
}
async fn end_claim(&self, id: &str) -> anyhow::Result<()> {
let outcome = self
.client
.decommission_managed_session_record_only(id)
.await?;
anyhow::ensure!(
outcome.workspace_removed == Some(false),
"the daemon did not confirm it only tombstoned session {id}'s record (it reported \
workspace_removed = {:?}) — it may predate the record-only decommission; restart it \
before cleaning up",
outcome.workspace_removed
);
Ok(())
}
}
fn repo_root() -> anyhow::Result<PathBuf> {
let out = trusty_common::git::command()
.args(["rev-parse", "--show-toplevel"])
.output()?;
anyhow::ensure!(
out.status.success(),
"not inside a git checkout: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
anyhow::ensure!(
!path.is_empty(),
"`git rev-parse --show-toplevel` named no directory"
);
Ok(PathBuf::from(path))
}
pub(crate) async fn run(
args: &PrCleanupArgs,
daemon: &trusty_mpm::client::DaemonClient,
) -> anyhow::Result<i32> {
run_scoped(args, daemon, false).await
}
async fn run_engine(
gh: &RealGhRunner,
claims: &DaemonClaims,
req: &CleanupRequest,
) -> trusty_mpm::core::pr_cleanup::CleanupReport {
let probe = &inspect_dirt_with_ignored_output;
trusty_mpm::core::pr_cleanup::run(gh, &RealGit, claims, &RealLanding, probe, req).await
}
async fn run_scoped(
args: &PrCleanupArgs,
daemon: &trusty_mpm::client::DaemonClient,
head_only: bool,
) -> anyhow::Result<i32> {
let gh = RealGhRunner::new()?;
let repo = repo_slug(&gh, args.repo.as_deref())?;
let claims = DaemonClaims {
client: daemon.clone(),
};
let req = CleanupRequest {
pr: args.pr,
repo: Some(repo.clone()),
repo_root: repo_root()?,
dry_run: args.dry_run,
head_only,
};
if head_only && !req.dry_run {
let plan_req = CleanupRequest {
dry_run: true,
..req.clone()
};
let plan = run_engine(&gh, &claims, &plan_req).await;
println!("cleanup plan for #{}:\n{}", req.pr, plan.render());
if plan.failed() {
return Ok(EXIT_BLOCKED);
}
}
let report = run_engine(&gh, &claims, &req).await;
println!("{}", report.render());
if report.failed() {
return Ok(EXIT_BLOCKED);
}
if !req.dry_run {
if let Err(e) =
CleanupRegistry::production().mark_cleaned(&repo, req.pr, chrono::Utc::now())
{
eprintln!("tm pr cleanup: could not stamp the cleanup registry: {e:#}");
}
}
Ok(EXIT_OK)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PostMerge {
NotMerged(i32),
Cleanup { repo: String },
Deferred,
AwaitSweep,
}
fn merge_scope(args: &crate::cli::PrMergeArgs) -> CleanupScope {
if args.no_cleanup || args.no_delete_branch {
CleanupScope::Deferred
} else {
CleanupScope::HeadOnly
}
}
pub(crate) fn record_merge_scope(
args: &crate::cli::PrMergeArgs,
repo: &str,
registry: &CleanupRegistry,
) -> anyhow::Result<Vec<String>> {
let scope = merge_scope(args);
let path = registry.path().display();
let mut warnings = Vec::new();
if registry.rewritten_by_older_writer() {
let daemon = trusty_mpm::core::daemon_identity::read_lock()
.map(|l| format!("the running daemon (pid {} at {})", l.pid, l.addr))
.unwrap_or_else(|| "a daemon or tm older than this one".to_string());
warnings.push(format!(
"tm pr merge: {path} was rewritten by {daemon}, which predates recorded cleanup \
scopes and drops them; run `tm restart` so the daemon matches this tm (#8301)"
));
}
let hit = registry.record_scope(repo, args.pr, scope).map_err(|e| {
let sweep = registry
.entry(repo, args.pr)
.filter(|e| e.pending() && e.scope == CleanupScope::Wide)
.map(|_| "; until it is recorded, the daemon's sweep would run the full cleanup")
.unwrap_or_default();
anyhow::anyhow!(
"not merging #{}: its post-merge cleanup choice could not be recorded in {path} \
({e:#}). Repair the file or move {path} aside to proceed{sweep}",
args.pr
)
})?;
if !hit && scope == CleanupScope::Deferred {
warnings.push(format!(
"tm pr merge: no cleanup-registry entry for {repo}#{} in {path}; nothing to defer \
(the daemon's sweep only visits PRs `tm pr open` recorded)",
args.pr
));
}
Ok(warnings)
}
pub(crate) fn post_merge_step(args: &crate::cli::PrMergeArgs, repo: String) -> PostMerge {
if merge_scope(args) == CleanupScope::Deferred {
PostMerge::Deferred
} else if args.auto {
PostMerge::AwaitSweep
} else {
PostMerge::Cleanup { repo }
}
}
pub(crate) fn merge_with_recorded_scope<R: super::GhRunner>(
gh: &R,
args: &crate::cli::PrMergeArgs,
repo: impl FnOnce() -> anyhow::Result<String>,
registry: &CleanupRegistry,
) -> anyhow::Result<PostMerge> {
let repo = repo()?;
for warning in record_merge_scope(args, &repo, registry)? {
eprintln!("{warning}");
}
let code = super::merge::run(gh, args)?;
Ok(if code == EXIT_OK {
post_merge_step(args, repo)
} else {
PostMerge::NotMerged(code)
})
}
pub(crate) async fn after_merge(
args: &crate::cli::PrMergeArgs,
repo: String,
daemon: &trusty_mpm::client::DaemonClient,
) -> anyhow::Result<i32> {
let cleanup = PrCleanupArgs {
pr: args.pr,
repo: Some(repo),
dry_run: false,
};
run_scoped(&cleanup, daemon, true).await
}
#[cfg(test)]
#[path = "cleanup_tests.rs"]
mod cleanup_tests;