use crate::clone::sh_squote;
use crate::config::current_branch_opt;
use crate::git::Git;
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
Fixed,
Skipped,
Failed(String),
}
#[derive(Debug)]
pub struct FixsubReport {
pub root: PathBuf,
pub outcome: Outcome,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SwitchPlan {
Switch { to: String },
Keep { branch: String },
Diverged { on: String, configured: String },
}
pub fn decide_switch(current: Option<&str>, configured: &str) -> SwitchPlan {
match current {
None => SwitchPlan::Switch {
to: configured.to_string(),
},
Some(b) if b == configured => SwitchPlan::Keep {
branch: b.to_string(),
},
Some(b) => SwitchPlan::Diverged {
on: b.to_string(),
configured: configured.to_string(),
},
}
}
pub fn inherit_identity_cmd(root_name: Option<&str>, root_email: Option<&str>) -> Option<String> {
let parts: Vec<String> = [("user.name", root_name), ("user.email", root_email)]
.into_iter()
.filter_map(|(k, v)| {
v.map(|v| {
format!(
"git config --local {k} >/dev/null 2>&1 || git config {k} {}",
sh_squote(v)
)
})
})
.collect();
(!parts.is_empty()).then(|| parts.join("; "))
}
fn is_git_repo(git: &dyn Git, dir: &Path) -> bool {
let r = git.run(dir, &["rev-parse", "--is-inside-work-tree"]);
r.success && r.trimmed() == "true"
}
fn local_config(git: &dyn Git, dir: &Path, key: &str) -> Option<String> {
let o = git.run(dir, &["config", "--local", "--get", key]);
let v = o.trimmed();
(o.success && !v.is_empty()).then(|| v.to_string())
}
struct SubInfo {
displaypath: String,
name: String,
toplevel: String,
}
fn submodule_infos(git: &dyn Git, root: &Path) -> Result<Vec<SubInfo>, String> {
let out = git.run(
root,
&[
"submodule",
"foreach",
"--recursive",
r#"printf '%s\t%s\t%s\n' "$displaypath" "$name" "$toplevel""#,
],
);
if !out.success {
return Err(format!(
"submodule foreach (enumerate) failed: {}",
out.stderr.trim()
));
}
Ok(out
.stdout
.lines()
.filter_map(|line| {
let mut it = line.splitn(3, '\t');
let displaypath = it.next()?.trim();
let name = it.next()?;
let toplevel = it.next()?;
(!displaypath.is_empty()).then(|| SubInfo {
displaypath: displaypath.to_string(),
name: name.to_string(),
toplevel: toplevel.to_string(),
})
})
.collect())
}
fn configured_branch(git: &dyn Git, root: &Path, toplevel: &str, name: &str) -> String {
let gitmodules = format!("{toplevel}/.gitmodules");
let key = format!("submodule.{name}.branch");
let o = git.run(root, &["config", "-f", &gitmodules, "--get", &key]);
let v = o.trimmed();
if o.success && !v.is_empty() {
v.to_string()
} else {
"main".to_string()
}
}
pub fn fixsub<G: Git>(git: &G, root: &Path, dry_run: bool, direnv: bool) -> FixsubReport {
let mk = |outcome| FixsubReport {
root: root.to_path_buf(),
outcome,
};
if !is_git_repo(git, root) {
return mk(Outcome::Failed("not a git repository".into()));
}
let subs = match submodule_infos(git, root) {
Ok(s) => s,
Err(e) => return mk(Outcome::Failed(e)),
};
if subs.is_empty() {
println!(" no initialized submodules — nothing to do");
return mk(Outcome::Skipped);
}
let mut switch_failures = 0u32;
for s in &subs {
let dir = root.join(&s.displaypath);
let configured = configured_branch(git, root, &s.toplevel, &s.name);
let current = current_branch_opt(git, &dir);
match decide_switch(current.as_deref(), &configured) {
SwitchPlan::Switch { to } => {
if dry_run {
println!(
" {}: detached HEAD → would switch to '{to}'",
s.displaypath
);
} else {
println!(" {}: detached HEAD → switching to '{to}'", s.displaypath);
println!(" + git switch {to}");
let o = git.run(&dir, &["switch", &to]);
if !o.success {
println!(" ! switch to '{to}' FAILED: {}", o.stderr.trim());
switch_failures += 1;
}
}
}
SwitchPlan::Keep { branch } => {
println!(
" {}: on '{branch}' (matches .gitmodules) — kept",
s.displaypath
);
}
SwitchPlan::Diverged { on, configured } => {
println!(
" {}: on '{on}'; .gitmodules tracks '{configured}' — left as-is \
(merge it into '{configured}', or update .gitmodules)",
s.displaypath
);
}
}
}
let name = local_config(git, root, "user.name");
let email = local_config(git, root, "user.email");
let mut parts: Vec<String> = Vec::new();
if let Some(id) = inherit_identity_cmd(name.as_deref(), email.as_deref()) {
parts.push(id);
}
if direnv {
parts.push("[ -f .envrc ] && direnv allow . 2>/dev/null || true".to_string());
}
if !parts.is_empty() {
let body = parts.join("; ");
println!("+ git submodule foreach --recursive {body}");
if !dry_run {
let out = git.run(
root,
&["submodule", "foreach", "--recursive", body.as_str()],
);
if !out.success {
return mk(Outcome::Failed(format!(
"submodule foreach failed: {}",
out.stderr.trim()
)));
}
}
}
if switch_failures > 0 {
return mk(Outcome::Failed(format!(
"{switch_failures} submodule(s) could not be switched onto their .gitmodules branch (see output above)"
)));
}
mk(Outcome::Fixed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::test_support::FakeGit;
use std::path::Path;
#[test]
fn decide_switch_un_detaches_only_and_reports_divergence() {
assert_eq!(
decide_switch(None, "main"),
SwitchPlan::Switch { to: "main".into() }
);
assert_eq!(
decide_switch(Some("main"), "main"),
SwitchPlan::Keep {
branch: "main".into()
}
);
assert_eq!(
decide_switch(Some("feature-x"), "main"),
SwitchPlan::Diverged {
on: "feature-x".into(),
configured: "main".into()
}
);
}
#[test]
fn inherit_identity_cmd_set_if_unset_and_quotes() {
assert_eq!(
inherit_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
Some(
"git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane Dev'; \
git config --local user.email >/dev/null 2>&1 || git config user.email 'jane@acme.com'"
)
);
assert_eq!(
inherit_identity_cmd(Some("Jane"), None).as_deref(),
Some("git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane'")
);
assert_eq!(inherit_identity_cmd(None, None), None);
assert_eq!(
inherit_identity_cmd(Some("O'Brien"), None).as_deref(),
Some(
r"git config --local user.name >/dev/null 2>&1 || git config user.name 'O'\''Brien'"
)
);
}
#[test]
fn dry_run_runs_no_git_mutations() {
let git = FakeGit::new()
.ok("rev-parse --is-inside-work-tree", "true")
.ok(
r#"submodule foreach --recursive printf '%s\t%s\t%s\n' "$displaypath" "$name" "$toplevel""#,
"",
);
let r = fixsub(&git, Path::new("/r"), true, true);
assert_eq!(r.outcome, Outcome::Skipped);
}
#[test]
fn non_git_root_fails() {
let git = FakeGit::new().fail("rev-parse --is-inside-work-tree");
let r = fixsub(&git, Path::new("/nope"), false, true);
assert!(matches!(r.outcome, Outcome::Failed(_)));
}
}