From b776347e0acd9d0a953d571a865dfb7595368367 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Wed, 23 Sep 2026 06:44:41 +0000 Subject: [PATCH] fix(server): capture checkpoints through symlinked repository subdirectories Git's relative --git-common-dir is computed from the physical working directory. path.resolve applied ".." to the symlink path, so a project opened through a symlink into a repository subdirectory pointed GIT_INDEX_FILE at a missing directory and checkpoint capture exited 128 on every turn. Ask Git for the absolute common directory before joining it with the project path. Repository path lookup uses that same absolute directory, then realpath, so later --git-dir reads stay in the real repository. Fixes #13187 --- apps/server/src/vcs/GitVcsDriver.test.ts | 86 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 13 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 38 ++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 14 +++- 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..ead9e76ab189 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -163,6 +164,91 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); +it.effect.skipIf(!symlinksSupported)( + "checkpoint capture writes its index into the real git dir when cwd is a symlinked subdirectory", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveRunner = yield* ProcessRunner.ProcessRunner; + const indexFiles: string[] = []; + const captureProcess = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + const indexFile = input.env?.GIT_INDEX_FILE; + if (indexFile !== undefined) indexFiles.push(indexFile); + return liveRunner.run(input); + }, + }), + ); + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, captureProcess), + ); + const sandbox = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-checkpoint-symlink-", + }); + const repo = path.join(sandbox, "repo"); + const subdir = path.join(repo, "sub", "dir"); + const link = path.join(sandbox, "nest", "link"); + yield* fileSystem.makeDirectory(subdir, { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(link), { recursive: true }); + yield* fileSystem.symlink(subdir, link); + /** Runs git in `cwd` through the checkpoint driver under test. */ + const git = (cwd: string, args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + yield* git(repo, ["init"]); + yield* git(repo, ["config", "user.name", "Test"]); + yield* git(repo, ["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(repo, "file.txt"), "initial\n"); + yield* git(repo, ["add", "."]); + yield* git(repo, ["commit", "-m", "initial"]); + yield* fileSystem.writeFileString(path.join(subdir, "nested.txt"), "from-link\n"); + + const realCommonDir = yield* fileSystem.realPath( + (yield* git(repo, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ])).stdout.trim(), + ); + /** + * Checkpoint Git commands must keep the private index in the repository + * that owns `cwd`, including when a different repository sits where a + * lexical `path.resolve` of the relative common dir would land. + */ + const assertIndexesStayInRealCommonDir = Effect.gen(function* () { + assert.isTrue(indexFiles.length > 0); + for (const indexFile of indexFiles) { + assert.strictEqual(yield* fileSystem.realPath(path.dirname(indexFile)), realCommonDir); + } + }); + + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/symlink"); + yield* driver.checkpoints.captureCheckpoint({ cwd: link, checkpointRef }); + yield* assertIndexesStayInRealCommonDir; + assert.strictEqual( + (yield* git(repo, ["show", `${checkpointRef}:sub/dir/nested.txt`])).stdout, + "from-link\n", + ); + + indexFiles.length = 0; + yield* git(sandbox, ["init", "-b", "decoy-only"]); + yield* git(sandbox, ["config", "user.name", "Test"]); + yield* git(sandbox, ["config", "user.email", "test@test.com"]); + yield* git(sandbox, ["commit", "--allow-empty", "-m", "decoy"]); + yield* fileSystem.writeFileString(path.join(subdir, "nested.txt"), "again\n"); + const decoyRef = CheckpointRef.make("refs/t3/checkpoints/symlink-decoy"); + yield* driver.checkpoints.captureCheckpoint({ cwd: link, checkpointRef: decoyRef }); + yield* assertIndexesStayInRealCommonDir; + const decoyEntries = yield* fileSystem.readDirectory(path.join(sandbox, ".git")); + assert.isFalse(decoyEntries.some((entry) => entry.startsWith("t3-checkpoint-index"))); + assert.strictEqual( + (yield* git(repo, ["show", `${decoyRef}:sub/dir/nested.txt`])).stdout, + "again\n", + ); + }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), +); + it.effect("checkpoint recovery discovers nested HEAD independently of inherited GIT_DIR", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f450eef0dc24..d220c1f59f19 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -753,12 +753,23 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); + /** + * Absolute path of the repository's common git directory, where checkpoint + * capture places its private index. + * + * A relative `--git-common-dir` is computed from Git's physical working + * directory. Joining it with `path.resolve` applies `..` to the project path + * string, so a symlink into a repository subdirectory climbs out of the + * symlink and `GIT_INDEX_FILE` points at a directory that does not exist + * (exit 128). `--path-format=absolute` is the same request already used for + * `--git-path index`. + */ const resolveGitCommonDir = (cwd: string) => Effect.gen(function* () { const result = yield* execute({ operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", cwd, - args: ["rev-parse", "--git-common-dir"], + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], }); const gitCommonDir = result.stdout.trim(); return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 98dec86522de..f6acda649552 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -28,6 +28,7 @@ import { type ReviewDiffFileContentsInput, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; import { @@ -334,7 +335,7 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, + { args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], lcAll: "C" }, ]); }).pipe(Effect.provide(layer)); }); @@ -1688,6 +1689,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { + it.effect.skipIf(!symlinksSupported)( + "resolves the common dir through a symlink into a repository subdirectory", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sandbox = yield* makeTmpDir("git-symlink-subdir-"); + const repo = pathService.join(sandbox, "repo"); + const link = pathService.join(sandbox, "nest", "link"); + yield* fileSystem.makeDirectory(pathService.join(repo, "sub", "dir"), { + recursive: true, + }); + yield* fileSystem.makeDirectory(pathService.dirname(link), { recursive: true }); + yield* fileSystem.symlink(pathService.join(repo, "sub", "dir"), link); + yield* initRepoWithCommit(repo); + yield* git(repo, ["checkout", "-b", "real-only"]); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const refsBeforeDecoy = yield* driver.listRefs({ cwd: link, refresh: true }); + assert.isTrue(refsBeforeDecoy.isRepo); + assert.isTrue(refsBeforeDecoy.refs.some((ref) => ref.name === "real-only")); + + // The lexical join of the symlink with ../../.git is sandbox/.git. + // That directory must not be treated as this repository. + yield* git(sandbox, ["init", "-b", "decoy-only"]); + yield* git(sandbox, ["config", "user.email", "test@test.com"]); + yield* git(sandbox, ["config", "user.name", "Test"]); + yield* git(sandbox, ["commit", "--allow-empty", "-m", "decoy"]); + + const refsAfterDecoy = yield* driver.listRefs({ cwd: link, refresh: true }); + assert.isTrue(refsAfterDecoy.refs.some((ref) => ref.name === "real-only")); + assert.isFalse(refsAfterDecoy.refs.some((ref) => ref.name === "decoy-only")); + }), + ); + it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5fbae919c258..23ea51226db3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1142,13 +1142,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.asVoid); }; + /** + * Resolves the common git directory, worktree root, and current branch. + * + * `--git-common-dir` is requested with `--path-format=absolute` because a + * relative answer is computed from Git's physical working directory. + * `path.resolve` would apply `..` to a symlinked project path and climb out + * of a symlink that points at a repository subdirectory. `realPath` still + * runs afterward so Windows 8.3 short names collapse to one directory. + */ const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { + const gitCommonDirArgs = ["rev-parse", "--path-format=absolute", "--git-common-dir"] as const; const commonDirResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.resolveRepositoryPaths.commonDir", cwd, - ["rev-parse", "--git-common-dir"], + gitCommonDirArgs, { timeoutMs: 5_000, allowNonZeroExit: true, @@ -1163,7 +1173,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.resolveRepositoryPaths.commonDir", cwd, - args: ["rev-parse", "--git-common-dir"], + args: gitCommonDirArgs, }), detail: "Failed to resolve the Git common directory.", exitCode: commonDirResult.exitCode,