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.
This commit is contained in:
2026-09-17 19:39:40 +02:00
parent a9af02a697
commit b00e3e5c18
7 changed files with 433 additions and 29 deletions
+64 -4
View File
@@ -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<void>) {
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<StoredCredential | null>(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<GitSubmodule[] | null> {
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}
<module.default
modules={submodules} isLoading={submodulesLoading} {isBusy} error={submoduleError}
repoPath={activeRepoPath} 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))}
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}
<CredentialDialog action="submodule" error={submoduleAuthError} isBusy={submoduleAuthSaving}
initialUsername={submoduleAuthRequest.credential?.username ?? ""}
initialMode={submoduleAuthRequest.credential ? credentialModeFor(submoduleAuthRequest.credential) : "credentials"}
onSubmit={submitSubmoduleCredential} onCancel={cancelSubmoduleCredential} />
{/if}