diff --git a/README.md b/README.md index 03b7ef7..afdaa50 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,29 @@ Fast, simple, and designed for developers who want a clean Git experience withou - 📦 Repository management - ☁️ GitHub, GitLab, Azure DevOps, and Gitea integrations - 🗄️ Git LFS detection, tracking and object management +- 🧩 Submodule management, including nested repositories - 🎨 Modern and intuitive UI --- +## Submodules + +Cloning automatically downloads and initializes submodules, including nested +submodules, at their recorded commits. The Submodules toolbar badge counts modules +that still need initialization. After a successful pull, Gitty offers to initialize +missing modules; choosing **Later** keeps the badge visible. + +Open a repository and select **Submodules** in the repository toolbar. The dialog +shows each submodule's recorded commit (from its parent's index), checked-out +commit, and local changes. You can add a submodule, initialize it, check out its +recorded commit, stage a changed reference, synchronize its URL from `.gitmodules`, +or open it as a repository tab. Nested submodules are included by default. + +Checking out a recorded commit is blocked when the submodule has local changes; +commit or stash them in that repository first. Adding a submodule stages +`.gitmodules` and the new reference. Commit these changes in the parent repository. +Network operations use your configured Git credential helpers or SSH credentials. + ## 📸 Preview > Screenshots comes later. diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 92b7707..ab9f1f7 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -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, 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()); diff --git a/src-tauri/src/git/submodules.rs b/src-tauri/src/git/submodules.rs new file mode 100644 index 0000000..391e412 --- /dev/null +++ b/src-tauri/src/git/submodules.rs @@ -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, + 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 +} + +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, +) -> 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()); + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5e8b99f..90c425b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index 3cc760a..500dce2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -50,6 +50,9 @@ amendCommit, addRemote, addWorktree, + listSubmodules, + addSubmodule, + submoduleAction, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -193,6 +196,7 @@ GitStatus, GitTag, GitWorktree, + GitSubmodule, PatchApplyAction, PreparedResolution, RebaseCommit, @@ -428,6 +432,16 @@ let renameBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null; let deleteBranchForce = false; + let uninitializedSubmoduleCount = 0; + let submoduleNoticeRequest = 0; + let pendingSubmoduleInitialization: { repoPath: string; modules: GitSubmodule[] } | null = null; + let submoduleInitializationError = ""; + let submoduleDialogOpen = false; + let submodules: GitSubmodule[] = []; + let submodulesLoading = false; + let submoduleError = ""; + let submoduleRecursive = true; + let submoduleLoadId = 0; let worktreeDialogOpen = false; let worktreeInitialBranch = ""; let worktrees: GitWorktree[] = []; @@ -1037,7 +1051,7 @@ } async function autoRefreshTick() { - if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || bisectOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return; + if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || bisectOpen || worktreeDialogOpen || submoduleDialogOpen || pendingSubmoduleInitialization || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -2234,6 +2248,15 @@ worktreeDialogOpen = false; worktreeInitialBranch = ""; worktrees = []; + submoduleDialogOpen = false; + submodules = []; + uninitializedSubmoduleCount = 0; + submoduleNoticeRequest++; + pendingSubmoduleInitialization = null; + submoduleInitializationError = ""; + submoduleError = ""; + submoduleLoadId++; + submodulesLoading = false; worktreeError = ""; globalSearchOpen = false; globalSearchError = ""; @@ -2249,6 +2272,7 @@ repoPath = activeRepoPath; lastStatusFingerprint = statusFingerprint(nextStatus); upsertRepoTab(activeRepoPath, nextStatus); + void refreshSubmoduleNotice(activeRepoPath); void setSyncBadge(nextStatus.ahead, nextStatus.behind, nextStatus.files.length).catch(() => {}); } @@ -3247,6 +3271,104 @@ deleteBranchForce = false; } + async function refreshSubmoduleNotice(path: string): Promise { + const request = ++submoduleNoticeRequest; + try { + const modules = await listSubmodules(path, true); + if (request === submoduleNoticeRequest && sameRepoPath(path, activeRepoPath)) { + uninitializedSubmoduleCount = modules.filter(module => !module.local_commit).length; + } + return modules; + } catch { + return null; + } + } + + function dismissSubmoduleInitialization() { + if (isBusy) return; + pendingSubmoduleInitialization = null; + submoduleInitializationError = ""; + } + + async function initializePendingSubmodules() { + const pending = pendingSubmoduleInitialization; + if (!pending || isBusy || !sameRepoPath(pending.repoPath, activeRepoPath)) return; + operation = appLanguage === "de" ? "Submodule initialisieren" : "Initializing submodules"; + submoduleInitializationError = ""; + try { + for (const module of pending.modules) { + await submoduleAction(pending.repoPath, module.path, "initialize", true); + } + pendingSubmoduleInitialization = null; + } catch (error) { + submoduleInitializationError = errorToMessage(error); + } finally { + const current = await refreshSubmoduleNotice(pending.repoPath); + if (pendingSubmoduleInitialization && current) { + const remaining = current.filter(module => !module.local_commit); + pendingSubmoduleInitialization = remaining.length ? { repoPath: pending.repoPath, modules: remaining } : null; + } + try { await refreshRepositorySnapshot(pending.repoPath); } + catch (error) { errorMessage = errorToMessage(error); } + operation = ""; + } + } + + async function refreshSubmodules() { + if (!activeRepoPath) return; + const path = activeRepoPath; + const request = ++submoduleLoadId; + submodulesLoading = true; + submoduleError = ""; + try { + const loaded = await listSubmodules(path, submoduleRecursive); + if (request === submoduleLoadId && sameRepoPath(path, activeRepoPath)) { + submodules = loaded; + if (submoduleRecursive) uninitializedSubmoduleCount = loaded.filter(module => !module.local_commit).length; + } + } catch (error) { + if (request === submoduleLoadId) submoduleError = errorToMessage(error); + } finally { + if (request === submoduleLoadId) submodulesLoading = false; + } + } + + function openSubmoduleDialog() { + if (!activeRepoPath || isBusy) return; + submoduleDialogOpen = true; + void refreshSubmodules(); + } + + function closeSubmoduleDialog() { + if (isBusy || submodulesLoading) return; + submoduleDialogOpen = false; + submoduleError = ""; + } + + async function runSubmoduleOperation(task: (path: string) => Promise): Promise { + if (!activeRepoPath || isBusy || submodulesLoading) return false; + const path = activeRepoPath; + operation = appLanguage === "de" ? "Submodule aktualisieren" : "Updating submodules"; + submoduleError = ""; + let failure = ""; + try { await task(path); } + catch (error) { failure = errorToMessage(error); } + try { + // Git may have partially succeeded; refresh even after a failed operation. + await refreshSubmodules(); + await refreshRepositorySnapshot(path); + } catch (error) { failure ||= errorToMessage(error); } + finally { operation = ""; } + if (failure) submoduleError = failure; + return !failure && !submoduleError; + } + + async function openSubmoduleTab(module: GitSubmodule) { + if (isBusy || submodulesLoading || !module.local_commit) return; + submoduleDialogOpen = false; + await openRepo(module.full_path); + } + async function openWorktreeDialog(branch = "") { if (!activeRepoPath || isBusy) return; worktreeInitialBranch = branch; @@ -4049,6 +4171,7 @@ mode: CredentialMode, ) { errorMessage = ""; + const pulledRepoPath = activeRepoPath; const pulled = await pullWithUnrelatedHistoryConfirmation(username, password, "Pulling"); if (pulled) { trackEvent("repository_pulled", { @@ -4057,6 +4180,17 @@ }); } handleRemoteResult("pull", key, fromStore, username, mode); + if (pulled && sameRepoPath(pulledRepoPath, activeRepoPath)) { + const modules = await refreshSubmoduleNotice(pulledRepoPath); + if (!sameRepoPath(pulledRepoPath, activeRepoPath)) return; + if (modules === null) { + errorMessage = appLanguage === "de" ? "Pull erfolgreich, aber der Submodule-Status konnte nicht gelesen werden." : "Pull succeeded, but submodule status could not be read."; + } else { + const missing = modules.filter(module => !module.local_commit); + pendingSubmoduleInitialization = missing.length ? { repoPath: pulledRepoPath, modules: missing } : null; + submoduleInitializationError = ""; + } + } } async function pullWithUnrelatedHistoryConfirmation( @@ -5447,6 +5581,8 @@ else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); + else if (event.key === "Escape" && pendingSubmoduleInitialization) dismissSubmoduleInitialization(); + else if (event.key === "Escape" && submoduleDialogOpen) closeSubmoduleDialog(); else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog(); else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog(); else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; @@ -5536,6 +5672,8 @@ onForcePush={forcePushRepo} onSyncOptions={openSyncOptions} onOpenLfs={openGitLfsDialog} + onOpenSubmodules={openSubmoduleDialog} + {uninitializedSubmoduleCount} /> {/if} @@ -6463,3 +6601,23 @@ /> {/await} {/if} + +{#if submoduleDialogOpen} + {#await import("./lib/components/SubmoduleDialog.svelte") then module} + { submoduleRecursive = value; void refreshSubmodules(); }} + onRefresh={refreshSubmodules} onClose={closeSubmoduleDialog} onOpen={openSubmoduleTab} + onAdd={(url, destination, branch) => runSubmoduleOperation(path => addSubmodule(path, url, destination, branch))} + onAction={(selected, action) => runSubmoduleOperation(path => submoduleAction(path, selected.path, action, submoduleRecursive))} + /> + {/await} +{/if} + +{#if pendingSubmoduleInitialization} + {#await import("./lib/components/SubmoduleInitDialog.svelte") then module} + + {/await} +{/if} diff --git a/src/lib/RepoToolbar.svelte b/src/lib/RepoToolbar.svelte index 96ad4e5..68ff0de 100644 --- a/src/lib/RepoToolbar.svelte +++ b/src/lib/RepoToolbar.svelte @@ -1,6 +1,7 @@ + + + + diff --git a/src/lib/components/SubmoduleInitDialog.svelte b/src/lib/components/SubmoduleInitDialog.svelte new file mode 100644 index 0000000..5e79408 --- /dev/null +++ b/src/lib/components/SubmoduleInitDialog.svelte @@ -0,0 +1,60 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index 303bd63..72507fa 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -32,6 +32,7 @@ import type { GitStatus, GitTag, GitWorktree, + GitSubmodule, PatchApplyAction, RepositoryBundle, StoredCredential, @@ -732,3 +733,13 @@ export function createIntegrationIssue(provider: GitIntegrationProvider, baseUrl export function pullRequestAiGenerate(path: string, remote: string, sourceBranch: string, targetBranch: string, options: { provider: string; model: string; apiKey?: string; baseUrl?: string; language: string }): Promise<{title: string; description: string}> { return invoke("pull_request_ai_generate", { path, remote, sourceBranch, targetBranch, ...options }); } + +export function listSubmodules(path: string, recursive = true): Promise { + return invoke("list_submodules", { path, recursive }); +} +export function addSubmodule(path: string, url: string, destination: string, branch?: string): Promise { + return invoke("add_submodule", { path, url, destination, branch: branch || null }); +} +export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize", recursive: boolean): Promise { + return invoke("submodule_action", { path, modulePath, action, recursive }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index be705b7..2208ffb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -491,3 +491,18 @@ export interface IntegrationBoard { export interface IntegrationBoardReference { title: string; webUrl: string; scope: string } export interface IssueComment { id: string; author: string; body: string; createdAt: string; bodyHtml: boolean } + +export interface GitSubmodule { + name: string; + path: string; + owner_path: string; + relative_path: string; + full_path: string; + url: string; + branch: string | null; + recorded_commit: string; + local_commit: string | null; + dirty: boolean; + conflicted: boolean; + depth: number; +}