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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use badge::set_sync_badge;
|
||||
use external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use git::submodules::{add_submodule, list_submodules, submodule_action};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
@@ -362,6 +363,9 @@ async fn main() {
|
||||
rename_branch,
|
||||
rename_remote_branch,
|
||||
delete_branch,
|
||||
list_submodules,
|
||||
add_submodule,
|
||||
submodule_action,
|
||||
list_worktrees,
|
||||
add_worktree,
|
||||
remove_worktree,
|
||||
|
||||
Reference in New Issue
Block a user