Add submodule management and enable recursive clone by default #44
@@ -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.
|
||||
|
||||
+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,
|
||||
|
||||
+159
-1
@@ -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<GitSubmodule[] | null> {
|
||||
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<void>): Promise<boolean> {
|
||||
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}
|
||||
<module.default
|
||||
modules={submodules} isLoading={submodulesLoading} {isBusy} error={submoduleError}
|
||||
language={appLanguage} recursive={submoduleRecursive}
|
||||
onRecursive={(value) => { 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}
|
||||
<module.default modules={pendingSubmoduleInitialization.modules} language={appLanguage} {isBusy}
|
||||
error={submoduleInitializationError} onInitialize={initializePendingSubmodules} onLater={dismissSubmoduleInitialization} />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Box,
|
||||
Boxes,
|
||||
Bug,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
@@ -44,6 +45,8 @@
|
||||
export let onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
export let uninitializedSubmoduleCount = 0;
|
||||
export let onOpenSubmodules: () => void = () => {};
|
||||
export let onOpenLfs: () => void = () => {};
|
||||
|
||||
let historyOpen = false;
|
||||
@@ -51,6 +54,9 @@
|
||||
let toolbarElement: HTMLDivElement;
|
||||
|
||||
$: isGerman = language === "de";
|
||||
$: submoduleLabel = uninitializedSubmoduleCount > 0
|
||||
? (isGerman ? `Submodule verwalten – ${uninitializedSubmoduleCount} nicht initialisiert` : `Manage submodules – ${uninitializedSubmoduleCount} not initialized`)
|
||||
: (isGerman ? "Submodule verwalten" : "Manage submodules");
|
||||
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
|
||||
$: pushTitle = localOnly
|
||||
? (isGerman
|
||||
@@ -220,6 +226,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="repo-action"
|
||||
type="button"
|
||||
onclick={onOpenSubmodules}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={submoduleLabel}
|
||||
aria-label={submoduleLabel}
|
||||
>
|
||||
<Boxes size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Submodule" : "Submodules"}</span>
|
||||
{#if uninitializedSubmoduleCount > 0}<span class="repo-action-count ahead" aria-hidden="true">{uninitializedSubmoduleCount}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="repo-toolbar-spacer"></div>
|
||||
|
||||
@@ -346,7 +346,7 @@
|
||||
{:else}
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Seit" : "Since"}</span><input type="date" bind:value={shallowSince} disabled={isBusy} aria-invalid={!shallowSince.trim()} /></label>
|
||||
{/if}
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Zusätzliche Flags" : "Custom flags"}</span><input bind:value={customFlags} autocomplete="off" spellcheck="false" placeholder="--recurse-submodules --single-branch" disabled={isBusy} /></label>
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Zusätzliche Flags" : "Custom flags"}</span><input bind:value={customFlags} autocomplete="off" spellcheck="false" placeholder="--single-branch" disabled={isBusy} /></label>
|
||||
<small class="clone-option-help">{isGerman ? "Flags wie in der Git-Kommandozeile; verwaltete oder unsichere Flags werden abgewiesen." : "Enter flags as on the Git command line; managed or unsafe flags are rejected."}</small>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { Boxes, Plus, RefreshCw, X, ExternalLink, GitCommitHorizontal, LoaderCircle } from "@lucide/svelte";
|
||||
import type { GitSubmodule } from "../types";
|
||||
interface Props {
|
||||
modules: GitSubmodule[]; isLoading: boolean; isBusy: boolean; error: string;
|
||||
language: "de" | "en"; recursive: boolean;
|
||||
onRecursive: (value: boolean) => void;
|
||||
onRefresh: () => void; onClose: () => void;
|
||||
onAdd: (url: string, path: string, branch: string) => Promise<boolean>;
|
||||
onAction: (module: GitSubmodule, action: "update" | "stage" | "sync") => Promise<boolean>;
|
||||
onOpen: (module: GitSubmodule) => void;
|
||||
}
|
||||
let { modules, isLoading, isBusy, error, language, recursive, onRecursive, onRefresh, onClose, onAdd, onAction, onOpen }: Props = $props();
|
||||
const t = (de: string, en: string) => language === "de" ? de : en;
|
||||
let selectedPath = $state("");
|
||||
let adding = $state(false);
|
||||
let url = $state("");
|
||||
let destination = $state("");
|
||||
let branch = $state("");
|
||||
let selected = $derived(modules.find(m => m.path === selectedPath) ?? modules[0]);
|
||||
let disabled = $derived(isBusy || isLoading);
|
||||
function statusLabel(m: GitSubmodule) {
|
||||
if (m.conflicted) return t("Konflikt", "Conflict");
|
||||
if (!m.local_commit) return t("Nicht initialisiert", "Not initialized");
|
||||
if (m.dirty) return t("Lokale Änderungen", "Local changes");
|
||||
if (m.local_commit !== m.recorded_commit) return t("Abweichender Commit", "Different commit");
|
||||
return t("Sauber", "Clean");
|
||||
}
|
||||
function focusDialog(node: HTMLElement) {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
(node.querySelector<HTMLElement>("button:not(:disabled)") ?? node).focus();
|
||||
const trap = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = Array.from(node.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), [tabindex="0"]'));
|
||||
const first = controls[0]; const last = controls[controls.length - 1];
|
||||
if (!first) { event.preventDefault(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === node)) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && (document.activeElement === last || document.activeElement === node)) { event.preventDefault(); first.focus(); }
|
||||
};
|
||||
node.addEventListener("keydown", trap);
|
||||
return { destroy() { node.removeEventListener("keydown", trap); previous?.focus(); } };
|
||||
}
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (await onAdd(url.trim(), destination.trim(), branch.trim())) {
|
||||
adding = false; url = ""; destination = ""; branch = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog submodule-dialog" role="dialog" aria-modal="true" aria-labelledby="submodule-title" tabindex="-1" use:focusDialog>
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon"><Boxes size={18} /></span>
|
||||
<div class="unified-dialog-text"><span class="eyebrow">{t("Eingebundene Repositories", "Embedded repositories")}</span><h2 class="dialog-title" id="submodule-title">{t("Submodule", "Submodules")}</h2></div>
|
||||
<button class="btn-sm" onclick={onRefresh} disabled={disabled}><RefreshCw size={15} class={isLoading ? "spin" : ""} />{t("Aktualisieren", "Refresh")}</button>
|
||||
<button class="dialog-close" onclick={onClose} disabled={disabled} aria-label={t("Schließen", "Close")}><X size={18} /></button>
|
||||
</header>
|
||||
<div class="submodule-content">
|
||||
<div class="submodule-toolbar">
|
||||
<span class="submodule-count">{modules.length} {t("Submodule", "submodules")}</span>
|
||||
<label class="submodule-check"><input type="checkbox" checked={recursive} disabled={disabled} onchange={e => onRecursive(e.currentTarget.checked)} />{t("Verschachtelte einschließen", "Include nested")}</label>
|
||||
<button class="btn-primary" disabled={disabled} onclick={() => adding = !adding}><Plus size={15} />{t("Hinzufügen", "Add submodule")}</button>
|
||||
</div>
|
||||
{#if error}<div class="submodule-error" role="alert">{error}</div>{/if}
|
||||
{#if adding}
|
||||
<form class="submodule-add" onsubmit={submit}>
|
||||
<h3>{t("Submodul hinzufügen", "Add submodule")}</h3>
|
||||
<label>{t("Repository-URL", "Repository URL")}<input bind:value={url} required disabled={disabled} placeholder="https://github.com/team/repository.git" /></label>
|
||||
<div class="submodule-fields">
|
||||
<label>{t("Pfad im Repository", "Path in repository")}<input bind:value={destination} required disabled={disabled} placeholder="libs/repository" /></label>
|
||||
<label>{t("Tracking-Branch · optional", "Tracking branch · optional")}<input bind:value={branch} disabled={disabled} placeholder={t("Standard-Branch", "Default branch")} /></label>
|
||||
</div>
|
||||
<p>{t("Die .gitmodules-Datei und der neue Verweis werden zum Commit vorgemerkt.", "The .gitmodules file and new reference will be staged for commit.")}</p>
|
||||
<div class="submodule-actions"><button class="btn-secondary" type="button" disabled={disabled} onclick={() => adding = false}>{t("Abbrechen", "Cancel")}</button><button class="btn-primary" disabled={disabled || !url.trim() || !destination.trim()}>{t("Submodul hinzufügen", "Add submodule")}</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
{#if isLoading && !modules.length}
|
||||
<div class="submodule-empty" role="status"><LoaderCircle size={24} class="spin" />{t("Submodule werden geladen…", "Loading submodules…")}</div>
|
||||
{:else if !modules.length}
|
||||
<div class="submodule-empty"><Boxes size={30} /><h3>{t("Noch keine Submodule", "No submodules yet")}</h3><p>{t("Binde ein anderes Repository unter einem eigenen Ordner ein.", "Embed another repository in its own directory.")}</p></div>
|
||||
{:else}
|
||||
<div class="submodule-workspace">
|
||||
<nav class="submodule-list" aria-label={t("Submodule auswählen", "Select submodule")}>
|
||||
{#each modules as module (module.path)}
|
||||
<button class:active={selected?.path === module.path} aria-pressed={selected?.path === module.path} disabled={disabled} onclick={() => selectedPath = module.path}>
|
||||
<strong>{module.depth > 0 ? "↳ " : ""}{module.name}</strong><code>{module.path}</code>
|
||||
<span class:attention={module.dirty || module.conflicted || module.local_commit !== module.recorded_commit}>{statusLabel(module)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if selected}
|
||||
<section class="submodule-detail" aria-live="polite">
|
||||
<h3>{selected.name}</h3><code class="submodule-path">{selected.path}</code>
|
||||
<dl><dt>{t("Repository", "Repository")}</dt><dd>{selected.url || "—"}</dd><dt>{t("Commit im Index des übergeordneten Repositorys", "Commit in parent repository index")}</dt><dd><GitCommitHorizontal size={14} /><code>{selected.recorded_commit.slice(0, 12)}</code></dd><dt>{t("Lokal ausgecheckt", "Checked out locally")}</dt><dd><code>{selected.local_commit?.slice(0, 12) ?? "—"}</code><span>{selected.branch ?? (selected.local_commit ? "Detached HEAD" : t("Nicht initialisiert", "Not initialized"))}</span></dd></dl>
|
||||
<div class="submodule-notice">
|
||||
{#if selected.conflicted}{t("Löse zuerst den Submodul-Konflikt im übergeordneten Repository.", "Resolve the submodule conflict in the parent repository first.")}
|
||||
{:else if !selected.local_commit}{t("Initialisieren lädt den im übergeordneten Repository gespeicherten Commit.", "Initialize downloads the commit recorded in the parent repository.")}
|
||||
{:else if selected.dirty}{t("Dieses Submodul enthält lokale Änderungen. Öffne es, um diese zu committen oder zu stashen.", "This submodule contains local changes. Open it to commit or stash them.")}
|
||||
{:else if selected.local_commit !== selected.recorded_commit}{t("Der lokale Commit weicht vom gespeicherten Verweis ab. Checke den gespeicherten Stand aus oder stage den lokalen Verweis im übergeordneten Repository.", "The local commit differs from the recorded reference. Check out the recorded commit or stage the local reference in the parent repository.")}
|
||||
{:else}{t("Der lokale Stand entspricht dem gespeicherten Commit.", "The local state matches the recorded commit.")}{/if}
|
||||
</div>
|
||||
<div class="submodule-actions">
|
||||
<button class="btn-primary" disabled={disabled || selected.dirty || selected.conflicted} onclick={() => selected && onAction(selected, "update")}>{selected.local_commit ? t("Gespeicherten Stand auschecken", "Check out recorded commit") : t("Initialisieren", "Initialize")}</button>
|
||||
{#if selected.local_commit && selected.local_commit !== selected.recorded_commit}<button class="btn-secondary" disabled={disabled || selected.conflicted} onclick={() => selected && onAction(selected, "stage")}>{t("Verweis stagen", "Stage reference")}</button>{/if}
|
||||
{#if selected.local_commit}<button class="btn-secondary" disabled={disabled} onclick={() => selected && onOpen(selected)}><ExternalLink size={14} />{t("Als Repository öffnen", "Open as repository")}</button>{/if}
|
||||
<button class="btn-secondary" disabled={disabled || selected.conflicted} onclick={() => selected && onAction(selected, "sync")}>{t("URL synchronisieren", "Sync URL")}</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="submodule-footer">{t("Submodule sind auf einen Commit festgelegt. Änderungen am Verweis anschließend im übergeordneten Repository committen.", "Submodules are pinned to a commit. Commit reference changes in the parent repository afterwards.")}</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.submodule-dialog { width: min(940px, calc(100vw - 32px)); max-width: 940px; }
|
||||
.submodule-content { padding: 20px; overflow-y: auto; min-height: 0; }
|
||||
.submodule-toolbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.submodule-count { flex: 1; color: var(--color-ink-muted); }
|
||||
.submodule-check { display: flex; gap: 7px; align-items: center; font-size: 12px; color: var(--color-ink-muted); }
|
||||
.submodule-check input { width: 15px; height: 15px; min-width: 15px; padding: 0; margin: 0; flex: 0 0 15px; accent-color: var(--color-primary); }
|
||||
.submodule-workspace { display: grid; grid-template-columns: minmax(220px, .85fr) minmax(0, 1.3fr); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; }
|
||||
.submodule-list { background: var(--color-surface); border-right: 1px solid var(--color-border); }
|
||||
.submodule-list button { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; width: 100%; padding: 17px; border: 0; border-bottom: 1px solid var(--color-border); border-radius: 0; text-align: left; background: transparent; color: var(--color-ink); overflow-wrap: anywhere; }
|
||||
.submodule-list button.active { background: color-mix(in srgb, var(--color-primary) 12%, transparent); box-shadow: inset 3px 0 var(--color-primary); }
|
||||
.submodule-list span, .submodule-list code, .submodule-path { font-size: 12px; color: var(--color-ink-muted); }
|
||||
.submodule-list .attention { color: var(--color-sync-ahead); }
|
||||
.submodule-detail { padding: 22px; min-width: 0; }
|
||||
h3 { margin: 0 0 6px; font-size: 16px; }
|
||||
code, dd { overflow-wrap: anywhere; }
|
||||
dt { margin: 22px 0 7px; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--color-ink-muted); }
|
||||
dd { margin: 0; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font-size: 12px; }
|
||||
dd span { margin-left: auto; color: var(--color-ink-muted); }
|
||||
.submodule-notice { padding: 12px; margin: 20px 0; background: var(--color-surface); border-radius: 6px; font-size: 12px; line-height: 1.6; color: var(--color-ink-muted); }
|
||||
.submodule-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.submodule-actions button { white-space: normal; }
|
||||
.submodule-footer { border-top: 1px solid var(--color-border); padding: 14px 20px; font-size: 11px; line-height: 1.5; color: var(--color-ink-muted); }
|
||||
.submodule-empty { padding: 42px 20px; display: flex; flex-direction: column; gap: 12px; align-items: center; text-align: center; color: var(--color-ink-muted); }
|
||||
.submodule-error { padding: 12px; margin-bottom: 14px; color: var(--code-delete-text); background: var(--code-delete-bg); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.submodule-add { padding: 18px; border: 1px solid var(--color-border); border-radius: 8px; margin-bottom: 18px; background: var(--color-surface); }
|
||||
.submodule-add label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; margin: 13px 0; min-width: 0; }
|
||||
.submodule-add input { width: 100%; min-width: 0; }
|
||||
.submodule-add p { font-size: 12px; color: var(--color-ink-muted); margin: 4px 0 16px; }
|
||||
.submodule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 650px) { .submodule-workspace, .submodule-fields { grid-template-columns: 1fr; } .submodule-list { border-right: 0; } .submodule-content { padding: 12px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Boxes, Download, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitSubmodule } from "../types";
|
||||
interface Props {
|
||||
modules: GitSubmodule[];
|
||||
language: "de" | "en";
|
||||
isBusy: boolean;
|
||||
error: string;
|
||||
onInitialize: () => void | Promise<void>;
|
||||
onLater: () => void;
|
||||
}
|
||||
let { modules, language, isBusy, error, onInitialize, onLater }: Props = $props();
|
||||
const t = (de: string, en: string) => language === "de" ? de : en;
|
||||
function focusDialog(node: HTMLElement) {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
(node.querySelector<HTMLElement>("button:not(:disabled)") ?? node).focus();
|
||||
function trap(event: KeyboardEvent) {
|
||||
if (event.key !== "Tab") return;
|
||||
const buttons = Array.from(node.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"));
|
||||
const first = buttons[0], last = buttons[buttons.length - 1];
|
||||
if (!first) { event.preventDefault(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === node)) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && (document.activeElement === last || document.activeElement === node)) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
node.addEventListener("keydown", trap);
|
||||
return { destroy() { node.removeEventListener("keydown", trap); previous?.focus(); } };
|
||||
}
|
||||
</script>
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog submodule-init-dialog" role="dialog" aria-modal="true" aria-labelledby="submodule-init-title" aria-describedby="submodule-init-description" tabindex="-1" use:focusDialog>
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><Boxes size={18} /></span>
|
||||
<div class="unified-dialog-text"><span class="eyebrow">{t("Pull abgeschlossen", "Pull complete")}</span><h2 class="dialog-title" id="submodule-init-title">{t("Submodule initialisieren?", "Initialize submodules?")}</h2></div>
|
||||
<button class="dialog-close" disabled={isBusy} onclick={onLater} aria-label={t("Später", "Later")}><X size={18} /></button>
|
||||
</header>
|
||||
<div class="init-body">
|
||||
<p id="submodule-init-description">{t("Diese Submodule sind im Repository vorhanden, aber noch nicht geladen:", "These submodules exist in the repository but have not been downloaded:")}</p>
|
||||
<ul>{#each modules as module (module.path)}<li><code>{module.path}</code></li>{/each}</ul>
|
||||
<p class="hint">{t("Die gespeicherten Commits werden einschließlich verschachtelter Submodule heruntergeladen. Mit „Später“ bleibt der Hinweis am Submodule-Button bestehen.", "The recorded commits will be downloaded, including nested submodules. Choosing Later keeps the badge on the Submodules button.")}</p>
|
||||
{#if error}<p class="init-error" role="alert">{error}</p>{/if}
|
||||
</div>
|
||||
<footer class="init-actions">
|
||||
<button class="btn-secondary" onclick={onLater} disabled={isBusy}>{t("Später", "Later")}</button>
|
||||
<button class="btn-primary" onclick={onInitialize} disabled={isBusy}>
|
||||
{#if isBusy}<LoaderCircle size={15} class="spin" />{:else}<Download size={15} />{/if}
|
||||
{t("Jetzt initialisieren", "Initialize now")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.submodule-init-dialog { width: min(560px, calc(100vw - 32px)); height: auto; max-height: calc(100vh - 48px); }
|
||||
.init-body { padding: 20px; overflow-y: auto; min-height: 0; font-size: 13px; line-height: 1.6; }
|
||||
p { margin: 0; }
|
||||
ul { margin: 16px 0; padding-left: 20px; }
|
||||
li { padding: 4px 0; overflow-wrap: anywhere; }
|
||||
.hint { color: var(--color-ink-muted); font-size: 12px; }
|
||||
.init-error { margin-top: 16px; color: var(--code-delete-text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.init-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--color-border); flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -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<GitSubmodule[]> {
|
||||
return invoke("list_submodules", { path, recursive });
|
||||
}
|
||||
export function addSubmodule(path: string, url: string, destination: string, branch?: string): Promise<void> {
|
||||
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<void> {
|
||||
return invoke("submodule_action", { path, modulePath, action, recursive });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user