From b00e3e5c1802c8d8880b860bed75c26c7594ba2d Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 17 Sep 2026 19:39:40 +0200 Subject: [PATCH] feat(submodules): support auth and checkout submodule revisions Add credential-aware submodule operations and a command to checkout a specific tag or commit in a submodule without staging the parent repository. - Backend (src-tauri): - Export checkout_submodule_revision and implement checkout_revision which validates tag vs commit inputs, verifies refs locally, and checks out the submodule in detached mode without modifying the parent's index. - Add optional username/password parameters to add_submodule and submodule_action flows. Implement submodule_git to call run_git_authenticated when credentials are supplied and classify auth failures by prefixing errors with "AUTH_FAILED:". - Wire authenticated variants (operate_authenticated, add_authenticated) and update fetch/update actions to use credentials where needed. - Add unit tests covering authenticated submodule commands, auth failure classification, and checkout-by-tag/commit behavior. - Frontend: - App.svelte: introduce credential prompt flow (withSubmoduleCredentials, submit/cancel handlers), surface credential dialog on auth failures, and wire credentialed calls for initialize/add/update/fetch operations. Hook up checkoutSubmoduleRevision and listTags to the submodule dialog. - SubmoduleDialog.svelte: add UI for selecting destination folder, loading tags and checking out revisions; expose fetch action. - CredentialDialog.svelte: include "submodule" action and adjust labels. - Docs: - README: document "Change commit or tag" and "Fetch tags & commits" behaviors. The commit focuses only on enabling credentialed submodule interactions and safe local checkouts of tags/commits; no other git behavior changes are made. --- README.md | 3 + src-tauri/src/git/submodules.rs | 265 ++++++++++++++++++++- src-tauri/src/main.rs | 3 +- src/App.svelte | 68 +++++- src/lib/components/CredentialDialog.svelte | 8 +- src/lib/components/SubmoduleDialog.svelte | 103 +++++++- src/lib/git.ts | 12 +- 7 files changed, 433 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index afdaa50..c28971a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ 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. +Use **Change commit or tag** to select a local tag or enter a commit hash; +**Fetch tags & commits** downloads remote revisions using the submodule login. +Checking out a revision leaves the parent index unchanged until you stage its reference. 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 diff --git a/src-tauri/src/git/submodules.rs b/src-tauri/src/git/submodules.rs index 391e412..e11b99b 100644 --- a/src-tauri/src/git/submodules.rs +++ b/src-tauri/src/git/submodules.rs @@ -185,7 +185,19 @@ pub async fn list_submodules(path: String, recursive: bool) -> Result Result<(), String> { + operate_authenticated(repo, module_path, action, recursive, None, None) +} + +fn operate_authenticated( + repo: &Path, + module_path: &str, + action: &str, + recursive: bool, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { let module = list(repo, true)? .into_iter() .find(|m| m.path == module_path) @@ -214,7 +226,18 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res args.push("--recursive"); } args.extend(["--", module.relative_path.as_str()]); - submodule_git(owner, &args)?; + submodule_git(owner, &args, username, password)?; + } + "fetch" => { + if module.local_commit.is_none() { + return Err("Initialize the submodule first.".into()); + } + submodule_git( + Path::new(&module.full_path), + &["fetch", "--tags"], + username, + password, + )?; } "stage" => { if module.local_commit.is_none() { @@ -231,7 +254,7 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res args.push("--recursive"); } args.extend(["--", module.relative_path.as_str()]); - submodule_git(owner, &args)?; + submodule_git(owner, &args, username, password)?; } _ => return Err("Unknown submodule action.".into()), } @@ -244,14 +267,103 @@ pub async fn submodule_action( module_path: String, action: String, recursive: bool, + username: Option, + password: Option, ) -> Result<(), String> { run_git_task("Could not update submodule", move || { - operate(&resolve_repo(&path)?, &module_path, &action, recursive) + operate_authenticated( + &resolve_repo(&path)?, + &module_path, + &action, + recursive, + username.as_deref(), + password.as_deref(), + ) }) .await } +fn checkout_revision( + repo: &Path, + module_path: &str, + revision: &str, + kind: &str, +) -> Result<(), String> { + let module = list(repo, true)? + .into_iter() + .find(|m| m.path == module_path) + .ok_or("Submodule no longer exists. Refresh the list.")?; + if module.local_commit.is_none() { + return Err("Initialize the submodule first.".into()); + } + if module.dirty || module.conflicted { + return Err("Commit or stash local changes and resolve conflicts before changing the submodule revision.".into()); + } + let target = Path::new(&module.full_path); + let revision = revision.trim(); + let reference = match kind { + "commit" + if (4..=64).contains(&revision.len()) + && revision.bytes().all(|c| c.is_ascii_hexdigit()) => + { + revision.to_owned() + } + "tag" => { + let reference = format!("refs/tags/{revision}"); + run_git(target, ["check-ref-format", &reference])?; + reference + } + _ => return Err("Choose a tag or enter a valid commit hash.".into()), + }; + let hash = run_git( + target, + [ + "rev-parse", + "--verify", + "--end-of-options", + &format!("{reference}^{{commit}}"), + ], + ) + .map_err(|_| "Commit or tag was not found locally. Fetch tags and commits first.".to_owned())?; + let hash = String::from_utf8_lossy(&hash); + run_git( + target, + [ + "checkout", + "--detach", + "--no-recurse-submodules", + hash.trim(), + ], + )?; + Ok(()) +} + +#[tauri::command] +pub async fn checkout_submodule_revision( + path: String, + module_path: String, + revision: String, + kind: String, +) -> Result<(), String> { + run_git_task("Could not change submodule revision", move || { + checkout_revision(&resolve_repo(&path)?, &module_path, &revision, &kind) + }) + .await +} + +#[cfg(test)] fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> { + add_authenticated(repo, url, destination, branch, None, None) +} + +fn add_authenticated( + repo: &Path, + url: &str, + destination: &str, + branch: Option<&str>, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { safe_path(repo, destination)?; if url.trim().is_empty() || url.starts_with('-') { return Err("Enter a valid repository URL.".into()); @@ -262,11 +374,19 @@ fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Resul args.extend(["--branch", branch]); } args.extend(["--", url, destination]); - submodule_git(repo, &args)?; + submodule_git(repo, &args, username, password)?; Ok(()) } -fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> { +fn submodule_git( + repo: &Path, + args: &[&str], + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { + if let (Some(username), Some(password)) = (username, password) { + return super::run_git_authenticated(repo, args, username, password).map(|_| ()); + } let output = super::git_command() .arg("-C") .arg(repo) @@ -277,7 +397,12 @@ fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> { if output.status.success() { Ok(()) } else { - Err(super::command_output_details(&output)) + let details = super::command_output_details(&output); + if super::is_auth_error(&details) { + Err(format!("AUTH_FAILED:{details}")) + } else { + Err(details) + } } } @@ -287,9 +412,18 @@ pub async fn add_submodule( url: String, destination: String, branch: Option, + username: Option, + password: Option, ) -> Result<(), String> { run_git_task("Could not add submodule", move || { - add(&resolve_repo(&path)?, &url, &destination, branch.as_deref()) + add_authenticated( + &resolve_repo(&path)?, + &url, + &destination, + branch.as_deref(), + username.as_deref(), + password.as_deref(), + ) }) .await } @@ -345,6 +479,123 @@ mod tests { git(&parent, &["commit", "-qam", "submodule"]); Fixture(path) } + #[test] + #[cfg(unix)] + fn submodules_authenticated_commands_receive_askpass_credentials() { + let f = fixture(); + let repo = f.0.join("parent"); + let probe = r#"alias.auth-probe=!test "$("$GIT_ASKPASS" Username)" = 'fixture-user' && test "$("$GIT_ASKPASS" Password)" = 'fixture-token'"#; + submodule_git( + &repo, + &["-c", probe, "auth-probe"], + Some("fixture-user"), + Some("fixture-token"), + ) + .unwrap(); + } + + #[test] + #[cfg(unix)] + fn submodules_auth_failures_are_classified_for_the_login_dialog() { + let f = fixture(); + let repo = f.0.join("parent"); + let probe = "alias.auth-probe=!echo 'fatal: could not read Username: terminal prompts disabled' >&2; exit 1"; + let error = submodule_git(&repo, &["-c", probe, "auth-probe"], None, None).unwrap_err(); + assert!(error.starts_with("AUTH_FAILED:")); + let error = submodule_git( + &repo, + &["-c", probe, "auth-probe"], + Some("user"), + Some("token"), + ) + .unwrap_err(); + assert!(error.starts_with("AUTH_FAILED:")); + let error = submodule_git(&repo, &["not-a-command"], None, None).unwrap_err(); + assert!(!error.starts_with("AUTH_FAILED:")); + } + + #[test] + fn submodules_checkout_tags_and_commits_without_staging_parent() { + let f = fixture(); + let repo = f.0.join("parent"); + let child = repo.join("libs/with spaces"); + let original = list(&repo, true).unwrap()[0].recorded_commit.clone(); + git(&child, &["tag", "v1.0"]); + fs::write(child.join("file.txt"), "version two\n").unwrap(); + git( + &child, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qam", + "version two", + ], + ); + git( + &child, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "tag", + "-a", + "v2.0", + "-m", + "version two", + ], + ); + let latest = list(&repo, true).unwrap()[0].local_commit.clone().unwrap(); + checkout_revision(&repo, "libs/with spaces", "v1.0", "tag").unwrap(); + assert_eq!( + list(&repo, true).unwrap()[0].local_commit.as_deref(), + Some(original.as_str()) + ); + checkout_revision(&repo, "libs/with spaces", "v2.0", "tag").unwrap(); + let module = list(&repo, true).unwrap().remove(0); + assert_eq!(module.local_commit.as_deref(), Some(latest.as_str())); + assert_eq!(module.recorded_commit, original); + assert!(module.branch.is_none()); + checkout_revision(&repo, "libs/with spaces", &original[..8], "commit").unwrap(); + assert_eq!( + list(&repo, true).unwrap()[0].local_commit.as_deref(), + Some(original.as_str()) + ); + } + + #[test] + fn submodules_revision_rejects_unknown_refs_options_and_local_changes() { + let f = fixture(); + let repo = f.0.join("parent"); + let child = repo.join("libs/with spaces"); + let before = list(&repo, true).unwrap()[0].local_commit.clone(); + for (revision, kind) in [ + ("--force", "commit"), + ("HEAD~1", "commit"), + ("../bad", "tag"), + ("missing", "tag"), + ("deadbeef", "commit"), + ("main", "branch"), + ] { + assert!(checkout_revision(&repo, "libs/with spaces", revision, kind).is_err()); + } + assert_eq!(list(&repo, true).unwrap()[0].local_commit, before); + git(&child, &["tag", "valid"]); + fs::write(child.join("file.txt"), "local changes\n").unwrap(); + assert!( + checkout_revision(&repo, "libs/with spaces", "valid", "tag") + .unwrap_err() + .contains("stash") + ); + assert_eq!( + fs::read_to_string(child.join("file.txt")).unwrap(), + "local changes\n" + ); + } + #[test] fn submodules_list_clean_and_uninitialized() { let f = fixture(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 90c425b..fe5faf2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,7 +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::submodules::{checkout_submodule_revision, 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, @@ -366,6 +366,7 @@ async fn main() { list_submodules, add_submodule, submodule_action, + checkout_submodule_revision, list_worktrees, add_worktree, remove_worktree, diff --git a/src/App.svelte b/src/App.svelte index 588058e..857c7b8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -55,6 +55,7 @@ listSubmodules, addSubmodule, submoduleAction, + checkoutSubmoduleRevision, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -440,6 +441,9 @@ let submoduleNoticeRequest = 0; let pendingSubmoduleInitialization: { repoPath: string; modules: GitSubmodule[] } | null = null; let submoduleInitializationError = ""; + let submoduleAuthRequest: { url: string; key: string | null; credential: StoredCredential | null; resolve: (credential: StoredCredential | null) => void } | null = null; + let submoduleAuthError = ""; + let submoduleAuthSaving = false; let submoduleDialogOpen = false; let submodules: GitSubmodule[] = []; let submodulesLoading = false; @@ -3379,6 +3383,53 @@ deleteBranchForce = false; } + async function withSubmoduleCredentials(url: string, task: (username?: string, password?: string) => Promise) { + const key = orgKeyFromUrl(url); + let credential = key && !rejectedCredentialKeys.has(key) ? await loadStoredCredential(key) : null; + while (true) { + try { + await task(credential?.username, credential?.password); + if (key) rejectedCredentialKeys.delete(key); + return; + } catch (error) { + const message = errorToMessage(error); + if (!isAuthError(message)) throw error; + if (credential && key) rejectedCredentialKeys.add(key); + submoduleAuthError = stripAuthPrefix(message); + const previous = credential; + credential = await new Promise(resolve => { + submoduleAuthRequest = { url, key, credential: previous, resolve }; + }); + if (!credential) throw new Error(appLanguage === "de" ? "Submodule-Anmeldung abgebrochen." : "Submodule sign-in cancelled."); + } + } + } + + async function submitSubmoduleCredential(username: string, password: string, save: boolean, mode: CredentialMode) { + const request = submoduleAuthRequest; + if (!request || submoduleAuthSaving) return; + submoduleAuthSaving = true; + try { + const credential = { username, password, mode }; + if (save && request.key) { + await credSave(request.key, username, password, mode); + credentialCache.set(request.key, credential); + } + submoduleAuthRequest = null; + submoduleAuthError = ""; + request.resolve(credential); + } catch (error) { submoduleAuthError = errorToMessage(error); } + finally { submoduleAuthSaving = false; } + } + + function cancelSubmoduleCredential() { + if (submoduleAuthSaving) return; + const request = submoduleAuthRequest; + submoduleAuthRequest = null; + submoduleAuthError = ""; + request?.resolve(null); + } + async function refreshSubmoduleNotice(path: string): Promise { const request = ++submoduleNoticeRequest; try { @@ -3405,7 +3456,7 @@ submoduleInitializationError = ""; try { for (const module of pending.modules) { - await submoduleAction(pending.repoPath, module.path, "initialize", true); + await withSubmoduleCredentials(module.url, (username, password) => submoduleAction(pending.repoPath, module.path, "initialize", true, username, password)); } pendingSubmoduleInitialization = null; } catch (error) { @@ -6764,12 +6815,14 @@ {#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))} + onLoadTags={(selected) => listTags(selected.full_path)} + onCheckout={(selected, revision, kind) => runSubmoduleOperation(path => checkoutSubmoduleRevision(path, selected.path, revision, kind))} + onAdd={(url, destination, branch) => runSubmoduleOperation(path => withSubmoduleCredentials(url, (username, password) => addSubmodule(path, url, destination, branch, username, password)))} + onAction={(selected, action) => runSubmoduleOperation(path => (action === "update" || action === "fetch") ? withSubmoduleCredentials(selected.url, (username, password) => submoduleAction(path, selected.path, action, submoduleRecursive, username, password)) : submoduleAction(path, selected.path, action, submoduleRecursive))} /> {/await} {/if} @@ -6780,3 +6833,10 @@ error={submoduleInitializationError} onInitialize={initializePendingSubmodules} onLater={dismissSubmoduleInitialization} /> {/await} {/if} + +{#if submoduleAuthRequest} + +{/if} diff --git a/src/lib/components/CredentialDialog.svelte b/src/lib/components/CredentialDialog.svelte index 68bb5c3..c4645e9 100644 --- a/src/lib/components/CredentialDialog.svelte +++ b/src/lib/components/CredentialDialog.svelte @@ -15,7 +15,7 @@ } from "@lucide/svelte"; interface Props { - action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete"; + action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete" | "submodule"; error: string; isBusy: boolean; initialUsername?: string; @@ -47,9 +47,11 @@ password.trim().length > 0 && username.trim().length > 0, ); - let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull"); + let actionLabel = $derived(action === "submodule" ? "Submodule" : action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull"); let actionTitle = $derived( - action === "push" + action === "submodule" + ? "Authenticate submodule" + : action === "push" ? "Authenticate push" : action === "rename" ? "Authenticate remote rename" diff --git a/src/lib/components/SubmoduleDialog.svelte b/src/lib/components/SubmoduleDialog.svelte index 450cdb3..2c47a85 100644 --- a/src/lib/components/SubmoduleDialog.svelte +++ b/src/lib/components/SubmoduleDialog.svelte @@ -1,22 +1,77 @@ @@ -67,12 +123,17 @@

