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