Skip to main content

gkit_core/
fixsub.rs

1//! Fix submodule metadata over an existing tree — a generalized port of the zsh
2//! `fixSubModuleMeta`. Two universal, idempotent fixes applied recursively to every
3//! initialized submodule:
4//!
5//! 1. **Branch-switch (un-detach):** `git submodule update --init` checks out the
6//!    pinned commit in **detached HEAD** (the gitlink is a SHA, not a branch; it
7//!    ignores `.gitmodules branch=`). `fixsub` switches each submodule onto its
8//!    declared `.gitmodules` branch — reusing clone's `SUBMODULE_SWITCH`.
9//! 2. **Identity inherit (set-if-unset):** a submodule added after `gkit clone`
10//!    misses the identity stamp. `fixsub` copies the **root** repo's local
11//!    `user.name`/`user.email` into each submodule that has **no local identity** —
12//!    never clobbering a deliberately-different one.
13//!
14//! (Optionally `direnv allow` each submodule with an `.envrc`, mirroring clone's
15//! direnv built-in, after the branch flip re-points the working tree.)
16//!
17//! **Project-specific** config (e.g. `core.hooksPath`) is intentionally NOT here —
18//! that belongs in the conf's `post-clone`, re-applied by `gkit stamp`. `fixsub` only
19//! does universal git/submodule hygiene.
20
21use crate::clone::{sh_squote, SUBMODULE_SWITCH};
22use crate::git::Git;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, PartialEq, Eq)]
26pub enum Outcome {
27    /// Ran the submodule fixes (the tree had submodules).
28    Fixed,
29    /// Nothing to do (no submodules / no recursion target).
30    Skipped,
31    /// Not a git repo, or a `submodule foreach` failed.
32    Failed(String),
33}
34
35#[derive(Debug)]
36pub struct FixsubReport {
37    pub root: PathBuf,
38    pub outcome: Outcome,
39}
40
41/// The `submodule foreach` body that inherits the root's identity **only where the
42/// submodule lacks its own** — for each of `user.name`/`user.email` that the root
43/// has, emit `git config --local user.X >/dev/null 2>&1 || git config user.X '<val>'`
44/// (values single-quoted via [`sh_squote`]). `None` when the root has no identity to
45/// inherit (nothing to do).
46pub fn inherit_identity_cmd(root_name: Option<&str>, root_email: Option<&str>) -> Option<String> {
47    let parts: Vec<String> = [("user.name", root_name), ("user.email", root_email)]
48        .into_iter()
49        .filter_map(|(k, v)| {
50            v.map(|v| {
51                format!(
52                    "git config --local {k} >/dev/null 2>&1 || git config {k} {}",
53                    sh_squote(v)
54                )
55            })
56        })
57        .collect();
58    (!parts.is_empty()).then(|| parts.join("; "))
59}
60
61/// Is `dir` inside a git work tree? (Same probe `stamp`/the gate use.)
62fn is_git_repo(git: &dyn Git, dir: &Path) -> bool {
63    let r = git.run(dir, &["rev-parse", "--is-inside-work-tree"]);
64    r.success && r.trimmed() == "true"
65}
66
67/// A repo's local config value (`--local --get`), trimmed, or `None`.
68fn local_config(git: &dyn Git, dir: &Path, key: &str) -> Option<String> {
69    let o = git.run(dir, &["config", "--local", "--get", key]);
70    let v = o.trimmed();
71    (o.success && !v.is_empty()).then(|| v.to_string())
72}
73
74/// Branch-switch + identity-inherit (+ optional `direnv allow`) over the submodule
75/// tree rooted at `root`. Prints every git command; idempotent. `dry_run` prints the
76/// plan and runs nothing.
77pub fn fixsub<G: Git>(git: &G, root: &Path, dry_run: bool, direnv: bool) -> FixsubReport {
78    let mk = |outcome| FixsubReport {
79        root: root.to_path_buf(),
80        outcome,
81    };
82    if !is_git_repo(git, root) {
83        return mk(Outcome::Failed("not a git repository".into()));
84    }
85
86    // Build one `submodule foreach --recursive` body: un-detach, then inherit
87    // identity where unset, then (optionally) re-trust .envrc. `foreach` visits only
88    // initialized submodules and recurses — matching the zsh skip + recursion.
89    let name = local_config(git, root, "user.name");
90    let email = local_config(git, root, "user.email");
91    let mut body = SUBMODULE_SWITCH.to_string();
92    if let Some(id) = inherit_identity_cmd(name.as_deref(), email.as_deref()) {
93        body.push_str("; ");
94        body.push_str(&id);
95    }
96    if direnv {
97        body.push_str("; [ -f .envrc ] && direnv allow . 2>/dev/null || true");
98    }
99
100    println!("+ git submodule foreach --recursive {body}");
101    if dry_run {
102        return mk(Outcome::Fixed);
103    }
104    let out = git.run(
105        root,
106        &["submodule", "foreach", "--recursive", body.as_str()],
107    );
108    if !out.success {
109        return mk(Outcome::Failed(format!(
110            "submodule foreach failed: {}",
111            out.stderr.trim()
112        )));
113    }
114    mk(Outcome::Fixed)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::git::test_support::FakeGit;
121    use std::path::Path;
122
123    #[test]
124    fn inherit_identity_cmd_set_if_unset_and_quotes() {
125        // both fields → two guarded, single-quoted clauses joined with `; `
126        assert_eq!(
127            inherit_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
128            Some(
129                "git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane Dev'; \
130                 git config --local user.email >/dev/null 2>&1 || git config user.email 'jane@acme.com'"
131            )
132        );
133        // only one field → just that clause
134        assert_eq!(
135            inherit_identity_cmd(Some("Jane"), None).as_deref(),
136            Some("git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane'")
137        );
138        // neither → None (caller skips identity)
139        assert_eq!(inherit_identity_cmd(None, None), None);
140        // embedded single quote is escaped so `sh` can't break out
141        assert_eq!(
142            inherit_identity_cmd(Some("O'Brien"), None).as_deref(),
143            Some(
144                r"git config --local user.name >/dev/null 2>&1 || git config user.name 'O'\''Brien'"
145            )
146        );
147    }
148
149    #[test]
150    fn dry_run_runs_no_git_mutations() {
151        // is_git_repo true, but dry_run → fixsub must NOT call `submodule foreach`
152        // (a default FakeGit would fail that unknown call). Returns Fixed.
153        let git = FakeGit::new().ok("rev-parse --is-inside-work-tree", "true");
154        let r = fixsub(&git, Path::new("/r"), true, true);
155        assert_eq!(r.outcome, Outcome::Fixed);
156    }
157
158    #[test]
159    fn non_git_root_fails() {
160        let git = FakeGit::new().fail("rev-parse --is-inside-work-tree");
161        let r = fixsub(&git, Path::new("/nope"), false, true);
162        assert!(matches!(r.outcome, Outcome::Failed(_)));
163    }
164}