Skip to main content

wt/git/
submodule.rs

1//! Git submodule detection and initialization (issue #50).
2//!
3//! `git submodule status` is a sanctioned subprocess read (spec §4); the parsers
4//! live in [`porcelain`](super::porcelain). Initialization is a mutating/network
5//! operation, so it goes through the `git` CLI. Both run against a worktree
6//! directory: a freshly created worktree (or one switched to a branch that adds
7//! submodules) reports its submodules as uninitialized until `update --init`
8//! populates them.
9//!
10//! # Why a new worktree re-clones everything
11//!
12//! A linked worktree does **not** share the superproject's submodule object
13//! stores. Git resolves a linked worktree's submodule gitdir to
14//! `$GIT_COMMON_DIR/worktrees/<id>/modules/<name>`, not the shared
15//! `$GIT_COMMON_DIR/modules/<name>`, so a plain `update --init --recursive` in a
16//! new worktree performs a full network clone of every submodule even though
17//! byte-identical objects already sit on the same disk.
18//!
19//! Two consequences show up here: enumeration has to recurse to be honest about
20//! nested submodules, and `git worktree remove` refuses any worktree that has a
21//! populated submodule.
22
23use std::path::Path;
24
25use crate::error::{Error, Result};
26use crate::git::cli::GitCli;
27use crate::git::porcelain::{Submodule, parse_gitmodules, parse_submodule_status};
28
29pub(crate) mod reattach;
30pub(crate) mod seed;
31
32/// Returns the paths of submodules that are defined but not yet initialized in
33/// `worktree_dir` (the `-` marker of `git submodule status`). Best-effort: a repo
34/// with no submodules, or a directory where the command cannot run, yields an
35/// empty list rather than an error, so callers can treat "no submodules" and
36/// "could not tell" alike.
37///
38/// Recursive, so nested submodules count too. Note that `--recursive` can only
39/// descend into submodules that are already initialized: an uninitialized parent
40/// hides its children until it is populated. The count is therefore honest about
41/// what is knowable now, not a prediction of the eventual total.
42pub fn uninitialized(git: &dyn GitCli, worktree_dir: &Path) -> Result<Vec<String>> {
43    let output = git.run_raw(worktree_dir, &["submodule", "status", "--recursive"])?;
44    if !output.success {
45        return Ok(Vec::new());
46    }
47    Ok(parse_submodule_status(&output.stdout)
48        .into_iter()
49        .filter(|s| s.is_uninitialized())
50        .map(|s| s.path)
51        .collect())
52}
53
54/// Whether `worktree_dir` currently has at least one *populated* submodule.
55///
56/// This is the condition `git worktree remove` refuses on
57/// (`fatal: working trees containing submodules cannot be moved or removed`),
58/// so removal consults it to decide whether git needs forcing. Best-effort, like
59/// [`uninitialized`]: "could not tell" reports `false`.
60pub(crate) fn any_initialized(git: &dyn GitCli, worktree_dir: &Path) -> Result<bool> {
61    let output = git.run_raw(worktree_dir, &["submodule", "status", "--recursive"])?;
62    if !output.success {
63        return Ok(false);
64    }
65    Ok(parse_submodule_status(&output.stdout)
66        .into_iter()
67        .any(|s| !s.is_uninitialized()))
68}
69
70/// Whether `value` is a plain relative path — non-empty, and made only of
71/// normal components, so it can never climb out of a directory it is joined
72/// onto.
73///
74/// Submodule names and paths come from `.gitmodules`, which is repository
75/// content and therefore untrusted, and callers here join them onto real
76/// directories. Git has rejected `..` in submodule names and paths since 2.20,
77/// but nothing in that check is reachable from
78/// [`git config -f .gitmodules`](declared) — it reads the file as plain config —
79/// so this code must not lean on it.
80pub(crate) fn is_plain_relative(value: &str) -> bool {
81    use std::path::Component;
82
83    !value.is_empty()
84        && Path::new(value)
85            .components()
86            .all(|c| matches!(c, Component::Normal(_)))
87}
88
89/// Returns the submodules declared in `worktree_dir`'s `.gitmodules`, in
90/// declaration order.
91///
92/// Reads the file through `git config -f`, so git's own config parser handles
93/// quoting, comments and line continuations rather than a hand-rolled reader.
94/// Best-effort: no `.gitmodules` (or an unreadable one) yields an empty list,
95/// which is the same shape as "this directory has no submodules".
96///
97/// Entries whose name or path is not a [plain relative path](is_plain_relative)
98/// are dropped here, at the boundary where the untrusted file is read, so no
99/// caller downstream can join one onto a directory. Git refuses such a
100/// submodule anyway, so dropping it costs nothing: the stock
101/// `update --init --recursive` pass reports it exactly as it would have.
102pub(crate) fn declared(git: &dyn GitCli, worktree_dir: &Path) -> Result<Vec<Submodule>> {
103    if !worktree_dir.join(".gitmodules").is_file() {
104        return Ok(Vec::new());
105    }
106    let output = git.run_raw(
107        worktree_dir,
108        &[
109            "config",
110            "-f",
111            ".gitmodules",
112            "-z",
113            "--get-regexp",
114            "^submodule\\..*",
115        ],
116    )?;
117    if !output.success {
118        return Ok(Vec::new());
119    }
120    Ok(parse_gitmodules(&output.stdout)
121        .into_iter()
122        .filter(|s| {
123            let ok = is_plain_relative(&s.name) && is_plain_relative(&s.path);
124            if !ok {
125                tracing::debug!(
126                    name = %s.name,
127                    path = %s.path,
128                    "ignoring a .gitmodules entry that is not a plain relative path"
129                );
130            }
131            ok
132        })
133        .collect())
134}
135
136/// Initializes and updates all submodules in `worktree_dir`, recursively
137/// (`git submodule update --init --recursive`). Propagates a subprocess error;
138/// callers decide whether that is fatal.
139///
140/// Parallelism is deliberately not a `wt` option: git already honours its own
141/// `submodule.fetchJobs` here, and a second knob for the same thing would only
142/// apply on the paths `wt` happens to own.
143pub fn update_init(git: &dyn GitCli, worktree_dir: &Path) -> Result<()> {
144    git.run(
145        worktree_dir,
146        &["submodule", "update", "--init", "--recursive"],
147    )?;
148    Ok(())
149}
150
151/// Populates every submodule in `worktree_dir`, optionally seeding from the
152/// repository's own local mirrors first.
153///
154/// The ordering is the correctness contract:
155///
156/// 1. Seed what has a local mirror (near-instant, hardlinked, no network).
157/// 2. [`sync`] the URLs back to `.gitmodules`, undoing the mirror `origin` the
158///    seed clones left behind.
159/// 3. Run the stock [`update_init`] pass, which decides the result: anything
160///    seeding skipped is cloned normally, anything it got wrong is corrected.
161///
162/// So seeding is only ever an accelerator. If it fails outright the end state is
163/// byte-identical to not seeding at all, just slower. The returned
164/// [`SeedReport`](seed::SeedReport) is informational; the `Result` reflects only
165/// the stock pass.
166pub(crate) fn populate(
167    git: &dyn GitCli,
168    worktree_dir: &Path,
169    seed_from_mirrors: bool,
170) -> (seed::SeedReport, Result<()>) {
171    let span = tracing::info_span!("submodules", worktree = %worktree_dir.display());
172    let _guard = span.enter();
173
174    let mut report = seed::SeedReport::default();
175    if seed_from_mirrors {
176        match linked_worktree_common_dir(git, worktree_dir) {
177            Ok(Some(common)) => report = seed::seed_from_mirrors(git, worktree_dir, &common),
178            // The primary worktree already resolves its submodules to the shared
179            // `.git/modules`, so there is nothing to seed from and nothing to
180            // save — seeding is purely a linked-worktree concern.
181            Ok(None) => tracing::debug!("primary worktree; submodules already share the mirrors"),
182            Err(e) => {
183                tracing::debug!(error = %e, "could not resolve the git dirs; not seeding");
184            }
185        }
186        // Restore the real upstreams before anything can fetch. A failure here
187        // must stop the reconcile pass, which would otherwise fetch through a
188        // mirror path and could persist it as the submodule's origin.
189        if !report.is_empty()
190            && let Err(e) = sync(git, worktree_dir)
191        {
192            return (report, Err(e));
193        }
194    }
195    let result = update_init(git, worktree_dir);
196    (report, result)
197}
198
199/// The repository's common git directory — the shared `.git` holding
200/// `modules/` — but only when `worktree_dir` is a *linked* worktree.
201///
202/// `None` means `worktree_dir` is the primary worktree, where the private and
203/// common git dirs are the same path and submodules already live in the shared
204/// mirrors.
205fn linked_worktree_common_dir(
206    git: &dyn GitCli,
207    worktree_dir: &Path,
208) -> Result<Option<std::path::PathBuf>> {
209    let out = git.run(
210        worktree_dir,
211        &[
212            "rev-parse",
213            "--path-format=absolute",
214            "--git-dir",
215            "--git-common-dir",
216        ],
217    )?;
218    let mut lines = out.lines();
219    let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
220        return Err(Error::operation(
221            "could not resolve the repository git dirs",
222        ));
223    };
224    if git_dir.trim() == common.trim() {
225        return Ok(None);
226    }
227    Ok(Some(std::path::PathBuf::from(common.trim())))
228}
229
230/// Re-reads every submodule URL from `.gitmodules` into the worktree's config
231/// and into each populated submodule's `remote.origin.url`
232/// (`git submodule sync --recursive`).
233///
234/// Seeding clones a submodule from a local mirror, which leaves that mirror path
235/// as the submodule's `origin`. This restores the real upstream, so it must run
236/// before anything that could contact a remote. Propagates a subprocess error:
237/// leaving mirror paths as origins would be a silently wrong repository.
238pub(crate) fn sync(git: &dyn GitCli, worktree_dir: &Path) -> Result<()> {
239    git.run(worktree_dir, &["submodule", "sync", "--recursive"])?;
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::git::cli::RealGit;
247    use crate::testutil::TestRepo;
248
249    #[test]
250    fn no_submodules_yields_empty() {
251        let repo = TestRepo::init();
252        assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
253    }
254
255    #[test]
256    fn reports_uninitialized_submodule() {
257        let repo = TestRepo::init();
258        repo.add_submodule("libs/sub");
259        // After `add` the submodule is initialized; deinit makes it report `-`.
260        repo.deinit_submodule("libs/sub");
261        let pending = uninitialized(&RealGit, repo.root()).unwrap();
262        assert_eq!(pending, vec!["libs/sub".to_string()]);
263    }
264
265    #[test]
266    fn update_init_populates_submodule() {
267        let repo = TestRepo::init();
268        repo.add_submodule("libs/sub");
269        repo.deinit_submodule("libs/sub");
270        // Sanity: empty before, populated after (reuses .git/modules, no clone).
271        assert!(!repo.root().join("libs/sub/sub.txt").exists());
272        update_init(&RealGit, repo.root()).unwrap();
273        assert!(repo.root().join("libs/sub/sub.txt").exists());
274        assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
275    }
276
277    #[test]
278    fn is_plain_relative_accepts_only_normal_components() {
279        assert!(is_plain_relative("libs/sub"));
280        assert!(is_plain_relative("a.b.c"));
281        assert!(!is_plain_relative(""));
282        assert!(!is_plain_relative(".."));
283        assert!(!is_plain_relative("../outside"));
284        assert!(!is_plain_relative("libs/../../outside"));
285        assert!(!is_plain_relative("/etc"));
286        assert!(!is_plain_relative("./libs"));
287    }
288
289    #[test]
290    fn declared_drops_entries_that_could_escape_the_repository() {
291        // `.gitmodules` is untrusted repository content, and `git config -f`
292        // reads it as plain config — none of git's own submodule name/path
293        // validation is in play. Callers join both fields onto real directories
294        // (`.git/modules/<name>`, `<worktree>/<path>`), so a traversing entry
295        // must not reach them.
296        let repo = TestRepo::init();
297        repo.write(
298            ".gitmodules",
299            concat!(
300                "[submodule \"good\"]\n\tpath = libs/good\n\turl = ../g\n",
301                "[submodule \"../../../../tmp/evil\"]\n\tpath = libs/good2\n\turl = ../e\n",
302                "[submodule \"escaper\"]\n\tpath = ..\n\turl = ../e\n",
303                "[submodule \"absolute\"]\n\tpath = /etc/passwd\n\turl = ../e\n",
304            ),
305        );
306        repo.commit_all("declare hostile submodules");
307
308        let subs = declared(&RealGit, repo.root()).unwrap();
309        assert_eq!(
310            subs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
311            vec!["good"],
312            "a traversing name or path survived the .gitmodules read"
313        );
314    }
315
316    #[test]
317    fn uninitialized_is_empty_outside_a_repo() {
318        // `git submodule status` fails (non-success) in a non-repo dir; the
319        // best-effort contract returns an empty list rather than erroring.
320        let dir = tempfile::tempdir().unwrap();
321        assert!(uninitialized(&RealGit, dir.path()).unwrap().is_empty());
322    }
323
324    #[test]
325    fn uninitialized_recurses_into_nested_submodules() {
326        let repo = TestRepo::init();
327        repo.add_nested_submodule("libs/sub", "deep");
328        // Everything is populated after the fixture builds it.
329        assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
330        // Deinit only the *nested* one. A non-recursive `submodule status`
331        // reports nothing here, which is the undercount this recursion fixes.
332        repo.git(&["-C", "libs/sub", "submodule", "deinit", "-q", "-f", "deep"]);
333        let pending = uninitialized(&RealGit, repo.root()).unwrap();
334        assert_eq!(pending, vec!["libs/sub/deep".to_string()]);
335    }
336
337    #[test]
338    fn any_initialized_tracks_population() {
339        let repo = TestRepo::init();
340        assert!(!any_initialized(&RealGit, repo.root()).unwrap());
341        repo.add_submodule("libs/sub");
342        assert!(any_initialized(&RealGit, repo.root()).unwrap());
343        repo.deinit_submodule("libs/sub");
344        assert!(!any_initialized(&RealGit, repo.root()).unwrap());
345    }
346}