| author | |
| committer | |
| log | dcbaf05279984187b96c99edc2416abf91e77b96 |
| tree | c9d9190c18e27626098bdb9317927123e3f51932 |
| parent | 1024941575067f68d909c6b98f56b02edac25b37 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 248 insertions(+), 87 deletions(-)
apps/server/src/git/GitManager.test.ts+43| ... | @@ -1071,6 +1071,49 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1071,6 +1071,49 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1071 | }), | 1071 | }), |
| 1072 | ); | 1072 | ); |
| 1073 | 1073 | ||
| 1074 | it.effect("a branch tracking origin reads origin's URL once per PR lookup", () => | ||
| 1075 | Effect.gen(function* () { | ||
| 1076 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1077 | yield* initRepo(repoDir); | ||
| 1078 | const remoteDir = yield* createBareRemote(); | ||
| 1079 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1080 | yield* runGit(repoDir, ["checkout", "-b", "feature/origin-once"]); | ||
| 1081 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/origin-once"]); | ||
| 1082 | |||
| 1083 | const gitConfigReads: string[] = []; | ||
| 1084 | const { manager } = yield* makeManager({ | ||
| 1085 | gitConfigReads, | ||
| 1086 | ghScenario: { | ||
| 1087 | prListSequence: [ | ||
| 1088 | // Fake gh returns raw JSON stdout, matching the CLI boundary under test. | ||
| 1089 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1090 | JSON.stringify([ | ||
| 1091 | { | ||
| 1092 | number: 217, | ||
| 1093 | title: "Origin once PR", | ||
| 1094 | url: "https://github.com/pingdotgg/t3code/pull/217", | ||
| 1095 | baseRefName: "main", | ||
| 1096 | headRefName: "feature/origin-once", | ||
| 1097 | state: "OPEN", | ||
| 1098 | updatedAt: "2026-04-03T15:00:00Z", | ||
| 1099 | }, | ||
| 1100 | ]), | ||
| 1101 | ], | ||
| 1102 | }, | ||
| 1103 | }); | ||
| 1104 | |||
| 1105 | yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/origin-once" }); | ||
| 1106 | gitConfigReads.length = 0; | ||
| 1107 | const pullRequest = yield* manager.branchPullRequest({ | ||
| 1108 | cwd: repoDir, | ||
| 1109 | branch: "feature/origin-once", | ||
| 1110 | }); | ||
| 1111 | |||
| 1112 | expect(pullRequest?.number).toBe(217); | ||
| 1113 | expect(gitConfigReads.filter((key) => key === "remote.origin.url")).toHaveLength(1); | ||
| 1114 | }), | ||
| 1115 | ); | ||
| 1116 | |||
| 1074 | it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => | 1117 | it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => |
| 1075 | Effect.gen(function* () { | 1118 | Effect.gen(function* () { |
| 1076 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 1119 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/GitManager.ts+18-13| ... | @@ -1404,17 +1404,25 @@ export const make = Effect.gen(function* () { | ... | @@ -1404,17 +1404,25 @@ export const make = Effect.gen(function* () { |
| 1404 | }; | 1404 | }; |
| 1405 | }); | 1405 | }); |
| 1406 | 1406 | ||
| 1407 | // Returns [head remote, origin]. Most branches track origin, so read it once. | ||
| 1408 | const resolveHeadAndOriginContexts = (cwd: string, remoteName: string | null) => | ||
| 1409 | remoteName === "origin" | ||
| 1410 | ? resolveRemoteRepositoryContext(cwd, "origin").pipe( | ||
| 1411 | Effect.map((origin) => [origin, origin] as const), | ||
| 1412 | ) | ||
| 1413 | : Effect.all( | ||
| 1414 | [ | ||
| 1415 | resolveRemoteRepositoryContext(cwd, remoteName), | ||
| 1416 | resolveRemoteRepositoryContext(cwd, "origin"), | ||
| 1417 | ], | ||
| 1418 | { concurrency: "unbounded" }, | ||
| 1419 | ); | ||
| 1420 | |||
| 1407 | const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( | 1421 | const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( |
| 1408 | function* (cwd: string, branch: string, remoteNameOverride?: string) { | 1422 | function* (cwd: string, branch: string, remoteNameOverride?: string) { |
| 1409 | const remoteName = | 1423 | const remoteName = |
| 1410 | remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); | 1424 | remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); |
| 1411 | const [headRemote, targetRemote] = yield* Effect.all( | 1425 | const [headRemote, targetRemote] = yield* resolveHeadAndOriginContexts(cwd, remoteName); |
| 1412 | [ | ||
| 1413 | resolveRemoteRepositoryContext(cwd, remoteName), | ||
| 1414 | resolveRemoteRepositoryContext(cwd, "origin"), | ||
| 1415 | ], | ||
| 1416 | { concurrency: "unbounded" }, | ||
| 1417 | ); | ||
| 1418 | return { | 1426 | return { |
| 1419 | remoteName, | 1427 | remoteName, |
| 1420 | headRemoteUrlKey: | 1428 | headRemoteUrlKey: |
| ... | @@ -1438,12 +1446,9 @@ export const make = Effect.gen(function* () { | ... | @@ -1438,12 +1446,9 @@ export const make = Effect.gen(function* () { |
| 1438 | const shouldProbeLocalBranchSelector = | 1446 | const shouldProbeLocalBranchSelector = |
| 1439 | headBranchFromUpstream.length === 0 || headBranch === details.branch; | 1447 | headBranchFromUpstream.length === 0 || headBranch === details.branch; |
| 1440 | 1448 | ||
| 1441 | const [remoteRepository, originRepository] = yield* Effect.all( | 1449 | const [remoteRepository, originRepository] = yield* resolveHeadAndOriginContexts( |
| 1442 | [ | 1450 | cwd, |
| 1443 | resolveRemoteRepositoryContext(cwd, remoteName), | 1451 | remoteName, |
| 1444 | resolveRemoteRepositoryContext(cwd, "origin"), | ||
| 1445 | ], | ||
| 1446 | { concurrency: "unbounded" }, | ||
| 1447 | ); | 1452 | ); |
| 1448 | 1453 | ||
| 1449 | const isCrossRepository = | 1454 | const isCrossRepository = |
apps/server/src/vcs/VcsDriverRegistry.test.ts+126-70| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Path from "effect/Path"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | 7 | import { ChildProcessSpawner } from "effect/process"; |
| 6 | 8 | ||
| 7 | import * as VcsProcess from "./VcsProcess.ts"; | 9 | import * as VcsProcess from "./VcsProcess.ts"; |
| ... | @@ -43,9 +45,10 @@ describe("VcsDriverRegistry", () => { | ... | @@ -43,9 +45,10 @@ describe("VcsDriverRegistry", () => { |
| 43 | }).pipe(Effect.provide(layer)); | 45 | }).pipe(Effect.provide(layer)); |
| 44 | }); | 46 | }); |
| 45 | 47 | ||
| 46 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { | 48 | // Answers detection like git: the nearest folder at or above the cwd, up to |
| 47 | const calls: VcsProcess.VcsProcessInput[] = []; | 49 | // `rootDir`, that holds `.git` is the repository. |
| 48 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( | 50 | const makeDiskBackedLayer = (rootDir: string, calls: string[]) => |
| 51 | Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( | ||
| 49 | Layer.provide(NodeServices.layer), | 52 | Layer.provide(NodeServices.layer), |
| 50 | Layer.provide( | 53 | Layer.provide( |
| 51 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ | 54 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ |
| ... | @@ -55,86 +58,139 @@ describe("VcsDriverRegistry", () => { | ... | @@ -55,86 +58,139 @@ describe("VcsDriverRegistry", () => { |
| 55 | Layer.provide( | 58 | Layer.provide( |
| 56 | Layer.mock(VcsProcess.VcsProcess)({ | 59 | Layer.mock(VcsProcess.VcsProcess)({ |
| 57 | run: (input) => | 60 | run: (input) => |
| 58 | Effect.sync(() => { | 61 | Effect.gen(function* () { |
| 59 | calls.push(input); | 62 | const fs = yield* FileSystem.FileSystem; |
| 60 | const normalizedArgs = | 63 | const path = yield* Path.Path; |
| 61 | input.args[0] === "-C" && input.args.length >= 2 ? input.args.slice(2) : input.args; | 64 | const command = normalizeGitArgs(input.args).join(" "); |
| 62 | const command = normalizedArgs.join(" "); | 65 | calls.push(command); |
| 63 | if (command === "rev-parse --is-inside-work-tree") { | 66 | let repoDir: string | null = input.cwd; |
| 64 | return processOutput("true\n"); | 67 | while (repoDir !== null && !(yield* fs.exists(path.join(repoDir, ".git")))) { |
| 68 | repoDir = repoDir === rootDir ? null : path.dirname(repoDir); | ||
| 65 | } | 69 | } |
| 66 | if (command === "rev-parse --show-toplevel") { | 70 | if (repoDir === null) { |
| 67 | return processOutput("/repo\n"); | 71 | return { |
| 72 | ...processOutput(""), | ||
| 73 | exitCode: ChildProcessSpawner.ExitCode(128), | ||
| 74 | stderr: "fatal: not a git repository", | ||
| 75 | }; | ||
| 68 | } | 76 | } |
| 77 | if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n"); | ||
| 78 | if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`); | ||
| 69 | if (command === "rev-parse --git-common-dir") { | 79 | if (command === "rev-parse --git-common-dir") { |
| 70 | return processOutput("/repo/.git\n"); | 80 | return processOutput(`${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`); |
| 71 | } | 81 | } |
| 72 | return processOutput(""); | 82 | return processOutput(""); |
| 73 | }), | 83 | }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), |
| 74 | }), | 84 | }), |
| 75 | ), | 85 | ), |
| 76 | ); | 86 | ); |
| 77 | 87 | ||
| 78 | return Effect.gen(function* () { | 88 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => |
| 79 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; | 89 | Effect.gen(function* () { |
| 80 | const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); | 90 | const fs = yield* FileSystem.FileSystem; |
| 81 | const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); | 91 | const path = yield* Path.Path; |
| 82 | 92 | const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); | |
| 83 | assert.equal(first.repository.rootPath, "/repo"); | 93 | yield* fs.makeDirectory(path.join(repoDir, ".git")); |
| 84 | assert.equal(second.repository.rootPath, "/repo"); | 94 | const calls: string[] = []; |
| 85 | assert.deepStrictEqual( | 95 | |
| 86 | calls.map((call) => normalizeGitArgs(call.args).join(" ")), | 96 | yield* Effect.gen(function* () { |
| 87 | [ | 97 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 98 | const first = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); | ||
| 99 | const second = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); | ||
| 100 | |||
| 101 | assert.equal(first.repository.rootPath, repoDir); | ||
| 102 | assert.equal(second.repository.rootPath, repoDir); | ||
| 103 | assert.deepStrictEqual(calls, [ | ||
| 88 | "rev-parse --is-inside-work-tree", | 104 | "rev-parse --is-inside-work-tree", |
| 89 | "rev-parse --show-toplevel", | 105 | "rev-parse --show-toplevel", |
| 90 | "rev-parse --git-common-dir", | 106 | "rev-parse --git-common-dir", |
| 91 | ], | 107 | ]); |
| 92 | ); | 108 | }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); |
| 93 | }).pipe(Effect.provide(layer)); | 109 | }).pipe(Effect.provide(NodeServices.layer)), |
| 94 | }); | 110 | ); |
| 95 | 111 | ||
| 96 | it.effect("detects a repository created after a negative lookup", () => { | 112 | it.effect("detects again when a cached repository is removed from disk", () => |
| 97 | let insideWorkTreeChecks = 0; | 113 | Effect.gen(function* () { |
| 98 | const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( | 114 | const fs = yield* FileSystem.FileSystem; |
| 99 | Layer.provide(NodeServices.layer), | 115 | const path = yield* Path.Path; |
| 100 | Layer.provide( | 116 | const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); |
| 101 | Layer.mock(VcsProjectConfig.VcsProjectConfig)({ | 117 | yield* fs.makeDirectory(path.join(repoDir, ".git")); |
| 102 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | 118 | const calls: string[] = []; |
| 103 | }), | ||
| 104 | ), | ||
| 105 | Layer.provide( | ||
| 106 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 107 | run: (input) => | ||
| 108 | Effect.sync(() => { | ||
| 109 | const command = normalizeGitArgs(input.args).join(" "); | ||
| 110 | if (command === "rev-parse --is-inside-work-tree") { | ||
| 111 | insideWorkTreeChecks += 1; | ||
| 112 | return insideWorkTreeChecks === 1 | ||
| 113 | ? { | ||
| 114 | ...processOutput(""), | ||
| 115 | exitCode: ChildProcessSpawner.ExitCode(128), | ||
| 116 | stderr: "fatal: not a git repository", | ||
| 117 | } | ||
| 118 | : processOutput("true\n"); | ||
| 119 | } | ||
| 120 | if (command === "rev-parse --show-toplevel") { | ||
| 121 | return processOutput("/repo\n"); | ||
| 122 | } | ||
| 123 | if (command === "rev-parse --git-common-dir") { | ||
| 124 | return processOutput("/repo/.git\n"); | ||
| 125 | } | ||
| 126 | return processOutput(""); | ||
| 127 | }), | ||
| 128 | }), | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | 119 | ||
| 132 | return Effect.gen(function* () { | 120 | yield* Effect.gen(function* () { |
| 133 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; | 121 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 122 | assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); | ||
| 134 | 123 | ||
| 135 | assert.equal(yield* registry.detect({ cwd: "/repo" }), null); | 124 | yield* fs.remove(path.join(repoDir, ".git"), { recursive: true }); |
| 136 | assert.equal((yield* registry.detect({ cwd: "/repo" }))?.repository.rootPath, "/repo"); | 125 | |
| 137 | assert.equal(insideWorkTreeChecks, 2); | 126 | assert.equal(yield* registry.detect({ cwd: repoDir }), null); |
| 138 | }).pipe(Effect.provide(layer)); | 127 | assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); |
| 139 | }); | 128 | }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); |
| 129 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 130 | ); | ||
| 131 | |||
| 132 | it.effect("detects again when git init creates a repository inside a cached one", () => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const fs = yield* FileSystem.FileSystem; | ||
| 135 | const path = yield* Path.Path; | ||
| 136 | const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); | ||
| 137 | const projectDir = path.join(parentDir, "project"); | ||
| 138 | yield* fs.makeDirectory(path.join(parentDir, ".git")); | ||
| 139 | yield* fs.makeDirectory(projectDir); | ||
| 140 | const calls: string[] = []; | ||
| 141 | |||
| 142 | yield* Effect.gen(function* () { | ||
| 143 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; | ||
| 144 | assert.equal((yield* registry.detect({ cwd: projectDir }))?.repository.rootPath, parentDir); | ||
| 145 | |||
| 146 | yield* fs.makeDirectory(path.join(projectDir, ".git")); | ||
| 147 | |||
| 148 | assert.equal( | ||
| 149 | (yield* registry.detect({ cwd: projectDir }))?.repository.rootPath, | ||
| 150 | projectDir, | ||
| 151 | ); | ||
| 152 | }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls))); | ||
| 153 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect("detects again when git init creates a repository between the cwd and its root", () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const fs = yield* FileSystem.FileSystem; | ||
| 159 | const path = yield* Path.Path; | ||
| 160 | const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); | ||
| 161 | const middleDir = path.join(parentDir, "a"); | ||
| 162 | const cwd = path.join(middleDir, "b"); | ||
| 163 | yield* fs.makeDirectory(path.join(parentDir, ".git")); | ||
| 164 | yield* fs.makeDirectory(cwd, { recursive: true }); | ||
| 165 | const calls: string[] = []; | ||
| 166 | |||
| 167 | yield* Effect.gen(function* () { | ||
| 168 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; | ||
| 169 | assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, parentDir); | ||
| 170 | |||
| 171 | yield* fs.makeDirectory(path.join(middleDir, ".git")); | ||
| 172 | |||
| 173 | assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, middleDir); | ||
| 174 | }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls))); | ||
| 175 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("detects a repository created after a negative lookup", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const fs = yield* FileSystem.FileSystem; | ||
| 181 | const path = yield* Path.Path; | ||
| 182 | const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); | ||
| 183 | const calls: string[] = []; | ||
| 184 | |||
| 185 | yield* Effect.gen(function* () { | ||
| 186 | const registry = yield* VcsDriverRegistry.VcsDriverRegistry; | ||
| 187 | assert.equal(yield* registry.detect({ cwd: repoDir }), null); | ||
| 188 | |||
| 189 | yield* fs.makeDirectory(path.join(repoDir, ".git")); | ||
| 190 | |||
| 191 | assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); | ||
| 192 | assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); | ||
| 193 | }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); | ||
| 194 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 195 | ); | ||
| 140 | }); | 196 | }); |
apps/server/src/vcs/VcsDriverRegistry.ts+61-4| ... | @@ -3,7 +3,9 @@ import * as Context from "effect/Context"; | ... | @@ -3,7 +3,9 @@ import * as Context from "effect/Context"; |
| 3 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; | ||
| 7 | 9 | ||
| 8 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; | 10 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; |
| 9 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; | 11 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; |
| ... | @@ -12,7 +14,10 @@ import * as VcsProjectConfig from "./VcsProjectConfig.ts"; | ... | @@ -12,7 +14,10 @@ import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 12 | import * as VcsDriver from "./VcsDriver.ts"; | 14 | import * as VcsDriver from "./VcsDriver.ts"; |
| 13 | 15 | ||
| 14 | const DETECTION_CACHE_CAPACITY = 2_048; | 16 | const DETECTION_CACHE_CAPACITY = 2_048; |
| 15 | const DETECTION_CACHE_TTL = Duration.seconds(2); | 17 | // Status polls detect every open cwd each tick, and detection costs three git |
| 18 | // processes. `detect` re-checks each hit against the disk, so this TTL only | ||
| 19 | // bounds rare changes those stats cannot see. | ||
| 20 | const DETECTION_CACHE_TTL = Duration.minutes(5); | ||
| 16 | 21 | ||
| 17 | export interface VcsDriverResolveInput { | 22 | export interface VcsDriverResolveInput { |
| 18 | readonly cwd: string; | 23 | readonly cwd: string; |
| ... | @@ -61,6 +66,8 @@ function parseDetectionCacheKey(key: string): { | ... | @@ -61,6 +66,8 @@ function parseDetectionCacheKey(key: string): { |
| 61 | } | 66 | } |
| 62 | 67 | ||
| 63 | export const make = Effect.gen(function* () { | 68 | export const make = Effect.gen(function* () { |
| 69 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 70 | const path = yield* Path.Path; | ||
| 64 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; | 71 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; |
| 65 | const git = yield* GitVcsDriver.makeVcsDriver; | 72 | const git = yield* GitVcsDriver.makeVcsDriver; |
| 66 | const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriver["Service"]>> = { | 73 | const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriver["Service"]>> = { |
| ... | @@ -111,8 +118,44 @@ export const make = Effect.gen(function* () { | ... | @@ -111,8 +118,44 @@ export const make = Effect.gen(function* () { |
| 111 | return yield* detectWithDriver("git", git, input.cwd); | 118 | return yield* detectWithDriver("git", git, input.cwd); |
| 112 | }); | 119 | }); |
| 113 | 120 | ||
| 114 | const detectionCache = yield* Cache.makeWith<string, VcsDriverHandle | null, VcsError>( | 121 | // The cwd and each parent below the repository root. A new `.git` in any of |
| 115 | (key) => detectResolvedKind(parseDetectionCacheKey(key)), | 122 | // them means `git init` made a nested repository. Falls back to the cwd alone |
| 123 | // when the root is not above it, such as a cwd reached through a symlink. | ||
| 124 | const foldersBelowRoot = (cwd: string, rootPath: string) => { | ||
| 125 | const relative = path.relative(path.resolve(rootPath), path.resolve(cwd)); | ||
| 126 | if (relative.startsWith("..") || path.isAbsolute(relative)) return [cwd]; | ||
| 127 | return Array.from({ length: relative.split(path.sep).length }, (_, levelsUp) => | ||
| 128 | path.resolve(cwd, ...Array<string>(levelsUp).fill("..")), | ||
| 129 | ); | ||
| 130 | }; | ||
| 131 | |||
| 132 | // What `detect` re-checks on every cache hit, using stats instead of git. | ||
| 133 | const readDiskState = (cwd: string, repository: VcsRepositoryIdentity) => | ||
| 134 | Effect.all({ | ||
| 135 | gitEntries: Effect.forEach(foldersBelowRoot(cwd, repository.rootPath), (folder) => | ||
| 136 | fileSystem.exists(path.join(folder, ".git")), | ||
| 137 | ), | ||
| 138 | repositoryOnDisk: Effect.forEach( | ||
| 139 | repository.metadataPath === null | ||
| 140 | ? [cwd] | ||
| 141 | : [cwd, path.resolve(cwd, repository.metadataPath)], | ||
| 142 | (target) => fileSystem.exists(target), | ||
| 143 | ).pipe(Effect.map((results) => results.every(Boolean))), | ||
| 144 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 145 | |||
| 146 | const detectionCache = yield* Cache.makeWith< | ||
| 147 | string, | ||
| 148 | { readonly handle: VcsDriverHandle; readonly gitEntries: ReadonlyArray<boolean> } | null, | ||
| 149 | VcsError | ||
| 150 | >( | ||
| 151 | (key) => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const input = parseDetectionCacheKey(key); | ||
| 154 | const handle = yield* detectResolvedKind(input); | ||
| 155 | if (handle === null) return null; | ||
| 156 | const disk = yield* readDiskState(input.cwd, handle.repository); | ||
| 157 | return { handle, gitEntries: disk?.gitEntries ?? [] }; | ||
| 158 | }), | ||
| 116 | { | 159 | { |
| 117 | capacity: DETECTION_CACHE_CAPACITY, | 160 | capacity: DETECTION_CACHE_CAPACITY, |
| 118 | timeToLive: Exit.match({ | 161 | timeToLive: Exit.match({ |
| ... | @@ -122,10 +165,24 @@ export const make = Effect.gen(function* () { | ... | @@ -122,10 +165,24 @@ export const make = Effect.gen(function* () { |
| 122 | }, | 165 | }, |
| 123 | ); | 166 | ); |
| 124 | 167 | ||
| 168 | // A hit is stale when its worktree or `.git` folder was removed, or when | ||
| 169 | // `git init` created a nested repository between the cwd and its root. | ||
| 125 | const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( | 170 | const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( |
| 126 | function* (input) { | 171 | function* (input) { |
| 127 | const requestedKind = yield* projectConfig.resolveKind(input); | 172 | const requestedKind = yield* projectConfig.resolveKind(input); |
| 128 | return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); | 173 | const key = detectionCacheKey({ cwd: input.cwd, requestedKind }); |
| 174 | const cached = yield* Cache.get(detectionCache, key); | ||
| 175 | if (cached === null) return null; | ||
| 176 | const disk = yield* readDiskState(input.cwd, cached.handle.repository); | ||
| 177 | if ( | ||
| 178 | disk?.repositoryOnDisk && | ||
| 179 | disk.gitEntries.length === cached.gitEntries.length && | ||
| 180 | disk.gitEntries.every((exists, index) => exists === cached.gitEntries[index]) | ||
| 181 | ) { | ||
| 182 | return cached.handle; | ||
| 183 | } | ||
| 184 | yield* Cache.invalidate(detectionCache, key); | ||
| 185 | return (yield* Cache.get(detectionCache, key))?.handle ?? null; | ||
| 129 | }, | 186 | }, |
| 130 | ); | 187 | ); |
| 131 | 188 |