authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-10-12 23:15:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 20:15:21-07:00
logae7bed6e402897b8020f10d13747dd6a3779b5ae
treeb47a52d296352cd42f716a96cf66fb6a09e28b39
parent06620faf6de7dc9dc02f245c9fedc49644c2ee08
signature Signed by PGP key B5690EEEBB952194

perf(server): cached branch PR lookups no longer start git processes (#17063)


3 files changed, 371 insertions(+), 85 deletions(-)

apps/server/src/git/GitManager.test.ts+187-32
......@@ -11,6 +11,7 @@ import * as Context from "effect/Context";
1111import * as Effect from "effect/Effect";
1212import * as Deferred from "effect/Deferred";
1313import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
1415import * as FileSystem from "effect/FileSystem";
1516import * as Layer from "effect/Layer";
1617import * as Logger from "effect/Logger";
......@@ -728,6 +729,14 @@ function makeManager(input?: {
728729 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
729730 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
730731 gitConfigReads?: string[];
732 /** Records the arguments of every git process the manager starts. */
733 gitCommands?: string[];
734 /** Holds a matching git process's output until `release`, once it has exited. */
735 gitSpawnGate?: {
736 readonly matches: (args: ReadonlyArray<string>) => boolean;
737 readonly reached: Deferred.Deferred<void>;
738 readonly release: Deferred.Deferred<void>;
739 };
731740 /** Seeds the V2 stores the per-project settings lookup reads. */
732741 seed?: Effect.Effect<
733742 void,
......@@ -745,7 +754,40 @@ function makeManager(input?: {
745754
746755 const layerServerSettings = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
747756
748 const layerVcsDriver = input?.gitConfigReads
757 const gitCommands = input?.gitCommands;
758 const gitSpawnGate = input?.gitSpawnGate;
759 const watchesGitSpawns = gitCommands !== undefined || gitSpawnGate !== undefined;
760 const layerGitDriverSpawner = watchesGitSpawns
761 ? Layer.effect(
762 ChildProcessSpawner.ChildProcessSpawner,
763 Effect.gen(function* () {
764 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
765 return ChildProcessSpawner.make((command) => {
766 // On Windows the driver starts git by its full path to git.exe.
767 const args =
768 command._tag === "StandardCommand" &&
769 NodePath.parse(command.command).name.toLowerCase() === "git"
770 ? command.args
771 : null;
772 if (args !== null) gitCommands?.push(args.join(" "));
773 if (args === null || gitSpawnGate === undefined || !gitSpawnGate.matches(args)) {
774 return spawner.spawn(command);
775 }
776 return spawner
777 .spawn(command)
778 .pipe(
779 Effect.tap((handle) =>
780 handle.exitCode.pipe(
781 Effect.andThen(Deferred.succeed(gitSpawnGate.reached, undefined)),
782 Effect.andThen(Deferred.await(gitSpawnGate.release)),
783 ),
784 ),
785 );
786 });
787 }),
788 ).pipe(Layer.provide(NodeServices.layer))
789 : Layer.empty;
790 const gitVcsDriver = input?.gitConfigReads
749791 ? Layer.effect(
750792 GitVcsDriver.GitVcsDriver,
751793 GitVcsDriver.make.pipe(
......@@ -759,16 +801,15 @@ function makeManager(input?: {
759801 }),
760802 ),
761803 ),
762 ).pipe(
763 Layer.provideMerge(VcsProcess.layer),
764 Layer.provideMerge(NodeServices.layer),
765 Layer.provideMerge(layerServerConfig),
766804 )
767 : GitVcsDriver.layer.pipe(
768 Layer.provideMerge(VcsProcess.layer),
769 Layer.provideMerge(NodeServices.layer),
770 Layer.provideMerge(layerServerConfig),
771 );
805 : GitVcsDriver.layer;
806 // Fresh so the counting spawner is not swapped for the suite's shared driver.
807 const layerVcsDriver = (watchesGitSpawns ? Layer.fresh(gitVcsDriver) : gitVcsDriver).pipe(
808 Layer.provide(layerGitDriverSpawner),
809 Layer.provideMerge(VcsProcess.layer),
810 Layer.provideMerge(NodeServices.layer),
811 Layer.provideMerge(layerServerConfig),
812 );
772813 const layerSourceControlRegistry = Layer.effect(
773814 SourceControlProviderRegistry.SourceControlProviderRegistry,
774815 Effect.gen(function* () {
......@@ -1250,45 +1291,159 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
12501291 }),
12511292 );
12521293
1253 it.effect("a branch tracking origin reads origin's URL once per PR lookup", () =>
1294 it.effect("a warm branch PR lookup starts no git process", () =>
12541295 Effect.gen(function* () {
12551296 const repoDir = yield* makeTempDir("t3code-git-manager-");
12561297 yield* initRepo(repoDir);
12571298 const remoteDir = yield* createBareRemote();
12581299 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1259 yield* runGit(repoDir, ["checkout", "-b", "feature/origin-once"]);
1260 yield* runGit(repoDir, ["push", "-u", "origin", "feature/origin-once"]);
1261
1262 const gitConfigReads: string[] = [];
1263 const { manager } = yield* makeManager({
1264 gitConfigReads,
1300 for (const branch of ["feature/warm-pr", "feature/warm-no-pr"]) {
1301 yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
1302 yield* runGit(repoDir, ["push", "-u", "origin", branch]);
1303 }
1304 const gitCommands: string[] = [];
1305 const { manager, ghCalls } = yield* makeManager({
1306 gitCommands,
12651307 ghScenario: {
1266 prListSequence: [
1267 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1268 JSON.stringify([
1308 prListByHeadSelector: {
1309 "feature/warm-pr": encodeCliJson([
12691310 {
1270 number: 217,
1271 title: "Origin once PR",
1272 url: "https://github.com/pingdotgg/t3code/pull/217",
1311 number: 230,
1312 title: "Warm PR",
1313 url: "https://github.com/pingdotgg/codething-mvp/pull/230",
12731314 baseRefName: "main",
1274 headRefName: "feature/origin-once",
1315 headRefName: "feature/warm-pr",
12751316 state: "OPEN",
1276 updatedAt: "2026-04-03T15:00:00Z",
1317 updatedAt: "2026-04-07T15:00:00Z",
12771318 },
12781319 ]),
1279 ],
1320 },
12801321 },
12811322 });
1323 const lookupAll = Effect.forEach(["feature/warm-pr", "feature/warm-no-pr"], (branch) =>
1324 manager.branchPullRequest({ cwd: repoDir, branch }),
1325 );
12821326
1283 yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/origin-once" });
1284 gitConfigReads.length = 0;
1285 const pullRequest = yield* manager.branchPullRequest({
1327 const cold = yield* lookupAll;
1328 expect(cold.map((pullRequest) => pullRequest?.number ?? null)).toEqual([230, null]);
1329 expect(gitCommands.length).toBeGreaterThan(0);
1330
1331 gitCommands.length = 0;
1332 const warm = yield* lookupAll;
1333 expect(warm).toEqual(cold);
1334 expect(gitCommands).toEqual([]);
1335 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1336 }),
1337 );
1338
1339 it.effect(
1340 "a branch PR lookup sees an upstream change on request, after a T3 git action, or after the TTL",
1341 () =>
1342 Effect.gen(function* () {
1343 const repoDir = yield* makeTempDir("t3code-git-manager-");
1344 yield* initRepo(repoDir);
1345 const remoteDir = yield* createBareRemote();
1346 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1347 yield* runGit(repoDir, ["checkout", "-b", "feature/moving-upstream"]);
1348 yield* runGit(repoDir, ["push", "-u", "origin", "feature/moving-upstream"]);
1349 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/other-head"]);
1350 const pullRequest = (number: number, headRefName: string) =>
1351 encodeCliJson([
1352 {
1353 number,
1354 title: headRefName,
1355 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
1356 baseRefName: "main",
1357 headRefName,
1358 state: "OPEN",
1359 updatedAt: "2026-04-07T15:00:00Z",
1360 },
1361 ]);
1362 const { manager } = yield* makeManager({
1363 ghScenario: {
1364 prListByHeadSelector: {
1365 "feature/moving-upstream": pullRequest(235, "feature/moving-upstream"),
1366 "feature/other-head": pullRequest(236, "feature/other-head"),
1367 },
1368 },
1369 });
1370 const lookup = manager.branchPullRequest({
1371 cwd: repoDir,
1372 branch: "feature/moving-upstream",
1373 });
1374 expect((yield* lookup)?.number).toBe(235);
1375
1376 // Discovery rereads git before it persists an answer.
1377 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]);
1378 const reread = yield* manager.branchPullRequest(
1379 { cwd: repoDir, branch: "feature/moving-upstream" },
1380 { freshGitState: true },
1381 );
1382 expect(reread?.number).toBe(236);
1383
1384 // T3's own git actions end in invalidateStatus.
1385 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/moving-upstream"]);
1386 yield* manager.invalidateStatus(repoDir);
1387 expect((yield* lookup)?.number).toBe(235);
1388
1389 // A change made outside T3 shows once the remembered state expires.
1390 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]);
1391 yield* TestClock.adjust("5 minutes");
1392 expect((yield* lookup)?.number).toBe(236);
1393 }),
1394 );
1395
1396 it.effect("a git read in flight during a T3 git action does not outlive it", () =>
1397 Effect.gen(function* () {
1398 const repoDir = yield* makeTempDir("t3code-git-manager-");
1399 yield* initRepo(repoDir);
1400 const remoteDir = yield* createBareRemote();
1401 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1402 yield* runGit(repoDir, ["checkout", "-b", "feature/racing-upstream"]);
1403 yield* runGit(repoDir, ["push", "-u", "origin", "feature/racing-upstream"]);
1404 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/raced-head"]);
1405 const pullRequest = (number: number, headRefName: string) =>
1406 encodeCliJson([
1407 {
1408 number,
1409 title: headRefName,
1410 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
1411 baseRefName: "main",
1412 headRefName,
1413 state: "OPEN",
1414 updatedAt: "2026-04-07T15:00:00Z",
1415 },
1416 ]);
1417 const gate = {
1418 matches: (args: ReadonlyArray<string>) =>
1419 args[0] === "for-each-ref" && args.at(-1) === "refs/heads/feature/racing-upstream",
1420 reached: yield* Deferred.make<void>(),
1421 release: yield* Deferred.make<void>(),
1422 };
1423 const { manager } = yield* makeManager({
1424 gitSpawnGate: gate,
1425 ghScenario: {
1426 prListByHeadSelector: {
1427 "feature/racing-upstream": pullRequest(237, "feature/racing-upstream"),
1428 "feature/raced-head": pullRequest(238, "feature/raced-head"),
1429 },
1430 },
1431 });
1432 const lookup = manager.branchPullRequest({
12861433 cwd: repoDir,
1287 branch: "feature/origin-once",
1434 branch: "feature/racing-upstream",
12881435 });
12891436
1290 expect(pullRequest?.number).toBe(217);
1291 expect(gitConfigReads.filter((key) => key === "remote.origin.url")).toHaveLength(1);
1437 // The branch read has exited with the old upstream; a T3 git action
1438 // changes it before the lookup gets that output.
1439 const inFlight = yield* Effect.forkChild(lookup);
1440 yield* Deferred.await(gate.reached);
1441 yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/raced-head"]);
1442 yield* manager.invalidateStatus(repoDir);
1443 yield* Deferred.succeed(gate.release, undefined);
1444 yield* Fiber.join(inFlight);
1445
1446 expect((yield* lookup)?.number).toBe(238);
12921447 }),
12931448 );
12941449
apps/server/src/git/GitManager.ts+180-52
......@@ -1,6 +1,7 @@
11import { SourceControlProviderKind } from "@t3tools/contracts";
22import * as Arr from "effect/Array";
33import * as Cache from "effect/Cache";
4import * as Clock from "effect/Clock";
45import * as Context from "effect/Context";
56import * as Crypto from "effect/Crypto";
67import * as ByteSize from "effect/ByteSize";
......@@ -131,10 +132,14 @@ export class GitManager extends Context.Service<
131132 input: VcsStatusInput,
132133 options?: GitRemoteStatusOptions,
133134 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
134 /** Resolve the PR for a saved branch without changing the current checkout. */
135 /**
136 * Resolve the PR for a saved branch without changing the current checkout.
137 * `refresh` asks the host again. `freshGitState` rereads the branch's git
138 * state without forcing a host refresh; use it before persisting a result.
139 */
135140 readonly branchPullRequest: (
136141 input: { readonly cwd: string; readonly branch: string },
137 options?: { readonly refresh?: boolean },
142 options?: { readonly refresh?: boolean; readonly freshGitState?: boolean },
138143 ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;
139144 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
140145 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
......@@ -183,6 +188,10 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);
183188const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
184189const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
185190const PR_LOOKUP_CACHE_CAPACITY = 2_048;
191// How long branchPullRequest reuses the git state around a cached PR answer.
192// Matches PR_LOOKUP_NO_OPEN_PR_CACHE_TTL, the delay already accepted for a
193// change made outside the app.
194const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5);
186195const isSourceControlProviderError = Schema.is(SourceControlProviderError);
187196
188197/**
......@@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () {
14911500 { concurrency: "unbounded" },
14921501 );
14931502
1494 const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")(
1495 function* (cwd: string, branch: string, remoteNameOverride?: string) {
1496 const remoteName =
1497 remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`));
1498 const [headRemote, targetRemote] = yield* resolveHeadAndOriginContexts(cwd, remoteName);
1499 return {
1500 remoteName,
1501 headRemoteUrlKey:
1502 headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null),
1503 targetRemoteUrlKey: targetRemote.remoteUrlKey,
1504 };
1505 },
1506 );
1507
15081503 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
15091504 cwd: string,
15101505 details: { branch: string; upstreamRef: string | null; remoteName?: string },
......@@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () {
23572352 });
23582353 return mergeGitStatusParts(local, remote);
23592354 });
2360 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
2361 "branchPullRequest",
2362 )(function* ({ cwd, branch }, options) {
2363 const cacheCwd = yield* normalizeStatusCacheKey(cwd);
2364 const remotes = yield* gitCore.execute({
2365 operation: "GitManager.branchPullRequest.remotes",
2366 cwd: cacheCwd,
2367 args: ["remote"],
2355 // Discovery and the settlement sweep call branchPullRequest for every thread
2356 // each minute, and most calls find the PR answer already cached. The git
2357 // state around that answer (remotes, the branch's upstream, the remote HEAD,
2358 // remote URLs) is remembered per checkout for PR_LOOKUP_GIT_STATE_TTL, so
2359 // those calls start no git process. T3's own git actions (invalidateStatus)
2360 // and `refresh` read it again, and a change made outside T3 shows within the
2361 // TTL. A merged or closed answer can settle a thread or remove its worktree,
2362 // so branchPullRequest confirms one against fresh reads, and discovery asks
2363 // for fresh reads (`freshGitState`) before it persists an answer.
2364 const prLookupGitStateByKey = new Map<
2365 string,
2366 { readonly readAtMs: number; readonly value: string | null }
2367 >();
2368 const rememberPrLookupGitState = <E>(
2369 cwd: string,
2370 key: string,
2371 fresh: boolean,
2372 read: Effect.Effect<string | null, E>,
2373 ) =>
2374 Effect.gen(function* () {
2375 const entryKey = `${cwd}\u0000${key}`;
2376 const readAtMs = yield* Clock.currentTimeMillis;
2377 const remembered = prLookupGitStateByKey.get(entryKey);
2378 if (
2379 !fresh &&
2380 remembered !== undefined &&
2381 readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL)
2382 ) {
2383 return remembered.value;
2384 }
2385 // invalidateStatus bumps the epoch. A read that started before a T3 git
2386 // action, or before a newer read finished, must not replace their state.
2387 const epoch = prLookupEpoch(cwd);
2388 const value = yield* read;
2389 const latest = prLookupGitStateByKey.get(entryKey);
2390 if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) {
2391 return value;
2392 }
2393 prLookupGitStateByKey.delete(entryKey);
2394 if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) {
2395 const oldestKey = prLookupGitStateByKey.keys().next().value;
2396 if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey);
2397 }
2398 prLookupGitStateByKey.set(entryKey, { readAtMs, value });
2399 return value;
23682400 });
2369 const remoteNames = remotes.stdout
2401 const forgetPrLookupGitState = (cwd: string) =>
2402 normalizeStatusCacheKey(cwd).pipe(
2403 Effect.map((cacheCwd) => {
2404 const prefix = `${cacheCwd}\u0000`;
2405 for (const key of prLookupGitStateByKey.keys()) {
2406 if (key.startsWith(prefix)) prLookupGitStateByKey.delete(key);
2407 }
2408 }),
2409 );
2410 const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")(
2411 function* (
2412 cwd: string,
2413 branch: string,
2414 remoteNameOverride: string | undefined,
2415 fresh: boolean,
2416 ) {
2417 const readConfig = (key: string) =>
2418 rememberPrLookupGitState(
2419 cwd,
2420 `config\u0000${key}`,
2421 fresh,
2422 gitCore.readConfigValue(cwd, key),
2423 ).pipe(Effect.orElseSucceed(() => null));
2424 const readRemoteUrlKey = (remoteName: string | null) =>
2425 remoteName === null
2426 ? Effect.succeed(null)
2427 : readConfig(`remote.${remoteName}.url`).pipe(
2428 Effect.map((remoteUrl) => (remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null)),
2429 );
2430 const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`));
2431 const [headRemoteUrlKey, targetRemoteUrlKey] =
2432 remoteName === "origin"
2433 ? yield* readRemoteUrlKey("origin").pipe(Effect.map((key) => [key, key] as const))
2434 : yield* Effect.all([readRemoteUrlKey(remoteName), readRemoteUrlKey("origin")], {
2435 concurrency: "unbounded",
2436 });
2437 return {
2438 remoteName,
2439 headRemoteUrlKey: headRemoteUrlKey ?? (remoteName === null ? targetRemoteUrlKey : null),
2440 targetRemoteUrlKey,
2441 };
2442 },
2443 );
2444 const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* (
2445 cacheCwd: string,
2446 branch: string,
2447 options: { readonly refresh: boolean; readonly freshGitState: boolean },
2448 ) {
2449 const remember = <E>(key: string, read: Effect.Effect<string | null, E>) =>
2450 rememberPrLookupGitState(cacheCwd, key, options.freshGitState, read);
2451 const remotes = yield* remember(
2452 "remotes",
2453 gitCore
2454 .execute({
2455 operation: "GitManager.branchPullRequest.remotes",
2456 cwd: cacheCwd,
2457 args: ["remote"],
2458 })
2459 .pipe(Effect.map((result) => result.stdout)),
2460 );
2461 const remoteNames = (remotes ?? "")
23702462 .split("\n")
23712463 .map((remoteName) => remoteName.trim())
23722464 .filter((remoteName) => remoteName.length > 0);
23732465 const [firstRemoteName] = remoteNames;
23742466 if (firstRemoteName === undefined) return null;
2375 const branchRef = yield* gitCore.execute({
2376 operation: "GitManager.branchPullRequest.branchRef",
2377 cwd: cacheCwd,
2378 args: [
2379 "for-each-ref",
2380 "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)",
2381 `refs/heads/${branch}`,
2382 ],
2383 });
2467 const branchRef = yield* remember(
2468 `branchRef\u0000${branch}`,
2469 gitCore
2470 .execute({
2471 operation: "GitManager.branchPullRequest.branchRef",
2472 cwd: cacheCwd,
2473 args: [
2474 "for-each-ref",
2475 "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)",
2476 `refs/heads/${branch}`,
2477 ],
2478 })
2479 .pipe(Effect.map((result) => result.stdout)),
2480 );
23842481 const expectedRefName = `refs/heads/${branch}`;
2385 const exactBranch = branchRef.stdout
2482 const exactBranch = (branchRef ?? "")
23862483 .split("\n")
23872484 .find((line) => line.split("\u0000", 1)[0] === expectedRefName);
23882485 const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] =
......@@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () {
24022499 const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, "");
24032500 upstreamRef = `${remoteName}/${upstreamBranch}`;
24042501 } else if (!localBranchExists) {
2405 const trackingRefs = yield* gitCore.execute({
2406 operation: "GitManager.branchPullRequest.remoteTrackingRefs",
2407 cwd: cacheCwd,
2408 args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],
2409 });
2410 const refNames = new Set(
2411 trackingRefs.stdout
2412 .split("\n")
2413 .map((remoteRef) => remoteRef.trim())
2414 .filter((remoteRef) => remoteRef.length > 0),
2415 );
2416 const matchingRemoteNames = remoteNames.filter((candidate) =>
2417 refNames.has(`refs/remotes/${candidate}/${branch}`),
2502 const trackingRemotes = yield* remember(
2503 `trackingRemotes\u0000${branch}`,
2504 gitCore
2505 .execute({
2506 operation: "GitManager.branchPullRequest.remoteTrackingRefs",
2507 cwd: cacheCwd,
2508 args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],
2509 })
2510 .pipe(
2511 Effect.map((trackingRefs) => {
2512 const refNames = new Set(
2513 trackingRefs.stdout
2514 .split("\n")
2515 .map((remoteRef) => remoteRef.trim())
2516 .filter((remoteRef) => remoteRef.length > 0),
2517 );
2518 return remoteNames
2519 .filter((candidate) => refNames.has(`refs/remotes/${candidate}/${branch}`))
2520 .join("\n");
2521 }),
2522 ),
24182523 );
2524 const matchingRemoteNames = (trackingRemotes ?? "")
2525 .split("\n")
2526 .filter((candidate) => candidate.length > 0);
24192527 if (matchingRemoteNames.length > 1) {
24202528 return yield* new GitManagerError({
24212529 operation: "branchPullRequest",
......@@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () {
24292537 }
24302538 }
24312539 const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName;
2432 const defaultBranch = yield* gitCore
2433 .resolveDefaultBranchName(cacheCwd, defaultRemoteName)
2434 .pipe(Effect.orElseSucceed(() => null));
2540 const defaultBranch = yield* remember(
2541 `defaultBranch\u0000${defaultRemoteName}`,
2542 gitCore.resolveDefaultBranchName(cacheCwd, defaultRemoteName),
2543 ).pipe(Effect.orElseSucceed(() => null));
24352544 const cacheKey = prLookupCacheKey(cacheCwd, {
24362545 branch,
24372546 upstreamRef,
......@@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () {
24392548 localBranchExists,
24402549 ...(localBranchExists ? {} : { remoteName }),
24412550 });
2442 if (options?.refresh) {
2551 if (options.refresh) {
24432552 // A completed turn can create a PR or reuse a merged PR's branch.
24442553 // Refresh successful answers, but keep failed lookups' retry backoff.
24452554 const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
......@@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () {
24572566 cacheCwd,
24582567 branch,
24592568 identityRemoteName(cached.headContext),
2569 options.freshGitState,
24602570 );
24612571 const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>
24622572 !(
......@@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () {
24802590 cacheCwd,
24812591 branch,
24822592 identityRemoteName(cached.headContext),
2593 true,
24832594 );
24842595 if (
24852596 !canVerifyIdentity(cached.headContext, refreshedIdentity) ||
......@@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () {
25112622 repositoryKey: pullRequestRepositoryKey(latest.url),
25122623 };
25132624 });
2625 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
2626 "branchPullRequest",
2627 )(function* ({ cwd, branch }, options) {
2628 const cacheCwd = yield* normalizeStatusCacheKey(cwd);
2629 const refresh = options?.refresh === true;
2630 const freshGitState = refresh || options?.freshGitState === true;
2631 const pullRequest = yield* lookupBranchPullRequest(cacheCwd, branch, {
2632 refresh,
2633 freshGitState,
2634 });
2635 if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest;
2636 return yield* lookupBranchPullRequest(cacheCwd, branch, {
2637 refresh: false,
2638 freshGitState: true,
2639 });
2640 });
25142641 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
25152642 "invalidateLocalStatus",
25162643 )(function* (cwd) {
......@@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () {
25292656 // refresh); it also bypasses the slow PR-lookup cache. The periodic
25302657 // status poll only invalidates local/remote and keeps the PR cache warm.
25312658 yield* bumpPrLookupEpoch(cwd);
2659 yield* forgetPrLookupGitState(cwd);
25322660 },
25332661 );
25342662
apps/server/src/orchestration-v2/ThreadPullRequestService.ts+4-1
......@@ -275,7 +275,10 @@ export const make = Effect.gen(function* () {
275275 if (updates.length === 0) return;
276276
277277 if (detected !== null && first.branch !== null) {
278 const current = yield* git.branchPullRequest({ cwd, branch: first.branch });
278 const current = yield* git.branchPullRequest(
279 { cwd, branch: first.branch },
280 { freshGitState: true },
281 );
279282 const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, {
280283 refresh: true,
281284 });