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,