use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "worktree-isolation",
default_stance: Stance::Observe,
evidence: Evidence {
per_1000: 5.6,
measured: "2026-09-08",
trend: Trend::Flat(4),
},
examine,
confirm: Some(confirm),
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Move {
Creating(String),
HardReset,
}
fn examine(parsed: &Parsed) -> Option<Finding> {
let (cmd, mv) = detect(parsed)?;
let reason = match &mv {
Move::Creating(name) => format!(
"`{name}` is being created in the checkout everything else here shares, \
and a working directory has one HEAD — whatever else is uncommitted in \
it comes along onto the new branch."
),
Move::HardReset => "`git reset --hard` in the shared checkout discards \
whatever is uncommitted there, including work that arrived from \
somebody else's session rather than this one."
.to_string(),
};
let remedy = match &mv {
Move::Creating(name) => format!(
"Give the task its own HEAD: `git fetch origin -q && git worktree add \
../<repo>-wt-<slug> -b {name} origin/main`, then work there."
),
Move::HardReset => "Check whose work is there first — `git status --short` — \
and if the intent was a clean start, take it in a new worktree off \
`origin/main` instead of resetting this one."
.to_string(),
};
Some(Finding {
reason,
remedy,
span: cmd.at..cmd.end,
})
}
pub fn detect(parsed: &Parsed) -> Option<(&Simple, Move)> {
for cmd in parsed.clauses() {
if cmd.program() != Some("git") || cmd.is_dry_run() {
continue;
}
let Some(sub) = cmd.subcommand() else {
continue;
};
let mv = match sub {
"checkout" => creating(cmd, &["-b", "-B"], &[]),
"switch" => creating(cmd, &["-c", "-C"], &["--create", "--force-create"]),
"reset" if cmd.has_flag("--hard") => Some(Move::HardReset),
_ => None,
};
if let Some(mv) = mv {
return Some((cmd, mv));
}
}
None
}
fn creating(cmd: &Simple, shorts: &[&str], longs: &[&str]) -> Option<Move> {
if cmd.has_flag("--detach") {
return None;
}
let sub = cmd.subcommand()?;
let mut words = cmd.words.iter().skip_while(|w| w.text != sub).skip(1);
while let Some(w) = words.next() {
if w.quoted {
continue; }
let t = w.text.as_str();
if shorts.contains(&t) || longs.contains(&t) {
let name = words.next()?;
if name.expanded {
return None;
}
return Some(Move::Creating(name.text.clone()));
}
}
None
}
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
let cwd = ctx.cwd_at(f.span.start);
let cwd = cwd.as_path();
if !cwd.is_dir() {
return Confirmed::No("the directory the command moves to does not exist");
}
let (Some(git_dir), Some(common)) = (
crate::git::stdout_in(cwd, &["rev-parse", "--git-dir"]),
crate::git::stdout_in(cwd, &["rev-parse", "--git-common-dir"]),
) else {
return Confirmed::No("not a git repository");
};
if git_dir != common {
return Confirmed::No("already in a linked worktree");
}
let Some(list) = crate::git::stdout_in(cwd, &["worktree", "list", "--porcelain"]) else {
return Confirmed::No("git could not list the worktrees");
};
if list.lines().filter(|l| l.starts_with("worktree ")).count() < 2 {
return Confirmed::No("nothing else is checked out from this repository");
}
Confirmed::Yes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn move_of(command: &str) -> Option<Move> {
detect(&lex(command)).map(|(_, m)| m)
}
#[test]
fn creating_a_branch_names_it() {
assert_eq!(
move_of("git checkout -b feat/thing"),
Some(Move::Creating("feat/thing".into()))
);
assert_eq!(
move_of("git switch -c fix/x origin/main"),
Some(Move::Creating("fix/x".into()))
);
assert_eq!(
move_of("git switch --create fix/x"),
Some(Move::Creating("fix/x".into()))
);
assert_eq!(
move_of("cd ~/Developer/Perso/homelab && git checkout -B chore/bump"),
Some(Move::Creating("chore/bump".into()))
);
}
#[test]
fn a_hard_reset_is_the_collision_itself() {
assert_eq!(
move_of("git reset --hard origin/main"),
Some(Move::HardReset)
);
assert_eq!(move_of("git reset --hard"), Some(Move::HardReset));
}
#[test]
fn navigation_and_the_remedy_stay_silent() {
for c in [
"git checkout main",
"git switch main",
"git checkout -- src/lib.rs",
"git checkout --detach origin/main",
"git reset --soft HEAD~1",
"git reset HEAD src/lib.rs",
"git worktree add ../x -b feat/y origin/main",
"git fetch origin -q && git worktree add ../amont-wt-thing -b fix/z origin/main",
"git branch -D feat/old",
"git status --short",
] {
assert_eq!(move_of(c), None, "{c}");
}
}
#[test]
fn an_unknowable_branch_name_is_not_guessed() {
assert_eq!(move_of("git checkout -b $(date +%s)"), None);
}
#[test]
fn a_bare_git_does_not_end_the_scan() {
assert_eq!(
move_of("git; git checkout -b feat/after"),
Some(Move::Creating("feat/after".into()))
);
}
}