feat(workspace): auto-unlink linked branches that no longer exist#3180
Conversation
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
|
Reviews (1): Last reviewed commit: "feat(workspace): auto-unlink linked bran..." | Re-trigger Greptile |
| it("unlinks a mismatched branch that has no refs left", async () => { | ||
| seedLinkedTask("feat/gone"); | ||
| vi.mocked(anyBranchRefExists).mockResolvedValue(false); | ||
| const emitted = vi.fn(); | ||
| service.on(WorkspaceServiceEvent.LinkedBranchChanged, emitted); | ||
|
|
||
| const workspace = await service.getWorkspace("t1"); | ||
|
|
||
| expect(workspace?.linkedBranch).toBeNull(); | ||
| expect(emitted).toHaveBeenCalledWith({ taskId: "t1", branchName: null }); | ||
| expect(mocks.analytics.track).toHaveBeenCalledWith( | ||
| ANALYTICS_EVENTS.BRANCH_UNLINKED, | ||
| expect.objectContaining({ task_id: "t1", source: "auto" }), | ||
| ); | ||
| expect(mocks.workspaceRepo.findByTaskId("t1")?.linkedBranch).toBeNull(); | ||
| }); | ||
|
|
||
| it("keeps a mismatched branch that still has a ref", async () => { | ||
| seedLinkedTask("feat/alive"); | ||
| vi.mocked(anyBranchRefExists).mockResolvedValue(true); | ||
|
|
||
| const workspace = await service.getWorkspace("t1"); | ||
|
|
||
| expect(workspace?.linkedBranch).toBe("feat/alive"); | ||
| expect(mocks.analytics.track).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The "unlinks when no refs left" and "keeps when a ref exists" cases follow the same structural pattern — seed a task, mock
anyBranchRefExists, call getWorkspace, assert linkedBranch — and the team rule is to always prefer parameterised tests. The extra assertions on the false case (event, analytics, DB persistence) can live in their own focused test so the core "refExists → linkedBranch outcome" logic is expressed once.
| it("unlinks a mismatched branch that has no refs left", async () => { | |
| seedLinkedTask("feat/gone"); | |
| vi.mocked(anyBranchRefExists).mockResolvedValue(false); | |
| const emitted = vi.fn(); | |
| service.on(WorkspaceServiceEvent.LinkedBranchChanged, emitted); | |
| const workspace = await service.getWorkspace("t1"); | |
| expect(workspace?.linkedBranch).toBeNull(); | |
| expect(emitted).toHaveBeenCalledWith({ taskId: "t1", branchName: null }); | |
| expect(mocks.analytics.track).toHaveBeenCalledWith( | |
| ANALYTICS_EVENTS.BRANCH_UNLINKED, | |
| expect.objectContaining({ task_id: "t1", source: "auto" }), | |
| ); | |
| expect(mocks.workspaceRepo.findByTaskId("t1")?.linkedBranch).toBeNull(); | |
| }); | |
| it("keeps a mismatched branch that still has a ref", async () => { | |
| seedLinkedTask("feat/alive"); | |
| vi.mocked(anyBranchRefExists).mockResolvedValue(true); | |
| const workspace = await service.getWorkspace("t1"); | |
| expect(workspace?.linkedBranch).toBe("feat/alive"); | |
| expect(mocks.analytics.track).not.toHaveBeenCalled(); | |
| }); | |
| it.each([ | |
| { branch: "feat/gone", refExists: false, expected: null }, | |
| { branch: "feat/alive", refExists: true, expected: "feat/alive" }, | |
| ])( | |
| "linkedBranch is $expected when refExists=$refExists", | |
| async ({ branch, refExists, expected }) => { | |
| seedLinkedTask(branch); | |
| vi.mocked(anyBranchRefExists).mockResolvedValue(refExists); | |
| const workspace = await service.getWorkspace("t1"); | |
| expect(workspace?.linkedBranch).toBe(expected); | |
| }, | |
| ); | |
| it("emits event, tracks analytics, and persists null when auto-unlinking", async () => { | |
| seedLinkedTask("feat/gone"); | |
| vi.mocked(anyBranchRefExists).mockResolvedValue(false); | |
| const emitted = vi.fn(); | |
| service.on(WorkspaceServiceEvent.LinkedBranchChanged, emitted); | |
| await service.getWorkspace("t1"); | |
| expect(emitted).toHaveBeenCalledWith({ taskId: "t1", branchName: null }); | |
| expect(mocks.analytics.track).toHaveBeenCalledWith( | |
| ANALYTICS_EVENTS.BRANCH_UNLINKED, | |
| expect.objectContaining({ task_id: "t1", source: "auto" }), | |
| ); | |
| expect(mocks.workspaceRepo.findByTaskId("t1")?.linkedBranch).toBeNull(); | |
| }); |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
A task's linkedBranch is never cleaned up when its branch is deleted (the usual end state after a PR merge), so returning to a finished task and asking a follow-up from main nags with a wrong-branch warning on every send — a link like that can never match the working tree again. Production data suggests this drives the ~59% "continue anyway" rate on the mismatch dialog. getWorkspace now self-heals: when the linked branch mismatches the current branch, a new anyBranchRefExists git query checks for the branch as a local head or remote-tracking ref on any remote (no network; tags don't count). If nothing remains, the link is dropped with source "auto" on the existing Branch unlinked analytics event. Being on the linked branch proves it exists, so only mismatched reads pay the extra ref lookup, and an in-flight guard keeps concurrent reads from stacking git calls. Also makes the mock workspace repository's updateLinkedBranch persist instead of being a no-op, so service tests can exercise link state. Generated-By: PostHog Code Task-Id: adec8438-4ca0-42f0-9d9d-948de60c3b23
Greptile review feedback: the unlink/keep cases share one shape (seed, mock anyBranchRefExists, read, assert linkedBranch), so they collapse into an it.each on refExists -> outcome; the unlink side effects (event, analytics, persisted null) move to their own focused test. Generated-By: PostHog Code Task-Id: adec8438-4ca0-42f0-9d9d-948de60c3b23
287ea33 to
ed27d0a
Compare
Problem
Nothing ever cleans up a task's
linkedBranchwhen its branch is deleted — the usual end state after a PR merge. Returning to a finished task and asking a follow-up frommaintriggers the wrong-branch warning on every send, about a branch that can never be checked out again. Production data suggests this stale-link case drives much of the ~59% "continue anyway" rate on the mismatch dialog (1,249 dialogs in the last 60 days).Change
getWorkspacenow self-heals stale links:anyBranchRefExistsquery in@posthog/git: true when the branch exists as a local head or a remote-tracking ref on any remote (for-each-ref refs/heads/<b> refs/remotes/*/<b>). No network — a deleted remote branch stops counting once the user's refs know about it; tags and raw commit-ishes don't count.unlinkBranch, with a new"auto"source on theBranch unlinkedanalytics event.Also makes the mock workspace repository's
updateLinkedBranchpersist (was a no-op) so service tests can exercise link state.Tests
workspace.test.ts: parameterised refExists → linkedBranch outcome, focused side-effects test (event + analytics + persisted null), skips the check when on the linked branch, keeps the link if the git check throws.queries.test.ts:anyBranchRefExistslocal ref / remote-tracking ref on a non-originremote / missing branch / tag-only name (plumbing-based fixture, nogit commitneeded).Stack:
🤖 Generated with Claude Code