Skip to content

fix(worktree): reuse an existing worktree instead of duplicating it#2652

Draft
haacked wants to merge 3 commits into
haacked/refactor-worktree-pathsfrom
posthog-code/worktree-reuse-existing
Draft

fix(worktree): reuse an existing worktree instead of duplicating it#2652
haacked wants to merge 3 commits into
haacked/refactor-worktree-pathsfrom
posthog-code/worktree-reuse-existing

Conversation

@haacked

@haacked haacked commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Problem

Starting a worktree task on a branch that already has a worktree didn't error. It silently went wrong. git worktree add fails, the code catches it, and creates a second, detached worktree at the branch's commit. You end up with a junk duplicate that isn't even on the branch.

Fix

Handle the already-has-a-worktree case correctly instead of duplicating, for a worktree in any location — including one you created by hand outside PostHog's managed base path. When the branch already has a worktree, we look at whether a task is already using it:

  • Unused worktree (no task owns it): offer to reuse it — the scenario this unlocks. A confirmation dialog ("Worktree already exists — use it?") adopts that worktree for the task (registers the association, no git worktree add); cancelling aborts before any task is created. The dialog warns that deleting the task later removes the worktree and any uncommitted work in it, even though it existed beforehand.
  • Owned worktree (a task already has it): fail clearly — show an error naming the task that holds the worktree and abort, so you can open that task and keep working there. This is where the silent duplicate used to happen.

Either way, every worktree stays associated with at most one task — the same as before this change.

How it works:

  • workspace.checkWorktreeBranch reports existingWorktreePath only for an unused worktree on the branch, and existingWorktreeTaskId when a task already holds it (mutually exclusive).
  • A reuseExistingWorktree opt-in threads from the UI through the saga to the workspace server; doCreateWorkspace re-checks occupancy before reusing and fails the step if the worktree was claimed between preflight and create.
  • Discovery uses listLinkedWorktrees — every linked worktree for the repo, in any location (only the main repo is excluded). It is not limited to the managed base path. The stored path column is the source of truth for a task's worktree (since the path refactor in refactor(worktree): resolve worktree path from stored value, not derived from name #2709), so an externally-created worktree round-trips just like a managed one. listTwigWorktrees stays in place for the managed-only worktree listing (listGitWorktrees).
  • The reused worktree's name is a cosmetic label only; it is recovered layout-aware for managed worktrees and degrades to the path's final segment for an external one.

The unused-worktree prompt takes priority over the remote-only-branch prompt (a branch with a worktree also exists locally).

Screenshot 2026-06-16 at 4 54 35 PM

Note

Stacked on #2709 (resolve worktree path from the stored value, not derived from name) — that refactor makes the stored path authoritative, which is what lets a worktree outside the base path attach to a task. Base/merge that PR first; this PR targets its branch.

How did you test this?

  • pnpm --filter @posthog/workspace-server test for the touched suites: workspace.test and worktree-query.test — 32 passing, including a new checkWorktreeBranch case that offers an out-of-base-path worktree for reuse and new listLinkedWorktrees coverage (keeps worktrees in any location, excludes the main repo).
  • typecheck clean across all changed packages (workspace-server / host-router / ui / core / shared).
  • Biome check clean on all changed files.
  • Unit tests for the confirmation store (existingWorktreeConfirmStore.test.ts, 5 cases).

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code from a Slack thread

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit c92c7c2.

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (1)

  1. packages/workspace-server/src/services/workspace/workspace.ts, line 763-780 (link)

    P1 Reused worktree deleted when sharing task is removed

    cleanupWorktree is called unconditionally for any worktree-mode task deletion. Once two tasks share the same worktreePath (the reuse scenario this PR introduces), deleting either task calls deleteGitWorktree on that path, immediately breaking the other task's workspace.

    The existing guard at line 771–780 prevents cleanupRepoWorktreeFolder for folders with other active workspaces, but cleanupWorktree runs before that guard and has no equivalent check. A fix would skip cleanupWorktree when getAllTaskAssociations() reveals another active worktree task pointing to the same resolved path.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/workspace-server/src/services/workspace/workspace.ts
    Line: 763-780
    
    Comment:
    **Reused worktree deleted when sharing task is removed**
    
    `cleanupWorktree` is called unconditionally for any worktree-mode task deletion. Once two tasks share the same `worktreePath` (the reuse scenario this PR introduces), deleting *either* task calls `deleteGitWorktree` on that path, immediately breaking the other task's workspace.
    
    The existing guard at line 771–780 prevents `cleanupRepoWorktreeFolder` for folders with other active workspaces, but `cleanupWorktree` runs before that guard and has no equivalent check. A fix would skip `cleanupWorktree` when `getAllTaskAssociations()` reveals another active worktree task pointing to the same resolved path.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/workspace-server/src/services/workspace/workspace.ts:763-780
**Reused worktree deleted when sharing task is removed**

`cleanupWorktree` is called unconditionally for any worktree-mode task deletion. Once two tasks share the same `worktreePath` (the reuse scenario this PR introduces), deleting *either* task calls `deleteGitWorktree` on that path, immediately breaking the other task's workspace.

The existing guard at line 771–780 prevents `cleanupRepoWorktreeFolder` for folders with other active workspaces, but `cleanupWorktree` runs before that guard and has no equivalent check. A fix would skip `cleanupWorktree` when `getAllTaskAssociations()` reveals another active worktree task pointing to the same resolved path.

