Skip to content

Create a worktree for each opened branch#113

Merged
saddlepaddle merged 3 commits intomainfrom
create-worktrees-for-workspaces
Nov 21, 2025
Merged

Create a worktree for each opened branch#113
saddlepaddle merged 3 commits intomainfrom
create-worktrees-for-workspaces

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Nov 21, 2025

Description

Related Issues

Type of Change

  • Bug fix
  • New feature
  • Documentation
  • Refactor
  • Other (please describe):

Testing

Screenshots (if applicable)

Additional Notes

Summary by CodeRabbit

  • New Features

    • Project-based recents with Git validation and explicit project records
    • Workspace dropdown to create workspaces from recent projects or open a new project
    • Automated Git worktree creation/removal during workspace create/delete
  • Improvements

    • Workspaces now reference projects/worktrees for better metadata and lifecycle handling
    • UI simplified: streamlined workspace creation flow and async data fetching for workspace state
  • Removals

    • Legacy recent-project UI and old workspace/store-based flows removed

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 21, 2025

Walkthrough

Replaces recents with a projects/worktrees model: adds Project and Worktree DB entities, new git worktree utilities, trpc endpoints (getRecents, openNew), workspace creation wired to worktrees, removes the old workspaces Zustand store and many recents-related UI components/hooks.

Changes

