From 48fc1cd9c46db09ac6ac5ccd79deac3a7e710f50 Mon Sep 17 00:00:00 2001 From: Walid Baharwal Date: Sat, 29 Aug 2026 17:25:30 +0500 Subject: [PATCH 1/3] fix(server): reject a bare repository root when adding a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worktree-only layout keeps a bare repository inside the root (commonly `/.bare`) and checks every branch out as a sibling directory, so the root holds no working tree of its own. `git rev-parse --is-inside-work-tree` still answers true there, so the root was accepted as an ordinary repository and became the parent of every worktree: status reported each worktree as untracked, the diff view was scoped across all of them, and a thread's cwd spanned every checkout at once. Recognize the layout where a project is added and explain that a worktree directory inside it is what to add. Detection reads the `.git` file: a directory `.git` is an ordinary repository, and a `.git` file pointing outside the root is a linked worktree or a submodule. What remains is shared with `git init --separate-git-dir`, and git records nothing that separates the two — it writes no `core.worktree` for either — so two structural signals are required together: the git directory hosts linked worktrees, and it never staged anything of its own. A working tree that has committed has an index; a bare repository does not. Anything ambiguous is accepted, since wrongly refusing a valid root is worse than the misscoping this prevents. Containment is tested with a relative path so a root at the filesystem boundary is compared correctly. Fixes #8164 --- apps/server/src/cli/project.ts | 5 +- apps/server/src/orchestration/Normalizer.ts | 1 + .../src/workspace/WorkspacePaths.test.ts | 163 ++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 86 ++++++++- packages/shared/src/devHome.ts | 13 +- packages/shared/src/git.ts | 15 ++ 6 files changed, 273 insertions(+), 10 deletions(-) diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 39b3b2431129..eafecd06ea4a 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -461,7 +461,10 @@ const projectAddCommand = Command.make("add", { command: ProjectCliDispatchCommand, ) => Effect.Effect; }) { - const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand( + flags.workspaceRoot, + ).pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); const existingProject = snapshot.projects.find( (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, ); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 1226a6cd25d5..6631c4813d34 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -100,6 +100,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => createIfMissing: createIfMissing === true, }) .pipe( + Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout), Effect.mapError( (cause) => new OrchestrationDispatchCommandError({ diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index 4f3bc833b4c5..bdd9bbfebc34 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -47,6 +47,169 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("rejects a bare repository root that only holds worktrees", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* fileSystem + .makeDirectory(path.join(root, ".bare", "worktrees", "develop"), { recursive: true }) + .pipe(Effect.orDie); + yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(root) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout), Effect.flip); + + expect(error.message).toContain("holds a bare repository and its worktrees"); + }), + ); + + it.effect("accepts a bare repository root when the caller does not check the layout", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* fileSystem + .makeDirectory(path.join(root, ".bare", "worktrees", "develop"), { recursive: true }) + .pipe(Effect.orDie); + yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(root); + + expect(resolved).toBe(root); + }), + ); + + it.effect("accepts a worktree of a bare repository root", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* fileSystem + .makeDirectory(path.join(root, ".bare", "worktrees", "develop"), { recursive: true }) + .pipe(Effect.orDie); + yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + const worktree = path.join(root, "develop"); + yield* writeTextFile( + worktree, + ".git", + `gitdir: ${path.join(root, ".bare", "worktrees", "develop")}\n`, + ); + + const resolved = yield* workspacePaths + .normalizeWorkspaceRoot(worktree) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + + expect(resolved).toBe(worktree); + }), + ); + + it.effect("accepts an ordinary repository and a linked worktree", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* makeTempDir(); + const ordinary = path.join(parent, "ordinary"); + yield* fileSystem + .makeDirectory(path.join(ordinary, ".git", "worktrees", "feature"), { recursive: true }) + .pipe(Effect.orDie); + const linked = path.join(parent, "feature"); + yield* writeTextFile( + linked, + ".git", + `gitdir: ${path.join(ordinary, ".git", "worktrees", "feature")}\n`, + ); + + const resolvedOrdinary = yield* workspacePaths + .normalizeWorkspaceRoot(ordinary) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + const resolvedLinked = yield* workspacePaths + .normalizeWorkspaceRoot(linked) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + + expect(resolvedOrdinary).toBe(ordinary); + expect(resolvedLinked).toBe(linked); + }), + ); + + it.effect("accepts a bare repository root before any worktree is added", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* fileSystem + .makeDirectory(path.join(root, ".bare", "objects"), { recursive: true }) + .pipe(Effect.orDie); + yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + + const resolved = yield* workspacePaths + .normalizeWorkspaceRoot(root) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + + expect(resolved).toBe(root); + }), + ); + + it.effect("accepts a freshly initialized separate git directory inside the root", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* fileSystem + .makeDirectory(path.join(root, "store", "objects"), { recursive: true }) + .pipe(Effect.orDie); + yield* writeTextFile(root, ".git", `gitdir: ${path.join(root, "store")}\n`); + + const resolved = yield* workspacePaths + .normalizeWorkspaceRoot(root) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + + expect(resolved).toBe(root); + }), + ); + + it.effect("accepts a separate git directory inside a root that stages work", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* fileSystem + .makeDirectory(path.join(root, "store", "worktrees", "feature"), { recursive: true }) + .pipe(Effect.orDie); + yield* writeTextFile(root, "store/index", ""); + yield* writeTextFile(root, ".git", `gitdir: ${path.join(root, "store")}\n`); + + const resolved = yield* workspacePaths + .normalizeWorkspaceRoot(root) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + + expect(resolved).toBe(root); + }), + ); + + it.effect("accepts a root whose git file points at the root itself", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* writeTextFile(root, ".git", `gitdir: ${path.join(root, ".")}\n`); + + const resolved = yield* workspacePaths + .normalizeWorkspaceRoot(root) + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + + expect(resolved).toBe(root); + }), + ); + it.effect("rejects missing directories", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 5acf6677cdef..818f13b21d4f 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -14,6 +14,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { parseGitDirPointer } from "@t3tools/shared/git"; export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( "WorkspaceRootNotExistsError", @@ -78,11 +79,24 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()( + "WorkspaceRootBareRepositoryLayoutError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `'${this.normalizedWorkspaceRoot}' holds a bare repository and its worktrees rather than a working tree of its own. Add one of the worktree directories inside it instead.`; + } +} + export const WorkspacePathsError = Schema.Union([ WorkspaceRootNotExistsError, WorkspaceRootCreateFailedError, WorkspaceRootStatFailedError, WorkspaceRootNotDirectoryError, + WorkspaceRootBareRepositoryLayoutError, WorkspacePathOutsideRootError, ]); export type WorkspacePathsError = typeof WorkspacePathsError.Type; @@ -102,6 +116,14 @@ export class WorkspacePaths extends Context.Service< | WorkspaceRootStatFailedError | WorkspaceRootNotDirectoryError >; + /** + * Reject a normalized root that holds a bare repository and its worktrees + * instead of a working tree of its own. Only the paths that add a project + * call this; every other caller keeps working with such a root. + */ + readonly ensureNotBareRepositoryLayout: ( + normalizedWorkspaceRoot: string, + ) => Effect.Effect; /** * Resolve a relative path within a validated workspace root. * @@ -158,6 +180,53 @@ export const make = Effect.gen(function* () { ); }); + // A worktree-only layout keeps a bare repository inside the root (commonly + // `/.bare`) with every branch checked out as a sibling directory, so the + // root holds no working tree of its own. `git rev-parse --is-inside-work-tree` + // still answers true there, which would make the root the parent of every + // worktree: status reports each worktree as untracked and a thread's cwd spans + // all of them. + // + // Read from the `.git` file rather than by running git, because a directory + // `.git` is an ordinary repository and a `.git` file pointing outside the root + // is a linked worktree or a submodule. What remains — an in-root gitdir — is + // shared with `git init --separate-git-dir`, and git records nothing that + // separates the two (it writes no `core.worktree` for either). Two structural + // signals together do: the gitdir hosts linked worktrees, and it never staged + // anything of its own. A working tree that has committed has an index; a bare + // repository never does. Anything ambiguous is accepted, since wrongly + // refusing a valid root is worse than the misscoping this prevents. + const isBareRepositoryLayout = Effect.fn("WorkspacePaths.isBareRepositoryLayout")(function* ( + normalizedWorkspaceRoot: string, + ) { + const gitPath = path.join(normalizedWorkspaceRoot, ".git"); + const gitStat = yield* fileSystem.stat(gitPath).pipe(Effect.option); + if (gitStat._tag === "None" || gitStat.value.type !== "File") return false; + + const gitFile = yield* fileSystem.readFileString(gitPath).pipe(Effect.orElseSucceed(() => "")); + const gitDirTarget = parseGitDirPointer(gitFile); + if (gitDirTarget === undefined) return false; + + const gitDir = path.resolve(normalizedWorkspaceRoot, gitDirTarget); + const gitDirFromRoot = path.relative(normalizedWorkspaceRoot, gitDir); + if ( + gitDirFromRoot.length === 0 || + gitDirFromRoot === ".." || + gitDirFromRoot.startsWith(`..${path.sep}`) || + path.isAbsolute(gitDirFromRoot) + ) { + return false; + } + + const worktrees = yield* fileSystem + .readDirectory(path.join(gitDir, "worktrees")) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + if (worktrees.length === 0) return false; + + const indexStat = yield* fileSystem.stat(path.join(gitDir, "index")).pipe(Effect.option); + return indexStat._tag === "None"; + }); + const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { @@ -199,6 +268,17 @@ export const make = Effect.gen(function* () { return normalizedWorkspaceRoot; }); + const ensureNotBareRepositoryLayout: WorkspacePaths["Service"]["ensureNotBareRepositoryLayout"] = + Effect.fn("WorkspacePaths.ensureNotBareRepositoryLayout")(function* (normalizedWorkspaceRoot) { + if (yield* isBareRepositoryLayout(normalizedWorkspaceRoot)) { + return yield* new WorkspaceRootBareRepositoryLayoutError({ + workspaceRoot: normalizedWorkspaceRoot, + normalizedWorkspaceRoot, + }); + } + return normalizedWorkspaceRoot; + }); + const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] = Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { const normalizedInputPath = input.relativePath.trim(); @@ -230,7 +310,11 @@ export const make = Effect.gen(function* () { }; }); - return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot }); + return WorkspacePaths.of({ + normalizeWorkspaceRoot, + ensureNotBareRepositoryLayout, + resolveRelativePathWithinRoot, + }); }); export const layer = Layer.effect(WorkspacePaths, make); diff --git a/packages/shared/src/devHome.ts b/packages/shared/src/devHome.ts index 1fde5409f5cd..cca3dfd9ee6a 100644 --- a/packages/shared/src/devHome.ts +++ b/packages/shared/src/devHome.ts @@ -13,6 +13,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import { parseGitDirPointer } from "./git.ts"; + /** * A `.git` file points at the real git directory. A linked worktree's lives at * `/worktrees/`; a submodule's at @@ -25,19 +27,14 @@ import * as Path from "effect/Path"; * rather than on the name of the directory containing it. */ const pointsAtLinkedWorktree = (gitFileContents: string, path: Path.Path): boolean => { - const gitdir = gitFileContents - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.startsWith("gitdir:")) - ?.slice("gitdir:".length) - .trim(); - if (gitdir === undefined || gitdir.length === 0) { + const gitdir = parseGitDirPointer(gitFileContents); + if (gitdir === undefined) { return false; } // Compare as path segments so a directory merely named `…worktrees…` cannot // match as a substring. Trailing separators normalize away first. const segments = path - .normalize(gitdir.replaceAll("\\", "/")) + .normalize(gitdir) .split(/[/\\]/) .filter((segment) => segment.length > 0); // `/worktrees/`: `worktrees` is the penultimate segment, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..e7dc682ad233 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -10,6 +10,21 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; +/** + * The path a `.git` *file* points at, or undefined when the contents are not a + * gitdir pointer. Backslashes normalize to `/` so a Windows-authored pointer + * parses on POSIX. + */ +export const parseGitDirPointer = (gitFileContents: string): string | undefined => { + const gitdir = gitFileContents + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith("gitdir:")) + ?.slice("gitdir:".length) + .trim(); + return gitdir === undefined || gitdir.length === 0 ? undefined : gitdir.replaceAll("\\", "/"); +}; + export const WORKTREE_BRANCH_PREFIX = "t3code"; // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly From b89cfd618f30f55006bc0dac4f63db4b17f38430 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:16:15 -0700 Subject: [PATCH 2/3] fix(server): confirm bare-root layouts with Git --- apps/server/src/bin.test.ts | 58 +++++ .../src/workspace/WorkspacePaths.test.ts | 225 +++++++++++++----- apps/server/src/workspace/WorkspacePaths.ts | 50 ++-- packages/shared/src/devHome.test.ts | 11 + packages/shared/src/devHome.ts | 2 +- packages/shared/src/git.ts | 7 +- 6 files changed, 264 insertions(+), 89 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e1a13d4ce8e7..e6465fda7b25 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -10,6 +10,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, EnvironmentOrchestrationHttpApi, + ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -46,6 +47,7 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; @@ -138,6 +140,62 @@ const readPersistedSnapshot = (baseDir: string) => }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); }); +it.effect.each(["bare", "bare-index", "separate", "converted"] as const)( + "project add and normalization respect current Git bare status: %s", + (layout) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-bare-state-")); + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-bare-root-")); + const gitDir = NodePath.join(root, "store"); + const git = (...args: string[]) => + NodeChildProcess.execFileSync("git", args, { cwd: root, stdio: "ignore" }); + if (layout === "separate") { + git("init", "--separate-git-dir", gitDir); + } else { + git("init", "--bare", gitDir); + NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: ./store\n"); + } + git("worktree", "add", "--orphan", "-b", "feature", "feature"); + if (layout === "bare-index") git("--git-dir", gitDir, "read-tree", "--empty"); + if (layout === "converted") git("init"); + + const cliResult = yield* runCliWithRuntime([ + "project", + "add", + root, + "--base-dir", + baseDir, + ]).pipe(Effect.result); + const config = yield* makeCliTestServerConfig(baseDir); + const normalized = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: CommandId.make("bare-create-command"), + projectId: ProjectId.make("bare-create-project"), + title: "Bare layout fixture", + workspaceRoot: root, + createdAt: "2026-09-05T04:00:00.000Z", + }).pipe( + Effect.provide( + Layer.mergeAll(WorkspacePaths.layer, ServerConfig.layer(config)).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Effect.result, + ); + const snapshot = yield* readPersistedSnapshot(baseDir); + const projects = snapshot.projects.filter((project) => project.workspaceRoot === root); + if (layout === "bare" || layout === "bare-index") { + assert.strictEqual(cliResult._tag, "Failure"); + assert.strictEqual(normalized._tag, "Failure"); + assert.lengthOf(projects, 0); + } else { + assert.strictEqual(cliResult._tag, "Success"); + assert.strictEqual(normalized._tag, "Success"); + assert.lengthOf(projects, 1); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + const makeProjectLookupFixture = Effect.fn("makeProjectLookupFixture")(function* ( withThread: boolean, removeWorkspace: boolean, diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index bdd9bbfebc34..d6311b668de9 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -1,4 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,9 +9,11 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); @@ -34,6 +38,27 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const runGit = Effect.fn("WorkspacePaths.test.runGit")(function* ( + cwd: string, + args: ReadonlyArray, +) { + const vcsProcess = yield* VcsProcess.VcsProcess; + return yield* vcsProcess.run({ + operation: "WorkspacePaths.test.git", + command: "git", + cwd, + args, + timeoutMs: 5_000, + }); +}); + +const makeBareRoot = Effect.fn("WorkspacePaths.test.makeBareRoot")(function* () { + const root = yield* makeTempDir(); + yield* runGit(root, ["init", "--bare", "--initial-branch=main", ".bare"]); + yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + return root; +}); + it.layer(TestLayer)("WorkspacePathsLive", (it) => { describe("normalizeWorkspaceRoot", () => { it.effect("resolves an existing directory", () => @@ -50,32 +75,21 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("rejects a bare repository root that only holds worktrees", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* makeTempDir(); - yield* fileSystem - .makeDirectory(path.join(root, ".bare", "worktrees", "develop"), { recursive: true }) - .pipe(Effect.orDie); - yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + const root = yield* makeBareRoot(); + yield* runGit(root, ["worktree", "add", "--orphan", "-b", "develop", "develop"]); const error = yield* workspacePaths .normalizeWorkspaceRoot(root) .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout), Effect.flip); - expect(error.message).toContain("holds a bare repository and its worktrees"); + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootBareRepositoryLayoutError); }), ); it.effect("accepts a bare repository root when the caller does not check the layout", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* makeTempDir(); - yield* fileSystem - .makeDirectory(path.join(root, ".bare", "worktrees", "develop"), { recursive: true }) - .pipe(Effect.orDie); - yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + const root = yield* makeBareRoot(); const resolved = yield* workspacePaths.normalizeWorkspaceRoot(root); @@ -86,19 +100,10 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("accepts a worktree of a bare repository root", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* makeTempDir(); - yield* fileSystem - .makeDirectory(path.join(root, ".bare", "worktrees", "develop"), { recursive: true }) - .pipe(Effect.orDie); - yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + const root = yield* makeBareRoot(); + yield* runGit(root, ["worktree", "add", "--orphan", "-b", "develop", "develop"]); const worktree = path.join(root, "develop"); - yield* writeTextFile( - worktree, - ".git", - `gitdir: ${path.join(root, ".bare", "worktrees", "develop")}\n`, - ); const resolved = yield* workspacePaths .normalizeWorkspaceRoot(worktree) @@ -111,19 +116,12 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("accepts an ordinary repository and a linked worktree", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const parent = yield* makeTempDir(); const ordinary = path.join(parent, "ordinary"); - yield* fileSystem - .makeDirectory(path.join(ordinary, ".git", "worktrees", "feature"), { recursive: true }) - .pipe(Effect.orDie); + yield* runGit(parent, ["init", "--initial-branch=main", ordinary]); const linked = path.join(parent, "feature"); - yield* writeTextFile( - linked, - ".git", - `gitdir: ${path.join(ordinary, ".git", "worktrees", "feature")}\n`, - ); + yield* runGit(ordinary, ["worktree", "add", "--orphan", "-b", "feature", linked]); const resolvedOrdinary = yield* workspacePaths .normalizeWorkspaceRoot(ordinary) @@ -137,35 +135,25 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); - it.effect("accepts a bare repository root before any worktree is added", () => + it.effect("rejects a bare repository root before any worktree is added", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* makeTempDir(); - yield* fileSystem - .makeDirectory(path.join(root, ".bare", "objects"), { recursive: true }) - .pipe(Effect.orDie); - yield* writeTextFile(root, ".git", "gitdir: ./.bare\n"); + const root = yield* makeBareRoot(); - const resolved = yield* workspacePaths + const error = yield* workspacePaths .normalizeWorkspaceRoot(root) - .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout)); + .pipe(Effect.flatMap(workspacePaths.ensureNotBareRepositoryLayout), Effect.flip); - expect(resolved).toBe(root); + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootBareRepositoryLayoutError); }), ); it.effect("accepts a freshly initialized separate git directory inside the root", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* makeTempDir(); - yield* fileSystem - .makeDirectory(path.join(root, "store", "objects"), { recursive: true }) - .pipe(Effect.orDie); - yield* writeTextFile(root, ".git", `gitdir: ${path.join(root, "store")}\n`); + yield* runGit(root, ["init", "--separate-git-dir", path.join(root, "store")]); const resolved = yield* workspacePaths .normalizeWorkspaceRoot(root) @@ -178,14 +166,12 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("accepts a separate git directory inside a root that stages work", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* makeTempDir(); - yield* fileSystem - .makeDirectory(path.join(root, "store", "worktrees", "feature"), { recursive: true }) - .pipe(Effect.orDie); - yield* writeTextFile(root, "store/index", ""); - yield* writeTextFile(root, ".git", `gitdir: ${path.join(root, "store")}\n`); + yield* runGit(root, ["init", "--separate-git-dir", path.join(root, "store")]); + yield* runGit(root, ["worktree", "add", "--orphan", "-b", "feature", "feature"]); + yield* writeTextFile(root, "README.md", "staged checkout\n"); + yield* runGit(root, ["add", "README.md"]); const resolved = yield* workspacePaths .normalizeWorkspaceRoot(root) @@ -210,6 +196,131 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("accepts an unborn separate-git-dir checkout with an orphan linked worktree", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* runGit(root, ["init", "--separate-git-dir", path.join(root, "store")]); + yield* runGit(root, ["worktree", "add", "--orphan", "-b", "feature", "feature"]); + expect(yield* fileSystem.exists(path.join(root, "store", "index"))).toBe(false); + expect((yield* runGit(root, ["rev-parse", "--is-inside-work-tree"])).stdout.trim()).toBe( + "true", + ); + + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + }), + ); + + it.effect("rejects a still-bare root even when Git has created an index", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeBareRoot(); + yield* runGit(root, ["worktree", "add", "--orphan", "-b", "feature", "feature"]); + yield* runGit(root, ["--git-dir", path.join(root, ".bare"), "read-tree", "--empty"]); + expect(yield* fileSystem.exists(path.join(root, ".bare", "index"))).toBe(true); + expect((yield* runGit(root, ["rev-parse", "--is-bare-repository"])).stdout.trim()).toBe( + "true", + ); + + const error = yield* workspacePaths.ensureNotBareRepositoryLayout(root).pipe(Effect.flip); + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootBareRepositoryLayoutError); + }), + ); + + it.effect("leaves a root already converted to a nonbare repository accepted", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const root = yield* makeBareRoot(); + yield* runGit(root, ["worktree", "add", "--orphan", "-b", "feature", "feature"]); + yield* runGit(root, ["init"]); + expect((yield* runGit(root, ["rev-parse", "--is-bare-repository"])).stdout.trim()).toBe( + "false", + ); + + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + }), + ); + + it.effect("accepts an unresolved git directory when the Git query exits nonzero", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const root = yield* makeTempDir(); + yield* writeTextFile(root, ".git", "gitdir: ./missing-git-directory\n"); + + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + }), + ); + + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "preserves literal backslashes in POSIX gitdir names", + () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + const gitDir = path.join(root, "store\\name"); + yield* runGit(root, ["init", "--separate-git-dir", gitDir]); + yield* runGit(root, ["init", "--bare", path.join(root, "store", "name")]); + expect((yield* runGit(root, ["rev-parse", "--is-inside-work-tree"])).stdout.trim()).toBe( + "true", + ); + + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + }), + ); + + it.effect("accepts the root when Git cannot be started", () => + Effect.gen(function* () { + const root = yield* makeBareRoot(); + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("Git is unavailable in this fixture"), + }), + ), + }), + ); + + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + }), + ); + + it.effect("does not run Git for ordinary roots or pointers outside or equal to the root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* makeTempDir(); + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: () => Effect.die("The fast path must not start Git"), + }), + ); + for (const [name, pointer] of [ + ["plain", undefined], + ["outside", "gitdir: ../shared.git\n"], + ["self", "gitdir: .\n"], + ["malformed", "not a gitdir pointer\n"], + ] as const) { + const root = path.join(parent, name); + yield* fileSystem.makeDirectory(root); + if (pointer !== undefined) yield* writeTextFile(root, ".git", pointer); + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + } + const ordinary = path.join(parent, "ordinary"); + yield* runGit(parent, ["init", ordinary]); + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(ordinary)).toBe(ordinary); + }), + ); + it.effect("rejects missing directories", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 66461a2012e9..b1f3977aca1d 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; import { parseGitDirPointer } from "@t3tools/shared/git"; import { expandHomePathWith } from "../pathExpansion.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( "WorkspaceRootNotExistsError", @@ -88,7 +89,7 @@ export class WorkspaceRootBareRepositoryLayoutError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return `'${this.normalizedWorkspaceRoot}' holds a bare repository and its worktrees rather than a working tree of its own. Add one of the worktree directories inside it instead.`; + return `'${this.normalizedWorkspaceRoot}' points at a bare Git repository instead of a working tree. Add an individual worktree directory instead.`; } } @@ -118,8 +119,8 @@ export class WorkspacePaths extends Context.Service< | WorkspaceRootNotDirectoryError >; /** - * Reject a normalized root that holds a bare repository and its worktrees - * instead of a working tree of its own. Only the paths that add a project + * Reject a normalized root whose in-root gitdir is still a bare repository. + * Only the paths that add a project * call this; every other caller keeps working with such a root. */ readonly ensureNotBareRepositoryLayout: ( @@ -147,6 +148,7 @@ function toPosixRelativePath(input: string): string { export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const vcsProcess = yield* VcsProcess.VcsProcess; const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* ( workspaceRoot: string, @@ -171,22 +173,9 @@ export const make = Effect.gen(function* () { ); }); - // A worktree-only layout keeps a bare repository inside the root (commonly - // `/.bare`) with every branch checked out as a sibling directory, so the - // root holds no working tree of its own. `git rev-parse --is-inside-work-tree` - // still answers true there, which would make the root the parent of every - // worktree: status reports each worktree as untracked and a thread's cwd spans - // all of them. - // - // Read from the `.git` file rather than by running git, because a directory - // `.git` is an ordinary repository and a `.git` file pointing outside the root - // is a linked worktree or a submodule. What remains — an in-root gitdir — is - // shared with `git init --separate-git-dir`, and git records nothing that - // separates the two (it writes no `core.worktree` for either). Two structural - // signals together do: the gitdir hosts linked worktrees, and it never staged - // anything of its own. A working tree that has committed has an index; a bare - // repository never does. Anything ambiguous is accepted, since wrongly - // refusing a valid root is worse than the misscoping this prevents. + // An in-root gitdir can also belong to a valid separate-git-dir checkout. + // Trust Git's current bare status, not index presence or historical intent: + // a previously bare root converted to a working tree remains accepted. const isBareRepositoryLayout = Effect.fn("WorkspacePaths.isBareRepositoryLayout")(function* ( normalizedWorkspaceRoot: string, ) { @@ -209,13 +198,20 @@ export const make = Effect.gen(function* () { return false; } - const worktrees = yield* fileSystem - .readDirectory(path.join(gitDir, "worktrees")) - .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - if (worktrees.length === 0) return false; - - const indexStat = yield* fileSystem.stat(path.join(gitDir, "index")).pipe(Effect.option); - return indexStat._tag === "None"; + return yield* vcsProcess + .run({ + operation: "WorkspacePaths.isBareRepositoryLayout", + command: "git", + args: ["--git-dir", gitDir, "rev-parse", "--is-bare-repository"], + cwd: normalizedWorkspaceRoot, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }) + .pipe( + Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), + Effect.orElseSucceed(() => false), + ); }); const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( @@ -308,4 +304,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(WorkspacePaths, make); +export const layer = Layer.effect(WorkspacePaths, make).pipe(Layer.provide(VcsProcess.layer)); diff --git a/packages/shared/src/devHome.test.ts b/packages/shared/src/devHome.test.ts index 812c34e3ccee..fb2ea81a04ea 100644 --- a/packages/shared/src/devHome.test.ts +++ b/packages/shared/src/devHome.test.ts @@ -51,6 +51,17 @@ describe("resolveGitWorktreePath", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("recognizes backslash-separated linked-worktree pointers", () => + Effect.gen(function* () { + const { root, nested } = yield* makeRepo("worktree"); + NodeFS.writeFileSync( + NodePath.join(root, ".git"), + "gitdir: C:\\projects\\repo\\.git\\worktrees\\feature\r\n", + ); + assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("reports a main checkout as not a linked worktree", () => Effect.gen(function* () { const { nested } = yield* makeRepo("checkout"); diff --git a/packages/shared/src/devHome.ts b/packages/shared/src/devHome.ts index cca3dfd9ee6a..80ed9e0fefff 100644 --- a/packages/shared/src/devHome.ts +++ b/packages/shared/src/devHome.ts @@ -34,7 +34,7 @@ const pointsAtLinkedWorktree = (gitFileContents: string, path: Path.Path): boole // Compare as path segments so a directory merely named `…worktrees…` cannot // match as a substring. Trailing separators normalize away first. const segments = path - .normalize(gitdir) + .normalize(gitdir.replaceAll("\\", "/")) .split(/[/\\]/) .filter((segment) => segment.length > 0); // `/worktrees/`: `worktrees` is the penultimate segment, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index e7dc682ad233..7781da344b28 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -11,9 +11,8 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; /** - * The path a `.git` *file* points at, or undefined when the contents are not a - * gitdir pointer. Backslashes normalize to `/` so a Windows-authored pointer - * parses on POSIX. + * The raw path a `.git` file points at, or undefined when it is not a gitdir + * pointer. Callers apply the path semantics appropriate to their use. */ export const parseGitDirPointer = (gitFileContents: string): string | undefined => { const gitdir = gitFileContents @@ -22,7 +21,7 @@ export const parseGitDirPointer = (gitFileContents: string): string | undefined .find((line) => line.startsWith("gitdir:")) ?.slice("gitdir:".length) .trim(); - return gitdir === undefined || gitdir.length === 0 ? undefined : gitdir.replaceAll("\\", "/"); + return gitdir === undefined || gitdir.length === 0 ? undefined : gitdir; }; export const WORKTREE_BRANCH_PREFIX = "t3code"; From e80cb32cc6b3b90ebaab473d08eb9309ece707ef Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:27:31 -0700 Subject: [PATCH 3/3] fix(server): let Git resolve bare-root gitfiles --- .../src/workspace/WorkspacePaths.test.ts | 73 +++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 4 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index d6311b668de9..ad9deb6c355c 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -255,6 +255,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("lets Git reject malformed gitfiles instead of probing an unrelated target", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const vcsProcess = yield* VcsProcess.VcsProcess; + const root = yield* makeBareRoot(); + for (const pointer of ["metadata\ngitdir: .bare\n", "gitdir:.bare\n", " gitdir: .bare\n"]) { + yield* writeTextFile(root, ".git", pointer); + const native = yield* vcsProcess.run({ + operation: "WorkspacePaths.test.malformedGitfile", + command: "git", + cwd: root, + args: ["rev-parse", "--is-bare-repository"], + allowNonZeroExit: true, + }); + expect(native.exitCode).not.toBe(0); + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + } + }), + ); + + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "preserves trailing spaces in POSIX gitdir names", + () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const root = yield* makeTempDir(); + yield* runGit(root, ["init", "--separate-git-dir", path.join(root, "store ")]); + yield* runGit(root, ["init", "--bare", path.join(root, "store")]); + expect((yield* runGit(root, ["rev-parse", "--is-inside-work-tree"])).stdout.trim()).toBe( + "true", + ); + + expect(yield* workspacePaths.ensureNotBareRepositoryLayout(root)).toBe(root); + }), + ); + + it.effect( + "classifies the root independently of inherited Git worktree and gitdir overrides", + () => + Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const path = yield* Path.Path; + const root = yield* makeBareRoot(); + const otherRoot = yield* makeTempDir(); + yield* runGit(otherRoot, ["init"]); + for (const inheritedEnv of [ + { GIT_WORK_TREE: root }, + { GIT_DIR: path.join(otherRoot, ".git") }, + ]) { + const native = yield* vcsProcess.run({ + operation: "WorkspacePaths.test.inheritedGitEnvironment", + command: "git", + cwd: root, + args: ["rev-parse", "--is-bare-repository"], + env: inheritedEnv, + }); + expect(native.stdout.trim()).toBe("false"); + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + vcsProcess.run({ ...input, env: { ...inheritedEnv, ...input.env } }), + }), + ); + + const error = yield* workspacePaths + .ensureNotBareRepositoryLayout(root) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootBareRepositoryLayoutError); + } + }), + ); + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves literal backslashes in POSIX gitdir names", () => diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index b1f3977aca1d..a446fbf65978 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -202,8 +202,10 @@ export const make = Effect.gen(function* () { .run({ operation: "WorkspacePaths.isBareRepositoryLayout", command: "git", - args: ["--git-dir", gitDir, "rev-parse", "--is-bare-repository"], + // Let Git read its own gitfile syntax; the parsed target is only a fast-path filter. + args: ["--git-dir", gitPath, "rev-parse", "--is-bare-repository"], cwd: normalizedWorkspaceRoot, + env: { GIT_WORK_TREE: undefined }, allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 4_096,