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}