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 parser
4//! lives 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
10use std::path::Path;
11
12use crate::error::Result;
13use crate::git::cli::GitCli;
14use crate::git::porcelain::parse_submodule_status;
15
16/// Returns the paths of submodules that are defined but not yet initialized in
17/// `worktree_dir` (the `-` marker of `git submodule status`). Best-effort: a repo
18/// with no submodules, or a directory where the command cannot run, yields an
19/// empty list rather than an error, so callers can treat "no submodules" and
20/// "could not tell" alike.
21pub fn uninitialized(git: &dyn GitCli, worktree_dir: &Path) -> Result<Vec<String>> {
22    let output = git.run_raw(worktree_dir, &["submodule", "status"])?;
23    if !output.success {
24        return Ok(Vec::new());
25    }
26    Ok(parse_submodule_status(&output.stdout)
27        .into_iter()
28        .filter(|s| s.is_uninitialized())
29        .map(|s| s.path)
30        .collect())
31}
32
33/// Initializes and updates all submodules in `worktree_dir`, recursively
34/// (`git submodule update --init --recursive`). Propagates a subprocess error;
35/// callers decide whether that is fatal.
36pub fn update_init(git: &dyn GitCli, worktree_dir: &Path) -> Result<()> {
37    git.run(
38        worktree_dir,
39        &["submodule", "update", "--init", "--recursive"],
40    )?;
41    Ok(())
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use crate::git::cli::RealGit;
48    use crate::testutil::TestRepo;
49
50    #[test]
51    fn no_submodules_yields_empty() {
52        let repo = TestRepo::init();
53        assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
54    }
55
56    #[test]
57    fn reports_uninitialized_submodule() {
58        let repo = TestRepo::init();
59        repo.add_submodule("libs/sub");
60        // After `add` the submodule is initialized; deinit makes it report `-`.
61        repo.deinit_submodule("libs/sub");
62        let pending = uninitialized(&RealGit, repo.root()).unwrap();
63        assert_eq!(pending, vec!["libs/sub".to_string()]);
64    }
65
66    #[test]
67    fn update_init_populates_submodule() {
68        let repo = TestRepo::init();
69        repo.add_submodule("libs/sub");
70        repo.deinit_submodule("libs/sub");
71        // Sanity: empty before, populated after (reuses .git/modules, no clone).
72        assert!(!repo.root().join("libs/sub/sub.txt").exists());
73        update_init(&RealGit, repo.root()).unwrap();
74        assert!(repo.root().join("libs/sub/sub.txt").exists());
75        assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
76    }
77
78    #[test]
79    fn uninitialized_is_empty_outside_a_repo() {
80        // `git submodule status` fails (non-success) in a non-repo dir; the
81        // best-effort contract returns an empty list rather than erroring.
82        let dir = tempfile::tempdir().unwrap();
83        assert!(uninitialized(&RealGit, dir.path()).unwrap().is_empty());
84    }
85}