use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "worktree-remove-force",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 5.2,
measured: "2026-09-05",
trend: Trend::Flat(8),
},
examine,
confirm: Some(confirm),
};
fn detect(cmd: &Simple) -> Option<String> {
if cmd.program() != Some("git") || cmd.subcommand() != Some("worktree") {
return None;
}
let ops = cmd.operands();
if ops.get(1).map(|w| w.text.as_str()) != Some("remove") {
return None;
}
if !cmd.has_flag("--force") && !cmd.has_short('f') {
return None;
}
let path = ops.get(2)?;
if path.expanded {
return None;
}
Some(path.text.clone())
}
fn examine(parsed: &Parsed) -> Option<Finding> {
for cmd in parsed.clauses() {
let Some(path) = detect(cmd) else { continue };
return Some(Finding {
reason: format!(
"`git worktree remove --force` deletes `{path}` with whatever is \
uncommitted in it and prints nothing about what went; the refusal it \
skips is the only warning."
),
remedy: format!(
"Look first: `git -C {path} status --short`. Commit or move what matters, \
then remove without `--force` — it refuses only when something would be \
lost."
),
span: cmd.at..cmd.end,
});
}
None
}
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
let Some(path) = ctx
.parsed
.clauses()
.iter()
.find(|c| c.at == f.span.start)
.and_then(detect)
else {
return Confirmed::No("the command no longer matches");
};
let cwd = ctx.cwd_at(f.span.start);
let target = if let Some(rest) = path.strip_prefix("~/") {
match std::env::var_os("HOME") {
Some(h) => std::path::PathBuf::from(h).join(rest),
None => return Confirmed::No("cannot resolve ~"),
}
} else {
cwd.join(&path)
};
if !target.is_dir() {
return Confirmed::No("nothing at that path");
}
match crate::git::stdout_in(&target, &["status", "--porcelain", "--untracked-files=all"]) {
Some(s) if !s.trim().is_empty() => Confirmed::Yes,
Some(_) => Confirmed::No("the worktree is clean"),
None => Confirmed::No("git could not read the worktree"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn path_of(command: &str) -> Option<String> {
lex(command).clauses().iter().find_map(detect)
}
#[test]
fn a_forced_removal_names_its_path() {
assert_eq!(
path_of(
"git worktree remove /Users/me/Developer/x-wt-y --force 2>&1; git worktree list"
),
Some("/Users/me/Developer/x-wt-y".to_string())
);
assert_eq!(
path_of("cd ~/Developer/homelab && git worktree remove /tmp/hl-bump --force"),
Some("/tmp/hl-bump".to_string())
);
assert_eq!(
path_of("git worktree remove -f ../x"),
Some("../x".to_string())
);
}
#[test]
fn a_plain_removal_keeps_gits_own_refusal() {
assert_eq!(path_of("git worktree remove ../amont-wt-rehearse"), None);
assert_eq!(path_of("git worktree prune"), None);
assert_eq!(path_of("git worktree list"), None);
assert_eq!(path_of("git worktree add --force ../x feat/y"), None);
}
}