use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "stale-base",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 1.0,
measured: "2026-08-24",
trend: Trend::Improving,
},
examine,
confirm: Some(confirm),
};
pub struct Creation {
pub start: Option<String>,
}
fn examine(parsed: &Parsed) -> Option<Finding> {
let (cmd, creation) = detect(parsed)?;
let from = creation.start.clone().unwrap_or_else(|| "HEAD".to_string());
Some(Finding {
reason: format!(
"this creates a branch from `{from}`, and a branch inherits whatever its \
start point is missing — every commit origin/main has gained since the \
last fetch stays invisible on it."
),
remedy: "Start it from the remote instead: `git fetch origin && git worktree \
add <path> -b <name> origin/main` (or `git switch -c <name> \
origin/main`)."
.to_string(),
span: cmd.at..cmd.end,
})
}
pub fn detect(parsed: &Parsed) -> Option<(&Simple, Creation)> {
for cmd in parsed.clauses() {
if cmd.program() != Some("git") {
continue;
}
if cmd.is_dry_run() {
continue;
}
let Some(sub) = cmd.subcommand() else {
continue;
};
let creation = match sub {
"worktree" => worktree_add(cmd),
"checkout" => checkout_or_switch(cmd, &["-b", "-B"], &[]),
"switch" => checkout_or_switch(cmd, &["-c", "-C"], &["--create", "--force-create"]),
_ => None,
};
if let Some(c) = creation {
return Some((cmd, c));
}
}
None
}
fn worktree_add(cmd: &Simple) -> Option<Creation> {
let mut words = cmd
.words
.iter()
.skip_while(|w| w.text != "worktree")
.skip(1);
if words.next().map(|w| w.text.as_str()) != Some("add") {
return None;
}
if cmd.has_flag("--detach") {
return None;
}
let mut named = false;
let mut positional: Vec<&str> = Vec::new();
let mut words = words.peekable();
while let Some(w) = words.next() {
let t = w.text.as_str();
if w.quoted {
positional.push(t);
continue;
}
if t == "-b" || t == "-B" {
named = true;
let _ = words.next();
continue;
}
if t.starts_with('-') {
continue;
}
positional.push(t);
}
match (named, positional.as_slice()) {
(true, [_path]) => Some(Creation { start: None }),
(true, [_path, start, ..]) => local(start),
(false, [_path]) => Some(Creation { start: None }),
_ => None,
}
}
fn checkout_or_switch(cmd: &Simple, shorts: &[&str], longs: &[&str]) -> Option<Creation> {
if cmd.has_flag("--track") || cmd.has_short('t') || cmd.has_flag("--detach") {
return None;
}
let sub = cmd.subcommand()?;
let mut words = cmd
.words
.iter()
.skip_while(|w| w.text != sub)
.skip(1)
.peekable();
let mut creating = false;
let mut positional: Vec<&str> = Vec::new();
while let Some(w) = words.next() {
let t = w.text.as_str();
if w.quoted {
positional.push(t);
continue;
}
if shorts.contains(&t) || longs.contains(&t) {
creating = true;
let _ = words.next(); continue;
}
if t.starts_with('-') {
continue;
}
positional.push(t);
}
if !creating {
return None;
}
match positional.as_slice() {
[] => Some(Creation { start: None }),
[start, ..] => local(start),
}
}
const REMOTE_NAMES: &[&str] = &[
"origin", "upstream", "forgejo", "gitea", "github", "gitlab", "fork",
];
fn local(start: &str) -> Option<Creation> {
if start.starts_with("refs/remotes/") || start == "FETCH_HEAD" {
return None;
}
if let Some((remote, _)) = start.split_once('/') {
if REMOTE_NAMES.contains(&remote) {
return None;
}
}
Some(Creation {
start: Some(start.to_string()),
})
}
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
let Some((_, creation)) = detect(ctx.parsed) else {
return Confirmed::No("the command no longer matches");
};
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 from = creation.start.unwrap_or_else(|| "HEAD".to_string());
if from.contains('/')
&& crate::git::succeeds_in(
cwd,
&[
"rev-parse",
"-q",
"--verify",
&format!("refs/remotes/{from}"),
],
)
{
return Confirmed::No("the start point is a remote-tracking ref");
}
match crate::stale::measure(cwd, &from) {
Some(d) if d.behind > 0 => Confirmed::Yes,
Some(_) => Confirmed::No("the start point is not behind origin"),
None => Confirmed::No("nothing to compare against"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn start_of(command: &str) -> Option<Option<String>> {
detect(&lex(command)).map(|(_, c)| c.start)
}
#[test]
fn a_worktree_with_no_start_point_is_from_head() {
assert_eq!(start_of("git worktree add ../x -b feat/y"), Some(None));
assert_eq!(start_of("git worktree add -b feat/y ../x"), Some(None));
assert_eq!(start_of("git worktree add ../x"), Some(None));
}
#[test]
fn a_local_start_point_is_named() {
assert_eq!(
start_of("git worktree add ../x -b feat/y main"),
Some(Some("main".into()))
);
assert_eq!(
start_of("git checkout -b feat/y develop"),
Some(Some("develop".into()))
);
}
#[test]
fn a_remote_start_point_is_the_remedy_not_the_mistake() {
for c in [
"git fetch origin -q && git worktree add ../x -b feat/y origin/main",
"git worktree add -b feat/y ../x origin/main",
"git checkout -b feat/y origin/main",
"git switch -c feat/y origin/main",
"git switch -c feat/y upstream/main",
"git fetch forgejo main -q && git checkout -B fix/x forgejo/main 2>&1 | tail -1",
"git checkout -b feat/y --track origin/feat/y",
"git checkout -t origin/feat/y",
] {
assert_eq!(start_of(c), None, "{c}");
}
}
#[test]
fn checking_out_an_existing_branch_creates_nothing() {
for c in [
"git worktree add ../x feat/existing",
"git worktree add --detach ../x",
"git checkout main",
"git switch feat/y",
"git worktree list",
"git worktree remove ../x",
"git worktree add --dry-run ../x",
] {
assert_eq!(start_of(c), None, "{c}");
}
}
#[test]
fn a_bare_git_does_not_end_the_scan() {
assert_eq!(start_of("git; git checkout -b feat/after"), Some(None));
}
#[test]
fn switch_and_checkout_create_forms() {
assert_eq!(start_of("git switch -c feat/y"), Some(None));
assert_eq!(start_of("git switch --create feat/y"), Some(None));
assert_eq!(start_of("git checkout -B feat/y"), Some(None));
}
}