Compare commits

...
3 Commits
Author SHA1 Message Date
Christoph 32832f5db7 feat(git): support credentials for remote branch rename
Allow remote branch renames to be performed with optional credentials so
operations against protected remotes succeed when authentication is needed.
The backend accepts username/password and uses an authenticated push path
when provided, while the frontend prompts for and reuses stored credentials.

- Add optional username/password to rename RPC and use authenticated push
- Wire UI to queue rename, open credential dialog, and execute rename
- Extend credential dialog and handling to include the rename action
2026-08-15 17:23:40 +02:00
Christoph 37d2152fcd feat(branch-panel): improve context menu positioning and styling
This update enhances the context menu for branches and tags by
ensuring it fits within the viewport, preventing overflow and
improving usability. Additionally, the styling of the branch
context menu has been adjusted for better visibility and
interaction.

- Added dynamic positioning for context menus to avoid overflow
- Updated CSS for branch context menu to improve layout and usability
- Simplified context menu opening logic for better performance
2026-08-15 17:15:13 +02:00
Christoph 2bc74dc4a7 feat(HelpOverlay): add Git Notes feature documentation
This update introduces documentation for the Git Notes feature, which
allows users to attach additional context to commits without altering
the commit history. The new section includes detailed steps, commands,
and notes on how to effectively use Git Notes within the application.

