diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d00301e96e7d8..786b75f16d1da 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,11 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -44,6 +46,32 @@ import { import * as GitVcsDriver from "./GitVcsDriver.ts"; import { gitConfigEnv } from "./VcsProcess.ts"; +// A test cannot chown without root, so stat reports another owner for this path instead. +const statOwner = vi.hoisted(() => ({ path: null as string | null })); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const reportingOwner = (stat: Stat) => + ((path: NodeFS.PathLike, ...rest: Array) => { + const run = stat as (...args: Array) => void; + const callback = rest.at(-1); + if (path !== statOwner.path || typeof callback !== "function") return run(path, ...rest); + run( + path, + ...rest.slice(0, -1), + (error: unknown, stats?: NodeFS.Stats | NodeFS.BigIntStats) => { + if (stats) { + Object.assign(stats, { + uid: typeof stats.uid === "bigint" ? stats.uid + 1n : stats.uid + 1, + }); + } + callback(error, stats); + }, + ); + }) as Stat; + return { ...actual, stat: reportingOwner(actual.stat), lstat: reportingOwner(actual.lstat) }; +}); + const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); const decodeVcsRef = Schema.decodeUnknownSync(VcsRef); const decodeVcsListRefsResult = Schema.decodeUnknownSync(VcsListRefsResult); @@ -348,6 +376,575 @@ it.effect.each(["origin", "github"] as const)( }).pipe(Effect.provide(layerTest)), ); +const caseInsensitiveFileSystem = (() => { + const directory = NodeOS.tmpdir(); + return directory !== directory.toUpperCase() && NodeFS.existsSync(directory.toUpperCase()); +})(); + +describe("config reads", () => { + const makeCountingDriver = Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawned: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) spawned.push(command.args.map(String)); + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + return { driver, spawned }; + }); + const readsOnlyConfig = (args: ReadonlyArray) => + args[0] === "config" || args[0] === "remote" || args[0] === "var"; + // The config GitManager's local status refresh reads after statusDetailsLocal. + const refreshStatus = (driver: GitVcsDriver.GitVcsDriver["Service"], cwd: string) => + Effect.gen(function* () { + const details = yield* driver.statusDetailsLocal(cwd, { + includeDivergence: false, + includeBranchChanges: true, + }); + const remoteName = yield* driver.readConfigValue(cwd, `branch.${details.branch}.remote`); + const remoteUrl = + (yield* driver.readConfigValue(cwd, `remote.${remoteName ?? "origin"}.url`)) ?? + (yield* driver.readConfigValue(cwd, "remote.origin.url")); + return { details, remoteName, remoteUrl }; + }); + + it.effect("runs no config reads on a refresh while the config is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + const { driver, spawned } = yield* makeCountingDriver; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const first = yield* refreshStatus(driver, cwd); + const firstArgs = [...spawned]; + spawned.length = 0; + + const second = yield* refreshStatus(driver, cwd); + + assert.deepStrictEqual(second, first); + assert.isNotEmpty(firstArgs.filter(readsOnlyConfig)); + assert.deepStrictEqual(spawned.filter(readsOnlyConfig), []); + + NodeFS.appendFileSync( + (yield* Path.Path).join(cwd, ".git", "config"), + `[branch "${initialBranch}"]\n\tremote = upstream\n[remote "upstream"]\n\turl = https://example.invalid/upstream.git\n`, + ); + const edited = yield* refreshStatus(driver, cwd); + assert.equal(edited.remoteName, "upstream"); + assert.equal(edited.remoteUrl, "https://example.invalid/upstream.git"); + }), + ).pipe(Effect.provide(layerTest)), + ); + + type TestServices = GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path; + type TestError = GitCommandError | PlatformError.PlatformError; + interface ConfigChange { + readonly name: string; + // Prepares the repository at `cwd` and returns the directory to read from. + readonly setup: (cwd: string) => Effect.Effect; + readonly change: ( + cwd: string, + readCwd: string, + ) => Effect.Effect; + readonly read: ( + driver: GitVcsDriver.GitVcsDriver["Service"], + cwd: string, + ) => Effect.Effect; + readonly before: string | null; + readonly after: string | null; + // False when the config cannot be tracked, so every read runs Git. + readonly reused: boolean; + } + const readValue = (key: string) => (driver: GitVcsDriver.GitVcsDriver["Service"], cwd: string) => + driver.readConfigValue(cwd, key); + const readPrimaryRemote = (driver: GitVcsDriver.GitVcsDriver["Service"], cwd: string) => + driver.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); + const setEnv = (name: string, value: string) => + Effect.acquireRelease( + Effect.sync(() => { + const previous = process.env[name]; + process.env[name] = value; + return previous; + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) Reflect.deleteProperty(process.env, name); + else process.env[name] = previous; + }), + ); + const setGlobalConfig = (file: string) => setEnv("GIT_CONFIG_GLOBAL", file); + // An empty safe.directory drops any configured exception, so Git checks owners. + const trustNoOtherOwner = Effect.all([ + setEnv("GIT_CONFIG_COUNT", "1"), + setEnv("GIT_CONFIG_KEY_0", "safe.directory"), + setEnv("GIT_CONFIG_VALUE_0", ""), + ]); + const changeOwner = (file: string) => + Effect.all([ + Effect.acquireRelease( + Effect.sync(() => { + statOwner.path = file; + }), + () => + Effect.sync(() => { + statOwner.path = null; + }), + ), + setEnv("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1"), + ]).pipe(Effect.asVoid); + const writeInclude = (file: string, value: string) => + Effect.sync(() => NodeFS.writeFileSync(file, `[t3]\n\tvalue = ${value}\n`)); + const changes: ReadonlyArray = [ + { + name: "a same-size .git/config edit with the same mtime", + setup: (cwd) => + git(cwd, ["config", "t3.value", "aaaa"]).pipe( + Effect.map(() => NodeFS.utimesSync(NodePath.join(cwd, ".git", "config"), 1_000, 1_000)), + Effect.as(cwd), + ), + change: (cwd) => + Effect.sync(() => { + const file = NodePath.join(cwd, ".git", "config"); + NodeFS.writeFileSync(file, NodeFS.readFileSync(file, "utf8").replace("aaaa", "bbbb")); + NodeFS.utimesSync(file, 1_000, 1_000); + }), + read: readValue("t3.value"), + before: "aaaa", + after: "bbbb", + reused: true, + }, + { + name: "an added origin remote", + setup: (cwd) => Effect.succeed(cwd), + change: (cwd) => git(cwd, ["remote", "add", "origin", "https://example.invalid/o.git"]), + read: readPrimaryRemote, + before: null, + after: "origin", + reused: true, + }, + { + name: "a removed remote", + setup: (cwd) => + git(cwd, ["remote", "add", "fork", "https://example.invalid/fork.git"]).pipe( + Effect.as(cwd), + ), + change: (cwd) => git(cwd, ["remote", "remove", "fork"]), + read: readPrimaryRemote, + before: "fork", + after: null, + reused: true, + }, + { + name: "a linked worktree's config.worktree", + setup: (cwd) => + Effect.gen(function* () { + const worktree = NodePath.join(yield* makeTmpDir("git-worktrees-"), "linked"); + yield* git(cwd, ["config", "extensions.worktreeConfig", "true"]); + yield* git(cwd, ["worktree", "add", "-b", "linked", worktree]); + yield* git(worktree, ["config", "--worktree", "t3.value", "first"]); + return worktree; + }), + change: (_cwd, worktree) => git(worktree, ["config", "--worktree", "t3.value", "second"]), + read: readValue("t3.value"), + before: "first", + after: "second", + reused: true, + }, + { + name: "an edit to an included file", + setup: (cwd) => + Effect.gen(function* () { + const included = NodePath.join(yield* makeTmpDir("git-include-"), "shared.inc"); + yield* writeInclude(included, "first"); + yield* git(cwd, ["config", "include.path", included]); + return cwd; + }), + change: (cwd) => + git(cwd, ["config", "--get", "include.path"]).pipe( + Effect.flatMap((included) => writeInclude(included, "second")), + ), + read: readValue("t3.value"), + before: "first", + after: "second", + reused: true, + }, + { + name: "an included file that appears", + setup: (cwd) => + Effect.gen(function* () { + const included = NodePath.join(yield* makeTmpDir("git-include-"), "later.inc"); + yield* git(cwd, ["config", "include.path", included]); + return cwd; + }), + change: (cwd) => + git(cwd, ["config", "--get", "include.path"]).pipe( + Effect.flatMap((included) => writeInclude(included, "later")), + ), + read: readValue("t3.value"), + before: null, + after: "later", + reused: true, + }, + { + name: "an edit to the global config", + setup: (cwd) => + Effect.gen(function* () { + const global = NodePath.join(yield* makeTmpDir("git-global-"), "gitconfig"); + yield* writeInclude(global, "first"); + yield* setGlobalConfig(global); + return cwd; + }), + change: () => writeInclude(process.env.GIT_CONFIG_GLOBAL!, "second"), + read: readValue("t3.value"), + before: "first", + after: "second", + reused: true, + }, + { + name: "a repository initialised in the subdirectory read from", + setup: (cwd) => + git(cwd, ["config", "t3.value", "parent"]).pipe( + Effect.map(() => NodeFS.mkdirSync(NodePath.join(cwd, "sub"))), + Effect.as(NodePath.join(cwd, "sub")), + ), + change: (cwd, sub) => + git(cwd, ["init", "-q", "sub"]).pipe( + Effect.andThen(git(sub, ["config", "t3.value", "nested"])), + Effect.asVoid, + ), + read: readValue("t3.value"), + before: "parent", + after: "nested", + reused: true, + }, + { + name: "a half-initialised repository in the subdirectory read from completing", + setup: (cwd) => + git(cwd, ["config", "t3.value", "parent"]).pipe( + Effect.map(() => + NodeFS.mkdirSync(NodePath.join(cwd, "sub", ".git"), { recursive: true }), + ), + Effect.as(NodePath.join(cwd, "sub")), + ), + change: (cwd, sub) => + git(cwd, ["init", "-q", "sub"]).pipe( + Effect.andThen(git(sub, ["config", "t3.value", "nested"])), + Effect.asVoid, + ), + read: readValue("t3.value"), + before: "parent", + after: "nested", + reused: false, + }, + { + name: "the repository losing its HEAD", + setup: (cwd) => git(cwd, ["config", "t3.value", "first"]).pipe(Effect.as(cwd)), + change: (cwd) => Effect.sync(() => NodeFS.rmSync(NodePath.join(cwd, ".git", "HEAD"))), + read: readValue("t3.value"), + before: "first", + after: null, + reused: true, + }, + // Windows has no search permission, and root keeps it whatever the mode says. + ...(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0 + ? [] + : (["objects", "refs"] as const).map((directory): ConfigChange => ({ + name: `the repository's ${directory} losing search permission`, + setup: (cwd) => + Effect.gen(function* () { + const file = NodePath.join(cwd, ".git", directory); + const mode = NodeFS.statSync(file).mode & 0o7777; + yield* Effect.addFinalizer(() => Effect.sync(() => NodeFS.chmodSync(file, mode))); + yield* git(cwd, ["config", "t3.value", "first"]); + return cwd; + }), + change: (cwd) => + Effect.sync(() => { + const file = NodePath.join(cwd, ".git", directory); + NodeFS.chmodSync(file, NodeFS.statSync(file).mode & 0o7666); + }), + read: readValue("t3.value"), + before: "first", + after: null, + reused: true, + }))), + { + name: "a new owner of the top level above the directory read from", + setup: (cwd) => + Effect.gen(function* () { + yield* git(cwd, ["config", "t3.value", "first"]); + yield* trustNoOtherOwner; + NodeFS.mkdirSync(NodePath.join(cwd, "sub")); + return NodePath.join(cwd, "sub"); + }), + change: (_cwd, sub) => + git(sub, ["rev-parse", "--show-toplevel"]).pipe(Effect.flatMap(changeOwner)), + read: readValue("t3.value"), + before: "first", + after: null, + reused: true, + }, + { + name: "a new owner of a linked worktree's git dir", + setup: (cwd) => + Effect.gen(function* () { + const worktree = NodePath.join(yield* makeTmpDir("git-worktrees-"), "linked"); + yield* git(cwd, ["config", "t3.value", "first"]); + yield* git(cwd, ["worktree", "add", "-b", "linked", worktree]); + yield* trustNoOtherOwner; + return worktree; + }), + change: (_cwd, worktree) => + git(worktree, ["rev-parse", "--absolute-git-dir"]).pipe(Effect.flatMap(changeOwner)), + read: readValue("t3.value"), + before: "first", + after: null, + reused: true, + }, + ...(["garbage", ""] as const).map((head): ConfigChange => ({ + name: `a nested repository's HEAD becoming ${head ? "invalid" : "empty"}`, + setup: (cwd) => + Effect.gen(function* () { + const sub = NodePath.join(cwd, "sub"); + yield* git(cwd, ["config", "t3.value", "parent"]); + yield* git(cwd, ["init", "-q", "sub"]); + yield* git(sub, ["config", "t3.value", "child"]); + return sub; + }), + change: (_cwd, sub) => + Effect.sync(() => NodeFS.writeFileSync(NodePath.join(sub, ".git", "HEAD"), head)), + read: readValue("t3.value"), + before: "child", + after: "parent", + reused: true, + })), + { + name: "a relative include", + setup: (cwd) => + writeInclude(NodePath.join(cwd, ".git", "shared.inc"), "first").pipe( + Effect.andThen(git(cwd, ["config", "include.path", "shared.inc"])), + Effect.as(cwd), + ), + change: (cwd) => writeInclude(NodePath.join(cwd, ".git", "shared.inc"), "second"), + read: readValue("t3.value"), + before: "first", + after: "second", + reused: false, + }, + { + name: "a branch switch under an onbranch include", + setup: (cwd) => + Effect.gen(function* () { + const included = NodePath.join(yield* makeTmpDir("git-include-"), "feature.inc"); + yield* writeInclude(included, "feature"); + yield* git(cwd, ["config", "includeIf.onbranch:feature.path", included]); + return cwd; + }), + change: (cwd) => git(cwd, ["checkout", "-q", "-b", "feature"]).pipe(Effect.asVoid), + read: readValue("t3.value"), + before: null, + after: "feature", + reused: false, + }, + ]; + + it.effect.each(changes)("sees $name on the next read", (change) => + Effect.scoped( + Effect.gen(function* () { + const { driver, spawned } = yield* makeCountingDriver; + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const readCwd = yield* change.setup(cwd); + assert.equal(yield* change.read(driver, readCwd), change.before); + assert.equal(yield* change.read(driver, readCwd), change.before); + spawned.length = 0; + assert.equal(yield* change.read(driver, readCwd), change.before); + assert.equal(spawned.filter(readsOnlyConfig).length === 0, change.reused); + + yield* change.change(cwd, readCwd); + + assert.equal(yield* change.read(driver, readCwd), change.after); + }), + ).pipe(Effect.provide(layerTest)), + ); + + it.effect.skipIf(!caseInsensitiveFileSystem)( + "reuses config read through a differently cased path", + () => + Effect.scoped( + Effect.gen(function* () { + const { driver, spawned } = yield* makeCountingDriver; + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "t3.value", "first"]); + const typed = NodePath.join(NodePath.dirname(cwd), NodePath.basename(cwd).toUpperCase()); + assert.equal(yield* driver.readConfigValue(typed, "t3.value"), "first"); + assert.equal(yield* driver.readConfigValue(typed, "t3.value"), "first"); + spawned.length = 0; + assert.equal(yield* driver.readConfigValue(typed, "t3.value"), "first"); + assert.deepStrictEqual(spawned, []); + + yield* git(cwd, ["config", "t3.value", "second"]); + + assert.equal(yield* driver.readConfigValue(typed, "t3.value"), "second"); + }), + ).pipe(Effect.provide(layerTest)), + ); + + it.effect("sees a repository initialised below a directory outside GIT_WORK_TREE", () => + Effect.scoped( + Effect.gen(function* () { + const { driver } = yield* makeCountingDriver; + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "t3.value", "parent"]); + const sub = NodePath.join(cwd, "sub"); + NodeFS.mkdirSync(sub); + const workTree = yield* makeTmpDir("git-work-tree-"); + const readWithWorkTree = Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.GIT_WORK_TREE; + process.env.GIT_WORK_TREE = workTree; + return previous; + }), + () => driver.readConfigValue(sub, "t3.value"), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.GIT_WORK_TREE; + else process.env.GIT_WORK_TREE = previous; + }), + ); + assert.equal(yield* readWithWorkTree, "parent"); + assert.equal(yield* readWithWorkTree, "parent"); + + yield* git(cwd, ["init", "-q", "sub"]); + yield* git(sub, ["config", "t3.value", "nested"]); + + assert.equal(yield* readWithWorkTree, "nested"); + }), + ).pipe(Effect.provide(layerTest)), + ); + + it.effect("reads what git config --get and git remote read", () => + Effect.scoped( + Effect.gen(function* () { + const { driver, spawned } = yield* makeCountingDriver; + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const included = NodePath.join(yield* makeTmpDir("git-include-"), "shared.inc"); + NodeFS.writeFileSync( + included, + '[remote "fork"]\n\turl = https://example.invalid/fork.git\n[t3]\n\tpadded = " padded "\n', + ); + NodeFS.appendFileSync( + NodePath.join(cwd, ".git", "config"), + [ + "[core]\n\tflag\n\tempty =\n", + '[branch "Main"]\n\tremote = up\n', + '[branch "x.y"]\n\tremote = a\n\tremote = b\n', + "[branch.Legacy]\n\tremote = l\n", + '[remote "origin"]\n\turl = https://example.invalid/origin.git\n', + `[include]\n\tpath = ${included}\n`, + ].join(""), + ); + const keys = [ + "core.flag", + "core.empty", + "Core.Empty", + "core.bare", + "branch.Main.remote", + "BRANCH.Main.REMOTE", + "branch.main.remote", + "branch.x.y.remote", + "branch.legacy.remote", + "branch.Legacy.remote", + "remote.origin.url", + "remote.fork.url", + "t3.padded", + "include.path", + "missing.key", + "core", + "core.", + ".core", + "core.bad_key", + "branch..remote", + ]; + const remotes = ["origin", "fork", "missing"]; + yield* driver.readConfigValue(cwd, "core.bare"); + yield* driver.readConfigValue(cwd, "core.bare"); + spawned.length = 0; + const readAll = Effect.all([ + Effect.forEach(keys, (key) => driver.readConfigValue(cwd, key)), + Effect.forEach(remotes, (remoteName) => driver.remoteExists({ cwd, remoteName })), + ]); + + const [values, exists] = yield* readAll; + assert.deepStrictEqual( + spawned, + remotes.map((remoteName) => ["remote", "get-url", "--", remoteName]), + ); + spawned.length = 0; + assert.deepStrictEqual(yield* readAll, [values, exists]); + assert.deepStrictEqual(spawned, []); + + const raw = (args: ReadonlyArray) => + driver.execute({ operation: "test.raw", cwd, args, allowNonZeroExit: true }); + assert.deepStrictEqual( + values, + yield* Effect.forEach(keys, (key) => + raw(["config", "--get", key]).pipe( + Effect.map(({ stdout }) => (stdout.trim().length > 0 ? stdout.trim() : null)), + ), + ), + ); + assert.deepStrictEqual( + exists, + yield* Effect.forEach(remotes, (remoteName) => + raw(["remote", "get-url", "--", remoteName]).pipe( + Effect.map(({ exitCode }) => exitCode === 0), + ), + ), + ); + }), + ).pipe(Effect.provide(layerTest)), + ); + + it.effect("reads each value with git config when git var cannot name the config files", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawned: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) return delegate.spawn(command); + spawned.push(command.args.map(String)); + if (command.args[0] !== "var") return delegate.spawn(command); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(129)), + stderr: Stream.encodeText(Stream.make("usage: git var (-l | )")), + }), + ); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "t3.value", "first"]); + assert.equal(yield* driver.readConfigValue(cwd, "t3.value"), "first"); + assert.equal(yield* driver.readConfigValue(cwd, "t3.value"), "first"); + yield* git(cwd, ["config", "t3.value", "second"]); + spawned.length = 0; + + assert.equal(yield* driver.readConfigValue(cwd, "t3.value"), "second"); + assert.deepStrictEqual(spawned, [["config", "--get", "t3.value"]]); + }), + ).pipe(Effect.provide(layerTest)), + ); +}); + it.effect.each([ { hookExitCode: 0, commonDirFailure: "none" }, { hookExitCode: 1, commonDirFailure: "none" }, @@ -580,12 +1177,15 @@ it.effect.each(["on a branch", "detached", "unborn"] as const)( const driver = yield* makeGitVcsDriverCore().pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); + // The config snapshot fills itself with one batched probe per path; its own tests + // count that, so only the driver's discoveries are counted here. const discoveries = () => { const commands = spawnedArgs.filter( (args) => - args.includes("--show-toplevel") || - args.includes("--git-common-dir") || - (args.includes("symbolic-ref") && args.includes("HEAD")), + !args.includes("--absolute-git-dir") && + (args.includes("--show-toplevel") || + args.includes("--git-common-dir") || + (args.includes("symbolic-ref") && args.includes("HEAD"))), ); spawnedArgs.length = 0; return commands.length; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 25a757104db32..e4f1e74aa22db 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -43,6 +43,11 @@ import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { removeEmptyDirectory } from "./removeEmptyDirectory.ts"; +import { + gitConfigGetOutput, + makeGitConfigSnapshots, + runWithGitConfigSnapshot, +} from "./gitConfigSnapshot.ts"; import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts"; import { removeLinkedWorktree } from "./worktreeRemoval.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; @@ -1135,6 +1140,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.writesSharedConfig ? withSharedConfigLock(input.cwd, execution) : execution, ); + const requireGitExit = + (operation: string, cwd: string, args: readonly string[], options: ExecuteGitOptions) => + ( + result: GitVcsDriver.ExecuteGitResult, + ): Effect.Effect => { + if (options.allowNonZeroExit || result.exitCode === 0) { + return Effect.succeed(result); + } + const reason = classifyGitFailure(result.stderr); + return Effect.fail( + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + ...(reason === null ? {} : { reason }), + detail: options.fallbackErrorDetail ?? gitFailureDetail(reason), + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + }; + const executeGit = ( operation: string, cwd: string, @@ -1158,24 +1184,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : {}), ...(options.progress ? { progress: options.progress } : {}), ...(options.writesSharedConfig ? { writesSharedConfig: true } : {}), - }).pipe( - Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.exitCode === 0) { - return Effect.succeed(result); - } - const reason = classifyGitFailure(result.stderr); - return Effect.fail( - new GitCommandError({ - ...gitCommandContext({ operation, cwd, args }), - ...(reason === null ? {} : { reason }), - detail: options.fallbackErrorDetail ?? gitFailureDetail(reason), - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ); - }), - ); + }).pipe(Effect.flatMap(requireGitExit(operation, cwd, args, options))); const executeGitWithStableDiagnostics = ( operation: string, @@ -1210,6 +1219,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((result) => result.stdout), ); + const configSnapshots = yield* makeGitConfigSnapshots((operation, cwd, args) => + executeGit(operation, cwd, args, { allowNonZeroExit: true }), + ); + + // Commands that read only config reuse their result while the config is unchanged. A reused result + // is shared by every caller, so it takes no options that would change how Git runs. + const executeConfigGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: Pick = {}, + ): Effect.Effect => + configSnapshots.read(cwd).pipe( + Effect.flatMap((snapshot) => + runWithGitConfigSnapshot( + snapshot, + args, + executeGit(operation, cwd, args, { allowNonZeroExit: true }), + ), + ), + Effect.flatMap(requireGitExit(operation, cwd, args, options)), + ); + // Pre-2.36 Git rejects worktree list -z; normalize its line fallback to NUL records. const executeWorktreeList = Effect.fn("executeWorktreeList")(function* ( operation: string, @@ -1322,8 +1354,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return null; } - const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map(parseRemoteNames), + const remoteNames = yield* executeConfigGit("GitVcsDriver.listRemoteNames", cwd, [ + "remote", + ]).pipe( + Effect.map((result) => parseRemoteNames(result.stdout)), Effect.orElseSucceed((): ReadonlyArray => []), ); return ( @@ -1704,7 +1738,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((result) => result.exitCode === 0)); const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => - executeGit( + executeConfigGit( "GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", "--", input.remoteName], @@ -1717,8 +1751,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* remoteExists({ cwd, remoteName: "origin" }); const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map(parseRemoteNamesInGitOrder), + executeConfigGit("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map((result) => parseRemoteNamesInGitOrder(result.stdout)), ); const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* ( @@ -3786,7 +3820,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) => - runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( + configSnapshots.read(cwd).pipe( + Effect.flatMap((snapshot) => + snapshot === null + ? runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true) + : Effect.succeed(gitConfigGetOutput(snapshot.values, key)), + ), Effect.map((stdout) => stdout.trim()), Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); diff --git a/apps/server/src/vcs/gitConfigSnapshot.test.ts b/apps/server/src/vcs/gitConfigSnapshot.test.ts new file mode 100644 index 0000000000000..91b5dde0ad921 --- /dev/null +++ b/apps/server/src/vcs/gitConfigSnapshot.test.ts @@ -0,0 +1,146 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + gitConfigEnvironmentSignature, + gitConfigListDependencies, + parseGitConfigList, +} from "./gitConfigSnapshot.ts"; + +describe("parseGitConfigList", () => { + it.each([ + { name: "no entries", stdout: "", expected: [] }, + { + name: "a value, an empty value and no value", + stdout: + "file:.git/config\0core.bare\nfalse\0file:.git/config\0core.empty\n\0command line:\0core.flag\0", + expected: [ + { origin: "file:.git/config", key: "core.bare", value: "false" }, + { origin: "file:.git/config", key: "core.empty", value: "" }, + { origin: "command line:", key: "core.flag", value: null }, + ], + }, + { + name: "a value spanning lines", + stdout: "file:/etc/gitconfig\0t3.note\nfirst\nsecond\0", + expected: [{ origin: "file:/etc/gitconfig", key: "t3.note", value: "first\nsecond" }], + }, + { name: "a missing terminator", stdout: "file:a\0core.bare\nfalse", expected: null }, + { + name: "an origin without an entry", + stdout: "file:a\0core.bare\nfalse\0file:b\0", + expected: null, + }, + ])("parses $name", ({ stdout, expected }) => { + assert.deepStrictEqual(parseGitConfigList(stdout), expected); + }); +}); + +describe("gitConfigListDependencies", () => { + const options = { + home: "/home/me", + windows: false, + isAbsolute: (p: string) => p.startsWith("/"), + }; + const entry = (origin: string, key: string, value: string | null = "x") => ({ + origin, + key, + value, + }); + + it.each([ + { + name: "absolute origins, and not the repository's relative ones", + entries: [ + entry("file:/home/me/.gitconfig", "user.name"), + entry("file:.git/config", "core.bare"), + entry("command line:", "safe.barerepository"), + ], + expected: { paths: ["/home/me/.gitconfig"], supported: true }, + }, + { + name: "include targets whether or not they were read", + entries: [ + entry("file:/home/me/.gitconfig", "include.path", "~/.gitconfig-local"), + entry("file:/home/me/.gitconfig", "includeif.gitdir:~/work/.path", "work/../config"), + entry("file:.git/config", "includeif.hasconfig:remote.*.url:https://x/**.path", "/etc/x"), + entry("file:.git/config", "includeif.gitdir/i:/w/.path", "/etc/y"), + ], + expected: { + paths: [ + "/home/me/.gitconfig", + "/home/me/.gitconfig-local", + "/home/me/work/../config", + "/etc/x", + "/etc/y", + ], + supported: true, + }, + }, + { + name: "a relative include in a relative origin", + entries: [entry("file:.git/config", "include.path", "shared.inc")], + expected: { paths: [], supported: false }, + }, + { + name: "an include that depends on the branch", + entries: [entry("file:/home/me/.gitconfig", "includeif.onbranch:main.path", "/etc/main")], + expected: { paths: ["/home/me/.gitconfig", "/etc/main"], supported: false }, + }, + { + name: "an include in another user's home", + entries: [entry("file:/home/me/.gitconfig", "include.path", "~other/config")], + expected: { paths: ["/home/me/.gitconfig"], supported: false }, + }, + { + name: "an include under the runtime prefix", + entries: [entry("command line:", "include.path", "%(prefix)/etc/x")], + expected: { paths: [], supported: false }, + }, + { + name: "an include without a value", + entries: [entry("file:/etc/gitconfig", "include.path", null)], + expected: { paths: ["/etc/gitconfig"], supported: false }, + }, + { + name: "values that are not from files", + entries: [entry("standard input:", "core.bare")], + expected: { paths: [], supported: false }, + }, + ])("tracks $name", ({ entries, expected }) => { + assert.deepStrictEqual(gitConfigListDependencies(entries, options), expected); + }); + + it("joins a relative include onto a Windows origin's directory", () => { + assert.deepStrictEqual( + gitConfigListDependencies( + [entry("file:C:\\Users\\me\\.gitconfig", "include.path", "local")], + { + home: undefined, + windows: true, + isAbsolute: (p) => /^[A-Za-z]:[\\/]/.test(p), + }, + ), + { paths: ["C:\\Users\\me\\.gitconfig", "C:\\Users\\me\\local"], supported: true }, + ); + }); +}); + +describe("gitConfigEnvironmentSignature", () => { + it("changes with the config Git takes from the environment", () => { + const base = { HOME: "/home/me", GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b" }; + assert.notEqual( + gitConfigEnvironmentSignature({ ...base, GIT_CONFIG_VALUE_0: "1" }), + gitConfigEnvironmentSignature({ ...base, GIT_CONFIG_VALUE_0: "2" }), + ); + assert.notEqual( + gitConfigEnvironmentSignature(base), + gitConfigEnvironmentSignature({ ...base, HOME: "/home/other" }), + ); + assert.notEqual( + gitConfigEnvironmentSignature(base), + gitConfigEnvironmentSignature({ ...base, GIT_OBJECT_DIRECTORY: "/tmp/objects" }), + ); + assert.isNull(gitConfigEnvironmentSignature({ ...base, GIT_CONFIG: "/tmp/only" })); + assert.isNull(gitConfigEnvironmentSignature({ ...base, GIT_CONFIG_COUNT: "1001" })); + }); +}); diff --git a/apps/server/src/vcs/gitConfigSnapshot.ts b/apps/server/src/vcs/gitConfigSnapshot.ts new file mode 100644 index 0000000000000..0e37ee9b2e5e4 --- /dev/null +++ b/apps/server/src/vcs/gitConfigSnapshot.ts @@ -0,0 +1,556 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime or nanosecond mtime, which the fingerprint needs. +import * as NodeFS from "node:fs"; + +import type { GitCommandError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import type * as GitVcsDriver from "./GitVcsDriver.ts"; + +export interface GitConfigEntry { + readonly origin: string; + readonly key: string; + readonly value: string | null; +} + +// `git config --list --show-origin -z` prints origin NUL key [LF value] NUL; no LF means no value. +export const parseGitConfigList = (stdout: string): ReadonlyArray | null => { + if (stdout.length === 0) return []; + if (!stdout.endsWith("\0")) return null; + const records = stdout.slice(0, -1).split("\0"); + if (records.length % 2 !== 0) return null; + const entries: GitConfigEntry[] = []; + for (let index = 0; index < records.length; index += 2) { + const record = records[index + 1]!; + const newline = record.indexOf("\n"); + entries.push({ + origin: records[index]!, + key: newline === -1 ? record : record.slice(0, newline), + value: newline === -1 ? null : record.slice(newline + 1), + }); + } + return entries; +}; + +const lowerAscii = (value: string) => value.replace(/[A-Z]+/g, (letters) => letters.toLowerCase()); + +// Git matches keys with the section and variable name lowercased and the subsection as written. +const canonicalGitConfigKey = (key: string) => { + const first = key.indexOf("."); + if (first === -1) return key; + const last = key.lastIndexOf("."); + return `${lowerAscii(key.slice(0, first))}${key.slice(first, last + 1)}${lowerAscii(key.slice(last + 1))}`; +}; + +// What `git config --get ` prints: the last value, an empty line for a key without one. +export const gitConfigGetOutput = ( + values: ReadonlyMap, + key: string, +): string => { + const canonicalKey = canonicalGitConfigKey(key); + if (!values.has(canonicalKey)) return ""; + return `${values.get(canonicalKey) ?? ""}\n`; +}; + +const INCLUDE_CONDITION_PREFIXES = ["gitdir:", "gitdir/i:", "hasconfig:remote.*.url:"] as const; + +const includeTarget = ( + value: string | null, + originPath: string | null, + options: GitConfigDependencyOptions, +): string | null => { + if (value === null || value.length === 0) return null; + if (value.startsWith("~")) { + if (value !== "~" && !value.startsWith("~/")) return null; + return options.home ? `${options.home}${value.slice(1)}` : null; + } + if (value.startsWith("%(prefix)/")) return null; + if (options.isAbsolute(value)) return value; + if (originPath === null || !options.isAbsolute(originPath)) return null; + // Git appends a relative include to the including file's directory without normalising it. + const separator = Math.max( + originPath.lastIndexOf("/"), + options.windows ? originPath.lastIndexOf("\\") : -1, + ); + return `${originPath.slice(0, separator + 1)}${value}`; +}; + +export interface GitConfigDependencyOptions { + readonly home: string | undefined; + readonly windows: boolean; + readonly isAbsolute: (path: string) => boolean; +} + +/** + * Files the listed values came from, and the includes they name (read or not), so a change to + * any of them is seen. Relative origins are the repository's own config files, which the caller + * tracks. `supported` is false when a value may depend on something these files do not cover. + */ +export const gitConfigListDependencies = ( + entries: ReadonlyArray, + options: GitConfigDependencyOptions, +): { readonly paths: ReadonlyArray; readonly supported: boolean } => { + const paths = new Set(); + let supported = true; + for (const entry of entries) { + let originPath: string | null = null; + if (entry.origin.startsWith("file:")) { + originPath = entry.origin.slice("file:".length); + if (options.isAbsolute(originPath)) paths.add(originPath); + } else if (entry.origin !== "command line:") { + supported = false; + } + const conditional = entry.key.startsWith("includeif.") && entry.key.endsWith(".path"); + if (entry.key !== "include.path" && !conditional) continue; + if (conditional) { + const condition = entry.key.slice("includeif.".length, -".path".length); + if (!INCLUDE_CONDITION_PREFIXES.some((prefix) => condition.startsWith(prefix))) { + supported = false; + } + } + const target = includeTarget(entry.value, originPath, options); + if (target === null) supported = false; + else paths.add(target); + } + return { paths: [...paths], supported }; +}; + +const CONFIG_ENVIRONMENT_NAMES = [ + "HOME", + "XDG_CONFIG_HOME", + "PATH", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_DIR", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_WORK_TREE", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", +] as const; +const MAX_CONFIG_ENVIRONMENT_ENTRIES = 1_000; + +// The environment that chooses which config Git reads; null when `GIT_CONFIG` names the only file +// or there are more `GIT_CONFIG_KEY_` overrides than the signature takes. +export const gitConfigEnvironmentSignature = (env: NodeJS.ProcessEnv): string | null => { + if (env.GIT_CONFIG !== undefined) return null; + const parts: Array = CONFIG_ENVIRONMENT_NAMES.map((name) => env[name] ?? null); + const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? "", 10); + if (count > MAX_CONFIG_ENVIRONMENT_ENTRIES) return null; + for (let index = 0; index < count; index++) { + parts.push(env[`GIT_CONFIG_KEY_${index}`] ?? null, env[`GIT_CONFIG_VALUE_${index}`] ?? null); + } + return JSON.stringify(parts); +}; + +export interface GitConfigDependency { + readonly path: string; + /** + * content: any change. identity: a directory's identity, mode and owners (a file's content), for + * the `.git` Git rewrites often. presence: whether it exists, with its mode and owners. absent: the + * snapshot holds only while it is missing, for the markers that would make Git find another + * repository on the way up. + */ + readonly kind: "content" | "identity" | "presence" | "absent"; +} + +const dependencyKey = (dependency: GitConfigDependency) => `${dependency.kind}:${dependency.path}`; + +// Git replaces config files by rename, which changes the inode. An in-place edit of the same size can +// go unseen when it lands within the filesystem's timestamp granularity (Linux stamps from a coarse +// clock, ~4 ms) or behind NFS attribute caching. Every kind counts the type, permission bits and +// owners, which discovery and safe.directory read; an ACL-only change is not seen. +const describeStats = (kind: GitConfigDependency["kind"], stats: NodeFS.BigIntStats) => { + const access = `${stats.mode}:${stats.uid}:${stats.gid}`; + if (kind === "presence" || kind === "absent") return access; + const identity = `${stats.dev}:${stats.ino}:${access}`; + return kind === "identity" && stats.isDirectory() + ? identity + : `${identity}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`; +}; + +const statOf = (path: string, follow: boolean) => + new Promise((resolve) => { + const done = (error: NodeJS.ErrnoException | null, stats: NodeFS.BigIntStats) => + resolve(error ? `!${error.code ?? "error"}` : stats); + try { + if (follow) NodeFS.stat(path, { bigint: true }, done); + else NodeFS.lstat(path, { bigint: true }, done); + } catch { + resolve("!invalid"); + } + }); + +// Git reads owners with lstat, so an identity that is a link counts as itself and as its target. +const statFingerprint = async ({ path, kind }: GitConfigDependency) => { + const describe = (stats: NodeFS.BigIntStats | string) => + typeof stats === "string" ? stats : describeStats(kind, stats); + if (kind !== "identity") return describe(await statOf(path, true)); + const link = await statOf(path, false); + if (typeof link === "string" || !link.isSymbolicLink()) return describe(link); + return `${describe(link)}>${describe(await statOf(path, true))}`; +}; + +const fingerprintDependencies = (dependencies: ReadonlyArray) => + Effect.promise(() => Promise.all(dependencies.map(statFingerprint))); + +const fileIdentity = (path: string) => + new Promise((resolve) => { + try { + NodeFS.stat(path, { bigint: true }, (error, stats) => + resolve(error ? null : `${stats.dev}:${stats.ino}`), + ); + } catch { + resolve(null); + } + }); + +const isSameFile = (left: string, right: string) => + Effect.promise(() => Promise.all([fileIdentity(left), fileIdentity(right)])).pipe( + Effect.map( + ([leftIdentity, rightIdentity]) => leftIdentity !== null && leftIdentity === rightIdentity, + ), + ); + +export interface GitConfigSnapshot { + readonly values: ReadonlyMap; + // Results of commands that read only config, valid while the snapshot is. + readonly results: Map; +} + +interface CachedGitConfigSnapshot { + readonly environment: string; + readonly dependencies: ReadonlyArray; + readonly fingerprint: string; + // null: a value may depend on something the dependencies miss, so every read runs Git. + readonly snapshot: GitConfigSnapshot | null; +} + +type RunGit = ( + operation: string, + cwd: string, + args: ReadonlyArray, +) => Effect.Effect; + +const CONFIG_SNAPSHOT_CACHE_CAPACITY = 2_048; +const CONFIG_SNAPSHOT_RETRY_TTL = Duration.seconds(1); +const CONFIG_SNAPSHOT_LOAD_ATTEMPTS = 3; +const CONFIG_FILE_LOCATIONS_CAPACITY = 16; +const REPOSITORY_PATH_COUNT = 10; +const REUSED_RESULTS_CAPACITY = 64; +const REPOSITORY_PATH_ARGS = [ + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--absolute-git-dir", + "--git-path", + "config", + "--git-path", + "config.worktree", + "--git-path", + "commondir", + "--git-path", + "remotes", + "--git-path", + "branches", + "--git-path", + "HEAD", + "--git-path", + "objects", + "--git-path", + "refs", + "--show-prefix", +] as const; + +/** + * Per-directory snapshots of `git config --list`, reused while the files that chose and supplied + * those values keep the same stat. Every dependency is stat'ed before the read that depends on it, + * so a write during a load leaves the snapshot stale rather than wrong. + */ +export const makeGitConfigSnapshots = Effect.fn("makeGitConfigSnapshots")(function* ( + runGit: RunGit, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const windows = (yield* HostProcess.Platform) === "win32"; + // Git before 2.42 has no `git var GIT_CONFIG_*`, so it cannot name its global and system files. + let supported = true; + const configFileLocations = new Map>(); + const readOnce = new Set(); + const reloadDependencies = new Map>(); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe( + Effect.map((names) => names.map((name) => path.join(directory, name))), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + + const configFiles = (result: GitVcsDriver.ExecuteGitResult) => { + if (result.exitCode === 0) return result.stdout.split("\n").filter((line) => line.length > 0); + if (result.exitCode === 1) return []; + if (result.exitCode === 129) supported = false; + return null; + }; + + // The global and system files depend only on the environment and the Git build. + const readConfigFileLocations = Effect.fn("GitVcsDriver.configSnapshot.configFiles")(function* ( + cwd: string, + environment: string, + ) { + const known = configFileLocations.get(environment); + if (known !== undefined) return known; + const [globalResult, systemResult] = yield* Effect.all( + [ + runGit("GitVcsDriver.configSnapshot.globalFiles", cwd, ["var", "GIT_CONFIG_GLOBAL"]), + runGit("GitVcsDriver.configSnapshot.systemFiles", cwd, ["var", "GIT_CONFIG_SYSTEM"]), + ], + { concurrency: 2 }, + ); + const globalFiles = configFiles(globalResult); + const systemFiles = configFiles(systemResult); + if (globalFiles === null || systemFiles === null) return null; + if (configFileLocations.size >= CONFIG_FILE_LOCATIONS_CAPACITY) configFileLocations.clear(); + const files = [...globalFiles, ...systemFiles]; + configFileLocations.set(environment, files); + return files; + }); + + const readConfigState = Effect.fn("GitVcsDriver.configSnapshot.read")(function* ( + cwd: string, + environment: string, + ) { + const pathsResult = yield* runGit( + "GitVcsDriver.configSnapshot.paths", + cwd, + REPOSITORY_PATH_ARGS, + ); + if (pathsResult.exitCode !== 0) return null; + const lines = pathsResult.stdout.split("\n"); + // Git before 2.31 echoes --path-format; a path containing a newline spans lines. + if (lines[0] === "--path-format=absolute") { + supported = false; + return null; + } + const paths = lines.slice(0, REPOSITORY_PATH_COUNT); + const prefix = lines[REPOSITORY_PATH_COUNT]; + if ( + lines.length !== REPOSITORY_PATH_COUNT + 2 || + prefix === undefined || + lines.at(-1) !== "" || + !paths.every((line) => line.length > 0 && path.isAbsolute(line)) + ) { + return null; + } + const [ + toplevel, + gitDir, + config, + configWorktree, + commondir, + remotes, + branches, + head, + objects, + refs, + ] = paths as [string, string, string, string, string, string, string, string, string, string]; + const segments = prefix.split("/").filter((segment) => segment.length > 0); + const [locations, listResult, remoteFiles, branchFiles, insideTopLevel] = yield* Effect.all( + [ + readConfigFileLocations(cwd, environment), + runGit("GitVcsDriver.configSnapshot.list", cwd, [ + "config", + "--list", + "--show-origin", + "-z", + ]), + listDirectory(remotes), + listDirectory(branches), + isSameFile(cwd, path.join(toplevel, ...segments)), + ], + { concurrency: "unbounded" }, + ); + // A directory outside the work tree (GIT_WORK_TREE elsewhere) has no prefix to walk. + if (locations === null || !insideTopLevel) return null; + + // Discovery: the directory, what would make Git stop below the top level (a `.git`, or HEAD of a + // bare repository), the `.git` it found, what keeps that a repository, and the top level and git + // dir whose owners safe.directory checks. The prefix comes from Git, cased as on disk like the + // top level, whatever case the directory was given in. + const dependencies: GitConfigDependency[] = [{ path: cwd, kind: "identity" }]; + for (let depth = segments.length; depth > 0; depth--) { + const directory = path.join(toplevel, ...segments.slice(0, depth)); + dependencies.push( + { path: path.join(directory, ".git"), kind: "absent" }, + { path: path.join(directory, "HEAD"), kind: "absent" }, + ); + } + dependencies.push( + { path: path.join(toplevel, ".git"), kind: "identity" }, + { path: toplevel, kind: "identity" }, + { path: gitDir, kind: "identity" }, + { path: head, kind: "content" }, + { path: objects, kind: "presence" }, + { path: refs, kind: "presence" }, + ); + const files = [ + config, + configWorktree, + commondir, + remotes, + ...remoteFiles, + branches, + ...branchFiles, + ...locations, + ]; + let snapshotSupported = locations.every((file) => path.isAbsolute(file)); + const entries = + listResult.exitCode === 0 && !listResult.stdoutTruncated + ? parseGitConfigList(listResult.stdout) + : null; + if (entries === null) { + snapshotSupported = false; + } else { + const listed = gitConfigListDependencies(entries, { + home: process.env.HOME, + windows, + isAbsolute: path.isAbsolute, + }); + files.push(...listed.paths); + snapshotSupported &&= listed.supported; + } + for (const file of files) dependencies.push({ path: file, kind: "content" }); + const values = new Map(); + for (const entry of entries ?? []) values.set(entry.key, entry.value); + return { dependencies, values: snapshotSupported ? values : null }; + }); + + const load = Effect.fn("GitVcsDriver.configSnapshot.load")( + function* (cwd: string) { + const environment = gitConfigEnvironmentSignature(process.env); + if (environment === null) return null; + let dependencies = reloadDependencies.get(cwd) ?? []; + reloadDependencies.delete(cwd); + for (let attempt = 0; attempt < CONFIG_SNAPSHOT_LOAD_ATTEMPTS; attempt++) { + const fingerprints = yield* fingerprintDependencies(dependencies); + const state = yield* readConfigState(cwd, environment); + if (state === null) return null; + const fingerprintByKey = new Map( + dependencies.map((dependency, index) => [ + dependencyKey(dependency), + fingerprints[index]!, + ]), + ); + const unique = [ + ...new Map( + state.dependencies.map((dependency) => [dependencyKey(dependency), dependency]), + ), + ].map(([, dependency]) => dependency); + if (unique.every((dependency) => fingerprintByKey.has(dependencyKey(dependency)))) { + const fingerprint = unique.map((dependency) => + fingerprintByKey.get(dependencyKey(dependency))!, + ); + const discovered = unique.every( + (dependency, index) => + dependency.kind !== "absent" || fingerprint[index]!.startsWith("!"), + ); + return { + environment, + dependencies: unique, + fingerprint: fingerprint.join("\n"), + snapshot: + state.values === null || !discovered + ? null + : { values: state.values, results: new Map() }, + } satisfies CachedGitConfigSnapshot; + } + // Something was read before it was stat'ed; stat it and read again. + dependencies = unique; + } + return null; + }, + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + + const snapshots = yield* Cache.makeWith(load, { + capacity: CONFIG_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : CONFIG_SNAPSHOT_RETRY_TTL, + }); + + const isCurrent = (cached: CachedGitConfigSnapshot) => + cached.environment === gitConfigEnvironmentSignature(process.env) + ? fingerprintDependencies(cached.dependencies).pipe( + Effect.map((fingerprints) => fingerprints.join("\n") === cached.fingerprint), + ) + : Effect.succeed(false); + + // A one-off read runs one command; a directory read again, such as on every refresh, is loaded. + const isReadAgain = (cwd: string) => { + if (readOnce.has(cwd)) return true; + if (readOnce.size >= CONFIG_SNAPSHOT_CACHE_CAPACITY) { + readOnce.delete(readOnce.values().next().value!); + } + readOnce.add(cwd); + return false; + }; + + /** + * The current snapshot for `cwd`, or null when this read must run Git. Untraced: it runs on every + * config read, several per status refresh; loads keep their spans. + */ + const read = Effect.fnUntraced(function* (cwd: string) { + if (!supported) return null; + if (!(yield* Cache.has(snapshots, cwd)) && !isReadAgain(cwd)) return null; + const cached = yield* Cache.get(snapshots, cwd); + if (cached === null) return null; + if (yield* isCurrent(cached)) return cached.snapshot; + reloadDependencies.set(cwd, cached.dependencies); + yield* Cache.invalidateWhen(snapshots, cwd, (current) => current === cached); + // Whatever replaces the stale entry loaded after this read began, so it needs no check. + const reloaded = yield* Cache.get(snapshots, cwd); + reloadDependencies.delete(cwd); + return reloaded?.snapshot ?? null; + }); + + return { read }; +}); + +/** + * Runs a command whose result depends only on config (such as `git remote`) once per snapshot. + * Exit codes from 128 up are fatal errors or signals that a later run could avoid, so they rerun. + */ +export const runWithGitConfigSnapshot = ( + snapshot: GitConfigSnapshot | null, + args: ReadonlyArray, + run: Effect.Effect, +): Effect.Effect => { + if (snapshot === null) return run; + const key = args.join("\0"); + const reused = snapshot.results.get(key); + if (reused !== undefined) return Effect.succeed(reused); + return run.pipe( + Effect.tap((result) => + Effect.sync(() => { + if ( + result.exitCode !== null && + result.exitCode < 128 && + !result.stdoutTruncated && + !result.stderrTruncated + ) { + if (snapshot.results.size >= REUSED_RESULTS_CAPACITY) { + snapshot.results.delete(snapshot.results.keys().next().value!); + } + snapshot.results.set(key, result); + } + }), + ), + ); +};