feat(submodules): add submodule management and recursive clone

Add a new backend module to manage Git submodules (list, initialize/update,
stage, sync, add) and expose Tauri commands (list_submodules,
submodule_action, add_submodule). The implementation enforces safe relative
paths, a maximum nesting depth, and guards (dirty/conflicted checks and
initialization/no-op semantics) to avoid unsafe operations.

Refactor clone logic to a testable run_git_clone_command and enable
automatic initialization of submodules during clone by passing
--recurse-submodules. Also disallow certain submodule-related custom clone
flags so callers cannot override this behaviour.

Update README with a Submodules section and add frontend components and types
to surface submodule UI (dialogs, toolbar badge). Unit tests were added for
submodule discovery, initialization/update semantics, and recursive clone
behavior.
This commit is contained in:
2026-09-16 22:31:14 +02:00
parent f0ff1914c9
commit 66c85321ea
11 changed files with 1084 additions and 3 deletions
+597
View File
@@ -0,0 +1,597 @@
use super::{resolve_repo, run_git, run_git_task};
use serde::Serialize;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Serialize)]
pub struct GitSubmodule {
pub name: String,
pub path: String,
pub owner_path: String,
pub relative_path: String,
pub full_path: String,
pub url: String,
pub branch: Option<String>,
pub recorded_commit: String,
pub local_commit: Option<String>,
pub dirty: bool,
pub conflicted: bool,
pub depth: usize,
}
fn safe_path(repo: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty()
|| path.starts_with('-')
|| !Path::new(path)
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
return Err("Submodule path must be a relative path inside the repository.".into());
}
let root = repo.canonicalize().map_err(|e| e.to_string())?;
let target = root.join(path);
let mut parent = target.as_path();
while !parent.exists() {
parent = parent.parent().ok_or("Invalid submodule path")?;
}
if !parent
.canonicalize()
.map_err(|e| e.to_string())?
.starts_with(&root)
{
return Err("Submodule path points outside the repository.".into());
}
Ok(target)
}
fn config(repo: &Path, key: &str) -> Option<String> {
run_git(repo, ["config", "--file", ".gitmodules", "--get", key])
.ok()
.map(|b| String::from_utf8_lossy(&b).trim().to_owned())
}
fn collect(
repo: &Path,
prefix: &str,
depth: usize,
recursive: bool,
result: &mut Vec<GitSubmodule>,
) -> Result<(), String> {
if depth > 32 {
return Err("Submodules exceed the maximum nesting depth (32).".into());
}
let index = run_git(repo, ["ls-files", "--stage", "-z"])?;
let mut links = std::collections::BTreeMap::new();
for entry in index.split(|b| *b == 0).filter(|e| !e.is_empty()) {
let Some(tab) = entry.iter().position(|b| *b == b'\t') else {
continue;
};
let metadata = String::from_utf8_lossy(&entry[..tab]);
let fields: Vec<_> = metadata.split_whitespace().collect();
if fields.len() != 3 || fields[0] != "160000" {
continue;
}
let path = String::from_utf8(entry[tab + 1..].to_vec())
.map_err(|_| "Submodule path is not UTF-8")?;
links.insert(path, (fields[1].to_owned(), fields[2] != "0"));
}
let paths = if repo.join(".gitmodules").exists() {
let output = super::git_command()
.arg("-C")
.arg(repo)
.args([
"config",
"-z",
"--file",
".gitmodules",
"--get-regexp",
"^submodule\\..*\\.path$",
])
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() && output.status.code() != Some(1) {
return Err(super::command_output_details(&output));
}
output.stdout
} else {
Vec::new()
};
let names: std::collections::BTreeMap<_, _> = paths
.split(|b| *b == 0)
.filter_map(|entry| {
let text = String::from_utf8_lossy(entry);
let (key, value) = text.split_once('\n')?;
Some((
value.to_owned(),
key.strip_prefix("submodule.")?
.strip_suffix(".path")?
.to_owned(),
))
})
.collect();
for (relative_path, (recorded_commit, conflicted)) in links {
let full = safe_path(repo, &relative_path)?;
let name = names
.get(&relative_path)
.cloned()
.unwrap_or_else(|| relative_path.clone());
// A directory without its own .git would otherwise resolve to the parent's HEAD.
let initialized = full.join(".git").exists();
let local_commit = if initialized {
Some(
String::from_utf8_lossy(&run_git(&full, ["rev-parse", "HEAD"])?)
.trim()
.to_owned(),
)
} else {
None
};
let dirty = initialized
&& !run_git(
&full,
[
"status",
"--porcelain=v1",
"--untracked-files=normal",
"--ignore-submodules=none",
],
)?
.is_empty();
let path = format!("{prefix}{relative_path}");
result.push(GitSubmodule {
name,
path: path.clone(),
owner_path: repo.to_string_lossy().into_owned(),
relative_path: relative_path.clone(),
full_path: full.to_string_lossy().into_owned(),
url: config(
repo,
&format!(
"submodule.{}.url",
names.get(&relative_path).unwrap_or(&relative_path)
),
)
.unwrap_or_default(),
branch: if initialized {
run_git(&full, ["symbolic-ref", "--quiet", "--short", "HEAD"])
.ok()
.map(|b| String::from_utf8_lossy(&b).trim().to_owned())
} else {
None
},
recorded_commit,
local_commit,
dirty,
conflicted,
depth,
});
if recursive && initialized {
collect(&full, &format!("{path}/"), depth + 1, true, result)?;
}
}
Ok(())
}
fn list(repo: &Path, recursive: bool) -> Result<Vec<GitSubmodule>, String> {
let mut result = Vec::new();
collect(repo, "", 0, recursive, &mut result)?;
Ok(result)
}
#[tauri::command]
pub async fn list_submodules(path: String, recursive: bool) -> Result<Vec<GitSubmodule>, String> {
run_git_task("Could not load submodules", move || {
list(&resolve_repo(&path)?, recursive)
})
.await
}
fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Result<(), String> {
let module = list(repo, true)?
.into_iter()
.find(|m| m.path == module_path)
.ok_or("Submodule no longer exists. Refresh the list.")?;
if module.conflicted {
return Err("Resolve the submodule conflict before continuing.".into());
}
let owner = Path::new(&module.owner_path);
match action {
"update" | "initialize" => {
// A delayed prompt must never reset an already initialized module.
if action == "initialize" && module.local_commit.is_some() {
return Ok(());
}
if module.dirty {
return Err("Commit or stash local submodule changes before checking out the recorded commit.".into());
}
let mut args = vec![
"--literal-pathspecs",
"submodule",
"update",
"--init",
"--checkout",
];
if recursive {
args.push("--recursive");
}
args.extend(["--", module.relative_path.as_str()]);
submodule_git(owner, &args)?;
}
"stage" => {
if module.local_commit.is_none() {
return Err("Initialize the submodule first.".into());
}
run_git(
owner,
["--literal-pathspecs", "add", "--", &module.relative_path],
)?;
}
"sync" => {
let mut args = vec!["--literal-pathspecs", "submodule", "sync"];
if recursive {
args.push("--recursive");
}
args.extend(["--", module.relative_path.as_str()]);
submodule_git(owner, &args)?;
}
_ => return Err("Unknown submodule action.".into()),
}
Ok(())
}
#[tauri::command]
pub async fn submodule_action(
path: String,
module_path: String,
action: String,
recursive: bool,
) -> Result<(), String> {
run_git_task("Could not update submodule", move || {
operate(&resolve_repo(&path)?, &module_path, &action, recursive)
})
.await
}
fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> {
safe_path(repo, destination)?;
if url.trim().is_empty() || url.starts_with('-') {
return Err("Enter a valid repository URL.".into());
}
let mut args = vec!["submodule", "add"];
if let Some(branch) = branch.filter(|b| !b.is_empty()) {
run_git(repo, ["check-ref-format", "--branch", branch])?;
args.extend(["--branch", branch]);
}
args.extend(["--", url, destination]);
submodule_git(repo, &args)?;
Ok(())
}
fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> {
let output = super::git_command()
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(super::command_output_details(&output))
}
}
#[tauri::command]
pub async fn add_submodule(
path: String,
url: String,
destination: String,
branch: Option<String>,
) -> Result<(), String> {
run_git_task("Could not add submodule", move || {
add(&resolve_repo(&path)?, &url, &destination, branch.as_deref())
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn git(repo: &Path, args: &[&str]) {
run_git(repo, args).unwrap();
}
fn fixture() -> Fixture {
let path = std::env::temp_dir().join(format!(
"gitlite-submodules-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
for name in ["parent", "child"] {
let repo = path.join(name);
fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q"]);
git(&repo, &["config", "user.name", "Test"]);
git(&repo, &["config", "user.email", "test@example.com"]);
fs::write(repo.join("file.txt"), "first\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-qm", "initial"]);
}
let parent = path.join("parent");
let child = path.join("child");
git(
&parent,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"--",
child.to_str().unwrap(),
"libs/with spaces",
],
);
git(&parent, &["commit", "-qam", "submodule"]);
Fixture(path)
}
#[test]
fn submodules_list_clean_and_uninitialized() {
let f = fixture();
let repo = f.0.join("parent");
let modules = list(&repo, true).unwrap();
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].path, "libs/with spaces");
assert_eq!(
modules[0].local_commit.as_deref(),
Some(modules[0].recorded_commit.as_str())
);
assert!(!modules[0].dirty);
git(&repo, &["submodule", "deinit", "--", "libs/with spaces"]);
assert!(list(&repo, true).unwrap()[0].local_commit.is_none());
operate(&repo, "libs/with spaces", "initialize", true).unwrap();
assert!(list(&repo, true).unwrap()[0].local_commit.is_some());
}
#[test]
fn submodules_dirty_checkout_is_rejected_and_reference_can_be_staged() {
let f = fixture();
let repo = f.0.join("parent");
let child = repo.join("libs/with spaces");
fs::write(child.join("file.txt"), "changed\n").unwrap();
assert!(list(&repo, true).unwrap()[0].dirty);
assert!(
operate(&repo, "libs/with spaces", "update", true)
.unwrap_err()
.contains("stash")
);
assert_eq!(
fs::read_to_string(child.join("file.txt")).unwrap(),
"changed\n"
);
git(
&child,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qam",
"new commit",
],
);
let before = list(&repo, false).unwrap();
assert_ne!(
before[0].local_commit.as_deref(),
Some(before[0].recorded_commit.as_str())
);
operate(&repo, "libs/with spaces", "stage", false).unwrap();
let after = list(&repo, false).unwrap();
assert_eq!(
after[0].local_commit.as_deref(),
Some(after[0].recorded_commit.as_str())
);
}
#[test]
fn submodules_detect_new_uninitialized_module_after_pull() {
let f = fixture();
let upstream = f.0.join("parent");
let clone = f.0.join("clone");
git(
&f.0,
&[
"-c",
"protocol.file.allow=always",
"clone",
"--recurse-submodules",
upstream.to_str().unwrap(),
clone.to_str().unwrap(),
],
);
assert!(
list(&clone, true)
.unwrap()
.iter()
.all(|module| module.local_commit.is_some())
);
git(
&upstream,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"--",
f.0.join("child").to_str().unwrap(),
"libs/new-module",
],
);
git(&upstream, &["commit", "-am", "Add new module"]);
git(&clone, &["pull", "--ff-only"]);
let modules = list(&clone, true).unwrap();
let missing: Vec<_> = modules
.iter()
.filter(|module| module.local_commit.is_none())
.collect();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].path, "libs/new-module");
}
#[test]
fn submodules_initialize_does_not_reset_existing_commits_or_changes() {
let f = fixture();
let repo = f.0.join("parent");
let child = repo.join("libs/with spaces");
fs::write(child.join("file.txt"), "new commit\n").unwrap();
git(
&child,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qam",
"new commit",
],
);
fs::write(child.join("file.txt"), "local edits\n").unwrap();
let before = list(&repo, true).unwrap()[0].local_commit.clone();
operate(&repo, "libs/with spaces", "initialize", true).unwrap();
assert_eq!(list(&repo, true).unwrap()[0].local_commit, before);
assert_eq!(
fs::read_to_string(child.join("file.txt")).unwrap(),
"local edits\n"
);
}
#[test]
fn submodules_checkout_restores_recorded_commit() {
let f = fixture();
let repo = f.0.join("parent");
let child = repo.join("libs/with spaces");
fs::write(child.join("file.txt"), "changed\n").unwrap();
git(
&child,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qam",
"new commit",
],
);
operate(&repo, "libs/with spaces", "update", false).unwrap();
assert_eq!(
fs::read_to_string(child.join("file.txt")).unwrap(),
"first\n"
);
}
#[test]
fn submodules_nested_discovery_and_stage_use_parent_repository() {
let f = fixture();
let repo = f.0.join("parent");
let child = repo.join("libs/with spaces");
git(
&child,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"--",
f.0.join("child").to_str().unwrap(),
"nested",
],
);
assert_eq!(list(&repo, false).unwrap().len(), 1);
let modules = list(&repo, true).unwrap();
assert_eq!(modules.len(), 2);
assert_eq!(modules[1].depth, 1);
let nested = child.join("nested");
fs::write(nested.join("file.txt"), "nested change\n").unwrap();
git(
&nested,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qam",
"nested",
],
);
operate(&repo, "libs/with spaces/nested", "stage", true).unwrap();
let modules = list(&repo, true).unwrap();
assert_eq!(
modules[1].local_commit.as_deref(),
Some(modules[1].recorded_commit.as_str())
);
}
#[test]
fn submodules_add_existing_clone_and_handle_literal_pathspecs() {
let f = fixture();
let repo = f.0.join("parent");
let source = f.0.join("child");
git(
&repo,
&["clone", "--", source.to_str().unwrap(), "libs/[sdk]"],
);
add(&repo, source.to_str().unwrap(), "libs/[sdk]", None).unwrap();
let modules = list(&repo, false).unwrap();
let added = modules.iter().find(|m| m.path == "libs/[sdk]").unwrap();
assert_eq!(added.url, source.to_string_lossy());
let child = repo.join("libs/[sdk]");
fs::write(child.join("file.txt"), "new version\n").unwrap();
git(
&child,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qam",
"new version",
],
);
operate(&repo, "libs/[sdk]", "update", false).unwrap();
assert_eq!(
fs::read_to_string(child.join("file.txt")).unwrap(),
"first\n"
);
operate(&repo, "libs/[sdk]", "sync", false).unwrap();
let staged = run_git(&repo, &["diff", "--cached", "--name-only"]).unwrap();
assert!(String::from_utf8_lossy(&staged).contains(".gitmodules"));
}
#[test]
fn submodules_reject_invalid_paths_and_unknown_actions() {
let f = fixture();
let repo = f.0.join("parent");
for path in ["", "../child", "/tmp/outside", "-option"] {
assert!(safe_path(&repo, path).is_err());
}
assert!(operate(&repo, "missing", "stage", false).is_err());
assert!(operate(&repo, "libs/with spaces", "invalid", false).is_err());
assert!(add(&repo, "-option", "libs/new", None).is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(f.0.join("child"), repo.join("outside")).unwrap();
assert!(safe_path(&repo, "outside/new").is_err());
}
}
}