| author | |
| committer | |
| log | 376c149eac201fd1492c7b7512a1de96c3b345b7 |
| tree | 5f633841dc0ec7374a1fd3ecdb12cd1f5eeed7da |
| parent | 7e2bb475042ccfd196779831e69f5b19a38a2423 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>9 files changed, 465 insertions(+), 36 deletions(-)
apps/server/src/git/GitManager.test.ts+179-1| ... | @@ -50,6 +50,8 @@ interface FakeGhScenario { | ... | @@ -50,6 +50,8 @@ interface FakeGhScenario { |
| 50 | }; | 50 | }; |
| 51 | repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>; | 51 | repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>; |
| 52 | failWith?: GitHubCli.GitHubCliError; | 52 | failWith?: GitHubCli.GitHubCliError; |
| 53 | /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ | ||
| 54 | failAfterCalls?: number; | ||
| 53 | } | 55 | } |
| 54 | 56 | ||
| 55 | function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { | 57 | function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { |
| ... | @@ -382,7 +384,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -382,7 +384,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 382 | const args = [...input.args]; | 384 | const args = [...input.args]; |
| 383 | ghCalls.push(args.join(" ")); | 385 | ghCalls.push(args.join(" ")); |
| 384 | 386 | ||
| 385 | if (scenario.failWith) { | 387 | if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { |
| 386 | return Effect.fail(scenario.failWith); | 388 | return Effect.fail(scenario.failWith); |
| 387 | } | 389 | } |
| 388 | 390 | ||
| ... | @@ -1336,6 +1338,182 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1336,6 +1338,182 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1336 | }), | 1338 | }), |
| 1337 | ); | 1339 | ); |
| 1338 | 1340 | ||
| 1341 | it.effect("status keeps the last known PR when a later lookup fails", () => | ||
| 1342 | Effect.gen(function* () { | ||
| 1343 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1344 | yield* initRepo(repoDir); | ||
| 1345 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); | ||
| 1346 | const remoteDir = yield* createBareRemote(); | ||
| 1347 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1348 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); | ||
| 1349 | |||
| 1350 | const existingPr = { | ||
| 1351 | number: 214, | ||
| 1352 | title: "Sticky PR", | ||
| 1353 | url: "https://github.com/pingdotgg/codething-mvp/pull/214", | ||
| 1354 | baseRefName: "main", | ||
| 1355 | headRefName: "feature/pr-sticky", | ||
| 1356 | }; | ||
| 1357 | const { manager } = yield* makeManager({ | ||
| 1358 | ghScenario: { | ||
| 1359 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1360 | prListSequence: [JSON.stringify([existingPr])], | ||
| 1361 | failWith: new GitHubCli.GitHubCliUnavailableError({ | ||
| 1362 | command: "gh", | ||
| 1363 | cwd: repoDir, | ||
| 1364 | cause: new Error("rate limited"), | ||
| 1365 | }), | ||
| 1366 | failAfterCalls: 1, | ||
| 1367 | }, | ||
| 1368 | }); | ||
| 1369 | |||
| 1370 | const first = yield* manager.status({ cwd: repoDir }); | ||
| 1371 | expect(first.pr?.number).toBe(214); | ||
| 1372 | |||
| 1373 | // An explicit invalidation (user refresh, git action) bypasses the PR | ||
| 1374 | // cache and forces a live lookup — which now fails. The badge must keep | ||
| 1375 | // the last known PR instead of blanking out. | ||
| 1376 | yield* manager.invalidateStatus(repoDir); | ||
| 1377 | const second = yield* manager.status({ cwd: repoDir }); | ||
| 1378 | expect(second.pr?.number).toBe(214); | ||
| 1379 | }), | ||
| 1380 | ); | ||
| 1381 | |||
| 1382 | it.effect( | ||
| 1383 | "status does not reuse a stale PR after the branch is retargeted to a different upstream", | ||
| 1384 | () => | ||
| 1385 | Effect.gen(function* () { | ||
| 1386 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1387 | yield* initRepo(repoDir); | ||
| 1388 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]); | ||
| 1389 | |||
| 1390 | const originRemote = yield* createBareRemote(); | ||
| 1391 | yield* runGit(repoDir, ["remote", "add", "origin", originRemote]); | ||
| 1392 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]); | ||
| 1393 | |||
| 1394 | const existingPr = { | ||
| 1395 | number: 214, | ||
| 1396 | title: "Sticky PR", | ||
| 1397 | url: "https://github.com/pingdotgg/codething-mvp/pull/214", | ||
| 1398 | baseRefName: "main", | ||
| 1399 | headRefName: "feature/pr-retarget", | ||
| 1400 | }; | ||
| 1401 | const { manager } = yield* makeManager({ | ||
| 1402 | ghScenario: { | ||
| 1403 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1404 | prListSequence: [JSON.stringify([existingPr])], | ||
| 1405 | failWith: new GitHubCli.GitHubCliUnavailableError({ | ||
| 1406 | command: "gh", | ||
| 1407 | cwd: repoDir, | ||
| 1408 | cause: new Error("rate limited"), | ||
| 1409 | }), | ||
| 1410 | failAfterCalls: 1, | ||
| 1411 | }, | ||
| 1412 | }); | ||
| 1413 | |||
| 1414 | const first = yield* manager.status({ cwd: repoDir }); | ||
| 1415 | expect(first.pr?.number).toBe(214); | ||
| 1416 | |||
| 1417 | // Retarget the branch to a different remote/upstream (e.g. the PR was | ||
| 1418 | // reopened against a fork). The previously cached PR belonged to the | ||
| 1419 | // old upstream and must not be shown against the new one. | ||
| 1420 | const forkRemote = yield* createBareRemote(); | ||
| 1421 | yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]); | ||
| 1422 | yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]); | ||
| 1423 | yield* runGit(repoDir, [ | ||
| 1424 | "branch", | ||
| 1425 | "--set-upstream-to=fork/feature/pr-retarget", | ||
| 1426 | "feature/pr-retarget", | ||
| 1427 | ]); | ||
| 1428 | |||
| 1429 | yield* manager.invalidateStatus(repoDir); | ||
| 1430 | const second = yield* manager.status({ cwd: repoDir }); | ||
| 1431 | expect(second.pr).toBeNull(); | ||
| 1432 | }), | ||
| 1433 | ); | ||
| 1434 | |||
| 1435 | it.effect("status keeps the last known PR when the branch gains its first upstream", () => | ||
| 1436 | Effect.gen(function* () { | ||
| 1437 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1438 | yield* initRepo(repoDir); | ||
| 1439 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); | ||
| 1440 | const remoteDir = yield* createBareRemote(); | ||
| 1441 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1442 | |||
| 1443 | const existingPr = { | ||
| 1444 | number: 215, | ||
| 1445 | title: "Sticky first-push PR", | ||
| 1446 | url: "https://github.com/pingdotgg/codething-mvp/pull/215", | ||
| 1447 | baseRefName: "main", | ||
| 1448 | headRefName: "feature/pr-sticky-first-push", | ||
| 1449 | }; | ||
| 1450 | const { manager } = yield* makeManager({ | ||
| 1451 | ghScenario: { | ||
| 1452 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1453 | prListSequence: [JSON.stringify([existingPr])], | ||
| 1454 | failWith: new GitHubCli.GitHubCliUnavailableError({ | ||
| 1455 | command: "gh", | ||
| 1456 | cwd: repoDir, | ||
| 1457 | cause: new Error("rate limited"), | ||
| 1458 | }), | ||
| 1459 | failAfterCalls: 1, | ||
| 1460 | }, | ||
| 1461 | }); | ||
| 1462 | |||
| 1463 | const first = yield* manager.status({ cwd: repoDir }); | ||
| 1464 | expect(first.pr?.number).toBe(215); | ||
| 1465 | |||
| 1466 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); | ||
| 1467 | yield* manager.invalidateStatus(repoDir); | ||
| 1468 | |||
| 1469 | const second = yield* manager.status({ cwd: repoDir }); | ||
| 1470 | expect(second.pr?.number).toBe(215); | ||
| 1471 | }), | ||
| 1472 | ); | ||
| 1473 | |||
| 1474 | it.effect("status drops the last known PR when the tracked remote is repointed", () => | ||
| 1475 | Effect.gen(function* () { | ||
| 1476 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1477 | yield* initRepo(repoDir); | ||
| 1478 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); | ||
| 1479 | const originalRemoteDir = yield* createBareRemote(); | ||
| 1480 | yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); | ||
| 1481 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); | ||
| 1482 | |||
| 1483 | const existingPr = { | ||
| 1484 | number: 216, | ||
| 1485 | title: "Old remote PR", | ||
| 1486 | url: "https://github.com/pingdotgg/codething-mvp/pull/216", | ||
| 1487 | baseRefName: "main", | ||
| 1488 | headRefName: "feature/pr-repointed", | ||
| 1489 | }; | ||
| 1490 | const { manager } = yield* makeManager({ | ||
| 1491 | ghScenario: { | ||
| 1492 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1493 | prListSequence: [JSON.stringify([existingPr])], | ||
| 1494 | failWith: new GitHubCli.GitHubCliUnavailableError({ | ||
| 1495 | command: "gh", | ||
| 1496 | cwd: repoDir, | ||
| 1497 | cause: new Error("rate limited"), | ||
| 1498 | }), | ||
| 1499 | failAfterCalls: 1, | ||
| 1500 | }, | ||
| 1501 | }); | ||
| 1502 | |||
| 1503 | const first = yield* manager.status({ cwd: repoDir }); | ||
| 1504 | expect(first.pr?.number).toBe(216); | ||
| 1505 | |||
| 1506 | const replacementRemoteDir = yield* createBareRemote(); | ||
| 1507 | yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); | ||
| 1508 | yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); | ||
| 1509 | yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); | ||
| 1510 | yield* manager.invalidateStatus(repoDir); | ||
| 1511 | |||
| 1512 | const second = yield* manager.status({ cwd: repoDir }); | ||
| 1513 | expect(second.pr).toBeNull(); | ||
| 1514 | }), | ||
| 1515 | ); | ||
| 1516 | |||
| 1339 | it.effect("creates a commit when working tree is dirty", () => | 1517 | it.effect("creates a commit when working tree is dirty", () => |
| 1340 | Effect.gen(function* () { | 1518 | Effect.gen(function* () { |
| 1341 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 1519 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/GitManager.ts+158-15| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | import { | 32 | import { |
| 33 | detectSourceControlProviderFromGitRemoteUrl, | 33 | detectSourceControlProviderFromGitRemoteUrl, |
| 34 | mergeGitStatusParts, | 34 | mergeGitStatusParts, |
| 35 | normalizeGitRemoteUrl, | ||
| 35 | resolveAutoFeatureBranchName, | 36 | resolveAutoFeatureBranchName, |
| 36 | sanitizeBranchFragment, | 37 | sanitizeBranchFragment, |
| 37 | sanitizeFeatureBranchName, | 38 | sanitizeFeatureBranchName, |
| ... | @@ -95,6 +96,9 @@ const SHORT_SHA_LENGTH = 7; | ... | @@ -95,6 +96,9 @@ const SHORT_SHA_LENGTH = 7; |
| 95 | const TOAST_DESCRIPTION_MAX = 72; | 96 | const TOAST_DESCRIPTION_MAX = 72; |
| 96 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); | 97 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); |
| 97 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; | 98 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; |
| 99 | const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); | ||
| 100 | const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); | ||
| 101 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; | ||
| 98 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; | 102 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; |
| 99 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | 103 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 100 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; | 104 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; |
| ... | @@ -142,6 +146,7 @@ interface BranchHeadContext { | ... | @@ -142,6 +146,7 @@ interface BranchHeadContext { |
| 142 | headSelectors: ReadonlyArray<string>; | 146 | headSelectors: ReadonlyArray<string>; |
| 143 | preferredHeadSelector: string; | 147 | preferredHeadSelector: string; |
| 144 | remoteName: string | null; | 148 | remoteName: string | null; |
| 149 | headRemoteUrlKey: string | null; | ||
| 145 | headRepositoryNameWithOwner: string | null; | 150 | headRepositoryNameWithOwner: string | null; |
| 146 | headRepositoryOwnerLogin: string | null; | 151 | headRepositoryOwnerLogin: string | null; |
| 147 | isCrossRepository: boolean; | 152 | isCrossRepository: boolean; |
| ... | @@ -768,6 +773,145 @@ export const make = Effect.gen(function* () { | ... | @@ -768,6 +773,145 @@ export const make = Effect.gen(function* () { |
| 768 | normalizeStatusCacheKey(cwd).pipe( | 773 | normalizeStatusCacheKey(cwd).pipe( |
| 769 | Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), | 774 | Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), |
| 770 | ); | 775 | ); |
| 776 | // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh | ||
| 777 | // on their own, slower cadence: ahead/behind counts stay fresh on every | ||
| 778 | // status poll while the PR association is re-fetched at most once per | ||
| 779 | // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump | ||
| 780 | // the epoch (invalidateStatus) to bypass the cache immediately. | ||
| 781 | const prLookupEpochByCwd = new Map<string, number>(); | ||
| 782 | const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; | ||
| 783 | const bumpPrLookupEpoch = (cwd: string) => | ||
| 784 | normalizeStatusCacheKey(cwd).pipe( | ||
| 785 | Effect.map((cacheKey) => { | ||
| 786 | prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); | ||
| 787 | }), | ||
| 788 | ); | ||
| 789 | // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the | ||
| 790 | // segments can contain a NUL byte, and refs are never empty, so "" decodes | ||
| 791 | // back to a null upstreamRef. | ||
| 792 | const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => | ||
| 793 | [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); | ||
| 794 | const prLookupCache = yield* Cache.makeWith( | ||
| 795 | (key: string) => { | ||
| 796 | const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); | ||
| 797 | const details = { | ||
| 798 | branch, | ||
| 799 | upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, | ||
| 800 | }; | ||
| 801 | return resolveBranchHeadContext(cwd, details).pipe( | ||
| 802 | Effect.flatMap((headContext) => | ||
| 803 | findLatestPrForHeadContext(cwd, headContext).pipe( | ||
| 804 | Effect.map((latest) => ({ latest, headContext })), | ||
| 805 | ), | ||
| 806 | ), | ||
| 807 | ); | ||
| 808 | }, | ||
| 809 | { | ||
| 810 | capacity: PR_LOOKUP_CACHE_CAPACITY, | ||
| 811 | timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), | ||
| 812 | }, | ||
| 813 | ); | ||
| 814 | // A transient lookup failure (rate limit, network blip) must not clear an | ||
| 815 | // already-known PR badge, so the last successful answer per branch sticks | ||
| 816 | // around as the fallback. Keep the resolved head context with it so a | ||
| 817 | // branch retargeted to another remote/fork cannot inherit the old badge. | ||
| 818 | interface LastKnownPr { | ||
| 819 | readonly pr: ReturnType<typeof toStatusPr> | null; | ||
| 820 | readonly upstreamRef: string | null; | ||
| 821 | readonly headBranch: string; | ||
| 822 | readonly remoteName: string | null; | ||
| 823 | readonly headRemoteUrlKey: string | null; | ||
| 824 | } | ||
| 825 | const lastKnownPrByBranchKey = new Map<string, LastKnownPr>(); | ||
| 826 | const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { | ||
| 827 | if ( | ||
| 828 | !lastKnownPrByBranchKey.has(branchKey) && | ||
| 829 | lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY | ||
| 830 | ) { | ||
| 831 | const oldestKey = lastKnownPrByBranchKey.keys().next().value; | ||
| 832 | if (oldestKey !== undefined) { | ||
| 833 | lastKnownPrByBranchKey.delete(oldestKey); | ||
| 834 | } | ||
| 835 | } | ||
| 836 | lastKnownPrByBranchKey.set(branchKey, entry); | ||
| 837 | }; | ||
| 838 | const resolveLastKnownPr = ( | ||
| 839 | branchKey: string, | ||
| 840 | current: Pick<LastKnownPr, "upstreamRef" | "headBranch" | "remoteName" | "headRemoteUrlKey">, | ||
| 841 | ): ReturnType<typeof toStatusPr> | null => { | ||
| 842 | const lastKnown = lastKnownPrByBranchKey.get(branchKey); | ||
| 843 | if (!lastKnown) return null; | ||
| 844 | if (lastKnown.headBranch !== current.headBranch) { | ||
| 845 | return null; | ||
| 846 | } | ||
| 847 | |||
| 848 | // The normalized URL catches both remote-alias changes and an existing | ||
| 849 | // alias being repointed. It also lets an upstream appear after `push -u` | ||
| 850 | // without invalidating the fallback when it still targets the same repo. | ||
| 851 | if (lastKnown.headRemoteUrlKey !== null || current.headRemoteUrlKey !== null) { | ||
| 852 | return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; | ||
| 853 | } | ||
| 854 | |||
| 855 | // If neither remote URL is available, fall back to the remote identity | ||
| 856 | // encoded by tracked branches. A null-to-non-null transition is allowed | ||
| 857 | // because that is the expected first-push case. | ||
| 858 | if (lastKnown.upstreamRef !== null && current.upstreamRef !== null) { | ||
| 859 | return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; | ||
| 860 | } | ||
| 861 | return lastKnown.pr; | ||
| 862 | }; | ||
| 863 | const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( | ||
| 864 | cwd: string, | ||
| 865 | details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, | ||
| 866 | ) { | ||
| 867 | // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first | ||
| 868 | // `push -u`) must not orphan the fallback value for the same branch. | ||
| 869 | const branchKey = `${cwd}\u0000${details.branch}`; | ||
| 870 | return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( | ||
| 871 | Effect.map(({ latest, headContext }) => { | ||
| 872 | if (!latest) return { pr: null, headContext }; | ||
| 873 | // On the default branch, only surface open PRs. | ||
| 874 | // Merged/closed matches are usually reverse-merge history, not the thread's PR context. | ||
| 875 | if (details.isDefaultBranch && latest.state !== "open") { | ||
| 876 | return { pr: null, headContext }; | ||
| 877 | } | ||
| 878 | return { pr: toStatusPr(latest), headContext }; | ||
| 879 | }), | ||
| 880 | Effect.tap(({ pr, headContext }) => | ||
| 881 | Effect.sync(() => | ||
| 882 | rememberLastKnownPr(branchKey, { | ||
| 883 | pr, | ||
| 884 | upstreamRef: details.upstreamRef, | ||
| 885 | headBranch: headContext.headBranch, | ||
| 886 | remoteName: headContext.remoteName, | ||
| 887 | headRemoteUrlKey: headContext.headRemoteUrlKey, | ||
| 888 | }), | ||
| 889 | ), | ||
| 890 | ), | ||
| 891 | Effect.map(({ pr }) => pr), | ||
| 892 | Effect.catch((error) => | ||
| 893 | Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( | ||
| 894 | Effect.annotateLogs({ | ||
| 895 | operation: "lookupStatusPr", | ||
| 896 | branch: details.branch, | ||
| 897 | errorTag: | ||
| 898 | typeof error === "object" && error !== null && "_tag" in error | ||
| 899 | ? String(error._tag) | ||
| 900 | : typeof error, | ||
| 901 | }), | ||
| 902 | Effect.andThen(resolveBranchHeadContext(cwd, details)), | ||
| 903 | Effect.map((headContext) => | ||
| 904 | resolveLastKnownPr(branchKey, { | ||
| 905 | upstreamRef: details.upstreamRef, | ||
| 906 | headBranch: headContext.headBranch, | ||
| 907 | remoteName: headContext.remoteName, | ||
| 908 | headRemoteUrlKey: headContext.headRemoteUrlKey, | ||
| 909 | }), | ||
| 910 | ), | ||
| 911 | ), | ||
| 912 | ), | ||
| 913 | ); | ||
| 914 | }); | ||
| 771 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( | 915 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( |
| 772 | cwd: string, | 916 | cwd: string, |
| 773 | options?: GitVcsDriver.GitRemoteStatusOptions, | 917 | options?: GitVcsDriver.GitRemoteStatusOptions, |
| ... | @@ -781,19 +925,11 @@ export const make = Effect.gen(function* () { | ... | @@ -781,19 +925,11 @@ export const make = Effect.gen(function* () { |
| 781 | 925 | ||
| 782 | const pr = | 926 | const pr = |
| 783 | details.branch !== null | 927 | details.branch !== null |
| 784 | ? yield* findLatestPr(cwd, { | 928 | ? yield* lookupStatusPr(cwd, { |
| 785 | branch: details.branch, | 929 | branch: details.branch, |
| 786 | upstreamRef: details.upstreamRef, | 930 | upstreamRef: details.upstreamRef, |
| 787 | }).pipe( | 931 | isDefaultBranch: details.isDefaultBranch, |
| 788 | Effect.map((latest) => { | 932 | }) |
| 789 | if (!latest) return null; | ||
| 790 | // On the default branch, only surface open PRs. | ||
| 791 | // Merged/closed matches are usually reverse-merge history, not the thread's PR context. | ||
| 792 | if (details.isDefaultBranch && latest.state !== "open") return null; | ||
| 793 | return toStatusPr(latest); | ||
| 794 | }), | ||
| 795 | Effect.orElseSucceed(() => null), | ||
| 796 | ) | ||
| 797 | : null; | 933 | : null; |
| 798 | 934 | ||
| 799 | return { | 935 | return { |
| ... | @@ -837,6 +973,7 @@ export const make = Effect.gen(function* () { | ... | @@ -837,6 +973,7 @@ export const make = Effect.gen(function* () { |
| 837 | ) { | 973 | ) { |
| 838 | if (!remoteName) { | 974 | if (!remoteName) { |
| 839 | return { | 975 | return { |
| 976 | remoteUrlKey: null, | ||
| 840 | repositoryNameWithOwner: null, | 977 | repositoryNameWithOwner: null, |
| 841 | ownerLogin: null, | 978 | ownerLogin: null, |
| 842 | }; | 979 | }; |
| ... | @@ -845,6 +982,7 @@ export const make = Effect.gen(function* () { | ... | @@ -845,6 +982,7 @@ export const make = Effect.gen(function* () { |
| 845 | const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); | 982 | const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); |
| 846 | const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | 983 | const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); |
| 847 | return { | 984 | return { |
| 985 | remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, | ||
| 848 | repositoryNameWithOwner, | 986 | repositoryNameWithOwner, |
| 849 | ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), | 987 | ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), |
| 850 | }; | 988 | }; |
| ... | @@ -915,6 +1053,9 @@ export const make = Effect.gen(function* () { | ... | @@ -915,6 +1053,9 @@ export const make = Effect.gen(function* () { |
| 915 | preferredHeadSelector: | 1053 | preferredHeadSelector: |
| 916 | ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, | 1054 | ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, |
| 917 | remoteName, | 1055 | remoteName, |
| 1056 | headRemoteUrlKey: | ||
| 1057 | remoteRepository.remoteUrlKey ?? | ||
| 1058 | (remoteName === null ? originRepository.remoteUrlKey : null), | ||
| 918 | headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, | 1059 | headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, |
| 919 | headRepositoryOwnerLogin: remoteRepository.ownerLogin, | 1060 | headRepositoryOwnerLogin: remoteRepository.ownerLogin, |
| 920 | isCrossRepository, | 1061 | isCrossRepository, |
| ... | @@ -960,11 +1101,10 @@ export const make = Effect.gen(function* () { | ... | @@ -960,11 +1101,10 @@ export const make = Effect.gen(function* () { |
| 960 | return null; | 1101 | return null; |
| 961 | }); | 1102 | }); |
| 962 | 1103 | ||
| 963 | const findLatestPr = Effect.fn("findLatestPr")(function* ( | 1104 | const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( |
| 964 | cwd: string, | 1105 | cwd: string, |
| 965 | details: { branch: string; upstreamRef: string | null }, | 1106 | headContext: BranchHeadContext, |
| 966 | ) { | 1107 | ) { |
| 967 | const headContext = yield* resolveBranchHeadContext(cwd, details); | ||
| 968 | const parsedByNumber = new Map<number, PullRequestInfo>(); | 1108 | const parsedByNumber = new Map<number, PullRequestInfo>(); |
| 969 | 1109 | ||
| 970 | for (const headSelector of headContext.headSelectors) { | 1110 | for (const headSelector of headContext.headSelectors) { |
| ... | @@ -991,7 +1131,6 @@ export const make = Effect.gen(function* () { | ... | @@ -991,7 +1131,6 @@ export const make = Effect.gen(function* () { |
| 991 | } | 1131 | } |
| 992 | return parsed[0] ?? null; | 1132 | return parsed[0] ?? null; |
| 993 | }); | 1133 | }); |
| 994 | |||
| 995 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( | 1134 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( |
| 996 | cwd: string, | 1135 | cwd: string, |
| 997 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, | 1136 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, |
| ... | @@ -1442,6 +1581,10 @@ export const make = Effect.gen(function* () { | ... | @@ -1442,6 +1581,10 @@ export const make = Effect.gen(function* () { |
| 1442 | function* (cwd) { | 1581 | function* (cwd) { |
| 1443 | yield* invalidateLocalStatusResultCache(cwd); | 1582 | yield* invalidateLocalStatusResultCache(cwd); |
| 1444 | yield* invalidateRemoteStatusResultCache(cwd); | 1583 | yield* invalidateRemoteStatusResultCache(cwd); |
| 1584 | // Full invalidation is the explicit-freshness path (git actions, user | ||
| 1585 | // refresh); it also bypasses the slow PR-lookup cache. The periodic | ||
| 1586 | // status poll only invalidates local/remote and keeps the PR cache warm. | ||
| 1587 | yield* bumpPrLookupEpoch(cwd); | ||
| 1445 | }, | 1588 | }, |
| 1446 | ); | 1589 | ); |
| 1447 | 1590 |
apps/server/src/server.test.ts+3| ... | @@ -5285,6 +5285,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5285,6 +5285,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5285 | gitManager: { | 5285 | gitManager: { |
| 5286 | invalidateLocalStatus: () => Effect.void, | 5286 | invalidateLocalStatus: () => Effect.void, |
| 5287 | invalidateRemoteStatus: () => Effect.void, | 5287 | invalidateRemoteStatus: () => Effect.void, |
| 5288 | invalidateStatus: () => Effect.void, | ||
| 5288 | localStatus: () => | 5289 | localStatus: () => |
| 5289 | Effect.succeed({ | 5290 | Effect.succeed({ |
| 5290 | isRepo: true, | 5291 | isRepo: true, |
| ... | @@ -5331,6 +5332,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5331,6 +5332,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5331 | gitManager: { | 5332 | gitManager: { |
| 5332 | invalidateLocalStatus: () => Effect.void, | 5333 | invalidateLocalStatus: () => Effect.void, |
| 5333 | invalidateRemoteStatus: () => Effect.void, | 5334 | invalidateRemoteStatus: () => Effect.void, |
| 5335 | invalidateStatus: () => Effect.void, | ||
| 5334 | localStatus: () => | 5336 | localStatus: () => |
| 5335 | Effect.succeed({ | 5337 | Effect.succeed({ |
| 5336 | isRepo: true, | 5338 | isRepo: true, |
| ... | @@ -5407,6 +5409,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5407,6 +5409,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5407 | gitManager: { | 5409 | gitManager: { |
| 5408 | invalidateLocalStatus: () => Effect.void, | 5410 | invalidateLocalStatus: () => Effect.void, |
| 5409 | invalidateRemoteStatus: () => Effect.void, | 5411 | invalidateRemoteStatus: () => Effect.void, |
| 5412 | invalidateStatus: () => Effect.void, | ||
| 5410 | localStatus: () => | 5413 | localStatus: () => |
| 5411 | Deferred.succeed(localRefreshStarted, undefined).pipe( | 5414 | Deferred.succeed(localRefreshStarted, undefined).pipe( |
| 5412 | Effect.ignore, | 5415 | Effect.ignore, |
apps/server/src/sourceControl/GitHubCli.test.ts+55| ... | @@ -208,6 +208,61 @@ describe("GitHubCli.layer", () => { | ... | @@ -208,6 +208,61 @@ describe("GitHubCli.layer", () => { |
| 208 | }).pipe(Effect.provide(layer)), | 208 | }).pipe(Effect.provide(layer)), |
| 209 | ); | 209 | ); |
| 210 | 210 | ||
| 211 | it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => | ||
| 212 | // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as | ||
| 213 | // {id, name} only. These entries must decode instead of being dropped, | ||
| 214 | // with nameWithOwner rebuilt from the owner login. | ||
| 215 | Effect.gen(function* () { | ||
| 216 | mockRun.mockReturnValueOnce( | ||
| 217 | Effect.succeed( | ||
| 218 | processOutput( | ||
| 219 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 220 | JSON.stringify([ | ||
| 221 | { | ||
| 222 | number: 2829, | ||
| 223 | title: "Codex turn mapping", | ||
| 224 | url: "https://github.com/pingdotgg/codething-mvp/pull/2829", | ||
| 225 | baseRefName: "main", | ||
| 226 | headRefName: "t3code/codex-turn-mapping", | ||
| 227 | state: "OPEN", | ||
| 228 | mergedAt: null, | ||
| 229 | isCrossRepository: false, | ||
| 230 | headRepository: { | ||
| 231 | id: "R_kgDORLtfbQ", | ||
| 232 | name: "codething-mvp", | ||
| 233 | }, | ||
| 234 | headRepositoryOwner: { | ||
| 235 | id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", | ||
| 236 | login: "pingdotgg", | ||
| 237 | }, | ||
| 238 | }, | ||
| 239 | ]), | ||
| 240 | ), | ||
| 241 | ), | ||
| 242 | ); | ||
| 243 | |||
| 244 | const gh = yield* GitHubCli.GitHubCli; | ||
| 245 | const result = yield* gh.listOpenPullRequests({ | ||
| 246 | cwd: "/repo", | ||
| 247 | headSelector: "t3code/codex-turn-mapping", | ||
| 248 | }); | ||
| 249 | |||
| 250 | assert.deepStrictEqual(result, [ | ||
| 251 | { | ||
| 252 | number: 2829, | ||
| 253 | title: "Codex turn mapping", | ||
| 254 | url: "https://github.com/pingdotgg/codething-mvp/pull/2829", | ||
| 255 | baseRefName: "main", | ||
| 256 | headRefName: "t3code/codex-turn-mapping", | ||
| 257 | state: "open", | ||
| 258 | isCrossRepository: false, | ||
| 259 | headRepositoryNameWithOwner: "pingdotgg/codething-mvp", | ||
| 260 | headRepositoryOwnerLogin: "pingdotgg", | ||
| 261 | }, | ||
| 262 | ]); | ||
| 263 | }).pipe(Effect.provide(layer)), | ||
| 264 | ); | ||
| 265 | |||
| 211 | it.effect("reads repository clone URLs", () => | 266 | it.effect("reads repository clone URLs", () => |
| 212 | Effect.gen(function* () { | 267 | Effect.gen(function* () { |
| 213 | mockRun.mockReturnValueOnce( | 268 | mockRun.mockReturnValueOnce( |
apps/server/src/sourceControl/gitHubPullRequests.ts+13-5| ... | @@ -30,17 +30,21 @@ const GitHubPullRequestSchema = Schema.Struct({ | ... | @@ -30,17 +30,21 @@ const GitHubPullRequestSchema = Schema.Struct({ |
| 30 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | 30 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 31 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | 31 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), |
| 32 | isCrossRepository: Schema.optional(Schema.Boolean), | 32 | isCrossRepository: Schema.optional(Schema.Boolean), |
| 33 | // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was | ||
| 34 | // added later. Both fields stay optional so a version-drifted gh CLI can | ||
| 35 | // never fail the decode and silently drop the PR from the list. | ||
| 33 | headRepository: Schema.optional( | 36 | headRepository: Schema.optional( |
| 34 | Schema.NullOr( | 37 | Schema.NullOr( |
| 35 | Schema.Struct({ | 38 | Schema.Struct({ |
| 36 | nameWithOwner: Schema.String, | 39 | nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), |
| 40 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 37 | }), | 41 | }), |
| 38 | ), | 42 | ), |
| 39 | ), | 43 | ), |
| 40 | headRepositoryOwner: Schema.optional( | 44 | headRepositoryOwner: Schema.optional( |
| 41 | Schema.NullOr( | 45 | Schema.NullOr( |
| 42 | Schema.Struct({ | 46 | Schema.Struct({ |
| 43 | login: Schema.String, | 47 | login: Schema.optional(Schema.NullOr(Schema.String)), |
| 44 | }), | 48 | }), |
| 45 | ), | 49 | ), |
| 46 | ), | 50 | ), |
| ... | @@ -71,11 +75,15 @@ function normalizeGitHubPullRequestState(input: { | ... | @@ -71,11 +75,15 @@ function normalizeGitHubPullRequestState(input: { |
| 71 | function normalizeGitHubPullRequestRecord( | 75 | function normalizeGitHubPullRequestRecord( |
| 72 | raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, | 76 | raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, |
| 73 | ): NormalizedGitHubPullRequestRecord { | 77 | ): NormalizedGitHubPullRequestRecord { |
| 74 | const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); | 78 | const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); |
| 79 | const headRepositoryName = trimOptionalString(raw.headRepository?.name); | ||
| 75 | const headRepositoryOwnerLogin = | 80 | const headRepositoryOwnerLogin = |
| 76 | trimOptionalString(raw.headRepositoryOwner?.login) ?? | 81 | trimOptionalString(raw.headRepositoryOwner?.login) ?? |
| 77 | (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") | 82 | (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); |
| 78 | ? (headRepositoryNameWithOwner.split("/")[0] ?? null) | 83 | const headRepositoryNameWithOwner = |
| 84 | explicitNameWithOwner ?? | ||
| 85 | (headRepositoryOwnerLogin && headRepositoryName | ||
| 86 | ? `${headRepositoryOwnerLogin}/${headRepositoryName}` | ||
| 79 | : null); | 87 | : null); |
| 80 | 88 | ||
| 81 | return { | 89 | return { |
apps/server/src/vcs/GitVcsDriverCore.test.ts+41| ... | @@ -690,6 +690,47 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -690,6 +690,47 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 690 | ); | 690 | ); |
| 691 | }); | 691 | }); |
| 692 | 692 | ||
| 693 | describe("remote operations", () => { | ||
| 694 | it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => | ||
| 695 | Effect.gen(function* () { | ||
| 696 | const cwd = yield* makeTmpDir(); | ||
| 697 | yield* initRepoWithCommit(cwd); | ||
| 698 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 699 | |||
| 700 | yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); | ||
| 701 | |||
| 702 | const reusedForSsh = yield* driver.ensureRemote({ | ||
| 703 | cwd, | ||
| 704 | preferredName: "pingdotgg", | ||
| 705 | url: "git@github.com:pingdotgg/t3code.git", | ||
| 706 | }); | ||
| 707 | assert.equal(reusedForSsh, "origin"); | ||
| 708 | |||
| 709 | const reusedForSshScheme = yield* driver.ensureRemote({ | ||
| 710 | cwd, | ||
| 711 | preferredName: "pingdotgg", | ||
| 712 | url: "ssh://git@github.com/pingdotgg/t3code", | ||
| 713 | }); | ||
| 714 | assert.equal(reusedForSshScheme, "origin"); | ||
| 715 | |||
| 716 | const reusedForSshWithPort = yield* driver.ensureRemote({ | ||
| 717 | cwd, | ||
| 718 | preferredName: "pingdotgg", | ||
| 719 | url: "ssh://git@github.com:22/pingdotgg/t3code.git", | ||
| 720 | }); | ||
| 721 | assert.equal(reusedForSshWithPort, "origin"); | ||
| 722 | |||
| 723 | const addedForFork = yield* driver.ensureRemote({ | ||
| 724 | cwd, | ||
| 725 | preferredName: "octocat", | ||
| 726 | url: "git@github.com:octocat/t3code.git", | ||
| 727 | }); | ||
| 728 | assert.equal(addedForFork, "octocat"); | ||
| 729 | assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); | ||
| 730 | }), | ||
| 731 | ); | ||
| 732 | }); | ||
| 733 | |||
| 693 | describe("commit context", () => { | 734 | describe("commit context", () => { |
| 694 | it.effect("stages selected files and commits only those files", () => | 735 | it.effect("stages selected files and commits only those files", () => |
| 695 | Effect.gen(function* () { | 736 | Effect.gen(function* () { |
apps/server/src/vcs/GitVcsDriverCore.ts+3-11| ... | @@ -25,7 +25,7 @@ import { | ... | @@ -25,7 +25,7 @@ import { |
| 25 | type ReviewDiffPreviewSource, | 25 | type ReviewDiffPreviewSource, |
| 26 | type VcsRef, | 26 | type VcsRef, |
| 27 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 28 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | 28 | import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 29 | import { compactTraceAttributes } from "@t3tools/shared/observability"; | 29 | import { compactTraceAttributes } from "@t3tools/shared/observability"; |
| 30 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 30 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 31 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; | 31 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| ... | @@ -235,14 +235,6 @@ function sanitizeRemoteName(value: string): string { | ... | @@ -235,14 +235,6 @@ function sanitizeRemoteName(value: string): string { |
| 235 | return sanitized.length > 0 ? sanitized : "fork"; | 235 | return sanitized.length > 0 ? sanitized : "fork"; |
| 236 | } | 236 | } |
| 237 | 237 | ||
| 238 | function normalizeRemoteUrl(value: string): string { | ||
| 239 | return value | ||
| 240 | .trim() | ||
| 241 | .replace(/\/+$/g, "") | ||
| 242 | .replace(/\.git$/i, "") | ||
| 243 | .toLowerCase(); | ||
| 244 | } | ||
| 245 | |||
| 246 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | 238 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { |
| 247 | const remotes = new Map<string, string>(); | 239 | const remotes = new Map<string, string>(); |
| 248 | for (const line of stdout.split("\n")) { | 240 | for (const line of stdout.split("\n")) { |
| ... | @@ -1092,7 +1084,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1092,7 +1084,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1092 | "ensureRemote", | 1084 | "ensureRemote", |
| 1093 | )(function* (input) { | 1085 | )(function* (input) { |
| 1094 | const preferredName = sanitizeRemoteName(input.preferredName); | 1086 | const preferredName = sanitizeRemoteName(input.preferredName); |
| 1095 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); | 1087 | const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); |
| 1096 | const remoteFetchUrls = yield* runGitStdout( | 1088 | const remoteFetchUrls = yield* runGitStdout( |
| 1097 | "GitVcsDriver.ensureRemote.listRemoteUrls", | 1089 | "GitVcsDriver.ensureRemote.listRemoteUrls", |
| 1098 | input.cwd, | 1090 | input.cwd, |
| ... | @@ -1100,7 +1092,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1100,7 +1092,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1100 | ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); | 1092 | ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); |
| 1101 | 1093 | ||
| 1102 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { | 1094 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { |
| 1103 | if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { | 1095 | if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { |
| 1104 | return remoteName; | 1096 | return remoteName; |
| 1105 | } | 1097 | } |
| 1106 | } | 1098 | } |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+10| ... | @@ -94,6 +94,11 @@ function makeTestLayer(state: { | ... | @@ -94,6 +94,11 @@ function makeTestLayer(state: { |
| 94 | Effect.sync(() => { | 94 | Effect.sync(() => { |
| 95 | state.remoteInvalidationCalls += 1; | 95 | state.remoteInvalidationCalls += 1; |
| 96 | }), | 96 | }), |
| 97 | invalidateStatus: () => | ||
| 98 | Effect.sync(() => { | ||
| 99 | state.localInvalidationCalls += 1; | ||
| 100 | state.remoteInvalidationCalls += 1; | ||
| 101 | }), | ||
| 97 | }), | 102 | }), |
| 98 | ), | 103 | ), |
| 99 | ); | 104 | ); |
| ... | @@ -206,6 +211,11 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -206,6 +211,11 @@ describe("VcsStatusBroadcaster", () => { |
| 206 | Effect.sync(() => { | 211 | Effect.sync(() => { |
| 207 | state.remoteInvalidationCalls += 1; | 212 | state.remoteInvalidationCalls += 1; |
| 208 | }), | 213 | }), |
| 214 | invalidateStatus: () => | ||
| 215 | Effect.sync(() => { | ||
| 216 | state.localInvalidationCalls += 1; | ||
| 217 | state.remoteInvalidationCalls += 1; | ||
| 218 | }), | ||
| 209 | }), | 219 | }), |
| 210 | ), | 220 | ), |
| 211 | ); | 221 | ); |
apps/server/src/vcs/VcsStatusBroadcaster.ts+3-4| ... | @@ -366,10 +366,9 @@ export const make = Effect.gen(function* () { | ... | @@ -366,10 +366,9 @@ export const make = Effect.gen(function* () { |
| 366 | "VcsStatusBroadcaster.refreshStatus", | 366 | "VcsStatusBroadcaster.refreshStatus", |
| 367 | )(function* (rawCwd) { | 367 | )(function* (rawCwd) { |
| 368 | const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); | 368 | const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); |
| 369 | yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { | 369 | // invalidateStatus (not the two partial invalidations) so an explicit |
| 370 | concurrency: "unbounded", | 370 | // refresh also bypasses GitManager's slow PR-lookup cache. |
| 371 | discard: true, | 371 | yield* workflow.invalidateStatus(cwd); |
| 372 | }); | ||
| 373 | const [local, remote] = yield* Effect.all( | 372 | const [local, remote] = yield* Effect.all( |
| 374 | [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], | 373 | [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], |
| 375 | { concurrency: "unbounded" }, | 374 | { concurrency: "unbounded" }, |