Skip to content

Commit f739e7c

Browse files
Kvadratniclaude
andcommitted
fix: import existing worktrees including main worktree on project add
- Fix worktree import for existing projects (duplicate check was skipping import) - Import the main worktree alongside linked worktrees - Add isMainWorktree flag to branch data, derived from path comparison - Block deletion of main worktree branches in backend - Hide delete menu on main worktree branch cards in UI - Show "main worktree" badge to distinguish from linked worktrees - Canonicalize paths for reliable comparison on macOS - Fetch branches from backend after project creation for immediate UI update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 44b3701 commit f739e7c

5 files changed

Lines changed: 130 additions & 65 deletions

File tree

staged/src-tauri/src/lib.rs

Lines changed: 100 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ pub struct BranchWithWorkdir {
5656
pub base_branch: String,
5757
pub pr_number: Option<u64>,
5858
pub worktree_path: Option<String>,
59+
pub is_main_worktree: bool,
5960
pub created_at: i64,
6061
pub updated_at: i64,
6162
}
@@ -402,74 +403,72 @@ fn list_projects(
402403
}
403404

404405
/// Import existing worktrees for a project.
405-
///
406+
///
406407
/// Scans the repository for existing worktrees and creates Branch + Workdir
407408
/// records for each one. Skips the main worktree (the repo itself).
408-
///
409+
///
409410
/// Returns the number of worktrees imported.
410411
fn import_existing_worktrees(
411412
store: &Arc<Store>,
412413
project: &store::Project,
413414
) -> Result<usize, String> {
414415
let repo_path = Path::new(&project.repo_path);
415-
416-
// Get the default branch to use as base for imported branches
416+
417417
let default_branch = git::detect_default_branch(repo_path)
418-
.map_err(|e| format!("Failed to detect default branch: {}", e))?;
419-
420-
// List all worktrees in the repository
421-
let worktrees = git::list_worktrees(repo_path)
422-
.map_err(|e| format!("Failed to list worktrees: {}", e))?;
423-
418+
.map_err(|e| format!("Failed to detect default branch: {e}"))?;
419+
420+
let worktrees =
421+
git::list_worktrees(repo_path).map_err(|e| format!("Failed to list worktrees: {e}"))?;
422+
424423
let mut imported_count = 0;
425-
424+
426425
for (worktree_path, branch_name) in worktrees {
427-
// Skip the main worktree (the repo itself)
428-
if worktree_path == repo_path {
429-
continue;
430-
}
431-
432-
// Skip worktrees without a branch (detached HEAD)
433426
let branch_name = match branch_name {
434427
Some(name) => name,
435428
None => {
436-
log::debug!("Skipping worktree at {} (detached HEAD)", worktree_path.display());
429+
log::debug!(
430+
"Skipping worktree at {} (detached HEAD)",
431+
worktree_path.display()
432+
);
437433
continue;
438434
}
439435
};
440-
441-
// Check if we already have a branch with this name
436+
442437
let existing_branches = store
443438
.list_branches_for_project(&project.id)
444439
.map_err(|e| e.to_string())?;
445-
446-
if existing_branches.iter().any(|b| b.branch_name == branch_name) {
447-
log::debug!("Branch '{}' already exists, skipping import", branch_name);
440+
441+
if existing_branches
442+
.iter()
443+
.any(|b| b.branch_name == branch_name)
444+
{
445+
log::debug!("Branch '{branch_name}' already exists, skipping import");
448446
continue;
449447
}
450-
451-
// Create a Branch record
448+
452449
let branch = store::Branch::new(&project.id, &branch_name, &default_branch);
453-
store.create_branch(&branch).map_err(|e| {
454-
format!("Failed to create branch '{}': {}", branch_name, e)
455-
})?;
456-
457-
// Create a Workdir record linked to this branch
450+
store
451+
.create_branch(&branch)
452+
.map_err(|e| format!("Failed to create branch '{branch_name}': {e}"))?;
453+
458454
let worktree_str = worktree_path
459455
.to_str()
460456
.ok_or_else(|| format!("Invalid worktree path: {}", worktree_path.display()))?;
461-
462-
let workdir = store::Workdir::new(&project.id, worktree_str)
463-
.with_branch(&branch.id);
464-
465-
store.create_workdir(&workdir).map_err(|e| {
466-
format!("Failed to create workdir for '{}': {}", branch_name, e)
467-
})?;
468-
469-
log::info!("Imported existing worktree: {} -> {}", branch_name, worktree_path.display());
457+
458+
let workdir = store::Workdir::new(&project.id, worktree_str).with_branch(&branch.id);
459+
460+
store
461+
.create_workdir(&workdir)
462+
.map_err(|e| format!("Failed to create workdir for '{branch_name}': {e}"))?;
463+
464+
log::info!(
465+
"Imported worktree: {} -> {}",
466+
branch_name,
467+
worktree_path.display()
468+
);
470469
imported_count += 1;
471470
}
472-
471+
473472
Ok(imported_count)
474473
}
475474

@@ -484,14 +483,27 @@ fn create_project(
484483
// Validate that the path is a git repo
485484
let path = Path::new(&repo_path);
486485
if !path.join(".git").exists() && !path.is_dir() {
487-
return Err(format!("Not a git repository: {}", repo_path));
486+
return Err(format!("Not a git repository: {repo_path}"));
488487
}
489488

490-
// Check for duplicate
489+
// Check for duplicate — still import worktrees even for existing projects,
490+
// since they may have been added before this feature existed.
491491
if let Some(existing) = store
492492
.get_project_by_repo(&repo_path)
493493
.map_err(|e| e.to_string())?
494494
{
495+
match import_existing_worktrees(&store, &existing) {
496+
Ok(count) if count > 0 => {
497+
log::info!(
498+
"Imported {count} worktree(s) for existing project '{}'",
499+
existing.repo_path
500+
);
501+
}
502+
Err(e) => {
503+
log::warn!("Failed to import worktrees for existing project: {e}");
504+
}
505+
_ => {}
506+
}
495507
return Ok(existing);
496508
}
497509

@@ -500,20 +512,23 @@ fn create_project(
500512
project = project.with_subpath(sub);
501513
}
502514
store.create_project(&project).map_err(|e| e.to_string())?;
503-
515+
504516
// Import any existing worktrees for this project
505517
match import_existing_worktrees(&store, &project) {
506518
Ok(count) => {
507519
if count > 0 {
508-
log::info!("Imported {} existing worktree(s) for project '{}'", count, project.repo_path);
520+
log::info!(
521+
"Imported {count} existing worktree(s) for project '{}'",
522+
project.repo_path
523+
);
509524
}
510525
}
511526
Err(e) => {
512527
// Log the error but don't fail project creation
513-
log::warn!("Failed to import existing worktrees: {}", e);
528+
log::warn!("Failed to import existing worktrees: {e}");
514529
}
515530
}
516-
531+
517532
Ok(project)
518533
}
519534

@@ -537,6 +552,15 @@ fn list_branches_for_project(
537552
project_id: String,
538553
) -> Result<Vec<BranchWithWorkdir>, String> {
539554
let store = get_store(&store)?;
555+
let project = store
556+
.get_project(&project_id)
557+
.map_err(|e| e.to_string())?
558+
.ok_or_else(|| format!("Project not found: {project_id}"))?;
559+
560+
let canonical_repo = Path::new(&project.repo_path)
561+
.canonicalize()
562+
.unwrap_or_else(|_| PathBuf::from(&project.repo_path));
563+
540564
let branches = store
541565
.list_branches_for_project(&project_id)
542566
.map_err(|e| e.to_string())?;
@@ -547,13 +571,21 @@ fn list_branches_for_project(
547571
.get_workdir_for_branch(&branch.id)
548572
.map_err(|e| e.to_string())?;
549573

574+
let is_main = workdir.as_ref().is_some_and(|w| {
575+
Path::new(&w.path)
576+
.canonicalize()
577+
.unwrap_or_else(|_| PathBuf::from(&w.path))
578+
== canonical_repo
579+
});
580+
550581
result.push(BranchWithWorkdir {
551582
id: branch.id,
552583
project_id: branch.project_id,
553584
branch_name: branch.branch_name,
554585
base_branch: branch.base_branch,
555586
pr_number: branch.pr_number,
556587
worktree_path: workdir.map(|w| w.path),
588+
is_main_worktree: is_main,
557589
created_at: branch.created_at,
558590
updated_at: branch.updated_at,
559591
});
@@ -574,7 +606,7 @@ fn create_branch(
574606
let project = store
575607
.get_project(&project_id)
576608
.map_err(|e| e.to_string())?
577-
.ok_or_else(|| format!("Project not found: {}", project_id))?;
609+
.ok_or_else(|| format!("Project not found: {project_id}"))?;
578610

579611
let repo_path = Path::new(&project.repo_path);
580612

@@ -608,6 +640,7 @@ fn create_branch(
608640
base_branch: branch.base_branch,
609641
pr_number: branch.pr_number,
610642
worktree_path: Some(worktree_str),
643+
is_main_worktree: false,
611644
created_at: branch.created_at,
612645
updated_at: branch.updated_at,
613646
})
@@ -624,19 +657,31 @@ fn delete_branch(
624657
let branch = store
625658
.get_branch(&branch_id)
626659
.map_err(|e| e.to_string())?
627-
.ok_or_else(|| format!("Branch not found: {}", branch_id))?;
660+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
628661

629662
// Get the project for the repo path
630663
let project = store
631664
.get_project(&branch.project_id)
632665
.map_err(|e| e.to_string())?
633666
.ok_or_else(|| format!("Project not found: {}", branch.project_id))?;
634667

635-
// Get the workdir (if any) so we can remove the worktree
636668
let workdir = store
637669
.get_workdir_for_branch(&branch_id)
638670
.map_err(|e| e.to_string())?;
639671

672+
// Prevent deletion of the main worktree (the repo checkout itself)
673+
if let Some(ref wd) = workdir {
674+
let canonical_repo = Path::new(&project.repo_path)
675+
.canonicalize()
676+
.unwrap_or_else(|_| PathBuf::from(&project.repo_path));
677+
let canonical_wd = Path::new(&wd.path)
678+
.canonicalize()
679+
.unwrap_or_else(|_| PathBuf::from(&wd.path));
680+
if canonical_wd == canonical_repo {
681+
return Err("Cannot delete the main worktree".to_string());
682+
}
683+
}
684+
640685
if let Some(ref wd) = workdir {
641686
let repo_path = Path::new(&project.repo_path);
642687
let worktree_path = Path::new(&wd.path);
@@ -664,7 +709,7 @@ fn delete_note(
664709
let note = store
665710
.get_note(&note_id)
666711
.map_err(|e| e.to_string())?
667-
.ok_or_else(|| format!("Note not found: {}", note_id))?;
712+
.ok_or_else(|| format!("Note not found: {note_id}"))?;
668713

669714
store.delete_note(&note_id).map_err(|e| e.to_string())?;
670715

@@ -694,7 +739,7 @@ fn delete_commit(
694739
let workdir = store
695740
.get_workdir_for_branch(&branch_id)
696741
.map_err(|e| e.to_string())?
697-
.ok_or_else(|| format!("No worktree for branch: {}", branch_id))?;
742+
.ok_or_else(|| format!("No worktree for branch: {branch_id}"))?;
698743

699744
let worktree = Path::new(&workdir.path);
700745

@@ -744,7 +789,7 @@ fn get_branch_timeline(
744789
let branch = store
745790
.get_branch(&branch_id)
746791
.map_err(|e| e.to_string())?
747-
.ok_or_else(|| format!("Branch not found: {}", branch_id))?;
792+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
748793

749794
let workdir = store
750795
.get_workdir_for_branch(&branch_id)
@@ -877,12 +922,12 @@ fn resolve_branch_context(
877922
let branch = store
878923
.get_branch(branch_id)
879924
.map_err(|e| e.to_string())?
880-
.ok_or_else(|| format!("Branch not found: {}", branch_id))?;
925+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
881926

882927
let workdir = store
883928
.get_workdir_for_branch(branch_id)
884929
.map_err(|e| e.to_string())?
885-
.ok_or_else(|| format!("No worktree for branch: {}", branch_id))?;
930+
.ok_or_else(|| format!("No worktree for branch: {branch_id}"))?;
886931

887932
Ok(BranchDiffContext {
888933
worktree_path: workdir.path,
@@ -1017,7 +1062,7 @@ async fn ensure_review(
10171062
) -> Result<store::Review, String> {
10181063
let store = get_store(&store)?;
10191064
let review_scope =
1020-
store::ReviewScope::parse(&scope).ok_or_else(|| format!("Invalid scope: {}", scope))?;
1065+
store::ReviewScope::parse(&scope).ok_or_else(|| format!("Invalid scope: {scope}"))?;
10211066

10221067
store
10231068
.ensure_review(&branch_id, &commit_sha, review_scope)

staged/src-tauri/src/session_commands.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ pub fn resume_session(
131131
let session = store
132132
.get_session(&session_id)
133133
.map_err(|e| e.to_string())?
134-
.ok_or_else(|| format!("Session not found: {}", session_id))?;
134+
.ok_or_else(|| format!("Session not found: {session_id}"))?;
135135

136136
let agent_session_id = session.agent_id.clone();
137137

@@ -248,12 +248,12 @@ pub fn start_branch_session(
248248
let branch = store
249249
.get_branch(&branch_id)
250250
.map_err(|e| e.to_string())?
251-
.ok_or_else(|| format!("Branch not found: {}", branch_id))?;
251+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
252252

253253
let workdir = store
254254
.get_workdir_for_branch(&branch_id)
255255
.map_err(|e| e.to_string())?
256-
.ok_or_else(|| format!("No worktree for branch: {}", branch_id))?;
256+
.ok_or_else(|| format!("No worktree for branch: {branch_id}"))?;
257257

258258
let worktree_path = Path::new(&workdir.path);
259259

staged/src/lib/BranchCard.svelte

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@
3939
4040
let { branch, onDelete }: Props = $props();
4141
42-
const menuItems: MenuItem[] = [
43-
{ label: 'Delete Branch', icon: Trash2, danger: true, action: () => onDelete?.() },
44-
];
42+
let menuItems = $derived<MenuItem[]>(
43+
branch.isMainWorktree
44+
? []
45+
: [{ label: 'Delete Branch', icon: Trash2, danger: true, action: () => onDelete?.() }]
46+
);
4547
4648
let timeline = $state<BranchTimelineData | null>(null);
4749
let loading = $state(true);
@@ -239,14 +241,19 @@
239241
<div class="branch-info">
240242
<GitBranch size={16} class="branch-icon" />
241243
<span class="branch-name">{branch.branchName}</span>
244+
{#if branch.isMainWorktree}
245+
<span class="main-badge">main worktree</span>
246+
{/if}
242247
<span class="branch-separator">›</span>
243248
<span class="base-branch-name">{formatBaseBranch(branch.baseBranch)}</span>
244249
</div>
245250
<div class="header-actions">
246251
<button class="view-diff-btn" onclick={() => (showBranchDiff = true)} title="View diff">
247252
<FileDiff size={16} />
248253
</button>
249-
<DropdownMenu items={menuItems} />
254+
{#if menuItems.length > 0}
255+
<DropdownMenu items={menuItems} />
256+
{/if}
250257
</div>
251258
</div>
252259

@@ -428,6 +435,15 @@
428435
letter-spacing: -0.01em;
429436
}
430437
438+
.main-badge {
439+
font-size: var(--size-xs);
440+
font-weight: 500;
441+
color: var(--text-faint);
442+
background-color: var(--bg-hover);
443+
padding: 1px 6px;
444+
border-radius: 4px;
445+
}
446+
431447
.branch-separator {
432448
color: var(--text-faint);
433449
font-size: var(--size-md);

0 commit comments

Comments
 (0)