1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//! `bare-stash-pop` — `git stash pop` with no explicit stash reference.
//!
//! `refs/stash` is shared across every worktree of a repository. A bare `pop`
//! or `apply` takes `stash@{0}` — whichever worktree pushed last, which in a
//! parallel-agent setup is very often not this one. The failure is quiet: the
//! wrong changes land in the wrong tree and look like your own work.
//!
//! Rare, and kept anyway: this rule is priced on the cost of one miss rather
//! than on frequency.
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.judgeable() {
if cmd.program() != Some("git") || cmd.subcommand() != Some("stash") {
continue;
}
let ops = cmd.operands();
// operands()[0] is `stash` itself; the verb follows it. `continue`, NOT
// `?`: a bare `git stash` has no verb, and returning here would abandon
// the whole scan — so `git stash; …; git stash pop` (the canonical
// save/restore round-trip) went unseen.
let Some(verb) = ops.get(1).map(|w| w.text.as_str()) else {
continue;
};
if verb != "pop" && verb != "apply" {
continue;
}
// An explicit reference makes the choice deliberate, which is all this
// rule wants.
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 {
// `stash@{0}` is the SHARED top of stack — the very entry a bare pop would
// take. Naming it is not a choice of WHICH entry, so it carries the same
// risk and the rule still fires. (A reviewed case says exactly this: a pop
// of `stash@{0}` from inside a worktree.)
if t == "stash@{0}" || t == "refs/stash@{0}" {
return false;
}
t.starts_with("stash@{")
// any other ref namespace, including the per-worktree `refs/wtstash/<n>`
// pattern people use precisely to avoid the shared ref
|| t.starts_with("refs/")
|| (t.len() >= 7 && t.bytes().all(|c| c.is_ascii_hexdigit()))
}
/// The risk is *shared* refs/stash, which is a fact about this checkout rather
/// than about the command. One `git worktree list` answers it, and only on the
/// rare occasion the rule fires.
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
// The repository a `cd` earlier in the command moved to, not the
// session's — `cd ../other-worktree && git stash pop` is the shape.
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")
}
}
// Git would not answer. Not confirmed is always silence.
_ => Confirmed::No("git would not list the worktrees"),
}
}