use std::path::Path;
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
use crate::git::porcelain::{Submodule, parse_gitmodules, parse_submodule_status};
pub(crate) mod reattach;
pub(crate) mod seed;
pub fn uninitialized(git: &dyn GitCli, worktree_dir: &Path) -> Result<Vec<String>> {
let output = git.run_raw(worktree_dir, &["submodule", "status", "--recursive"])?;
if !output.success {
return Ok(Vec::new());
}
Ok(parse_submodule_status(&output.stdout)
.into_iter()
.filter(|s| s.is_uninitialized())
.map(|s| s.path)
.collect())
}
pub(crate) fn any_initialized(git: &dyn GitCli, worktree_dir: &Path) -> Result<bool> {
let output = git.run_raw(worktree_dir, &["submodule", "status", "--recursive"])?;
if !output.success {
return Ok(false);
}
Ok(parse_submodule_status(&output.stdout)
.into_iter()
.any(|s| !s.is_uninitialized()))
}
pub(crate) fn is_plain_relative(value: &str) -> bool {
use std::path::Component;
!value.is_empty()
&& Path::new(value)
.components()
.all(|c| matches!(c, Component::Normal(_)))
}
pub(crate) fn declared(git: &dyn GitCli, worktree_dir: &Path) -> Result<Vec<Submodule>> {
if !worktree_dir.join(".gitmodules").is_file() {
return Ok(Vec::new());
}
let output = git.run_raw(
worktree_dir,
&[
"config",
"-f",
".gitmodules",
"-z",
"--get-regexp",
"^submodule\\..*",
],
)?;
if !output.success {
return Ok(Vec::new());
}
Ok(parse_gitmodules(&output.stdout)
.into_iter()
.filter(|s| {
let ok = is_plain_relative(&s.name) && is_plain_relative(&s.path);
if !ok {
tracing::debug!(
name = %s.name,
path = %s.path,
"ignoring a .gitmodules entry that is not a plain relative path"
);
}
ok
})
.collect())
}
pub fn update_init(git: &dyn GitCli, worktree_dir: &Path) -> Result<()> {
git.run(
worktree_dir,
&["submodule", "update", "--init", "--recursive"],
)?;
Ok(())
}
pub(crate) fn populate(
git: &dyn GitCli,
worktree_dir: &Path,
seed_from_mirrors: bool,
) -> (seed::SeedReport, Result<()>) {
let span = tracing::info_span!("submodules", worktree = %worktree_dir.display());
let _guard = span.enter();
let mut report = seed::SeedReport::default();
if seed_from_mirrors {
match linked_worktree_common_dir(git, worktree_dir) {
Ok(Some(common)) => report = seed::seed_from_mirrors(git, worktree_dir, &common),
Ok(None) => tracing::debug!("primary worktree; submodules already share the mirrors"),
Err(e) => {
tracing::debug!(error = %e, "could not resolve the git dirs; not seeding");
}
}
if !report.is_empty()
&& let Err(e) = sync(git, worktree_dir)
{
return (report, Err(e));
}
}
let result = update_init(git, worktree_dir);
(report, result)
}
fn linked_worktree_common_dir(
git: &dyn GitCli,
worktree_dir: &Path,
) -> Result<Option<std::path::PathBuf>> {
let out = git.run(
worktree_dir,
&[
"rev-parse",
"--path-format=absolute",
"--git-dir",
"--git-common-dir",
],
)?;
let mut lines = out.lines();
let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
return Err(Error::operation(
"could not resolve the repository git dirs",
));
};
if git_dir.trim() == common.trim() {
return Ok(None);
}
Ok(Some(std::path::PathBuf::from(common.trim())))
}
pub(crate) fn sync(git: &dyn GitCli, worktree_dir: &Path) -> Result<()> {
git.run(worktree_dir, &["submodule", "sync", "--recursive"])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::testutil::TestRepo;
#[test]
fn no_submodules_yields_empty() {
let repo = TestRepo::init();
assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
}
#[test]
fn reports_uninitialized_submodule() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
repo.deinit_submodule("libs/sub");
let pending = uninitialized(&RealGit, repo.root()).unwrap();
assert_eq!(pending, vec!["libs/sub".to_string()]);
}
#[test]
fn update_init_populates_submodule() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
repo.deinit_submodule("libs/sub");
assert!(!repo.root().join("libs/sub/sub.txt").exists());
update_init(&RealGit, repo.root()).unwrap();
assert!(repo.root().join("libs/sub/sub.txt").exists());
assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
}
#[test]
fn is_plain_relative_accepts_only_normal_components() {
assert!(is_plain_relative("libs/sub"));
assert!(is_plain_relative("a.b.c"));
assert!(!is_plain_relative(""));
assert!(!is_plain_relative(".."));
assert!(!is_plain_relative("../outside"));
assert!(!is_plain_relative("libs/../../outside"));
assert!(!is_plain_relative("/etc"));
assert!(!is_plain_relative("./libs"));
}
#[test]
fn declared_drops_entries_that_could_escape_the_repository() {
let repo = TestRepo::init();
repo.write(
".gitmodules",
concat!(
"[submodule \"good\"]\n\tpath = libs/good\n\turl = ../g\n",
"[submodule \"../../../../tmp/evil\"]\n\tpath = libs/good2\n\turl = ../e\n",
"[submodule \"escaper\"]\n\tpath = ..\n\turl = ../e\n",
"[submodule \"absolute\"]\n\tpath = /etc/passwd\n\turl = ../e\n",
),
);
repo.commit_all("declare hostile submodules");
let subs = declared(&RealGit, repo.root()).unwrap();
assert_eq!(
subs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
vec!["good"],
"a traversing name or path survived the .gitmodules read"
);
}
#[test]
fn uninitialized_is_empty_outside_a_repo() {
let dir = tempfile::tempdir().unwrap();
assert!(uninitialized(&RealGit, dir.path()).unwrap().is_empty());
}
#[test]
fn uninitialized_recurses_into_nested_submodules() {
let repo = TestRepo::init();
repo.add_nested_submodule("libs/sub", "deep");
assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
repo.git(&["-C", "libs/sub", "submodule", "deinit", "-q", "-f", "deep"]);
let pending = uninitialized(&RealGit, repo.root()).unwrap();
assert_eq!(pending, vec!["libs/sub/deep".to_string()]);
}
#[test]
fn any_initialized_tracks_population() {
let repo = TestRepo::init();
assert!(!any_initialized(&RealGit, repo.root()).unwrap());
repo.add_submodule("libs/sub");
assert!(any_initialized(&RealGit, repo.root()).unwrap());
repo.deinit_submodule("libs/sub");
assert!(!any_initialized(&RealGit, repo.root()).unwrap());
}
}