From 7800f0fb24aa4366d53b7339f42eb6335ddcb28c Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 13 Jul 2026 23:24:36 +0200 Subject: [PATCH] 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;