Skip to main content

lattice_vcs/
submodule.rs

1use crate::{Repository, Result, VcsError};
2
3/// Submodule operations — list, add, update, sync, remove.
4///
5/// Uses the git CLI. `update` and `add` reach the network; the rest are
6/// local.
7pub struct Submodule;
8
9/// What `git submodule status` reports about one submodule.
10///
11/// The leading character of each status line, which is the part that
12/// decides what the user can usefully do next.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SubmoduleState {
15    /// `-` — not initialised. Its working tree is empty; `update` is
16    /// what fills it.
17    Uninitialised,
18    /// ` ` — checked out at the commit the superproject records.
19    InSync,
20    /// `+` — checked out at a *different* commit than the superproject
21    /// records. Either you moved it deliberately or an `update` is
22    /// pending.
23    Modified,
24    /// `U` — has merge conflicts.
25    Conflicted,
26}
27
28impl SubmoduleState {
29    /// The one-character marker `git submodule status` uses, which is
30    /// also what the buffer renders — so the row reads the same as
31    /// git's own output.
32    pub fn marker(self) -> char {
33        match self {
34            SubmoduleState::Uninitialised => '-',
35            SubmoduleState::InSync => ' ',
36            SubmoduleState::Modified => '+',
37            SubmoduleState::Conflicted => 'U',
38        }
39    }
40}
41
42/// One configured submodule.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SubmoduleEntry {
45    pub state: SubmoduleState,
46    /// The commit the submodule is at (full SHA, as git prints it).
47    pub sha: String,
48    /// Path relative to the superproject's working tree.
49    pub path: String,
50    /// The `git describe` output git appends in parentheses, when
51    /// there is one. Empty for an uninitialised submodule, which has
52    /// no checkout to describe.
53    pub describe: String,
54}
55
56/// Parse `git submodule status` output.
57///
58/// Each line is `<marker><sha> <path>[ (<describe>)]`, where the marker
59/// is one character and is **not** separated from the SHA. A line that
60/// does not fit is skipped rather than failing the list: one
61/// unparseable submodule must not hide the others.
62pub fn parse_submodule_status(out: &str) -> Vec<SubmoduleEntry> {
63    let mut entries = Vec::new();
64    for line in out.lines() {
65        if line.trim().is_empty() {
66            continue;
67        }
68        let mut chars = line.chars();
69        let state = match chars.next() {
70            Some('-') => SubmoduleState::Uninitialised,
71            Some('+') => SubmoduleState::Modified,
72            Some('U') => SubmoduleState::Conflicted,
73            Some(' ') => SubmoduleState::InSync,
74            _ => continue,
75        };
76        let rest = chars.as_str();
77        let Some((sha, tail)) = rest.split_once(' ') else {
78            continue;
79        };
80        if sha.is_empty() {
81            continue;
82        }
83        // The describe suffix is parenthesised and always last; a path
84        // containing a space keeps it, because only the trailing
85        // `(...)` is peeled off.
86        let (path, describe) = match tail.rfind(" (") {
87            Some(i) if tail.ends_with(')') => (&tail[..i], &tail[i + 2..tail.len() - 1]),
88            _ => (tail, ""),
89        };
90        if path.is_empty() {
91            continue;
92        }
93        entries.push(SubmoduleEntry {
94            state,
95            sha: sha.to_string(),
96            path: path.to_string(),
97            describe: describe.to_string(),
98        });
99    }
100    entries
101}
102
103impl Submodule {
104    /// Every configured submodule, in the order git lists them.
105    ///
106    /// Equivalent to `git submodule status`.
107    pub fn list(repo: &Repository) -> Result<Vec<SubmoduleEntry>> {
108        let out = repo
109            .run_git_str(["submodule", "status"])
110            .map_err(|e| VcsError::Submodule(format!("submodule list: {}", e)))?;
111        Ok(parse_submodule_status(&out))
112    }
113
114    /// Add a submodule at `path` from `url`.
115    ///
116    /// Equivalent to `git submodule add <url> <path>`.
117    pub fn add(repo: &Repository, url: &str, path: &str) -> Result<()> {
118        repo.run_git(["submodule", "add", url, path])
119            .map(|_| ())
120            .map_err(|e| VcsError::Submodule(format!("submodule add {}: {}", path, e)))
121    }
122
123    /// Initialise and check out a submodule at the recorded commit
124    /// (`None` = all of them).
125    ///
126    /// Equivalent to `git submodule update --init --recursive [<path>]`
127    /// — magit's `p` populate, `r` register and `u` update collapsed
128    /// into the one that subsumes them. Reaches the network for a
129    /// submodule that has never been cloned.
130    pub fn update(repo: &Repository, path: Option<&str>) -> Result<()> {
131        let mut args: Vec<String> = vec![
132            "submodule".into(),
133            "update".into(),
134            "--init".into(),
135            "--recursive".into(),
136        ];
137        if let Some(path) = path {
138            args.push(path.to_string());
139        }
140        repo.run_git(args)
141            .map(|_| ())
142            .map_err(|e| VcsError::Submodule(format!("submodule update: {}", e)))
143    }
144
145    /// Re-copy the configured URLs into the submodules' own git config
146    /// (`None` = all of them).
147    ///
148    /// Equivalent to `git submodule sync --recursive [<path>]`. What
149    /// you run after the superproject's `.gitmodules` URLs changed.
150    pub fn sync(repo: &Repository, path: Option<&str>) -> Result<()> {
151        let mut args: Vec<String> = vec!["submodule".into(), "sync".into(), "--recursive".into()];
152        if let Some(path) = path {
153            args.push(path.to_string());
154        }
155        repo.run_git(args)
156            .map(|_| ())
157            .map_err(|e| VcsError::Submodule(format!("submodule sync: {}", e)))
158    }
159
160    /// Remove a submodule: deinit it, then drop it from the index and
161    /// the working tree.
162    ///
163    /// `git submodule deinit -f` followed by `git rm -f`. **This
164    /// deletes the submodule's working tree**, including anything
165    /// uncommitted inside it, which is why callers confirm first.
166    ///
167    /// The deinit runs first and its failure is fatal: `git rm` on a
168    /// still-populated submodule leaves the checkout orphaned in
169    /// `.git/modules` with nothing in the index pointing at it.
170    pub fn remove(repo: &Repository, path: &str) -> Result<()> {
171        repo.run_git(["submodule", "deinit", "-f", path])
172            .map_err(|e| VcsError::Submodule(format!("submodule deinit {}: {}", path, e)))?;
173        repo.run_git(["rm", "-f", path])
174            .map(|_| ())
175            .map_err(|e| VcsError::Submodule(format!("submodule remove {}: {}", path, e)))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn parses_the_three_markers_git_uses() {
185        let out = concat!(
186            "-a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 vendor/uninit\n",
187            " b1b2c3d4e5f60718293a4b5c6d7e8f9012345678 vendor/insync (v1.2.3)\n",
188            "+c1b2c3d4e5f60718293a4b5c6d7e8f9012345678 vendor/moved (v1.2.3-4-gabcdef)\n",
189        );
190        let got = parse_submodule_status(out);
191        assert_eq!(got.len(), 3);
192        assert_eq!(got[0].state, SubmoduleState::Uninitialised);
193        assert_eq!(got[0].path, "vendor/uninit");
194        assert_eq!(
195            got[0].describe, "",
196            "an uninitialised submodule has no checkout to describe"
197        );
198        assert_eq!(got[1].state, SubmoduleState::InSync);
199        assert_eq!(got[1].describe, "v1.2.3");
200        assert_eq!(got[2].state, SubmoduleState::Modified);
201        assert_eq!(got[2].path, "vendor/moved");
202    }
203
204    #[test]
205    fn a_conflicted_submodule_is_its_own_state() {
206        let out = "Uabc123 vendor/x\n";
207        assert_eq!(
208            parse_submodule_status(out)[0].state,
209            SubmoduleState::Conflicted
210        );
211    }
212
213    #[test]
214    fn the_marker_round_trips_to_gits_own_character() {
215        for state in [
216            SubmoduleState::Uninitialised,
217            SubmoduleState::InSync,
218            SubmoduleState::Modified,
219            SubmoduleState::Conflicted,
220        ] {
221            let line = format!("{}abc123 vendor/x\n", state.marker());
222            assert_eq!(
223                parse_submodule_status(&line)[0].state,
224                state,
225                "marker {:?} did not round-trip",
226                state.marker()
227            );
228        }
229    }
230
231    #[test]
232    fn a_path_containing_spaces_keeps_it() {
233        // Only the trailing parenthesised describe is peeled off.
234        let out = " abc123 vendor/my module (v1)\n";
235        let got = parse_submodule_status(out);
236        assert_eq!(got[0].path, "vendor/my module");
237        assert_eq!(got[0].describe, "v1");
238    }
239
240    #[test]
241    fn a_path_with_parentheses_but_no_describe_is_not_truncated() {
242        let out = " abc123 vendor/thing\n";
243        assert_eq!(parse_submodule_status(out)[0].path, "vendor/thing");
244        assert_eq!(parse_submodule_status(out)[0].describe, "");
245    }
246
247    #[test]
248    fn unparseable_lines_are_skipped_and_do_not_lose_the_good_ones() {
249        let out = concat!(
250            "garbage\n",
251            "?abc123 vendor/unknown-marker\n",
252            " abc123 vendor/fine (v1)\n",
253        );
254        let got = parse_submodule_status(out);
255        assert_eq!(got.len(), 1, "{got:?}");
256        assert_eq!(got[0].path, "vendor/fine");
257    }
258
259    #[test]
260    fn no_submodules_is_an_empty_list_not_an_error() {
261        assert!(parse_submodule_status("").is_empty());
262        assert!(parse_submodule_status("\n\n").is_empty());
263    }
264}