From 38b7f1a5362559783eb1a8f7fa28ea259698d868 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 26 Jul 2026 21:47:35 +0200 Subject: [PATCH] feat(git): Add comprehensive worktree management capabilities This update introduces full support for Git worktrees, allowing users to manage multiple isolated working copies within a single repository. This includes new functionality to list, add, remove, move, lock, and repair worktrees, significantly enhancing the repository's capability to handle parallel development streams. - Added `GitWorktree` structure definition across API contracts and Rust backend - Implemented full CRUD operations for worktrees in Tauri commands - Updated UI components (App.svelte, BranchPanel.svelte) to expose worktree management dialog --- docs/api-contract.md | 26 ++ src-tauri/src/git.rs | 374 +++++++++++++++++++ src-tauri/src/main.rs | 33 +- src/App.svelte | 185 ++++++++- src/app.css | 251 +++++++++++++ src/lib/components/BranchPanel.svelte | 27 +- src/lib/components/WorktreeDialog.svelte | 455 +++++++++++++++++++++++ src/lib/git.ts | 51 +++ src/lib/types.ts | 18 + 9 files changed, 1406 insertions(+), 14 deletions(-) create mode 100644 src/lib/components/WorktreeDialog.svelte diff --git a/docs/api-contract.md b/docs/api-contract.md index 00db310..c0b4a41 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -33,6 +33,24 @@ interface GitBranch { remote: boolean; } +interface GitWorktree { + path: string; + head: string | null; + short_head: string | null; + branch: string | null; + bare: boolean; + detached: boolean; + locked: boolean; + lock_reason: string | null; + prunable: boolean; + prune_reason: string | null; + missing: boolean; + is_main: boolean; + is_current: boolean; + clean: boolean; + changed_files: number; +} + interface GitCommit { hash: string; short_hash: string; @@ -73,6 +91,14 @@ The command list below includes the repository-management and synchronization AP - `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` +- `list_worktrees(path: string): Promise` +- `add_worktree(path: string, worktreePath: string, ...): Promise` +- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise` +- `move_worktree(path: string, worktreePath: string, destination: string): Promise` +- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise` +- `unlock_worktree(path: string, worktreePath: string): Promise` +- `prune_worktrees(path: string): Promise` +- `repair_worktree(path: string, worktreePath: 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` diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 1475064..98722f0 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -68,6 +68,25 @@ pub struct GitBranch { pub remote: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitWorktree { + pub path: String, + pub head: Option, + pub short_head: Option, + pub branch: Option, + pub bare: bool, + pub detached: bool, + pub locked: bool, + pub lock_reason: Option, + pub prunable: bool, + pub prune_reason: Option, + pub missing: bool, + pub is_main: bool, + pub is_current: bool, + pub clean: bool, + pub changed_files: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitTag { pub name: String, @@ -927,6 +946,291 @@ pub fn delete_branch( status_for_repo(&repo) } +fn worktree_path_matches(left: &Path, right: &Path) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec { + #[derive(Default)] + struct Record { + path: Option, + head: Option, + branch: Option, + bare: bool, + detached: bool, + locked: bool, + lock_reason: Option, + prunable: bool, + prune_reason: Option, + } + + fn finish_record(rows: &mut Vec, record: &mut Record, current_repo: &Path) { + let Some(path) = record.path.take() else { + *record = Record::default(); + return; + }; + let path_buf = PathBuf::from(&path); + let missing = !path_buf.exists(); + let is_current = worktree_path_matches(&path_buf, current_repo); + let head = record.head.take(); + let short_head = head + .as_ref() + .map(|value| value.chars().take(8).collect::()); + rows.push(GitWorktree { + path, + head, + short_head, + branch: record.branch.take(), + bare: record.bare, + detached: record.detached, + locked: record.locked, + lock_reason: record.lock_reason.take(), + prunable: record.prunable, + prune_reason: record.prune_reason.take(), + missing, + is_main: rows.is_empty(), + is_current, + clean: true, + changed_files: 0, + }); + *record = Record::default(); + } + + let mut rows = Vec::new(); + let mut record = Record::default(); + for field in output.split(|byte| *byte == 0) { + if field.is_empty() { + finish_record(&mut rows, &mut record, current_repo); + continue; + } + let value = String::from_utf8_lossy(field); + let (key, detail) = value + .split_once(' ') + .map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail))); + match key { + "worktree" => record.path = detail.map(str::to_string), + "HEAD" => record.head = detail.map(str::to_string), + "branch" => { + record.branch = detail.map(|branch| { + branch + .strip_prefix("refs/heads/") + .unwrap_or(branch) + .to_string() + }) + } + "bare" => record.bare = true, + "detached" => record.detached = true, + "locked" => { + record.locked = true; + record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty()); + } + "prunable" => { + record.prunable = true; + record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty()); + } + _ => {} + } + } + finish_record(&mut rows, &mut record, current_repo); + rows +} + +fn worktrees_for_repo(repo: &Path) -> Result, String> { + let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?; + let mut worktrees = parse_worktree_porcelain(&output, repo); + for worktree in &mut worktrees { + if worktree.missing { + worktree.clean = false; + continue; + } + if worktree.bare { + continue; + } + match run_git_at( + Path::new(&worktree.path), + ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], + "Could not inspect worktree status", + ) { + Ok(status) => { + worktree.changed_files = status + .split(|byte| *byte == 0) + .filter(|entry| entry.len() >= 3 && entry[2] == b' ') + .count() as u32; + worktree.clean = worktree.changed_files == 0; + } + Err(_) => { + worktree.clean = false; + } + } + } + Ok(worktrees) +} + +#[tauri::command] +pub fn list_worktrees(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn add_worktree( + path: String, + worktree_path: String, + branch: Option, + new_branch: Option, + start_point: Option, + detached: Option, + lock: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let destination = worktree_path.trim(); + if destination.is_empty() { + return Err("Choose a folder for the new worktree.".to_string()); + } + let branch = branch + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let new_branch = new_branch + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if branch.is_some() && new_branch.is_some() { + return Err("Choose either an existing branch or a new branch.".to_string()); + } + + let mut args = vec![OsString::from("worktree"), OsString::from("add")]; + if lock.unwrap_or(false) { + args.push(OsString::from("--lock")); + } + if detached.unwrap_or(false) { + args.push(OsString::from("--detach")); + } + + let target = if let Some(new_branch) = new_branch { + let new_branch = validate_new_branch_name(&repo, &new_branch)?; + args.push(OsString::from("-b")); + args.push(OsString::from(new_branch)); + start_point + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| verify_commit(&repo, value)) + .transpose()? + } else if let Some(branch) = branch { + Some(validate_existing_local_branch_name(&repo, &branch)?) + } else { + start_point + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| verify_commit(&repo, value)) + .transpose()? + }; + + args.push(OsString::from(destination)); + if let Some(target) = target { + args.push(OsString::from(target)); + } + run_git(&repo, args)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn remove_worktree( + path: String, + worktree_path: String, + force: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let worktrees = worktrees_for_repo(&repo)?; + let target = worktrees + .iter() + .find(|worktree| { + worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path)) + }) + .ok_or_else(|| "The selected worktree is no longer registered.".to_string())?; + if target.is_main { + return Err("The main worktree cannot be removed.".to_string()); + } + if target.is_current { + return Err("The currently open worktree cannot be removed.".to_string()); + } + if target.locked { + return Err("Unlock this worktree before removing it.".to_string()); + } + let mut args = vec![OsString::from("worktree"), OsString::from("remove")]; + if force.unwrap_or(false) { + args.push(OsString::from("--force")); + } + args.push(OsString::from(worktree_path)); + run_git(&repo, args)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn move_worktree( + path: String, + worktree_path: String, + destination: String, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let destination = destination.trim(); + if destination.is_empty() { + return Err("Choose a new location for the worktree.".to_string()); + } + run_git( + &repo, + [ + OsString::from("worktree"), + OsString::from("move"), + OsString::from(worktree_path), + OsString::from(destination), + ], + )?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn lock_worktree( + path: String, + worktree_path: String, + reason: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let mut args = vec![OsString::from("worktree"), OsString::from("lock")]; + if let Some(reason) = reason + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + args.push(OsString::from("--reason")); + args.push(OsString::from(reason)); + } + args.push(OsString::from(worktree_path)); + run_git(&repo, args)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn unlock_worktree(path: String, worktree_path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn prune_worktrees(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "prune"])?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn repair_worktree(path: String, worktree_path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?; + worktrees_for_repo(&repo) +} + #[tauri::command] pub fn create_tag( path: String, @@ -6687,6 +6991,76 @@ mod tests { assert!(result.lines[0].is_uncommitted); } + #[test] + fn parse_worktree_porcelain_preserves_flags_and_reasons() { + let current = Path::new("/repos/main"); + let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0"; + + let rows = parse_worktree_porcelain(output, current); + + assert_eq!(rows.len(), 2); + assert!(rows[0].is_main); + assert_eq!(rows[0].branch.as_deref(), Some("main")); + assert_eq!(rows[0].short_head.as_deref(), Some("12345678")); + assert!(rows[1].detached); + assert!(rows[1].locked); + assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive")); + assert!(rows[1].prunable); + } + + #[test] + fn worktree_add_list_and_remove_round_trip() { + let repo = init_temp_repo("worktree_round_trip"); + let destination = temp_dir("worktree_round_trip_destination"); + commit_initial_file(&repo.path); + run_git_test(&repo.path, ["branch", "feature"]); + + let rows = add_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + Some("feature".to_string()), + None, + None, + Some(false), + Some(false), + ) + .expect("worktree should be created"); + + let linked = rows + .iter() + .find(|row| row.branch.as_deref() == Some("feature")) + .expect("linked worktree should be listed"); + assert!(!linked.is_main); + assert!(linked.clean); + + let rows = lock_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + Some("test lock".to_string()), + ) + .expect("worktree should lock"); + let linked = rows + .iter() + .find(|row| row.branch.as_deref() == Some("feature")) + .expect("linked worktree should remain listed"); + assert!(linked.locked); + assert_eq!(linked.lock_reason.as_deref(), Some("test lock")); + + unlock_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + ) + .expect("worktree should unlock"); + + let rows = remove_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + Some(false), + ) + .expect("worktree should be removed"); + assert_eq!(rows.len(), 1); + } + #[test] fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() { let raw = r#"```json diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a41858c..8a5764e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,23 +6,24 @@ mod telemetry; use badge::set_sync_badge; use git::{ - 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, + SearchCancellationState, add_remote, add_worktree, 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_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, update_remote, + list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, + merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository, + open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag, + read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, + rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, + revert_commit, run_sequence_editor_if_requested, search_code_introductions, + set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, + stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, }; use tauri::Manager; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; @@ -135,6 +136,14 @@ async fn main() { create_branch, rename_branch, delete_branch, + list_worktrees, + add_worktree, + remove_worktree, + move_worktree, + lock_worktree, + unlock_worktree, + prune_worktrees, + repair_worktree, list_tags, create_tag, delete_tag, diff --git a/src/App.svelte b/src/App.svelte index d87a24e..6174b02 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -37,10 +37,12 @@ import StatusPanel from "./lib/components/StatusPanel.svelte"; import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte"; + import WorktreeDialog from "./lib/components/WorktreeDialog.svelte"; import { amendCommit, addRemote, + addWorktree, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -72,6 +74,7 @@ listRemotes, listStashes, listTags, + listWorktrees, listCommits, listFileHistory, listInteractiveRebaseCommits, @@ -83,10 +86,15 @@ openRepoInExplorer, openRepositoryFile, openRepositoryBundle, + lockWorktree, + moveWorktree, + pruneWorktrees, pull, push, pushTag, removeRemote, + removeWorktree, + repairWorktree, revertCommit, setBranchUpstream, updateRemote, @@ -115,6 +123,7 @@ stashPop, stashPush, undoLastCommit, + unlockWorktree, unstageFiles, } from "./lib/git"; @@ -142,6 +151,7 @@ GitStash, GitStatus, GitTag, + GitWorktree, LocalModelOption, PatchApplyAction, PreparedResolution, @@ -304,6 +314,11 @@ let renameBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null; let deleteBranchForce = false; + let worktreeDialogOpen = false; + let worktreeInitialBranch = ""; + let worktrees: GitWorktree[] = []; + let worktreesLoading = false; + let worktreeError = ""; let compareSelectOpen = false; let compareDialogOpen = false; let interactiveRebaseOpen = false; @@ -719,7 +734,7 @@ } async function autoRefreshTick() { - if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) return; + if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -1760,6 +1775,10 @@ globalSearchResults = []; deleteBranchTarget = null; deleteBranchForce = false; + worktreeDialogOpen = false; + worktreeInitialBranch = ""; + worktrees = []; + worktreeError = ""; globalSearchOpen = false; globalSearchError = ""; resolveDialogOpen = false; @@ -2369,6 +2388,146 @@ deleteBranchForce = false; } + async function openWorktreeDialog(branch = "") { + if (!activeRepoPath || isBusy) return; + worktreeInitialBranch = branch; + worktreeDialogOpen = true; + worktreeError = ""; + worktreesLoading = true; + try { + worktrees = await listWorktrees(activeRepoPath); + trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) }); + } catch (error) { + worktreeError = errorToMessage(error); + } finally { + worktreesLoading = false; + } + } + + function openBranchInWorktree(branch: GitBranchInfo) { + if (branch.remote) return; + void openWorktreeDialog(branch.name); + } + + async function refreshWorktrees() { + if (!activeRepoPath || worktreesLoading) return; + worktreesLoading = true; + worktreeError = ""; + try { + worktrees = await listWorktrees(activeRepoPath); + } catch (error) { + worktreeError = errorToMessage(error); + } finally { + worktreesLoading = false; + } + } + + async function runWorktreeOperation( + label: string, + task: () => Promise, + eventName: string, + ): Promise { + if (!activeRepoPath || isBusy) return false; + operation = label; + worktreeError = ""; + try { + worktrees = await task(); + await refreshBranchList(activeRepoPath); + trackEvent(eventName, { linked_worktrees: Math.max(0, worktrees.length - 1) }); + return true; + } catch (error) { + worktreeError = errorToMessage(error); + return false; + } finally { + operation = ""; + } + } + + async function createWorktree(request: { + worktreePath: string; + branch?: string; + newBranch?: string; + startPoint?: string; + detached?: boolean; + lock?: boolean; + }): Promise { + return runWorktreeOperation( + "Creating worktree", + () => addWorktree(activeRepoPath, request.worktreePath, request), + "worktree_created", + ); + } + + async function openWorktreeTab(worktree: GitWorktree) { + if (worktree.missing || worktree.bare || isBusy) return; + worktreeDialogOpen = false; + worktreeInitialBranch = ""; + await openRepo(worktree.path); + } + + async function removeSelectedWorktree(worktree: GitWorktree, force: boolean): Promise { + if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) { + worktreeError = "Close this worktree's repository tab before removing it."; + return false; + } + return runWorktreeOperation( + `Removing ${worktree.branch || "worktree"}`, + () => removeWorktree(activeRepoPath, worktree.path, force), + "worktree_removed", + ); + } + + async function moveSelectedWorktree(worktree: GitWorktree, destination: string) { + if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) { + worktreeError = "Close this worktree's repository tab before moving it."; + return; + } + await runWorktreeOperation( + `Moving ${worktree.branch || "worktree"}`, + () => moveWorktree(activeRepoPath, worktree.path, destination), + "worktree_moved", + ); + } + + function lockSelectedWorktree(worktree: GitWorktree, reason: string): Promise { + return runWorktreeOperation( + `Locking ${worktree.branch || "worktree"}`, + () => lockWorktree(activeRepoPath, worktree.path, reason), + "worktree_locked", + ); + } + + async function unlockSelectedWorktree(worktree: GitWorktree) { + await runWorktreeOperation( + `Unlocking ${worktree.branch || "worktree"}`, + () => unlockWorktree(activeRepoPath, worktree.path), + "worktree_unlocked", + ); + } + + async function pruneStaleWorktrees() { + await runWorktreeOperation( + "Pruning stale worktrees", + () => pruneWorktrees(activeRepoPath), + "worktrees_pruned", + ); + } + + async function repairSelectedWorktree(worktree: GitWorktree, location: string) { + await runWorktreeOperation( + `Repairing ${worktree.branch || "worktree"}`, + () => repairWorktree(activeRepoPath, location), + "worktree_repaired", + ); + } + + function closeWorktreeDialog() { + if (isBusy) return; + worktreeDialogOpen = false; + worktreeInitialBranch = ""; + worktreeError = ""; + } + function openNewBranchDialog(commit: GitCommit) { if (!activeRepoPath || isBusy) return; newBranchCommit = commit; @@ -3730,6 +3889,7 @@ else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); + else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog(); else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; @@ -4115,6 +4275,8 @@ onCreateTag={createNewTag} onDeleteTag={deleteLocalTag} onPushTag={pushLocalTag} + onManageWorktrees={() => { void openWorktreeDialog(); }} + onCreateWorktree={openBranchInWorktree} collapsed={branchPanelCollapsed} onToggleCollapsed={toggleBranchPanelCollapsed} /> @@ -4480,6 +4642,27 @@ /> {/if} +{#if worktreeDialogOpen} + +{/if} + {#if newBranchCommit} div { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; + padding: 11px 14px; + background: var(--app-dialog-bg); + } + .worktree-summary > div strong { color: var(--color-ink); font-family: var(--font-mono); font-size: 14px; } + .worktree-summary > div span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } + .worktree-summary > div.attention strong { color: #ffc07a; } + .worktree-summary > .btn-primary { align-self: center; margin: 0 14px; white-space: nowrap; } + .worktree-error { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-bottom: 1px solid rgba(255, 90, 103, 0.2); + color: #ffb8bf; + background: rgba(255, 90, 103, 0.08); + font-size: 11.5px; + font-weight: 650; + } + .worktree-content { + min-height: 0; + overflow: auto; + padding: 14px; + background: + radial-gradient(circle at 92% 0%, rgba(77, 182, 214, 0.055), transparent 28%), + var(--app-dialog-bg); + } + .worktree-create-card { + display: grid; + gap: 14px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid rgba(77, 182, 214, 0.26); + border-radius: 10px; + background: var(--color-surface-raised); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.035); + } + .worktree-create-card > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .worktree-create-card h3 { margin: 3px 0 0; color: var(--color-ink); font-size: 13px; font-weight: 700; } + .worktree-mode-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; + padding: 3px; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: rgba(0, 0, 0, 0.14); + } + .worktree-mode-tabs button { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + border-color: transparent; + color: var(--color-ink-faint); + background: transparent; + font-size: 10.5px; + font-weight: 750; + } + .worktree-mode-tabs button.active { + border-color: rgba(77, 182, 214, 0.27); + color: #b8e7f6; + background: rgba(77, 182, 214, 0.11); + box-shadow: 0 3px 12px rgba(0,0,0,0.14); + } + .worktree-create-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .worktree-create-fields label, + .worktree-lock-body label { display: grid; gap: 5px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; } + .worktree-create-fields input, + .worktree-create-fields select, + .worktree-lock-body input { width: 100%; min-width: 0; } + .worktree-create-fields .worktree-path-field { grid-column: 1 / -1; } + .worktree-path-field > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } + .worktree-create-card > footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; } + .worktree-check { display: flex; align-items: flex-start; gap: 8px; color: var(--color-ink-muted); font-size: 11px; cursor: pointer; } + .worktree-check input { width: auto; margin-top: 2px; } + .worktree-check span { display: grid; gap: 1px; } + .worktree-check strong { color: var(--color-ink); font-size: 11px; } + .worktree-check small { color: var(--color-ink-faint); font-size: 10px; font-weight: 500; } + .worktree-check.danger strong, + .worktree-check.danger small { color: #ffb8bf; } + .worktree-list { display: grid; gap: 8px; } + .worktree-card { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + min-width: 0; + border: 1px solid var(--color-border-subtle); + border-radius: 9px; + background: var(--color-surface-raised); + overflow: hidden; + } + .worktree-card.current { border-color: rgba(77, 182, 214, 0.34); box-shadow: inset 0 0 0 1px rgba(77, 182, 214, 0.06); } + .worktree-card.stale { border-color: rgba(255, 151, 61, 0.28); } + .worktree-rail { + position: relative; + display: grid; + place-items: start center; + padding-top: 18px; + background: rgba(0,0,0,0.1); + } + .worktree-rail::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: rgba(77, 182, 214, 0.22); + } + .worktree-rail span { + position: relative; + z-index: 1; + width: 9px; + height: 9px; + border: 2px solid #76cde7; + border-radius: 50%; + background: var(--color-surface-solid); + box-shadow: 0 0 0 3px rgba(77, 182, 214, 0.08); + } + .worktree-rail i { + position: absolute; + top: 31px; + left: 50%; + width: 7px; + height: 14px; + border-bottom: 1px solid rgba(77, 182, 214, 0.26); + border-left: 1px solid rgba(77, 182, 214, 0.26); + } + .worktree-card-main { display: grid; gap: 9px; min-width: 0; padding: 12px; } + .worktree-card-main > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } + .worktree-name { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--color-accent); } + .worktree-name > div { display: grid; gap: 1px; min-width: 0; } + .worktree-name strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } + .worktree-name span { color: var(--color-ink-faint); font-size: 10px; } + .worktree-badges { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 4px; } + .worktree-badges span { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border: 1px solid var(--color-border-subtle); + border-radius: 999px; + color: var(--color-ink-faint); + background: rgba(255,255,255,0.025); + font-size: 8.5px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .worktree-badges span.active { border-color: rgba(78, 202, 118, 0.25); color: #77d99a; background: rgba(78, 202, 118, 0.08); } + .worktree-badges span.locked { border-color: rgba(111, 140, 255, 0.26); color: #aebcff; background: rgba(111, 140, 255, 0.08); } + .worktree-badges span.danger { border-color: rgba(255, 151, 61, 0.3); color: #ffc07a; background: rgba(255, 151, 61, 0.08); } + .worktree-path { display: flex; align-items: center; gap: 5px; min-width: 0; color: var(--color-ink-faint); } + .worktree-path code { overflow: hidden; font-family: var(--font-mono); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + .worktree-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; color: var(--color-ink-faint); font-size: 9.5px; } + .worktree-meta span { display: inline-flex; align-items: center; gap: 4px; } + .worktree-meta span.dirty, + .worktree-meta span.danger { color: #ffc07a; } + .worktree-card-main > footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 2px; } + .worktree-card-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; } + .worktree-card-actions .danger { border-color: rgba(255, 90, 103, 0.2); color: #ff9aa4; background: rgba(255, 90, 103, 0.06); } + .worktree-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var(--color-border-subtle); + background: var(--app-dialog-chrome); + } + .worktree-dialog-footer > div { display: flex; align-items: center; gap: 6px; color: var(--color-ink-faint); font-size: 10px; } + .worktree-loading, + .worktree-empty { + display: grid; + place-items: center; + align-content: center; + gap: 7px; + min-height: 240px; + color: var(--color-ink-faint); + text-align: center; + font-size: 11px; + } + .worktree-empty strong { color: var(--color-ink); font-size: 13px; } + .worktree-confirm-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 13px; + padding: 17px 16px; + } + .worktree-confirm-body > div { display: grid; gap: 10px; min-width: 0; } + .worktree-confirm-body p { margin: 0; color: var(--color-ink-muted); font-size: 12px; line-height: 1.45; } + .worktree-confirm-body code { overflow: auto; padding: 8px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink); background: rgba(0,0,0,0.16); font-size: 10.5px; } + .worktree-lock-body { padding: 18px 16px; } + .worktree-lock-body label > span { display: flex; align-items: baseline; justify-content: space-between; } + .worktree-lock-body small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; } + @media (max-width: 700px) { + .worktree-summary { grid-template-columns: repeat(3, 1fr); } + .worktree-summary > div { display: grid; gap: 2px; } + .worktree-summary > .btn-primary { grid-column: 1 / -1; margin: 9px 14px; } + .worktree-create-fields { grid-template-columns: 1fr; } + .worktree-create-fields .worktree-path-field { grid-column: auto; } + .worktree-create-card > footer, + .worktree-card-main > footer { align-items: stretch; flex-direction: column; } + .worktree-card-actions { justify-content: flex-start; } + .worktree-dialog-footer > div { display: none; } + } .discard-target-list { display: grid; gap: 4px; diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 5731ab4..63655fa 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -1,5 +1,5 @@ + + + +{#if pendingRemoval} + +{/if} + +{#if pendingLock} + +{/if} diff --git a/src/lib/git.ts b/src/lib/git.ts index 7190702..530ce30 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -21,6 +21,7 @@ import type { GitStash, GitStatus, GitTag, + GitWorktree, LocalModelOption, PatchApplyAction, RepositoryBundle, @@ -116,6 +117,56 @@ export function deleteBranch(path: string, branch: string, force = false): Promi return invoke("delete_branch", { path, branch, force }); } +export function listWorktrees(path: string): Promise { + return invoke("list_worktrees", { path }); +} + +export function addWorktree( + path: string, + worktreePath: string, + options: { + branch?: string; + newBranch?: string; + startPoint?: string; + detached?: boolean; + lock?: boolean; + } = {}, +): Promise { + return invoke("add_worktree", { + path, + worktreePath, + branch: options.branch ?? null, + newBranch: options.newBranch ?? null, + startPoint: options.startPoint ?? null, + detached: options.detached ?? false, + lock: options.lock ?? false, + }); +} + +export function removeWorktree(path: string, worktreePath: string, force = false): Promise { + return invoke("remove_worktree", { path, worktreePath, force }); +} + +export function moveWorktree(path: string, worktreePath: string, destination: string): Promise { + return invoke("move_worktree", { path, worktreePath, destination }); +} + +export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise { + return invoke("lock_worktree", { path, worktreePath, reason: reason?.trim() || null }); +} + +export function unlockWorktree(path: string, worktreePath: string): Promise { + return invoke("unlock_worktree", { path, worktreePath }); +} + +export function pruneWorktrees(path: string): Promise { + return invoke("prune_worktrees", { path }); +} + +export function repairWorktree(path: string, worktreePath: string): Promise { + return invoke("repair_worktree", { path, worktreePath }); +} + export function listTags(path: string): Promise { return invoke("list_tags", { path }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 957ec31..efcd711 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -90,6 +90,24 @@ export interface GitBranch { remote: boolean; } +export interface GitWorktree { + path: string; + head: string | null; + short_head: string | null; + branch: string | null; + bare: boolean; + detached: boolean; + locked: boolean; + lock_reason: string | null; + prunable: boolean; + prune_reason: string | null; + missing: boolean; + is_main: boolean; + is_current: boolean; + clean: boolean; + changed_files: number; +} + export interface GitTag { name: string; hash: string;