Skip to main content

gkit_core/
checks.rs

1//! The six log-off checks, ported from the zsh `isEverythingCheckedIn`
2//! (code-conf `gitCoreLib.sh`). Each is a pure function over a `&dyn Git`, so it
3//! can be unit-tested with `FakeGit`. A repo is "ok" only if all six pass.
4
5use crate::config::ResolvedBase;
6use crate::git::Git;
7use std::collections::HashSet;
8use std::path::Path;
9
10/// Current checked-out branch (`git rev-parse --abbrev-ref HEAD`); "HEAD" if detached.
11pub fn current_branch(git: &dyn Git, dir: &Path) -> String {
12    git.run(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
13        .trimmed()
14        .to_string()
15}
16
17/// 1. Nothing uncommitted: `git status -s` is empty.
18pub fn committed(git: &dyn Git, dir: &Path) -> bool {
19    git.run(dir, &["status", "-s"]).trimmed().is_empty()
20}
21
22/// 2. Every local commit exists on some remote:
23///    `git log --oneline --branches --not --remotes` is empty.
24pub fn all_commits_pushed(git: &dyn Git, dir: &Path) -> bool {
25    git.run(
26        dir,
27        &["log", "--oneline", "--branches", "--not", "--remotes"],
28    )
29    .trimmed()
30    .is_empty()
31}
32
33/// 3. Every local branch has a remote counterpart (matched by short name).
34pub fn branches_have_remote(git: &dyn Git, dir: &Path) -> bool {
35    let remotes: HashSet<String> = git
36        .run(
37            dir,
38            &[
39                "for-each-ref",
40                "--format=%(refname:short)",
41                "refs/remotes/origin/*",
42            ],
43        )
44        .stdout
45        .lines()
46        .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
47        .filter(|b| b != "HEAD")
48        .collect();
49
50    git.run(
51        dir,
52        &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
53    )
54    .stdout
55    .lines()
56    .map(str::trim)
57    .filter(|l| !l.is_empty())
58    .all(|local| remotes.contains(local))
59}
60
61/// 4. Current branch is not behind `origin/<branch>` (nothing to pull).
62///    **Fail-closed**: if we can't determine behind-ness — no current branch
63///    (detached / unborn), no matching remote-tracking ref, or an unparseable
64///    `rev-list` — the check **fails** rather than passing vacuously. It only
65///    passes when there's a remote ref and the branch is genuinely not behind it.
66pub fn not_behind_remote(git: &dyn Git, dir: &Path) -> bool {
67    let cur = current_branch(git, dir);
68    if cur.is_empty() {
69        return false;
70    }
71    let remote_ref = format!("refs/remotes/origin/{cur}");
72    if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
73        return false;
74    }
75    let range = format!("origin/{cur}...{cur}");
76    let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
77    // Output is "<behind>\t<ahead>": left = commits in origin/cur not in cur.
78    out.trimmed()
79        .split_whitespace()
80        .next()
81        .and_then(|s| s.parse::<u64>().ok())
82        .map(|behind| behind == 0)
83        .unwrap_or(false)
84}
85
86/// True for "integration" branches that are not feature work: the configured
87/// base branch plus the universal git defaults `main`/`master`.
88fn is_integration(branch: &str, base_branch: &str) -> bool {
89    branch == base_branch || branch == "main" || branch == "master"
90}
91
92/// The ref to compare "merged into base" against: the local `<base>` branch if it
93/// exists, else the remote-tracking `origin/<base>`. After a normal clone you
94/// often only have the default branch locally, so the remote-tracking ref is the
95/// usable stand-in.
96fn base_ref_for(git: &dyn Git, dir: &Path, base_branch: &str) -> String {
97    let local = format!("refs/heads/{base_branch}");
98    if git
99        .run(dir, &["show-ref", "--verify", "--quiet", &local])
100        .success
101    {
102        base_branch.to_string()
103    } else {
104        format!("origin/{base_branch}")
105    }
106}
107
108/// Which correct-branch rule set applies — selected by `gkit.solo`. The two are
109/// **mutually exclusive**: exactly one runs. This is the single place that decides
110/// "when to use which rule".
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum BranchRule {
113    /// Default (`gkit.solo` off). Flags only a **local** branch unmerged into base
114    /// (your own unfinished work); others' branches on the remote are ignored.
115    Team,
116    /// `gkit.solo` on. Flags **any** feature branch on the **remote** (for a solo
117    /// developer every remote branch is theirs, so a leftover one = unfinished
118    /// work). The original strict behavior.
119    Solo,
120}
121
122impl BranchRule {
123    pub fn from_solo(solo: bool) -> Self {
124        if solo {
125            BranchRule::Solo
126        } else {
127            BranchRule::Team
128        }
129    }
130
131    /// One-line "which rule + why" for `logoff -v` — its own line, so the
132    /// `correct-branch` line stays a bare boolean.
133    pub fn describe(&self) -> &'static str {
134        match self {
135            BranchRule::Team => "team (gkit.solo off) — flags a local branch unmerged into base",
136            BranchRule::Solo => "solo (gkit.solo on) — flags any feature branch on the remote",
137        }
138    }
139}
140
141/// TEAM rule helper: the first **local** non-integration branch with commits not
142/// merged into base (your unfinished work), or `None`. (Can't determine the base
143/// ref → not flagged.)
144fn local_unmerged_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
145    let base_ref = base_ref_for(git, dir, base_branch);
146    let merged = git.run(
147        dir,
148        &["branch", "--merged", &base_ref, "--format=%(refname:short)"],
149    );
150    if !merged.success {
151        return None;
152    }
153    let merged: HashSet<&str> = merged
154        .stdout
155        .lines()
156        .map(str::trim)
157        .filter(|l| !l.is_empty())
158        .collect();
159    git.run(
160        dir,
161        &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
162    )
163    .stdout
164    .lines()
165    .map(str::trim)
166    .filter(|l| !l.is_empty())
167    .find(|b| !is_integration(b, base_branch) && !merged.contains(*b))
168    .map(str::to_string)
169}
170
171/// SOLO rule helper: the first non-integration (feature) branch on the **remote**,
172/// or `None`.
173fn remote_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
174    git.run(dir, &["ls-remote", "--heads", "origin"])
175        .stdout
176        .lines()
177        .filter_map(|l| {
178            l.split_once("refs/heads/")
179                .map(|(_, b)| b.trim().to_string())
180        })
181        .find(|b| !is_integration(b, base_branch))
182}
183
184/// The outcome of the correct-branch check, rich enough to explain *why* it
185/// failed (surfaced by `logoff -vv`'s `R5 reason` line). Only the two passing
186/// variants make [`BranchVerdict::passed`] true.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum BranchVerdict {
189    /// On a feature branch — actively on your work (passes).
190    OnFeature,
191    /// On an integration branch with nothing pending under the active rule (passes).
192    IntegrationClean,
193    /// Detached HEAD — not on any branch (a risky resting state).
194    DetachedHead,
195    /// Base branch couldn't be resolved, so the check can't certify anything.
196    BaseUnresolved,
197    /// TEAM rule: this local branch isn't merged into base (your unfinished work).
198    LocalUnmerged(String),
199    /// SOLO rule: the remote has this feature branch.
200    RemoteFeature(String),
201}
202
203impl BranchVerdict {
204    /// Did the correct-branch check pass?
205    pub fn passed(&self) -> bool {
206        matches!(
207            self,
208            BranchVerdict::OnFeature | BranchVerdict::IntegrationClean
209        )
210    }
211
212    /// One-line reason for a **failing** verdict (empty string for the passing
213    /// ones) — the text shown after `R5 reason` at `logoff -vv`.
214    pub fn reason(&self) -> String {
215        match self {
216            BranchVerdict::OnFeature | BranchVerdict::IntegrationClean => String::new(),
217            BranchVerdict::DetachedHead => {
218                "detached HEAD — not on any branch (commits are easily lost here)".to_string()
219            }
220            BranchVerdict::BaseUnresolved => {
221                "base branch unresolved — set gkit.baseBranch or fetch origin/main|master"
222                    .to_string()
223            }
224            BranchVerdict::LocalUnmerged(b) => {
225                format!(
226                    "local branch '{b}' is not merged into base (team rule: your unfinished work)"
227                )
228            }
229            BranchVerdict::RemoteFeature(b) => {
230                format!("remote has feature branch '{b}' (solo rule: every remote branch is yours)")
231            }
232        }
233    }
234}
235
236/// 5. Correct branch — a real-life "are you parked safely?" check (see
237///    `docs/commands/logoff.md`), returning a [`BranchVerdict`] that also explains
238///    a failure. Shared preamble for both rules:
239///    - **detached HEAD** → fails (risky resting state; commits easily lost).
240///    - on a **feature** branch (not base/main/master) → passes (actively on work).
241///
242///    On an **integration** branch, exactly one rule runs (see [`BranchRule`]):
243///    `Team` flags a local unmerged feature branch; `Solo` flags any remote
244///    feature branch.
245pub fn branch_verdict(
246    git: &dyn Git,
247    dir: &Path,
248    base_branch: &str,
249    rule: BranchRule,
250) -> BranchVerdict {
251    // Detached HEAD: `symbolic-ref --short HEAD` fails when not on a branch.
252    if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
253        return BranchVerdict::DetachedHead;
254    }
255    let cur = current_branch(git, dir);
256    if !is_integration(&cur, base_branch) {
257        return BranchVerdict::OnFeature; // on a feature branch — fine
258    }
259    match rule {
260        BranchRule::Team => match local_unmerged_feature(git, dir, base_branch) {
261            Some(b) => BranchVerdict::LocalUnmerged(b),
262            None => BranchVerdict::IntegrationClean,
263        },
264        BranchRule::Solo => match remote_feature(git, dir, base_branch) {
265            Some(b) => BranchVerdict::RemoteFeature(b),
266            None => BranchVerdict::IntegrationClean,
267        },
268    }
269}
270
271/// Boolean form of [`branch_verdict`] — for callers that only need pass/fail.
272pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
273    branch_verdict(git, dir, base_branch, rule).passed()
274}
275
276/// Whether a behind-base feature branch also carries unique commits.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum BehindKind {
279    /// Has commits base lacks AND is behind base — history split (rebase onto base).
280    Diverged,
281    /// No unique commits, just behind base — merged/stale (switch to base & delete).
282    Stale,
283}
284
285/// Outcome of R6 (`not-behind-base`): is the current **feature** branch up to date
286/// with base? Fail-closed — anything we can't determine ([`BaseSyncVerdict::Undeterminable`])
287/// fails. `Behind` fails unless `allowed` (`gkit.allowDiverged`), in which case it
288/// passes but is surfaced as a default-level marker.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum BaseSyncVerdict {
291    /// On an integration branch (base/main/master) — not feature work (passes).
292    NotApplicable,
293    /// Feature branch, not behind base (passes).
294    Current,
295    /// Feature branch behind base. Fails unless `allowed`.
296    Behind {
297        kind: BehindKind,
298        ahead: u64,
299        behind: u64,
300        base: String,
301        allowed: bool,
302    },
303    /// Couldn't determine sync vs base (absent base ref, detached) — fails.
304    Undeterminable { why: String },
305}
306
307impl BaseSyncVerdict {
308    /// Did R6 pass? (`Behind { allowed: true }` passes but is marked.)
309    pub fn passed(&self) -> bool {
310        match self {
311            BaseSyncVerdict::NotApplicable | BaseSyncVerdict::Current => true,
312            BaseSyncVerdict::Behind { allowed, .. } => *allowed,
313            BaseSyncVerdict::Undeterminable { .. } => false,
314        }
315    }
316
317    /// One-line reason for a **failing** verdict (empty for passing ones) — the
318    /// `R6 reason` text at `logoff -vv`.
319    pub fn reason(&self) -> String {
320        match self {
321            BaseSyncVerdict::Behind {
322                kind,
323                ahead,
324                behind,
325                base,
326                allowed: false,
327            } => match kind {
328                BehindKind::Diverged => format!(
329                    "diverged from base '{base}': {ahead} ahead, {behind} behind — rebase onto base"
330                ),
331                BehindKind::Stale => format!(
332                    "behind base '{base}' by {behind} (no unique commits — switch to base & delete)"
333                ),
334            },
335            BaseSyncVerdict::Undeterminable { why } => why.clone(),
336            _ => String::new(),
337        }
338    }
339
340    /// The default-level marker for a **tolerated** divergence
341    /// (`Behind { allowed: true }`), else `None`. ASCII, carrying the stable
342    /// `gkit.allowDiverged` token so suppressed repos stay greppable.
343    pub fn marker(&self) -> Option<String> {
344        match self {
345            BaseSyncVerdict::Behind {
346                kind,
347                allowed: true,
348                ..
349            } => Some(match kind {
350                BehindKind::Diverged => "(diverged, allowed by gkit.allowDiverged)".to_string(),
351                BehindKind::Stale => {
352                    "(behind base, merged, allowed by gkit.allowDiverged)".to_string()
353                }
354            }),
355            _ => None,
356        }
357    }
358}
359
360/// 6. Not behind base — the base-side twin of [`not_behind_remote`] (R4). On a
361///    **feature** branch, fails when the branch is **behind** base (either
362///    *diverged* — also ahead — or *merged/stale* — no unique commits). Integration
363///    branches are skipped ([`BaseSyncVerdict::NotApplicable`]). Fail-closed:
364///    detached HEAD or a base whose ref can't be located →
365///    [`BaseSyncVerdict::Undeterminable`]. `allow_diverged` (`gkit.allowDiverged`)
366///    downgrades a `Behind` failure to a marked pass.
367pub fn base_sync_verdict(
368    git: &dyn Git,
369    dir: &Path,
370    base_branch: &str,
371    allow_diverged: bool,
372) -> BaseSyncVerdict {
373    // Detached HEAD: not on a branch → can't certify a feature branch vs base.
374    if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
375        return BaseSyncVerdict::Undeterminable {
376            why: "detached HEAD — not on a branch (can't compare to base)".to_string(),
377        };
378    }
379    let cur = current_branch(git, dir);
380    if is_integration(&cur, base_branch) {
381        return BaseSyncVerdict::NotApplicable;
382    }
383    // Locate the base ref: prefer local `refs/heads/<base>`, else `origin/<base>`.
384    // Fail-closed if neither exists (e.g. single-branch clone, never fetched).
385    let local = format!("refs/heads/{base_branch}");
386    let base_ref = if git
387        .run(dir, &["show-ref", "--verify", "--quiet", &local])
388        .success
389    {
390        base_branch.to_string()
391    } else {
392        let remote = format!("refs/remotes/origin/{base_branch}");
393        if !git
394            .run(dir, &["show-ref", "--verify", "--quiet", &remote])
395            .success
396        {
397            return BaseSyncVerdict::Undeterminable {
398                why: format!(
399                    "base '{base_branch}' not found locally or on origin — fetch or set gkit.baseBranch"
400                ),
401            };
402        }
403        format!("origin/{base_branch}")
404    };
405    // `<base>...HEAD`: left = behind base, right = ahead of base (same orientation
406    // as R4's `origin/<cur>...<cur>`).
407    let range = format!("{base_ref}...HEAD");
408    let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
409    let mut it = out.trimmed().split_whitespace();
410    let counts = (
411        it.next().and_then(|s| s.parse::<u64>().ok()),
412        it.next().and_then(|s| s.parse::<u64>().ok()),
413    );
414    let (behind, ahead) = match counts {
415        (Some(b), Some(a)) => (b, a),
416        _ => {
417            return BaseSyncVerdict::Undeterminable {
418                why: format!("could not compare to base '{base_branch}'"),
419            }
420        }
421    };
422    if behind == 0 {
423        BaseSyncVerdict::Current
424    } else {
425        BaseSyncVerdict::Behind {
426            kind: if ahead > 0 {
427                BehindKind::Diverged
428            } else {
429                BehindKind::Stale
430            },
431            ahead,
432            behind,
433            base: base_branch.to_string(),
434            allowed: allow_diverged,
435        }
436    }
437}
438
439/// The five logoff checks, in run order, with stable `R<n>` ids. Single source of
440/// truth for `logoff -vv` line prefixes and the `logoff -e` catalog.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum RuleId {
443    Committed,
444    AllCommitsPushed,
445    BranchesHaveRemote,
446    NotBehindRemote,
447    CorrectBranch,
448    NotBehindBase,
449}
450
451impl RuleId {
452    /// All six, in the order they run and print.
453    pub const ALL: [RuleId; 6] = [
454        RuleId::Committed,
455        RuleId::AllCommitsPushed,
456        RuleId::BranchesHaveRemote,
457        RuleId::NotBehindRemote,
458        RuleId::CorrectBranch,
459        RuleId::NotBehindBase,
460    ];
461
462    /// 1-based rule number (the `<n>` in `R<n>`).
463    pub fn num(self) -> u8 {
464        match self {
465            RuleId::Committed => 1,
466            RuleId::AllCommitsPushed => 2,
467            RuleId::BranchesHaveRemote => 3,
468            RuleId::NotBehindRemote => 4,
469            RuleId::CorrectBranch => 5,
470            RuleId::NotBehindBase => 6,
471        }
472    }
473
474    /// The `R<n>` tag shown as a line prefix at `-vv` and in `-e`.
475    pub fn tag(self) -> String {
476        format!("R{}", self.num())
477    }
478
479    /// The stable, greppable check key — identical to the `-v` output keys.
480    pub fn key(self) -> &'static str {
481        match self {
482            RuleId::Committed => "committed",
483            RuleId::AllCommitsPushed => "all-commits-pushed",
484            RuleId::BranchesHaveRemote => "branches-have-remote",
485            RuleId::NotBehindRemote => "not-behind-remote",
486            RuleId::CorrectBranch => "correct-branch",
487            RuleId::NotBehindBase => "not-behind-base",
488        }
489    }
490
491    /// One-line description for `logoff -e`.
492    pub fn description(self) -> &'static str {
493        match self {
494            RuleId::Committed => {
495                "no uncommitted changes in the working tree (git status -s is empty)"
496            }
497            RuleId::AllCommitsPushed => {
498                "every local commit exists on some remote (nothing unpushed)"
499            }
500            RuleId::BranchesHaveRemote => "every local branch has a remote-tracking counterpart",
501            RuleId::NotBehindRemote => {
502                "the current branch tracks a remote and is not behind it (no pull needed); \
503                 fail-closed — a detached/unborn HEAD or a missing remote-tracking branch fails \
504                 rather than passing vacuously"
505            }
506            RuleId::CorrectBranch => {
507                "parked on a safe branch: a feature branch always passes; on an integration \
508                 branch the team rule (default) flags a local branch unmerged into base, while \
509                 the solo rule (gkit.solo=true) flags any remote feature branch; detached HEAD \
510                 or an unresolved base always fail"
511            }
512            RuleId::NotBehindBase => {
513                "on a feature branch, not behind the integration base (the base-side twin of \
514                 not-behind-remote): fails when the branch is behind base — diverged (also ahead, \
515                 rebase) or merged/stale (no unique commits, switch to base & delete). Integration \
516                 branches are skipped; fail-closed on detached HEAD or a base whose ref can't be \
517                 located. Suppress with git config gkit.allowDiverged true (still shown as a marker)"
518            }
519        }
520    }
521
522    /// Static teaching examples — `(scenario, outcome)` pairs shown after the
523    /// live state in the `-e <N>` deep dive. Illustrative, not derived from any repo.
524    pub fn examples(self) -> &'static [(&'static str, &'static str)] {
525        match self {
526            RuleId::Committed => &[
527                ("clean working tree", "PASS (nothing to commit)"),
528                ("edited file, not committed", "FAIL (commit or stash it)"),
529                ("staged but uncommitted file", "FAIL (still uncommitted)"),
530            ],
531            RuleId::AllCommitsPushed => &[
532                ("every commit pushed", "PASS"),
533                ("local-only commit on any branch", "FAIL (push it)"),
534                ("amended commit not force-pushed", "FAIL (push the rewrite)"),
535            ],
536            RuleId::BranchesHaveRemote => &[
537                ("every local branch tracks a remote", "PASS"),
538                (
539                    "local 'wip' branch never pushed",
540                    "FAIL (push or delete it)",
541                ),
542            ],
543            RuleId::NotBehindRemote => &[
544                ("up to date with origin", "PASS"),
545                ("no remote-tracking branch", "FAIL (push it / fix tracking)"),
546                ("origin has commits you don't", "FAIL (pull --rebase)"),
547            ],
548            RuleId::CorrectBranch => &[
549                ("on a feature branch", "PASS (actively on your work)"),
550                (
551                    "on base/main, all local branches merged",
552                    "PASS (parked clean)",
553                ),
554                (
555                    "on base/main, local 'wip' unmerged",
556                    "FAIL (team: unfinished work)",
557                ),
558                (
559                    "on base/main, remote feature branch exists",
560                    "FAIL (solo only)",
561                ),
562                ("detached HEAD", "FAIL (risky resting state)"),
563            ],
564            RuleId::NotBehindBase => &[
565                ("feature 2 ahead, 0 behind base", "PASS (on top of base)"),
566                ("feature 1 ahead, 2 behind base", "FAIL (diverged — rebase)"),
567                (
568                    "feature 0 ahead, 3 behind base",
569                    "FAIL (merged/stale — delete)",
570                ),
571                ("on base/main/master", "PASS (integration branch skipped)"),
572                (
573                    "gkit.allowDiverged=true, diverged",
574                    "PASS (tolerated, marked)",
575                ),
576            ],
577        }
578    }
579
580    /// Look up a rule by its 1-based number (for `-e <N>`).
581    pub fn from_num(n: u8) -> Option<RuleId> {
582        RuleId::ALL.into_iter().find(|r| r.num() == n)
583    }
584}
585
586/// Outcome of all six checks for one repo.
587#[derive(Debug, Clone)]
588pub struct RepoStatus {
589    pub branch: String,
590    pub committed: bool,
591    pub all_commits_pushed: bool,
592    pub branches_have_remote: bool,
593    pub not_behind_remote: bool,
594    pub correct_branch: bool,
595    /// The detailed correct-branch verdict (drives `correct_branch` + the `-vv`
596    /// `R5 reason` line).
597    pub branch_verdict: BranchVerdict,
598    /// The base branch used for the correct-branch check + how it was resolved.
599    /// When `base.name` is `None` (unresolved), `correct_branch` is forced `false`.
600    pub base: ResolvedBase,
601    /// R6 (`not-behind-base`) verdict: is the current feature branch behind base?
602    /// Drives the `not_behind_base` pass/fail, the `-vv` `R6 reason`, and the
603    /// default-level `gkit.allowDiverged` marker.
604    pub base_sync: BaseSyncVerdict,
605    /// Which correct-branch rule applied (`gkit.solo` selects it). Surfaced in
606    /// verbose only when [`BranchRule::Solo`] (the non-default rule).
607    pub rule: BranchRule,
608    /// Set when the path couldn't be checked at all (missing dir / not a git
609    /// repo). When present, the gate FAILS and `problem` is shown in place of the
610    /// checks — otherwise a non-repo would pass every check vacuously (empty git
611    /// output reads as "nothing pending").
612    pub problem: Option<String>,
613}
614
615impl RepoStatus {
616    /// A path that couldn't be checked (missing dir / not a git repo). Fails the
617    /// gate; `reason` is rendered in place of the per-check results.
618    pub fn unusable(reason: impl Into<String>) -> Self {
619        RepoStatus {
620            branch: String::new(),
621            committed: false,
622            all_commits_pushed: false,
623            branches_have_remote: false,
624            not_behind_remote: false,
625            correct_branch: false,
626            branch_verdict: BranchVerdict::BaseUnresolved,
627            base: ResolvedBase::unresolved(),
628            base_sync: BaseSyncVerdict::NotApplicable,
629            rule: BranchRule::Team,
630            problem: Some(reason.into()),
631        }
632    }
633
634    /// True only if the repo was checkable AND every check passed.
635    pub fn ok(&self) -> bool {
636        self.problem.is_none()
637            && self.committed
638            && self.all_commits_pushed
639            && self.branches_have_remote
640            && self.not_behind_remote
641            && self.correct_branch
642            && self.base_sync.passed()
643    }
644
645    /// Pass/fail for a single rule (used by the `-vv` per-rule lines).
646    pub fn rule_passed(&self, rule: RuleId) -> bool {
647        match rule {
648            RuleId::Committed => self.committed,
649            RuleId::AllCommitsPushed => self.all_commits_pushed,
650            RuleId::BranchesHaveRemote => self.branches_have_remote,
651            RuleId::NotBehindRemote => self.not_behind_remote,
652            RuleId::CorrectBranch => self.correct_branch,
653            RuleId::NotBehindBase => self.base_sync.passed(),
654        }
655    }
656
657    /// The reason a rule **failed**, or `None` if it passed — the text shown after
658    /// `R<n> reason` at `logoff -vv`.
659    pub fn failure_reason(&self, rule: RuleId) -> Option<String> {
660        if self.rule_passed(rule) {
661            return None;
662        }
663        Some(match rule {
664            RuleId::Committed => "uncommitted changes in the working tree".to_string(),
665            RuleId::AllCommitsPushed => "local commits are not pushed to any remote".to_string(),
666            RuleId::BranchesHaveRemote => {
667                "a local branch has no remote-tracking counterpart".to_string()
668            }
669            RuleId::NotBehindRemote => {
670                "the branch is behind its remote, or has no remote-tracking branch to compare \
671                 (push it / pull --rebase)"
672                    .to_string()
673            }
674            RuleId::CorrectBranch => self.branch_verdict.reason(),
675            RuleId::NotBehindBase => self.base_sync.reason(),
676        })
677    }
678}
679
680/// Run all six checks for a single repo at `dir`. An unresolved base
681/// (`base.name == None`) forces both base-dependent checks to fail — the base
682/// couldn't be determined, so we can't certify the right branch is checked out
683/// (R5) nor that it's current with base (R6). The two are **independent**: each
684/// reports its own verdict. `solo` selects the correct-branch rule (`gkit.solo`;
685/// see [`BranchRule`]); `allow_diverged` (`gkit.allowDiverged`) downgrades an R6
686/// behind-base failure to a marked pass.
687pub fn evaluate(
688    git: &dyn Git,
689    dir: &Path,
690    base: &ResolvedBase,
691    solo: bool,
692    allow_diverged: bool,
693) -> RepoStatus {
694    let rule = BranchRule::from_solo(solo);
695    let verdict = match &base.name {
696        Some(b) => branch_verdict(git, dir, b, rule),
697        None => BranchVerdict::BaseUnresolved,
698    };
699    // R6 is independent and fail-closed: an unresolved base fails it too (not a
700    // vacuous pass), with its own reason — it does not defer to R5.
701    let base_sync = match &base.name {
702        Some(b) => base_sync_verdict(git, dir, b, allow_diverged),
703        None => BaseSyncVerdict::Undeterminable {
704            why: "base unresolved — set gkit.baseBranch or fetch origin/main|master".to_string(),
705        },
706    };
707    let correct_branch = verdict.passed();
708    RepoStatus {
709        branch: current_branch(git, dir),
710        committed: committed(git, dir),
711        all_commits_pushed: all_commits_pushed(git, dir),
712        branches_have_remote: branches_have_remote(git, dir),
713        not_behind_remote: not_behind_remote(git, dir),
714        correct_branch,
715        branch_verdict: verdict,
716        base: base.clone(),
717        base_sync,
718        rule,
719        problem: None,
720    }
721}
722
723/// One rule's deep-dive report for `logoff -e <N>`: the live, per-repo state behind
724/// a single check, ready for [`crate::report::print_rule_detail`] to render.
725#[derive(Debug, Clone)]
726pub struct RuleReport {
727    pub id: RuleId,
728    pub passed: bool,
729    /// "This repo now" label/value lines (rule-specific live state).
730    pub facts: Vec<(String, String)>,
731    /// One-line verdict: the failure reason, or a short "PASS …".
732    pub verdict: String,
733}
734
735/// Gather the live, per-repo state behind one rule for the `-e <N>` deep dive.
736/// Reads git for a **single** repo (no submodule recursion, no fetch) and reuses
737/// the same git commands as the corresponding check, so the two can't drift.
738pub fn rule_report(
739    git: &dyn Git,
740    dir: &Path,
741    base: &ResolvedBase,
742    solo: bool,
743    allow_diverged: bool,
744    id: RuleId,
745) -> RuleReport {
746    let lines = |out: crate::git::GitOutput| -> Vec<String> {
747        out.stdout
748            .lines()
749            .map(str::trim)
750            .filter(|l| !l.is_empty())
751            .map(str::to_string)
752            .collect()
753    };
754    let or_none = |v: &[String]| {
755        if v.is_empty() {
756            "(none)".to_string()
757        } else {
758            v.join(", ")
759        }
760    };
761
762    let mut facts: Vec<(String, String)> = Vec::new();
763    let (passed, verdict) = match id {
764        RuleId::Committed => {
765            let dirty = lines(git.run(dir, &["status", "-s"]));
766            for f in &dirty {
767                facts.push(("dirty".to_string(), f.clone()));
768            }
769            if dirty.is_empty() {
770                (true, "PASS — working tree clean".to_string())
771            } else {
772                (
773                    false,
774                    format!("FAIL — {} uncommitted change(s)", dirty.len()),
775                )
776            }
777        }
778        RuleId::AllCommitsPushed => {
779            let unpushed = lines(git.run(
780                dir,
781                &["log", "--oneline", "--branches", "--not", "--remotes"],
782            ));
783            for c in &unpushed {
784                facts.push(("unpushed".to_string(), c.clone()));
785            }
786            if unpushed.is_empty() {
787                (true, "PASS — nothing unpushed".to_string())
788            } else {
789                (
790                    false,
791                    format!("FAIL — {} commit(s) not on any remote", unpushed.len()),
792                )
793            }
794        }
795        RuleId::BranchesHaveRemote => {
796            let remotes: HashSet<String> = git
797                .run(
798                    dir,
799                    &[
800                        "for-each-ref",
801                        "--format=%(refname:short)",
802                        "refs/remotes/origin/*",
803                    ],
804                )
805                .stdout
806                .lines()
807                .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
808                .filter(|b| b != "HEAD")
809                .collect();
810            let locals = lines(git.run(
811                dir,
812                &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
813            ));
814            facts.push(("local branches".to_string(), or_none(&locals)));
815            let missing: Vec<String> = locals
816                .iter()
817                .filter(|b| !remotes.contains(*b))
818                .cloned()
819                .collect();
820            if missing.is_empty() {
821                (
822                    true,
823                    "PASS — every local branch tracks a remote".to_string(),
824                )
825            } else {
826                facts.push(("missing remote".to_string(), missing.join(", ")));
827                (
828                    false,
829                    format!("FAIL — no remote for: {}", missing.join(", ")),
830                )
831            }
832        }
833        RuleId::NotBehindRemote => {
834            let cur = current_branch(git, dir);
835            facts.push((
836                "branch".to_string(),
837                if cur.is_empty() {
838                    "(detached)".to_string()
839                } else {
840                    cur.clone()
841                },
842            ));
843            if cur.is_empty() {
844                (
845                    false,
846                    "FAIL — no current branch (detached/unborn); can't compare to a remote"
847                        .to_string(),
848                )
849            } else {
850                let remote_ref = format!("refs/remotes/origin/{cur}");
851                if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
852                    facts.push(("remote branch".to_string(), "none".to_string()));
853                    (
854                        false,
855                        "FAIL — no remote-tracking branch (push it / fix tracking)".to_string(),
856                    )
857                } else {
858                    let range = format!("origin/{cur}...{cur}");
859                    let behind = git
860                        .run(dir, &["rev-list", "--left-right", "--count", &range])
861                        .trimmed()
862                        .split_whitespace()
863                        .next()
864                        .and_then(|s| s.parse::<u64>().ok())
865                        .unwrap_or(0);
866                    facts.push(("behind by".to_string(), behind.to_string()));
867                    if behind == 0 {
868                        (true, "PASS — up to date with origin".to_string())
869                    } else {
870                        (
871                            false,
872                            format!("FAIL — behind by {behind} commit(s); pull --rebase"),
873                        )
874                    }
875                }
876            }
877        }
878        RuleId::CorrectBranch => {
879            let rule = BranchRule::from_solo(solo);
880            let cur = current_branch(git, dir);
881            let verdict_enum = match &base.name {
882                Some(b) => branch_verdict(git, dir, b, rule),
883                None => BranchVerdict::BaseUnresolved,
884            };
885            let locals = lines(git.run(
886                dir,
887                &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
888            ));
889            facts.push((
890                "branch".to_string(),
891                if cur.is_empty() {
892                    "(detached)".to_string()
893                } else {
894                    cur.clone()
895                },
896            ));
897            facts.push(("base".to_string(), base.describe()));
898            facts.push(("rule".to_string(), rule.describe().to_string()));
899            facts.push(("local branches".to_string(), or_none(&locals)));
900            if verdict_enum.passed() {
901                (true, "PASS — parked safely".to_string())
902            } else {
903                (false, format!("FAIL — {}", verdict_enum.reason()))
904            }
905        }
906        RuleId::NotBehindBase => {
907            let cur = current_branch(git, dir);
908            facts.push((
909                "branch".to_string(),
910                if cur.is_empty() {
911                    "(detached)".to_string()
912                } else {
913                    cur.clone()
914                },
915            ));
916            facts.push(("base".to_string(), base.describe()));
917            let verdict = match &base.name {
918                Some(b) => base_sync_verdict(git, dir, b, allow_diverged),
919                None => BaseSyncVerdict::Undeterminable {
920                    why: "base unresolved — set gkit.baseBranch or fetch origin/main|master"
921                        .to_string(),
922                },
923            };
924            if let BaseSyncVerdict::Behind { ahead, behind, .. } = &verdict {
925                facts.push(("ahead of base".to_string(), ahead.to_string()));
926                facts.push(("behind base".to_string(), behind.to_string()));
927            }
928            match &verdict {
929                BaseSyncVerdict::NotApplicable => (
930                    true,
931                    "PASS — on an integration branch (not feature work)".to_string(),
932                ),
933                BaseSyncVerdict::Current => (
934                    true,
935                    "PASS — feature branch is current with base".to_string(),
936                ),
937                BaseSyncVerdict::Behind {
938                    kind,
939                    ahead,
940                    behind,
941                    base: b,
942                    allowed: true,
943                } => {
944                    let what = match kind {
945                        BehindKind::Diverged => {
946                            format!("diverged from '{b}' ({ahead} ahead, {behind} behind)")
947                        }
948                        BehindKind::Stale => format!("behind '{b}' by {behind} (merged/stale)"),
949                    };
950                    (
951                        true,
952                        format!("PASS — {what} but allowed by gkit.allowDiverged"),
953                    )
954                }
955                BaseSyncVerdict::Behind { allowed: false, .. }
956                | BaseSyncVerdict::Undeterminable { .. } => {
957                    (false, format!("FAIL — {}", verdict.reason()))
958                }
959            }
960        }
961    };
962    RuleReport {
963        id,
964        passed,
965        facts,
966        verdict,
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use crate::git::test_support::FakeGit;
974    use std::path::Path;
975
976    fn d() -> &'static Path {
977        Path::new("/x")
978    }
979
980    #[test]
981    fn committed_is_true_when_status_clean() {
982        assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
983        assert!(!committed(
984            &FakeGit::new().ok("status -s", " M file.rs"),
985            d()
986        ));
987    }
988
989    #[test]
990    fn pushed_is_true_when_no_unpushed_commits() {
991        let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
992        assert!(all_commits_pushed(&clean, d()));
993        let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
994        assert!(!all_commits_pushed(&dirty, d()));
995    }
996
997    #[test]
998    fn branches_have_remote_checks_every_local() {
999        let ok = FakeGit::new()
1000            .ok(
1001                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1002                "origin/dev\norigin/main\norigin/HEAD",
1003            )
1004            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
1005        assert!(branches_have_remote(&ok, d()));
1006
1007        let missing = FakeGit::new()
1008            .ok(
1009                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1010                "origin/dev",
1011            )
1012            .ok(
1013                "for-each-ref --format=%(refname:short) refs/heads/*",
1014                "dev\nlocal-only",
1015            );
1016        assert!(!branches_have_remote(&missing, d()));
1017    }
1018
1019    #[test]
1020    fn not_behind_false_when_no_remote_branch() {
1021        // Fail-closed: no remote-tracking ref to compare against -> fail, not a
1022        // vacuous pass (R3 owns "branch has no remote"; R4 stays independent).
1023        let g = FakeGit::new()
1024            .ok("rev-parse --abbrev-ref HEAD", "dev")
1025            .fail("show-ref --quiet refs/remotes/origin/dev");
1026        assert!(!not_behind_remote(&g, d()));
1027    }
1028
1029    #[test]
1030    fn not_behind_false_when_detached_or_unborn() {
1031        // No current branch name -> can't determine behind-ness -> fail-closed.
1032        let g = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "");
1033        assert!(!not_behind_remote(&g, d()));
1034    }
1035
1036    #[test]
1037    fn not_behind_reflects_left_count() {
1038        let aligned = FakeGit::new()
1039            .ok("rev-parse --abbrev-ref HEAD", "dev")
1040            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1041            .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
1042        assert!(not_behind_remote(&aligned, d()));
1043
1044        let behind = FakeGit::new()
1045            .ok("rev-parse --abbrev-ref HEAD", "dev")
1046            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1047            .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
1048        assert!(!not_behind_remote(&behind, d()));
1049    }
1050
1051    /// Stub the on-integration path: HEAD attached on `cur`, local base `dev`
1052    /// exists, with the given local branches + merged set.
1053    fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
1054        FakeGit::new()
1055            .ok("symbolic-ref --short HEAD", cur)
1056            .ok("rev-parse --abbrev-ref HEAD", cur)
1057            .ok("show-ref --verify --quiet refs/heads/dev", "")
1058            .ok("branch --merged dev --format=%(refname:short)", merged)
1059            .ok(
1060                "for-each-ref --format=%(refname:short) refs/heads/*",
1061                local_heads,
1062            )
1063    }
1064
1065    #[test]
1066    fn correct_branch_detached_head_fails() {
1067        // Not on any branch -> risky resting state -> false (both rules; shared preamble).
1068        let g = FakeGit::new().fail("symbolic-ref --short HEAD");
1069        assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
1070        assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
1071    }
1072
1073    #[test]
1074    fn correct_branch_on_feature_is_fine() {
1075        let g = FakeGit::new()
1076            .ok("symbolic-ref --short HEAD", "feature-x")
1077            .ok("rev-parse --abbrev-ref HEAD", "feature-x");
1078        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1079        assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
1080    }
1081
1082    #[test]
1083    fn team_rule_ignores_others_remote_branches() {
1084        // On dev; your only LOCAL branch is dev. Others' branches live on the
1085        // remote, but the team rule never scans the remote -> PASS.
1086        // (The real-life win: the ideal logged-off state isn't flagged.)
1087        let g = on_integration("dev", "dev", "dev");
1088        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1089    }
1090
1091    #[test]
1092    fn team_rule_flags_local_unmerged_feature() {
1093        // On dev with a LOCAL feature branch not merged into dev -> unfinished work.
1094        let g = on_integration("dev", "dev\nfeature-x", "dev");
1095        assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
1096    }
1097
1098    #[test]
1099    fn team_rule_allows_local_merged_feature() {
1100        // A local feature branch already merged into dev (just not deleted) -> PASS.
1101        let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
1102        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1103    }
1104
1105    #[test]
1106    fn solo_rule_flags_remote_feature_branch() {
1107        // Solo rule: on dev, but the remote has a feature branch -> FAIL. The team
1108        // rule on the same repo (local dev only) -> PASS (mutually exclusive).
1109        let g = on_integration("dev", "dev", "dev").ok(
1110            "ls-remote --heads origin",
1111            "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
1112        );
1113        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1114        assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
1115    }
1116
1117    #[test]
1118    fn solo_rule_passes_when_remote_is_integration_only() {
1119        // Solo rule, remote has only dev + main (both integration) -> PASS.
1120        let g = on_integration("dev", "dev", "dev").ok(
1121            "ls-remote --heads origin",
1122            "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
1123        );
1124        assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
1125    }
1126
1127    #[test]
1128    fn evaluate_all_clear() {
1129        let g = FakeGit::new()
1130            .ok("rev-parse --abbrev-ref HEAD", "dev")
1131            .ok("status -s", "")
1132            .ok("log --oneline --branches --not --remotes", "")
1133            .ok(
1134                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1135                "origin/dev",
1136            )
1137            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
1138            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1139            .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
1140            // correct-branch (default rule): attached on dev, local dev merged.
1141            .ok("symbolic-ref --short HEAD", "dev")
1142            .ok("show-ref --verify --quiet refs/heads/dev", "")
1143            .ok("branch --merged dev --format=%(refname:short)", "dev");
1144        let base = ResolvedBase {
1145            name: Some("dev".into()),
1146            source: crate::config::BaseSource::Config,
1147        };
1148        let st = evaluate(&g, d(), &base, false, false);
1149        assert!(st.ok(), "expected all-clear, got {st:?}");
1150        assert_eq!(st.branch, "dev");
1151    }
1152
1153    #[test]
1154    fn unresolved_base_fails_correct_branch() {
1155        // Everything else is clean, but the base couldn't be resolved → the gate
1156        // fails on correct-branch rather than passing vacuously.
1157        let g = FakeGit::new()
1158            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1159            .ok("status -s", "")
1160            .ok("log --oneline --branches --not --remotes", "")
1161            .ok(
1162                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1163                "origin/feature-x",
1164            )
1165            .ok(
1166                "for-each-ref --format=%(refname:short) refs/heads/*",
1167                "feature-x",
1168            )
1169            .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
1170            .ok(
1171                "rev-list --left-right --count origin/feature-x...feature-x",
1172                "0\t0",
1173            );
1174        let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false, false);
1175        assert!(!st.correct_branch);
1176        assert!(!st.ok());
1177    }
1178
1179    // ---- branch_verdict: the reason behind a correct-branch verdict (for -vv) ----
1180
1181    #[test]
1182    fn verdict_detached_head() {
1183        let g = FakeGit::new().fail("symbolic-ref --short HEAD");
1184        assert_eq!(
1185            branch_verdict(&g, d(), "dev", BranchRule::Team),
1186            BranchVerdict::DetachedHead
1187        );
1188    }
1189
1190    #[test]
1191    fn verdict_on_feature_branch() {
1192        let g = FakeGit::new()
1193            .ok("symbolic-ref --short HEAD", "feature-x")
1194            .ok("rev-parse --abbrev-ref HEAD", "feature-x");
1195        assert_eq!(
1196            branch_verdict(&g, d(), "dev", BranchRule::Team),
1197            BranchVerdict::OnFeature
1198        );
1199    }
1200
1201    #[test]
1202    fn verdict_team_names_the_unmerged_local_branch() {
1203        let g = on_integration("dev", "dev\nfeature-x", "dev");
1204        assert_eq!(
1205            branch_verdict(&g, d(), "dev", BranchRule::Team),
1206            BranchVerdict::LocalUnmerged("feature-x".into())
1207        );
1208    }
1209
1210    #[test]
1211    fn verdict_team_clean_integration() {
1212        let g = on_integration("dev", "dev", "dev");
1213        assert_eq!(
1214            branch_verdict(&g, d(), "dev", BranchRule::Team),
1215            BranchVerdict::IntegrationClean
1216        );
1217    }
1218
1219    #[test]
1220    fn verdict_solo_names_the_remote_feature_branch() {
1221        let g = on_integration("dev", "dev", "dev").ok(
1222            "ls-remote --heads origin",
1223            "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
1224        );
1225        assert_eq!(
1226            branch_verdict(&g, d(), "dev", BranchRule::Solo),
1227            BranchVerdict::RemoteFeature("alice-x".into())
1228        );
1229    }
1230
1231    #[test]
1232    fn verdict_reason_is_empty_only_when_passing() {
1233        assert!(BranchVerdict::OnFeature.reason().is_empty());
1234        assert!(BranchVerdict::IntegrationClean.reason().is_empty());
1235        assert!(BranchVerdict::DetachedHead
1236            .reason()
1237            .contains("detached HEAD"));
1238        assert!(BranchVerdict::BaseUnresolved
1239            .reason()
1240            .contains("unresolved"));
1241        assert!(BranchVerdict::LocalUnmerged("x".into())
1242            .reason()
1243            .contains("'x'"));
1244        assert!(BranchVerdict::RemoteFeature("x".into())
1245            .reason()
1246            .contains("'x'"));
1247    }
1248
1249    #[test]
1250    fn evaluate_unresolved_sets_base_unresolved_verdict() {
1251        // The forced-fail path records *why* (for the -vv reason line), not a bare
1252        // false.
1253        let g = FakeGit::new()
1254            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1255            .ok("status -s", "")
1256            .ok("log --oneline --branches --not --remotes", "")
1257            .ok(
1258                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1259                "origin/feature-x",
1260            )
1261            .ok(
1262                "for-each-ref --format=%(refname:short) refs/heads/*",
1263                "feature-x",
1264            )
1265            .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
1266            .ok(
1267                "rev-list --left-right --count origin/feature-x...feature-x",
1268                "0\t0",
1269            );
1270        let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false, false);
1271        assert_eq!(st.branch_verdict, BranchVerdict::BaseUnresolved);
1272        assert_eq!(
1273            st.failure_reason(RuleId::CorrectBranch),
1274            Some(BranchVerdict::BaseUnresolved.reason())
1275        );
1276    }
1277
1278    // ---- RuleId catalog + per-rule reasons (for -vv and -e) ----
1279
1280    #[test]
1281    fn rule_ids_are_stable_and_round_trip() {
1282        let nums: Vec<u8> = RuleId::ALL.iter().map(|r| r.num()).collect();
1283        assert_eq!(nums, vec![1, 2, 3, 4, 5, 6]);
1284        for r in RuleId::ALL {
1285            assert_eq!(RuleId::from_num(r.num()), Some(r));
1286            assert_eq!(r.tag(), format!("R{}", r.num()));
1287            assert!(!r.key().is_empty() && !r.description().is_empty());
1288        }
1289        assert_eq!(RuleId::from_num(0), None);
1290        assert_eq!(RuleId::from_num(7), None);
1291        assert_eq!(RuleId::CorrectBranch.key(), "correct-branch");
1292        assert_eq!(RuleId::NotBehindBase.key(), "not-behind-base");
1293        assert_eq!(RuleId::from_num(6), Some(RuleId::NotBehindBase));
1294    }
1295
1296    #[test]
1297    fn failure_reason_is_some_only_for_failing_rules() {
1298        // A repo failing only committed: that rule reports a reason, the rest don't.
1299        let g = FakeGit::new()
1300            .ok("rev-parse --abbrev-ref HEAD", "dev")
1301            .ok("status -s", " M file.txt") // dirty -> committed fails
1302            .ok("log --oneline --branches --not --remotes", "")
1303            .ok(
1304                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1305                "origin/dev",
1306            )
1307            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
1308            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1309            .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
1310            .ok("symbolic-ref --short HEAD", "dev")
1311            .ok("show-ref --verify --quiet refs/heads/dev", "")
1312            .ok("branch --merged dev --format=%(refname:short)", "dev");
1313        let base = ResolvedBase {
1314            name: Some("dev".into()),
1315            source: crate::config::BaseSource::Config,
1316        };
1317        let st = evaluate(&g, d(), &base, false, false);
1318        assert!(st.failure_reason(RuleId::Committed).is_some());
1319        assert!(st.failure_reason(RuleId::AllCommitsPushed).is_none());
1320        assert!(st.failure_reason(RuleId::CorrectBranch).is_none());
1321    }
1322
1323    // ---- RuleId::examples + rule_report (the `-e <N>` deep dive) ----
1324
1325    #[test]
1326    fn every_rule_has_examples() {
1327        for r in RuleId::ALL {
1328            assert!(!r.examples().is_empty(), "{:?} has no examples", r);
1329        }
1330    }
1331
1332    fn dev_base() -> ResolvedBase {
1333        ResolvedBase {
1334            name: Some("dev".into()),
1335            source: crate::config::BaseSource::Config,
1336        }
1337    }
1338
1339    #[test]
1340    fn rule_report_r5_names_unmerged_branch_and_lists_state() {
1341        // On dev (integration) with a local unmerged feature -> FAIL, naming it.
1342        let g = on_integration("dev", "dev\nfeature-x", "dev");
1343        let rep = rule_report(&g, d(), &dev_base(), false, false, RuleId::CorrectBranch);
1344        assert!(!rep.passed);
1345        assert!(
1346            rep.verdict.contains("feature-x"),
1347            "verdict: {}",
1348            rep.verdict
1349        );
1350        // "This repo now" surfaces branch, base, rule, and the local branches.
1351        let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1352        assert_eq!(facts.get("branch").map(String::as_str), Some("dev"));
1353        assert!(facts.contains_key("base"));
1354        assert!(facts.get("local branches").unwrap().contains("feature-x"));
1355    }
1356
1357    #[test]
1358    fn rule_report_r1_lists_dirty_files() {
1359        let g = FakeGit::new()
1360            .ok("rev-parse --abbrev-ref HEAD", "dev")
1361            .ok("status -s", " M a.txt\n?? b.txt");
1362        let rep = rule_report(&g, d(), &dev_base(), false, false, RuleId::Committed);
1363        assert!(!rep.passed);
1364        let dirty: Vec<&str> = rep
1365            .facts
1366            .iter()
1367            .filter(|(l, _)| l == "dirty")
1368            .map(|(_, v)| v.as_str())
1369            .collect();
1370        assert_eq!(dirty, vec!["M a.txt", "?? b.txt"]);
1371    }
1372
1373    #[test]
1374    fn rule_report_r4_shows_behind_count() {
1375        let g = FakeGit::new()
1376            .ok("rev-parse --abbrev-ref HEAD", "dev")
1377            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1378            .ok("rev-list --left-right --count origin/dev...dev", "3\t0");
1379        let rep = rule_report(&g, d(), &dev_base(), false, false, RuleId::NotBehindRemote);
1380        assert!(!rep.passed);
1381        let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1382        assert_eq!(facts.get("behind by").map(String::as_str), Some("3"));
1383        assert!(
1384            rep.verdict.contains("behind by 3"),
1385            "verdict: {}",
1386            rep.verdict
1387        );
1388    }
1389
1390    // ---- R6 not-behind-base (base_sync_verdict) ----
1391
1392    /// On feature branch `cur`, base `dev` resolves to `origin/dev` (no local
1393    /// `dev`), with the given `<behind>\t<ahead>` rev-list count vs base.
1394    fn on_feature_vs_base(cur: &str, counts: &str) -> FakeGit {
1395        FakeGit::new()
1396            .ok("symbolic-ref --short HEAD", cur)
1397            .ok("rev-parse --abbrev-ref HEAD", cur)
1398            .fail("show-ref --verify --quiet refs/heads/dev")
1399            .ok("show-ref --verify --quiet refs/remotes/origin/dev", "")
1400            .ok("rev-list --left-right --count origin/dev...HEAD", counts)
1401    }
1402
1403    #[test]
1404    fn base_sync_diverged_fails() {
1405        // behind 2, ahead 1 -> diverged -> fail (allow_diverged off).
1406        let v = base_sync_verdict(&on_feature_vs_base("feature-x", "2\t1"), d(), "dev", false);
1407        assert!(!v.passed());
1408        assert!(matches!(
1409            v,
1410            BaseSyncVerdict::Behind {
1411                kind: BehindKind::Diverged,
1412                ahead: 1,
1413                behind: 2,
1414                allowed: false,
1415                ..
1416            }
1417        ));
1418        assert!(v.reason().contains("diverged from base 'dev'"));
1419    }
1420
1421    #[test]
1422    fn base_sync_pure_ahead_passes() {
1423        // behind 0, ahead 3 -> on top of base -> pass.
1424        let v = base_sync_verdict(&on_feature_vs_base("feature-x", "0\t3"), d(), "dev", false);
1425        assert!(v.passed());
1426        assert_eq!(v, BaseSyncVerdict::Current);
1427    }
1428
1429    #[test]
1430    fn base_sync_merged_stale_fails() {
1431        // behind 2, ahead 0 -> merged/stale -> fail.
1432        let v = base_sync_verdict(&on_feature_vs_base("feature-x", "2\t0"), d(), "dev", false);
1433        assert!(!v.passed());
1434        assert!(matches!(
1435            v,
1436            BaseSyncVerdict::Behind {
1437                kind: BehindKind::Stale,
1438                ..
1439            }
1440        ));
1441        assert!(v.reason().contains("behind base 'dev'"));
1442    }
1443
1444    #[test]
1445    fn base_sync_even_passes() {
1446        let v = base_sync_verdict(&on_feature_vs_base("feature-x", "0\t0"), d(), "dev", false);
1447        assert!(v.passed());
1448    }
1449
1450    #[test]
1451    fn base_sync_integration_branch_not_applicable() {
1452        // On dev itself -> not feature work -> NotApplicable (passes, no marker).
1453        let g = FakeGit::new()
1454            .ok("symbolic-ref --short HEAD", "dev")
1455            .ok("rev-parse --abbrev-ref HEAD", "dev");
1456        let v = base_sync_verdict(&g, d(), "dev", false);
1457        assert_eq!(v, BaseSyncVerdict::NotApplicable);
1458        assert!(v.passed());
1459        assert!(v.marker().is_none());
1460    }
1461
1462    #[test]
1463    fn base_sync_detached_is_undeterminable() {
1464        let g = FakeGit::new().fail("symbolic-ref --short HEAD");
1465        let v = base_sync_verdict(&g, d(), "dev", false);
1466        assert!(!v.passed());
1467        assert!(matches!(v, BaseSyncVerdict::Undeterminable { .. }));
1468        assert!(v.reason().contains("detached"));
1469    }
1470
1471    #[test]
1472    fn base_sync_absent_base_ref_is_undeterminable() {
1473        // Base 'dev' present neither locally nor on origin -> fail-closed.
1474        let g = FakeGit::new()
1475            .ok("symbolic-ref --short HEAD", "feature-x")
1476            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1477            .fail("show-ref --verify --quiet refs/heads/dev")
1478            .fail("show-ref --verify --quiet refs/remotes/origin/dev");
1479        let v = base_sync_verdict(&g, d(), "dev", false);
1480        assert!(!v.passed());
1481        assert!(v.reason().contains("not found"));
1482    }
1483
1484    #[test]
1485    fn base_sync_allow_diverged_suppresses_to_marked_pass() {
1486        // Same diverged repo, but allow_diverged -> passes with a marker.
1487        let v = base_sync_verdict(&on_feature_vs_base("feature-x", "2\t1"), d(), "dev", true);
1488        assert!(v.passed());
1489        assert!(matches!(v, BaseSyncVerdict::Behind { allowed: true, .. }));
1490        let marker = v.marker().expect("allowed divergence has a marker");
1491        assert!(marker.contains("allowed by gkit.allowDiverged"));
1492        assert!(marker.contains("diverged"));
1493    }
1494
1495    #[test]
1496    fn evaluate_unresolved_base_fails_r6_independently() {
1497        // No base -> R6 is Undeterminable (fail-closed), independent of R5.
1498        let g = FakeGit::new()
1499            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1500            .ok("status -s", "")
1501            .ok("log --oneline --branches --not --remotes", "")
1502            .ok(
1503                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1504                "origin/feature-x",
1505            )
1506            .ok(
1507                "for-each-ref --format=%(refname:short) refs/heads/*",
1508                "feature-x",
1509            )
1510            .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
1511            .ok(
1512                "rev-list --left-right --count origin/feature-x...feature-x",
1513                "0\t0",
1514            );
1515        let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false, false);
1516        assert!(!st.rule_passed(RuleId::NotBehindBase));
1517        assert!(st.failure_reason(RuleId::NotBehindBase).is_some());
1518    }
1519}