Cohort / File(s) Summary
Database Schema
apps/desktop/src/main/lib/db/schemas.ts
Adds Project (id, mainRepoPath, name, lastOpenedAt, createdAt); redefines Worktree (id, projectId, path, branch, createdAt); updates Workspace to reference projectId & worktreeId; adds Tab; replaces recentProjects with projects and worktrees in defaultDatabase.
Projects TRPC Router
apps/desktop/src/lib/trpc/routers/projects/projects.ts
Removes openProject/openRecent/removeRecent; adds getRecents() returning Project[] and openNew() which uses nanoid + getGitRoot to validate git root and create or update Project entries.
Workspaces TRPC Router
apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
create now requires projectId (name optional), generates branch, creates git worktree, persists Worktree + Workspace (IDs), updates project.lastOpenedAt and settings; getById now throws if missing; update only patches name; delete removes associated git worktree and worktree record.
Git Utilities
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
New file exporting generateBranchName(), createWorktree(mainRepoPath, branch, worktreePath), removeWorktree(mainRepoPath, worktreePath), and getGitRoot(path) (replaces prior isGitRepo). Uses simple-git and ensures parent dirs.
Renderer: Projects hooks
apps/desktop/src/renderer/react-query/projects/*
Removes useOpenProject, useOpenRecent, useRemoveRecent; adds useOpenNew (wraps trpc.projects.openNew.useMutation, invalidates getRecents on success); barrel now only exports useOpenNew.
Renderer: Workspace UI / TopBar
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/*
Removes AddWorkspaceButton; adds WorkspaceDropdown listing recent projects from getRecents and offering "Open New Project..." (calls openNew then creates a workspace). index.tsx now renders WorkspaceDropdown.
Renderer: NewWorkspaceView & subcomponents
apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView*
Deletes NewWorkspaceView, StartSection, RecentSection, RecentProjectItem; WorkspaceView simplified to always render main layout (no new-workspace path).
Renderer: Store / Hooks migration
apps/desktop/src/renderer/stores/workspaces.ts, apps/desktop/src/renderer/stores/index.ts, apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts, various components
Removes the Zustand workspaces store and its barrel export; components and hooks migrated to TRPC queries/mutations (workspaces.getAll / getActive / useSetActiveWorkspace) and now depend on async TRPC data.
Misc / Package
apps/desktop/package.json
Adds dependencies execa and simple-git.
Minor UI tweaks
apps/desktop/src/renderer/screens/main/components/TopBar/*
Small className and no-drag adjustments on workspace item and container elements; no API changes.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as WorkspaceDropdown
    participant TRPC_Projects as trpc.projects.openNew
    participant TRPC_Workspaces as trpc.workspaces.create
    participant Git as Git Utils
    participant DB as Database

    rect rgb(220,240,255)
    Note over User,DB: Open New Project → Create Workspace flow
    User->>UI: Click "Open New Project"
    UI->>TRPC_Projects: openNew(folderPath)
    TRPC_Projects->>Git: getGitRoot(folderPath)
    alt is git repo
        Git-->>TRPC_Projects: mainRepoPath
        TRPC_Projects->>DB: create/update Project (nanoid, timestamps)
        DB-->>TRPC_Projects: Project
        TRPC_Projects-->>UI: { success: true, project }
        UI->>TRPC_Workspaces: create({ projectId, name? })
        TRPC_Workspaces->>Git: generateBranchName()
        TRPC_Workspaces->>Git: createWorktree(mainRepoPath, branch, worktreePath)
        Git-->>TRPC_Workspaces: success
        TRPC_Workspaces->>DB: create Worktree, create Workspace (worktreeId, projectId)
        DB-->>TRPC_Workspaces: Workspace
        TRPC_Workspaces-->>UI: success
    else not git repo
        Git-->>TRPC_Projects: error
        TRPC_Projects-->>UI: { success: false, error }
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Areas needing extra attention:
    • DB schema migration risks and relationships between projects, worktrees, and workspaces.
    • Side effects ordering and error handling around git worktree creation/removal vs DB writes.
    • getGitRoot error messages and cross-platform path normalization.
    • Renderer changes replacing synchronous store assumptions with async TRPC queries (loading/null/throw semantics).

Possibly related PRs

Poem

🐰
I dug a new branch under moonlit code,
planted projects where old recents strode.
Worktrees take root, IDs hop in line,
dropdowns bloom — a workspace vine.
Nibble the code, the repo's fine. 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is entirely empty—all template sections (Description, Related Issues, Type of Change, Testing, Screenshots, Additional Notes) contain only placeholder comments with no actual content filled in, making it impossible to understand the change rationale, testing approach, or related context. Fill in all key template sections: provide a clear description of the architectural changes, link any related issues, mark the appropriate Type of Change checkbox (appears to be 'New feature' and/or 'Refactor'), describe testing steps, and add any relevant context or migration notes for reviewers.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Create a worktree for each opened branch' accurately summarizes the main architectural change: migrating from path-based project handling to Git worktrees, where each workspace is backed by a dedicated worktree on a specific branch.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch create-worktrees-for-workspaces

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (2)

153-155: Clarify whether lastOpenedAt should be updated on name change.

Line 155 updates lastOpenedAt whenever the workspace name is changed. This conflates "last updated" with "last opened," which could confuse analytics or UI that relies on lastOpenedAt to show recently accessed workspaces.

Consider whether update should modify lastOpenedAt, or if it should only be updated by setActive:

  // Update timestamps
  workspace.updatedAt = Date.now();
- workspace.lastOpenedAt = Date.now();

If updating lastOpenedAt on name changes is intentional (e.g., to treat any interaction as "opening"), please add a comment explaining this semantic choice.


167-171: Inconsistent error handling: returns error object instead of throwing.

The delete procedure returns { success: false, error: "..." } when the workspace is not found (Lines 169-170), while get and getActive throw errors in similar situations. This inconsistency makes error handling unpredictable for callers.

Standardize error handling across all procedures:

  const workspace = db.data.workspaces.find((w) => w.id === input.id);

  if (!workspace) {
-   return { success: false, error: "Workspace not found" };
+   throw new Error(`Workspace ${input.id} not found`);
  }

Then update the return type to just { success: true } (remove the error case from the union).

🧹 Nitpick comments (7)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx (1)

4-4: Swap to WorkspaceDropdown looks good; verify width assumption

The swap from the old add button to <WorkspaceDropdown /> is straightforward and integrates cleanly with the existing layout logic.

One minor thing to verify: ADD_BUTTON_WIDTH is still hard‑coded to 48 and used in the tab width calculation. If the new dropdown control’s rendered width differs from the old button, tab widths may be slightly off at smaller window sizes. Consider adjusting the constant or deriving the control width from the DOM if you notice layout drift.

Also applies to: 122-123

apps/desktop/src/renderer/react-query/projects/useOpenNew.ts (1)

1-22: Solid mutation wrapper; consider guarding user onSuccess against invalidate failures

Wrapping trpc.projects.openNew.useMutation and auto‑invalidating projects.getRecents is a good pattern. One small robustness improvement:

If utils.projects.getRecents.invalidate() ever throws/rejects, the user’s options.onSuccess won’t run. You can ensure it always runs by using try/finally:

return trpc.projects.openNew.useMutation({
  ...options,
- onSuccess: async (...args) => {
-   // Auto-invalidate projects query
-   await utils.projects.getRecents.invalidate();
-
-   // Call user's onSuccess if provided
-   await options?.onSuccess?.(...args);
- },
+ onSuccess: async (...args) => {
+   try {
+     // Auto-invalidate projects query
+     await utils.projects.getRecents.invalidate();
+   } finally {
+     // Call user's onSuccess if provided
+     await options?.onSuccess?.(...args);
+   }
+ },
});

This keeps cache invalidation while avoiding surprising skips of caller callbacks.

apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)

13-86: Workspace creation flow is solid; consider surfacing openNew errors

The dropdown wiring for recent projects and “Open New Project...” looks good and lines up with the new router/hook APIs.

Right now, if openNew returns { success: false, error } (e.g., non‑git repo selection), the user sees no feedback and the dropdown just stays open. It may be worth handling that case, e.g.:

onSuccess: (result) => {
  if (result.success) {
    handleCreateWorkspace(result.project.id);
  } else if (result.error) {
    // show toast/notification with result.error
  }
}

Similarly, if you don’t have global error handling for createWorkspace, you might want a local error surface there as well.

apps/desktop/src/lib/trpc/routers/projects/projects.ts (1)

4-8: New projects router aligns well with the updated model

The getRecents and openNew implementations look consistent with the new Project schema and the UI usage:

  • getRecents correctly returns a sorted copy, avoiding in‑place mutation.
  • openNew:
    • Cleanly handles cancel vs. selection.
    • Validates the directory as a git repo via isGitRepo.
    • Reuses existing projects by mainRepoPath and updates lastOpenedAt, or creates a new one with nanoid and timestamps.

Two minor considerations (not blockers):

  • You traverse projects twice in the “existing project” path (once with db.data.projects.find, once in db.update). With small lists this is fine; if project count grows, you might eventually want a helper that updates by ID in one pass.
  • If you expect pre‑existing databases from older schemas, ensure there’s a migration path that initializes data.projects and gracefully ignores any legacy recentProjects data.

Overall, the router surface matches the client hooks and looks good.

Also applies to: 12-16, 18-67

apps/desktop/src/main/lib/db/schemas.ts (2)

6-15: Schema changes line up with router usage

The new Project, Worktree, Workspace, and Database shapes match how the routers and hooks are using them (mainRepoPath, projectId, worktreeId, timestamps, etc.), and initializing projects/worktrees in defaultDatabase is appropriate.

One thing to keep in mind is migration: existing user DB files that predate projects/worktrees will need a migration step to populate these arrays and possibly transform any legacy recentProjects data, otherwise the new code will see empty recents. If that migration lives elsewhere, this definition looks good.

Also applies to: 20-40, 55-70


42-49: Clarify Tab usage and avoid confusion with other Tab types

Tab is defined here but not included in Database, and there is already a different Tab shape in apps/desktop/src/shared/types.ts (fields like name, type: TabType, mosaic layout, etc.).

To reduce confusion:

  • Either add tabs: Tab[] (or similar) to Database if these will be persisted soon, or
  • Move/rename this interface (e.g., DbTab or WorkspaceTab) or remove it until it’s actually used.

This will make it clearer which Tab type is the source of truth in each layer.

apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (1)

109-126: Good error handling with clear distinction between valid and invalid states.

The implementation correctly distinguishes between:

  • No active workspace set (returns null - valid state)
  • Active workspace ID exists but workspace missing (throws error - data inconsistency)

This approach helps detect database corruption early.

Consider enhancing the error message to suggest recovery:

  if (!workspace) {
    throw new Error(
-     `Active workspace ${lastActiveWorkspaceId} not found in database`,
+     `Active workspace ${lastActiveWorkspaceId} not found in database. Database may be corrupted. Consider resetting lastActiveWorkspaceId in settings.`,
    );
  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5194ed0 and 49bb086.

📒 Files selected for processing (20)
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts (2 hunks)
  • apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1 hunks)
  • apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (5 hunks)
  • apps/desktop/src/main/lib/db/schemas.ts (1 hunks)
  • apps/desktop/src/renderer/react-query/projects/index.ts (1 hunks)
  • apps/desktop/src/renderer/react-query/projects/useOpenNew.ts (1 hunks)
  • apps/desktop/src/renderer/react-query/projects/useOpenProject.ts (0 hunks)
  • apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts (0 hunks)
  • apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx (2 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx (0 hunks)
💤 Files with no reviewable changes (12)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts
  • apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx
  • apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx
  • apps/desktop/src/renderer/react-query/projects/useOpenProject.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx
🧰 Additional context used
🧬 Code graph analysis (5)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)
apps/desktop/src/renderer/react-query/projects/useOpenNew.ts (1)
  • useOpenNew (7-22)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx (1)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)
  • WorkspaceDropdown (13-86)
apps/desktop/src/lib/trpc/routers/projects/projects.ts (4)
apps/desktop/src/lib/trpc/index.ts (1)
  • router (15-15)
apps/desktop/src/main/lib/db/schemas.ts (1)
  • Project (9-15)
apps/desktop/src/main/lib/db/index.ts (1)
  • db (19-26)
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1)
  • isGitRepo (111-118)
apps/desktop/src/main/lib/db/schemas.ts (1)
apps/desktop/src/shared/types.ts (3)
  • Worktree (58-68)
  • Workspace (70-82)
  • Tab (44-56)
apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (2)
apps/desktop/src/main/lib/db/index.ts (1)
  • db (19-26)
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (3)
  • generateBranchName (12-54)
  • createWorktree (62-82)
  • removeWorktree (89-104)
🔇 Additional comments (3)
apps/desktop/src/renderer/react-query/projects/index.ts (1)

1-1: Barrel export matches new projects API surface

Exporting only useOpenNew aligns with the new projects router and hook set; no issues here.

apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (2)

85-93: The get procedure has no callers in the codebase.

The review assumes that the breaking change from returning null to throwing an error would affect existing callers. However, verification shows that workspaces.get is never called anywhere in the desktop app—only getAll and getActive are used. The procedure appears to be an unused endpoint or future API without current integrations, so this breaking change has no practical impact on the codebase.

Likely an incorrect or invalid review comment.


174-201: No cascading deletion risk — the 1:1 workspace-to-worktree relationship is by design

The codebase explicitly documents this relationship. The Workspace schema includes the comment: "Workspace represents a UI tab (1:1 with Worktree)" (line 29, schemas.ts). Worktree creation logic confirms this: each time a workspace is created, a fresh worktree is pushed to the database (line 67, workspaces.ts). No evidence of worktree sharing or reuse exists in the codebase. The deletion code correctly assumes 1:1 cardinality.

Comment on lines +8 to +54
/**
* Generate a random branch name using adjective-noun-number pattern
* Examples: "ws-crimson-forest-42", "ws-azure-cloud-17"
*/
export function generateBranchName(): string {
const adjectives = [
"azure",
"crimson",
"emerald",
"golden",
"indigo",
"jade",
"lavender",
"magenta",
"navy",
"olive",
"pearl",
"rose",
"silver",
"teal",
"violet",
];

const nouns = [
"cloud",
"forest",
"mountain",
"ocean",
"river",
"storm",
"sunset",
"thunder",
"wave",
"wind",
"meadow",
"canyon",
"glacier",
"valley",
"peak",
];

const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 100);

