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";...@@ -11,6 +11,7 @@ import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Deferred from "effect/Deferred";12import * as Deferred from "effect/Deferred";
13import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
14import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
16import * as Logger from "effect/Logger";17import * as Logger from "effect/Logger";
...@@ -728,6 +729,14 @@ function makeManager(input?: {...@@ -728,6 +729,14 @@ function makeManager(input?: {
728 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];729 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
729 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];730 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
730 gitConfigReads?: string[];731 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 };
731 /** Seeds the V2 stores the per-project settings lookup reads. */740 /** Seeds the V2 stores the per-project settings lookup reads. */
732 seed?: Effect.Effect<741 seed?: Effect.Effect<
733 void,742 void,
...@@ -745,7 +754,40 @@ function makeManager(input?: {...@@ -745,7 +754,40 @@ function makeManager(input?: {
745754
746 const layerServerSettings = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);755 const layerServerSettings = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
747756
748 const layerVcsDriver = input?.gitConfigReads757 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
749 ? Layer.effect(791 ? Layer.effect(
750 GitVcsDriver.GitVcsDriver,792 GitVcsDriver.GitVcsDriver,
751 GitVcsDriver.make.pipe(793 GitVcsDriver.make.pipe(
...@@ -759,16 +801,15 @@ function makeManager(input?: {...@@ -759,16 +801,15 @@ function makeManager(input?: {
759 }),801 }),
760 ),802 ),
761 ),803 ),
762 ).pipe(
763 Layer.provideMerge(VcsProcess.layer),
764 Layer.provideMerge(NodeServices.layer),
765 Layer.provideMerge(layerServerConfig),
766 )804 )
767 : GitVcsDriver.layer.pipe(805 : GitVcsDriver.layer;
768 Layer.provideMerge(VcsProcess.layer),806 // Fresh so the counting spawner is not swapped for the suite's shared driver.
769 Layer.provideMerge(NodeServices.layer),807 const layerVcsDriver = (watchesGitSpawns ? Layer.fresh(gitVcsDriver) : gitVcsDriver).pipe(
770 Layer.provideMerge(layerServerConfig),808 Layer.provide(layerGitDriverSpawner),
771 );809 Layer.provideMerge(VcsProcess.layer),
810 Layer.provideMerge(NodeServices.layer),
811 Layer.provideMerge(layerServerConfig),
812 );
772 const layerSourceControlRegistry = Layer.effect(813 const layerSourceControlRegistry = Layer.effect(
773 SourceControlProviderRegistry.SourceControlProviderRegistry,814 SourceControlProviderRegistry.SourceControlProviderRegistry,
774 Effect.gen(function* () {815 Effect.gen(function* () {
...@@ -1250,45 +1291,159 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1250,45 +1291,159 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1250 }),1291 }),
1251 );1292 );
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", () =>
1254 Effect.gen(function* () {1295 Effect.gen(function* () {
1255 const repoDir = yield* makeTempDir("t3code-git-manager-");1296 const repoDir = yield* makeTempDir("t3code-git-manager-");
1256 yield* initRepo(repoDir);1297 yield* initRepo(repoDir);
1257 const remoteDir = yield* createBareRemote();1298 const remoteDir = yield* createBareRemote();
1258 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);1299 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1259 yield* runGit(repoDir, ["checkout", "-b", "feature/origin-once"]);1300 for (const branch of ["feature/warm-pr", "feature/warm-no-pr"]) {
1260 yield* runGit(repoDir, ["push", "-u", "origin", "feature/origin-once"]);1301 yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
12611302 yield* runGit(repoDir, ["push", "-u", "origin", branch]);
1262 const gitConfigReads: string[] = [];1303 }
1263 const { manager } = yield* makeManager({1304 const gitCommands: string[] = [];
1264 gitConfigReads,1305 const { manager, ghCalls } = yield* makeManager({
1306 gitCommands,
1265 ghScenario: {1307 ghScenario: {
1266 prListSequence: [1308 prListByHeadSelector: {
1267 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.1309 "feature/warm-pr": encodeCliJson([
1268 JSON.stringify([
1269 {1310 {
1270 number: 217,1311 number: 230,
1271 title: "Origin once PR",1312 title: "Warm PR",
1272 url: "https://github.com/pingdotgg/t3code/pull/217",1313 url: "https://github.com/pingdotgg/codething-mvp/pull/230",
1273 baseRefName: "main",1314 baseRefName: "main",
1274 headRefName: "feature/origin-once",1315 headRefName: "feature/warm-pr",
1275 state: "OPEN",1316 state: "OPEN",
1276 updatedAt: "2026-04-03T15:00:00Z",1317 updatedAt: "2026-04-07T15:00:00Z",
1277 },1318 },
1278 ]),1319 ]),
1279 ],1320 },
1280 },1321 },
1281 });1322 });
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" });1327 const cold = yield* lookupAll;
1284 gitConfigReads.length = 0;1328 expect(cold.map((pullRequest) => pullRequest?.number ?? null)).toEqual([230, null]);
1285 const pullRequest = yield* manager.branchPullRequest({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({
1286 cwd: repoDir,1433 cwd: repoDir,
1287 branch: "feature/origin-once",1434 branch: "feature/racing-upstream",
1288 });1435 });
12891436
1290 expect(pullRequest?.number).toBe(217);1437 // The branch read has exited with the old upstream; a T3 git action
1291 expect(gitConfigReads.filter((key) => key === "remote.origin.url")).toHaveLength(1);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);
1292 }),1447 }),
1293 );1448 );
12941449
apps/server/src/git/GitManager.ts+180-52
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Cache from "effect/Cache";3import * as Cache from "effect/Cache";
4import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";5import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";6import * as Crypto from "effect/Crypto";
6import * as ByteSize from "effect/ByteSize";7import * as ByteSize from "effect/ByteSize";
...@@ -131,10 +132,14 @@ export class GitManager extends Context.Service<...@@ -131,10 +132,14 @@ export class GitManager extends Context.Service<
131 input: VcsStatusInput,132 input: VcsStatusInput,
132 options?: GitRemoteStatusOptions,133 options?: GitRemoteStatusOptions,
133 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;134 ) => 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 */
135 readonly branchPullRequest: (140 readonly branchPullRequest: (
136 input: { readonly cwd: string; readonly branch: string },141 input: { readonly cwd: string; readonly branch: string },
137 options?: { readonly refresh?: boolean },142 options?: { readonly refresh?: boolean; readonly freshGitState?: boolean },
138 ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;143 ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;
139 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;144 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
140 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;145 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
...@@ -183,6 +188,10 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);...@@ -183,6 +188,10 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);
183const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);188const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
184const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);189const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
185const PR_LOOKUP_CACHE_CAPACITY = 2_048;190const 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);
186const isSourceControlProviderError = Schema.is(SourceControlProviderError);195const isSourceControlProviderError = Schema.is(SourceControlProviderError);
187196
188/**197/**
...@@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () {...@@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () {
1491 { concurrency: "unbounded" },1500 { concurrency: "unbounded" },
1492 );1501 );
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
1508 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (1503 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
1509 cwd: string,1504 cwd: string,
1510 details: { branch: string; upstreamRef: string | null; remoteName?: string },1505 details: { branch: string; upstreamRef: string | null; remoteName?: string },
...@@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () {...@@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () {
2357 });2352 });
2358 return mergeGitStatusParts(local, remote);2353 return mergeGitStatusParts(local, remote);
2359 });2354 });
2360 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(2355 // Discovery and the settlement sweep call branchPullRequest for every thread
2361 "branchPullRequest",2356 // each minute, and most calls find the PR answer already cached. The git
2362 )(function* ({ cwd, branch }, options) {2357 // state around that answer (remotes, the branch's upstream, the remote HEAD,
2363 const cacheCwd = yield* normalizeStatusCacheKey(cwd);2358 // remote URLs) is remembered per checkout for PR_LOOKUP_GIT_STATE_TTL, so
2364 const remotes = yield* gitCore.execute({2359 // those calls start no git process. T3's own git actions (invalidateStatus)
2365 operation: "GitManager.branchPullRequest.remotes",2360 // and `refresh` read it again, and a change made outside T3 shows within the
2366 cwd: cacheCwd,2361 // TTL. A merged or closed answer can settle a thread or remove its worktree,
2367 args: ["remote"],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;
2368 });2400 });
2369 const remoteNames = remotes.stdout2401 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 ?? "")
2370 .split("\n")2462 .split("\n")
2371 .map((remoteName) => remoteName.trim())2463 .map((remoteName) => remoteName.trim())
2372 .filter((remoteName) => remoteName.length > 0);2464 .filter((remoteName) => remoteName.length > 0);
2373 const [firstRemoteName] = remoteNames;2465 const [firstRemoteName] = remoteNames;
2374 if (firstRemoteName === undefined) return null;2466 if (firstRemoteName === undefined) return null;
2375 const branchRef = yield* gitCore.execute({2467 const branchRef = yield* remember(
2376 operation: "GitManager.branchPullRequest.branchRef",2468 `branchRef\u0000${branch}`,
2377 cwd: cacheCwd,2469 gitCore
2378 args: [2470 .execute({
2379 "for-each-ref",2471 operation: "GitManager.branchPullRequest.branchRef",
2380 "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)",2472 cwd: cacheCwd,
2381 `refs/heads/${branch}`,2473 args: [
2382 ],2474 "for-each-ref",
2383 });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 );
2384 const expectedRefName = `refs/heads/${branch}`;2481 const expectedRefName = `refs/heads/${branch}`;
2385 const exactBranch = branchRef.stdout2482 const exactBranch = (branchRef ?? "")
2386 .split("\n")2483 .split("\n")
2387 .find((line) => line.split("\u0000", 1)[0] === expectedRefName);2484 .find((line) => line.split("\u0000", 1)[0] === expectedRefName);
2388 const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] =2485 const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] =
...@@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () {...@@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () {
2402 const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, "");2499 const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, "");
2403 upstreamRef = `${remoteName}/${upstreamBranch}`;2500 upstreamRef = `${remoteName}/${upstreamBranch}`;
2404 } else if (!localBranchExists) {2501 } else if (!localBranchExists) {
2405 const trackingRefs = yield* gitCore.execute({2502 const trackingRemotes = yield* remember(
2406 operation: "GitManager.branchPullRequest.remoteTrackingRefs",2503 `trackingRemotes\u0000${branch}`,
2407 cwd: cacheCwd,2504 gitCore
2408 args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],2505 .execute({
2409 });2506 operation: "GitManager.branchPullRequest.remoteTrackingRefs",
2410 const refNames = new Set(2507 cwd: cacheCwd,
2411 trackingRefs.stdout2508 args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],
2412 .split("\n")2509 })
2413 .map((remoteRef) => remoteRef.trim())2510 .pipe(
2414 .filter((remoteRef) => remoteRef.length > 0),2511 Effect.map((trackingRefs) => {
2415 );2512 const refNames = new Set(
2416 const matchingRemoteNames = remoteNames.filter((candidate) =>2513 trackingRefs.stdout
2417 refNames.has(`refs/remotes/${candidate}/${branch}`),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 ),
2418 );2523 );
2524 const matchingRemoteNames = (trackingRemotes ?? "")
2525 .split("\n")
2526 .filter((candidate) => candidate.length > 0);
2419 if (matchingRemoteNames.length > 1) {2527 if (matchingRemoteNames.length > 1) {
2420 return yield* new GitManagerError({2528 return yield* new GitManagerError({
2421 operation: "branchPullRequest",2529 operation: "branchPullRequest",
...@@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () {...@@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () {
2429 }2537 }
2430 }2538 }
2431 const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName;2539 const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName;
2432 const defaultBranch = yield* gitCore2540 const defaultBranch = yield* remember(
2433 .resolveDefaultBranchName(cacheCwd, defaultRemoteName)2541 `defaultBranch\u0000${defaultRemoteName}`,
2434 .pipe(Effect.orElseSucceed(() => null));2542 gitCore.resolveDefaultBranchName(cacheCwd, defaultRemoteName),
2543 ).pipe(Effect.orElseSucceed(() => null));
2435 const cacheKey = prLookupCacheKey(cacheCwd, {2544 const cacheKey = prLookupCacheKey(cacheCwd, {
2436 branch,2545 branch,
2437 upstreamRef,2546 upstreamRef,
...@@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () {...@@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () {
2439 localBranchExists,2548 localBranchExists,
2440 ...(localBranchExists ? {} : { remoteName }),2549 ...(localBranchExists ? {} : { remoteName }),
2441 });2550 });
2442 if (options?.refresh) {2551 if (options.refresh) {
2443 // A completed turn can create a PR or reuse a merged PR's branch.2552 // A completed turn can create a PR or reuse a merged PR's branch.
2444 // Refresh successful answers, but keep failed lookups' retry backoff.2553 // Refresh successful answers, but keep failed lookups' retry backoff.
2445 const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(2554 const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
...@@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () {...@@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () {
2457 cacheCwd,2566 cacheCwd,
2458 branch,2567 branch,
2459 identityRemoteName(cached.headContext),2568 identityRemoteName(cached.headContext),
2569 options.freshGitState,
2460 );2570 );
2461 const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>2571 const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>
2462 !(2572 !(
...@@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () {...@@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () {
2480 cacheCwd,2590 cacheCwd,
2481 branch,2591 branch,
2482 identityRemoteName(cached.headContext),2592 identityRemoteName(cached.headContext),
2593 true,
2483 );2594 );
2484 if (2595 if (
2485 !canVerifyIdentity(cached.headContext, refreshedIdentity) ||2596 !canVerifyIdentity(cached.headContext, refreshedIdentity) ||
...@@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () {...@@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () {
2511 repositoryKey: pullRequestRepositoryKey(latest.url),2622 repositoryKey: pullRequestRepositoryKey(latest.url),
2512 };2623 };
2513 });2624 });
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 });
2514 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(2641 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
2515 "invalidateLocalStatus",2642 "invalidateLocalStatus",
2516 )(function* (cwd) {2643 )(function* (cwd) {
...@@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () {...@@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () {
2529 // refresh); it also bypasses the slow PR-lookup cache. The periodic2656 // refresh); it also bypasses the slow PR-lookup cache. The periodic
2530 // status poll only invalidates local/remote and keeps the PR cache warm.2657 // status poll only invalidates local/remote and keeps the PR cache warm.
2531 yield* bumpPrLookupEpoch(cwd);2658 yield* bumpPrLookupEpoch(cwd);
2659 yield* forgetPrLookupGitState(cwd);
2532 },2660 },
2533 );2661 );
25342662
apps/server/src/orchestration-v2/ThreadPullRequestService.ts+4-1
...@@ -275,7 +275,10 @@ export const make = Effect.gen(function* () {...@@ -275,7 +275,10 @@ export const make = Effect.gen(function* () {
275 if (updates.length === 0) return;275 if (updates.length === 0) return;
276276
277 if (detected !== null && first.branch !== null) {277 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 );
279 const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, {282 const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, {
280 refresh: true,283 refresh: true,
281 });284 });