From 7800f0fb24aa4366d53b7339f42eb6335ddcb28c Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 13 Jul 2026 23:24:36 +0200 Subject: [PATCH 1/2] feat(git): expand core git repository management features This update significantly expands the available Git functionality by adding robust support for initializing repositories, managing remotes, and improving complex workflow operations like merging and reverting commits. New API endpoints are exposed across the backend and frontend to handle remote setup, branch tracking, and conflict resolution workflows. - Added full remote management capabilities (add, update, remove). - Implemented advanced merge strategies and commit reversion logic. - Introduced a dedicated UI component for synchronization settings. --- docs/api-contract.md | 23 +- src-tauri/src/git.rs | 336 ++++++++++++++++++- src-tauri/src/main.rs | 32 +- src/App.svelte | 142 +++++++- src/app.css | 40 +++ src/lib/RepoToolbar.svelte | 21 +- src/lib/components/BranchPanel.svelte | 10 +- src/lib/components/HistoryPanel.svelte | 12 + src/lib/components/SyncSettingsDialog.svelte | 82 +++++ src/lib/git.ts | 33 +- src/lib/types.ts | 5 + 11 files changed, 688 insertions(+), 48 deletions(-) create mode 100644 src/lib/components/SyncSettingsDialog.svelte diff --git a/docs/api-contract.md b/docs/api-contract.md index 0ca5293..00db310 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -15,6 +15,9 @@ interface GitStatus { behind: number; files: GitFileStatus[]; clean: boolean; + rebase_in_progress: boolean; + cherry_pick_in_progress: boolean; + merge_in_progress: boolean; } interface GitFileStatus { @@ -56,19 +59,33 @@ interface GitRepositoryFile { ## Commands +The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list. + - `open_repository(path: string): Promise` +- `init_repository(path: string, initialBranch?: string): Promise` +- `clone_repository(...): Promise` - `get_status(path: string): Promise` - `list_branches(path: string): Promise` +- `list_remotes(path: string): Promise` +- `add_remote(path: string, name: string, url: string): Promise` +- `update_remote(path: string, name: string, url: string): Promise` +- `remove_remote(path: string, name: string): Promise` +- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise` +- `delete_remote_branch(path: string, remote: string, branch: string): Promise` - `checkout_branch(path: string, branch: string): Promise` - `stage_files(path: string, files: string[]): Promise` - `unstage_files(path: string, files: string[]): Promise` - `restore_files(path: string, files: string[], staged: boolean): Promise` - `commit(path: string, message: string): Promise` -- `pull(path: string): Promise` -- `push(path: string): Promise` +- `fetch(path: string, prune?: boolean, remote?: string): Promise` +- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise` +- `push(path: string, forceWithLease?: boolean, remote?: string): Promise` - `list_commits(path: string, limit?: number): Promise` - `restore_to_commit(path: string, commit: string): Promise` - `restore_file_from_commit(path: string, commit: string, file: string): Promise` (the `file` argument can also be a folder path) -- `merge_branch(path: string, branch: string): Promise` +- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise` +- `merge_continue(path: string): Promise` +- `merge_abort(path: string): Promise` +- `revert_commit(path: string, commit: string): Promise` - `list_repository_files(path: string): Promise` - `list_file_history(path: string, file: string, limit?: number): Promise` (the `file` argument can also be a folder path) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index c0d13fd..c182510 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -51,6 +51,14 @@ pub struct GitStatus { pub clean: bool, pub rebase_in_progress: bool, pub cherry_pick_in_progress: bool, + pub merge_in_progress: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitRemote { + pub name: String, + pub fetch_url: String, + pub push_url: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -357,6 +365,34 @@ pub fn open_repository(path: String) -> Result { status_for_repo(&repo) } +#[tauri::command] +pub fn init_repository(path: String, initial_branch: Option) -> Result { + let path = PathBuf::from(path.trim()); + if path.as_os_str().is_empty() { + return Err("Repository path must not be empty.".to_string()); + } + fs::create_dir_all(&path) + .map_err(|err| format!("Could not create repository folder: {err}"))?; + let branch = initial_branch.unwrap_or_else(|| "main".to_string()); + let branch = branch.trim(); + if branch.is_empty() { + return Err("Initial branch must not be empty.".to_string()); + } + let output = git_command() + .arg("-C") + .arg(&path) + .args(["init", "-b", branch]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + if !output.status.success() { + return Err(format!( + "Could not initialize repository: {}", + command_output_details(&output) + )); + } + status_for_repo(&path) +} + #[tauri::command] pub fn open_repo_in_explorer(path: String) -> Result<(), String> { let repo = resolve_repo(&path)?; @@ -455,6 +491,102 @@ pub fn list_branches(path: String) -> Result, String> { branches_for_repo(&repo) } +#[tauri::command] +pub fn list_remotes(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + let names = run_git(&repo, ["remote"])?; + Ok(String::from_utf8_lossy(&names) + .lines() + .filter_map(|line| { + let name = line.trim(); + if name.is_empty() { + return None; + } + Some(GitRemote { + name: name.to_string(), + fetch_url: remote_url_for(&repo, name).unwrap_or_default(), + push_url: remote_push_url_for(&repo, name).unwrap_or_default(), + }) + }) + .collect()) +} + +#[tauri::command] +pub fn add_remote(path: String, name: String, url: String) -> Result, String> { + let repo = resolve_repo(&path)?; + let name = validate_remote_name(&repo, &name, false)?; + let url = validate_remote_url(&url)?; + run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?; + list_remotes(path) +} + +#[tauri::command] +pub fn update_remote(path: String, name: String, url: String) -> Result, String> { + let repo = resolve_repo(&path)?; + let name = validate_remote_name(&repo, &name, true)?; + let url = validate_remote_url(&url)?; + run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?; + list_remotes(path) +} + +#[tauri::command] +pub fn remove_remote(path: String, name: String) -> Result, String> { + let repo = resolve_repo(&path)?; + let name = validate_remote_name(&repo, &name, true)?; + run_git(&repo, ["remote", "remove", name.as_str()])?; + list_remotes(path) +} + +#[tauri::command] +pub fn set_branch_upstream( + path: String, + branch: String, + upstream: Option, +) -> Result { + let repo = resolve_repo(&path)?; + let branch = validate_existing_local_branch_name(&repo, &branch)?; + match upstream + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + Some(upstream) => { + if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? { + return Err(format!("Remote branch '{upstream}' was not found.")); + } + run_git( + &repo, + [ + "branch", + "--set-upstream-to", + upstream.as_str(), + branch.as_str(), + ], + )?; + } + None => { + run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?; + } + } + status_for_repo(&repo) +} + +#[tauri::command] +pub fn delete_remote_branch( + path: String, + remote: String, + branch: String, +) -> Result { + let repo = resolve_repo(&path)?; + let remote = validate_remote_name(&repo, &remote, true)?; + let branch = branch.trim(); + if branch.is_empty() || branch.starts_with('-') { + return Err("Invalid remote branch name.".to_string()); + } + run_git(&repo, ["check-ref-format", "--branch", branch])?; + run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; + status_for_repo(&repo) +} + #[tauri::command] pub fn list_stashes(path: String) -> Result, String> { let repo = resolve_repo(&path)?; @@ -1405,18 +1537,43 @@ pub async fn pull( path: String, username: Option, password: Option, + strategy: Option, + remote: Option, + branch: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; - let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; + let strategy = strategy.as_deref().unwrap_or("merge"); + let mut pull_args = vec![OsString::from("pull")]; + match strategy { + "merge" => { + pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]) + } + "rebase" => pull_args.push(OsString::from("--rebase")), + "ff-only" => pull_args.push(OsString::from("--ff-only")), + _ => return Err("Unknown pull strategy.".to_string()), + } + if let Some(remote) = remote + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + validate_remote_name(&repo, &remote, true)?; + pull_args.push(remote.into()); + if let Some(branch) = branch + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + pull_args.push(branch.into()); + } + } let output = match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { - run_git_authenticated_output(&repo, pull_args, u, p)? + run_git_authenticated_output(&repo, pull_args.clone(), u, p)? } _ => git_command() .arg("-C") .arg(&repo) - .args(pull_args) + .args(&pull_args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, }; @@ -1445,18 +1602,30 @@ pub async fn fetch( path: String, username: Option, password: Option, + prune: Option, + remote: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; - let fetch_args = ["fetch"]; + let mut fetch_args = vec![OsString::from("fetch")]; + if prune.unwrap_or(false) { + fetch_args.push(OsString::from("--prune")); + } + if let Some(remote) = remote + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + validate_remote_name(&repo, &remote, true)?; + fetch_args.push(remote.into()); + } let output = match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { - run_git_authenticated_output(&repo, fetch_args, u, p)? + run_git_authenticated_output(&repo, fetch_args.clone(), u, p)? } _ => git_command() .arg("-C") .arg(&repo) - .args(fetch_args) + .args(&fetch_args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, }; @@ -1480,10 +1649,15 @@ pub async fn push( path: String, username: Option, password: Option, + force_with_lease: Option, + remote: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; - let push_args = push_args_for_repo(&repo)?; + let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?; + if force_with_lease.unwrap_or(false) { + push_args.insert(1, OsString::from("--force-with-lease")); + } match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { run_git_authenticated(&repo, push_args, u, p)?; @@ -1553,6 +1727,43 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option { if url.is_empty() { None } else { Some(url) } } +fn remote_push_url_for(repo: &Path, remote: &str) -> Option { + let out = git_command() + .arg("-C") + .arg(repo) + .args(["remote", "get-url", "--push", remote]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if url.is_empty() { None } else { Some(url) } +} + +fn validate_remote_url(url: &str) -> Result { + let url = url.trim(); + if url.is_empty() || url.starts_with('-') { + return Err("Remote URL must not be empty.".to_string()); + } + Ok(url.to_string()) +} + +fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result { + let name = name.trim(); + if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) { + return Err("Invalid remote name.".to_string()); + } + let exists = remote_url_for(repo, name).is_some(); + if must_exist && !exists { + return Err(format!("Remote '{name}' was not found.")); + } + if !must_exist && exists { + return Err(format!("Remote '{name}' already exists.")); + } + Ok(name.to_string()) +} + fn upstream_remote_name(repo: &Path) -> Option { let branch = current_branch_name(repo).ok()?; let out = git_command() @@ -1605,7 +1816,25 @@ fn initial_push_remote_name(repo: &Path) -> Result { .ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string()) } +#[cfg(test)] fn push_args_for_repo(repo: &Path) -> Result, String> { + push_args_for_repo_to(repo, None) +} + +fn push_args_for_repo_to( + repo: &Path, + requested_remote: Option<&str>, +) -> Result, String> { + if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) { + let remote = validate_remote_name(repo, remote, true)?; + let branch = current_branch_name(repo)?; + return Ok(vec![ + OsString::from("push"), + OsString::from("--set-upstream"), + remote.into(), + branch.into(), + ]); + } if branch_has_upstream(repo) { return Ok(vec![OsString::from("push")]); } @@ -1665,7 +1894,11 @@ pub fn cred_delete(key: String) -> Result<(), String> { } #[tauri::command] -pub async fn merge_branch(path: String, branch: String) -> Result { +pub async fn merge_branch( + path: String, + branch: String, + strategy: Option, +) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let branch = branch.trim(); @@ -1673,10 +1906,19 @@ pub async fn merge_branch(path: String, branch: String) -> Result {} + "squash" => args.push("--squash"), + "ff-only" => args.push("--ff-only"), + "no-ff" => args.push("--no-ff"), + _ => return Err("Unknown merge strategy.".to_string()), + } + args.push(branch); let output = git_command() .arg("-C") .arg(&repo) - .args(["merge", "--no-edit", branch]) + .args(args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; @@ -1708,6 +1950,52 @@ pub async fn merge_branch(path: String, branch: String) -> Result Result { + let repo = resolve_repo(&path)?; + if !merge_in_progress(&repo) { + return Err("No merge is in progress.".to_string()); + } + if has_unresolved_conflicts(&status_for_repo(&repo)?) { + return Err("Resolve all conflicts before continuing the merge.".to_string()); + } + run_git(&repo, ["commit", "--no-edit"])?; + status_for_repo(&repo) +} + +#[tauri::command] +pub fn merge_abort(path: String) -> Result { + let repo = resolve_repo(&path)?; + if !merge_in_progress(&repo) { + return Err("No merge is in progress.".to_string()); + } + run_git(&repo, ["merge", "--abort"])?; + status_for_repo(&repo) +} + +#[tauri::command] +pub fn revert_commit(path: String, commit: String) -> Result { + let repo = resolve_repo(&path)?; + let commit = verify_commit(&repo, &commit)?; + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["revert", "--no-edit", commit.as_str()]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + if output.status.success() { + return status_for_repo(&repo); + } + let status = status_for_repo(&repo)?; + if has_unresolved_conflicts(&status) { + return Ok(status); + } + Err(format!( + "Revert failed: {}", + command_output_details(&output) + )) +} + #[tauri::command] pub async fn rebase_branch(path: String, branch: String) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { @@ -3114,6 +3402,7 @@ fn status_for_repo(repo: &Path) -> Result { files, rebase_in_progress: rebase_in_progress(repo), cherry_pick_in_progress: cherry_pick_in_progress(repo), + merge_in_progress: merge_in_progress(repo), }) } @@ -3125,6 +3414,10 @@ fn cherry_pick_in_progress(repo: &Path) -> bool { git_path_exists(repo, "CHERRY_PICK_HEAD") } +fn merge_in_progress(repo: &Path) -> bool { + git_path_exists(repo, "MERGE_HEAD") +} + fn git_path_exists(repo: &Path, name: &str) -> bool { let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { return false; @@ -5626,9 +5919,16 @@ mod tests { ], ); - let status = pull(repo.path.to_string_lossy().to_string(), None, None) - .await - .unwrap(); + let status = pull( + repo.path.to_string_lossy().to_string(), + None, + None, + None, + None, + None, + ) + .await + .unwrap(); assert!(status.clean, "{:?}", status.files); assert!(repo.path.join("remote.txt").exists()); @@ -5699,9 +5999,15 @@ mod tests { ["remote", "add", "origin", remote.path.to_str().unwrap()], ); - let status = push(repo.path.to_string_lossy().to_string(), None, None) - .await - .unwrap(); + let status = push( + repo.path.to_string_lossy().to_string(), + None, + None, + None, + None, + ) + .await + .unwrap(); assert_eq!( status.upstream.as_deref(), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0ff83c3..aff4d15 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,21 +5,23 @@ mod git; use badge::set_sync_badge; use git::{ - SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search, + SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, - cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch, - get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, - list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, - list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer, - open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag, - read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, - resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, - restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, + cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag, + diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, + get_status, init_repository, last_commit_message, list_branches, list_commits, + list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, + list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue, + open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, + push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, + rename_branch, 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, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, - unstage_files, + unstage_files, update_remote, }; use tauri::Manager; @@ -82,11 +84,18 @@ async fn main() { builder .invoke_handler(tauri::generate_handler![ open_repository, + init_repository, clone_repository, open_repo_in_explorer, open_repository_file, get_status, list_branches, + list_remotes, + add_remote, + update_remote, + remove_remote, + set_branch_upstream, + delete_remote_branch, list_stashes, checkout_branch, create_branch, @@ -124,6 +133,9 @@ async fn main() { restore_to_commit, restore_file_from_commit, merge_branch, + merge_continue, + merge_abort, + revert_commit, rebase_branch, rebase_continue, rebase_abort, diff --git a/src/App.svelte b/src/App.svelte index bcd419a..a366617 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -36,10 +36,12 @@ import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StashPanel from "./lib/components/StashPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte"; + import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte"; import { amendCommit, + addRemote, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -59,6 +61,8 @@ createTag, deleteBranch, deleteTag, + deleteRemoteBranch, + initRepository, diffFileAgainstWorkingTree, compareFileToParent, fetchRemote, @@ -66,6 +70,7 @@ getStatus, lastCommitMessage, listBranches, + listRemotes, listStashes, listTags, listCommits, @@ -74,12 +79,18 @@ listReflog, listRepositoryFiles, mergeBranch, + mergeAbort, + mergeContinue, openRepoInExplorer, openRepositoryFile, openRepositoryBundle, pull, push, pushTag, + removeRemote, + revertCommit, + setBranchUpstream, + updateRemote, renameBranch, rebaseAbort, rebaseBranch, @@ -126,6 +137,8 @@ GitDiffFile, GitFileStatus, GitRepositoryFile, + GitRemote, + PullStrategy, GitSearchHit, GitStash, GitStatus, @@ -230,6 +243,12 @@ let repoStatusCache: Record = {}; let repoSearch = ""; let cloneDialogOpen = false; + let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge"; + let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || ""; + let remoteActionForceWithLease = false; + let remoteActionPrune = false; + let syncSettingsOpen = false; + let syncSettingsRemotes: GitRemote[] = []; let cloneDialogError = ""; let cloneDialogErrorTimer: ReturnType | undefined; let pendingClone: CloneRequest | null = null; @@ -392,6 +411,7 @@ $: hasConflicts = conflictedFiles.length > 0; $: rebaseInProgress = status?.rebase_in_progress ?? false; $: cherryPickInProgress = status?.cherry_pick_in_progress ?? false; + $: mergeInProgress = status?.merge_in_progress ?? false; // Amending/undoing is only offered while the last commit hasn't reached a // remote yet: no upstream at all, or the branch is still ahead of it. $: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress @@ -2314,8 +2334,11 @@ async function merge(branch: GitBranchInfo) { if (!activeRepoPath || branch.current) return; + const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim(); + if (!strategy) return; + if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; } await runOperation(`Merging ${branch.name}`, async () => { - applyStatus(await mergeBranch(activeRepoPath, branch.name)); + applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy)); await refreshBranchList(activeRepoPath); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); @@ -2618,7 +2641,7 @@ ) { errorMessage = ""; await runOperation("Pulling", async () => { - applyStatus(await pull(activeRepoPath, username, password)); + applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined)); await refreshBranchList(activeRepoPath); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); @@ -2639,7 +2662,8 @@ ) { errorMessage = ""; await runOperation("Fetching", async () => { - applyStatus(await fetchRemote(activeRepoPath, username, password)); + applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined)); + remoteActionPrune = false; await refreshRefsAndCommitGraph(activeRepoPath); trackEvent("repository_fetched", { from_stored_credential: fromStore ? 1 : 0, @@ -2658,7 +2682,8 @@ ) { errorMessage = ""; await runOperation("Pushing", async () => { - applyStatus(await push(activeRepoPath, username, password)); + applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined)); + remoteActionForceWithLease = false; await refreshBranchList(activeRepoPath); await refreshCommitHistory(activeRepoPath); await refreshFileHistory(activeRepoPath); @@ -2782,6 +2807,78 @@ await startRemoteAction("push"); } + async function deleteTrackedRemoteBranch(branch: GitBranchInfo) { + if (!activeRepoPath || !branch.remote) return; + const slash = branch.name.indexOf("/"); + if (slash < 1) { errorMessage = "Could not determine remote name."; return; } + const remote = branch.name.slice(0, slash); const remoteBranch = branch.name.slice(slash + 1); + if (!window.confirm(`Delete '${remoteBranch}' from remote '${remote}'?`)) return; + await runOperation("Deleting remote branch", async () => { applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); await refreshRefsAndCommitGraph(activeRepoPath); }); + } + + async function initializeRepository() { + const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" }); + if (typeof selected !== "string") return; + const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return; + await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); }); + } + + async function revertHistoryCommit(commit: GitCommit) { + if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return; + await runOperation(`Reverting ${commit.short_hash}`, async () => { + applyStatus(await revertCommit(activeRepoPath, commit.hash)); + await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath); + }); + } + + async function continueMerge() { + if (!activeRepoPath) return; + await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); }); + } + + async function abortMerge() { + if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return; + await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); }); + } + + async function fetchPruneRepo() { + remoteActionPrune = true; + await startRemoteAction("fetch"); + } + + async function forcePushRepo() { + if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return; + remoteActionForceWithLease = true; + await startRemoteAction("push"); + } + + async function openSyncOptions() { + if (!activeRepoPath) return; + try { + syncSettingsRemotes = await listRemotes(activeRepoPath); + syncSettingsOpen = true; + } catch (error) { errorMessage = errorToMessage(error); } + } + + async function saveSyncSettings(strategy: PullStrategy, remote: string, upstream: string) { + if (!activeRepoPath || !status?.current_branch) return; + await runOperation("Saving sync settings", async () => { + pullStrategy = strategy; selectedRemote = remote; + localStorage.setItem("gitlite.pullStrategy", strategy); localStorage.setItem("gitlite.selectedRemote", remote); + applyStatus(await setBranchUpstream(activeRepoPath, status!.current_branch!, upstream || undefined)); + await refreshBranchList(activeRepoPath); syncSettingsOpen = false; + }); + } + + async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); } + async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); } + async function removeSyncRemote(name: string) { + if (!activeRepoPath || !window.confirm(`Remove remote '${name}'? Local commits and branches are kept.`)) return; + syncSettingsRemotes = await removeRemote(activeRepoPath, name); + if (selectedRemote === name) selectedRemote = ""; + await refreshBranchList(activeRepoPath); + } + async function saveStash(message: string, includeUntracked: boolean) { if (!activeRepoPath || changedFiles.length === 0) return; const stashedFiles = changedFiles.length; @@ -3626,6 +3723,9 @@ onInteractiveRebase={openInteractiveRebase} onReflog={openReflog} onOpenInExplorer={openActiveRepoInExplorer} + onFetchPrune={fetchPruneRepo} + onForcePush={forcePushRepo} + onSyncOptions={openSyncOptions} /> {/if} @@ -3671,7 +3771,15 @@ {/if} - {#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress} + {#if workspaceActive && mergeInProgress} +
+
+ {:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress} diff --git a/src/lib/components/SyncSettingsDialog.svelte b/src/lib/components/SyncSettingsDialog.svelte new file mode 100644 index 0000000..af006f8 --- /dev/null +++ b/src/lib/components/SyncSettingsDialog.svelte @@ -0,0 +1,82 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index 66efbfd..95a5941 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -11,6 +11,9 @@ import type { GitCommit, GitCommitComparison, GitRepositoryFile, + GitRemote, + MergeStrategy, + PullStrategy, RebaseCommit, RebasePlanItem, ReflogEntry, @@ -28,6 +31,10 @@ export function openRepository(path: string): Promise { return invoke("open_repository", { path }); } +export function initRepository(path: string, initialBranch = "main"): Promise { + return invoke("init_repository", { path, initialBranch }); +} + export function openRepoInExplorer(path: string): Promise { return invoke("open_repo_in_explorer", { path }); } @@ -72,6 +79,13 @@ export function listBranches(path: string): Promise { return invoke("list_branches", { path }); } +export function listRemotes(path: string): Promise { return invoke("list_remotes", { path }); } +export function addRemote(path: string, name: string, url: string): Promise { return invoke("add_remote", { path, name, url }); } +export function updateRemote(path: string, name: string, url: string): Promise { return invoke("update_remote", { path, name, url }); } +export function removeRemote(path: string, name: string): Promise { return invoke("remove_remote", { path, name }); } +export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); } +export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise { return invoke("delete_remote_branch", { path, remote, branch }); } + export function listStashes(path: string): Promise { return invoke("list_stashes", { path }); } @@ -255,16 +269,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions): }); } -export function pull(path: string, username?: string, password?: string): Promise { - return invoke("pull", { path, username: username ?? null, password: password ?? null }); +export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise { + return invoke("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null }); } -export function fetchRemote(path: string, username?: string, password?: string): Promise { - return invoke("fetch", { path, username: username ?? null, password: password ?? null }); +export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise { + return invoke("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null }); } -export function push(path: string, username?: string, password?: string): Promise { - return invoke("push", { path, username: username ?? null, password: password ?? null }); +export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise { + return invoke("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null }); } export function getRemoteUrl(path: string): Promise { @@ -304,9 +318,12 @@ export function restoreFileFromCommit( return invoke("restore_file_from_commit", { path, commit, file }); } -export function mergeBranch(path: string, branch: string): Promise { - return invoke("merge_branch", { path, branch }); +export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise { + return invoke("merge_branch", { path, branch, strategy }); } +export function mergeContinue(path: string): Promise { return invoke("merge_continue", { path }); } +export function mergeAbort(path: string): Promise { return invoke("merge_abort", { path }); } +export function revertCommit(path: string, commit: string): Promise { return invoke("revert_commit", { path, commit }); } export function rebaseBranch(path: string, branch: string): Promise { return invoke("rebase_branch", { path, branch }); diff --git a/src/lib/types.ts b/src/lib/types.ts index e5808c9..957ec31 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,8 +68,13 @@ export interface GitStatus { clean: boolean; rebase_in_progress: boolean; cherry_pick_in_progress: boolean; + merge_in_progress: boolean; } +export interface GitRemote { name: string; fetch_url: string; push_url: string; } +export type PullStrategy = "merge" | "rebase" | "ff-only"; +export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff"; + export interface GitFileStatus { path: string; old_path: string | null; From 95c8b01ed1331dd0b58c7943a34c91f3a313a47a Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 14 Jul 2026 00:29:37 +0200 Subject: [PATCH 2/2] feat(remote): Enhance remote branch management and stability Improved handling for deleting remote branches across the application, enhancing both user experience and backend reliability. This includes adding structured logging to all Git remote operations in Rust, refining UI components to handle remote-specific deletion flows, and providing clear status/error feedback in sync settings. - Standardized styling for action toggles (Stash, Branch, Explorer) using consistent dimensions. - Implemented detailed console logging for all Git remote operations on the backend. - Refined dialogs and sync settings to provide explicit status and error messages during remote management. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/git.rs | 47 +++++++--- src-tauri/src/main.rs | 31 +++++++ src/App.svelte | 53 ++++++++--- src/app.css | 91 ++++++++++++++----- .../BranchDeleteConfirmDialog.svelte | 15 ++- src/lib/components/BranchPanel.svelte | 2 +- src/lib/components/SyncSettingsDialog.svelte | 31 ++++++- src/lib/git.ts | 4 +- 10 files changed, 219 insertions(+), 57 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4284170..9e300b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2537,6 +2537,7 @@ version = "0.1.0" dependencies = [ "commit_ai", "keyring", + "log", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 51920cc..f81922f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-plugin-aptabase = "1.0" keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] } commit_ai = { path = "crates/commit_ai" } tokio = "1.52.3" +log = "0.4" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index c182510..90d9b8f 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -493,6 +493,7 @@ pub fn list_branches(path: String) -> Result, String> { #[tauri::command] pub fn list_remotes(path: String) -> Result, String> { + log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}"); let repo = resolve_repo(&path)?; let names = run_git(&repo, ["remote"])?; Ok(String::from_utf8_lossy(&names) @@ -531,10 +532,23 @@ pub fn update_remote(path: String, name: String, url: String) -> Result Result, String> { - let repo = resolve_repo(&path)?; - let name = validate_remote_name(&repo, &name, true)?; - run_git(&repo, ["remote", "remove", name.as_str()])?; - list_remotes(path) + log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}"); + let result = (|| { + let repo = resolve_repo(&path)?; + log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display()); + let name = validate_remote_name(&repo, &name, true)?; + log::info!(target: "gitty::remote", "remove_remote validated remote: {name}"); + run_git(&repo, ["remote", "remove", name.as_str()])?; + log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}"); + list_remotes(path) + })(); + match &result { + Ok(remotes) => { + log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::>()) + } + Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"), + } + result } #[tauri::command] @@ -576,15 +590,24 @@ pub fn delete_remote_branch( remote: String, branch: String, ) -> Result { - let repo = resolve_repo(&path)?; - let remote = validate_remote_name(&repo, &remote, true)?; - let branch = branch.trim(); - if branch.is_empty() || branch.starts_with('-') { - return Err("Invalid remote branch name.".to_string()); + log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}"); + let result = (|| { + let repo = resolve_repo(&path)?; + let remote = validate_remote_name(&repo, &remote, true)?; + let branch = branch.trim(); + if branch.is_empty() || branch.starts_with('-') { + return Err("Invalid remote branch name.".to_string()); + } + run_git(&repo, ["check-ref-format", "--branch", branch])?; + log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}"); + run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; + log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}"); + status_for_repo(&repo) + })(); + if let Err(error) = &result { + log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}"); } - run_git(&repo, ["check-ref-format", "--branch", branch])?; - run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; - status_for_repo(&repo) + result } #[tauri::command] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aff4d15..8e0d866 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,6 +25,36 @@ use git::{ }; use tauri::Manager; +struct ConsoleLogger; + +impl log::Log for ConsoleLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Info + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + eprintln!( + "[{}] [{}] {}", + record.level(), + record.target(), + record.args() + ); + } + } + + fn flush(&self) {} +} + +static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger; + +fn init_console_logging() { + if log::set_logger(&CONSOLE_LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Info); + log::info!(target: "gitty", "Rust console logging initialized"); + } +} + #[tauri::command] fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("splashscreen") { @@ -47,6 +77,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { #[tokio::main] async fn main() { + init_console_logging(); if let Some(result) = run_sequence_editor_if_requested() { if let Err(error) = result { eprintln!("{error}"); diff --git a/src/App.svelte b/src/App.svelte index a366617..29fe331 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2276,7 +2276,27 @@ async function confirmDeleteBranch() { const branch = deleteBranchTarget; - if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return; + if (!activeRepoPath || !branch || branch.current || isBusy) 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 remoteBranch = branch.name.slice(slash + 1); + operation = `Deleting ${branch.name} from remote`; + errorMessage = ""; + try { + applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); + deleteBranchTarget = null; + await refreshRefsAndCommitGraph(activeRepoPath); + trackEvent("remote_branch_deleted"); + } catch (error) { + errorMessage = errorToMessage(error); + } finally { + operation = ""; + } + return; + } operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`; errorMessage = ""; @@ -2809,11 +2829,10 @@ async function deleteTrackedRemoteBranch(branch: GitBranchInfo) { if (!activeRepoPath || !branch.remote) return; - const slash = branch.name.indexOf("/"); - if (slash < 1) { errorMessage = "Could not determine remote name."; return; } - const remote = branch.name.slice(0, slash); const remoteBranch = branch.name.slice(slash + 1); - if (!window.confirm(`Delete '${remoteBranch}' from remote '${remote}'?`)) return; - await runOperation("Deleting remote branch", async () => { applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); await refreshRefsAndCommitGraph(activeRepoPath); }); + if (import.meta.env.DEV) console.info("[Gitty remote] remote branch delete requested", branch); + deleteBranchTarget = branch; + deleteBranchForce = false; + trackEvent("remote_branch_delete_dialog_opened"); } async function initializeRepository() { @@ -2873,10 +2892,21 @@ async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); } async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); } async function removeSyncRemote(name: string) { - if (!activeRepoPath || !window.confirm(`Remove remote '${name}'? Local commits and branches are kept.`)) return; - syncSettingsRemotes = await removeRemote(activeRepoPath, name); - if (selectedRemote === name) selectedRemote = ""; - await refreshBranchList(activeRepoPath); + if (!activeRepoPath) return; + operation = `Removing remote ${name}`; + try { + syncSettingsRemotes = await removeRemote(activeRepoPath, name); + if (syncSettingsRemotes.some((remote) => remote.name === name)) throw new Error(`Remote '${name}' still exists after removal.`); + if (selectedRemote === name) { selectedRemote = ""; localStorage.setItem("gitlite.selectedRemote", ""); } + applyStatus(await getStatus(activeRepoPath)); + await refreshBranchList(activeRepoPath); + } catch (error) { + const message = errorToMessage(error); + if (import.meta.env.DEV) console.error("[Gitty remote] remove_remote failed", { name, path: activeRepoPath, error }); + throw new Error(message); + } finally { + operation = ""; + } } async function saveStash(message: string, includeUntracked: boolean) { @@ -3667,6 +3697,7 @@ } function handleWindowContextMenu(event: MouseEvent) { + if (import.meta.env.DEV) return; event.preventDefault(); if (repoTabContextMenu) closeRepoTabContextMenu(); } @@ -4404,7 +4435,7 @@ /> {/if} - + {#if deleteBranchTarget} div { display: flex; gap: 8px; } @media (max-width: 720px) { .strategy-options, .sync-fields { grid-template-columns: 1fr; } .remote-add { grid-template-columns: 1fr; } .sync-settings-footer { align-items: stretch; flex-direction: column; } .sync-settings-footer > div { justify-content: flex-end; } } + +/* Compact square controls shared by the Branches, Stash and Explorer headers. */ +.branch-head-actions .branch-create-toggle, +.stash-head-actions .stash-toggle, +.explorer-head-actions .explorer-bulk-button { + box-sizing: border-box; + display: inline-grid; + place-items: center; + inline-size: 24px; + min-inline-size: 24px; + max-inline-size: 24px; + block-size: 24px; + min-block-size: 24px; + max-block-size: 24px; + flex: 0 0 24px; + aspect-ratio: 1 / 1; + padding: 0; + border-radius: 5px; +} .ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } .ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } .ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } diff --git a/src/lib/components/BranchDeleteConfirmDialog.svelte b/src/lib/components/BranchDeleteConfirmDialog.svelte index 0d6b563..3aea620 100644 --- a/src/lib/components/BranchDeleteConfirmDialog.svelte +++ b/src/lib/components/BranchDeleteConfirmDialog.svelte @@ -18,7 +18,8 @@ onClose = () => {}, }: Props = $props(); - let title = $derived(force ? "Force delete branch?" : "Delete branch?"); + let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?"); + let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []); @@ -26,7 +27,7 @@ diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 5a6697b..5731ab4 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -266,7 +266,7 @@ async function deleteContextBranch() { const branch = contextBranch; - if (!branch || branch.current || branch.remote || isBusy) return; + if (!branch || branch.current || isBusy) return; closeBranchContextMenu(); if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch); } diff --git a/src/lib/components/SyncSettingsDialog.svelte b/src/lib/components/SyncSettingsDialog.svelte index af006f8..34823cb 100644 --- a/src/lib/components/SyncSettingsDialog.svelte +++ b/src/lib/components/SyncSettingsDialog.svelte @@ -23,12 +23,37 @@ let newUrl = ""; let editingName = ""; let editingUrl = ""; + let actionError = ""; + let actionStatus = ""; $: de = language === "de"; - function beginEdit(remote: GitRemote) { editingName = remote.name; editingUrl = remote.fetch_url; } + function beginEdit(remote: GitRemote) { actionError = ""; editingName = remote.name; editingUrl = remote.fetch_url; } + async function requestDelete(event: MouseEvent, name: string) { + console.log(name) + event.preventDefault(); + event.stopPropagation(); + editingName = ""; + actionError = ""; + actionStatus = de ? `Remote „${name}“ wird entfernt …` : `Removing remote “${name}” …`; + console.info("[Gitty remote] remove button activated", { name }); + await remove(name); + } function cancelEdit() { editingName = ""; editingUrl = ""; } async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; } async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); } + async function remove(name: string) { + actionError = ""; + console.log("remove") + try { + await onRemoveRemote(name); + if (draftRemote === name) draftRemote = ""; + if (draftUpstream.startsWith(`${name}/`)) draftUpstream = ""; + } catch (error) { + actionError = error instanceof Error ? error.message : String(error); + } finally { + actionStatus = ""; + } + }