feat(git): rename remote branches atomically via push
Adds a new command to rename remote branches and update tracking refs. Renaming remote refs is performed atomically using a single push. The push creates the new remote ref and deletes the old if it succeeds. The frontend now invokes remote-rename when needed and shows labels. - Atomic remote rename via push with create/ref and delete - Frontend supports remote branch renames from the branch panel - Compare UI now shows labels for remote refs in results
This commit is contained in:
@@ -151,8 +151,8 @@
|
||||
class="repo-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
@@ -49,6 +49,7 @@
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
onRebase: (branch: GitBranchInfo) => void;
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
@@ -72,6 +73,7 @@
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onCompareBranch = () => {},
|
||||
onMerge = () => {},
|
||||
onRebase = () => {},
|
||||
onCreateBranch = () => {},
|
||||
@@ -250,7 +252,7 @@
|
||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
|
||||
|
||||
contextBranch = branch;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
@@ -268,6 +270,13 @@
|
||||
await onRenameBranch(branch);
|
||||
}
|
||||
|
||||
function compareContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
onCompareBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
@@ -677,6 +686,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Checkout
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
Compare with...
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||
<GitMerge size={14} aria-hidden="true" />
|
||||
Merge into current
|
||||
@@ -690,9 +703,9 @@
|
||||
Open in new worktree
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
|
||||
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
|
||||
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
|
||||
action("compare", "compare", isGerman ? "Commits vergleichen" : "Compare commits", isGerman ? "Unterschiede zwischen zwei Revisionen" : "Diff two revisions", onOpenCompare),
|
||||
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
||||
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
||||
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
||||
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
fromLabel?: string;
|
||||
toLabel?: string;
|
||||
restoreLabel?: string;
|
||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||
highlightQuery?: string;
|
||||
@@ -32,6 +34,8 @@
|
||||
comparison,
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
fromLabel = "",
|
||||
toLabel = "",
|
||||
restoreLabel = "",
|
||||
highlightQuery = "",
|
||||
onClose = () => {},
|
||||
@@ -219,15 +223,15 @@
|
||||
class="dialog-backdrop compare-dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
|
||||
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
@@ -289,11 +293,11 @@
|
||||
<div class="split-col-headers">
|
||||
<div class="split-col-label">
|
||||
<span>Before</span>
|
||||
<span class="split-col-hash">{comparison.from_short}</span>
|
||||
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
</div>
|
||||
<div class="split-col-label">
|
||||
<span>After</span>
|
||||
<span class="split-col-hash">{comparison.to_short}</span>
|
||||
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
import type { GitBranch, GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
branches: GitBranch[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
branches = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
@@ -32,6 +34,14 @@
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function branchValue(branch: GitBranch): string {
|
||||
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||
}
|
||||
|
||||
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
|
||||
let targetCount = $derived(branches.length + commits.length);
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
@@ -42,53 +52,89 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{#if targetCount < 2}
|
||||
<div class="blank-state">At least two branches or commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<span>Base</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<span>Compare with</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
{#if operation === "Comparing revisions"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
@@ -98,9 +144,11 @@
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
<div class="blank-state">Select two different branches or commits to compare.</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
<div class="compare-target-help">
|
||||
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,29 @@
|
||||
|
||||
let name = $state("");
|
||||
|
||||
function remoteName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return "";
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||
}
|
||||
|
||||
function editableName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return branch.name;
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||
}
|
||||
|
||||
let originalName = $derived(editableName(branch));
|
||||
let remote = $derived(remoteName(branch));
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
name = originalName;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
if (!value || value === originalName) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
@@ -34,10 +49,10 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
@@ -48,27 +63,37 @@
|
||||
<form class="rename-branch-form" onsubmit={submit}>
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
<div class:remote-branch-name-field={branch.remote}>
|
||||
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if branch.remote}
|
||||
<p class="rename-remote-note">
|
||||
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
{branch.remote ? "Rename on remote" : "Rename"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -133,6 +133,15 @@ export function renameBranch(
|
||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function renameRemoteBranch(
|
||||
path: string,
|
||||
remote: string,
|
||||
oldBranch: string,
|
||||
newBranch: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user