Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32832f5db7 | ||
|
|
37d2152fcd | ||
|
|
2bc74dc4a7 |
+28
-13
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user