From 356f65a8a2bf0205a1b8b39f1f160eb462d2226f Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 6 Feb 2026 15:28:19 -0800 Subject: [PATCH 1/8] feat: add main worktree as non-deletable branch in branches view When a project is added to the branches view, the main worktree is now automatically added as a branch. This allows users to switch branches on the main worktree without creating a separate worktree. Changes: - Add is_main_worktree field to Branch struct and database schema - Auto-create main worktree branch when creating a git project - Add get_current_branch() function to detect the current branch - Prevent deletion of main worktree branches in UI - Update TypeScript types to include isMainWorktree field Co-Authored-By: Claude Sonnet 4.5 --- staged/src-tauri/src/git/mod.rs | 4 +- staged/src-tauri/src/git/refs.rs | 15 ++++++++ staged/src-tauri/src/lib.rs | 17 +++++++++ staged/src-tauri/src/store.rs | 63 +++++++++++++++++++++++++++---- staged/src/lib/BranchCard.svelte | 16 ++++---- staged/src/lib/BranchHome.svelte | 5 +++ staged/src/lib/services/branch.ts | 2 + 7 files changed, 105 insertions(+), 17 deletions(-) diff --git a/staged/src-tauri/src/git/mod.rs b/staged/src-tauri/src/git/mod.rs index b9614be..1547292 100644 --- a/staged/src-tauri/src/git/mod.rs +++ b/staged/src-tauri/src/git/mod.rs @@ -18,8 +18,8 @@ pub use github::{ CreatePrResult, GitHubAuthStatus, GitHubSyncResult, Issue, PullRequest, PullRequestInfo, }; pub use refs::{ - detect_default_branch, get_repo_root, list_branches, list_refs, merge_base, resolve_ref, - BranchRef, + detect_default_branch, get_current_branch, get_repo_root, list_branches, list_refs, merge_base, + resolve_ref, BranchRef, }; pub use types::*; pub use worktree::{ diff --git a/staged/src-tauri/src/git/refs.rs b/staged/src-tauri/src/git/refs.rs index 5d413dc..1566fed 100644 --- a/staged/src-tauri/src/git/refs.rs +++ b/staged/src-tauri/src/git/refs.rs @@ -119,6 +119,21 @@ pub fn resolve_ref(repo: &Path, reference: &str) -> Result { Ok(output.trim().to_string()) } +/// Get the current branch name. +/// Returns an error if in detached HEAD state. +pub fn get_current_branch(repo: &Path) -> Result { + let output = cli::run(repo, &["branch", "--show-current"])?; + let branch_name = output.trim(); + + if branch_name.is_empty() { + return Err(GitError::CommandFailed( + "Not on a branch (detached HEAD)".to_string(), + )); + } + + Ok(branch_name.to_string()) +} + /// Detect the default branch for this repository. /// Checks for common default branch names in order of preference. /// Returns the remote-tracking branch (e.g., "origin/main") if available, diff --git a/staged/src-tauri/src/lib.rs b/staged/src-tauri/src/lib.rs index c23c852..822d394 100644 --- a/staged/src-tauri/src/lib.rs +++ b/staged/src-tauri/src/lib.rs @@ -2871,6 +2871,23 @@ fn create_git_project( state .create_git_project(&project) .map_err(|e| e.to_string())?; + + // Auto-create a branch for the main worktree + let repo = Path::new(&repo_path); + + // Get the current branch name + let current_branch = git::get_current_branch(repo).map_err(|e| e.to_string())?; + + // Detect the default branch for base + let base_branch = git::detect_default_branch(repo).map_err(|e| e.to_string())?; + + // Create the main worktree branch + let main_branch = + Branch::new_main_worktree(&project.id, &repo_path, ¤t_branch, &base_branch); + + // Save it to the database (ignore if it already exists) + let _ = state.create_branch(&main_branch); + Ok(project) } diff --git a/staged/src-tauri/src/store.rs b/staged/src-tauri/src/store.rs index 53b88b3..ca5caa8 100644 --- a/staged/src-tauri/src/store.rs +++ b/staged/src-tauri/src/store.rs @@ -447,6 +447,8 @@ pub struct Branch { pub base_branch: String, /// PR number if this branch was created from a GitHub PR pub pr_number: Option, + /// Whether this is the main worktree (cannot be deleted) + pub is_main_worktree: bool, pub created_at: i64, pub updated_at: i64, } @@ -468,6 +470,7 @@ impl Branch { worktree_path: worktree_path.into(), base_branch: base_branch.into(), pr_number: None, + is_main_worktree: false, created_at: now, updated_at: now, } @@ -491,6 +494,30 @@ impl Branch { worktree_path: worktree_path.into(), base_branch: base_branch.into(), pr_number: Some(pr_number), + is_main_worktree: false, + created_at: now, + updated_at: now, + } + } + + /// Create a new branch for the main worktree. + pub fn new_main_worktree( + project_id: impl Into, + repo_path: impl Into, + branch_name: impl Into, + base_branch: impl Into, + ) -> Self { + let repo_path_str = repo_path.into(); + let now = now_timestamp(); + Self { + id: uuid::Uuid::new_v4().to_string(), + project_id: project_id.into(), + repo_path: repo_path_str.clone(), + branch_name: branch_name.into(), + worktree_path: repo_path_str, // Main worktree uses the repo path itself + base_branch: base_branch.into(), + pr_number: None, + is_main_worktree: true, created_at: now, updated_at: now, } @@ -500,6 +527,7 @@ impl Branch { fn from_row(row: &rusqlite::Row) -> rusqlite::Result { // pr_number is stored as i64 in SQLite, convert to u64 let pr_number: Option = row.get(6)?; + let is_main_worktree: i32 = row.get(7).unwrap_or(0); Ok(Self { id: row.get(0)?, project_id: row.get(1)?, @@ -508,8 +536,9 @@ impl Branch { worktree_path: row.get(4)?, base_branch: row.get(5)?, pr_number: pr_number.map(|n| n as u64), - created_at: row.get(7)?, - updated_at: row.get(8)?, + is_main_worktree: is_main_worktree != 0, + created_at: row.get(8)?, + updated_at: row.get(9)?, }) } } @@ -1080,6 +1109,22 @@ impl Store { } } + // Check if is_main_worktree column exists on branches, add if not + let has_is_main_worktree: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM pragma_table_info('branches') WHERE name = 'is_main_worktree'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + + if !has_is_main_worktree { + conn.execute( + "ALTER TABLE branches ADD COLUMN is_main_worktree INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + Ok(()) } @@ -1645,9 +1690,10 @@ impl Store { let conn = self.conn.lock().unwrap(); // Convert pr_number from u64 to i64 for SQLite storage let pr_number_i64: Option = branch.pr_number.map(|n| n as i64); + let is_main_worktree_i32: i32 = if branch.is_main_worktree { 1 } else { 0 }; conn.execute( - "INSERT INTO branches (id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO branches (id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, is_main_worktree, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ &branch.id, &branch.project_id, @@ -1656,6 +1702,7 @@ impl Store { &branch.worktree_path, &branch.base_branch, &pr_number_i64, + is_main_worktree_i32, branch.created_at, branch.updated_at, ], @@ -1667,7 +1714,7 @@ impl Store { pub fn get_branch(&self, id: &str) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, created_at, updated_at + "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, is_main_worktree, created_at, updated_at FROM branches WHERE id = ?1", params![id], Branch::from_row, @@ -1680,7 +1727,7 @@ impl Store { pub fn list_branches(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, created_at, updated_at + "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, is_main_worktree, created_at, updated_at FROM branches ORDER BY created_at ASC", )?; let branches = stmt @@ -1693,7 +1740,7 @@ impl Store { pub fn list_branches_for_repo(&self, repo_path: &str) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, created_at, updated_at + "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, is_main_worktree, created_at, updated_at FROM branches WHERE repo_path = ?1 ORDER BY updated_at DESC", )?; let branches = stmt @@ -1706,7 +1753,7 @@ impl Store { pub fn list_branches_for_project(&self, project_id: &str) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, created_at, updated_at + "SELECT id, project_id, repo_path, branch_name, worktree_path, base_branch, pr_number, is_main_worktree, created_at, updated_at FROM branches WHERE project_id = ?1 ORDER BY updated_at DESC", )?; let branches = stmt diff --git a/staged/src/lib/BranchCard.svelte b/staged/src/lib/BranchCard.svelte index 444d407..58bb59c 100644 --- a/staged/src/lib/BranchCard.svelte +++ b/staged/src/lib/BranchCard.svelte @@ -993,14 +993,16 @@ {/each} {/if} - - {#if projectActions.length > 0} - + + {#if !branch.isMainWorktree} + {#if projectActions.length > 0} + + {/if} + {/if} - {/if} diff --git a/staged/src/lib/BranchHome.svelte b/staged/src/lib/BranchHome.svelte index ba7b450..ba1e4ea 100644 --- a/staged/src/lib/BranchHome.svelte +++ b/staged/src/lib/BranchHome.svelte @@ -272,6 +272,11 @@ const branch = branches.find((b) => b.id === branchId); if (!branch) return; + // Cannot delete main worktree + if (branch.isMainWorktree) { + return; + } + // Show confirmation dialog branchToDelete = branch; } diff --git a/staged/src/lib/services/branch.ts b/staged/src/lib/services/branch.ts index 6906951..3a8d167 100644 --- a/staged/src/lib/services/branch.ts +++ b/staged/src/lib/services/branch.ts @@ -85,6 +85,8 @@ export interface Branch { baseBranch: string; /** The PR number this branch was created from (if any) */ prNumber: number | null; + /** Whether this is the main worktree (cannot be deleted) */ + isMainWorktree: boolean; createdAt: number; updatedAt: number; } From edfd0346f81a9e5e135c653372da5db6ab036262 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 6 Feb 2026 15:41:10 -0800 Subject: [PATCH 2/8] fix: reload branches after project creation to show main worktree When a new project is added, the backend automatically creates a main worktree branch entry. However, the UI wasn't reloading branches after project creation, so the main worktree branch never appeared in the UI. This fix makes handleNewProjectCreated async and calls loadData() after adding the project to ensure the auto-created main worktree branch is fetched and displayed. Co-Authored-By: Claude Sonnet 4.5 --- staged/src/lib/BranchHome.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/staged/src/lib/BranchHome.svelte b/staged/src/lib/BranchHome.svelte index ba1e4ea..9b3a211 100644 --- a/staged/src/lib/BranchHome.svelte +++ b/staged/src/lib/BranchHome.svelte @@ -329,9 +329,11 @@ ); } - function handleNewProjectCreated(project: GitProject) { + async function handleNewProjectCreated(project: GitProject) { projects = [...projects, project]; showNewProjectModal = false; + // Reload branches to include the auto-created main worktree branch + await loadData(); } function handleProjectDetecting(projectId: string, isDetecting: boolean) { From a8725e6f05710e9f8d6a5523030421196dc2cb1e Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 6 Feb 2026 15:56:17 -0800 Subject: [PATCH 3/8] fix: ensure main worktree branch exists for all projects Projects created before the main worktree feature was added don't have a main worktree branch entry in the database. This fix ensures that when listing branches for a project, the main worktree branch is auto-created if it doesn't exist. This provides backward compatibility and ensures consistent behavior across all projects, regardless of when they were created. Co-Authored-By: Claude Sonnet 4.5 --- staged/src-tauri/src/lib.rs | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/staged/src-tauri/src/lib.rs b/staged/src-tauri/src/lib.rs index 822d394..dc6e1cc 100644 --- a/staged/src-tauri/src/lib.rs +++ b/staged/src-tauri/src/lib.rs @@ -1861,6 +1861,10 @@ fn list_branches_for_project( state: State<'_, Arc>, project_id: String, ) -> Result, String> { + // Ensure main worktree branch exists for this project + // This handles projects created before the main worktree feature was added + ensure_main_worktree_exists(&state, &project_id)?; + state .list_branches_for_project(&project_id) .map_err(|e| e.to_string()) @@ -2835,6 +2839,50 @@ fn extract_text_from_assistant_content(content: &str) -> String { use store::GitProject; +/// Ensures that a main worktree branch exists for the given project. +/// This is used to ensure backward compatibility for projects created before +/// the main worktree feature was added. +fn ensure_main_worktree_exists( + state: &State<'_, Arc>, + project_id: &str, +) -> Result<(), String> { + // Get the project + let project = state + .get_git_project(project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", project_id))?; + + // Check if a main worktree branch already exists for this project + let branches = state + .list_branches_for_project(project_id) + .map_err(|e| e.to_string())?; + + let has_main_worktree = branches.iter().any(|b| b.is_main_worktree); + + if !has_main_worktree { + // Create the main worktree branch + let repo = Path::new(&project.repo_path); + + // Get the current branch name (ignore errors, e.g., detached HEAD) + if let Ok(current_branch) = git::get_current_branch(repo) { + // Detect the default branch for base (ignore errors) + if let Ok(base_branch) = git::detect_default_branch(repo) { + let main_branch = Branch::new_main_worktree( + &project.id, + &project.repo_path, + ¤t_branch, + &base_branch, + ); + + // Save it to the database (ignore if it already exists) + let _ = state.create_branch(&main_branch); + } + } + } + + Ok(()) +} + /// Create a new git project. /// If a project already exists for the repo_path, returns an error. #[tauri::command(rename_all = "camelCase")] From e78c0b616e85fb5be6684b66d35b6050635d1da9 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 6 Feb 2026 16:28:00 -0800 Subject: [PATCH 4/8] feat: add project deletion from settings modal Add ability to delete projects from the settings modal without requiring the project to be empty. The delete button is now available in the project settings, making it easier to remove projects while preserving the main worktree branch and git repository. Changes: - Add "Delete Project" button to ProjectSettingsModal footer - Add confirmation dialog for project deletion - Wire up onDeleted callback to remove project from BranchHome list - Clarify in dialog that main worktree and repository won't be deleted Co-Authored-By: Claude Sonnet 4.5 --- staged/src/lib/BranchHome.svelte | 5 ++ staged/src/lib/ProjectSettingsModal.svelte | 63 +++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/staged/src/lib/BranchHome.svelte b/staged/src/lib/BranchHome.svelte index 9b3a211..b32e089 100644 --- a/staged/src/lib/BranchHome.svelte +++ b/staged/src/lib/BranchHome.svelte @@ -584,6 +584,11 @@ onUpdated={(updatedProject) => { projects = projects.map((p) => (p.id === updatedProject.id ? updatedProject : p)); }} + onDeleted={() => { + if (projectToEdit) { + projects = projects.filter((p) => p.id !== projectToEdit.id); + } + }} /> {/if} diff --git a/staged/src/lib/ProjectSettingsModal.svelte b/staged/src/lib/ProjectSettingsModal.svelte index 75a7b2d..1026191 100644 --- a/staged/src/lib/ProjectSettingsModal.svelte +++ b/staged/src/lib/ProjectSettingsModal.svelte @@ -21,14 +21,16 @@ } from 'lucide-svelte'; import type { GitProject, ProjectAction, ActionType, SuggestedAction } from './services/branch'; import * as branchService from './services/branch'; + import ConfirmDialog from './ConfirmDialog.svelte'; interface Props { project: GitProject; onClose: () => void; onUpdated?: (project: GitProject) => void; + onDeleted?: () => void; } - let { project, onClose, onUpdated }: Props = $props(); + let { project, onClose, onUpdated, onDeleted }: Props = $props(); // Actions state let actions = $state([]); @@ -41,6 +43,7 @@ actionType: 'run' as ActionType, autoCommit: false, }); + let showDeleteConfirm = $state(false); // Load actions on mount onMount(() => { @@ -217,6 +220,17 @@ onClose(); } } + + async function confirmDeleteProject() { + try { + await branchService.deleteGitProject(project.id); + showDeleteConfirm = false; + onDeleted?.(); + onClose(); + } catch (e) { + console.error('Failed to delete project:', e); + } + } @@ -322,6 +336,13 @@ {/if} + +
+ +
@@ -397,6 +418,18 @@ {/if} + + + {#if showDeleteConfirm} + (showDeleteConfirm = false)} + /> + {/if} diff --git a/staged/src/lib/NewBranchModal.svelte b/staged/src/lib/NewBranchModal.svelte index 9ad8ae6..1e92d1f 100644 --- a/staged/src/lib/NewBranchModal.svelte +++ b/staged/src/lib/NewBranchModal.svelte @@ -573,7 +573,7 @@ {:else if step === 'issue'} Select Issue {:else} - New Branch + New Worktree {/if} @@ -330,6 +339,10 @@ color: var(--status-renamed); } + :global(.remote-icon) { + color: var(--text-muted); + } + .branch-item-name { flex: 1; overflow: hidden;