return `${adjective}-${noun}-${number}`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Branch name examples don’t match implementation

JSDoc shows examples like "ws-crimson-forest-42", but the function currently returns "crimson-forest-42" (no ws- prefix).

Either add the prefix in the implementation:

- return `${adjective}-${noun}-${number}`;
+ return `ws-${adjective}-${noun}-${number}`;

or update the doc examples to match actual output so downstream expectations (e.g., branch naming conventions or filters) stay consistent.

🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts around lines 8 to
54, the JSDoc examples include a "ws-" prefix but the generateBranchName
function returns names without that prefix; update the implementation to prepend
"ws-" to the returned branch name (i.e., return
`ws-${adjective}-${noun}-${number}`) so runtime output matches docs and
downstream conventions, or alternatively update the JSDoc examples to remove
"ws-"—pick the option that matches project naming rules and ensure
tests/consumers are adjusted accordingly.

Comment on lines +62 to +103
export async function createWorktree(
mainRepoPath: string,
branch: string,
worktreePath: string,
): Promise<void> {
try {
// Ensure the parent directory exists
const parentDir = join(worktreePath, "..");
await mkdir(parentDir, { recursive: true });

// Create the worktree with a new branch
await execAsync(`git worktree add "${worktreePath}" -b "${branch}"`, {
cwd: mainRepoPath,
});

console.log(`Created worktree at ${worktreePath} with branch ${branch}`);
} catch (error) {
console.error(`Failed to create worktree: ${error}`);
throw new Error(`Failed to create worktree: ${error}`);
}
}

/**
* Remove a git worktree
* @param mainRepoPath - Path to the main git repository
* @param worktreePath - Path to the worktree to remove
*/
export async function removeWorktree(
mainRepoPath: string,
worktreePath: string,
): Promise<void> {
try {
// Remove the worktree (--force removes even if there are uncommitted changes)
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: mainRepoPath,
});

console.log(`Removed worktree at ${worktreePath}`);
} catch (error) {
console.error(`Failed to remove worktree: ${error}`);
throw new Error(`Failed to remove worktree: ${error}`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use safer path handling and avoid shell-constructed git commands

There are a few issues in createWorktree/removeWorktree:

  1. Parent directory computation
const parentDir = join(worktreePath, "..");
await mkdir(parentDir, { recursive: true });

Using join(worktreePath, "..") yields a path with a trailing .. segment; behavior is OS‑dependent and brittle. Prefer dirname:

-import { join } from "node:path";
+import { dirname, join } from "node:path";
...
-const parentDir = join(worktreePath, "..");
+const parentDir = dirname(worktreePath);
  1. Command construction / injection risk

Both helpers build shell commands via string interpolation:

await execAsync(`git worktree add "${worktreePath}" -b "${branch}"`, { cwd: mainRepoPath });
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: mainRepoPath });

