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
+51 -1
View File
@@ -1,3 +1,4 @@
pub mod submodules;
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -5973,6 +5974,17 @@ fn run_git_clone(
username: Option<&str>,
password: Option<&str>,
clone_options: &CloneRunOptions,
) -> Result<(), String> {
run_git_clone_command(git_command(), remote_url, target, username, password, clone_options)
}
fn run_git_clone_command(
mut command: Command,
remote_url: &str,
target: &Path,
username: Option<&str>,
password: Option<&str>,
clone_options: &CloneRunOptions,
) -> Result<(), String> {
if matches!(clone_options.shallow_depth, Some(0)) {
return Err("Shallow clone depth must be at least 1.".to_string());
@@ -5987,7 +5999,6 @@ fn run_git_clone(
let custom_flags = parse_custom_clone_flags(&clone_options.custom_flags)?;
let sparse_paths = validate_sparse_checkout_paths(&clone_options.sparse_paths)?;
let sparse_enabled = clone_options.sparse || !sparse_paths.is_empty();
let mut command = git_command();
let has_explicit_credentials = matches!(
(username, password),
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty()
@@ -6012,6 +6023,8 @@ fn run_git_clone(
command.arg("--sparse");
}
command.args(custom_flags);
// Initialize every submodule, including nested ones, at its recorded commit.
command.arg("--recurse-submodules");
command
.arg("--")
.arg(remote_url)
@@ -6089,6 +6102,9 @@ fn parse_custom_clone_flags(input: &str) -> Result<Vec<String>, String> {
"--filter",
"--mirror",
"--no-checkout",
"--no-recurse-submodules",
"--no-recursive",
"--remote-submodules",
"--reference",
"--reference-if-able",
"--separate-git-dir",
@@ -8359,6 +8375,37 @@ mod tests {
assert!(bundle.warning.is_none());
}
#[test]
fn clone_automatically_downloads_nested_submodules_at_recorded_commits() {
let leaf = init_temp_repo("clone_submodule_leaf");
commit_initial_file(&leaf.path);
let child = init_temp_repo("clone_submodule_child");
commit_initial_file(&child.path);
run_git_test(&child.path, ["-c", "protocol.file.allow=always", "submodule", "add", "--", leaf.path.to_str().unwrap(), "nested module"]);
run_git_test(&child.path, ["commit", "-am", "Add nested module"]);
let source = init_temp_repo("clone_submodule_source");
commit_initial_file(&source.path);
run_git_test(&source.path, ["-c", "protocol.file.allow=always", "submodule", "add", "--", child.path.to_str().unwrap(), "libs/child"]);
run_git_test(&source.path, ["commit", "-am", "Add module"]);
let recorded = git_output_test(&child.path, ["rev-parse", "HEAD"]);
fs::write(child.path.join("new.txt"), "not pinned").unwrap();
run_git_test(&child.path, ["add", "new.txt"]);
run_git_test(&child.path, ["commit", "-m", "Newer unpinned commit"]);
let parent = temp_dir("clone_submodule_target");
let target = parent.path.join("cloned");
// Permit local fixture URLs only in this command, never in production.
let mut command = git_command();
command.args(["-c", "protocol.file.allow=always"]);
run_git_clone_command(command, source.path.to_str().unwrap(), &target, None, None, &CloneRunOptions::default()).unwrap();
assert!(target.join("libs/child/old.txt").exists());
assert!(target.join("libs/child/nested module/old.txt").exists());
assert!(!target.join("libs/child/new.txt").exists());
assert_eq!(git_output_test(&target.join("libs/child"), ["rev-parse", "HEAD"]), recorded);
let status = git_output_test(&target, ["submodule", "status", "--recursive"]);
assert_eq!(status.lines().count(), 2);
assert!(status.lines().all(|line| !line.starts_with('-') && !line.starts_with('+')));
}
#[test]
#[cfg_attr(windows, ignore = "file:// clone URL differs on Windows")]
fn clone_repository_core_supports_shallow_and_sparse_options() {
@@ -8494,6 +8541,9 @@ mod tests {
vec!["--recurse-submodules", "--origin", "team remote"]
);
assert!(parse_custom_clone_flags("--depth 5").is_err());
assert!(parse_custom_clone_flags("--no-recurse-submodules").is_err());
assert!(parse_custom_clone_flags("--no-recursive").is_err());
assert!(parse_custom_clone_flags("--remote-submodules").is_err());
assert!(parse_custom_clone_flags("--upload-pack=/tmp/helper").is_err());
assert!(parse_custom_clone_flags("--config core.hooksPath=/tmp/hooks").is_err());
assert!(parse_custom_clone_flags("--recurse-submodules '").is_err());