{t("Submodul hinzufügen", "Add submodule")}

+ +
+ {#if browseError}{/if}
- +
+

{t("Zielpfad", "Destination")}: {destination || "—"}

+ {#if destination && !validDestination}{/if}

{t("Die .gitmodules-Datei und der neue Verweis werden zum Commit vorgemerkt.", "The .gitmodules file and new reference will be staged for commit.")}

-
+
{/if} {#if isLoading && !modules.length} @@ -100,6 +161,20 @@ {: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} + {#if selected.local_commit} +
{ event.preventDefault(); if (selected && revision.trim()) void onCheckout(selected, revision.trim(), revisionKind); }}> +
{t("Commit oder Tag wechseln", "Change commit or tag")}
+ + {#if revisionKind === "tag"} + + {:else} + + {/if} + {#if tagsError}{/if} + +

{t("Danach den neuen Verweis stagen und im übergeordneten Repository committen.", "Then stage the new reference and commit it in the parent repository.")}

+
+ {/if}
{#if selected.local_commit && selected.local_commit !== selected.recorded_commit}{/if} @@ -135,6 +210,11 @@ 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-revision { border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); padding: 16px 0; margin-bottom: 16px; } + .revision-heading { display: flex; justify-content: space-between; gap: 8px; align-items: center; flex-wrap: wrap; } + .submodule-revision label { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; font-size: 12px; } + .submodule-revision input, .submodule-revision select { width: 100%; min-width: 0; } + .submodule-revision p { font-size: 12px; color: var(--color-ink-muted); margin: 10px 0 0; line-height: 1.5; } .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); } @@ -144,6 +224,9 @@ .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-folder-picker { display: flex; gap: 8px; align-items: center; } + .submodule-folder-picker input { flex: 1; } + .submodule-folder-picker button { flex-shrink: 0; } .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; } } diff --git a/src/lib/git.ts b/src/lib/git.ts index 72507fa..447166b 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -737,9 +737,13 @@ export function pullRequestAiGenerate(path: string, remote: string, sourceBranch 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 addSubmodule(path: string, url: string, destination: string, branch?: string, username?: string, password?: string): Promise { + return invoke("add_submodule", { path, url, destination, branch: branch || null, username: username ?? null, password: password ?? null }); } -export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize", recursive: boolean): Promise { - return invoke("submodule_action", { path, modulePath, action, recursive }); +export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize" | "fetch", recursive: boolean, username?: string, password?: string): Promise { + return invoke("submodule_action", { path, modulePath, action, recursive, username: username ?? null, password: password ?? null }); +} + +export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise { + return invoke("checkout_submodule_revision", { path, modulePath, revision, kind }); }