diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 96ac1a9..f1ff97c 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -675,6 +675,99 @@ pub fn delete_remote_branch( result } +#[tauri::command] +pub async fn rename_remote_branch( + path: String, + remote: String, + old_branch: String, + new_branch: String, +) -> Result { + 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 { + 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] pub async fn list_stashes(path: String) -> Result, String> { run_git_task("Could not load stashes", move || { @@ -6686,6 +6779,31 @@ mod tests { 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] fn compare_commits_includes_full_file_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] fn delete_branch_removes_local_branch_but_rejects_current_branch() { let repo = init_temp_repo("delete_branch"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3c1098f..a5df731 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -24,11 +24,12 @@ use git::{ 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, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch, - repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit, - restore_files, restore_reflog_entry, restore_to_commit, revert_commit, - run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, - set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, - stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, + rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, + revert_commit, run_sequence_editor_if_requested, search_code_introductions, + set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply, + stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, + update_remote, }; use tauri::Manager; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; @@ -144,6 +145,7 @@ async fn main() { checkout_branch, create_branch, rename_branch, + rename_remote_branch, delete_branch, list_worktrees, add_worktree, diff --git a/src/App.svelte b/src/App.svelte index d32a2d6..be05b94 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -100,6 +100,7 @@ setCommitNote, updateRemote, renameBranch, + renameRemoteBranch, rebaseAbort, rebaseBranch, rebaseContinue, @@ -359,6 +360,8 @@ let compareFrom = ""; let compareTo = ""; let comparison: GitCommitComparison | null = null; + let comparisonFromLabel = ""; + let comparisonToLabel = ""; let newBranchCommit: GitCommit | null = null; let renameBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null; @@ -1852,6 +1855,8 @@ compareFrom = ""; compareTo = ""; comparison = null; + comparisonFromLabel = ""; + comparisonToLabel = ""; compareSelectOpen = false; compareDialogOpen = false; interactiveRebaseOpen = false; @@ -1981,15 +1986,12 @@ commitHistoryLoadingMore = false; commitHistoryLoadError = ""; lastFileHistoryHeadHash = commits[0]?.hash ?? ""; - const hashes = new Set(commits.map((c) => c.hash)); - if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; - if (compareTo && !hashes.has(compareTo)) compareTo = ""; - if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) { - comparison = null; - compareDialogOpen = false; - selectedDiffPath = ""; - pendingRestoreFile = null; - } + const targets = new Set([ + ...commits.map((commit) => commit.hash), + ...branches.map(compareRefForBranch), + ]); + if (compareFrom && !targets.has(compareFrom)) compareFrom = ""; + if (compareTo && !targets.has(compareTo)) compareTo = ""; } async function loadMoreCommitHistory() { @@ -2482,22 +2484,47 @@ }); } - function renameLocalBranch(branch: GitBranchInfo) { - if (!activeRepoPath || branch.remote) return; + function openRenameBranchDialog(branch: GitBranchInfo) { + if (!activeRepoPath) return; + if (branch.remote && branch.name.indexOf("/") < 1) { + errorMessage = "Could not determine remote name."; + return; + } renameBranchTarget = branch; - trackEvent("branch_rename_dialog_opened"); + trackEvent("branch_rename_dialog_opened", { remote: branch.remote ? 1 : 0 }); } async function submitRenameBranch(branchName: string) { const branch = renameBranchTarget; 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 () => { applyStatus(await renameBranch(activeRepoPath, branch.name, name)); renameBranchTarget = null; await refreshRepositoryViews(activeRepoPath); - trackEvent("branch_renamed"); + trackEvent("branch_renamed", { remote: 0 }); }); } @@ -2841,6 +2868,8 @@ await runOperation("Previewing reflog entry", async () => { const result = await compareCommits(activeRepoPath, entry.hash, "HEAD"); comparison = result; + comparisonFromLabel = entry.selector; + comparisonToLabel = "HEAD"; selectedDiffPath = result.files[0]?.path ?? ""; diffHighlightQuery = ""; pendingRestoreFile = null; @@ -3816,6 +3845,8 @@ diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path, ); comparison = result; + comparisonFromLabel = ""; + comparisonToLabel = ""; selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path; pendingRestoreFile = { commit: target, file }; compareDialogOpen = true; @@ -4072,12 +4103,36 @@ // ── 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() { if (!hasRepository) return; + if (!compareFrom) { + const current = branches.find((branch) => branch.current); + if (current) compareFrom = compareRefForBranch(current); + } compareSelectOpen = true; 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() { globalSearchOpen = true; trackEvent("global_search_opened"); @@ -4096,11 +4151,13 @@ aiSettingsOpen = true; } - async function compareSelectedCommits() { + async function compareSelectedTargets() { if (!canCompare) return; - await runOperation("Comparing commits", async () => { + await runOperation("Comparing revisions", async () => { const result = await compareCommits(activeRepoPath, compareFrom, compareTo); comparison = result; + comparisonFromLabel = compareLabelForTarget(compareFrom); + comparisonToLabel = compareLabelForTarget(compareTo); selectedDiffPath = result.files[0]?.path ?? ""; diffHighlightQuery = ""; pendingRestoreFile = null; @@ -4117,6 +4174,8 @@ await runOperation(`Diffing ${selectedExplorerPath}`, async () => { const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath); comparison = result; + comparisonFromLabel = ""; + comparisonToLabel = ""; selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath; diffHighlightQuery = ""; pendingRestoreFile = null; @@ -4133,6 +4192,8 @@ await runOperation(`Diffing ${hit.file}`, async () => { const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file); comparison = result; + comparisonFromLabel = ""; + comparisonToLabel = ""; selectedDiffPath = result.files[0]?.path ?? hit.file; diffHighlightQuery = lastSearchQuery; pendingRestoreFile = null; @@ -4711,10 +4772,11 @@ {hasRepository} {isBusy} onCheckout={checkout} + onCompareBranch={compareBranchWithCurrent} onMerge={merge} onRebase={rebaseOnto} onCreateBranch={createNewBranch} - onRenameBranch={renameLocalBranch} + onRenameBranch={openRenameBranchDialog} onDeleteBranch={deleteLocalBranch} onDeleteRemoteBranch={deleteTrackedRemoteBranch} onCreateTag={createNewTag} @@ -5195,7 +5257,7 @@ /> {/if} - + {#if renameBranchTarget} + {#if interactiveRebaseOpen} {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module} {/if} - + {#if compareSelectOpen} { compareFrom = val; }} onCompareToChange={(val) => { compareTo = val; }} - onCompare={compareSelectedCommits} + onCompare={compareSelectedTargets} onClose={() => { compareSelectOpen = false; }} /> {/if} @@ -5284,6 +5347,8 @@ {comparison} {selectedDiffPath} {isBusy} + fromLabel={comparisonFromLabel} + toLabel={comparisonToLabel} highlightQuery={diffHighlightQuery} restoreLabel={pendingRestoreFile ? "Restore file" : ""} onClose={closeCompareDialog} diff --git a/src/app.css b/src/app.css index b5ff9fa..737ba1f 100644 --- a/src/app.css +++ b/src/app.css @@ -3023,6 +3023,16 @@ .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-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-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); } @@ -3685,6 +3695,34 @@ text-transform: uppercase; 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 { display: flex; justify-content: flex-end; @@ -3703,7 +3741,8 @@ .compare-restore { max-width: 170px; min-width: 0; } .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-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-hash { + min-width: 0; + max-width: min(42%, 260px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; padding: 1px 6px; border-radius: 5px; background: rgba(90,140,248,0.12); diff --git a/src/lib/RepoToolbar.svelte b/src/lib/RepoToolbar.svelte index ce69e8c..96e4d86 100644 --- a/src/lib/RepoToolbar.svelte +++ b/src/lib/RepoToolbar.svelte @@ -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"} >