feat(git): expand core git repository management features

This update significantly expands the available Git functionality by adding robust support for initializing repositories, managing remotes, and improving complex workflow operations like merging and reverting commits. New API endpoints are exposed across the backend and frontend to handle remote setup, branch tracking, and conflict resolution workflows.

- Added full remote management capabilities (add, update, remove).
- Implemented advanced merge strategies and commit reversion logic.
- Introduced a dedicated UI component for synchronization settings.
This commit is contained in:
Christoph Brandau
2026-07-13 23:24:36 +02:00
parent 35310bec6d
commit 7800f0fb24
11 changed files with 688 additions and 48 deletions
+137 -5
View File
@@ -36,10 +36,12 @@
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
import {
amendCommit,
addRemote,
checkoutBranch,
cherryPickAbort,
cherryPickCommit,
@@ -59,6 +61,8 @@
createTag,
deleteBranch,
deleteTag,
deleteRemoteBranch,
initRepository,
diffFileAgainstWorkingTree,
compareFileToParent,
fetchRemote,
@@ -66,6 +70,7 @@
getStatus,
lastCommitMessage,
listBranches,
listRemotes,
listStashes,
listTags,
listCommits,
@@ -74,12 +79,18 @@
listReflog,
listRepositoryFiles,
mergeBranch,
mergeAbort,
mergeContinue,
openRepoInExplorer,
openRepositoryFile,
openRepositoryBundle,
pull,
push,
pushTag,
removeRemote,
revertCommit,
setBranchUpstream,
updateRemote,
renameBranch,
rebaseAbort,
rebaseBranch,
@@ -126,6 +137,8 @@
GitDiffFile,
GitFileStatus,
GitRepositoryFile,
GitRemote,
PullStrategy,
GitSearchHit,
GitStash,
GitStatus,
@@ -230,6 +243,12 @@
let repoStatusCache: Record<string, RepoTab> = {};
let repoSearch = "";
let cloneDialogOpen = false;
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
let remoteActionForceWithLease = false;
let remoteActionPrune = false;
let syncSettingsOpen = false;
let syncSettingsRemotes: GitRemote[] = [];
let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null;
@@ -392,6 +411,7 @@
$: hasConflicts = conflictedFiles.length > 0;
$: rebaseInProgress = status?.rebase_in_progress ?? false;
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
$: mergeInProgress = status?.merge_in_progress ?? false;
// Amending/undoing is only offered while the last commit hasn't reached a
// remote yet: no upstream at all, or the branch is still ahead of it.
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
@@ -2314,8 +2334,11 @@
async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return;
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
if (!strategy) return;
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
await runOperation(`Merging ${branch.name}`, async () => {
applyStatus(await mergeBranch(activeRepoPath, branch.name));
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -2618,7 +2641,7 @@
) {
errorMessage = "";
await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password));
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -2639,7 +2662,8 @@
) {
errorMessage = "";
await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password));
applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined));
remoteActionPrune = false;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("repository_fetched", {
from_stored_credential: fromStore ? 1 : 0,
@@ -2658,7 +2682,8 @@
) {
errorMessage = "";
await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password));
applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
remoteActionForceWithLease = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
@@ -2782,6 +2807,78 @@
await startRemoteAction("push");
}
async function deleteTrackedRemoteBranch(branch: GitBranchInfo) {
if (!activeRepoPath || !branch.remote) return;
const slash = branch.name.indexOf("/");
if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
const remote = branch.name.slice(0, slash); const remoteBranch = branch.name.slice(slash + 1);
if (!window.confirm(`Delete '${remoteBranch}' from remote '${remote}'?`)) return;
await runOperation("Deleting remote branch", async () => { applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); await refreshRefsAndCommitGraph(activeRepoPath); });
}
async function initializeRepository() {
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
if (typeof selected !== "string") return;
const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
}
async function revertHistoryCommit(commit: GitCommit) {
if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
await runOperation(`Reverting ${commit.short_hash}`, async () => {
applyStatus(await revertCommit(activeRepoPath, commit.hash));
await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
});
}
async function continueMerge() {
if (!activeRepoPath) return;
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); });
}
async function abortMerge() {
if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); });
}
async function fetchPruneRepo() {
remoteActionPrune = true;
await startRemoteAction("fetch");
}
async function forcePushRepo() {
if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return;
remoteActionForceWithLease = true;
await startRemoteAction("push");
}
async function openSyncOptions() {
if (!activeRepoPath) return;
try {
syncSettingsRemotes = await listRemotes(activeRepoPath);
syncSettingsOpen = true;
} catch (error) { errorMessage = errorToMessage(error); }
}
async function saveSyncSettings(strategy: PullStrategy, remote: string, upstream: string) {
if (!activeRepoPath || !status?.current_branch) return;
await runOperation("Saving sync settings", async () => {
pullStrategy = strategy; selectedRemote = remote;
localStorage.setItem("gitlite.pullStrategy", strategy); localStorage.setItem("gitlite.selectedRemote", remote);
applyStatus(await setBranchUpstream(activeRepoPath, status!.current_branch!, upstream || undefined));
await refreshBranchList(activeRepoPath); syncSettingsOpen = false;
});
}
async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); }
async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); }
async function removeSyncRemote(name: string) {
if (!activeRepoPath || !window.confirm(`Remove remote '${name}'? Local commits and branches are kept.`)) return;
syncSettingsRemotes = await removeRemote(activeRepoPath, name);
if (selectedRemote === name) selectedRemote = "";
await refreshBranchList(activeRepoPath);
}
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
const stashedFiles = changedFiles.length;
@@ -3626,6 +3723,9 @@
onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer}
onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions}
/>
{/if}
@@ -3671,7 +3771,15 @@
</section>
{/if}
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
{#if workspaceActive && mergeInProgress}
<section class="notice conflict" role="status">
<GitMerge size={17} aria-hidden="true" />
<span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
{#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
<button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
</section>
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
<section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
@@ -3729,6 +3837,9 @@
<Download size={15} aria-hidden="true" />
Clone
</button>
<button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
<Plus size={15} aria-hidden="true" /> Init
</button>
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />
Browse
@@ -3904,6 +4015,7 @@
onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
@@ -4115,6 +4227,7 @@
onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -4384,6 +4497,25 @@
/>
{/if}
<!-- Clone repository dialog -->
{#if syncSettingsOpen}
<SyncSettingsDialog
remotes={syncSettingsRemotes}
remoteBranches={remoteBranches.map((branch) => branch.name)}
currentBranch={status?.current_branch ?? ""}
currentUpstream={status?.upstream ?? ""}
strategy={pullStrategy}
{selectedRemote}
{isBusy}
language={appLanguage}
onSaveSync={saveSyncSettings}
onAddRemote={addSyncRemote}
onUpdateRemote={updateSyncRemote}
onRemoveRemote={removeSyncRemote}
onClose={() => { if (!isBusy) syncSettingsOpen = false; }}
/>
{/if}
<!-- Clone repository dialog -->
{#if cloneDialogOpen}
<CloneRepositoryDialog