Skip to main content

gkit_core/
checks.rs

1//! The five 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 five 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///    If there's no matching remote branch, there's nothing to be behind → true.
63pub fn not_behind_remote(git: &dyn Git, dir: &Path) -> bool {
64    let cur = current_branch(git, dir);
65    if cur.is_empty() {
66        return true;
67    }
68    let remote_ref = format!("refs/remotes/origin/{cur}");
69    if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
70        return true;
71    }
72    let range = format!("origin/{cur}...{cur}");
73    let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
74    // Output is "<behind>\t<ahead>": left = commits in origin/cur not in cur.
75    out.trimmed()
76        .split_whitespace()
77        .next()
78        .and_then(|s| s.parse::<u64>().ok())
79        .map(|behind| behind == 0)
80        .unwrap_or(true)
81}
82
83/// True for "integration" branches that are not feature work: the configured
84/// base branch plus the universal git defaults `main`/`master`.
85fn is_integration(branch: &str, base_branch: &str) -> bool {
86    branch == base_branch || branch == "main" || branch == "master"
87}
88
89/// The ref to compare "merged into base" against: the local `<base>` branch if it
90/// exists, else the remote-tracking `origin/<base>`. After a normal clone you
91/// often only have the default branch locally, so the remote-tracking ref is the
92/// usable stand-in.
93fn base_ref_for(git: &dyn Git, dir: &Path, base_branch: &str) -> String {
94    let local = format!("refs/heads/{base_branch}");
95    if git
96        .run(dir, &["show-ref", "--verify", "--quiet", &local])
97        .success
98    {
99        base_branch.to_string()
100    } else {
101        format!("origin/{base_branch}")
102    }
103}
104
105/// Which correct-branch rule set applies — selected by `gkit.solo`. The two are
106/// **mutually exclusive**: exactly one runs. This is the single place that decides
107/// "when to use which rule".
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum BranchRule {
110    /// Default (`gkit.solo` off). Flags only a **local** branch unmerged into base
111    /// (your own unfinished work); others' branches on the remote are ignored.
112    Team,
113    /// `gkit.solo` on. Flags **any** feature branch on the **remote** (for a solo
114    /// developer every remote branch is theirs, so a leftover one = unfinished
115    /// work). The original strict behavior.
116    Solo,
117}
118
119impl BranchRule {
120    pub fn from_solo(solo: bool) -> Self {
121        if solo {
122            BranchRule::Solo
123        } else {
124            BranchRule::Team
125        }
126    }
127
128    /// One-line "which rule + why" for `logoff -v` — its own line, so the
129    /// `correct-branch` line stays a bare boolean.
130    pub fn describe(&self) -> &'static str {
131        match self {
132            BranchRule::Team => "team (gkit.solo off) — flags a local branch unmerged into base",
133            BranchRule::Solo => "solo (gkit.solo on) — flags any feature branch on the remote",
134        }
135    }
136}
137
138/// TEAM rule helper: the first **local** non-integration branch with commits not
139/// merged into base (your unfinished work), or `None`. (Can't determine the base
140/// ref → not flagged.)
141fn local_unmerged_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
142    let base_ref = base_ref_for(git, dir, base_branch);
143    let merged = git.run(
144        dir,
145        &["branch", "--merged", &base_ref, "--format=%(refname:short)"],
146    );
147    if !merged.success {
148        return None;
149    }
150    let merged: HashSet<&str> = merged
151        .stdout
152        .lines()
153        .map(str::trim)
154        .filter(|l| !l.is_empty())
155        .collect();
156    git.run(
157        dir,
158        &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
159    )
160    .stdout
161    .lines()
162    .map(str::trim)
163    .filter(|l| !l.is_empty())
164    .find(|b| !is_integration(b, base_branch) && !merged.contains(*b))
165    .map(str::to_string)
166}
167
168/// SOLO rule helper: the first non-integration (feature) branch on the **remote**,
169/// or `None`.
170fn remote_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
171    git.run(dir, &["ls-remote", "--heads", "origin"])
172        .stdout
173        .lines()
174        .filter_map(|l| {
175            l.split_once("refs/heads/")
176                .map(|(_, b)| b.trim().to_string())
177        })
178        .find(|b| !is_integration(b, base_branch))
179}
180
181/// The outcome of the correct-branch check, rich enough to explain *why* it
182/// failed (surfaced by `logoff -vv`'s `R5 reason` line). Only the two passing
183/// variants make [`BranchVerdict::passed`] true.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum BranchVerdict {
186    /// On a feature branch — actively on your work (passes).
187    OnFeature,
188    /// On an integration branch with nothing pending under the active rule (passes).
189    IntegrationClean,
190    /// Detached HEAD — not on any branch (a risky resting state).
191    DetachedHead,
192    /// Base branch couldn't be resolved, so the check can't certify anything.
193    BaseUnresolved,
194    /// TEAM rule: this local branch isn't merged into base (your unfinished work).
195    LocalUnmerged(String),
196    /// SOLO rule: the remote has this feature branch.
197    RemoteFeature(String),
198}
199
200impl BranchVerdict {
201    /// Did the correct-branch check pass?
202    pub fn passed(&self) -> bool {
203        matches!(
204            self,
205            BranchVerdict::OnFeature | BranchVerdict::IntegrationClean
206        )
207    }
208
209    /// One-line reason for a **failing** verdict (empty string for the passing
210    /// ones) — the text shown after `R5 reason` at `logoff -vv`.
211    pub fn reason(&self) -> String {
212        match self {
213            BranchVerdict::OnFeature | BranchVerdict::IntegrationClean => String::new(),
214            BranchVerdict::DetachedHead => {
215                "detached HEAD — not on any branch (commits are easily lost here)".to_string()
216            }
217            BranchVerdict::BaseUnresolved => {
218                "base branch unresolved — set gkit.baseBranch or fetch origin/main|master"
219                    .to_string()
220            }
221            BranchVerdict::LocalUnmerged(b) => {
222                format!(
223                    "local branch '{b}' is not merged into base (team rule: your unfinished work)"
224                )
225            }
226            BranchVerdict::RemoteFeature(b) => {
227                format!("remote has feature branch '{b}' (solo rule: every remote branch is yours)")
228            }
229        }
230    }
231}
232
233/// 5. Correct branch — a real-life "are you parked safely?" check (see
234///    `docs/commands/logoff.md`), returning a [`BranchVerdict`] that also explains
235///    a failure. Shared preamble for both rules:
236///    - **detached HEAD** → fails (risky resting state; commits easily lost).
237///    - on a **feature** branch (not base/main/master) → passes (actively on work).
238///
239///    On an **integration** branch, exactly one rule runs (see [`BranchRule`]):
240///    `Team` flags a local unmerged feature branch; `Solo` flags any remote
241///    feature branch.
242pub fn branch_verdict(
243    git: &dyn Git,
244    dir: &Path,
245    base_branch: &str,
246    rule: BranchRule,
247) -> BranchVerdict {
248    // Detached HEAD: `symbolic-ref --short HEAD` fails when not on a branch.
249    if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
250        return BranchVerdict::DetachedHead;
251    }
252    let cur = current_branch(git, dir);
253    if !is_integration(&cur, base_branch) {
254        return BranchVerdict::OnFeature; // on a feature branch — fine
255    }
256    match rule {
257        BranchRule::Team => match local_unmerged_feature(git, dir, base_branch) {
258            Some(b) => BranchVerdict::LocalUnmerged(b),
259            None => BranchVerdict::IntegrationClean,
260        },
261        BranchRule::Solo => match remote_feature(git, dir, base_branch) {
262            Some(b) => BranchVerdict::RemoteFeature(b),
263            None => BranchVerdict::IntegrationClean,
264        },
265    }
266}
267
268/// Boolean form of [`branch_verdict`] — for callers that only need pass/fail.
269pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
270    branch_verdict(git, dir, base_branch, rule).passed()
271}
272
273/// The five logoff checks, in run order, with stable `R<n>` ids. Single source of
274/// truth for `logoff -vv` line prefixes and the `logoff -e` catalog.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum RuleId {
277    Committed,
278    AllCommitsPushed,
279    BranchesHaveRemote,
280    NotBehindRemote,
281    CorrectBranch,
282}
283
284impl RuleId {
285    /// All five, in the order they run and print.
286    pub const ALL: [RuleId; 5] = [
287        RuleId::Committed,
288        RuleId::AllCommitsPushed,
289        RuleId::BranchesHaveRemote,
290        RuleId::NotBehindRemote,
291        RuleId::CorrectBranch,
292    ];
293
294    /// 1-based rule number (the `<n>` in `R<n>`).
295    pub fn num(self) -> u8 {
296        match self {
297            RuleId::Committed => 1,
298            RuleId::AllCommitsPushed => 2,
299            RuleId::BranchesHaveRemote => 3,
300            RuleId::NotBehindRemote => 4,
301            RuleId::CorrectBranch => 5,
302        }
303    }
304
305    /// The `R<n>` tag shown as a line prefix at `-vv` and in `-e`.
306    pub fn tag(self) -> String {
307        format!("R{}", self.num())
308    }
309
310    /// The stable, greppable check key — identical to the `-v` output keys.
311    pub fn key(self) -> &'static str {
312        match self {
313            RuleId::Committed => "committed",
314            RuleId::AllCommitsPushed => "all-commits-pushed",
315            RuleId::BranchesHaveRemote => "branches-have-remote",
316            RuleId::NotBehindRemote => "not-behind-remote",
317            RuleId::CorrectBranch => "correct-branch",
318        }
319    }
320
321    /// One-line description for `logoff -e`.
322    pub fn description(self) -> &'static str {
323        match self {
324            RuleId::Committed => {
325                "no uncommitted changes in the working tree (git status -s is empty)"
326            }
327            RuleId::AllCommitsPushed => {
328                "every local commit exists on some remote (nothing unpushed)"
329            }
330            RuleId::BranchesHaveRemote => "every local branch has a remote-tracking counterpart",
331            RuleId::NotBehindRemote => {
332                "the current branch is not behind its remote (no pull needed)"
333            }
334            RuleId::CorrectBranch => {
335                "parked on a safe branch: a feature branch always passes; on an integration \
336                 branch the team rule (default) flags a local branch unmerged into base, while \
337                 the solo rule (gkit.solo=true) flags any remote feature branch; detached HEAD \
338                 or an unresolved base always fail"
339            }
340        }
341    }
342
343    /// Static teaching examples — `(scenario, outcome)` pairs shown after the
344    /// live state in the `-e <N>` deep dive. Illustrative, not derived from any repo.
345    pub fn examples(self) -> &'static [(&'static str, &'static str)] {
346        match self {
347            RuleId::Committed => &[
348                ("clean working tree", "PASS (nothing to commit)"),
349                ("edited file, not committed", "FAIL (commit or stash it)"),
350                ("staged but uncommitted file", "FAIL (still uncommitted)"),
351            ],
352            RuleId::AllCommitsPushed => &[
353                ("every commit pushed", "PASS"),
354                ("local-only commit on any branch", "FAIL (push it)"),
355                ("amended commit not force-pushed", "FAIL (push the rewrite)"),
356            ],
357            RuleId::BranchesHaveRemote => &[
358                ("every local branch tracks a remote", "PASS"),
359                (
360                    "local 'wip' branch never pushed",
361                    "FAIL (push or delete it)",
362                ),
363            ],
364            RuleId::NotBehindRemote => &[
365                ("up to date with origin", "PASS"),
366                ("no matching remote branch", "PASS (nothing to be behind)"),
367                ("origin has commits you don't", "FAIL (pull --rebase)"),
368            ],
369            RuleId::CorrectBranch => &[
370                ("on a feature branch", "PASS (actively on your work)"),
371                (
372                    "on base/main, all local branches merged",
373                    "PASS (parked clean)",
374                ),
375                (
376                    "on base/main, local 'wip' unmerged",
377                    "FAIL (team: unfinished work)",
378                ),
379                (
380                    "on base/main, remote feature branch exists",
381                    "FAIL (solo only)",
382                ),
383                ("detached HEAD", "FAIL (risky resting state)"),
384            ],
385        }
386    }
387
388    /// Look up a rule by its 1-based number (for `-e <N>`).
389    pub fn from_num(n: u8) -> Option<RuleId> {
390        RuleId::ALL.into_iter().find(|r| r.num() == n)
391    }
392}
393
394/// Outcome of all five checks for one repo.
395#[derive(Debug, Clone)]
396pub struct RepoStatus {
397    pub branch: String,
398    pub committed: bool,
399    pub all_commits_pushed: bool,
400    pub branches_have_remote: bool,
401    pub not_behind_remote: bool,
402    pub correct_branch: bool,
403    /// The detailed correct-branch verdict (drives `correct_branch` + the `-vv`
404    /// `R5 reason` line).
405    pub branch_verdict: BranchVerdict,
406    /// The base branch used for the correct-branch check + how it was resolved.
407    /// When `base.name` is `None` (unresolved), `correct_branch` is forced `false`.
408    pub base: ResolvedBase,
409    /// Which correct-branch rule applied (`gkit.solo` selects it). Surfaced in
410    /// verbose only when [`BranchRule::Solo`] (the non-default rule).
411    pub rule: BranchRule,
412    /// Set when the path couldn't be checked at all (missing dir / not a git
413    /// repo). When present, the gate FAILS and `problem` is shown in place of the
414    /// checks — otherwise a non-repo would pass every check vacuously (empty git
415    /// output reads as "nothing pending").
416    pub problem: Option<String>,
417}
418
419impl RepoStatus {
420    /// A path that couldn't be checked (missing dir / not a git repo). Fails the
421    /// gate; `reason` is rendered in place of the per-check results.
422    pub fn unusable(reason: impl Into<String>) -> Self {
423        RepoStatus {
424            branch: String::new(),
425            committed: false,
426            all_commits_pushed: false,
427            branches_have_remote: false,
428            not_behind_remote: false,
429            correct_branch: false,
430            branch_verdict: BranchVerdict::BaseUnresolved,
431            base: ResolvedBase::unresolved(),
432            rule: BranchRule::Team,
433            problem: Some(reason.into()),
434        }
435    }
436
437    /// True only if the repo was checkable AND every check passed.
438    pub fn ok(&self) -> bool {
439        self.problem.is_none()
440            && self.committed
441            && self.all_commits_pushed
442            && self.branches_have_remote
443            && self.not_behind_remote
444            && self.correct_branch
445    }
446
447    /// Pass/fail for a single rule (used by the `-vv` per-rule lines).
448    pub fn rule_passed(&self, rule: RuleId) -> bool {
449        match rule {
450            RuleId::Committed => self.committed,
451            RuleId::AllCommitsPushed => self.all_commits_pushed,
452            RuleId::BranchesHaveRemote => self.branches_have_remote,
453            RuleId::NotBehindRemote => self.not_behind_remote,
454            RuleId::CorrectBranch => self.correct_branch,
455        }
456    }
457
458    /// The reason a rule **failed**, or `None` if it passed — the text shown after
459    /// `R<n> reason` at `logoff -vv`.
460    pub fn failure_reason(&self, rule: RuleId) -> Option<String> {
461        if self.rule_passed(rule) {
462            return None;
463        }
464        Some(match rule {
465            RuleId::Committed => "uncommitted changes in the working tree".to_string(),
466            RuleId::AllCommitsPushed => "local commits are not pushed to any remote".to_string(),
467            RuleId::BranchesHaveRemote => {
468                "a local branch has no remote-tracking counterpart".to_string()
469            }
470            RuleId::NotBehindRemote => "the branch is behind its remote (run git pull)".to_string(),
471            RuleId::CorrectBranch => self.branch_verdict.reason(),
472        })
473    }
474}
475
476/// Run all five checks for a single repo at `dir`. An unresolved base
477/// (`base.name == None`) forces the correct-branch check to fail — the base
478/// couldn't be determined, so we can't certify the right branch is checked out.
479/// `solo` selects the correct-branch rule (`gkit.solo`; see [`BranchRule`]).
480pub fn evaluate(git: &dyn Git, dir: &Path, base: &ResolvedBase, solo: bool) -> RepoStatus {
481    let rule = BranchRule::from_solo(solo);
482    let verdict = match &base.name {
483        Some(b) => branch_verdict(git, dir, b, rule),
484        None => BranchVerdict::BaseUnresolved,
485    };
486    let correct_branch = verdict.passed();
487    RepoStatus {
488        branch: current_branch(git, dir),
489        committed: committed(git, dir),
490        all_commits_pushed: all_commits_pushed(git, dir),
491        branches_have_remote: branches_have_remote(git, dir),
492        not_behind_remote: not_behind_remote(git, dir),
493        correct_branch,
494        branch_verdict: verdict,
495        base: base.clone(),
496        rule,
497        problem: None,
498    }
499}
500
501/// One rule's deep-dive report for `logoff -e <N>`: the live, per-repo state behind
502/// a single check, ready for [`crate::report::print_rule_detail`] to render.
503#[derive(Debug, Clone)]
504pub struct RuleReport {
505    pub id: RuleId,
506    pub passed: bool,
507    /// "This repo now" label/value lines (rule-specific live state).
508    pub facts: Vec<(String, String)>,
509    /// One-line verdict: the failure reason, or a short "PASS …".
510    pub verdict: String,
511}
512
513/// Gather the live, per-repo state behind one rule for the `-e <N>` deep dive.
514/// Reads git for a **single** repo (no submodule recursion, no fetch) and reuses
515/// the same git commands as the corresponding check, so the two can't drift.
516pub fn rule_report(
517    git: &dyn Git,
518    dir: &Path,
519    base: &ResolvedBase,
520    solo: bool,
521    id: RuleId,
522) -> RuleReport {
523    let lines = |out: crate::git::GitOutput| -> Vec<String> {
524        out.stdout
525            .lines()
526            .map(str::trim)
527            .filter(|l| !l.is_empty())
528            .map(str::to_string)
529            .collect()
530    };
531    let or_none = |v: &[String]| {
532        if v.is_empty() {
533            "(none)".to_string()
534        } else {
535            v.join(", ")
536        }
537    };
538
539    let mut facts: Vec<(String, String)> = Vec::new();
540    let (passed, verdict) = match id {
541        RuleId::Committed => {
542            let dirty = lines(git.run(dir, &["status", "-s"]));
543            for f in &dirty {
544                facts.push(("dirty".to_string(), f.clone()));
545            }
546            if dirty.is_empty() {
547                (true, "PASS — working tree clean".to_string())
548            } else {
549                (
550                    false,
551                    format!("FAIL — {} uncommitted change(s)", dirty.len()),
552                )
553            }
554        }
555        RuleId::AllCommitsPushed => {
556            let unpushed = lines(git.run(
557                dir,
558                &["log", "--oneline", "--branches", "--not", "--remotes"],
559            ));
560            for c in &unpushed {
561                facts.push(("unpushed".to_string(), c.clone()));
562            }
563            if unpushed.is_empty() {
564                (true, "PASS — nothing unpushed".to_string())
565            } else {
566                (
567                    false,
568                    format!("FAIL — {} commit(s) not on any remote", unpushed.len()),
569                )
570            }
571        }
572        RuleId::BranchesHaveRemote => {
573            let remotes: HashSet<String> = git
574                .run(
575                    dir,
576                    &[
577                        "for-each-ref",
578                        "--format=%(refname:short)",
579                        "refs/remotes/origin/*",
580                    ],
581                )
582                .stdout
583                .lines()
584                .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
585                .filter(|b| b != "HEAD")
586                .collect();
587            let locals = lines(git.run(
588                dir,
589                &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
590            ));
591            facts.push(("local branches".to_string(), or_none(&locals)));
592            let missing: Vec<String> = locals
593                .iter()
594                .filter(|b| !remotes.contains(*b))
595                .cloned()
596                .collect();
597            if missing.is_empty() {
598                (
599                    true,
600                    "PASS — every local branch tracks a remote".to_string(),
601                )
602            } else {
603                facts.push(("missing remote".to_string(), missing.join(", ")));
604                (
605                    false,
606                    format!("FAIL — no remote for: {}", missing.join(", ")),
607                )
608            }
609        }
610        RuleId::NotBehindRemote => {
611            let cur = current_branch(git, dir);
612            facts.push((
613                "branch".to_string(),
614                if cur.is_empty() {
615                    "(detached)".to_string()
616                } else {
617                    cur.clone()
618                },
619            ));
620            if cur.is_empty() {
621                (true, "PASS — no branch".to_string())
622            } else {
623                let remote_ref = format!("refs/remotes/origin/{cur}");
624                if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
625                    facts.push(("remote branch".to_string(), "none".to_string()));
626                    (true, "PASS — no matching remote branch".to_string())
627                } else {
628                    let range = format!("origin/{cur}...{cur}");
629                    let behind = git
630                        .run(dir, &["rev-list", "--left-right", "--count", &range])
631                        .trimmed()
632                        .split_whitespace()
633                        .next()
634                        .and_then(|s| s.parse::<u64>().ok())
635                        .unwrap_or(0);
636                    facts.push(("behind by".to_string(), behind.to_string()));
637                    if behind == 0 {
638                        (true, "PASS — up to date with origin".to_string())
639                    } else {
640                        (
641                            false,
642                            format!("FAIL — behind by {behind} commit(s); pull --rebase"),
643                        )
644                    }
645                }
646            }
647        }
648        RuleId::CorrectBranch => {
649            let rule = BranchRule::from_solo(solo);
650            let cur = current_branch(git, dir);
651            let verdict_enum = match &base.name {
652                Some(b) => branch_verdict(git, dir, b, rule),
653                None => BranchVerdict::BaseUnresolved,
654            };
655            let locals = lines(git.run(
656                dir,
657                &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
658            ));
659            facts.push((
660                "branch".to_string(),
661                if cur.is_empty() {
662                    "(detached)".to_string()
663                } else {
664                    cur.clone()
665                },
666            ));
667            facts.push(("base".to_string(), base.describe()));
668            facts.push(("rule".to_string(), rule.describe().to_string()));
669            facts.push(("local branches".to_string(), or_none(&locals)));
670            if verdict_enum.passed() {
671                (true, "PASS — parked safely".to_string())
672            } else {
673                (false, format!("FAIL — {}", verdict_enum.reason()))
674            }
675        }
676    };
677    RuleReport {
678        id,
679        passed,
680        facts,
681        verdict,
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::git::test_support::FakeGit;
689    use std::path::Path;
690
691    fn d() -> &'static Path {
692        Path::new("/x")
693    }
694
695    #[test]
696    fn committed_is_true_when_status_clean() {
697        assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
698        assert!(!committed(
699            &FakeGit::new().ok("status -s", " M file.rs"),
700            d()
701        ));
702    }
703
704    #[test]
705    fn pushed_is_true_when_no_unpushed_commits() {
706        let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
707        assert!(all_commits_pushed(&clean, d()));
708        let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
709        assert!(!all_commits_pushed(&dirty, d()));
710    }
711
712    #[test]
713    fn branches_have_remote_checks_every_local() {
714        let ok = FakeGit::new()
715            .ok(
716                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
717                "origin/dev\norigin/main\norigin/HEAD",
718            )
719            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
720        assert!(branches_have_remote(&ok, d()));
721
722        let missing = FakeGit::new()
723            .ok(
724                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
725                "origin/dev",
726            )
727            .ok(
728                "for-each-ref --format=%(refname:short) refs/heads/*",
729                "dev\nlocal-only",
730            );
731        assert!(!branches_have_remote(&missing, d()));
732    }
733
734    #[test]
735    fn not_behind_true_when_no_remote_branch() {
736        let g = FakeGit::new()
737            .ok("rev-parse --abbrev-ref HEAD", "dev")
738            .fail("show-ref --quiet refs/remotes/origin/dev");
739        assert!(not_behind_remote(&g, d()));
740    }
741
742    #[test]
743    fn not_behind_reflects_left_count() {
744        let aligned = FakeGit::new()
745            .ok("rev-parse --abbrev-ref HEAD", "dev")
746            .ok("show-ref --quiet refs/remotes/origin/dev", "")
747            .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
748        assert!(not_behind_remote(&aligned, d()));
749
750        let behind = FakeGit::new()
751            .ok("rev-parse --abbrev-ref HEAD", "dev")
752            .ok("show-ref --quiet refs/remotes/origin/dev", "")
753            .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
754        assert!(!not_behind_remote(&behind, d()));
755    }
756
757    /// Stub the on-integration path: HEAD attached on `cur`, local base `dev`
758    /// exists, with the given local branches + merged set.
759    fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
760        FakeGit::new()
761            .ok("symbolic-ref --short HEAD", cur)
762            .ok("rev-parse --abbrev-ref HEAD", cur)
763            .ok("show-ref --verify --quiet refs/heads/dev", "")
764            .ok("branch --merged dev --format=%(refname:short)", merged)
765            .ok(
766                "for-each-ref --format=%(refname:short) refs/heads/*",
767                local_heads,
768            )
769    }
770
771    #[test]
772    fn correct_branch_detached_head_fails() {
773        // Not on any branch -> risky resting state -> false (both rules; shared preamble).
774        let g = FakeGit::new().fail("symbolic-ref --short HEAD");
775        assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
776        assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
777    }
778
779    #[test]
780    fn correct_branch_on_feature_is_fine() {
781        let g = FakeGit::new()
782            .ok("symbolic-ref --short HEAD", "feature-x")
783            .ok("rev-parse --abbrev-ref HEAD", "feature-x");
784        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
785        assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
786    }
787
788    #[test]
789    fn team_rule_ignores_others_remote_branches() {
790        // On dev; your only LOCAL branch is dev. Others' branches live on the
791        // remote, but the team rule never scans the remote -> PASS.
792        // (The real-life win: the ideal logged-off state isn't flagged.)
793        let g = on_integration("dev", "dev", "dev");
794        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
795    }
796
797    #[test]
798    fn team_rule_flags_local_unmerged_feature() {
799        // On dev with a LOCAL feature branch not merged into dev -> unfinished work.
800        let g = on_integration("dev", "dev\nfeature-x", "dev");
801        assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
802    }
803
804    #[test]
805    fn team_rule_allows_local_merged_feature() {
806        // A local feature branch already merged into dev (just not deleted) -> PASS.
807        let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
808        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
809    }
810
811    #[test]
812    fn solo_rule_flags_remote_feature_branch() {
813        // Solo rule: on dev, but the remote has a feature branch -> FAIL. The team
814        // rule on the same repo (local dev only) -> PASS (mutually exclusive).
815        let g = on_integration("dev", "dev", "dev").ok(
816            "ls-remote --heads origin",
817            "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
818        );
819        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
820        assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
821    }
822
823    #[test]
824    fn solo_rule_passes_when_remote_is_integration_only() {
825        // Solo rule, remote has only dev + main (both integration) -> PASS.
826        let g = on_integration("dev", "dev", "dev").ok(
827            "ls-remote --heads origin",
828            "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
829        );
830        assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
831    }
832
833    #[test]
834    fn evaluate_all_clear() {
835        let g = FakeGit::new()
836            .ok("rev-parse --abbrev-ref HEAD", "dev")
837            .ok("status -s", "")
838            .ok("log --oneline --branches --not --remotes", "")
839            .ok(
840                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
841                "origin/dev",
842            )
843            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
844            .ok("show-ref --quiet refs/remotes/origin/dev", "")
845            .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
846            // correct-branch (default rule): attached on dev, local dev merged.
847            .ok("symbolic-ref --short HEAD", "dev")
848            .ok("show-ref --verify --quiet refs/heads/dev", "")
849            .ok("branch --merged dev --format=%(refname:short)", "dev");
850        let base = ResolvedBase {
851            name: Some("dev".into()),
852            source: crate::config::BaseSource::Config,
853        };
854        let st = evaluate(&g, d(), &base, false);
855        assert!(st.ok(), "expected all-clear, got {st:?}");
856        assert_eq!(st.branch, "dev");
857    }
858
859    #[test]
860    fn unresolved_base_fails_correct_branch() {
861        // Everything else is clean, but the base couldn't be resolved → the gate
862        // fails on correct-branch rather than passing vacuously.
863        let g = FakeGit::new()
864            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
865            .ok("status -s", "")
866            .ok("log --oneline --branches --not --remotes", "")
867            .ok(
868                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
869                "origin/feature-x",
870            )
871            .ok(
872                "for-each-ref --format=%(refname:short) refs/heads/*",
873                "feature-x",
874            )
875            .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
876            .ok(
877                "rev-list --left-right --count origin/feature-x...feature-x",
878                "0\t0",
879            );
880        let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
881        assert!(!st.correct_branch);
882        assert!(!st.ok());
883    }
884
885    // ---- branch_verdict: the reason behind a correct-branch verdict (for -vv) ----
886
887    #[test]
888    fn verdict_detached_head() {
889        let g = FakeGit::new().fail("symbolic-ref --short HEAD");
890        assert_eq!(
891            branch_verdict(&g, d(), "dev", BranchRule::Team),
892            BranchVerdict::DetachedHead
893        );
894    }
895
896    #[test]
897    fn verdict_on_feature_branch() {
898        let g = FakeGit::new()
899            .ok("symbolic-ref --short HEAD", "feature-x")
900            .ok("rev-parse --abbrev-ref HEAD", "feature-x");
901        assert_eq!(
902            branch_verdict(&g, d(), "dev", BranchRule::Team),
903            BranchVerdict::OnFeature
904        );
905    }
906
907    #[test]
908    fn verdict_team_names_the_unmerged_local_branch() {
909        let g = on_integration("dev", "dev\nfeature-x", "dev");
910        assert_eq!(
911            branch_verdict(&g, d(), "dev", BranchRule::Team),
912            BranchVerdict::LocalUnmerged("feature-x".into())
913        );
914    }
915
916    #[test]
917    fn verdict_team_clean_integration() {
918        let g = on_integration("dev", "dev", "dev");
919        assert_eq!(
920            branch_verdict(&g, d(), "dev", BranchRule::Team),
921            BranchVerdict::IntegrationClean
922        );
923    }
924
925    #[test]
926    fn verdict_solo_names_the_remote_feature_branch() {
927        let g = on_integration("dev", "dev", "dev").ok(
928            "ls-remote --heads origin",
929            "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
930        );
931        assert_eq!(
932            branch_verdict(&g, d(), "dev", BranchRule::Solo),
933            BranchVerdict::RemoteFeature("alice-x".into())
934        );
935    }
936
937    #[test]
938    fn verdict_reason_is_empty_only_when_passing() {
939        assert!(BranchVerdict::OnFeature.reason().is_empty());
940        assert!(BranchVerdict::IntegrationClean.reason().is_empty());
941        assert!(BranchVerdict::DetachedHead
942            .reason()
943            .contains("detached HEAD"));
944        assert!(BranchVerdict::BaseUnresolved
945            .reason()
946            .contains("unresolved"));
947        assert!(BranchVerdict::LocalUnmerged("x".into())
948            .reason()
949            .contains("'x'"));
950        assert!(BranchVerdict::RemoteFeature("x".into())
951            .reason()
952            .contains("'x'"));
953    }
954
955    #[test]
956    fn evaluate_unresolved_sets_base_unresolved_verdict() {
957        // The forced-fail path records *why* (for the -vv reason line), not a bare
958        // false.
959        let g = FakeGit::new()
960            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
961            .ok("status -s", "")
962            .ok("log --oneline --branches --not --remotes", "")
963            .ok(
964                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
965                "origin/feature-x",
966            )
967            .ok(
968                "for-each-ref --format=%(refname:short) refs/heads/*",
969                "feature-x",
970            )
971            .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
972            .ok(
973                "rev-list --left-right --count origin/feature-x...feature-x",
974                "0\t0",
975            );
976        let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
977        assert_eq!(st.branch_verdict, BranchVerdict::BaseUnresolved);
978        assert_eq!(
979            st.failure_reason(RuleId::CorrectBranch),
980            Some(BranchVerdict::BaseUnresolved.reason())
981        );
982    }
983
984    // ---- RuleId catalog + per-rule reasons (for -vv and -e) ----
985
986    #[test]
987    fn rule_ids_are_stable_and_round_trip() {
988        let nums: Vec<u8> = RuleId::ALL.iter().map(|r| r.num()).collect();
989        assert_eq!(nums, vec![1, 2, 3, 4, 5]);
990        for r in RuleId::ALL {
991            assert_eq!(RuleId::from_num(r.num()), Some(r));
992            assert_eq!(r.tag(), format!("R{}", r.num()));
993            assert!(!r.key().is_empty() && !r.description().is_empty());
994        }
995        assert_eq!(RuleId::from_num(0), None);
996        assert_eq!(RuleId::from_num(6), None);
997        assert_eq!(RuleId::CorrectBranch.key(), "correct-branch");
998    }
999
1000    #[test]
1001    fn failure_reason_is_some_only_for_failing_rules() {
1002        // A repo failing only committed: that rule reports a reason, the rest don't.
1003        let g = FakeGit::new()
1004            .ok("rev-parse --abbrev-ref HEAD", "dev")
1005            .ok("status -s", " M file.txt") // dirty -> committed fails
1006            .ok("log --oneline --branches --not --remotes", "")
1007            .ok(
1008                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1009                "origin/dev",
1010            )
1011            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
1012            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1013            .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
1014            .ok("symbolic-ref --short HEAD", "dev")
1015            .ok("show-ref --verify --quiet refs/heads/dev", "")
1016            .ok("branch --merged dev --format=%(refname:short)", "dev");
1017        let base = ResolvedBase {
1018            name: Some("dev".into()),
1019            source: crate::config::BaseSource::Config,
1020        };
1021        let st = evaluate(&g, d(), &base, false);
1022        assert!(st.failure_reason(RuleId::Committed).is_some());
1023        assert!(st.failure_reason(RuleId::AllCommitsPushed).is_none());
1024        assert!(st.failure_reason(RuleId::CorrectBranch).is_none());
1025    }
1026
1027    // ---- RuleId::examples + rule_report (the `-e <N>` deep dive) ----
1028
1029    #[test]
1030    fn every_rule_has_examples() {
1031        for r in RuleId::ALL {
1032            assert!(!r.examples().is_empty(), "{:?} has no examples", r);
1033        }
1034    }
1035
1036    fn dev_base() -> ResolvedBase {
1037        ResolvedBase {
1038            name: Some("dev".into()),
1039            source: crate::config::BaseSource::Config,
1040        }
1041    }
1042
1043    #[test]
1044    fn rule_report_r5_names_unmerged_branch_and_lists_state() {
1045        // On dev (integration) with a local unmerged feature -> FAIL, naming it.
1046        let g = on_integration("dev", "dev\nfeature-x", "dev");
1047        let rep = rule_report(&g, d(), &dev_base(), false, RuleId::CorrectBranch);
1048        assert!(!rep.passed);
1049        assert!(
1050            rep.verdict.contains("feature-x"),
1051            "verdict: {}",
1052            rep.verdict
1053        );
1054        // "This repo now" surfaces branch, base, rule, and the local branches.
1055        let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1056        assert_eq!(facts.get("branch").map(String::as_str), Some("dev"));
1057        assert!(facts.contains_key("base"));
1058        assert!(facts.get("local branches").unwrap().contains("feature-x"));
1059    }
1060
1061    #[test]
1062    fn rule_report_r1_lists_dirty_files() {
1063        let g = FakeGit::new()
1064            .ok("rev-parse --abbrev-ref HEAD", "dev")
1065            .ok("status -s", " M a.txt\n?? b.txt");
1066        let rep = rule_report(&g, d(), &dev_base(), false, RuleId::Committed);
1067        assert!(!rep.passed);
1068        let dirty: Vec<&str> = rep
1069            .facts
1070            .iter()
1071            .filter(|(l, _)| l == "dirty")
1072            .map(|(_, v)| v.as_str())
1073            .collect();
1074        assert_eq!(dirty, vec!["M a.txt", "?? b.txt"]);
1075    }
1076
1077    #[test]
1078    fn rule_report_r4_shows_behind_count() {
1079        let g = FakeGit::new()
1080            .ok("rev-parse --abbrev-ref HEAD", "dev")
1081            .ok("show-ref --quiet refs/remotes/origin/dev", "")
1082            .ok("rev-list --left-right --count origin/dev...dev", "3\t0");
1083        let rep = rule_report(&g, d(), &dev_base(), false, RuleId::NotBehindRemote);
1084        assert!(!rep.passed);
1085        let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1086        assert_eq!(facts.get("behind by").map(String::as_str), Some("3"));
1087        assert!(
1088            rep.verdict.contains("behind by 3"),
1089            "verdict: {}",
1090            rep.verdict
1091        );
1092    }
1093}