feat(submodules): add submodule management and recursive clone

Add a new backend module to manage Git submodules (list, initialize/update,
stage, sync, add) and expose Tauri commands (list_submodules,
submodule_action, add_submodule). The implementation enforces safe relative
paths, a maximum nesting depth, and guards (dirty/conflicted checks and
initialization/no-op semantics) to avoid unsafe operations.

Refactor clone logic to a testable run_git_clone_command and enable
automatic initialization of submodules during clone by passing
--recurse-submodules. Also disallow certain submodule-related custom clone
flags so callers cannot override this behaviour.

Update README with a Submodules section and add frontend components and types
to surface submodule UI (dialogs, toolbar badge). Unit tests were added for
submodule discovery, initialization/update semantics, and recursive clone
behavior.
This commit is contained in:
2026-09-16 22:31:14 +02:00
parent f0ff1914c9
commit 66c85321ea
11 changed files with 1084 additions and 3 deletions
+18
View File
@@ -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}
+149
View File
@@ -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>
+11
View File
@@ -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 });
}
+15
View File
@@ -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;
}