Add credential-aware submodule operations and a command to checkout a specific
tag or commit in a submodule without staging the parent repository.
- Backend (src-tauri):
- Export checkout_submodule_revision and implement checkout_revision which
validates tag vs commit inputs, verifies refs locally, and checks out the
submodule in detached mode without modifying the parent's index.
- Add optional username/password parameters to add_submodule and submodule_action
flows. Implement submodule_git to call run_git_authenticated when credentials
are supplied and classify auth failures by prefixing errors with "AUTH_FAILED:".
- Wire authenticated variants (operate_authenticated, add_authenticated) and
update fetch/update actions to use credentials where needed.
- Add unit tests covering authenticated submodule commands, auth failure
classification, and checkout-by-tag/commit behavior.
- Frontend:
- App.svelte: introduce credential prompt flow (withSubmoduleCredentials,
submit/cancel handlers), surface credential dialog on auth failures, and
wire credentialed calls for initialize/add/update/fetch operations. Hook up
checkoutSubmoduleRevision and listTags to the submodule dialog.
- SubmoduleDialog.svelte: add UI for selecting destination folder, loading
tags and checking out revisions; expose fetch action.
- CredentialDialog.svelte: include "submodule" action and adjust labels.
- Docs:
- README: document "Change commit or tag" and "Fetch tags & commits" behaviors.
The commit focuses only on enabling credentialed submodule interactions and
safe local checkouts of tags/commits; no other git behavior changes are made.
849 lines
27 KiB
Rust
849 lines
27 KiB
Rust
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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Result<(), String> {
|
|
operate_authenticated(repo, module_path, action, recursive, None, None)
|
|
}
|
|
|
|
fn operate_authenticated(
|
|
repo: &Path,
|
|
module_path: &str,
|
|
action: &str,
|
|
recursive: bool,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
) -> 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, username, password)?;
|
|
}
|
|
"fetch" => {
|
|
if module.local_commit.is_none() {
|
|
return Err("Initialize the submodule first.".into());
|
|
}
|
|
submodule_git(
|
|
Path::new(&module.full_path),
|
|
&["fetch", "--tags"],
|
|
username,
|
|
password,
|
|
)?;
|
|
}
|
|
"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, username, password)?;
|
|
}
|
|
_ => return Err("Unknown submodule action.".into()),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn submodule_action(
|
|
path: String,
|
|
module_path: String,
|
|
action: String,
|
|
recursive: bool,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
run_git_task("Could not update submodule", move || {
|
|
operate_authenticated(
|
|
&resolve_repo(&path)?,
|
|
&module_path,
|
|
&action,
|
|
recursive,
|
|
username.as_deref(),
|
|
password.as_deref(),
|
|
)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn checkout_revision(
|
|
repo: &Path,
|
|
module_path: &str,
|
|
revision: &str,
|
|
kind: &str,
|
|
) -> 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.local_commit.is_none() {
|
|
return Err("Initialize the submodule first.".into());
|
|
}
|
|
if module.dirty || module.conflicted {
|
|
return Err("Commit or stash local changes and resolve conflicts before changing the submodule revision.".into());
|
|
}
|
|
let target = Path::new(&module.full_path);
|
|
let revision = revision.trim();
|
|
let reference = match kind {
|
|
"commit"
|
|
if (4..=64).contains(&revision.len())
|
|
&& revision.bytes().all(|c| c.is_ascii_hexdigit()) =>
|
|
{
|
|
revision.to_owned()
|
|
}
|
|
"tag" => {
|
|
let reference = format!("refs/tags/{revision}");
|
|
run_git(target, ["check-ref-format", &reference])?;
|
|
reference
|
|
}
|
|
_ => return Err("Choose a tag or enter a valid commit hash.".into()),
|
|
};
|
|
let hash = run_git(
|
|
target,
|
|
[
|
|
"rev-parse",
|
|
"--verify",
|
|
"--end-of-options",
|
|
&format!("{reference}^{{commit}}"),
|
|
],
|
|
)
|
|
.map_err(|_| "Commit or tag was not found locally. Fetch tags and commits first.".to_owned())?;
|
|
let hash = String::from_utf8_lossy(&hash);
|
|
run_git(
|
|
target,
|
|
[
|
|
"checkout",
|
|
"--detach",
|
|
"--no-recurse-submodules",
|
|
hash.trim(),
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn checkout_submodule_revision(
|
|
path: String,
|
|
module_path: String,
|
|
revision: String,
|
|
kind: String,
|
|
) -> Result<(), String> {
|
|
run_git_task("Could not change submodule revision", move || {
|
|
checkout_revision(&resolve_repo(&path)?, &module_path, &revision, &kind)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> {
|
|
add_authenticated(repo, url, destination, branch, None, None)
|
|
}
|
|
|
|
fn add_authenticated(
|
|
repo: &Path,
|
|
url: &str,
|
|
destination: &str,
|
|
branch: Option<&str>,
|
|
username: Option<&str>,
|
|
password: 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, username, password)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn submodule_git(
|
|
repo: &Path,
|
|
args: &[&str],
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
) -> Result<(), String> {
|
|
if let (Some(username), Some(password)) = (username, password) {
|
|
return super::run_git_authenticated(repo, args, username, password).map(|_| ());
|
|
}
|
|
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 {
|
|
let details = super::command_output_details(&output);
|
|
if super::is_auth_error(&details) {
|
|
Err(format!("AUTH_FAILED:{details}"))
|
|
} else {
|
|
Err(details)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn add_submodule(
|
|
path: String,
|
|
url: String,
|
|
destination: String,
|
|
branch: Option<String>,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
run_git_task("Could not add submodule", move || {
|
|
add_authenticated(
|
|
&resolve_repo(&path)?,
|
|
&url,
|
|
&destination,
|
|
branch.as_deref(),
|
|
username.as_deref(),
|
|
password.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]
|
|
#[cfg(unix)]
|
|
fn submodules_authenticated_commands_receive_askpass_credentials() {
|
|
let f = fixture();
|
|
let repo = f.0.join("parent");
|
|
let probe = r#"alias.auth-probe=!test "$("$GIT_ASKPASS" Username)" = 'fixture-user' && test "$("$GIT_ASKPASS" Password)" = 'fixture-token'"#;
|
|
submodule_git(
|
|
&repo,
|
|
&["-c", probe, "auth-probe"],
|
|
Some("fixture-user"),
|
|
Some("fixture-token"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn submodules_auth_failures_are_classified_for_the_login_dialog() {
|
|
let f = fixture();
|
|
let repo = f.0.join("parent");
|
|
let probe = "alias.auth-probe=!echo 'fatal: could not read Username: terminal prompts disabled' >&2; exit 1";
|
|
let error = submodule_git(&repo, &["-c", probe, "auth-probe"], None, None).unwrap_err();
|
|
assert!(error.starts_with("AUTH_FAILED:"));
|
|
let error = submodule_git(
|
|
&repo,
|
|
&["-c", probe, "auth-probe"],
|
|
Some("user"),
|
|
Some("token"),
|
|
)
|
|
.unwrap_err();
|
|
assert!(error.starts_with("AUTH_FAILED:"));
|
|
let error = submodule_git(&repo, &["not-a-command"], None, None).unwrap_err();
|
|
assert!(!error.starts_with("AUTH_FAILED:"));
|
|
}
|
|
|
|
#[test]
|
|
fn submodules_checkout_tags_and_commits_without_staging_parent() {
|
|
let f = fixture();
|
|
let repo = f.0.join("parent");
|
|
let child = repo.join("libs/with spaces");
|
|
let original = list(&repo, true).unwrap()[0].recorded_commit.clone();
|
|
git(&child, &["tag", "v1.0"]);
|
|
fs::write(child.join("file.txt"), "version two\n").unwrap();
|
|
git(
|
|
&child,
|
|
&[
|
|
"-c",
|
|
"user.name=Test",
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"commit",
|
|
"-qam",
|
|
"version two",
|
|
],
|
|
);
|
|
git(
|
|
&child,
|
|
&[
|
|
"-c",
|
|
"user.name=Test",
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"tag",
|
|
"-a",
|
|
"v2.0",
|
|
"-m",
|
|
"version two",
|
|
],
|
|
);
|
|
let latest = list(&repo, true).unwrap()[0].local_commit.clone().unwrap();
|
|
checkout_revision(&repo, "libs/with spaces", "v1.0", "tag").unwrap();
|
|
assert_eq!(
|
|
list(&repo, true).unwrap()[0].local_commit.as_deref(),
|
|
Some(original.as_str())
|
|
);
|
|
checkout_revision(&repo, "libs/with spaces", "v2.0", "tag").unwrap();
|
|
let module = list(&repo, true).unwrap().remove(0);
|
|
assert_eq!(module.local_commit.as_deref(), Some(latest.as_str()));
|
|
assert_eq!(module.recorded_commit, original);
|
|
assert!(module.branch.is_none());
|
|
checkout_revision(&repo, "libs/with spaces", &original[..8], "commit").unwrap();
|
|
assert_eq!(
|
|
list(&repo, true).unwrap()[0].local_commit.as_deref(),
|
|
Some(original.as_str())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn submodules_revision_rejects_unknown_refs_options_and_local_changes() {
|
|
let f = fixture();
|
|
let repo = f.0.join("parent");
|
|
let child = repo.join("libs/with spaces");
|
|
let before = list(&repo, true).unwrap()[0].local_commit.clone();
|
|
for (revision, kind) in [
|
|
("--force", "commit"),
|
|
("HEAD~1", "commit"),
|
|
("../bad", "tag"),
|
|
("missing", "tag"),
|
|
("deadbeef", "commit"),
|
|
("main", "branch"),
|
|
] {
|
|
assert!(checkout_revision(&repo, "libs/with spaces", revision, kind).is_err());
|
|
}
|
|
assert_eq!(list(&repo, true).unwrap()[0].local_commit, before);
|
|
git(&child, &["tag", "valid"]);
|
|
fs::write(child.join("file.txt"), "local changes\n").unwrap();
|
|
assert!(
|
|
checkout_revision(&repo, "libs/with spaces", "valid", "tag")
|
|
.unwrap_err()
|
|
.contains("stash")
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(child.join("file.txt")).unwrap(),
|
|
"local changes\n"
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|
|
}
|