1use crate::clone::sh_squote;
28use crate::config::current_branch_opt;
29use crate::git::Git;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, PartialEq, Eq)]
33pub enum Outcome {
34 Fixed,
36 Skipped,
38 Failed(String),
41}
42
43#[derive(Debug)]
44pub struct FixsubReport {
45 pub root: PathBuf,
46 pub outcome: Outcome,
47}
48
49#[derive(Debug, PartialEq, Eq)]
53pub enum SwitchPlan {
54 Switch { to: String },
56 Keep { branch: String },
58 Diverged { on: String, configured: String },
60}
61
62pub fn decide_switch(current: Option<&str>, configured: &str) -> SwitchPlan {
66 match current {
67 None => SwitchPlan::Switch {
68 to: configured.to_string(),
69 },
70 Some(b) if b == configured => SwitchPlan::Keep {
71 branch: b.to_string(),
72 },
73 Some(b) => SwitchPlan::Diverged {
74 on: b.to_string(),
75 configured: configured.to_string(),
76 },
77 }
78}
79
80pub fn inherit_identity_cmd(root_name: Option<&str>, root_email: Option<&str>) -> Option<String> {
86 let parts: Vec<String> = [("user.name", root_name), ("user.email", root_email)]
87 .into_iter()
88 .filter_map(|(k, v)| {
89 v.map(|v| {
90 format!(
91 "git config --local {k} >/dev/null 2>&1 || git config {k} {}",
92 sh_squote(v)
93 )
94 })
95 })
96 .collect();
97 (!parts.is_empty()).then(|| parts.join("; "))
98}
99
100fn is_git_repo(git: &dyn Git, dir: &Path) -> bool {
102 let r = git.run(dir, &["rev-parse", "--is-inside-work-tree"]);
103 r.success && r.trimmed() == "true"
104}
105
106fn local_config(git: &dyn Git, dir: &Path, key: &str) -> Option<String> {
108 let o = git.run(dir, &["config", "--local", "--get", key]);
109 let v = o.trimmed();
110 (o.success && !v.is_empty()).then(|| v.to_string())
111}
112
113struct SubInfo {
117 displaypath: String,
118 name: String,
119 toplevel: String,
120}
121
122fn submodule_infos(git: &dyn Git, root: &Path) -> Result<Vec<SubInfo>, String> {
127 let out = git.run(
128 root,
129 &[
130 "submodule",
131 "foreach",
132 "--recursive",
133 r#"printf '%s\t%s\t%s\n' "$displaypath" "$name" "$toplevel""#,
134 ],
135 );
136 if !out.success {
137 return Err(format!(
138 "submodule foreach (enumerate) failed: {}",
139 out.stderr.trim()
140 ));
141 }
142 Ok(out
143 .stdout
144 .lines()
145 .filter_map(|line| {
146 let mut it = line.splitn(3, '\t');
147 let displaypath = it.next()?.trim();
148 let name = it.next()?;
149 let toplevel = it.next()?;
150 (!displaypath.is_empty()).then(|| SubInfo {
151 displaypath: displaypath.to_string(),
152 name: name.to_string(),
153 toplevel: toplevel.to_string(),
154 })
155 })
156 .collect())
157}
158
159fn configured_branch(git: &dyn Git, root: &Path, toplevel: &str, name: &str) -> String {
162 let gitmodules = format!("{toplevel}/.gitmodules");
163 let key = format!("submodule.{name}.branch");
164 let o = git.run(root, &["config", "-f", &gitmodules, "--get", &key]);
165 let v = o.trimmed();
166 if o.success && !v.is_empty() {
167 v.to_string()
168 } else {
169 "main".to_string()
170 }
171}
172
173pub fn fixsub<G: Git>(git: &G, root: &Path, dry_run: bool, direnv: bool) -> FixsubReport {
177 let mk = |outcome| FixsubReport {
178 root: root.to_path_buf(),
179 outcome,
180 };
181 if !is_git_repo(git, root) {
182 return mk(Outcome::Failed("not a git repository".into()));
183 }
184
185 let subs = match submodule_infos(git, root) {
186 Ok(s) => s,
187 Err(e) => return mk(Outcome::Failed(e)),
188 };
189 if subs.is_empty() {
190 println!(" no initialized submodules — nothing to do");
191 return mk(Outcome::Skipped);
192 }
193
194 let mut switch_failures = 0u32;
197 for s in &subs {
198 let dir = root.join(&s.displaypath);
199 let configured = configured_branch(git, root, &s.toplevel, &s.name);
200 let current = current_branch_opt(git, &dir);
201 match decide_switch(current.as_deref(), &configured) {
202 SwitchPlan::Switch { to } => {
203 if dry_run {
204 println!(
205 " {}: detached HEAD → would switch to '{to}'",
206 s.displaypath
207 );
208 } else {
209 println!(" {}: detached HEAD → switching to '{to}'", s.displaypath);
210 println!(" + git switch {to}");
211 let o = git.run(&dir, &["switch", &to]);
212 if !o.success {
213 println!(" ! switch to '{to}' FAILED: {}", o.stderr.trim());
214 switch_failures += 1;
215 }
216 }
217 }
218 SwitchPlan::Keep { branch } => {
219 println!(
220 " {}: on '{branch}' (matches .gitmodules) — kept",
221 s.displaypath
222 );
223 }
224 SwitchPlan::Diverged { on, configured } => {
225 println!(
226 " {}: on '{on}'; .gitmodules tracks '{configured}' — left as-is \
227 (merge it into '{configured}', or update .gitmodules)",
228 s.displaypath
229 );
230 }
231 }
232 }
233
234 let name = local_config(git, root, "user.name");
237 let email = local_config(git, root, "user.email");
238 let mut parts: Vec<String> = Vec::new();
239 if let Some(id) = inherit_identity_cmd(name.as_deref(), email.as_deref()) {
240 parts.push(id);
241 }
242 if direnv {
243 parts.push("[ -f .envrc ] && direnv allow . 2>/dev/null || true".to_string());
244 }
245 if !parts.is_empty() {
246 let body = parts.join("; ");
247 println!("+ git submodule foreach --recursive {body}");
248 if !dry_run {
249 let out = git.run(
250 root,
251 &["submodule", "foreach", "--recursive", body.as_str()],
252 );
253 if !out.success {
254 return mk(Outcome::Failed(format!(
255 "submodule foreach failed: {}",
256 out.stderr.trim()
257 )));
258 }
259 }
260 }
261
262 if switch_failures > 0 {
263 return mk(Outcome::Failed(format!(
264 "{switch_failures} submodule(s) could not be switched onto their .gitmodules branch (see output above)"
265 )));
266 }
267 mk(Outcome::Fixed)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::git::test_support::FakeGit;
274 use std::path::Path;
275
276 #[test]
277 fn decide_switch_un_detaches_only_and_reports_divergence() {
278 assert_eq!(
280 decide_switch(None, "main"),
281 SwitchPlan::Switch { to: "main".into() }
282 );
283 assert_eq!(
285 decide_switch(Some("main"), "main"),
286 SwitchPlan::Keep {
287 branch: "main".into()
288 }
289 );
290 assert_eq!(
292 decide_switch(Some("feature-x"), "main"),
293 SwitchPlan::Diverged {
294 on: "feature-x".into(),
295 configured: "main".into()
296 }
297 );
298 }
299
300 #[test]
301 fn inherit_identity_cmd_set_if_unset_and_quotes() {
302 assert_eq!(
304 inherit_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
305 Some(
306 "git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane Dev'; \
307 git config --local user.email >/dev/null 2>&1 || git config user.email 'jane@acme.com'"
308 )
309 );
310 assert_eq!(
312 inherit_identity_cmd(Some("Jane"), None).as_deref(),
313 Some("git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane'")
314 );
315 assert_eq!(inherit_identity_cmd(None, None), None);
317 assert_eq!(
319 inherit_identity_cmd(Some("O'Brien"), None).as_deref(),
320 Some(
321 r"git config --local user.name >/dev/null 2>&1 || git config user.name 'O'\''Brien'"
322 )
323 );
324 }
325
326 #[test]
327 fn dry_run_runs_no_git_mutations() {
328 let git = FakeGit::new()
331 .ok("rev-parse --is-inside-work-tree", "true")
332 .ok(
333 r#"submodule foreach --recursive printf '%s\t%s\t%s\n' "$displaypath" "$name" "$toplevel""#,
334 "",
335 );
336 let r = fixsub(&git, Path::new("/r"), true, true);
337 assert_eq!(r.outcome, Outcome::Skipped);
338 }
339
340 #[test]
341 fn non_git_root_fails() {
342 let git = FakeGit::new().fail("rev-parse --is-inside-work-tree");
343 let r = fixsub(&git, Path::new("/nope"), false, true);
344 assert!(matches!(r.outcome, Outcome::Failed(_)));
345 }
346}