#![expect(unused, reason = "extraction; PHASE-03 prunes")]
use super::allowlist::{
Allowlist, allowlist_violations, is_withheld, parse_allowlist, select_copies,
};
use super::marker::{DISPATCH_WORKER_AGENT_TYPE, marker_present, write_marker};
use super::shared::{
gather_fork_worktree, gather_tree_clean, is_linked_worktree, matches, resolve_commit,
resolve_common_dir,
};
use crate::fsutil::{self, CopyOutcome};
use crate::git;
use crate::root;
use anyhow::{Context, bail};
use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GcState {
pub(crate) branch_exists: bool,
pub(crate) worktree_present: bool,
pub(crate) landed_verdict: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GcPlan {
pub(crate) remove_worktree: bool,
pub(crate) delete_branch: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GcRefusal {
NotLanded,
}
impl GcRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
GcRefusal::NotLanded => "not-landed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GcVerdict {
Reap(GcPlan),
Refuse(GcRefusal),
}
pub(crate) fn classify_gc(
state: GcState,
force: bool,
superseded_match: bool,
_dry_run: bool,
) -> GcVerdict {
if !state.branch_exists {
return GcVerdict::Reap(GcPlan {
remove_worktree: false,
delete_branch: false,
});
}
let authorised = force || superseded_match || state.landed_verdict == Some(true);
if !authorised {
return GcVerdict::Refuse(GcRefusal::NotLanded);
}
GcVerdict::Reap(GcPlan {
remove_worktree: state.worktree_present,
delete_branch: true,
})
}
fn reap_targets(plan: GcPlan) -> String {
let mut parts: Vec<&str> = Vec::new();
if plan.remove_worktree {
parts.push("worktree");
}
if plan.delete_branch {
parts.push("branch");
}
if parts.is_empty() {
"nothing".to_owned()
} else {
parts.join("/")
}
}
pub(crate) fn landed_against(root: &Path, target: &str, fork: &str) -> anyhow::Result<bool> {
if git::git_status_ok(root, &["merge-base", "--is-ancestor", fork, target])? {
return Ok(true);
}
let cherry = git::git_cherry(root, target, fork)?;
Ok(!cherry.is_empty() && cherry.iter().all(|line| line.starts_with('-')))
}
pub(crate) fn run_gc(
path: Option<PathBuf>,
fork: &str,
superseded_head: Option<&str>,
force: bool,
dry_run: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let root =
fs::canonicalize(&root).with_context(|| format!("canonicalize root {}", root.display()))?;
let branch_ref = format!("refs/heads/{fork}");
let branch_head = git::git_opt(
&root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{branch_ref}^{{commit}}"),
],
)?;
let branch_exists = branch_head.is_some();
let fork_wt = gather_fork_worktree(&root, fork)?;
let worktree_present = fork_wt.is_some();
let landed_verdict = if branch_exists {
Some(landed_against(&root, "HEAD", fork)?)
} else {
None
};
let superseded_match = match (superseded_head, &branch_head) {
(Some(sha), Some(head)) => {
match git::git_opt(
&root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{sha}^{{commit}}"),
],
)? {
Some(resolved) => matches(&resolved, head),
None => false,
}
}
_ => false,
};
let state = GcState {
branch_exists,
worktree_present,
landed_verdict,
};
let verdict = classify_gc(state, force, superseded_match, dry_run);
if dry_run {
match verdict {
GcVerdict::Reap(plan) => {
let basis = if !branch_exists {
"already-certified (branch gone)".to_owned()
} else if landed_verdict == Some(true) {
"landed ✓ (oracle)".to_owned()
} else {
let how = if force {
"--force"
} else {
"--superseded-head"
};
format!("NOT landed — reap authorised by {how} (oracle override)")
};
writeln!(
io::stdout(),
"{fork}: {basis} — would reap ({})",
reap_targets(plan)
)?;
}
GcVerdict::Refuse(GcRefusal::NotLanded) => {
writeln!(
io::stdout(),
"{fork}: not-landed — `--force` to reap, or `--superseded-head <SHA>` if spent-and-abandoned. If you squash-merged, re-land via `worktree land` (--no-ff)."
)?;
}
}
return Ok(());
}
let plan = match verdict {
GcVerdict::Refuse(GcRefusal::NotLanded) => bail!(
"gc-refused: {} — fork {fork} has not provably landed; `--force` to reap, or `--superseded-head <SHA>` to assert it is spent-and-abandoned. Cannot certify a squash-merge — re-land via `worktree land` (--no-ff), or `--force` knowingly.",
GcRefusal::NotLanded.token()
),
GcVerdict::Reap(plan) => plan,
};
let mut leftovers: Vec<String> = Vec::new();
if let (true, Some(wt)) = (plan.remove_worktree, fork_wt.as_deref()) {
let removed = git::git_opt(
&root,
&["worktree", "remove", "--force", &wt.to_string_lossy()],
)?;
if removed.is_none() {
drop(git::git_opt(&root, &["worktree", "prune"]));
if wt.exists() {
leftovers.push(format!("worktree {}", wt.display()));
}
}
}
if plan.delete_branch {
let deleted = git::git_opt(&root, &["branch", "-D", fork])?;
if deleted.is_none()
&& git::git_opt(&root, &["rev-parse", "--verify", "--quiet", &branch_ref])?.is_some()
{
leftovers.push(format!("branch {fork}"));
}
}
let record_name = fork.strip_prefix("dispatch/").unwrap_or(fork);
if let Err(cause) = super::dispatch_record::delete_dispatch_record(&root, record_name) {
leftovers.push(format!("dispatch record: {cause:#}"));
}
if !leftovers.is_empty() {
bail!(
"gc-incomplete: leftover(s) need manual cleanup: {}",
leftovers.join(", ")
);
}
writeln!(
io::stderr(),
"warning: test binaries baked with the reaped fork's CARGO_MANIFEST_DIR are now stale — recompile before trusting a RED"
)?;
writeln!(
io::stdout(),
"gc {fork}: reaped (worktree/branch as present)"
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::landed_against;
use std::path::{Path, PathBuf};
use std::process::Command;
struct ScratchRepo {
_dir: tempfile::TempDir,
path: PathBuf,
}
impl ScratchRepo {
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_path_buf();
let repo = Self { _dir: dir, path };
repo.git(&["init", "-q", "-b", "main"]);
repo.git(&["config", "user.email", "t@example.com"]);
repo.git(&["config", "user.name", "Test"]);
repo
}
fn path(&self) -> &Path {
&self.path
}
fn git(&self, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(&self.path)
.args(args)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(&self, rel: &str, contents: &str, message: &str) -> String {
std::fs::write(self.path.join(rel), contents).expect("write file");
self.git(&["add", rel]);
self.git(&["commit", "-q", "-m", message]);
self.git(&["rev-parse", "HEAD"])
}
}
#[test]
fn landed_against_non_head_target_via_ancestry() {
let repo = ScratchRepo::new();
repo.commit("base.txt", "0", "C0");
repo.git(&["checkout", "-q", "-b", "feature"]);
let fork = "feature";
repo.commit("feat.txt", "f", "feature work");
repo.git(&["checkout", "-q", "main"]);
repo.git(&["checkout", "-q", "-b", "release"]);
repo.git(&["merge", "--no-ff", "-q", "-m", "land feature", fork]);
repo.git(&["checkout", "-q", "main"]);
let target = "release";
assert!(landed_against(repo.path(), target, fork).expect("oracle"));
assert!(!landed_against(repo.path(), "HEAD", fork).expect("oracle"));
}
#[test]
fn landed_against_non_head_target_via_patch_id_cherry() {
let repo = ScratchRepo::new();
repo.commit("base.txt", "0", "C0");
repo.git(&["checkout", "-q", "-b", "feature"]);
let fork = "feature";
let fork_tip = repo.commit("feat.txt", "content", "add feat");
repo.git(&["checkout", "-q", "main"]);
repo.git(&["checkout", "-q", "-b", "release"]);
repo.git(&["cherry-pick", &fork_tip]);
repo.git(&["checkout", "-q", "main"]);
let target = "release";
assert!(landed_against(repo.path(), target, fork).expect("oracle"));
}
#[test]
fn not_landed_against_non_head_target() {
let repo = ScratchRepo::new();
repo.commit("base.txt", "0", "C0");
repo.git(&["checkout", "-q", "-b", "feature"]);
let fork = "feature";
repo.commit("only.txt", "only", "unlanded work");
repo.git(&["checkout", "-q", "main"]);
repo.git(&["checkout", "-q", "-b", "release"]);
repo.commit("other.txt", "other", "unrelated release work");
repo.git(&["checkout", "-q", "main"]);
let target = "release";
assert!(!landed_against(repo.path(), target, fork).expect("oracle"));
}
#[test]
fn reap_deletes_dispatch_record_and_resolve_yields_unknown_agent() {
use super::run_gc;
use crate::worktree::dispatch_record::{
RECORD_SUBPATH, ResolveRefusal, provision_dispatch_record, resolve_agent,
};
let repo = ScratchRepo::new();
let base = repo.commit("a.txt", "0", "base");
let coord = std::fs::canonicalize(repo.path()).unwrap();
let name = "agent-cafe";
let branch = format!("dispatch/{name}");
let dir = coord.join(".worktrees").join(name);
let dir_s = dir.to_string_lossy().to_string();
repo.git(&["worktree", "add", "-b", &branch, &dir_s, &base]);
provision_dispatch_record(&coord, name, &base, &dir, &branch).unwrap();
let record_file = coord.join(RECORD_SUBPATH).join(format!("{name}.toml"));
assert!(
record_file.exists(),
"the DispatchRecord is written for a live worker"
);
assert!(
resolve_agent(&coord, name).is_ok(),
"a live, consistent worker resolves pre-reap"
);
run_gc(Some(coord.clone()), &branch, None, true, false).expect("gc reap");
assert!(
!record_file.exists(),
"reap deleted the DispatchRecord — none survives a reaped worktree"
);
assert_eq!(
resolve_agent(&coord, name),
Err(ResolveRefusal::UnknownAgent),
"post-reap resolve of the same agent yields unknown-agent"
);
}
}