From 2fe6d526107f93bdba6e56f20562117615354408 Mon Sep 17 00:00:00 2001 From: Navid Nadali Date: Wed, 4 Nov 2026 10:45:42 +0000 Subject: [PATCH 1/2] perf(server): status refreshes reuse git config until a file changes Each status refresh ran about seven git config reads and git remote twice, for values that only change when a file changes. One `git config --list -z` snapshot per repository now answers those reads until a config file it read, an include or the git dir changes. Git older than 2.42 falls back to one command per read. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 500 +++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 87 ++- apps/server/src/vcs/gitConfigSnapshot.test.ts | 146 +++++ apps/server/src/vcs/gitConfigSnapshot.ts | 535 ++++++++++++++++++ 4 files changed, 1241 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/vcs/gitConfigSnapshot.test.ts create mode 100644 apps/server/src/vcs/gitConfigSnapshot.ts diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d00301e96e7d8..09d7f7e3704bb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,6 +1,7 @@ // @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"; @@ -348,6 +349,496 @@ 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 setGlobalConfig = (file: string) => + Effect.acquireRelease( + Effect.sync(() => { + const previous = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = file; + return previous; + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = previous; + }), + ); + 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, + }, + ...(["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 +1071,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..18a5d82f448db --- /dev/null +++ b/apps/server/src/vcs/gitConfigSnapshot.ts @@ -0,0 +1,535 @@ +// @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 (a file's content), for the `.git` Git + * rewrites often. presence: whether it exists. 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. Ownership counts only on the read directory and the +// found `.git`, so a chown elsewhere that trips safe.directory is not seen. +const statFingerprint = (dependency: GitConfigDependency) => + new Promise((resolve) => { + try { + NodeFS.stat(dependency.path, { bigint: true }, (error, stats) => { + if (error) { + resolve(`!${error.code ?? "error"}`); + return; + } + if (dependency.kind === "presence" || dependency.kind === "absent") { + resolve(stats.isDirectory() ? "directory" : "file"); + return; + } + const identity = `${stats.dev}:${stats.ino}:${stats.mode}:${stats.uid}`; + resolve( + dependency.kind === "identity" && stats.isDirectory() + ? identity + : `${identity}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`, + ); + }); + } catch { + resolve("!invalid"); + } + }); + +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, , 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, and what keeps that a repository. 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: 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); + } + }), + ), + ); +}; From dfc3ab13914d499523ec6c8476bef0f9fa61cca8 Mon Sep 17 00:00:00 2001 From: Navid Nadali Date: Wed, 4 Nov 2026 13:52:25 +0000 Subject: [PATCH 2/2] fix(server): a git config snapshot notices owner and permission changes A config snapshot stayed current when the top level of the work tree or a linked worktree's git dir changed owner, or when .git/objects or .git/refs lost search permission. Git then refuses or no longer finds the repository while the snapshot kept answering. Fingerprints now carry the mode and owners, and the top level and git dir are tracked, without another git command. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 118 ++++++++++++++++++- apps/server/src/vcs/gitConfigSnapshot.ts | 75 +++++++----- 2 files changed, 160 insertions(+), 33 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 09d7f7e3704bb..786b75f16d1da 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -7,6 +7,7 @@ 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"; @@ -45,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); @@ -416,7 +443,10 @@ describe("config reads", () => { 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 change: ( + cwd: string, + readCwd: string, + ) => Effect.Effect; readonly read: ( driver: GitVcsDriver.GitVcsDriver["Service"], cwd: string, @@ -430,19 +460,39 @@ describe("config reads", () => { driver.readConfigValue(cwd, key); const readPrimaryRemote = (driver: GitVcsDriver.GitVcsDriver["Service"], cwd: string) => driver.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); - const setGlobalConfig = (file: string) => + const setEnv = (name: string, value: string) => Effect.acquireRelease( Effect.sync(() => { - const previous = process.env.GIT_CONFIG_GLOBAL; - process.env.GIT_CONFIG_GLOBAL = file; + const previous = process.env[name]; + process.env[name] = value; return previous; }), (previous) => Effect.sync(() => { - if (previous === undefined) delete process.env.GIT_CONFIG_GLOBAL; - else process.env.GIT_CONFIG_GLOBAL = previous; + 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 = [ @@ -596,6 +646,62 @@ describe("config reads", () => { 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) => diff --git a/apps/server/src/vcs/gitConfigSnapshot.ts b/apps/server/src/vcs/gitConfigSnapshot.ts index 18a5d82f448db..0e37ee9b2e5e4 100644 --- a/apps/server/src/vcs/gitConfigSnapshot.ts +++ b/apps/server/src/vcs/gitConfigSnapshot.ts @@ -154,9 +154,10 @@ export const gitConfigEnvironmentSignature = (env: NodeJS.ProcessEnv): string | export interface GitConfigDependency { readonly path: string; /** - * content: any change. identity: a directory's identity (a file's content), for the `.git` Git - * rewrites often. presence: whether it exists. absent: the snapshot holds only while it is missing, - * for the markers that would make Git find another repository on the way up. + * 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"; } @@ -165,32 +166,39 @@ const dependencyKey = (dependency: GitConfigDependency) => `${dependency.kind}:$ // 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. Ownership counts only on the read directory and the -// found `.git`, so a chown elsewhere that trips safe.directory is not seen. -const statFingerprint = (dependency: GitConfigDependency) => - new Promise((resolve) => { +// 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 { - NodeFS.stat(dependency.path, { bigint: true }, (error, stats) => { - if (error) { - resolve(`!${error.code ?? "error"}`); - return; - } - if (dependency.kind === "presence" || dependency.kind === "absent") { - resolve(stats.isDirectory() ? "directory" : "file"); - return; - } - const identity = `${stats.dev}:${stats.ino}:${stats.mode}:${stats.uid}`; - resolve( - dependency.kind === "identity" && stats.isDirectory() - ? identity - : `${identity}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`, - ); - }); + 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))); @@ -341,8 +349,18 @@ export const makeGitConfigSnapshots = Effect.fn("makeGitConfigSnapshots")(functi ) { return null; } - const [toplevel, , config, configWorktree, commondir, remotes, branches, head, objects, refs] = - paths as [string, string, string, string, string, string, string, string, string, string]; + 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( [ @@ -363,8 +381,9 @@ export const makeGitConfigSnapshots = Effect.fn("makeGitConfigSnapshots")(functi 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, and what keeps that a repository. The prefix comes from - // Git, cased as on disk like the top level, whatever case the directory was given in. + // 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)); @@ -375,6 +394,8 @@ export const makeGitConfigSnapshots = Effect.fn("makeGitConfigSnapshots")(functi } 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" },