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/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 259b768..1475064 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)] @@ -361,6 +369,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)?; @@ -459,6 +495,125 @@ pub fn list_branches(path: String) -> Result, String> { branches_for_repo(&repo) } +#[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) + .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> { + 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] +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 { + 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}"); + } + result +} + #[tauri::command] pub fn list_stashes(path: String) -> Result, String> { let repo = resolve_repo(&path)?; @@ -1409,18 +1564,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}"))?, }; @@ -1449,18 +1629,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}"))?, }; @@ -1484,10 +1676,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)?; @@ -1557,6 +1754,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() @@ -1609,7 +1843,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")]); } @@ -1669,7 +1921,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(); @@ -1677,10 +1933,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}"))?; @@ -1712,6 +1977,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 { @@ -3118,6 +3429,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), }) } @@ -3129,6 +3441,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; @@ -5633,9 +5949,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()); @@ -5706,9 +6029,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..8e0d866 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,24 +5,56 @@ 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; +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") { @@ -45,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}"); @@ -82,11 +115,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 +164,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 71fc2d8..e50650e 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, @@ -231,6 +244,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; @@ -393,6 +412,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 @@ -2260,7 +2280,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 = ""; @@ -2318,8 +2358,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); @@ -2624,7 +2667,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); @@ -2645,7 +2688,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, @@ -2664,7 +2708,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); @@ -2788,6 +2833,88 @@ await startRemoteAction("push"); } + async function deleteTrackedRemoteBranch(branch: GitBranchInfo) { + if (!activeRepoPath || !branch.remote) return; + 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() { + 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) 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) { if (!activeRepoPath || changedFiles.length === 0) return; const stashedFiles = changedFiles.length; @@ -3576,6 +3703,7 @@ } function handleWindowContextMenu(event: MouseEvent) { + if (import.meta.env.DEV) return; event.preventDefault(); if (repoTabContextMenu) closeRepoTabContextMenu(); } @@ -3632,6 +3760,9 @@ onInteractiveRebase={openInteractiveRebase} onReflog={openReflog} onOpenInExplorer={openActiveRepoInExplorer} + onFetchPrune={fetchPruneRepo} + onForcePush={forcePushRepo} + onSyncOptions={openSyncOptions} /> {/if} @@ -3677,7 +3808,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..34823cb --- /dev/null +++ b/src/lib/components/SyncSettingsDialog.svelte @@ -0,0 +1,109 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index 66efbfd..9108c15 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,15 @@ 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 { + console.log("remove_remote") + 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 +271,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 +320,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;