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:
+51
-1
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user