Skip to main content

gkit_core/
fixsub.rs

1//! Fix submodule metadata over an existing tree — a generalized port of the zsh
2//! `fixSubModuleMeta`. Applied recursively to every initialized submodule:
3//!
4//! 1. **Un-detach (branch reconcile):** `git submodule update --init` checks out the
5//!    pinned commit in **detached HEAD** (the gitlink is a SHA, not a branch). `fixsub`
6//!    switches a submodule onto its declared `.gitmodules` branch **only when it is in
7//!    detached HEAD** — the genuine post-clone case. A submodule deliberately on a
8//!    *named* branch (active feature work) is **left alone and the divergence is
9//!    reported**, never silently switched. Every outcome is printed per submodule
10//!    (no swallowed `git switch` — gkit's "every side effect is visible" rule). There is
11//!    deliberately **no `--force`/`--switch-all`**: bulk-yanking feature branches is the
12//!    footgun we removed from `stmb`; to move one submodule by hand, `git switch` in it.
13//! 2. **Identity inherit (set-if-unset):** a submodule added after `gkit clone` misses
14//!    the identity stamp. `fixsub` copies the **root** repo's local `user.name`/`user.email`
15//!    into each submodule that has **no local identity** — never clobbering a deliberately-
16//!    different one.
17//!
18//! (Optionally `direnv allow` each submodule with an `.envrc`, after the branch reconcile
19//! re-points the working tree.)
20//!
21//! **Project-specific** config (e.g. `core.hooksPath`) is intentionally NOT here — that
22//! belongs in the conf's `post-clone`, re-applied by `gkit stamp`. `fixsub` only does
23//! universal git/submodule hygiene. (Note: `clone` still uses `clone::SUBMODULE_SWITCH`
24//! to un-detach right after `submodule update --init`, where everything is detached and a
25//! switch is unambiguously correct — that path is unchanged.)
26
27use crate::clone::sh_squote;
28use crate::config::current_branch_opt;
29use crate::git::Git;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, PartialEq, Eq)]
33pub enum Outcome {
34    /// Ran the submodule fixes (the tree had submodules).
35    Fixed,
36    /// Nothing to do (no initialized submodules).
37    Skipped,
38    /// Not a git repo, a `submodule foreach` failed, or a submodule couldn't be
39    /// un-detached onto its `.gitmodules` branch.
40    Failed(String),
41}
42
43#[derive(Debug)]
44pub struct FixsubReport {
45    pub root: PathBuf,
46    pub outcome: Outcome,
47}
48
49/// What to do with one submodule's checkout, decided purely from its current branch
50/// (`None` = detached HEAD) and its declared `.gitmodules` branch. This is the whole
51/// "un-detach only, never yank a named branch" policy in one testable function.
52#[derive(Debug, PartialEq, Eq)]
53pub enum SwitchPlan {
54    /// Detached HEAD → switch onto the configured branch.
55    Switch { to: String },
56    /// Already on the configured branch → nothing to do.
57    Keep { branch: String },
58    /// On a *different* named branch → report the divergence, do NOT switch.
59    Diverged { on: String, configured: String },
60}
61
62/// Decide the branch action for a submodule. **Only** an un-detach (detached →
63/// configured) ever mutates; a named branch is kept (if it matches) or reported as
64/// diverged (if it doesn't) — fixsub never moves a named branch.
65pub fn decide_switch(current: Option<&str>, configured: &str) -> SwitchPlan {
66    match current {
67        None => SwitchPlan::Switch {
68            to: configured.to_string(),
69        },
70        Some(b) if b == configured => SwitchPlan::Keep {
71            branch: b.to_string(),
72        },
73        Some(b) => SwitchPlan::Diverged {
74            on: b.to_string(),
75            configured: configured.to_string(),
76        },
77    }
78}
79
80/// The `submodule foreach` body that inherits the root's identity **only where the
81/// submodule lacks its own** — for each of `user.name`/`user.email` that the root
82/// has, emit `git config --local user.X >/dev/null 2>&1 || git config user.X '<val>'`
83/// (values single-quoted via [`sh_squote`]). `None` when the root has no identity to
84/// inherit (nothing to do).
85pub fn inherit_identity_cmd(root_name: Option<&str>, root_email: Option<&str>) -> Option<String> {
86    let parts: Vec<String> = [("user.name", root_name), ("user.email", root_email)]
87        .into_iter()
88        .filter_map(|(k, v)| {
89            v.map(|v| {
90                format!(
91                    "git config --local {k} >/dev/null 2>&1 || git config {k} {}",
92                    sh_squote(v)
93                )
94            })
95        })
96        .collect();
97    (!parts.is_empty()).then(|| parts.join("; "))
98}
99
100/// Is `dir` inside a git work tree? (Same probe `stamp`/the gate use.)
101fn is_git_repo(git: &dyn Git, dir: &Path) -> bool {
102    let r = git.run(dir, &["rev-parse", "--is-inside-work-tree"]);
103    r.success && r.trimmed() == "true"
104}
105
106/// A repo's local config value (`--local --get`), trimmed, or `None`.
107fn local_config(git: &dyn Git, dir: &Path, key: &str) -> Option<String> {
108    let o = git.run(dir, &["config", "--local", "--get", key]);
109    let v = o.trimmed();
110    (o.success && !v.is_empty()).then(|| v.to_string())
111}
112
113/// One initialized submodule, with the facts needed to resolve its `.gitmodules`
114/// branch: `displaypath` (relative to `root`, for locating it) + `name` + `toplevel`
115/// (its immediate parent superproject, whose `.gitmodules` declares the branch).
116struct SubInfo {
117    displaypath: String,
118    name: String,
119    toplevel: String,
120}
121
122/// Enumerate every initialized submodule recursively, with name + parent toplevel.
123/// One `submodule foreach --recursive` that just `printf`s tab-separated fields; git's
124/// own `Entering '…'` lines (no tabs) are naturally filtered out by the field split, so
125/// this is robust without relying on `--quiet`.
126fn submodule_infos(git: &dyn Git, root: &Path) -> Result<Vec<SubInfo>, String> {
127    let out = git.run(
128        root,
129        &[
130            "submodule",
131            "foreach",
132            "--recursive",
133            r#"printf '%s\t%s\t%s\n' "$displaypath" "$name" "$toplevel""#,
134        ],
135    );
136    if !out.success {
137        return Err(format!(
138            "submodule foreach (enumerate) failed: {}",
139            out.stderr.trim()
140        ));
141    }
142    Ok(out
143        .stdout
144        .lines()
145        .filter_map(|line| {
146            let mut it = line.splitn(3, '\t');
147            let displaypath = it.next()?.trim();
148            let name = it.next()?;
149            let toplevel = it.next()?;
150            (!displaypath.is_empty()).then(|| SubInfo {
151                displaypath: displaypath.to_string(),
152                name: name.to_string(),
153                toplevel: toplevel.to_string(),
154            })
155        })
156        .collect())
157}
158
159/// The branch a submodule declares in its parent's `.gitmodules`, defaulting to `main`
160/// (git's own default for an unspecified submodule branch).
161fn configured_branch(git: &dyn Git, root: &Path, toplevel: &str, name: &str) -> String {
162    let gitmodules = format!("{toplevel}/.gitmodules");
163    let key = format!("submodule.{name}.branch");
164    let o = git.run(root, &["config", "-f", &gitmodules, "--get", &key]);
165    let v = o.trimmed();
166    if o.success && !v.is_empty() {
167        v.to_string()
168    } else {
169        "main".to_string()
170    }
171}
172
173/// Un-detach + identity-inherit (+ optional `direnv allow`) over the submodule tree
174/// rooted at `root`. Prints a per-submodule outcome for the branch reconcile and the
175/// git commands it runs; idempotent. `dry_run` prints the plan and mutates nothing.
176pub fn fixsub<G: Git>(git: &G, root: &Path, dry_run: bool, direnv: bool) -> FixsubReport {
177    let mk = |outcome| FixsubReport {
178        root: root.to_path_buf(),
179        outcome,
180    };
181    if !is_git_repo(git, root) {
182        return mk(Outcome::Failed("not a git repository".into()));
183    }
184
185    let subs = match submodule_infos(git, root) {
186        Ok(s) => s,
187        Err(e) => return mk(Outcome::Failed(e)),
188    };
189    if subs.is_empty() {
190        println!("  no initialized submodules — nothing to do");
191        return mk(Outcome::Skipped);
192    }
193
194    // Phase 1 — branch reconcile: un-detach detached heads onto their `.gitmodules`
195    // branch; keep / report (never yank) named branches. Every outcome printed.
196    let mut switch_failures = 0u32;
197    for s in &subs {
198        let dir = root.join(&s.displaypath);
199        let configured = configured_branch(git, root, &s.toplevel, &s.name);
200        let current = current_branch_opt(git, &dir);
201        match decide_switch(current.as_deref(), &configured) {
202            SwitchPlan::Switch { to } => {
203                if dry_run {
204                    println!(
205                        "  {}: detached HEAD → would switch to '{to}'",
206                        s.displaypath
207                    );
208                } else {
209                    println!("  {}: detached HEAD → switching to '{to}'", s.displaypath);
210                    println!("    + git switch {to}");
211                    let o = git.run(&dir, &["switch", &to]);
212                    if !o.success {
213                        println!("    ! switch to '{to}' FAILED: {}", o.stderr.trim());
214                        switch_failures += 1;
215                    }
216                }
217            }
218            SwitchPlan::Keep { branch } => {
219                println!(
220                    "  {}: on '{branch}' (matches .gitmodules) — kept",
221                    s.displaypath
222                );
223            }
224            SwitchPlan::Diverged { on, configured } => {
225                println!(
226                    "  {}: on '{on}'; .gitmodules tracks '{configured}' — left as-is \
227                     (merge it into '{configured}', or update .gitmodules)",
228                    s.displaypath
229                );
230            }
231        }
232    }
233
234    // Phase 2 — identity-inherit (set-if-unset) + optional direnv, over the tree. Runs
235    // after the reconcile so `direnv allow` sees the (possibly) re-pointed working tree.
236    let name = local_config(git, root, "user.name");
237    let email = local_config(git, root, "user.email");
238    let mut parts: Vec<String> = Vec::new();
239    if let Some(id) = inherit_identity_cmd(name.as_deref(), email.as_deref()) {
240        parts.push(id);
241    }
242    if direnv {
243        parts.push("[ -f .envrc ] && direnv allow . 2>/dev/null || true".to_string());
244    }
245    if !parts.is_empty() {
246        let body = parts.join("; ");
247        println!("+ git submodule foreach --recursive {body}");
248        if !dry_run {
249            let out = git.run(
250                root,
251                &["submodule", "foreach", "--recursive", body.as_str()],
252            );
253            if !out.success {
254                return mk(Outcome::Failed(format!(
255                    "submodule foreach failed: {}",
256                    out.stderr.trim()
257                )));
258            }
259        }
260    }
261
262    if switch_failures > 0 {
263        return mk(Outcome::Failed(format!(
264            "{switch_failures} submodule(s) could not be switched onto their .gitmodules branch (see output above)"
265        )));
266    }
267    mk(Outcome::Fixed)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::git::test_support::FakeGit;
274    use std::path::Path;
275
276    #[test]
277    fn decide_switch_un_detaches_only_and_reports_divergence() {
278        // detached → switch to configured
279        assert_eq!(
280            decide_switch(None, "main"),
281            SwitchPlan::Switch { to: "main".into() }
282        );
283        // on the configured branch → keep
284        assert_eq!(
285            decide_switch(Some("main"), "main"),
286            SwitchPlan::Keep {
287                branch: "main".into()
288            }
289        );
290        // on a different named branch → diverged, NOT switched
291        assert_eq!(
292            decide_switch(Some("feature-x"), "main"),
293            SwitchPlan::Diverged {
294                on: "feature-x".into(),
295                configured: "main".into()
296            }
297        );
298    }
299
300    #[test]
301    fn inherit_identity_cmd_set_if_unset_and_quotes() {
302        // both fields → two guarded, single-quoted clauses joined with `; `
303        assert_eq!(
304            inherit_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
305            Some(
306                "git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane Dev'; \
307                 git config --local user.email >/dev/null 2>&1 || git config user.email 'jane@acme.com'"
308            )
309        );
310        // only one field → just that clause
311        assert_eq!(
312            inherit_identity_cmd(Some("Jane"), None).as_deref(),
313            Some("git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane'")
314        );
315        // neither → None (caller skips identity)
316        assert_eq!(inherit_identity_cmd(None, None), None);
317        // embedded single quote is escaped so `sh` can't break out
318        assert_eq!(
319            inherit_identity_cmd(Some("O'Brien"), None).as_deref(),
320            Some(
321                r"git config --local user.name >/dev/null 2>&1 || git config user.name 'O'\''Brien'"
322            )
323        );
324    }
325
326    #[test]
327    fn dry_run_runs_no_git_mutations() {
328        // is_git_repo true + no submodules → fixsub queries (enumerate) but must NOT
329        // call `git switch` / the mutating foreach. Returns Skipped.
330        let git = FakeGit::new()
331            .ok("rev-parse --is-inside-work-tree", "true")
332            .ok(
333                r#"submodule foreach --recursive printf '%s\t%s\t%s\n' "$displaypath" "$name" "$toplevel""#,
334                "",
335            );
336        let r = fixsub(&git, Path::new("/r"), true, true);
337        assert_eq!(r.outcome, Outcome::Skipped);
338    }
339
340    #[test]
341    fn non_git_root_fails() {
342        let git = FakeGit::new().fail("rev-parse --is-inside-work-tree");
343        let r = fixsub(&git, Path::new("/nope"), false, true);
344        assert!(matches!(r.outcome, Outcome::Failed(_)));
345    }
346}