Skip to content
7 changes: 4 additions & 3 deletions staged/src-tauri/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
15 changes: 15 additions & 0 deletions staged/src-tauri/src/git/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@ pub fn resolve_ref(repo: &Path, reference: &str) -> Result<String, GitError> {
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<String, GitError> {
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,
Expand Down
11 changes: 11 additions & 0 deletions staged/src-tauri/src/git/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
93 changes: 93 additions & 0 deletions staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,10 @@ fn list_branches_for_project(
state: State<'_, Arc<Store>>,
project_id: String,
) -> Result<Vec<Branch>, 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())
Expand All @@ -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<Store>>,
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")]
Expand Down Expand Up @@ -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<Store>>,
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,
&current_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")]
Expand Down Expand Up @@ -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, &current_branch, &base_branch);

// Save it to the database (ignore if it already exists)
let _ = state.create_branch(&main_branch);

Ok(project)
}

Expand Down Expand Up @@ -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,
Expand Down
73 changes: 65 additions & 8 deletions staged/src-tauri/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
/// Whether this is the main worktree (cannot be deleted)
pub is_main_worktree: bool,
pub created_at: i64,
pub updated_at: i64,
}
Expand All @@ -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,
}
Expand All @@ -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<String>,
repo_path: impl Into<String>,
branch_name: impl Into<String>,
base_branch: impl Into<String>,
) -> 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,
}
Expand All @@ -500,6 +527,7 @@ impl Branch {
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
// pr_number is stored as i64 in SQLite, convert to u64
let pr_number: Option<i64> = 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)?,
Expand All @@ -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)?,
})
}
}
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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<i64> = 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,
Expand All @@ -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,
],
Expand All @@ -1667,7 +1714,7 @@ impl Store {
pub fn get_branch(&self, id: &str) -> Result<Option<Branch>> {
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,
Expand All @@ -1680,7 +1727,7 @@ impl Store {
pub fn list_branches(&self) -> Result<Vec<Branch>> {
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
Expand All @@ -1693,7 +1740,7 @@ impl Store {
pub fn list_branches_for_repo(&self, repo_path: &str) -> Result<Vec<Branch>> {
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
Expand All @@ -1706,7 +1753,7 @@ impl Store {
pub fn list_branches_for_project(&self, project_id: &str) -> Result<Vec<Branch>> {
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
Expand Down Expand Up @@ -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<u64>) -> Result<()> {
let conn = self.conn.lock().unwrap();
Expand Down
Loading