Even though branch is currently generated internally, worktreePath and mainRepoPath come from the filesystem and can contain characters (", &, ;, etc.) that break quoting or enable command injection.

It’s safer to avoid the shell entirely and use execFile (or spawn) with an argument array:

-import { exec } from "node:child_process";
+import { execFile } from "node:child_process";
 import { promisify } from "node:util";
...
-const execAsync = promisify(exec);
+const execFileAsync = promisify(execFile);
...
- await execAsync(`git worktree add "${worktreePath}" -b "${branch}"`, {
-   cwd: mainRepoPath,
- });
+ await execFileAsync("git", ["worktree", "add", worktreePath, "-b", branch], {
+   cwd: mainRepoPath,
+ });
...
- await execAsync(`git worktree remove "${worktreePath}" --force`, {
-   cwd: mainRepoPath,
- });
+ await execFileAsync("git", ["worktree", "remove", worktreePath, "--force"], {
+   cwd: mainRepoPath,
+ });
  1. Error wrapping

Wrapping the caught error with new Error(...) loses the original stack:

catch (error) {
  console.error(`Failed to create worktree: ${error}`);
  throw new Error(`Failed to create worktree: ${error}`);
}

If you want a custom message, consider using cause to preserve context:

catch (error) {
  console.error("Failed to create worktree", error);
  throw new Error("Failed to create worktree", {
    cause: error instanceof Error ? error : undefined,
  });
}

Same applies to removeWorktree.

🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts around lines
62-103, replace brittle join(worktreePath, "..") with path.dirname(worktreePath)
when creating the parent directory, switch from constructing shell strings and
execAsync to a shell-free API (e.g., child_process.execFile or spawn with an
args array) to run git with arguments like ["worktree","add", worktreePath,
"-b", branch] and ["worktree","remove", worktreePath, "--force"] (keeping cwd:
mainRepoPath), and change the catch blocks to log the original error object and
rethrow using Error with the cause option (or rethrow the original error) so the
original stack is preserved.

Comment on lines +22 to +26
// Find the project
const project = db.data.projects.find((p) => p.id === input.projectId);
if (!project) {
throw new Error(`Project ${input.projectId} not found`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Verify that project.mainRepoPath is a valid Git repository.

The code assumes project.mainRepoPath is a valid Git repository but doesn't validate this before calling createWorktree. Invalid paths could lead to cryptic Git errors.

Check if Git repository validation exists:


🏁 Script executed:

#!/bin/bash
# Description: Check if mainRepoPath is validated as a Git repository

# Look for Git validation in the projects module or git utilities
rg -nP --type=ts -C3 'isGitRepo|git.*rev-parse|\.git.*HEAD|validate.*git|check.*repository' apps/desktop/src/lib/trpc/routers/

Length of output: 2152


Now I need to check the actual workspaces.ts file to see if createWorktree is called after finding the project:


🏁 Script executed:

#!/bin/bash
# Description: Check the full context of workspaces.ts around createWorktree

# First, let's find where createWorktree is called and what happens
rg -nP --type=ts -A20 'project.mainRepoPath' apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Description: Find the correct location of workspaces.ts

fd -type f -name "workspaces.ts" apps/desktop/

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Description: Find the correct location of workspaces.ts

fd "workspaces\.ts$" apps/desktop/

Length of output: 178


🏁 Script executed:

#!/bin/bash
# Description: Check the context around lines 22-26 in workspaces.ts and see if createWorktree is called

# Get the content around the specified lines and further to find createWorktree
cat apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts | head -100 | tail -80

Length of output: 1948


Add Git repository validation before calling createWorktree at line 37.

The code passes project.mainRepoPath to createWorktree without validating it's a valid Git repository. The isGitRepo function exists in the codebase and is already used in projects.ts (line 31) for the same purpose—apply the same validation here to prevent cryptic Git errors.

// Add this check before createWorktree
if (!(await isGitRepo(project.mainRepoPath))) {
	throw new Error(`Project repository at ${project.mainRepoPath} is not a valid git repository`);
}
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts around lines 22 to
26 (and before the createWorktree call at ~line 37), the code uses
project.mainRepoPath without validating it as a git repo; add an await
isGitRepo(project.mainRepoPath) check and if it returns false throw an
Error(`Project repository at ${project.mainRepoPath} is not a valid git
repository`) so createWorktree only runs against valid repositories; ensure
isGitRepo is imported/available in this module and use the same pattern as in
projects.ts line 31.

throw new Error(`Project ${input.projectId} not found`);
}

const branch = generateBranchName();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Verify branch name uniqueness to prevent conflicts.

The generated branch name could collide with an existing branch in the repository, causing createWorktree to fail at Line 37. While the random generation makes collisions unlikely, a concurrent creation or an existing branch could cause issues.

Consider verifying branch uniqueness before creating the worktree:


🏁 Script executed:

#!/bin/bash
# Description: Check if git.ts validates branch uniqueness before creation

# Search for branch existence validation in git utilities
rg -nP --type=ts -C3 'branch.*exist|git.*branch.*list|git.*show-ref' apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find git utilities file and createWorktree function
echo "=== Searching for createWorktree function ==="
rg -nP --type=ts 'createWorktree' apps/desktop/src/lib/trpc/routers/workspaces/ -A5

echo -e "\n=== Searching for git utilities file ==="
fd -e ts 'git' apps/desktop/src/lib/trpc/routers/workspaces/

echo -e "\n=== Searching for generateBranchName function ==="
rg -nP --type=ts 'generateBranchName' apps/desktop/src/lib/trpc/routers/workspaces/ -A8

Length of output: 3899


🏁 Script executed:

#!/bin/bash
# Get full implementation of generateBranchName and createWorktree

echo "=== Full generateBranchName implementation ==="
rg -nP --type=ts 'export function generateBranchName' apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts -A25

echo -e "\n=== Full createWorktree implementation ==="
rg -nP --type=ts 'export async function createWorktree' apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts -A30

echo -e "\n=== Check for branch validation in git.ts ==="
rg -nP --type=ts 'branch' apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts | grep -i 'exist\|check\|valid\|ref'

Length of output: 1513


🏁 Script executed:

#!/bin/bash
# Check error handling in create mutation - what happens if createWorktree fails?

echo "=== Create mutation error handling (lines 14-80) ==="
rg -nP --type=ts 'create: publicProcedure' apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts -A70 | head -80

echo -e "\n=== Check if there's try-catch or cleanup logic ==="
rg -nP --type=ts 'catch|finally|rollback' apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts -B2 -A2

Length of output: 2227


Add branch validation and error handling in create mutation to prevent orphaned worktrees.

The generateBranchName() function has no validation against existing branches, and createWorktree() is called without error handling. If a branch collision occurs or the worktree creation fails partway through, the system can be left in an inconsistent state with orphaned Git worktrees.

Specific issues:

  • Line 28: generateBranchName() generates names without checking if they already exist in the repository
  • Line 37: createWorktree() is called without a try-catch wrapper; if it fails after partial creation, there's no cleanup
  • Lines 37-76: Atomicity problem—if createWorktree() succeeds but db.update() fails, the Git worktree is orphaned

Recommendations:

  1. Add a check in createWorktree() or before calling it to validate the branch doesn't already exist
  2. Wrap the createWorktree() call in a try-catch with cleanup logic on failure
  3. Consider transactional semantics or ensure consistent error recovery (similar to the try-catch in the delete mutation at line 185)
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts around line 28
(and the create flow spanning ~37–76, with delete example at 185),
generateBranchName() is used without verifying the branch doesn't already exist
and createWorktree() is invoked without error handling, risking orphaned
worktrees if later DB updates fail. Before calling createWorktree(), query the
repo for existing branch names and loop/regenerate until you get a unique name
(or fail after N attempts); wrap createWorktree() in a try-catch, and on any
failure perform cleanup (remove the created worktree and delete the branch if
created), then rethrow or return an error; if createWorktree() succeeds but
db.update() fails, perform the same rollback cleanup to ensure atomicity (or
persist a transactional marker and run compensating cleanup), mirroring the
try-catch/cleanup pattern used in the delete mutation.

Comment on lines +30 to +37
const worktreePath = join(
project.mainRepoPath,
".superset",
branch,
);

// Create git worktree
await createWorktree(project.mainRepoPath, branch, worktreePath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical atomicity issue: Git worktree created before database transaction.

The Git worktree is created at Line 37 before the database transaction (Line 66). If the database update fails after the worktree is created, an orphaned worktree will remain on the filesystem without corresponding database records, leading to resource leaks and inconsistent state.

Consider one of these approaches:

Option 1: Wrap in try-catch with rollback

+try {
   // Create git worktree
   await createWorktree(project.mainRepoPath, branch, worktreePath);

   // Create worktree record
   const worktree = {
     id: nanoid(),
     projectId: input.projectId,
     path: worktreePath,
     branch,
     createdAt: Date.now(),
   };

   // ... rest of creation logic ...

   // Save to database
   await db.update((data) => {
     data.worktrees.push(worktree);
     data.workspaces.push(workspace);
     data.settings.lastActiveWorkspaceId = workspace.id;

     // Update project lastOpenedAt
     const p = data.projects.find((p) => p.id === input.projectId);
     if (p) {
       p.lastOpenedAt = Date.now();
     }
   });

   return workspace;
+} catch (error) {
+  // Rollback: remove the worktree if it was created
+  try {
+    await removeWorktree(project.mainRepoPath, worktreePath);
+  } catch (cleanupError) {
+    console.error("Failed to cleanup worktree after error:", cleanupError);
+  }
+  throw error;
+}

Option 2: Validate before creating resources
Add pre-flight validation (check disk space, verify repo state, validate branch name) before creating the worktree, though this doesn't fully eliminate the atomicity issue.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts around lines 30 to
37 and the DB transaction at ~66, the Git worktree is created before the
database transaction which can leave an orphaned worktree if the DB update
fails; change the flow so the worktree creation is atomic with the DB update:
either (preferred) perform the DB transaction first and only create the worktree
after the transaction commits, or (if worktree must be created first) wrap
creation and DB update in a try/catch where on any DB failure you remove the
created worktree (cleanup) and rethrow; additionally add pre-flight validation
(branch name, repo state, disk space) before creating resources to reduce
failure likelihood.

Comment on lines +182 to +189
if (worktree && project) {
try {
await removeWorktree(project.mainRepoPath, worktree.path);
} catch (error) {
console.error("Failed to remove worktree:", error);
// Continue with database cleanup even if git operation fails
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Git worktree removed with --force flag, discarding uncommitted changes without warning.

Line 184 calls removeWorktree, which uses git worktree remove --force (per the relevant code snippet). This forcibly deletes the worktree even with uncommitted changes, potentially causing data loss for users who haven't committed their work.

Consider one of these approaches:

Option 1: Check for uncommitted changes first

  // Remove git worktree if it exists
  if (worktree && project) {
    try {
+     // Check for uncommitted changes
+     const { stdout } = await execAsync(
+       `git -C "${worktree.path}" status --porcelain`,
+       { cwd: project.mainRepoPath }
+     );
+     if (stdout.trim()) {
+       throw new Error(
+         `Cannot delete workspace: worktree has uncommitted changes at ${worktree.path}`
+       );
+     }
      await removeWorktree(project.mainRepoPath, worktree.path);
    } catch (error) {
      console.error("Failed to remove worktree:", error);
-     // Continue with database cleanup even if git operation fails
+     throw error; // Don't delete from database if worktree can't be removed
    }
  }

Option 2: Add a force flag to the delete input
Allow callers to explicitly opt-in to force deletion:

.input(z.object({ 
  id: z.string(),
  force: z.boolean().optional()
}))
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts around lines
182–189, the code unconditionally calls removeWorktree which uses `git worktree
remove --force`, risking data loss; modify the handler to accept an optional
force boolean on the input (update the zod schema to .input(z.object({ id:
z.string(), force: z.boolean().optional() })) ), then before calling
removeWorktree, run a git status check for uncommitted changes in that worktree
(or use an existing helper) and if uncommitted changes exist and force is not
true, abort with a clear error to the caller; only call removeWorktree with
force when either no uncommitted changes are found or the input.force is true,
and ensure errors are returned/logged rather than silently proceeding.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts (1)

73-73: Consider error handling for the setActiveWorkspace mutation.

Same issue as line 66—the mutation lacks error handling. Please add an onError callback to handle failures gracefully.

Apply the same fix suggested for line 66.

apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1)

62-65: Preserve original errors instead of re‑wrapping with interpolated strings.

In createWorktree, removeWorktree, and getGitRoot, the catch blocks both log and then throw new Error(\Failed...: ${error}`)ornew Error("Not a git repository...")`, which discards the original stack trace and error type, making debugging harder.

Prefer either:

  • logging the original error object and rethrowing it, or
  • wrapping with cause:
} catch (error) {
  console.error("Failed to create worktree", error);
  throw new Error("Failed to create worktree", {
    cause: error instanceof Error ? error : undefined,
  });
}

and similarly for removeWorktree / getGitRoot.

#!/bin/bash
# Quick check: locate all custom Error constructions in git.ts to ensure
# they’re updated consistently if you adopt the `cause` pattern.
rg -n "Failed to (create|remove) worktree|Not a git repository" apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts -n -C2

Also applies to: 77-80, 88-90

🧹 Nitpick comments (4)
apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx (1)

22-23: Consider handling loading and error states from the TRPC query.

The query result doesn't extract isLoading or isError, so activeWorkspace will be undefined during initial load or if the query fails. While the component has null checks in place (lines 31-33, 48), the user experience could be improved by explicitly handling these states—for example, disabling interactions or showing a loading indicator when the workspace data is unavailable.

Consider destructuring the query result to handle these states:

-const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
+const { data: activeWorkspace, isLoading, isError } = trpc.workspaces.getActive.useQuery();

Then conditionally disable or adjust UI based on isLoading or isError.

apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx (1)

11-12: Consider handling loading and error states from the TRPC query.

Similar to other components in this PR, the query doesn't extract isLoading or isError. While the component gracefully handles undefined by returning an empty tabs array (line 22) and disabling the "New Tab" button (line 68), users won't have visibility into whether the workspace is still loading or failed to load.

Consider extracting loading/error states for better UX:

-const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
+const { data: activeWorkspace, isLoading } = trpc.workspaces.getActive.useQuery();

You could then show a loading skeleton or message when isLoading is true.

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx (1)

15-16: Loading state causes EmptyTabView to display during workspace data fetch.

When the workspace data is loading, activeWorkspace is undefined, causing tabToRender to be null (line 21) and the component to render EmptyTabView (lines 38-43). This may confuse users who expect to see a loading indicator rather than an empty state during initial load or refetch.

Extract the loading state and handle it explicitly:

-const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
+const { data: activeWorkspace, isLoading } = trpc.workspaces.getActive.useQuery();
 const activeWorkspaceId = activeWorkspace?.id;

Then conditionally render a loading state:

+if (isLoading) {
+  return (
+    <div ref={attachDrop} className="flex-1 h-full flex items-center justify-center">
+      <p>Loading workspace...</p>
+    </div>
+  );
+}
+
 if (!tabToRender) {
   return (
     <div ref={attachDrop} className="flex-1 h-full">
apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts (1)

66-66: Consider error handling for the setActiveWorkspace mutation.

The mutation is called with .mutate({ id }), which is fire-and-forget and doesn't handle errors. If the workspace switch fails (e.g., due to a network issue or backend error), the user won't receive feedback, and the UI state may become inconsistent.

Consider adding error handling to the mutation:

-setActiveWorkspace.mutate({ id: workspaces[index - 1].id });
+setActiveWorkspace.mutate(
+  { id: workspaces[index - 1].id },
+  {
+    onError: (error) => {
+      console.error('Failed to switch workspace:', error);
+      // Optionally show a toast notification
+    }
+  }
+);

Apply the same pattern to line 73 as well.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 49bb086 and 01db3ba.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • apps/desktop/package.json (2 hunks)
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts (1 hunks)
  • apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1 hunks)
  • apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts (4 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx (2 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx (1 hunks)
  • apps/desktop/src/renderer/stores/index.ts (0 hunks)
  • apps/desktop/src/renderer/stores/workspaces.ts (0 hunks)
💤 Files with no reviewable changes (2)
  • apps/desktop/src/renderer/stores/index.ts
  • apps/desktop/src/renderer/stores/workspaces.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/desktop/src/lib/trpc/routers/projects/projects.ts (4)
apps/desktop/src/lib/trpc/index.ts (1)
  • router (15-15)
apps/desktop/src/main/lib/db/schemas.ts (1)
  • Project (9-15)
apps/desktop/src/main/lib/db/index.ts (1)
  • db (19-26)
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1)
  • getGitRoot (83-91)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)
apps/desktop/src/renderer/react-query/projects/useOpenNew.ts (1)
  • useOpenNew (7-22)
🔇 Additional comments (4)
apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts (1)

35-37: Good use of default value for workspaces query.

Line 35 provides a sensible default empty array for workspaces, preventing potential issues during loading. However, the activeWorkspace query on line 36 doesn't extract isLoading or isError. While keyboard shortcuts gracefully no-op when activeWorkspaceId is undefined (checked in handlers), consider whether you want to provide user feedback if the active workspace fails to load.

apps/desktop/package.json (1)

48-48: Both dependencies are secure—no active vulnerabilities found.

Security verification confirms:

  • execa@9.6.0: No known vulnerabilities
  • simple-git@3.30.0: All historical CVEs (command injection, RCE) are patched in versions ≤3.16.0, which are superseded by 3.30.0

The additions align well with the PR objective. Ensure your Node.js version is compatible with both packages before deploying.

apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)

41-85: Workspace dropdown behavior looks consistent with the new projects/workspaces flow.

The controlled DropdownMenu state, recents rendering, and mutation disabling logic (using createWorkspace.isPending / openNew.isPending) align with the new TRPC APIs and avoid duplicate submits; nothing blocking here.

apps/desktop/src/lib/trpc/routers/projects/projects.ts (1)

12-16: Projects router logic and API shape look solid; verification confirms simple-git pattern is valid.

getRecents correctly returns projects sorted by lastOpenedAt, and openNew cleanly normalizes the selected folder to a Git root using revparse(['--show-toplevel']) (which is supported in current simple-git releases and still recommended). It then upserts a Project record (updating lastOpenedAt or creating with createdAt) and returns { success, project } as expected by the renderer. No blocking issues.

Comment on lines +31 to +39
const handleOpenNewProject = () => {
openNew.mutate(undefined, {
onSuccess: (result) => {
if (result.success && result.project) {
handleCreateWorkspace(result.project.id);
}
},
});
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Surface openNew failure to the user (non‑git folder case is silent).

openNew can return { success: false, error: "Selected folder is not in a git repository" }, but the onSuccess handler ignores the failure path, so choosing a non‑git folder results in no visible feedback and no workspace being created.

Consider handling the !result.success branch here (e.g., toast/dialog) so the user understands why nothing happened, and optionally closing the dropdown on failure as well.

🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx
around lines 31–39, the onSuccess handler for openNew only handles the success
path and ignores result.success === false, leaving the user without feedback
when they pick a non-git folder; update the handler to detect !result.success
and surface result.error (or a fallback message) to the user via the app's
toast/dialog system, optionally close the dropdown on failure, and also ensure
the mutate onError path surfaces errors similarly so failures are never silent.

@saddlepaddle saddlepaddle force-pushed the create-worktrees-for-workspaces branch from 01db3ba to 01b2171 Compare November 21, 2025 01:02
@saddlepaddle saddlepaddle merged commit 9a46d07 into main Nov 21, 2025
0 of 5 checks passed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)

20-43: Surface openNew / createWorkspace failures to the user (avoid silent no‑ops)

Right now both flows can fail silently from the user’s perspective:

  • handleOpenNewProject only handles the success branch (result.success && result.project); if openNew returns { success: false, error } (e.g., non‑git folder), nothing happens and the user gets no feedback.
  • handleCreateWorkspace only closes the dropdown on success and does not surface errors if the mutation fails.

To avoid “nothing happened” UX, consider:

  • Detecting !result.success in handleOpenNewProject and showing result.error (or a generic fallback) via the app’s toast/dialog mechanism, and optionally closing the dropdown on failure as well.
  • Wiring onError for both openNew and createWorkspace mutations so network or server errors are also communicated to the user.

Also applies to: 60-85

apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (4)

22-26: Verify that project.mainRepoPath is a valid Git repository.

The code assumes project.mainRepoPath is a valid Git repository but doesn't validate this before calling createWorktree at line 37. If the project's mainRepoPath has been moved, deleted, or corrupted since the project was created, this will lead to cryptic Git errors.

Based on past review feedback, add validation using getGitRoot:

 		// Find the project
 		const project = db.data.projects.find((p) => p.id === input.projectId);
 		if (!project) {
 			throw new Error(`Project ${input.projectId} not found`);
 		}
+
+		// Verify the project's mainRepoPath is still a valid git repository
+		try {
+			await getGitRoot(project.mainRepoPath);
+		} catch (error) {
+			throw new Error(`Project repository at ${project.mainRepoPath} is not a valid git repository`);
+		}

28-37: Critical atomicity and branch validation issues.

This code has two critical issues:

  1. No branch uniqueness validation: generateBranchName() at line 28 doesn't check if the branch already exists, risking collision and createWorktree failure.

  2. Atomicity violation: The Git worktree is created at line 37 before the database transaction at line 66. If the database update fails, an orphaned worktree remains on the filesystem.

Apply this comprehensive fix:

 		const branch = generateBranchName();
+
+		// Validate branch uniqueness
+		const git = simpleGit(project.mainRepoPath);
+		const branches = await git.branchLocal();
+		let attempts = 0;
+		let uniqueBranch = branch;
+		while (branches.all.includes(uniqueBranch) && attempts < 10) {
+			uniqueBranch = generateBranchName();
+			attempts++;
+		}
+		if (branches.all.includes(uniqueBranch)) {
+			throw new Error("Failed to generate unique branch name after 10 attempts");
+		}
+		const branch = uniqueBranch;
 
 		const worktreePath = join(
 			project.mainRepoPath,
 			".superset",
 			branch,
 		);
 
-		// Create git worktree
-		await createWorktree(project.mainRepoPath, branch, worktreePath);
-
 		// Create worktree record
 		const worktree = {
 			id: nanoid(),
 			projectId: input.projectId,
 			path: worktreePath,
 			branch,
 			createdAt: Date.now(),
 		};
 
 		// ... workspace creation ...
 
-		// Save to database
-		await db.update((data) => {
-			data.worktrees.push(worktree);
-			data.workspaces.push(workspace);
-			data.settings.lastActiveWorkspaceId = workspace.id;
-
-			// Update project lastOpenedAt
-			const p = data.projects.find((p) => p.id === input.projectId);
-			if (p) {
-				p.lastOpenedAt = Date.now();
-			}
-		});
+		// Atomic operation: try to create worktree and update DB together
+		try {
+			// Save to database first
+			await db.update((data) => {
+				data.worktrees.push(worktree);
+				data.workspaces.push(workspace);
+				data.settings.lastActiveWorkspaceId = workspace.id;
+
+				// Update project lastOpenedAt
+				const p = data.projects.find((p) => p.id === input.projectId);
+				if (p) {
+					p.lastOpenedAt = Date.now();
+				}
+			});
+
+			// Create git worktree after DB commit
+			await createWorktree(project.mainRepoPath, branch, worktreePath);
+		} catch (error) {
+			// Rollback DB if worktree creation failed
+			await db.update((data) => {
+				data.worktrees = data.worktrees.filter((wt) => wt.id !== worktree.id);
+				data.workspaces = data.workspaces.filter((w) => w.id !== workspace.id);
+			});
+			throw error;
+		}
 
 		return workspace;

182-189: Git worktree removed with --force flag, risking data loss.

Line 184 calls removeWorktree, which uses git worktree remove --force (per the relevant code snippet). This forcibly deletes the worktree even with uncommitted changes, potentially causing data loss for users who haven't committed their work.

Based on past review feedback, add uncommitted changes validation:

 		// Remove git worktree if it exists
 		if (worktree && project) {
 			try {
+				// Check for uncommitted changes first
+				const git = simpleGit(worktree.path);
+				const status = await git.status();
+				if (!status.isClean()) {
+					throw new Error(
+						`Cannot delete workspace: worktree has uncommitted changes at ${worktree.path}`
+					);
+				}
 				await removeWorktree(project.mainRepoPath, worktree.path);
 			} catch (error) {
 				console.error("Failed to remove worktree:", error);
-				// Continue with database cleanup even if git operation fails
+				throw error; // Don't delete from database if worktree can't be removed
 			}
 		}

Alternatively, add a force: z.boolean().optional() flag to the input schema to let users explicitly opt-in to force deletion.


197-201: Inconsistent error handling: Git operation can fail but DB cleanup continues.

Lines 186-188 catch and log Git worktree removal errors but continue with database cleanup. This creates an inconsistency where the database shows the workspace as deleted, but the Git worktree still exists on the filesystem.

This contradicts the comment at line 187 which says "Continue with database cleanup even if git operation fails" but actually makes the problem worse—users will lose the database reference to the worktree, making manual cleanup harder.

Consider one of these approaches:

Option 1 (Recommended): Fail fast and preserve consistency

 			} catch (error) {
 				console.error("Failed to remove worktree:", error);
-				// Continue with database cleanup even if git operation fails
+				throw error; // Preserve DB reference if git cleanup fails
 			}

Option 2: Add a "dangling worktree" flag in the database
If the Git operation fails, mark the worktree as dangling in the database rather than removing it, allowing users to retry cleanup later.

🧹 Nitpick comments (1)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx (1)

33-45: Consider guarding the width calc for 0 workspaces and documenting ADD_BUTTON_WIDTH

The new layout and no-drag usage around WorkspaceItem and WorkspaceDropdown look good. A couple of small robustness/readability tweaks you might consider:

  • In updateWorkspaceWidth, when workspaces.length === 0 the expression availableWidth / workspaces.length becomes Infinity and then gets clamped to MAX_WORKSPACE_WIDTH. It works today but is non-obvious; an early return like
if (!workspaces.length) {
  setWorkspaceWidth(MAX_WORKSPACE_WIDTH);
  return;
}

would make the intent clearer.

  • ADD_BUTTON_WIDTH is now effectively describing the width of WorkspaceDropdown’s trigger. A brief comment (or deriving it from an actual measured ref if this ever drifts) would help keep the width logic in sync with future design tweaks.

Also applies to: 70-71, 88-88, 120-121

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01db3ba and 01b2171.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (29)
  • apps/desktop/package.json (2 hunks)
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts (1 hunks)
  • apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1 hunks)
  • apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (5 hunks)
  • apps/desktop/src/main/lib/db/schemas.ts (1 hunks)
  • apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts (4 hunks)
  • apps/desktop/src/renderer/react-query/projects/index.ts (1 hunks)
  • apps/desktop/src/renderer/react-query/projects/useOpenNew.ts (1 hunks)
  • apps/desktop/src/renderer/react-query/projects/useOpenProject.ts (0 hunks)
  • apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts (0 hunks)
  • apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx (3 hunks)
  • apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts (0 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx (2 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx (0 hunks)
  • apps/desktop/src/renderer/stores/index.ts (0 hunks)
  • apps/desktop/src/renderer/stores/workspaces.ts (0 hunks)
💤 Files with no reviewable changes (14)
  • apps/desktop/src/renderer/stores/index.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx
  • apps/desktop/src/renderer/react-query/projects/useOpenProject.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts
  • apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts
  • apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx
  • apps/desktop/src/renderer/stores/workspaces.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/desktop/package.json
  • apps/desktop/src/renderer/react-query/projects/useOpenNew.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx
  • apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
  • apps/desktop/src/renderer/react-query/projects/index.ts
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx
🧰 Additional context used
🧬 Code graph analysis (5)
apps/desktop/src/lib/trpc/routers/projects/projects.ts (3)
apps/desktop/src/main/lib/db/schemas.ts (1)
  • Project (9-15)
apps/desktop/src/main/lib/db/index.ts (1)
  • db (19-26)
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1)
  • getGitRoot (83-91)
apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (2)
apps/desktop/src/main/lib/db/index.ts (1)
  • db (19-26)
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (3)
  • generateBranchName (5-47)
  • createWorktree (49-66)
  • removeWorktree (68-81)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)
apps/desktop/src/renderer/react-query/projects/useOpenNew.ts (1)
  • useOpenNew (7-22)
apps/desktop/src/main/lib/db/schemas.ts (1)
apps/desktop/src/shared/types.ts (3)
  • Worktree (58-68)
  • Workspace (70-82)
  • Tab (44-56)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx (1)
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx (1)
  • WorkspaceDropdown (17-90)
🔇 Additional comments (14)
apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx (1)

20-24: Verify WindowControls remain non-draggable after removing no-drag from wrapper

Removing no-drag from the right container makes that whole area part of the draggable region unless children explicitly apply no-drag. Please double-check that the root element inside WindowControls has no-drag (or equivalent -webkit-app-region: no-drag) so the window buttons stay fully clickable on non‑Mac platforms.

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx (1)

7-8: Switch to TRPC-sourced activeWorkspace looks consistent with existing tab logic

Using trpc.workspaces.getActive.useQuery() and deriving activeWorkspaceId from the result keeps the tabToRender memoization and early-return behavior intact. The component still safely handles “no active workspace” and missing-tab cases, so this migration looks good.

Also applies to: 15-17, 20-35

apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts (1)

18-20: TRPC-based workspace data + mutation wiring keeps shortcut behavior intact

Using trpc.workspaces.getAll/getActive plus useSetActiveWorkspace().mutate({ id }) preserves the previous semantics for prev/next workspace switching and tab navigation: handlers are safely no‑ops without an active workspace, index helpers guard unknown IDs, and the keyboard listener is correctly registered/cleaned up inside the effect. This refactor looks solid.

Also applies to: 35-38, 47-48, 60-75, 149-161

apps/desktop/src/lib/trpc/routers/projects/projects.ts (1)

12-16: LGTM!

The getRecents query correctly returns projects sorted by most recent access. The use of .slice() prevents mutation of the original array.

apps/desktop/src/main/lib/db/schemas.ts (5)

6-15: LGTM!

The Project interface is well-documented and uses appropriate types. The use of nanoid for IDs and timestamps for temporal tracking is consistent with the codebase patterns.


17-26: LGTM!

The Worktree interface correctly models a Git worktree with references to its parent project. The comment clarifying that branch is the "source of truth for git operations" is helpful for maintainers.


28-40: LGTM!

The Workspace interface correctly establishes a 1:1 relationship with Worktree while maintaining its own UI-specific properties like name and order. The inclusion of temporal tracking fields is appropriate.


55-70: LGTM!

The Database interface and defaultDatabase initialization correctly reflect the new data model with projects and worktrees arrays. The removal of recentProjects aligns with the PR's objective to replace recents with a projects/worktrees model.


42-49: I need to verify whether the Tab interface in schemas.ts is actually exported or used elsewhere:

Based on my verification of the script output, the review comment identifying a conflict between two Tab interface definitions is incorrect. Here's why:

In TypeScript, two different modules will never contribute names to the same scope. The two Tab interfaces exist in completely separate ES6 modules:

  1. apps/desktop/src/shared/types.ts - The canonical Tab interface used throughout the application (imported by renderer code, shared IPC channels, and stores)
  2. apps/desktop/src/main/lib/db/schemas.ts - A database schema Tab interface that is only defined locally and not imported anywhere in the codebase

The search results show zero imports of Tab from schemas.ts. The database schema's Tab interface is isolated to its own module and does not create type confusion or runtime errors because they exist in separate scopes with no cross-references.

This is proper architectural design: database schemas are separated from runtime application types. No changes are needed.


Likely an incorrect or invalid review comment.

apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts (5)

85-93: LGTM!

The get query correctly throws an error when a workspace is not found, providing clear error messaging. This is appropriate for cases where the caller expects the workspace to exist.


109-126: LGTM!

The getActive query handles both valid states (no active workspace set → returns null) and invalid states (active ID exists but workspace missing → throws error). The distinction between these cases is well-documented and appropriate.


132-159: LGTM!

The update mutation correctly implements partial updates, properly validates workspace existence, and maintains temporal tracking with updatedAt and lastOpenedAt timestamps.


219-234: LGTM!

The setActive mutation correctly validates workspace existence before setting it as active, and properly updates both the active workspace ID and temporal tracking fields.


239-269: LGTM!

The reorder mutation correctly implements drag-and-drop reordering by splicing the array and updating order fields. The use of .slice() prevents mutation of the source array during sorting.

Comment on lines +42 to +65
let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);

if (project) {
await db.update((data) => {
const recent = data.recentProjects.find((p) => p.path === path);
if (recent) {
recent.lastOpenedAt = Date.now();
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});

return {
success: true as const,
path,
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
}),
getRecents: publicProcedure.query((): RecentProject[] => {
return db.data.recentProjects
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),
removeRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {

await db.update((data) => {
data.recentProjects = data.recentProjects.filter(
(p) => p.path !== input.path,
);
data.projects.push(project!);
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential race condition between project lookup and database update.

The code finds a project at lines 42-44, then later updates it inside db.update() at lines 47-52 by searching again by project?.id. Between the initial lookup and the database update, the project could theoretically be deleted by another operation, making the second lookup fail silently.

Additionally, at line 63, the non-null assertion project! assumes the if/else logic is exhaustive, but if there's a logic error, this could cause a runtime error.

Apply this diff to make the code more defensive:

 		if (project) {
 			await db.update((data) => {
 				const p = data.projects.find((p) => p.id === project?.id);
 				if (p) {
 					p.lastOpenedAt = Date.now();
 				}
 			});
+			// Refresh project reference after update
+			project = db.data.projects.find((p) => p.id === project?.id) ?? project;
 		} else {
 			project = {
 				id: nanoid(),
 				mainRepoPath,
 				name,
 				lastOpenedAt: Date.now(),
 				createdAt: Date.now(),
 			};
 
 			await db.update((data) => {
 				data.projects.push(project!);
 			});
 		}
 
 		return {
 			success: true as const,
-			project,
+			project: project!,
 		};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);
if (project) {
await db.update((data) => {
const recent = data.recentProjects.find((p) => p.path === path);
if (recent) {
recent.lastOpenedAt = Date.now();
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});
return {
success: true as const,
path,
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
}),
getRecents: publicProcedure.query((): RecentProject[] => {
return db.data.recentProjects
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),
removeRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
await db.update((data) => {
data.recentProjects = data.recentProjects.filter(
(p) => p.path !== input.path,
);
data.projects.push(project!);
});
}
let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);
if (project) {
await db.update((data) => {
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});
// Refresh project reference after update
project = db.data.projects.find((p) => p.id === project?.id) ?? project;
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
await db.update((data) => {
data.projects.push(project!);
});
}
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/projects/projects.ts around lines 42 to 65,
avoid the race between the initial find and the later db.update and remove the
unsafe non-null assertion by making the create-or-update atomic inside
db.update: call db.update once and inside its callback search for the project by
mainRepoPath; if found set its lastOpenedAt = Date.now(); if not, construct a
new project object (with id = nanoid(), mainRepoPath, name, lastOpenedAt and
createdAt timestamps) and push it into data.projects; this ensures the lookup
and mutation happen under the same lock and eliminates the project! non-null
assertion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant