Skip to main content

gkit_core/
submodules.rs

1//! Submodule traversal + parallel evaluation with deterministic output order.
2//!
3//! Mirrors the zsh recursion (`gitCoreLib.sh` `isEverythingCheckedIn` →
4//! `git submodule foreach`): each repo's submodules are checked before the repo
5//! itself, so the emit order is **post-order DFS** (children first, superproject
6//! last), siblings in submodule-config order. Checks run in parallel for speed,
7//! but results are buffered into fixed slots so output never depends on which
8//! thread finishes first.
9
10use crate::checks::{self, RepoStatus};
11use crate::git::Git;
12use std::path::{Path, PathBuf};
13
14/// One evaluated repo (or submodule).
15pub struct Entry {
16    pub path: PathBuf,
17    pub status: RepoStatus,
18}
19
20/// Direct submodule paths (absolute) of `dir`, in `git submodule status` order.
21/// Uninitialized submodules (status `-`) are skipped — nothing to check.
22fn direct_submodules(git: &dyn Git, dir: &Path) -> Vec<PathBuf> {
23    git.run(dir, &["submodule", "status"])
24        .stdout
25        .lines()
26        .filter_map(|line| {
27            let status = line.chars().next()?;
28            if status == '-' {
29                return None; // uninitialized
30            }
31            // Drop the 1-char status column; remainder is "<sha> <path> (<describe>)".
32            let path = line[1..].split_whitespace().nth(1)?;
33            Some(dir.join(path))
34        })
35        .collect()
36}
37
38/// All repos to check rooted at `root`, in post-order (submodules before parent,
39/// `root` last).
40/// Public: repos rooted at `root` in post-order DFS (submodules before parent,
41/// `root` last). Reused by `stmb` to walk the same tree.
42pub fn repo_paths(git: &dyn Git, root: &Path) -> Vec<PathBuf> {
43    collect_repos(git, root)
44}
45
46/// Is `dir` inside a git work tree? (`git rev-parse --is-inside-work-tree`
47/// prints `true` and exits 0). False for a missing dir or a plain directory.
48fn is_work_tree(git: &dyn Git, dir: &Path) -> bool {
49    let r = git.run(dir, &["rev-parse", "--is-inside-work-tree"]);
50    r.success && r.trimmed() == "true"
51}
52
53fn collect_repos(git: &dyn Git, root: &Path) -> Vec<PathBuf> {
54    fn visit(git: &dyn Git, dir: &Path, order: &mut Vec<PathBuf>) {
55        for sub in direct_submodules(git, dir) {
56            visit(git, &sub, order);
57        }
58        order.push(dir.to_path_buf());
59    }
60    let mut order = Vec::new();
61    visit(git, root, &mut order);
62    order
63}
64
65/// Evaluate `root` and all (recursive) submodules. Checks run in parallel; the
66/// returned Vec is in the fixed post-order DFS order.
67///
68/// `base_override` (the CLI `--base-branch`) applies only to the root; each
69/// submodule resolves its own base (`gkit.baseBranch`, then remote
70/// `origin/main`/`origin/master`) and its own `gkit.solo` / `gkit.allowDiverged`.
71/// Every repo — root and submodules — is fetched before checking (when `fetch`,
72/// i.e. unless `--no-fetch`), so the behind checks (R4 not-behind-remote, R6
73/// not-behind-base) compare against fresh remote-tracking refs rather than stale
74/// ones. (The zsh fetched only submodules; the root's `origin/<branch>` could go
75/// stale and make R4/R6 a false green — fail-closed requires a fresh fetch.)
76pub fn evaluate_tree<G: Git + Sync>(
77    git: &G,
78    root: &Path,
79    base_override: Option<&str>,
80    fetch: bool,
81) -> Vec<Entry> {
82    // Guard the root: a non-repo (or missing) dir would otherwise pass every check
83    // vacuously. Only the root needs this — submodules come from a real repo's
84    // `git submodule status`, so they're already work trees.
85    if !is_work_tree(git, root) {
86        let reason = if root.exists() {
87            "not a git repository"
88        } else {
89            "no such directory"
90        };
91        return vec![Entry {
92            path: root.to_path_buf(),
93            status: RepoStatus::unusable(reason),
94        }];
95    }
96    let repos = collect_repos(git, root);
97    let last = repos.len().saturating_sub(1);
98    let mut slots: Vec<Option<RepoStatus>> = (0..repos.len()).map(|_| None).collect();
99
100    std::thread::scope(|scope| {
101        let mut handles = Vec::with_capacity(repos.len());
102        for (i, path) in repos.iter().enumerate() {
103            let is_root = i == last;
104            let ovr = if is_root { base_override } else { None };
105            let do_fetch = fetch; // fetch every repo (root + submodules) so R4/R6 compare against fresh remote refs
106            let path = path.clone();
107            let handle = scope.spawn(move || {
108                if do_fetch {
109                    let _ = git.run(&path, &["fetch", "--quiet"]);
110                    let _ = git.run(&path, &["remote", "prune", "origin"]);
111                }
112                let base = crate::config::resolve_base(git, &path, ovr);
113                let solo = crate::config::resolve_solo(git, &path);
114                let allow_diverged = crate::config::resolve_allow_diverged(git, &path);
115                checks::evaluate(git, &path, &base, solo, allow_diverged)
116            });
117            handles.push((i, handle));
118        }
119        for (i, handle) in handles {
120            slots[i] = Some(handle.join().expect("gkit: a check thread panicked"));
121        }
122    });
123
124    repos
125        .into_iter()
126        .zip(slots)
127        .map(|(path, status)| Entry {
128            path,
129            status: status.expect("every slot filled"),
130        })
131        .collect()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::git::test_support::FakeGit;
138
139    #[test]
140    fn collect_repos_is_post_order_dfs() {
141        // /r has submodules a, b ; b has submodule c. Expect children before parents.
142        let git = FakeGit::new()
143            .ok_in("/r", "submodule status", " sha a (x)\n sha b (x)")
144            .ok_in("/r/a", "submodule status", "")
145            .ok_in("/r/b", "submodule status", " sha c (x)")
146            .ok_in("/r/b/c", "submodule status", "");
147        let order = collect_repos(&git, Path::new("/r"));
148        // Normalize separators: `Path::join` yields `\` on Windows, `/` elsewhere.
149        let got: Vec<String> = order
150            .iter()
151            .map(|p| p.display().to_string().replace('\\', "/"))
152            .collect();
153        assert_eq!(got, vec!["/r/a", "/r/b/c", "/r/b", "/r"]);
154    }
155
156    #[test]
157    fn non_repo_root_is_flagged_not_passed() {
158        // A root that isn't a work tree (rev-parse fails) must yield ONE entry that
159        // fails the gate — not a vacuous pass.
160        let git = FakeGit::new().fail("rev-parse --is-inside-work-tree");
161        let entries = evaluate_tree(&git, Path::new("/not/a/repo"), None, false);
162        assert_eq!(entries.len(), 1);
163        assert!(!entries[0].status.ok());
164        assert!(entries[0].status.problem.is_some());
165    }
166
167    #[test]
168    fn skips_uninitialized_submodules() {
169        let git = FakeGit::new().ok_in("/r", "submodule status", "-sha a (x)\n sha b (x)\n");
170        let subs = direct_submodules(&git, Path::new("/r"));
171        let got: Vec<String> = subs
172            .iter()
173            .map(|p| p.display().to_string().replace('\\', "/"))
174            .collect();
175        assert_eq!(got, vec!["/r/b"]); // 'a' (uninitialized, '-') skipped
176    }
177}