Skip to content

feat(workspace): auto-unlink linked branches that no longer exist#3180

Merged
charlesvien merged 2 commits into
mainfrom
posthog-code/auto-unlink-stale-branches
Jul 6, 2026
Merged

feat(workspace): auto-unlink linked branches that no longer exist#3180
charlesvien merged 2 commits into
mainfrom
posthog-code/auto-unlink-stale-branches

Conversation

@adboio

@adboio adboio commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Problem

Nothing ever cleans up a task's linkedBranch when its branch is deleted — the usual end state after a PR merge. Returning to a finished task and asking a follow-up from main triggers 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

getWorkspace now self-heals stale links:

  • New anyBranchRefExists query 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.
  • When the linked branch mismatches the current branch and no ref remains, the link is dropped via the existing unlinkBranch, with a new "auto" source on the Branch unlinked analytics event.
  • Being on the linked branch proves it exists, so only mismatched reads pay the extra ref lookup; an in-flight guard keeps concurrent reads from stacking git calls, and the linked state is re-read after the git check to avoid racing a concurrent re-link.

Also makes the mock workspace repository's updateLinkedBranch persist (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: anyBranchRefExists local ref / remote-tracking ref on a non-origin remote / missing branch / tag-only name (plumbing-based fixture, no git commit needed).

Stack:

  1. → this PR — auto-unlink stale linked branches
  2. feat(workspace): replace branch mismatch modal with non-blocking banner #3183 — replace the modal with a non-blocking pre-send banner (also deletes the dialog whose Switch action was broken; that fix was fix(workspace): make branch mismatch "Switch branch" actually send the message #3179, closed unmerged in favor of this stack)

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit ed27d0a.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "feat(workspace): auto-unlink linked bran..." | Re-trigger Greptile

Comment on lines +299 to +324
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();
});

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.

P2 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.

Suggested change
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!

@adboio adboio changed the base branch from posthog-code/fix-branch-switch-send to main July 6, 2026 15:12
@trunk-io

trunk-io Bot commented Jul 6, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

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

adboio added 2 commits July 6, 2026 11:13
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
@charlesvien charlesvien merged commit a2f242d into main Jul 6, 2026
24 checks passed
@charlesvien charlesvien deleted the posthog-code/auto-unlink-stale-branches branch July 6, 2026 21:47
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.

2 participants