use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::Parsed;
pub const RULE: Rule = Rule {
id: "bare-stash-pop",
default_stance: Stance::Observe,
evidence: Evidence {
per_1000: 0.2,
measured: "2026-08-20",
trend: Trend::Rare,
},
examine,
confirm: Some(confirm),
};
fn examine(parsed: &Parsed) -> Option<Finding> {
for cmd in parsed.clauses() {
if cmd.program() != Some("git") || cmd.subcommand() != Some("stash") {
continue;
}
let ops = cmd.operands();
let Some(verb) = ops.get(1).map(|w| w.text.as_str()) else {
continue;
};
if verb != "pop" && verb != "apply" {
continue;
}
if ops.iter().skip(2).any(|w| names_a_stash(&w.text)) {
continue;
}
return Some(Finding {
reason: format!(
"`git stash {verb}` with no reference takes stash@{{0}}, and refs/stash \
is shared by every worktree of this repository — so it can restore \
another worktree's changes into this one."
),
remedy: "Run `git stash list`, identify the entry that belongs to this \
worktree, and name it: `git stash pop 'stash@{N}'`."
.to_string(),
span: cmd.at..cmd.end,
});
}
None
}
fn names_a_stash(t: &str) -> bool {
if t == "stash@{0}" || t == "refs/stash@{0}" {
return false;
}
t.starts_with("stash@{")
|| t.starts_with("refs/")
|| (t.len() >= 7 && t.bytes().all(|c| c.is_ascii_hexdigit()))
}
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
let cwd = ctx.cwd_at(f.span.start);
if !cwd.is_dir() {
return Confirmed::No("the directory the command moves to does not exist");
}
let out = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&cwd)
.output();
match out {
Ok(o) if o.status.success() => {
let n = String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| l.starts_with("worktree "))
.count();
if n > 1 {
Confirmed::Yes
} else {
Confirmed::No("this repository has a single worktree")
}
}
_ => Confirmed::No("git would not list the worktrees"),
}
}