diff --git a/plugins/codex/scripts/lib/workspace.mjs b/plugins/codex/scripts/lib/workspace.mjs index 89a0060b8..d8f34cd30 100644 --- a/plugins/codex/scripts/lib/workspace.mjs +++ b/plugins/codex/scripts/lib/workspace.mjs @@ -1,8 +1,153 @@ -import { ensureGitRepository } from "./git.mjs"; +import fs from "node:fs"; +import path from "node:path"; -export function resolveWorkspaceRoot(cwd) { +function resolveGitDirectory(candidatePath) { + const candidateStats = fs.statSync(candidatePath); + if (candidateStats.isDirectory()) { + return fs.realpathSync.native(candidatePath); + } + if (!candidateStats.isFile()) { + return null; + } + + const match = /^gitdir: ([^\r\n]+)\r?\n?$/.exec(fs.readFileSync(candidatePath, "utf8")); + if (!match) { + return null; + } + const target = path.resolve(path.dirname(candidatePath), match[1]); + return fs.statSync(target).isDirectory() ? fs.realpathSync.native(target) : null; +} + +function stripConfigComment(value) { + let quote = null; + let escaping = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (escaping) { + escaping = false; + continue; + } + if (character === "\\") { + escaping = true; + continue; + } + if (quote) { + if (character === quote) quote = null; + continue; + } + if (character === "\"" || character === "'") { + quote = character; + continue; + } + if (character === "#" || character === ";") { + return value.slice(0, index).trimEnd(); + } + } + return value; +} + +function parseConfigValue(value) { + const uncommented = stripConfigComment(value).trim(); + const quoted = /^(?:"((?:\\.|[^"\\])*)"|'([^']*)')$/.exec(uncommented); + if (!quoted) return uncommented; + return (quoted[1] ?? quoted[2]).replace(/\\([\\"])/g, "$1"); +} + +function readConfiguredWorkTree(gitDirectory) { + const configPath = path.join(gitDirectory, "config"); + let section = ""; + let workTree = null; + for (const rawLine of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) { + continue; + } + const sectionLine = stripConfigComment(line).trim(); + const sectionMatch = /^\[([^\]]+)\]$/.exec(sectionLine); + if (sectionMatch) { + section = sectionMatch[1].trim().toLowerCase(); + continue; + } + if (section !== "core") { + continue; + } + const valueMatch = /^worktree\s*=\s*(.+)$/i.exec(line); + if (valueMatch) { + const value = parseConfigValue(valueMatch[1]); + workTree = value ? path.resolve(gitDirectory, value) : null; + } + } + return workTree; +} + +function configuredWorkTree(gitDirectory) { try { - return ensureGitRepository(cwd); + const workTree = readConfiguredWorkTree(gitDirectory); + return workTree && fs.statSync(workTree).isDirectory() ? fs.realpathSync.native(workTree) : null; + } catch { + return null; + } +} + +function findMarkerWorkspace(canonicalCwd) { + const cwdStats = fs.statSync(canonicalCwd); + let current = cwdStats.isFile() ? path.dirname(canonicalCwd) : canonicalCwd; + + while (true) { + try { + const gitDirectory = resolveGitDirectory(path.join(current, ".git")); + if (gitDirectory) { + return { workspace: fs.realpathSync.native(current), gitDirectory }; + } + } catch (error) { + if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") { + throw error; + } + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +export function resolveWorkspaceRoot(cwd, env = process.env) { + try { + const canonicalCwd = fs.realpathSync.native(cwd); + const invocationDirectory = fs.statSync(canonicalCwd).isFile() ? path.dirname(canonicalCwd) : canonicalCwd; + const marker = findMarkerWorkspace(canonicalCwd); + + if (env?.GIT_DIR) { + try { + const gitDirectory = resolveGitDirectory(path.resolve(cwd, env.GIT_DIR)); + if (gitDirectory) { + const workTree = env.GIT_WORK_TREE + ? path.resolve(cwd, env.GIT_WORK_TREE) + : configuredWorkTree(gitDirectory) ?? invocationDirectory; + if (fs.statSync(workTree).isDirectory()) { + return fs.realpathSync.native(workTree); + } + } + } catch { + // Invalid Git environment overrides do not suppress normal marker discovery. + } + } else if (env?.GIT_WORK_TREE && marker) { + try { + const workTree = path.resolve(cwd, env.GIT_WORK_TREE); + if (fs.statSync(workTree).isDirectory()) { + return fs.realpathSync.native(workTree); + } + } catch { + // Invalid Git environment overrides do not suppress normal marker discovery. + } + } + + if (marker) { + return configuredWorkTree(marker.gitDirectory) ?? marker.workspace; + } + return cwd; } catch { return cwd; } diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs new file mode 100644 index 000000000..594f6bd68 --- /dev/null +++ b/tests/workspace.test.mjs @@ -0,0 +1,264 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { makeTempDir } from "./helpers.mjs"; +import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; +import { resolveWorkspaceRoot } from "../plugins/codex/scripts/lib/workspace.mjs"; + +function makeNestedWorkspace(gitMarker) { + const workspace = makeTempDir(); + const nested = path.join(workspace, "packages", "app"); + fs.mkdirSync(nested, { recursive: true }); + if (gitMarker === "directory") { + fs.mkdirSync(path.join(workspace, ".git")); + } else { + const gitDirectory = path.join(workspace, "metadata.git"); + fs.mkdirSync(gitDirectory); + fs.writeFileSync(path.join(workspace, ".git"), "gitdir: metadata.git\n", "utf8"); + } + return { workspace: fs.realpathSync.native(workspace), nested }; +} + +test("resolveWorkspaceRoot honors an environment-configured work tree without a .git marker", () => { + const worktree = makeTempDir(); + const gitDirectory = makeTempDir(); + const nested = path.join(worktree, "packages", "app"); + fs.mkdirSync(nested, { recursive: true }); + + assert.equal( + resolveWorkspaceRoot(nested, { GIT_DIR: gitDirectory, GIT_WORK_TREE: "../.." }), + fs.realpathSync.native(worktree) + ); +}); + +test("resolveWorkspaceRoot honors GIT_WORK_TREE alone after finding the current repository", () => { + const outer = makeNestedWorkspace("directory"); + const configuredWorktree = makeTempDir(); + assert.equal( + resolveWorkspaceRoot(outer.nested, { GIT_WORK_TREE: configuredWorktree }), + fs.realpathSync.native(configuredWorktree) + ); +}); + +test("resolveWorkspaceRoot ignores an environment work tree with a missing GIT_DIR", () => { + const outer = makeNestedWorkspace("directory"); + assert.equal( + resolveWorkspaceRoot(outer.nested, { GIT_DIR: "missing.git", GIT_WORK_TREE: "../.." }), + outer.workspace + ); +}); + +test("resolveWorkspaceRoot uses invocation directory for GIT_DIR alone", () => { + const outer = makeNestedWorkspace("directory"); + const gitDirectory = makeTempDir(); + assert.equal(resolveWorkspaceRoot(outer.nested, { GIT_DIR: gitDirectory }), fs.realpathSync.native(outer.nested)); +}); + +test("resolveWorkspaceRoot honors core.worktree from GIT_DIR config", () => { + const cwd = makeTempDir(); + const gitDirectory = makeTempDir(); + const worktree = makeTempDir(); + fs.writeFileSync(path.join(gitDirectory, "config"), `[core]\n worktree = ${worktree}\n`, "utf8"); + assert.equal(resolveWorkspaceRoot(cwd, { GIT_DIR: gitDirectory }), fs.realpathSync.native(worktree)); +}); + +test("resolveWorkspaceRoot strips inline comments outside quoted core.worktree values", () => { + const root = makeTempDir(); + const cwd = path.join(root, "metadata", "nested"); + const gitDirectory = path.join(root, "metadata", ".git"); + const worktree = path.join(root, "work tree"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(gitDirectory); + fs.mkdirSync(worktree); + fs.writeFileSync( + path.join(gitDirectory, "config"), + `[core]\n worktree = "../../work tree" # external tree\n`, + "utf8" + ); + assert.equal(resolveWorkspaceRoot(cwd, { GIT_DIR: gitDirectory }), fs.realpathSync.native(worktree)); +}); + +test("resolveWorkspaceRoot preserves comment characters inside quoted core.worktree values", () => { + const root = makeTempDir(); + const cwd = path.join(root, "metadata"); + const gitDirectory = path.join(cwd, ".git"); + const worktree = path.join(root, "work#tree"); + fs.mkdirSync(gitDirectory, { recursive: true }); + fs.mkdirSync(worktree); + fs.writeFileSync(path.join(gitDirectory, "config"), `[core]\n worktree = "../../work#tree"\n`, "utf8"); + assert.equal(resolveWorkspaceRoot(cwd, { GIT_DIR: gitDirectory }), fs.realpathSync.native(worktree)); +}); + +test("resolveWorkspaceRoot uses the final core.worktree value", () => { + const root = makeTempDir(); + const cwd = path.join(root, "metadata"); + const gitDirectory = path.join(cwd, ".git"); + const staleWorktree = path.join(root, "stale"); + const finalWorktree = path.join(root, "final"); + fs.mkdirSync(gitDirectory, { recursive: true }); + fs.mkdirSync(staleWorktree); + fs.mkdirSync(finalWorktree); + fs.writeFileSync( + path.join(gitDirectory, "config"), + `[core]\n worktree = ${staleWorktree}\n worktree = ${finalWorktree}\n`, + "utf8" + ); + assert.equal(resolveWorkspaceRoot(cwd, { GIT_DIR: gitDirectory }), fs.realpathSync.native(finalWorktree)); +}); + +for (const comment of ["# core options", "; core options"]) { + test(`resolveWorkspaceRoot accepts a comment after the core section header: ${comment}`, () => { + const root = makeTempDir(); + const cwd = path.join(root, "metadata"); + const gitDirectory = path.join(cwd, ".git"); + const worktree = path.join(root, "external"); + fs.mkdirSync(gitDirectory, { recursive: true }); + fs.mkdirSync(worktree); + fs.writeFileSync( + path.join(gitDirectory, "config"), + `[user]\n name = Example\n[core] ${comment}\n worktree = ${worktree}\n`, + "utf8" + ); + assert.equal(resolveWorkspaceRoot(cwd, { GIT_DIR: gitDirectory }), fs.realpathSync.native(worktree)); + }); +} + +test("resolveWorkspaceRoot honors core.worktree from a marker git directory", () => { + const metadata = makeTempDir(); + const gitDirectory = path.join(metadata, ".git"); + const nested = path.join(metadata, "sub"); + const worktree = makeTempDir(); + fs.mkdirSync(gitDirectory); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(gitDirectory, "config"), `[core]\n worktree = ${worktree}\n`, "utf8"); + assert.equal(resolveWorkspaceRoot(nested, {}), fs.realpathSync.native(worktree)); +}); + +test("resolveWorkspaceRoot follows a gitfile supplied through GIT_DIR", () => { + const cwd = makeTempDir(); + const metadata = makeTempDir(); + const gitDirectory = path.join(metadata, "actual.git"); + const gitfile = path.join(metadata, "linked.git"); + const worktree = makeTempDir(); + fs.mkdirSync(gitDirectory); + fs.writeFileSync(gitfile, "gitdir: actual.git\n", "utf8"); + + assert.equal( + resolveWorkspaceRoot(cwd, { GIT_DIR: gitfile, GIT_WORK_TREE: worktree }), + fs.realpathSync.native(worktree) + ); +}); + +test("resolveWorkspaceRoot ignores GIT_WORK_TREE alone outside a repository", () => { + const cwd = makeTempDir(); + const configuredWorktree = makeTempDir(); + assert.equal(resolveWorkspaceRoot(cwd, { GIT_WORK_TREE: configuredWorktree }), cwd); +}); + +test("resolveWorkspaceRoot discovers a checkout without starting a child process", () => { + const { workspace, nested } = makeNestedWorkspace("directory"); + const originalSpawnSync = childProcess.spawnSync; + childProcess.spawnSync = () => { + throw new Error("workspace resolution must not start a child process"); + }; + syncBuiltinESMExports(); + + try { + assert.equal(resolveWorkspaceRoot(nested), workspace); + } finally { + childProcess.spawnSync = originalSpawnSync; + syncBuiltinESMExports(); + } +}); + +test("resolveWorkspaceRoot accepts a linked-worktree gitfile", () => { + const { workspace, nested } = makeNestedWorkspace("file"); + + assert.equal(resolveWorkspaceRoot(nested), workspace); +}); + +test("resolveWorkspaceRoot ignores an ordinary file named .git", () => { + const outer = makeNestedWorkspace("directory"); + const nestedWorkspace = path.join(outer.workspace, "vendor", "not-a-repo"); + const cwd = path.join(nestedWorkspace, "src"); + fs.mkdirSync(cwd, { recursive: true }); + fs.writeFileSync(path.join(nestedWorkspace, ".git"), "not a gitfile\n", "utf8"); + + assert.equal(resolveWorkspaceRoot(cwd), outer.workspace); +}); + +for (const invalidMarker of ["gitdir:/tmp/metadata\n", "gitdir:\t/tmp/metadata\n", "metadata\ngitdir: /tmp/metadata\n", "gitdir: missing.git\n", "gitdir: metadata.git\ntrailing-data"]) { + test(`resolveWorkspaceRoot rejects malformed gitfile ${JSON.stringify(invalidMarker)}`, () => { + const outer = makeNestedWorkspace("directory"); + const nestedWorkspace = path.join(outer.workspace, "vendor", "not-a-repo"); + const cwd = path.join(nestedWorkspace, "src"); + fs.mkdirSync(cwd, { recursive: true }); + fs.writeFileSync(path.join(nestedWorkspace, ".git"), invalidMarker, "utf8"); + + assert.equal(resolveWorkspaceRoot(cwd), outer.workspace); + }); +} + +test("resolveWorkspaceRoot follows a symlinked git directory", () => { + const workspace = makeTempDir(); + const gitDirectory = makeTempDir(); + const nested = path.join(workspace, "packages", "app"); + fs.mkdirSync(nested, { recursive: true }); + fs.symlinkSync(gitDirectory, path.join(workspace, ".git"), process.platform === "win32" ? "junction" : "dir"); + + assert.equal(resolveWorkspaceRoot(nested), fs.realpathSync.native(workspace)); +}); + +test("resolveWorkspaceRoot selects the nearest nested working tree", () => { + const { workspace } = makeNestedWorkspace("directory"); + const nestedWorkspace = path.join(workspace, "vendor", "nested"); + const cwd = path.join(nestedWorkspace, "src"); + fs.mkdirSync(path.join(nestedWorkspace, ".git"), { recursive: true }); + fs.mkdirSync(cwd); + + assert.equal(resolveWorkspaceRoot(cwd), fs.realpathSync.native(nestedWorkspace)); +}); + +test("resolveWorkspaceRoot starts at a file cwd's parent", () => { + const { workspace, nested } = makeNestedWorkspace("directory"); + const file = path.join(nested, "index.js"); + fs.writeFileSync(file, "export {};\n", "utf8"); + + assert.equal(resolveWorkspaceRoot(file), workspace); +}); + +test("workspace aliases resolve to the same canonical root and state identity", (t) => { + const { workspace } = makeNestedWorkspace("directory"); + const nested = path.join(workspace, "packages", "app"); + const alias = `${workspace}-alias`; + try { + fs.symlinkSync(workspace, alias, process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (process.platform === "win32" && ["EPERM", "EACCES"].includes(error?.code)) { + t.skip(`junction creation unavailable: ${error.code}`); + return; + } + throw error; + } + const aliasedNested = path.join(alias, "packages", "app"); + + assert.equal(resolveWorkspaceRoot(aliasedNested), workspace); + assert.equal(resolveWorkspaceRoot(aliasedNested), resolveWorkspaceRoot(nested)); + assert.equal(resolveStateDir(aliasedNested), resolveStateDir(nested)); +}); + +test("resolveWorkspaceRoot preserves a non-repository cwd", () => { + const cwd = makeTempDir(); + + assert.equal(resolveWorkspaceRoot(cwd), cwd); +}); + +test("resolveWorkspaceRoot preserves an inaccessible or missing cwd", () => { + const cwd = path.join(makeTempDir(), "missing", "directory"); + + assert.equal(resolveWorkspaceRoot(cwd), cwd); +});