- Added German and English documentation for Git Notes
- Included commands for viewing and managing commit notes
- Explained how to fetch and push Git Notes to remote repositories
2026-08-15 17:08:53 +02:00
7 changed files with 165 additions and 50 deletions
+28 -13
View File
@@ -683,9 +683,17 @@ pub async fn rename_remote_branch(
remote: String,
old_branch: String,
new_branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch)
rename_remote_branch_core(
path,
remote,
old_branch,
new_branch,
username.as_deref().zip(password.as_deref()),
)
})
.await
}
@@ -695,6 +703,7 @@ fn rename_remote_branch_core(
remote: String,
old_branch: String,
new_branch: String,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
@@ -730,18 +739,23 @@ fn rename_remote_branch_core(
// Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched.
run_git(
&repo,
[
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
],
)?;
let push_args = [
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
];
match credentials {
Some((username, password)) if !username.is_empty() || !password.is_empty() => {
run_git_authenticated(&repo, push_args, username, password)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
// Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups.
@@ -7662,6 +7676,7 @@ mod tests {
"origin".to_string(),
"feature/old-name".to_string(),
"feature/new-name".to_string(),
None,
)
.unwrap();
+42 -10
View File
@@ -188,7 +188,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
@@ -410,6 +410,7 @@
let autoRefreshInFlight = false;
let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let credDialogUsername = "";
@@ -2622,12 +2623,14 @@
const oldRemoteBranch = branch.name.slice(slash + 1);
if (name === oldRemoteBranch) return;
await runOperation(`Renaming ${branch.name} on remote`, async () => {
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name));
renameBranchTarget = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name };
const key = await currentCredKey("rename");
const stored = await loadStoredCredential(key);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored));
} else {
await openCredentialDialog("rename", key, stored);
}
return;
}
@@ -3231,10 +3234,11 @@
// Resolve the keychain key (host/org) from the exact remote URL used by the
// operation. Push URLs may intentionally differ from fetch URLs.
async function currentCredKey(action: "push" | "pull" | "fetch" = "fetch"): Promise<string | null> {
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null;
try {
const url = await getRemoteUrl(activeRepoPath, selectedRemote || undefined, action === "push");
const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename");
return url ? orgKeyFromUrl(url) : null;
} catch {
return null;
@@ -3273,7 +3277,7 @@
// so a temporary 401/403 cannot erase a valid token; the key is only skipped
// for the rest of this session until the user replaces it successfully.
function handleRemoteResult(
action: "push" | "pull" | "fetch",
action: "push" | "pull" | "fetch" | "rename",
key: string | null,
fromStore: boolean,
username: string,
@@ -3414,6 +3418,33 @@
handleRemoteResult("push", key, fromStore, username, mode);
}
async function doActualRemoteRename(
username: string,
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
const rename = pendingRemoteRename;
if (!activeRepoPath || !rename) return;
errorMessage = "";
await runOperation(`Renaming ${rename.remote}/${rename.oldBranch} on remote`, async () => {
applyStatus(await renameRemoteBranch(
activeRepoPath,
rename.remote,
rename.oldBranch,
rename.newBranch,
username,
password,
));
renameBranchTarget = null;
pendingRemoteRename = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
handleRemoteResult("rename", key, fromStore, username, mode);
}
async function handleCredentialSubmit(
username: string,
password: string,
@@ -3436,6 +3467,7 @@
if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
+5 -1
View File
@@ -2212,7 +2212,11 @@
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.branch-context-menu,
.branch-context-menu {
position: fixed;
max-height: calc(100vh - 16px);
overflow-y: auto;
}
.history-context-menu { position: absolute; }
.explorer-context-menu,
.repo-tab-context-menu { position: fixed; }
+35 -20
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -99,11 +100,12 @@
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
@@ -243,20 +245,31 @@
onCheckout(branch);
}
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
const rect = element?.getBoundingClientRect();
const width = rect?.width ?? 184;
const height = rect?.height ?? 0;
return {
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
};
}
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
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) - 226);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2;
await tick();
if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
function closeBranchContextMenu() {
@@ -335,20 +348,20 @@
tagsOpen = true;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
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) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
tagContextMenuX = event.clientX + 2;
tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
}
function closeTagContextMenu() {
@@ -381,7 +394,7 @@
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head">
<div>
<span class="eyebrow">Branches</span>
@@ -676,6 +689,7 @@
{#if contextBranch}
<div
bind:this={branchContextMenuElement}
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
@@ -723,6 +737,7 @@
{#if contextTag}
<div
bind:this={tagContextMenuElement}
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
+7 -5
View File
@@ -15,7 +15,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch" | "clone";
action: "push" | "pull" | "fetch" | "clone" | "rename";
error: string;
isBusy: boolean;
initialUsername?: string;
@@ -47,17 +47,19 @@
password.trim().length > 0 &&
username.trim().length > 0,
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : "Pull");
let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "fetch"
: action === "rename"
? "Authenticate remote rename"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push"
let actionHint = $derived(action === "push" || action === "rename"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -78,7 +80,7 @@
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
{#if action === "push"}
{#if action === "push" || action === "rename"}
<Upload size={27} aria-hidden="true" />
{:else}
<Download size={27} aria-hidden="true" />
+38
View File
@@ -547,6 +547,25 @@
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
],
},
{
id: "app-git-notes",
title: "Commits mit Git Notes ergänzen",
summary: "Git Notes speichern zusätzliche Informationen zu einem Commit, ohne dessen Hash oder die Historie zu verändern. Sie eignen sich etwa für Review-Hinweise, Ticket-Kontext, Build-IDs oder Freigabestatus.",
steps: [
"Öffne im Commit-Verlauf über das Notiz-Symbol oder das Kontextmenü die Commit-Notiz.",
"Schreibe oder bearbeite die Notiz und speichere sie. Commits mit einer Notiz sind im Verlauf markiert; beim Überfahren der Markierung erscheint eine Vorschau.",
"Löschen entfernt nur die Notiz. Der zugehörige Commit und seine Dateien bleiben unverändert.",
"Gitty lädt Git Notes im Hintergrund vom bevorzugten Remote. Nutze im Dialog Vom Remote laden, um sie bei Bedarf gezielt zu aktualisieren.",
"Nutze Zum Remote senden, um lokale Notizen zu veröffentlichen. Ein normaler Branch-Push überträgt Git Notes nicht automatisch.",
],
commands: [
{ command: "git notes show <commit>", description: "Notiz eines Commits in der Kommandozeile anzeigen" },
{ command: "git notes add <commit>", description: "Notiz zu einem Commit hinzufügen oder im Editor verfassen" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Commit-Notizen gezielt vom Remote laden" },
{ command: "git push <remote> refs/notes/commits", description: "Lokale Commit-Notizen zum Remote senden" },
],
note: "Git Notes liegen standardmäßig unter refs/notes/commits und werden getrennt von Branches synchronisiert. Prüfe vor einem Push, ob der Ziel-Remote diese Referenz akzeptiert.",
},
{
id: "app-search",
title: "Code-Ursprung mit Global Search finden",
@@ -990,6 +1009,25 @@
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
],
},
{
id: "app-git-notes",
title: "Add context to commits with Git Notes",
summary: "Git Notes attach additional information to a commit without changing its hash or rewriting history. They are useful for review findings, ticket context, build IDs, or approval status.",
steps: [
"Open the commit note from the note icon or the commit context menu in History.",
"Write or edit the note and save it. Commits with a note are marked in History, and hovering over the marker shows a preview.",
"Deleting removes only the note. The associated commit and its files remain unchanged.",
"Gitty fetches Git Notes from the preferred remote in the background. Use Fetch from remote in the dialog to refresh them explicitly when needed.",
"Use Push to remote to publish local notes. A regular branch push does not transfer Git Notes automatically.",
],
commands: [
{ command: "git notes show <commit>", description: "Show a commit's note on the command line" },
{ command: "git notes add <commit>", description: "Add a note to a commit or compose it in an editor" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Fetch commit notes explicitly from a remote" },
{ command: "git push <remote> refs/notes/commits", description: "Push local commit notes to a remote" },
],
note: "Git Notes are stored under refs/notes/commits by default and synchronize separately from branches. Before pushing, make sure the destination remote accepts this reference.",
},
{
id: "app-search",
title: "Find code origins with Global Search",
+10 -1
View File
@@ -138,8 +138,17 @@ export function renameRemoteBranch(
remote: string,
oldBranch: string,
newBranch: string,
username?: string,
password?: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
return invoke<GitStatus>("rename_remote_branch", {
path,
remote,
oldBranch,
newBranch,
username: username ?? null,
password: password ?? null,
});
}
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {