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:
Christoph Brandau
2026-08-13 18:32:16 +02:00
parent ca14fac90c
commit f43fe00873
11 changed files with 447 additions and 71 deletions
+166
View File
@@ -675,6 +675,99 @@ pub fn delete_remote_branch(
result result
} }
#[tauri::command]
pub async fn rename_remote_branch(
path: String,
remote: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch)
})
.await
}
fn rename_remote_branch_core(
path: String,
remote: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
let old_branch = validate_branch_ref_name(old_branch.trim())?;
let new_branch = validate_branch_ref_name(new_branch.trim())?;
if old_branch == new_branch {
return Err("The new remote branch name is unchanged.".to_string());
}
let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}");
let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}");
if !ref_exists(&repo, &old_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{old_branch}' was not found."
));
}
if ref_exists(&repo, &new_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{new_branch}' already exists."
));
}
let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?;
let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string();
let old_remote_ref = format!("refs/heads/{old_branch}");
let new_remote_ref = format!("refs/heads/{new_branch}");
let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}");
// An empty expected value means the destination must not exist on the remote.
let destination_lease = format!("--force-with-lease={new_remote_ref}:");
let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}");
let delete_refspec = format!(":{old_remote_ref}");
// 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(),
],
)?;
// Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups.
if !ref_exists(&repo, &new_tracking_ref)? {
if let Err(error) = run_git(
&repo,
["update-ref", new_tracking_ref.as_str(), old_hash.as_str()],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}");
}
}
if ref_exists(&repo, &old_tracking_ref)? {
if let Err(error) = run_git(
&repo,
[
"update-ref",
"-d",
old_tracking_ref.as_str(),
old_hash.as_str(),
],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}");
}
}
status_for_repo(&repo)
}
#[tauri::command] #[tauri::command]
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> { pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
run_git_task("Could not load stashes", move || { run_git_task("Could not load stashes", move || {
@@ -6686,6 +6779,31 @@ mod tests {
assert!(comparison.patch.contains("second line")); assert!(comparison.patch.contains("second line"));
} }
#[test]
fn compare_commits_accepts_branch_refs_for_a_full_repository_diff() {
let repo = init_temp_repo("compare_branches");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "base"]);
fs::write(repo.path.join("branch-only.txt"), "only on feature\n")
.expect("branch file should be written");
run_git_test(&repo.path, ["add", "branch-only.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]);
run_git_test(&repo.path, ["branch", "feature/complete-compare"]);
let comparison = compare_commits(
repo.path.to_string_lossy().to_string(),
"refs/heads/base".to_string(),
"refs/heads/feature/complete-compare".to_string(),
)
.unwrap();
assert!(comparison.files.iter().any(|file| {
file.path == "branch-only.txt" && file.status == FileStatusKind::Added
}));
assert!(comparison.patch.contains("only on feature"));
}
#[test] #[test]
fn compare_commits_includes_full_file_context() { fn compare_commits_includes_full_file_context() {
let repo = init_temp_repo("compare_full_context"); let repo = init_temp_repo("compare_full_context");
@@ -7295,6 +7413,54 @@ mod tests {
); );
} }
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn rename_remote_branch_moves_the_remote_ref_atomically() {
let repo = init_temp_repo("rename_remote_branch");
let remote = init_bare_temp_repo("rename_remote_branch_remote");
commit_initial_file(&repo.path);
let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let remote_url = format!(
"file:///{}",
remote.path.to_string_lossy().replace('\\', "/")
);
run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]);
run_git_test(
&repo.path,
["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"],
);
run_git_test(&repo.path, ["fetch", "-q", "origin"]);
rename_remote_branch_core(
repo.path.to_string_lossy().to_string(),
"origin".to_string(),
"feature/old-name".to_string(),
"feature/new-name".to_string(),
)
.unwrap();
assert!(
!ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(),
"old remote branch should be gone"
);
assert_eq!(
git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]),
commit
);
assert!(
!ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(),
"old remote-tracking branch should be gone"
);
assert!(
ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(),
"new remote-tracking branch should exist"
);
}
#[test] #[test]
fn delete_branch_removes_local_branch_but_rejects_current_branch() { fn delete_branch_removes_local_branch_but_rejects_current_branch() {
let repo = init_temp_repo("delete_branch"); let repo = init_temp_repo("delete_branch");
+7 -5
View File
@@ -24,11 +24,12 @@ use git::{
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle, merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict, open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit, rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit, restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
}; };
use tauri::Manager; use tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
@@ -144,6 +145,7 @@ async fn main() {
checkout_branch, checkout_branch,
create_branch, create_branch,
rename_branch, rename_branch,
rename_remote_branch,
delete_branch, delete_branch,
list_worktrees, list_worktrees,
add_worktree, add_worktree,
+86 -21
View File
@@ -100,6 +100,7 @@
setCommitNote, setCommitNote,
updateRemote, updateRemote,
renameBranch, renameBranch,
renameRemoteBranch,
rebaseAbort, rebaseAbort,
rebaseBranch, rebaseBranch,
rebaseContinue, rebaseContinue,
@@ -359,6 +360,8 @@
let compareFrom = ""; let compareFrom = "";
let compareTo = ""; let compareTo = "";
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let comparisonFromLabel = "";
let comparisonToLabel = "";
let newBranchCommit: GitCommit | null = null; let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null; let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null;
@@ -1852,6 +1855,8 @@
compareFrom = ""; compareFrom = "";
compareTo = ""; compareTo = "";
comparison = null; comparison = null;
comparisonFromLabel = "";
comparisonToLabel = "";
compareSelectOpen = false; compareSelectOpen = false;
compareDialogOpen = false; compareDialogOpen = false;
interactiveRebaseOpen = false; interactiveRebaseOpen = false;
@@ -1981,15 +1986,12 @@
commitHistoryLoadingMore = false; commitHistoryLoadingMore = false;
commitHistoryLoadError = ""; commitHistoryLoadError = "";
lastFileHistoryHeadHash = commits[0]?.hash ?? ""; lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash)); const targets = new Set([
if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; ...commits.map((commit) => commit.hash),
if (compareTo && !hashes.has(compareTo)) compareTo = ""; ...branches.map(compareRefForBranch),
if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) { ]);
comparison = null; if (compareFrom && !targets.has(compareFrom)) compareFrom = "";
compareDialogOpen = false; if (compareTo && !targets.has(compareTo)) compareTo = "";
selectedDiffPath = "";
pendingRestoreFile = null;
}
} }
async function loadMoreCommitHistory() { async function loadMoreCommitHistory() {
@@ -2482,22 +2484,47 @@
}); });
} }
function renameLocalBranch(branch: GitBranchInfo) { function openRenameBranchDialog(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return; if (!activeRepoPath) return;
if (branch.remote && branch.name.indexOf("/") < 1) {
errorMessage = "Could not determine remote name.";
return;
}
renameBranchTarget = branch; renameBranchTarget = branch;
trackEvent("branch_rename_dialog_opened"); trackEvent("branch_rename_dialog_opened", { remote: branch.remote ? 1 : 0 });
} }
async function submitRenameBranch(branchName: string) { async function submitRenameBranch(branchName: string) {
const branch = renameBranchTarget; const branch = renameBranchTarget;
const name = branchName.trim(); const name = branchName.trim();
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return; if (!activeRepoPath || !branch || !name) return;
if (branch.remote) {
const slash = branch.name.indexOf("/");
if (slash < 1) {
errorMessage = "Could not determine remote name.";
return;
}
const remote = branch.name.slice(0, slash);
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 });
});
return;
}
if (name === branch.name) return;
await runOperation(`Renaming ${branch.name}`, async () => { await runOperation(`Renaming ${branch.name}`, async () => {
applyStatus(await renameBranch(activeRepoPath, branch.name, name)); applyStatus(await renameBranch(activeRepoPath, branch.name, name));
renameBranchTarget = null; renameBranchTarget = null;
await refreshRepositoryViews(activeRepoPath); await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_renamed"); trackEvent("branch_renamed", { remote: 0 });
}); });
} }
@@ -2841,6 +2868,8 @@
await runOperation("Previewing reflog entry", async () => { await runOperation("Previewing reflog entry", async () => {
const result = await compareCommits(activeRepoPath, entry.hash, "HEAD"); const result = await compareCommits(activeRepoPath, entry.hash, "HEAD");
comparison = result; comparison = result;
comparisonFromLabel = entry.selector;
comparisonToLabel = "HEAD";
selectedDiffPath = result.files[0]?.path ?? ""; selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = ""; diffHighlightQuery = "";
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -3816,6 +3845,8 @@
diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path, diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path,
); );
comparison = result; comparison = result;
comparisonFromLabel = "";
comparisonToLabel = "";
selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path; selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
pendingRestoreFile = { commit: target, file }; pendingRestoreFile = { commit: target, file };
compareDialogOpen = true; compareDialogOpen = true;
@@ -4072,12 +4103,36 @@
// ── Compare ──────────────────────────────────────────────────────────────── // ── Compare ────────────────────────────────────────────────────────────────
function compareRefForBranch(branch: GitBranchInfo): string {
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
}
function compareLabelForTarget(target: string): string {
const branch = branches.find((candidate) => compareRefForBranch(candidate) === target);
if (branch) return branch.name;
return commits.find((commit) => commit.hash === target)?.short_hash ?? "";
}
function openCompareSelect() { function openCompareSelect() {
if (!hasRepository) return; if (!hasRepository) return;
if (!compareFrom) {
const current = branches.find((branch) => branch.current);
if (current) compareFrom = compareRefForBranch(current);
}
compareSelectOpen = true; compareSelectOpen = true;
trackEvent("compare_opened"); trackEvent("compare_opened");
} }
function compareBranchWithCurrent(branch: GitBranchInfo) {
if (!hasRepository) return;
const selected = compareRefForBranch(branch);
const current = branches.find((candidate) => candidate.current);
compareFrom = current ? compareRefForBranch(current) : "";
compareTo = selected === compareFrom ? "" : selected;
compareSelectOpen = true;
trackEvent("compare_opened", { source: "branch_context", remote: branch.remote ? 1 : 0 });
}
function openGlobalSearchDialog() { function openGlobalSearchDialog() {
globalSearchOpen = true; globalSearchOpen = true;
trackEvent("global_search_opened"); trackEvent("global_search_opened");
@@ -4096,11 +4151,13 @@
aiSettingsOpen = true; aiSettingsOpen = true;
} }
async function compareSelectedCommits() { async function compareSelectedTargets() {
if (!canCompare) return; if (!canCompare) return;
await runOperation("Comparing commits", async () => { await runOperation("Comparing revisions", async () => {
const result = await compareCommits(activeRepoPath, compareFrom, compareTo); const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
comparison = result; comparison = result;
comparisonFromLabel = compareLabelForTarget(compareFrom);
comparisonToLabel = compareLabelForTarget(compareTo);
selectedDiffPath = result.files[0]?.path ?? ""; selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = ""; diffHighlightQuery = "";
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -4117,6 +4174,8 @@
await runOperation(`Diffing ${selectedExplorerPath}`, async () => { await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath); const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
comparison = result; comparison = result;
comparisonFromLabel = "";
comparisonToLabel = "";
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath; selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
diffHighlightQuery = ""; diffHighlightQuery = "";
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -4133,6 +4192,8 @@
await runOperation(`Diffing ${hit.file}`, async () => { await runOperation(`Diffing ${hit.file}`, async () => {
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file); const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
comparison = result; comparison = result;
comparisonFromLabel = "";
comparisonToLabel = "";
selectedDiffPath = result.files[0]?.path ?? hit.file; selectedDiffPath = result.files[0]?.path ?? hit.file;
diffHighlightQuery = lastSearchQuery; diffHighlightQuery = lastSearchQuery;
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -4711,10 +4772,11 @@
{hasRepository} {hasRepository}
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
onCompareBranch={compareBranchWithCurrent}
onMerge={merge} onMerge={merge}
onRebase={rebaseOnto} onRebase={rebaseOnto}
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={openRenameBranchDialog}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch} onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onCreateTag={createNewTag} onCreateTag={createNewTag}
@@ -5195,7 +5257,7 @@
/> />
{/if} {/if}
<!-- Rename a local branch from the branch context menu --> <!-- Rename a local or remote branch from the branch context menu -->
{#if renameBranchTarget} {#if renameBranchTarget}
<RenameBranchDialog <RenameBranchDialog
branch={renameBranchTarget} branch={renameBranchTarget}
@@ -5228,7 +5290,7 @@
{/await} {/await}
{/if} {/if}
<!-- Compare: pick the two commits to diff --> <!-- Interactive rebase -->
{#if interactiveRebaseOpen} {#if interactiveRebaseOpen}
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module} {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
<module.default <module.default
@@ -5261,10 +5323,11 @@
/> />
{/if} {/if}
<!-- Compare: pick the two commits to diff --> <!-- Compare: pick two branches or commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
{commits} {commits}
{branches}
{compareFrom} {compareFrom}
{compareTo} {compareTo}
{canCompare} {canCompare}
@@ -5272,7 +5335,7 @@
{operation} {operation}
onCompareFromChange={(val) => { compareFrom = val; }} onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }} onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedCommits} onCompare={compareSelectedTargets}
onClose={() => { compareSelectOpen = false; }} onClose={() => { compareSelectOpen = false; }}
/> />
{/if} {/if}
@@ -5284,6 +5347,8 @@
{comparison} {comparison}
{selectedDiffPath} {selectedDiffPath}
{isBusy} {isBusy}
fromLabel={comparisonFromLabel}
toLabel={comparisonToLabel}
highlightQuery={diffHighlightQuery} highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""} restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onClose={closeCompareDialog} onClose={closeCompareDialog}
+45 -1
View File
@@ -3023,6 +3023,16 @@
.compare-field { display: grid; gap: 4px; min-width: 0; } .compare-field { display: grid; gap: 4px; min-width: 0; }
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; } .compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); } .compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
.compare-target-help {
margin: 12px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
border-radius: 8px;
color: var(--color-ink-dim);
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-surface-raised));
font-size: 11.5px;
line-height: 1.5;
}
.compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; } .compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); } .compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
@@ -3685,6 +3695,34 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.new-branch-field > div { display: flex; min-width: 0; }
.new-branch-field > div > input { width: 100%; min-width: 0; }
.remote-branch-name-field > strong {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: 34px;
padding: 0 0 0 11px;
border: 1px solid var(--color-border-input);
border-right: 0;
border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm);
color: var(--color-ink-faint);
background: var(--color-surface-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.remote-branch-name-field > input { border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; }
.rename-remote-note {
margin: -2px 0 0;
padding: 10px 12px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
color: var(--color-ink-dim);
background: var(--color-surface-dim);
font-size: 11.5px;
line-height: 1.5;
}
.rename-remote-note strong { color: var(--color-ink); font-family: var(--font-mono); font-weight: 700; }
.new-branch-actions { .new-branch-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -3703,7 +3741,8 @@
.compare-restore { max-width: 170px; min-width: 0; } .compare-restore { max-width: 170px; min-width: 0; }
.compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dialog-range { display: flex; align-items: center; gap: 8px; margin: 2px 0 0; color: var(--color-accent); font-size: 15px; } .dialog-range { display: flex; align-items: center; gap: 8px; max-width: min(68vw, 780px); margin: 2px 0 0; color: var(--color-accent); font-size: 15px; }
.dialog-range .hash { min-width: 0; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; } .dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; }
.dialog-close { min-height: 32px; min-width: 32px; padding: 0; justify-content: center; } .dialog-close { min-height: 32px; min-width: 32px; padding: 0; justify-content: center; }
@@ -3852,6 +3891,11 @@
} }
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); } .split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
.split-col-hash { .split-col-hash {
min-width: 0;
max-width: min(42%, 260px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 1px 6px; padding: 1px 6px;
border-radius: 5px; border-radius: 5px;
background: rgba(90,140,248,0.12); background: rgba(90,140,248,0.12);
+2 -2
View File
@@ -151,8 +151,8 @@
class="repo-action" class="repo-action"
onclick={onCompare} onclick={onCompare}
disabled={!hasRepository || isBusy} disabled={!hasRepository || isBusy}
title={isGerman ? "Commits vergleichen" : "Compare commits"} title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"} aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
> >
<GitCompare size={15} aria-hidden="true" /> <GitCompare size={15} aria-hidden="true" />
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span> <span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
+17 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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"; import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -49,6 +49,7 @@
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
onCompareBranch: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void; onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
@@ -72,6 +73,7 @@
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
onCompareBranch = () => {},
onMerge = () => {}, onMerge = () => {},
onRebase = () => {}, onRebase = () => {},
onCreateBranch = () => {}, onCreateBranch = () => {},
@@ -250,7 +252,7 @@
const rawX = rect ? event.clientX - rect.left : event.offsetX; const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY; const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192); 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; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -268,6 +270,13 @@
await onRenameBranch(branch); await onRenameBranch(branch);
} }
function compareContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
onCompareBranch(branch);
}
async function deleteContextBranch() { async function deleteContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || isBusy) return;
@@ -677,6 +686,10 @@
<GitBranch size={14} aria-hidden="true" /> <GitBranch size={14} aria-hidden="true" />
Checkout Checkout
</button> </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}> <button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" /> <GitMerge size={14} aria-hidden="true" />
Merge into current Merge into current
@@ -690,9 +703,9 @@
Open in new worktree Open in new worktree
</button> </button>
<div class="menu-separator" role="separator"></div> <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" /> <Pencil size={14} aria-hidden="true" />
Rename {contextBranch.remote ? "Rename remote..." : "Rename"}
</button> </button>
<button <button
class="danger" class="danger"
+1 -1
View File
@@ -47,7 +47,7 @@
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush), 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("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("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("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("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees), action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
+9 -5
View File
@@ -20,6 +20,8 @@
comparison: GitCommitComparison; comparison: GitCommitComparison;
selectedDiffPath: string; selectedDiffPath: string;
isBusy: boolean; isBusy: boolean;
fromLabel?: string;
toLabel?: string;
restoreLabel?: string; restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */ /** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string; highlightQuery?: string;
@@ -32,6 +34,8 @@
comparison, comparison,
selectedDiffPath = "", selectedDiffPath = "",
isBusy = false, isBusy = false,
fromLabel = "",
toLabel = "",
restoreLabel = "", restoreLabel = "",
highlightQuery = "", highlightQuery = "",
onClose = () => {}, onClose = () => {},
@@ -219,15 +223,15 @@
class="dialog-backdrop compare-dialog-backdrop" class="dialog-backdrop compare-dialog-backdrop"
role="presentation" 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"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Compare</span> <span class="eyebrow">Compare</span>
<h2 class="dialog-range"> <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" /> <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> </h2>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
@@ -289,11 +293,11 @@
<div class="split-col-headers"> <div class="split-col-headers">
<div class="split-col-label"> <div class="split-col-label">
<span>Before</span> <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>
<div class="split-col-label"> <div class="split-col-label">
<span>After</span> <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>
</div> </div>
+66 -18
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte"; import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types"; import type { GitBranch, GitCommit } from "../types";
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
branches: GitBranch[];
compareFrom: string; compareFrom: string;
compareTo: string; compareTo: string;
canCompare: boolean; canCompare: boolean;
@@ -17,6 +18,7 @@
let { let {
commits = [], commits = [],
branches = [],
compareFrom = "", compareFrom = "",
compareTo = "", compareTo = "",
canCompare = false, canCompare = false,
@@ -32,6 +34,14 @@
return `${item.short_hash} - ${item.summary}`; 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) { function handleSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
onCompare(); onCompare();
@@ -42,53 +52,89 @@
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Compare</span> <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> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
</button> </button>
</header> </header>
{#if commits.length < 2} {#if targetCount < 2}
<div class="blank-state">At least two commits are needed to compare.</div> <div class="blank-state">At least two branches or commits are needed to compare.</div>
{:else} {:else}
<form class="compare-form" onsubmit={handleSubmit}> <form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field"> <label class="compare-field">
<span>From (older)</span> <span>Base</span>
<select <select
value={compareFrom} value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)} onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy} disabled={isBusy}
> >
<option value="" disabled>Select a commit</option> <option value="" disabled>Select a branch or commit</option>
{#each commits as item (item.hash)} {#if localBranches.length > 0}
<option value={item.hash}>{commitOptionLabel(item)}</option> <optgroup label="Local branches">
{/each} {#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> </select>
</label> </label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" /> <ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field"> <label class="compare-field">
<span>To (newer)</span> <span>Compare with</span>
<select <select
value={compareTo} value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)} onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy} disabled={isBusy}
> >
<option value="" disabled>Select a commit</option> <option value="" disabled>Select a branch or commit</option>
{#each commits as item (item.hash)} {#if localBranches.length > 0}
<option value={item.hash}>{commitOptionLabel(item)}</option> <optgroup label="Local branches">
{/each} {#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> </select>
</label> </label>
<button class="btn-primary" type="submit" disabled={!canCompare}> <button class="btn-primary" type="submit" disabled={!canCompare}>
{#if operation === "Comparing commits"} {#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<GitCompare size={16} aria-hidden="true" /> <GitCompare size={16} aria-hidden="true" />
@@ -98,9 +144,11 @@
</form> </form>
{#if compareFrom && compareTo && compareFrom === compareTo} {#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} {: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}
{/if} {/if}
</div> </div>
+39 -14
View File
@@ -18,14 +18,29 @@
let name = $state(""); 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(() => { $effect(() => {
name = branch.name; name = originalName;
}); });
function submit(event: SubmitEvent) { function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
const value = name.trim(); const value = name.trim();
if (!value || value === branch.name) return; if (!value || value === originalName) return;
onRename(value); onRename(value);
} }
</script> </script>
@@ -34,10 +49,10 @@
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <header class="dialog-header">
<div> <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> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
@@ -48,27 +63,37 @@
<form class="rename-branch-form" onsubmit={submit}> <form class="rename-branch-form" onsubmit={submit}>
<label class="new-branch-field"> <label class="new-branch-field">
<span>Branch name</span> <span>Branch name</span>
<!-- svelte-ignore a11y_autofocus --> <div class:remote-branch-name-field={branch.remote}>
<input {#if branch.remote}<strong>{remote}/</strong>{/if}
bind:value={name} <!-- svelte-ignore a11y_autofocus -->
autocomplete="off" <input
spellcheck="false" bind:value={name}
disabled={isBusy} autocomplete="off"
autofocus spellcheck="false"
/> disabled={isBusy}
autofocus
/>
</div>
</label> </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"> <div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}> <button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel Cancel
</button> </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} {#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<GitBranch size={16} aria-hidden="true" /> <GitBranch size={16} aria-hidden="true" />
{/if} {/if}
Rename {branch.remote ? "Rename on remote" : "Rename"}
</button> </button>
</div> </div>
</form> </form>
+9
View File
@@ -133,6 +133,15 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch }); 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> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force }); return invoke<GitStatus>("delete_branch", { path, branch, force });
} }