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: is there a **local** non-integration branch with commits not
139/// merged into base? (Can't determine the base ref → not flagged.)
140fn local_unmerged_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
141    let base_ref = base_ref_for(git, dir, base_branch);
142    let merged = git.run(
143        dir,
144        &["branch", "--merged", &base_ref, "--format=%(refname:short)"],
145    );
146    if !merged.success {
147        return false;
148    }
149    let merged: HashSet<&str> = merged
150        .stdout
151        .lines()
152        .map(str::trim)
153        .filter(|l| !l.is_empty())
154        .collect();
155    git.run(
156        dir,
157        &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
158    )
159    .stdout
160    .lines()
161    .map(str::trim)
162    .filter(|l| !l.is_empty())
163    .any(|b| !is_integration(b, base_branch) && !merged.contains(b))
164}
165
166/// SOLO rule helper: does the **remote** have any non-integration (feature) branch?
167fn remote_has_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
168    git.run(dir, &["ls-remote", "--heads", "origin"])
169        .stdout
170        .lines()
171        .filter_map(|l| {
172            l.split_once("refs/heads/")
173                .map(|(_, b)| b.trim().to_string())
174        })
175        .any(|b| !is_integration(&b, base_branch))
176}
177
178/// 5. Correct branch — a real-life "are you parked safely?" check (see
179///    `docs/commands/logoff.md`). Shared preamble for both rules:
180///    - **detached HEAD** → false (risky resting state; commits easily lost).
181///    - on a **feature** branch (not base/main/master) → true (actively on work).
182///
183///    On an **integration** branch, exactly one rule runs (see [`BranchRule`]):
184///    `Team` flags a local unmerged feature branch; `Solo` flags any remote
185///    feature branch.
186pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
187    // Detached HEAD: `symbolic-ref --short HEAD` fails when not on a branch.
188    if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
189        return false;
190    }
191    let cur = current_branch(git, dir);
192    if !is_integration(&cur, base_branch) {
193        return true; // on a feature branch — fine
194    }
195    match rule {
196        BranchRule::Team => !local_unmerged_feature(git, dir, base_branch),
197        BranchRule::Solo => !remote_has_feature(git, dir, base_branch),
198    }
199}
200
201/// Outcome of all five checks for one repo.
202#[derive(Debug, Clone)]
203pub struct RepoStatus {
204    pub branch: String,
205    pub committed: bool,
206    pub all_commits_pushed: bool,
207    pub branches_have_remote: bool,
208    pub not_behind_remote: bool,
209    pub correct_branch: bool,
210    /// The base branch used for the correct-branch check + how it was resolved.
211    /// When `base.name` is `None` (unresolved), `correct_branch` is forced `false`.
212    pub base: ResolvedBase,
213    /// Which correct-branch rule applied (`gkit.solo` selects it). Surfaced in
214    /// verbose only when [`BranchRule::Solo`] (the non-default rule).
215    pub rule: BranchRule,
216    /// Set when the path couldn't be checked at all (missing dir / not a git
217    /// repo). When present, the gate FAILS and `problem` is shown in place of the
218    /// checks — otherwise a non-repo would pass every check vacuously (empty git
219    /// output reads as "nothing pending").
220    pub problem: Option<String>,
221}
222
223impl RepoStatus {
224    /// A path that couldn't be checked (missing dir / not a git repo). Fails the
225    /// gate; `reason` is rendered in place of the per-check results.
226    pub fn unusable(reason: impl Into<String>) -> Self {
227        RepoStatus {
228            branch: String::new(),
229            committed: false,
230            all_commits_pushed: false,
231            branches_have_remote: false,
232            not_behind_remote: false,
233            correct_branch: false,
234            base: ResolvedBase::unresolved(),
235            rule: BranchRule::Team,
236            problem: Some(reason.into()),
237        }
238    }
239
240    /// True only if the repo was checkable AND every check passed.
241    pub fn ok(&self) -> bool {
242        self.problem.is_none()
243            && self.committed
244            && self.all_commits_pushed
245            && self.branches_have_remote
246            && self.not_behind_remote
247            && self.correct_branch
248    }
249}
250
251/// Run all five checks for a single repo at `dir`. An unresolved base
252/// (`base.name == None`) forces the correct-branch check to fail — the base
253/// couldn't be determined, so we can't certify the right branch is checked out.
254/// `solo` selects the correct-branch rule (`gkit.solo`; see [`BranchRule`]).
255pub fn evaluate(git: &dyn Git, dir: &Path, base: &ResolvedBase, solo: bool) -> RepoStatus {
256    let rule = BranchRule::from_solo(solo);
257    let correct_branch = match &base.name {
258        Some(b) => correct_branch(git, dir, b, rule),
259        None => false,
260    };
261    RepoStatus {
262        branch: current_branch(git, dir),
263        committed: committed(git, dir),
264        all_commits_pushed: all_commits_pushed(git, dir),
265        branches_have_remote: branches_have_remote(git, dir),
266        not_behind_remote: not_behind_remote(git, dir),
267        correct_branch,
268        base: base.clone(),
269        rule,
270        problem: None,
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::git::test_support::FakeGit;
278    use std::path::Path;
279
280    fn d() -> &'static Path {
281        Path::new("/x")
282    }
283
284    #[test]
285    fn committed_is_true_when_status_clean() {
286        assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
287        assert!(!committed(
288            &FakeGit::new().ok("status -s", " M file.rs"),
289            d()
290        ));
291    }
292
293    #[test]
294    fn pushed_is_true_when_no_unpushed_commits() {
295        let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
296        assert!(all_commits_pushed(&clean, d()));
297        let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
298        assert!(!all_commits_pushed(&dirty, d()));
299    }
300
301    #[test]
302    fn branches_have_remote_checks_every_local() {
303        let ok = FakeGit::new()
304            .ok(
305                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
306                "origin/dev\norigin/main\norigin/HEAD",
307            )
308            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
309        assert!(branches_have_remote(&ok, d()));
310
311        let missing = FakeGit::new()
312            .ok(
313                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
314                "origin/dev",
315            )
316            .ok(
317                "for-each-ref --format=%(refname:short) refs/heads/*",
318                "dev\nlocal-only",
319            );
320        assert!(!branches_have_remote(&missing, d()));
321    }
322
323    #[test]
324    fn not_behind_true_when_no_remote_branch() {
325        let g = FakeGit::new()
326            .ok("rev-parse --abbrev-ref HEAD", "dev")
327            .fail("show-ref --quiet refs/remotes/origin/dev");
328        assert!(not_behind_remote(&g, d()));
329    }
330
331    #[test]
332    fn not_behind_reflects_left_count() {
333        let aligned = FakeGit::new()
334            .ok("rev-parse --abbrev-ref HEAD", "dev")
335            .ok("show-ref --quiet refs/remotes/origin/dev", "")
336            .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
337        assert!(not_behind_remote(&aligned, d()));
338
339        let behind = FakeGit::new()
340            .ok("rev-parse --abbrev-ref HEAD", "dev")
341            .ok("show-ref --quiet refs/remotes/origin/dev", "")
342            .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
343        assert!(!not_behind_remote(&behind, d()));
344    }
345
346    /// Stub the on-integration path: HEAD attached on `cur`, local base `dev`
347    /// exists, with the given local branches + merged set.
348    fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
349        FakeGit::new()
350            .ok("symbolic-ref --short HEAD", cur)
351            .ok("rev-parse --abbrev-ref HEAD", cur)
352            .ok("show-ref --verify --quiet refs/heads/dev", "")
353            .ok("branch --merged dev --format=%(refname:short)", merged)
354            .ok(
355                "for-each-ref --format=%(refname:short) refs/heads/*",
356                local_heads,
357            )
358    }
359
360    #[test]
361    fn correct_branch_detached_head_fails() {
362        // Not on any branch -> risky resting state -> false (both rules; shared preamble).
363        let g = FakeGit::new().fail("symbolic-ref --short HEAD");
364        assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
365        assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
366    }
367
368    #[test]
369    fn correct_branch_on_feature_is_fine() {
370        let g = FakeGit::new()
371            .ok("symbolic-ref --short HEAD", "feature-x")
372            .ok("rev-parse --abbrev-ref HEAD", "feature-x");
373        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
374        assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
375    }
376
377    #[test]
378    fn team_rule_ignores_others_remote_branches() {
379        // On dev; your only LOCAL branch is dev. Others' branches live on the
380        // remote, but the team rule never scans the remote -> PASS.
381        // (The real-life win: the ideal logged-off state isn't flagged.)
382        let g = on_integration("dev", "dev", "dev");
383        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
384    }
385
386    #[test]
387    fn team_rule_flags_local_unmerged_feature() {
388        // On dev with a LOCAL feature branch not merged into dev -> unfinished work.
389        let g = on_integration("dev", "dev\nfeature-x", "dev");
390        assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
391    }
392
393    #[test]
394    fn team_rule_allows_local_merged_feature() {
395        // A local feature branch already merged into dev (just not deleted) -> PASS.
396        let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
397        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
398    }
399
400    #[test]
401    fn solo_rule_flags_remote_feature_branch() {
402        // Solo rule: on dev, but the remote has a feature branch -> FAIL. The team
403        // rule on the same repo (local dev only) -> PASS (mutually exclusive).
404        let g = on_integration("dev", "dev", "dev").ok(
405            "ls-remote --heads origin",
406            "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
407        );
408        assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
409        assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
410    }
411
412    #[test]
413    fn solo_rule_passes_when_remote_is_integration_only() {
414        // Solo rule, remote has only dev + main (both integration) -> PASS.
415        let g = on_integration("dev", "dev", "dev").ok(
416            "ls-remote --heads origin",
417            "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
418        );
419        assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
420    }
421
422    #[test]
423    fn evaluate_all_clear() {
424        let g = FakeGit::new()
425            .ok("rev-parse --abbrev-ref HEAD", "dev")
426            .ok("status -s", "")
427            .ok("log --oneline --branches --not --remotes", "")
428            .ok(
429                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
430                "origin/dev",
431            )
432            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
433            .ok("show-ref --quiet refs/remotes/origin/dev", "")
434            .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
435            // correct-branch (default rule): attached on dev, local dev merged.
436            .ok("symbolic-ref --short HEAD", "dev")
437            .ok("show-ref --verify --quiet refs/heads/dev", "")
438            .ok("branch --merged dev --format=%(refname:short)", "dev");
439        let base = ResolvedBase {
440            name: Some("dev".into()),
441            source: crate::config::BaseSource::Config,
442        };
443        let st = evaluate(&g, d(), &base, false);
444        assert!(st.ok(), "expected all-clear, got {st:?}");
445        assert_eq!(st.branch, "dev");
446    }
447
448    #[test]
449    fn unresolved_base_fails_correct_branch() {
450        // Everything else is clean, but the base couldn't be resolved → the gate
451        // fails on correct-branch rather than passing vacuously.
452        let g = FakeGit::new()
453            .ok("rev-parse --abbrev-ref HEAD", "feature-x")
454            .ok("status -s", "")
455            .ok("log --oneline --branches --not --remotes", "")
456            .ok(
457                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
458                "origin/feature-x",
459            )
460            .ok(
461                "for-each-ref --format=%(refname:short) refs/heads/*",
462                "feature-x",
463            )
464            .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
465            .ok(
466                "rev-list --left-right --count origin/feature-x...feature-x",
467                "0\t0",
468            );
469        let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
470        assert!(!st.correct_branch);
471        assert!(!st.ok());
472    }
473}