From 1f472f0f17ce2e0ec0dddfdb3d1ff044d38e726f Mon Sep 17 00:00:00 2001 From: Vladislav Nikonorov Date: Fri, 6 Nov 2026 11:51:17 +0100 Subject: [PATCH 1/4] fix(git): stop new threads inheriting historical branch PRs Filter completed branch PRs against each thread creation time during automatic discovery and restoration. Keep historical PRs accessible through existing or explicit thread links, and only use open PRs as the raw Git status display fallback. --- .../src/orchestration-v2/ProjectionStore.ts | 2 + .../ThreadPullRequestService.test.ts | 127 ++++++++++++++++++ .../ThreadPullRequestService.ts | 23 +++- .../BranchToolbarBranchSelector.tsx | 9 +- .../components/ThreadStatusIndicators.test.ts | 16 +++ .../src/components/ThreadStatusIndicators.tsx | 13 +- 6 files changed, 175 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2f8c972e7ded..9cd547128e9e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1147,6 +1147,7 @@ const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( id, projectId, lineage, + createdAt, branch, worktreePath, archivedAt, @@ -1159,6 +1160,7 @@ const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( id, projectId, lineage, + createdAt, branch, worktreePath, archivedAt, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 423d1cddd943..52eeefc09208 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -423,6 +423,130 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect.each(["open", "merged", "closed"] as const)( + "associates a $state branch PR only with threads that predate its completion", + (state) => + Effect.scoped( + Effect.gen(function* () { + const reference = { + projectId: ProjectId.make("project-1"), + repository: "acme/project", + number: 42, + url: "https://github.com/acme/project/pull/42", + }; + const terminalAt = "2026-09-19T00:00:00.000Z"; + const detected = { + number: 42, + url: reference.url, + title: "Previous staging work", + baseRef: "main", + headRef: "staging", + state, + updatedAt: terminalAt, + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, + repositoryKey: "github.com/acme/project", + }; + const threads = [ + { ...threadShell("older"), createdAt: DateTime.makeUnsafe("2026-09-18T00:00:00Z") }, + { ...threadShell("same-time"), createdAt: DateTime.makeUnsafe(terminalAt) }, + threadShell("new-1"), + threadShell("new-2"), + { ...threadShell("saved"), branchPullRequest: reference }, + { ...threadShell("explicit"), linkedPullRequest: reference }, + ].map((thread) => ({ ...thread, branch: "staging" })); + const project: OrchestrationProjectShell = { + id: reference.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const commands: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getPullRequestSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ snapshotSequence: 1, threads }), + ), + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: 2, storedEvents: [] }; + }), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: () => Effect.succeed(detected), + }), + Layer.mock(PullRequestService.PullRequestService)({ + summary: () => + Effect.succeed({ + ...detected, + ...reference, + provider: SourceControlProviderKind.make("github"), + baseBranch: detected.baseRef, + headBranch: detected.headRef, + }), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), + ), + FileSystem.layerNoop({}), + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + expect( + commands.map((command) => { + if (command.type !== "thread.pull-request.sync") throw new Error(command.type); + expect(command).not.toHaveProperty("linkedPullRequest"); + return [command.threadId, command.branchPullRequest]; + }), + ).toEqual( + state === "open" + ? ["older", "same-time", "new-1", "new-2", "explicit"].map((id) => [id, reference]) + : [ + ["older", reference], + ["same-time", reference], + ["saved", null], + ], + ); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, @@ -457,6 +581,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { const activation = yield* Deferred.make(); const sweepStarted = yield* Deferred.make(); const commands: Array = []; + let snapshotThread: unknown; const dependencies = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ streamDomainEvents: Stream.never, @@ -484,6 +609,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { ]) yield* sql.unsafe(`DROP TABLE ${table}`); const snapshot = yield* store.getPullRequestSnapshot(options); + snapshotThread = snapshot.threads[0]; yield* Deferred.succeed(sweepStarted, undefined); return snapshot; }).pipe( @@ -548,6 +674,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { yield* service.drain; }).pipe(Effect.provide(dependencies)); + expect(snapshotThread).toHaveProperty("createdAt", NOW); if (sameBranch) { expect(commands).toEqual([]); } else { diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 1cb01acd4ed9..b653c7a5096d 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -3,6 +3,7 @@ import { type OrchestrationProjectShell, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, + type PullRequestSummary, type ThreadId, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; @@ -10,6 +11,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -48,6 +50,19 @@ function samePullRequest( ); } +function pullRequestPredatesThread( + pullRequest: Pick, + createdAt: DateTime.Utc, +): boolean { + const terminalAt = + pullRequest.state === "merged" + ? pullRequest.mergedAt + : pullRequest.state === "closed" + ? pullRequest.closedAt + : null; + return terminalAt != null && Date.parse(terminalAt) < DateTime.toEpochMillis(createdAt); +} + function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: OrchestrationProjectShell, @@ -210,7 +225,10 @@ export const make = Effect.gen(function* () { const plans = yield* Effect.forEach(group, (thread) => Effect.gen(function* () { - let branchPullRequest = detectedReference; + let branchPullRequest = + detected !== null && pullRequestPredatesThread(detected, thread.createdAt) + ? null + : detectedReference; if ( branchPullRequest === null && thread.branch !== null && @@ -222,7 +240,8 @@ export const make = Effect.gen(function* () { }); if ( previous.headBranch === thread.branch && - (previous.state === "merged" || previous.state === "closed") + (previous.state === "merged" || previous.state === "closed") && + !pullRequestPredatesThread(previous, thread.createdAt) ) { branchPullRequest = thread.branchPullRequest; } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9782f258afe1..08b3e58a6a6e 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -58,6 +58,7 @@ import { import { ThreadPullRequestBadgeControl, prStatusIndicator, + resolveDisplayedThreadPr, resolveThreadPullRequestBadge, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -542,10 +543,10 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, resolvedActiveBranch, }); - const branchPr = - branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch - ? (branchStatusQuery.data.pr ?? null) - : null; + const branchPr = resolveDisplayedThreadPr({ + threadBranch: branchPrBranch, + gitStatus: branchStatusQuery.data, + }); const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); const linkedStatus = useLinkedThreadPullRequest( environmentId, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index caba939c26e0..ff1c05ad276d 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -103,6 +103,22 @@ describe("resolveDisplayedThreadPr", () => { expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); }); + it.each(["open", "merged", "closed"] as const)( + "only shows an unlinked branch PR while open (state=%s)", + (state) => { + const branchPr = { ...mergedPr, state }; + const input = { + threadBranch: featureBranch, + gitStatus: status({ pr: branchPr, sourceControlProvider: provider }), + }; + + expect(resolveDisplayedThreadPr(input)).toEqual(state === "open" ? branchPr : null); + expect(resolveDisplayedThreadPrProvider(input)).toEqual( + state === "open" ? provider : undefined, + ); + }, + ); + it("never attaches a PR reported by main to the feature thread", () => { const mainPr = { number: 99, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ea49b01665b6..99e0e01dfe03 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -467,7 +467,7 @@ export function resolveDisplayedThreadPr(input: { threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch && - gitStatus.pr != null + gitStatus.pr?.state === "open" ) { return gitStatus.pr; } @@ -481,17 +481,12 @@ export function resolveDisplayedThreadPrProvider(input: { linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): VcsStatusResult["sourceControlProvider"] | undefined { - const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; + const { gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; if (linkedPullRequest != null) { return linkedPullRequestStatus?.sourceControlProvider; } - if ( - threadBranch !== null && - gitStatus !== null && - gitStatus.refName === threadBranch && - gitStatus.pr != null - ) { - return gitStatus.sourceControlProvider; + if (resolveDisplayedThreadPr(input) !== null) { + return gitStatus?.sourceControlProvider; } return undefined; From d2ab3ed200d11706ec14a918910f1b0df3aac2eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:40:12 +0000 Subject: [PATCH 2/4] test(server): drop duplicate thread from historical branch PR test AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/ThreadPullRequestService.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 90c2dec801b9..64953443c4c5 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -583,8 +583,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { const threads = [ { ...threadShell("older"), createdAt: DateTime.makeUnsafe("2026-09-18T00:00:00Z") }, { ...threadShell("same-time"), createdAt: DateTime.makeUnsafe(terminalAt) }, - threadShell("new-1"), - threadShell("new-2"), + threadShell("new"), { ...threadShell("saved"), branchPullRequest: reference }, { ...threadShell("explicit"), linkedPullRequest: reference }, ].map((thread) => ({ ...thread, branch: "staging" })); @@ -669,7 +668,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), ).toEqual( state === "open" - ? ["older", "same-time", "new-1", "new-2", "explicit"].map((id) => [id, reference]) + ? ["older", "same-time", "new", "explicit"].map((id) => [id, reference]) : [ ["older", reference], ["same-time", reference], From c90688f8e4c30f48b349dfef0ae3e94d6862c4e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:57:10 +0000 Subject: [PATCH 3/4] fix(server): keep checked-out pull requests on their threads Checking out a merged or closed pull request created a thread after the PR finished, so the historical-PR filter dropped it. PR checkout now marks the branch in git config, and the sweep keeps a marked PR. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/git/GitManager.test.ts | 3 ++ apps/server/src/git/GitManager.ts | 40 ++++++++++++++++++- .../ThreadPullRequestService.test.ts | 14 +++++-- .../ThreadPullRequestService.ts | 9 ++++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 72b3504807c9..ee8605c42b74 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4962,6 +4962,9 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(NodeFS.readFileSync(NodePath.join(repoDir, "untracked.txt"), "utf8")).toBe( "keep me\n", ); + expect( + (yield* runGit(repoDir, ["config", "branch.feature/pr-safe.t3-pull-request"])).stdout, + ).toBe("https://github.com/pingdotgg/codething-mvp/pull/91\n"); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index db0d3c91ad19..5f6c469dbf46 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -140,6 +140,12 @@ export class GitManager extends Context.Service< input: { readonly cwd: string; readonly branch: string }, options?: { readonly refresh?: boolean; readonly freshGitState?: boolean }, ) => Effect.Effect; + /** Whether T3 checked this pull request out onto the branch, which ties it to later threads. */ + readonly checkedOutPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + readonly url: string; + }) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -2754,6 +2760,9 @@ export const make = Effect.gen(function* () { repositoryKey: pullRequestRepositoryKey(latest.url), }; }); + + const checkedOutPullRequestKey = (branch: string) => `branch.${branch}.t3-pull-request`; + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( "branchPullRequest", )(function* ({ cwd, branch }, options) { @@ -2798,6 +2807,16 @@ export const make = Effect.gen(function* () { } return confirmed; }); + + const checkedOutPullRequest: GitManager["Service"]["checkedOutPullRequest"] = Effect.fn( + "checkedOutPullRequest", + )(function* ({ cwd, branch, url }) { + const checkedOutUrl = yield* gitCore + .readConfigValue(cwd, checkedOutPullRequestKey(branch)) + .pipe(Effect.orElseSucceed(() => null)); + return checkedOutUrl === url; + }); + const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -3132,7 +3151,25 @@ export const make = Effect.gen(function* () { worktreePath: worktree.worktree.path, isOnPullRequestHead: true, }; - }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); + }).pipe( + Effect.tap((result) => + gitCore + .writeConfigValue({ + cwd: input.cwd, + key: checkedOutPullRequestKey(result.branch), + value: result.pullRequest.url, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("GitManager.preparePullRequestThread could not mark the branch", { + branch: result.branch, + cause, + }), + ), + ), + ), + Effect.ensuring(invalidateStatus(input.cwd)), + ); }, asUserAction); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -3391,6 +3428,7 @@ export const make = Effect.gen(function* () { remoteStatus, status, branchPullRequest, + checkedOutPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 64953443c4c5..66db4953f484 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -556,9 +556,14 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); - it.effect.each(["open", "merged", "closed"] as const)( - "associates a $state branch PR only with threads that predate its completion", - (state) => + it.effect.each([ + { state: "open", checkedOut: false }, + { state: "merged", checkedOut: false }, + { state: "closed", checkedOut: false }, + { state: "merged", checkedOut: true }, + ] as const)( + "associates a $state branch PR (checkedOut=$checkedOut) only with threads that predate its completion", + ({ state, checkedOut }) => Effect.scoped( Effect.gen(function* () { const reference = { @@ -618,6 +623,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), Layer.mock(GitManager.GitManager)({ branchPullRequest: () => Effect.succeed(detected), + checkedOutPullRequest: () => Effect.succeed(checkedOut), }), Layer.mock(PullRequestService.PullRequestService)({ summary: () => @@ -667,7 +673,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { return [command.threadId, command.branchPullRequest]; }), ).toEqual( - state === "open" + state === "open" || checkedOut ? ["older", "same-time", "new", "explicit"].map((id) => [id, reference]) : [ ["older", reference], diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 796dab264ffc..214dba8fbaaa 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -224,10 +224,17 @@ export const make = Effect.gen(function* () { } : null; + // A PR the user checked out stays with the threads made for it, however long ago it closed. + const dropsHistorical = + detected !== null && + first.branch !== null && + group.some((thread) => pullRequestPredatesThread(detected, thread.createdAt)) && + !(yield* git.checkedOutPullRequest({ cwd, branch: first.branch, url: detected.url })); + const plans = yield* Effect.forEach(group, (thread) => Effect.gen(function* () { let branchPullRequest = - detected !== null && pullRequestPredatesThread(detected, thread.createdAt) + dropsHistorical && pullRequestPredatesThread(detected, thread.createdAt) ? null : detectedReference; if ( From 997784dd6aac55a065765de95b1364ed94c31eca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 07:15:41 +0000 Subject: [PATCH 4/4] fix(server): threads keep branch pull requests they already saved The historical-PR filter cleared saved branch PRs, so threads that checked out a finished PR before this change lost it. The filter now only refuses new associations. AI-assisted; verified with the tests and tools listed above. --- .../ThreadPullRequestService.test.ts | 13 +++++-------- .../orchestration-v2/ThreadPullRequestService.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 66db4953f484..0bcdad9c4726 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -562,7 +562,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { { state: "closed", checkedOut: false }, { state: "merged", checkedOut: true }, ] as const)( - "associates a $state branch PR (checkedOut=$checkedOut) only with threads that predate its completion", + "associates a $state branch PR (checkedOut=$checkedOut) only with threads that predate its completion or saved it", ({ state, checkedOut }) => Effect.scoped( Effect.gen(function* () { @@ -673,13 +673,10 @@ describe("ThreadPullRequestServiceV2 reads", () => { return [command.threadId, command.branchPullRequest]; }), ).toEqual( - state === "open" || checkedOut - ? ["older", "same-time", "new", "explicit"].map((id) => [id, reference]) - : [ - ["older", reference], - ["same-time", reference], - ["saved", null], - ], + (state === "open" || checkedOut + ? ["older", "same-time", "new", "explicit"] + : ["older", "same-time"] + ).map((id) => [id, reference]), ); }), ), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 214dba8fbaaa..69fbd1fd918b 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -224,19 +224,22 @@ export const make = Effect.gen(function* () { } : null; - // A PR the user checked out stays with the threads made for it, however long ago it closed. + // Only a new association is refused: a thread keeps a PR it already saved, and a PR the + // user checked out stays with the threads made for it, however long ago it closed. + const isHistoricalFor = (thread: (typeof group)[number]) => + detected !== null && + pullRequestPredatesThread(detected, thread.createdAt) && + !samePullRequest(thread.branchPullRequest, detectedReference); const dropsHistorical = detected !== null && first.branch !== null && - group.some((thread) => pullRequestPredatesThread(detected, thread.createdAt)) && + group.some(isHistoricalFor) && !(yield* git.checkedOutPullRequest({ cwd, branch: first.branch, url: detected.url })); const plans = yield* Effect.forEach(group, (thread) => Effect.gen(function* () { let branchPullRequest = - dropsHistorical && pullRequestPredatesThread(detected, thread.createdAt) - ? null - : detectedReference; + dropsHistorical && isHistoricalFor(thread) ? null : detectedReference; if ( branchPullRequest === null && thread.branch !== null && @@ -248,8 +251,7 @@ export const make = Effect.gen(function* () { }); if ( previous.headBranch === thread.branch && - (previous.state === "merged" || previous.state === "closed") && - !pullRequestPredatesThread(previous, thread.createdAt) + (previous.state === "merged" || previous.state === "closed") ) { branchPullRequest = thread.branchPullRequest; }