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::git::Git;
6use std::collections::HashSet;
7use std::path::Path;
8
9/// Current checked-out branch (`git rev-parse --abbrev-ref HEAD`); "HEAD" if detached.
10pub fn current_branch(git: &dyn Git, dir: &Path) -> String {
11    git.run(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
12        .trimmed()
13        .to_string()
14}
15
16/// 1. Nothing uncommitted: `git status -s` is empty.
17pub fn committed(git: &dyn Git, dir: &Path) -> bool {
18    git.run(dir, &["status", "-s"]).trimmed().is_empty()
19}
20
21/// 2. Every local commit exists on some remote:
22///    `git log --oneline --branches --not --remotes` is empty.
23pub fn all_commits_pushed(git: &dyn Git, dir: &Path) -> bool {
24    git.run(
25        dir,
26        &["log", "--oneline", "--branches", "--not", "--remotes"],
27    )
28    .trimmed()
29    .is_empty()
30}
31
32/// 3. Every local branch has a remote counterpart (matched by short name).
33pub fn branches_have_remote(git: &dyn Git, dir: &Path) -> bool {
34    let remotes: HashSet<String> = git
35        .run(
36            dir,
37            &[
38                "for-each-ref",
39                "--format=%(refname:short)",
40                "refs/remotes/origin/*",
41            ],
42        )
43        .stdout
44        .lines()
45        .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
46        .filter(|b| b != "HEAD")
47        .collect();
48
49    git.run(
50        dir,
51        &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
52    )
53    .stdout
54    .lines()
55    .map(str::trim)
56    .filter(|l| !l.is_empty())
57    .all(|local| remotes.contains(local))
58}
59
60/// 4. Current branch is not behind `origin/<branch>` (nothing to pull).
61///    If there's no matching remote branch, there's nothing to be behind → true.
62pub fn not_behind_remote(git: &dyn Git, dir: &Path) -> bool {
63    let cur = current_branch(git, dir);
64    if cur.is_empty() {
65        return true;
66    }
67    let remote_ref = format!("refs/remotes/origin/{cur}");
68    if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
69        return true;
70    }
71    let range = format!("origin/{cur}...{cur}");
72    let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
73    // Output is "<behind>\t<ahead>": left = commits in origin/cur not in cur.
74    out.trimmed()
75        .split_whitespace()
76        .next()
77        .and_then(|s| s.parse::<u64>().ok())
78        .map(|behind| behind == 0)
79        .unwrap_or(true)
80}
81
82/// True for "integration" branches that are not feature work: the configured
83/// base branch plus the universal git defaults `main`/`master`.
84fn is_integration(branch: &str, base_branch: &str) -> bool {
85    branch == base_branch || branch == "main" || branch == "master"
86}
87
88/// 5. Correct branch: NOT ok only if the remote has "feature" branches
89///    (any head that is not an integration branch) AND we're currently sitting on
90///    an integration branch (base / main / master).
91pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
92    let cur = current_branch(git, dir);
93    if !is_integration(&cur, base_branch) {
94        return true; // on a feature branch — fine
95    }
96    let has_feature = git
97        .run(dir, &["ls-remote", "--heads", "origin"])
98        .stdout
99        .lines()
100        .filter_map(|l| {
101            l.split_once("refs/heads/")
102                .map(|(_, b)| b.trim().to_string())
103        })
104        .any(|b| !is_integration(&b, base_branch));
105    !has_feature
106}
107
108/// Outcome of all five checks for one repo.
109#[derive(Debug, Clone)]
110pub struct RepoStatus {
111    pub branch: String,
112    pub committed: bool,
113    pub all_commits_pushed: bool,
114    pub branches_have_remote: bool,
115    pub not_behind_remote: bool,
116    pub correct_branch: bool,
117    /// Set when the path couldn't be checked at all (missing dir / not a git
118    /// repo). When present, the gate FAILS and `problem` is shown in place of the
119    /// checks — otherwise a non-repo would pass every check vacuously (empty git
120    /// output reads as "nothing pending").
121    pub problem: Option<String>,
122}
123
124impl RepoStatus {
125    /// A path that couldn't be checked (missing dir / not a git repo). Fails the
126    /// gate; `reason` is rendered in place of the per-check results.
127    pub fn unusable(reason: impl Into<String>) -> Self {
128        RepoStatus {
129            branch: String::new(),
130            committed: false,
131            all_commits_pushed: false,
132            branches_have_remote: false,
133            not_behind_remote: false,
134            correct_branch: false,
135            problem: Some(reason.into()),
136        }
137    }
138
139    /// True only if the repo was checkable AND every check passed.
140    pub fn ok(&self) -> bool {
141        self.problem.is_none()
142            && self.committed
143            && self.all_commits_pushed
144            && self.branches_have_remote
145            && self.not_behind_remote
146            && self.correct_branch
147    }
148}
149
150/// Run all five checks for a single repo at `dir`.
151pub fn evaluate(git: &dyn Git, dir: &Path, base_branch: &str) -> RepoStatus {
152    RepoStatus {
153        branch: current_branch(git, dir),
154        committed: committed(git, dir),
155        all_commits_pushed: all_commits_pushed(git, dir),
156        branches_have_remote: branches_have_remote(git, dir),
157        not_behind_remote: not_behind_remote(git, dir),
158        correct_branch: correct_branch(git, dir, base_branch),
159        problem: None,
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::git::test_support::FakeGit;
167    use std::path::Path;
168
169    fn d() -> &'static Path {
170        Path::new("/x")
171    }
172
173    #[test]
174    fn committed_is_true_when_status_clean() {
175        assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
176        assert!(!committed(
177            &FakeGit::new().ok("status -s", " M file.rs"),
178            d()
179        ));
180    }
181
182    #[test]
183    fn pushed_is_true_when_no_unpushed_commits() {
184        let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
185        assert!(all_commits_pushed(&clean, d()));
186        let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
187        assert!(!all_commits_pushed(&dirty, d()));
188    }
189
190    #[test]
191    fn branches_have_remote_checks_every_local() {
192        let ok = FakeGit::new()
193            .ok(
194                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
195                "origin/dev\norigin/main\norigin/HEAD",
196            )
197            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
198        assert!(branches_have_remote(&ok, d()));
199
200        let missing = FakeGit::new()
201            .ok(
202                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
203                "origin/dev",
204            )
205            .ok(
206                "for-each-ref --format=%(refname:short) refs/heads/*",
207                "dev\nlocal-only",
208            );
209        assert!(!branches_have_remote(&missing, d()));
210    }
211
212    #[test]
213    fn not_behind_true_when_no_remote_branch() {
214        let g = FakeGit::new()
215            .ok("rev-parse --abbrev-ref HEAD", "dev")
216            .fail("show-ref --quiet refs/remotes/origin/dev");
217        assert!(not_behind_remote(&g, d()));
218    }
219
220    #[test]
221    fn not_behind_reflects_left_count() {
222        let aligned = FakeGit::new()
223            .ok("rev-parse --abbrev-ref HEAD", "dev")
224            .ok("show-ref --quiet refs/remotes/origin/dev", "")
225            .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
226        assert!(not_behind_remote(&aligned, d()));
227
228        let behind = FakeGit::new()
229            .ok("rev-parse --abbrev-ref HEAD", "dev")
230            .ok("show-ref --quiet refs/remotes/origin/dev", "")
231            .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
232        assert!(!not_behind_remote(&behind, d()));
233    }
234
235    #[test]
236    fn correct_branch_only_flags_base_with_features() {
237        // On base (dev) AND remote has a feature branch -> wrong branch.
238        let on_base_with_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "dev").ok(
239            "ls-remote --heads origin",
240            "aaa\trefs/heads/dev\nbbb\trefs/heads/feature-x",
241        );
242        assert!(!correct_branch(&on_base_with_feature, d(), "dev"));
243
244        // On base (dev), no feature branches -> fine.
245        let on_base_no_feature = FakeGit::new()
246            .ok("rev-parse --abbrev-ref HEAD", "dev")
247            .ok("ls-remote --heads origin", "aaa\trefs/heads/dev");
248        assert!(correct_branch(&on_base_no_feature, d(), "dev"));
249
250        // On a feature branch -> always fine, regardless of remote.
251        let on_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "feature-x");
252        assert!(correct_branch(&on_feature, d(), "dev"));
253
254        // On dev, remote has dev + main (both integration) -> NOT a feature -> fine.
255        // (This is the cosp/manage-cms case that was wrongly flagged before.)
256        let dev_plus_main = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "dev").ok(
257            "ls-remote --heads origin",
258            "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
259        );
260        assert!(correct_branch(&dev_plus_main, d(), "dev"));
261
262        // On main (an integration branch) with a real feature present -> flagged.
263        let on_main_with_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "main").ok(
264            "ls-remote --heads origin",
265            "aaa\trefs/heads/main\nbbb\trefs/heads/feature-y",
266        );
267        assert!(!correct_branch(&on_main_with_feature, d(), "dev"));
268    }
269
270    #[test]
271    fn evaluate_all_clear() {
272        let g = FakeGit::new()
273            .ok("rev-parse --abbrev-ref HEAD", "dev")
274            .ok("status -s", "")
275            .ok("log --oneline --branches --not --remotes", "")
276            .ok(
277                "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
278                "origin/dev",
279            )
280            .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
281            .ok("show-ref --quiet refs/remotes/origin/dev", "")
282            .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
283            .ok("ls-remote --heads origin", "aaa\trefs/heads/dev");
284        let st = evaluate(&g, d(), "dev");
285        assert!(st.ok(), "expected all-clear, got {st:?}");
286        assert_eq!(st.branch, "dev");
287    }
288}