use std::path::{Path, PathBuf};
use tracing::debug;
use crate::error::Result;
use crate::git::cli::GitCli;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SeedReport {
pub(crate) seeded: Vec<String>,
pub(crate) skipped: Vec<String>,
pub(crate) failed: Vec<(String, String)>,
}
impl SeedReport {
pub(crate) fn is_empty(&self) -> bool {
self.seeded.is_empty() && self.failed.is_empty()
}
}
pub(crate) fn seed_from_mirrors(
git: &dyn GitCli,
worktree_dir: &Path,
common_dir: &Path,
) -> SeedReport {
let mut report = SeedReport::default();
let root = common_dir.join("modules");
walk(git, worktree_dir, &root, &root, "", &mut report);
report
}
fn walk(
git: &dyn GitCli,
dir: &Path,
mirror_prefix: &Path,
mirror_root: &Path,
rel_prefix: &str,
report: &mut SeedReport,
) {
let declared = match super::declared(git, dir) {
Ok(subs) => subs,
Err(e) => {
debug!(dir = %dir.display(), error = %e, "could not read .gitmodules");
return;
}
};
for sub in declared {
let rel = if rel_prefix.is_empty() {
sub.path.clone()
} else {
format!("{rel_prefix}/{}", sub.path)
};
let Some(mirror) = mirror_within(mirror_prefix, &sub.name, mirror_root) else {
debug!(submodule = %rel, "no usable local mirror; leaving it to the reconcile pass");
report.skipped.push(rel);
continue;
};
match seed_one(git, dir, &sub.name, &sub.path, &mirror) {
Ok(()) => {
debug!(submodule = %rel, "seeded from local mirror");
report.seeded.push(rel.clone());
walk(
git,
&dir.join(&sub.path),
&mirror.join("modules"),
mirror_root,
&rel,
report,
);
}
Err(e) => {
debug!(submodule = %rel, error = %e, "seeding failed");
report.failed.push((rel, e.to_string()));
}
}
}
}
pub(crate) fn mirror_within(prefix: &Path, name: &str, root: &Path) -> Option<PathBuf> {
if !super::is_plain_relative(name) {
return None;
}
let candidate = prefix.join(name);
if !candidate.is_dir() {
return None;
}
let real = candidate.canonicalize().ok()?;
let real_root = root.canonicalize().ok()?;
real.starts_with(&real_root).then_some(real)
}
fn seed_one(git: &dyn GitCli, dir: &Path, name: &str, path: &str, mirror: &Path) -> Result<()> {
git.run(dir, &["submodule", "init", "--", path])?;
let url_override = format!("submodule.{name}.url={}", mirror.display());
git.run(
dir,
&[
"-c",
"protocol.file.allow=always",
"-c",
&url_override,
"submodule",
"update",
"--",
path,
],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::{GitOutput, RealGit};
use crate::testutil::TestRepo;
use std::sync::Mutex;
struct Recording {
inner: RealGit,
calls: Mutex<Vec<Vec<String>>>,
}
impl Recording {
fn new() -> Self {
Self {
inner: RealGit,
calls: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
}
}
impl GitCli for Recording {
fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
self.calls
.lock()
.unwrap()
.push(args.iter().map(|a| a.to_string()).collect());
self.inner.run_raw(repo, args)
}
}
fn worktree_of(repo: &TestRepo, branch: &str) -> (PathBuf, PathBuf) {
repo.add_worktree(branch, "../wt-seed");
let path = repo.root().parent().unwrap().join("wt-seed");
let common = repo.root().join(".git");
(path, common)
}
#[test]
fn seeds_a_submodule_from_the_local_mirror() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let (wt, common) = worktree_of(&repo, "topic");
assert!(!wt.join("libs/sub/sub.txt").exists());
let report = seed_from_mirrors(&RealGit, &wt, &common);
assert_eq!(report.seeded, vec!["libs/sub".to_string()]);
assert!(report.failed.is_empty(), "{:?}", report.failed);
assert!(wt.join("libs/sub/sub.txt").exists());
}
#[test]
fn seeded_objects_are_shared_with_the_mirror() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let (wt, common) = worktree_of(&repo, "topic");
seed_from_mirrors(&RealGit, &wt, &common);
let mirror_objects = common.join("modules/libs/sub/objects");
let linked = count_multiply_linked(&mirror_objects);
assert!(
linked > 0,
"no object in {} is hardlinked; the clone copied instead of sharing",
mirror_objects.display()
);
}
fn count_multiply_linked(dir: &Path) -> usize {
use std::os::unix::fs::MetadataExt;
let mut n = 0;
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().is_some_and(|f| f == "info" || f == "pack") {
continue;
}
n += count_multiply_linked(&path);
} else if let Ok(md) = path.metadata()
&& md.nlink() > 1
{
n += 1;
}
}
n
}
#[test]
fn never_persists_the_mirror_url_into_the_shared_config() {
let repo = TestRepo::init();
let src = repo.add_submodule("libs/sub");
let before = repo.git(&["config", "submodule.libs/sub.url"]);
assert_eq!(before.trim(), src.to_string_lossy());
let (wt, common) = worktree_of(&repo, "topic");
seed_from_mirrors(&RealGit, &wt, &common);
let after = repo.git(&["config", "submodule.libs/sub.url"]);
assert_eq!(
after.trim(),
src.to_string_lossy(),
"seeding rewrote the shared submodule URL"
);
}
#[test]
fn file_protocol_is_only_relaxed_for_a_mirror_backed_update() {
let repo = TestRepo::init();
repo.add_submodule("mirrored");
repo.write(
".gitmodules",
&format!(
"{}\n[submodule \"orphan\"]\n\tpath = orphan\n\turl = ../nowhere\n",
std::fs::read_to_string(repo.root().join(".gitmodules"))
.unwrap()
.trim_end()
),
);
repo.commit_all("declare an unmirrored submodule");
let (wt, common) = worktree_of(&repo, "topic");
let git = Recording::new();
let report = seed_from_mirrors(&git, &wt, &common);
assert_eq!(report.seeded, vec!["mirrored".to_string()]);
assert_eq!(report.skipped, vec!["orphan".to_string()]);
for call in git.calls() {
if !call.iter().any(|a| a == "protocol.file.allow=always") {
continue;
}
let after_sep = call.iter().skip_while(|a| *a != "--").skip(1).count();
assert_eq!(after_sep, 1, "relaxed call is not path-scoped: {call:?}");
assert!(
call.iter().any(|a| a == "mirrored"),
"file protocol relaxed for a non-mirrored submodule: {call:?}"
);
assert!(
!call.iter().any(|a| a.contains("orphan")),
"file protocol relaxed for an unmirrored submodule: {call:?}"
);
}
}
#[test]
fn a_mirror_name_cannot_escape_the_modules_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("modules");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(root.join("real")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
assert!(mirror_within(&root, "real", &root).is_some());
assert_eq!(mirror_within(&root, "../outside", &root), None);
assert_eq!(mirror_within(&root, "../../outside", &root), None);
assert_eq!(mirror_within(&root, "", &root), None);
assert_eq!(
mirror_within(&root, outside.to_str().unwrap(), &root),
None,
"an absolute name must not be accepted"
);
}
#[test]
#[cfg(unix)]
fn a_mirror_symlinked_out_of_the_modules_directory_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("modules");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("sneaky")).unwrap();
assert_eq!(mirror_within(&root, "sneaky", &root), None);
}
#[test]
fn a_submodule_without_a_mirror_is_skipped_not_failed() {
let repo = TestRepo::init();
repo.write(
".gitmodules",
"[submodule \"orphan\"]\n\tpath = orphan\n\turl = ../nowhere\n",
);
repo.commit_all("declare an unmirrored submodule");
let (wt, common) = worktree_of(&repo, "topic");
let report = seed_from_mirrors(&RealGit, &wt, &common);
assert_eq!(report.skipped, vec!["orphan".to_string()]);
assert!(report.seeded.is_empty());
assert!(report.failed.is_empty());
}
#[test]
fn no_submodules_is_an_empty_report() {
let repo = TestRepo::init();
let (wt, common) = worktree_of(&repo, "topic");
let report = seed_from_mirrors(&RealGit, &wt, &common);
assert!(report.is_empty());
assert!(report.skipped.is_empty());
}
#[test]
fn seeding_produces_the_same_state_as_a_stock_update() {
let repo = TestRepo::init();
repo.add_nested_submodule("libs/sub", "deep");
repo.add_worktree("seeded", "../wt-seeded");
let seeded = repo.root().parent().unwrap().join("wt-seeded");
let common = repo.root().join(".git");
seed_from_mirrors(&RealGit, &seeded, &common);
super::super::sync(&RealGit, &seeded).unwrap();
super::super::update_init(&RealGit, &seeded).unwrap();
repo.add_worktree("stock", "../wt-stock");
let stock = repo.root().parent().unwrap().join("wt-stock");
repo.git(&[
"-C",
&stock.to_string_lossy(),
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
"--recursive",
]);
let status = |dir: &Path| {
repo.git(&[
"-C",
&dir.to_string_lossy(),
"submodule",
"status",
"--recursive",
])
};
assert_eq!(status(&seeded), status(&stock));
let origin = |dir: &Path, sub: &str| {
repo.git(&[
"-C",
&dir.join(sub).to_string_lossy(),
"config",
"remote.origin.url",
])
};
assert_eq!(origin(&seeded, "libs/sub"), origin(&stock, "libs/sub"));
assert_eq!(
origin(&seeded, "libs/sub/deep"),
origin(&stock, "libs/sub/deep")
);
}
#[test]
fn seeds_nested_submodules_recursively() {
let repo = TestRepo::init();
repo.add_nested_submodule("libs/sub", "deep");
let (wt, common) = worktree_of(&repo, "topic");
let report = seed_from_mirrors(&RealGit, &wt, &common);
assert_eq!(
report.seeded,
vec!["libs/sub".to_string(), "libs/sub/deep".to_string()]
);
assert!(wt.join("libs/sub/deep/deep.txt").exists());
}
}