| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | 3 | import * as Cache from "effect/Cache"; |
| 4 | import * as Clock from "effect/Clock"; |
| 4 | 5 | import * as Context from "effect/Context"; |
| 5 | 6 | import * as Crypto from "effect/Crypto"; |
| 6 | 7 | import * as ByteSize from "effect/ByteSize"; |
| ... | ... | @@ -131,10 +132,14 @@ export class GitManager extends Context.Service< |
| 131 | 132 | input: VcsStatusInput, |
| 132 | 133 | options?: GitRemoteStatusOptions, |
| 133 | 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 | 140 | readonly branchPullRequest: ( |
| 136 | 141 | input: { readonly cwd: string; readonly branch: string }, |
| 137 | | options?: { readonly refresh?: boolean }, |
| 142 | options?: { readonly refresh?: boolean; readonly freshGitState?: boolean }, |
| 138 | 143 | ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>; |
| 139 | 144 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| 140 | 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 | 188 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); |
| 184 | 189 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); |
| 185 | 190 | const 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. |
| 194 | const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5); |
| 186 | 195 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); |
| 187 | 196 | |
| 188 | 197 | /** |
| ... | ... | @@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () { |
| 1491 | 1500 | { concurrency: "unbounded" }, |
| 1492 | 1501 | ); |
| 1493 | 1502 | |
| 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 | 1503 | const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( |
| 1509 | 1504 | cwd: string, |
| 1510 | 1505 | details: { branch: string; upstreamRef: string | null; remoteName?: string }, |
| ... | ... | @@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () { |
| 2357 | 2352 | }); |
| 2358 | 2353 | return mergeGitStatusParts(local, remote); |
| 2359 | 2354 | }); |
| 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; |
| 2368 | 2400 | }); |
| 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 ?? "") |
| 2370 | 2462 | .split("\n") |
| 2371 | 2463 | .map((remoteName) => remoteName.trim()) |
| 2372 | 2464 | .filter((remoteName) => remoteName.length > 0); |
| 2373 | 2465 | const [firstRemoteName] = remoteNames; |
| 2374 | 2466 | 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 | ); |
| 2384 | 2481 | const expectedRefName = `refs/heads/${branch}`; |
| 2385 | | const exactBranch = branchRef.stdout |
| 2482 | const exactBranch = (branchRef ?? "") |
| 2386 | 2483 | .split("\n") |
| 2387 | 2484 | .find((line) => line.split("\u0000", 1)[0] === expectedRefName); |
| 2388 | 2485 | const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = |
| ... | ... | @@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () { |
| 2402 | 2499 | const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); |
| 2403 | 2500 | upstreamRef = `${remoteName}/${upstreamBranch}`; |
| 2404 | 2501 | } 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 | ), |
| 2418 | 2523 | ); |
| 2524 | const matchingRemoteNames = (trackingRemotes ?? "") |
| 2525 | .split("\n") |
| 2526 | .filter((candidate) => candidate.length > 0); |
| 2419 | 2527 | if (matchingRemoteNames.length > 1) { |
| 2420 | 2528 | return yield* new GitManagerError({ |
| 2421 | 2529 | operation: "branchPullRequest", |
| ... | ... | @@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () { |
| 2429 | 2537 | } |
| 2430 | 2538 | } |
| 2431 | 2539 | 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)); |
| 2435 | 2544 | const cacheKey = prLookupCacheKey(cacheCwd, { |
| 2436 | 2545 | branch, |
| 2437 | 2546 | upstreamRef, |
| ... | ... | @@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () { |
| 2439 | 2548 | localBranchExists, |
| 2440 | 2549 | ...(localBranchExists ? {} : { remoteName }), |
| 2441 | 2550 | }); |
| 2442 | | if (options?.refresh) { |
| 2551 | if (options.refresh) { |
| 2443 | 2552 | // A completed turn can create a PR or reuse a merged PR's branch. |
| 2444 | 2553 | // Refresh successful answers, but keep failed lookups' retry backoff. |
| 2445 | 2554 | const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( |
| ... | ... | @@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () { |
| 2457 | 2566 | cacheCwd, |
| 2458 | 2567 | branch, |
| 2459 | 2568 | identityRemoteName(cached.headContext), |
| 2569 | options.freshGitState, |
| 2460 | 2570 | ); |
| 2461 | 2571 | const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => |
| 2462 | 2572 | !( |
| ... | ... | @@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () { |
| 2480 | 2590 | cacheCwd, |
| 2481 | 2591 | branch, |
| 2482 | 2592 | identityRemoteName(cached.headContext), |
| 2593 | true, |
| 2483 | 2594 | ); |
| 2484 | 2595 | if ( |
| 2485 | 2596 | !canVerifyIdentity(cached.headContext, refreshedIdentity) || |
| ... | ... | @@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () { |
| 2511 | 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 | 2641 | const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( |
| 2515 | 2642 | "invalidateLocalStatus", |
| 2516 | 2643 | )(function* (cwd) { |
| ... | ... | @@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () { |
| 2529 | 2656 | // refresh); it also bypasses the slow PR-lookup cache. The periodic |
| 2530 | 2657 | // status poll only invalidates local/remote and keeps the PR cache warm. |
| 2531 | 2658 | yield* bumpPrLookupEpoch(cwd); |
| 2659 | yield* forgetPrLookupGitState(cwd); |
| 2532 | 2660 | }, |
| 2533 | 2661 | ); |
| 2534 | 2662 | |