### Issue 2 of 2
packages/workspace-server/src/services/workspace/workspace.ts:479-485
**Wrong `worktreeName` for legacy-layout worktrees**

`path.basename(path.dirname(match.worktreePath))` extracts the correct name only for the *new* layout `<base>/<name>/<repo>`. For *legacy* layout `<base>/<repo>/<name>` (still handled by `deriveWorktreePath`) this expression yields the repository name, not the worktree name.

That incorrect name is stored via `worktreeRepo.create({ name: worktree.worktreeName })` and is later fed back into `deriveWorktreePath(folderPath, assoc.worktree)`, which would reconstruct the path as `<base>/<repoName>/<repoName>` — a path that does not exist — causing every subsequent `verifyWorkspaceExists`, `getWorkspace`, and `getWorkspaceEnv` call to fail for that task.

A reliable extraction: if the final component of `match.worktreePath` equals `path.basename(mainRepoPath)` it's the new layout and the name is `path.basename(path.dirname(…))`; otherwise it's the legacy layout and the name is `path.basename(match.worktreePath)`.

Reviews (1): Last reviewed commit: "feat(worktree): reuse an existing worktr..." | Re-trigger Greptile

Comment on lines +479 to +485
return {
worktreePath: match.worktreePath,
worktreeName: path.basename(path.dirname(match.worktreePath)),
branchName: branch,
baseBranch: branch,
createdAt: new Date().toISOString(),
};

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.

P1 Wrong worktreeName for legacy-layout worktrees

path.basename(path.dirname(match.worktreePath)) extracts the correct name only for the new layout <base>/<name>/<repo>. For legacy layout <base>/<repo>/<name> (still handled by deriveWorktreePath) this expression yields the repository name, not the worktree name.

That incorrect name is stored via worktreeRepo.create({ name: worktree.worktreeName }) and is later fed back into deriveWorktreePath(folderPath, assoc.worktree), which would reconstruct the path as <base>/<repoName>/<repoName> — a path that does not exist — causing every subsequent verifyWorkspaceExists, getWorkspace, and getWorkspaceEnv call to fail for that task.

A reliable extraction: if the final component of match.worktreePath equals path.basename(mainRepoPath) it's the new layout and the name is path.basename(path.dirname(…)); otherwise it's the legacy layout and the name is path.basename(match.worktreePath).

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/workspace-server/src/services/workspace/workspace.ts
Line: 479-485

Comment:
**Wrong `worktreeName` for legacy-layout worktrees**

`path.basename(path.dirname(match.worktreePath))` extracts the correct name only for the *new* layout `<base>/<name>/<repo>`. For *legacy* layout `<base>/<repo>/<name>` (still handled by `deriveWorktreePath`) this expression yields the repository name, not the worktree name.

That incorrect name is stored via `worktreeRepo.create({ name: worktree.worktreeName })` and is later fed back into `deriveWorktreePath(folderPath, assoc.worktree)`, which would reconstruct the path as `<base>/<repoName>/<repoName>` — a path that does not exist — causing every subsequent `verifyWorkspaceExists`, `getWorkspace`, and `getWorkspaceEnv` call to fail for that task.

A reliable extraction: if the final component of `match.worktreePath` equals `path.basename(mainRepoPath)` it's the new layout and the name is `path.basename(path.dirname(…))`; otherwise it's the legacy layout and the name is `path.basename(match.worktreePath)`.

How can I resolve this? If you propose a fix, please make it concise.

@haacked haacked force-pushed the posthog-code/worktree-remote-branch-checkout branch from 0f66f43 to 7bfb8e9 Compare June 15, 2026 17:11
Base automatically changed from posthog-code/worktree-remote-branch-checkout to main June 16, 2026 05:27
@haacked haacked force-pushed the posthog-code/worktree-reuse-existing branch 2 times, most recently from 4086085 to a70b01e Compare June 16, 2026 15:41
@haacked haacked changed the title feat(worktree): reuse an existing worktree for the branch fix(worktree): reuse an existing worktree instead of duplicating it Jun 16, 2026
@haacked haacked force-pushed the posthog-code/worktree-reuse-existing branch 2 times, most recently from 25c8d6a to be1a58a Compare June 16, 2026 17:31
haacked and others added 2 commits June 16, 2026 16:06
When starting a worktree task on a branch that already has a worktree
checked out, confirm with the user and reuse that worktree for the task
instead of silently creating a second, detached worktree at the same commit.

- checkWorktreeBranch now reports an existingWorktreePath when a PostHog-managed
  worktree (under the worktree base path) is already on the branch.
- The task-creation preflight prompts on that and passes reuseExistingWorktree
  through to the server, which registers the task against the existing worktree
  rather than running `git worktree add`.

Stacked on the remote-only-branch checkout change; base that PR first.

Generated-By: PostHog Code
Task-Id: 1c8ffc23-3673-4b14-b5b7-50637fca8fb2
@haacked haacked force-pushed the posthog-code/worktree-reuse-existing branch from be1a58a to f73ce30 Compare June 16, 2026 23:10
@haacked haacked changed the base branch from main to haacked/refactor-worktree-paths June 16, 2026 23:10
Discovery for worktree reuse was limited to worktrees under the managed
base path, so starting a task on a branch already checked out in a
hand-created worktree elsewhere fell through to a detached duplicate.

Add listLinkedWorktrees (every linked worktree, any location) and use it
in findExistingWorktreeForBranch. The stored path column is authoritative
since the path refactor, so an external worktree round-trips like a
managed one. listTwigWorktrees stays for managed-only listing.
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