-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix(session): resolve workspaces without git #716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
090f28b
e965ca9
2667b20
0106a64
2dba26a
39c6c2a
fe4f2a4
4718eda
8023616
8e6a998
6f45241
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,151 @@ | ||
| 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 = ""; | ||
| for (const rawLine of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Git config permits a value to continue across a backslash-newline. For example, a valid Useful? React with 👍 / 👎. |
||
| const line = rawLine.trim(); | ||
| if (!line || line.startsWith("#") || line.startsWith(";")) { | ||
| continue; | ||
| } | ||
| const sectionMatch = /^\[([^\]]+)\]$/.exec(line); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a valid config uses Useful? React with 👍 / 👎. |
||
| 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]); | ||
| return value ? path.resolve(gitDirectory, value) : null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a valid config contains more than one Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| 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 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); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.gitdirectories before accepting them as repositoriesWhen a non-repository child inside a real checkout contains an empty (or otherwise invalid)
.gitdirectory, this branch immediately accepts it. Git ignores such a directory and continues discovery to the enclosing repository, so a task invoked below the child now receives a separate job/state namespace andrunAppServerTurnis confined to that child; validate directory markers as actual Git directories before returning them.Useful? React with 👍 / 👎.