1use crate::{Repository, Result, VcsError};
2
3pub struct Submodule;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SubmoduleState {
15 Uninitialised,
18 InSync,
20 Modified,
24 Conflicted,
26}
27
28impl SubmoduleState {
29 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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SubmoduleEntry {
45 pub state: SubmoduleState,
46 pub sha: String,
48 pub path: String,
50 pub describe: String,
54}
55
56pub 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 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 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 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 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 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 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 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}