diff --git a/staged/src-tauri/src/git/mod.rs b/staged/src-tauri/src/git/mod.rs index b9614be..f6916aa 100644 --- a/staged/src-tauri/src/git/mod.rs +++ b/staged/src-tauri/src/git/mod.rs @@ -18,12 +18,13 @@ 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::{ branch_exists, create_worktree, create_worktree_for_existing_branch, create_worktree_from_pr, get_commits_since_base, get_head_sha, get_parent_commit, list_worktrees, remove_worktree, - reset_to_commit, update_branch_from_pr, worktree_path_for, CommitInfo, UpdateFromPrResult, + reset_to_commit, switch_branch, update_branch_from_pr, worktree_path_for, CommitInfo, + UpdateFromPrResult, }; 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/git/worktree.rs b/staged/src-tauri/src/git/worktree.rs index c2c68a6..0a477c4 100644 --- a/staged/src-tauri/src/git/worktree.rs +++ b/staged/src-tauri/src/git/worktree.rs @@ -465,6 +465,17 @@ pub fn update_branch_from_pr( }) } +/// Switch a worktree to a different branch. +/// +/// This checks out the specified branch in the worktree. +/// The branch must already exist (locally or as a remote tracking branch). +/// +/// Returns an error if the checkout fails (e.g., uncommitted changes, branch doesn't exist). +pub fn switch_branch(worktree: &Path, branch_name: &str) -> Result<(), GitError> { + cli::run(worktree, &["checkout", branch_name])?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/staged/src-tauri/src/lib.rs b/staged/src-tauri/src/lib.rs index c23c852..7a652c1 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()) @@ -1878,6 +1882,33 @@ fn update_branch_base( .map_err(|e| e.to_string()) } +#[tauri::command(rename_all = "camelCase")] +async fn switch_worktree_branch( + state: State<'_, Arc>, + branch_id: String, + new_branch_name: String, +) -> Result<(), String> { + let store = state.inner().clone(); + + tauri::async_runtime::spawn_blocking(move || { + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch '{branch_id}' not found"))?; + + let worktree = Path::new(&branch.worktree_path); + git::switch_branch(worktree, &new_branch_name).map_err(|e| e.to_string())?; + + store + .update_branch_name(&branch_id, &new_branch_name) + .map_err(|e| e.to_string())?; + + Ok(()) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + /// Delete a branch and its worktree. /// This runs asynchronously to avoid blocking the UI during slow git operations. #[tauri::command(rename_all = "camelCase")] @@ -2835,6 +2866,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")] @@ -2871,6 +2946,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) } @@ -3674,6 +3766,7 @@ pub fn run() { detect_default_branch, delete_branch, update_branch_base, + switch_worktree_branch, get_branch_commits, list_branch_sessions, get_session_for_commit, diff --git a/staged/src-tauri/src/store.rs b/staged/src-tauri/src/store.rs index 53b88b3..c572ac3 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 @@ -1744,6 +1791,16 @@ impl Store { Ok(()) } + pub fn update_branch_name(&self, id: &str, branch_name: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = now_timestamp(); + conn.execute( + "UPDATE branches SET branch_name = ?1, updated_at = ?2 WHERE id = ?3", + params![branch_name, now, id], + )?; + Ok(()) + } + /// Update a branch's PR number pub fn update_branch_pr_number(&self, id: &str, pr_number: Option) -> Result<()> { let conn = self.conn.lock().unwrap(); diff --git a/staged/src/lib/BranchCard.svelte b/staged/src/lib/BranchCard.svelte index 444d407..a891e4e 100644 --- a/staged/src/lib/BranchCard.svelte +++ b/staged/src/lib/BranchCard.svelte @@ -53,6 +53,7 @@ import NewNoteModal from './NewNoteModal.svelte'; import NewReviewModal from './NewReviewModal.svelte'; import BaseBranchPickerModal from './BaseBranchPickerModal.svelte'; + import BranchSwitcherModal from './BranchSwitcherModal.svelte'; import CreatePrModal from './CreatePrModal.svelte'; import ConfirmDialog from './ConfirmDialog.svelte'; import { openUrl } from './services/window'; @@ -183,6 +184,9 @@ // Base branch picker modal state let showBaseBranchPicker = $state(false); + // Branch switcher modal state + let showBranchSwitcher = $state(false); + // Create PR modal state let showCreatePrModal = $state(false); @@ -736,6 +740,13 @@ await loadData(); } + async function handleBranchSwitched(newBranchName: string) { + showBranchSwitcher = false; + const updatedBranch = { ...branch, branchName: newBranchName }; + onBranchUpdated?.(updatedBranch); + await loadData(); + } + // Format base branch for display (strip origin/ prefix if present) function formatBaseBranch(baseBranch: string): string { return baseBranch.replace(/^origin\//, ''); @@ -847,7 +858,14 @@
- {branch.branchName} + {/if} -
{/if}
@@ -1445,6 +1465,15 @@ /> {/if} + +{#if showBranchSwitcher} + (showBranchSwitcher = false)} + onSelected={handleBranchSwitched} + /> +{/if} + {#if showCreatePrModal} + + + + 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} + @@ -397,6 +418,18 @@ {/if} + + + {#if showDeleteConfirm} + (showDeleteConfirm = false)} + /> + {/if}