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, pub recorded_commit: String, pub local_commit: Option, pub dirty: bool, pub conflicted: bool, pub depth: usize, } fn safe_path(repo: &Path, path: &str) -> Result { 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 { 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, ) -> 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, 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, 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, password: Option, ) -> 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, username: Option, password: Option, ) -> 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()); } } }