From 1a006d09051ddd556d2404771f4c9e3d281c8fb0 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 00:26:08 +0900 Subject: [PATCH 01/19] Add copy-on-write syncDirs bootstrap --- README.md | 28 ++- src/cli.ts | 4 +- src/completion.test.ts | 2 +- src/config.test.ts | 73 +++++++ src/config.ts | 60 +++++- src/dir-clone.test.ts | 145 ++++++++++++++ src/dir-clone.ts | 239 +++++++++++++++++++++++ src/install-prompt.ts | 3 +- src/new.test.ts | 275 ++++++++++++++++++++++++++- src/new.ts | 337 +++++++++++++++++++++++++++++---- src/pr.test.ts | 51 +++++ src/pr.ts | 103 ++++++---- src/shell-completion.ts | 3 +- website/docs/commands.mdx | 4 +- website/docs/configuration.mdx | 20 +- website/docs/installation.mdx | 10 + 16 files changed, 1264 insertions(+), 93 deletions(-) create mode 100644 src/dir-clone.test.ts create mode 100644 src/dir-clone.ts diff --git a/README.md b/README.md index ada922c..e687474 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ No setup required. Optional config lives in: | `syncRemote` | remote for `gji sync` (default: `origin`) | | `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) | | `syncFiles` | files to copy from main worktree into each new worktree; use global per-repo config for private files | +| `syncDirs` | directories to clone with filesystem copy-on-write before sync files, installs, and hooks | | `skipInstallPrompt` | `true` to disable the auto-install prompt permanently | | `installSaveTarget` | `"local"` or `"global"` — where **Always**/**Never** choices are persisted (default: `"local"`); set during `gji init --write` | | `hooks` | lifecycle scripts (see [Hooks](#hooks)) | @@ -298,7 +299,8 @@ No setup required. Optional config lives in: "branchPrefix": "feature/", "syncRemote": "upstream", "syncDefaultBranch": "main", - "syncFiles": [".env.example", ".nvmrc"] + "syncFiles": [".env.example", ".nvmrc"], + "syncDirs": ["node_modules", ".next"] } ``` @@ -326,6 +328,28 @@ This stores: } ``` +### Instant directory bootstrap + +Use `syncDirs` for large, reproducible directories that should be available immediately in each new worktree: + +```json +{ + "syncDirs": ["node_modules", ".next"] +} +``` + +`gji new` clones these directories with APFS copy-on-write on macOS or mandatory reflinks on Linux, before `syncFiles`, install prompts, and `afterCreate` hooks. It never falls back to a slow ordinary copy. Unsupported filesystems, external symlink targets, missing sources, and existing destinations are skipped safely; failed CoW attempts are cached in `~/.config/gji/state.json` so repeated worktree creation does not keep waiting on the same unsupported filesystem. + +Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. For pnpm projects, the cloned `node_modules/.modules.yaml` is removed because pnpm recreates it for the new worktree. + +The human output includes the cloned size and elapsed time: + +```text +⚡ cloned node_modules (2.1 GB → 1.2s) — run install only if lockfile changed +``` + +Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array with each directory and its clone time. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. + ### Per-repo overrides in global config If you work across many repositories, you can scope config to a specific repo inside `~/.config/gji/config.json` without adding a `.gji.json` to that repo: @@ -444,6 +468,8 @@ Run `pnpm install` in the new worktree? **Always** saves `hooks.afterCreate`; **Never** writes `skipInstallPrompt: true`. Where they are saved depends on `installSaveTarget` (see [Available keys](#available-keys)) — defaults to `.gji.json`. +When `syncDirs` successfully clones `node_modules`, the install prompt is skipped because dependencies are already present. Run the package manager manually if the lockfile changed. + ## JSON output Every mutating command supports `--json` for scripting and AI agent use. Success goes to stdout, errors go to stderr with exit code 1. diff --git a/src/cli.ts b/src/cli.ts index 23b5941..0314b7f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -208,7 +208,9 @@ async function maybeRegisterCurrentRepo(cwd: string): Promise { function registerCommands(program: Command): void { program .command("new [branch]") - .description("create a new branch or detached linked worktree") + .description( + "create a new branch or detached linked worktree and CoW-bootstrap configured directories", + ) .option( "-f, --force", "remove and recreate the worktree if the target path already exists", diff --git a/src/completion.test.ts b/src/completion.test.ts index 441a717..387ce6a 100644 --- a/src/completion.test.ts +++ b/src/completion.test.ts @@ -95,7 +95,7 @@ describe("gji completion", () => { expect(stdout.join("")).toContain('branch = ($1 == "*" ? $2 : $1)'); expect(stdout.join("")).toContain("_values 'config action' get set unset"); expect(stdout.join("")).toContain( - "_values 'config key' branchPrefix editor hooks installSaveTarget shellIntegration skipInstallPrompt syncDefaultBranch syncFiles syncRemote worktreePath repos", + "_values 'config key' branchPrefix editor hooks installSaveTarget shellIntegration skipInstallPrompt syncDirs syncDefaultBranch syncFiles syncRemote worktreePath repos", ); expect(stdout.join("")).toContain(`case "\${words[3]}" in`); expect(stdout.join("")).toContain("get|unset)"); diff --git a/src/config.test.ts b/src/config.test.ts index 6de16f3..bee8201 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -15,6 +15,8 @@ import { saveLocalConfig, updateGlobalRepoConfigKey, updateLocalConfigKey, + validateSyncDirPattern, + validateSyncDirsConfig, } from "./config.js"; const originalHome = process.env.HOME; @@ -51,6 +53,27 @@ describe("resolveConfigString", () => { }); }); +describe("syncDirs validation", () => { + it.each([ + ["an absolute path", "/tmp/node_modules", "relative path"], + ["a parent traversal", "../node_modules", "'..' segments"], + ["a nested Git path", "cache/.git/objects", ".git"], + ["a Windows absolute path", "C:\\node_modules", "relative path"], + ])("rejects %s", (_description, pattern, reason) => { + // Given a syncDirs entry that could escape or clone Git metadata. + // When the entry is validated. + // Then validation rejects it with a safety-specific message. + expect(() => validateSyncDirPattern(pattern)).toThrow(reason); + }); + + it("rejects non-array syncDirs values", () => { + // Given a scalar syncDirs configuration value. + // When the value is loaded through a local config file. + // Then configuration loading rejects the malformed shape. + expect(() => validateSyncDirsConfig("node_modules")).toThrow("array"); + }); +}); + describe("loadConfig", () => { it("returns defaults when the config file does not exist", async () => { // Given @@ -89,6 +112,56 @@ describe("loadConfig", () => { path: join(root, CONFIG_FILE_NAME), }); }); + + it("rejects unsafe syncDirs in a local config file", async () => { + // Given a local config with a path that escapes the repository. + const root = await mkdtemp(join(tmpdir(), "gji-config-")); + await writeFile( + join(root, CONFIG_FILE_NAME), + JSON.stringify({ syncDirs: ["../node_modules"] }), + "utf8", + ); + + // When the local config is loaded. + // Then loading fails before the unsafe value can be used. + await expect(loadConfig(root)).rejects.toThrow("'..' segments"); + }); + + it("validates syncDirs in both global config layers", async () => { + // Given a repository and a per-repository global config with an unsafe path. + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const repoRoot = await mkdtemp(join(tmpdir(), "gji-repo-")); + const globalConfigPath = GLOBAL_CONFIG_FILE_PATH(home); + await mkdir(dirname(globalConfigPath), { recursive: true }); + await writeFile( + globalConfigPath, + JSON.stringify({ repos: { [repoRoot]: { syncDirs: [".git"] } } }), + "utf8", + ); + + // When effective configuration is loaded. + // Then the per-repository layer is rejected with the same safety rule. + await expect(loadEffectiveConfig(repoRoot, home)).rejects.toThrow(".git"); + }); + + it("rejects unsafe syncDirs in global defaults", async () => { + // Given a global config with an absolute syncDirs path. + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const repoRoot = await mkdtemp(join(tmpdir(), "gji-repo-")); + const globalConfigPath = GLOBAL_CONFIG_FILE_PATH(home); + await mkdir(dirname(globalConfigPath), { recursive: true }); + await writeFile( + globalConfigPath, + JSON.stringify({ syncDirs: ["/tmp/node_modules"] }), + "utf8", + ); + + // When effective configuration is loaded. + // Then global validation rejects the absolute path. + await expect(loadEffectiveConfig(repoRoot, home)).rejects.toThrow( + "relative path", + ); + }); }); describe("saveLocalConfig", () => { diff --git a/src/config.ts b/src/config.ts index 3699516..f3f0bef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { + dirname, + isAbsolute, + join, + normalize, + resolve, + win32, +} from "node:path"; export const CONFIG_FILE_NAME = ".gji.json"; export const GLOBAL_CONFIG_DIRECTORY = ".config/gji"; @@ -13,6 +20,7 @@ export const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([ "installSaveTarget", "shellIntegration", "skipInstallPrompt", + "syncDirs", "syncDefaultBranch", "syncFiles", "syncRemote", @@ -56,6 +64,7 @@ export async function loadEffectiveConfig( const perRepoConfig: Record = isPlainObject(repos) ? findPerRepoConfig(repos, root, home) : {}; + validateSyncDirsConfig(perRepoConfig.syncDirs); // Strip the internal `repos` registry from the global base before merging. const globalBase: Record = { ...globalConfig.config }; @@ -248,10 +257,59 @@ export function resolveConfigString( return typeof value === "string" && value.length > 0 ? value : undefined; } +export function validateSyncDirsConfig(value: unknown): void { + if (value === undefined) return; + + if (!Array.isArray(value)) { + throw new Error("syncDirs: expected an array of relative directory paths"); + } + + for (const pattern of value) { + if (typeof pattern !== "string") { + throw new Error("syncDirs: every entry must be a string"); + } + + validateSyncDirPattern(pattern); + } +} + +export function validateSyncDirPattern(pattern: string): string { + if ( + pattern.length === 0 || + isAbsolute(pattern) || + win32.isAbsolute(pattern) + ) { + throw new Error( + `syncDirs: pattern must be a relative path, got: ${pattern}`, + ); + } + + const segments = pattern.split(/[\\/]+/u); + if (segments.includes("..")) { + throw new Error( + `syncDirs: pattern must not contain '..' segments, got: ${pattern}`, + ); + } + + if (segments.some((segment) => segment.toLowerCase() === ".git")) { + throw new Error(`syncDirs: pattern must not include .git, got: ${pattern}`); + } + + const normalized = normalize(pattern); + if (normalized === ".") { + throw new Error( + `syncDirs: pattern must name a directory below the repository root, got: ${pattern}`, + ); + } + + return normalized; +} + async function loadConfigFile(path: string): Promise { try { const rawConfig = await readFile(path, "utf8"); const parsedConfig = JSON.parse(rawConfig) as Record; + validateSyncDirsConfig(parsedConfig.syncDirs); return { config: mergeConfig(parsedConfig), diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts new file mode 100644 index 0000000..7591ff2 --- /dev/null +++ b/src/dir-clone.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + cloneDir, + cloneStrategy, + isCloneDestinationExistsError, +} from "./dir-clone.js"; + +describe("cloneStrategy", () => { + it("selects the APFS clone command on macOS", () => { + // Given the macOS platform. + // When the CoW strategy is selected. + const strategy = cloneStrategy("darwin"); + + // Then it requests recursive clonefile copies. + expect(strategy?.("source", "destination")).toEqual([ + "-Rc", + "source", + "destination", + ]); + }); + + it("selects mandatory reflinks on Linux", () => { + // Given the Linux platform. + // When the CoW strategy is selected. + const strategy = cloneStrategy("linux"); + + // Then it requires reflinks instead of allowing a normal copy. + expect(strategy?.("source", "destination")).toEqual([ + "-a", + "--reflink=always", + "source", + "destination", + ]); + }); + + it("rejects platforms without a CoW strategy", () => { + // Given an unsupported platform. + // When the CoW strategy is selected. + const strategy = cloneStrategy("win32"); + + // Then no ordinary-copy strategy is returned. + expect(strategy).toBeNull(); + }); +}); + +describe("cloneDir", () => { + it("atomically publishes a successful clone", async () => { + // Given a source directory and a fake CoW command that creates its temporary output. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await writeFile(join(source, "package.json"), "{}\n", "utf8"); + + // When cloneDir runs the injected platform command. + const result = await cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => { + const temporaryDestination = args.at(-1) as string; + await mkdir(temporaryDestination); + await writeFile( + join(temporaryDestination, "package.json"), + "{}\n", + "utf8", + ); + }, + }); + + // Then the final destination contains the clone and no temporary sibling remains. + expect(result.bytes).toBe(3); + expect(result.ms).toBeGreaterThanOrEqual(0); + await expect( + readFile(join(destination, "package.json"), "utf8"), + ).resolves.toBe("{}\n"); + expect((await readdir(root)).sort()).toEqual(["destination", "source"]); + }); + + it("removes partial output after a clone command fails", async () => { + // Given a source directory and a fake command that leaves partial temporary output. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await writeFile(join(source, "package.json"), "{}\n", "utf8"); + + // When the CoW command fails after writing one file. + await expect( + cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => { + const temporaryDestination = args.at(-1) as string; + await mkdir(temporaryDestination); + await writeFile(join(temporaryDestination, "partial"), "x", "utf8"); + throw new Error("reflink unsupported"); + }, + }), + ).rejects.toThrow("reflink unsupported"); + + // Then neither the destination nor the temporary partial clone remains. + expect((await readdir(root)).sort()).toEqual(["source"]); + }); + + it("does not invoke the copy command when the destination already exists", async () => { + // Given an existing destination and a valid source directory. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await mkdir(destination); + let commandCalled = false; + + // When cloneDir is asked to clone over the destination. + const error = await cloneDir(source, destination, { + platform: "linux", + runCommand: async () => { + commandCalled = true; + }, + }).catch((caught) => caught); + + // Then it reports the conflict without touching the existing directory. + expect(isCloneDestinationExistsError(error)).toBe(true); + expect(commandCalled).toBe(false); + }); + + it("skips unsupported filesystems without creating a destination", async () => { + // Given a source directory on an unsupported platform. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + + // When cloneDir is invoked. + await expect( + cloneDir(source, destination, { platform: "win32" }), + ).rejects.toThrow("not supported"); + + // Then no destination is created. + await expect(readdir(root)).resolves.toEqual(["source"]); + }); +}); diff --git a/src/dir-clone.ts b/src/dir-clone.ts new file mode 100644 index 0000000..89f2d69 --- /dev/null +++ b/src/dir-clone.ts @@ -0,0 +1,239 @@ +import { execFile } from "node:child_process"; +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { GLOBAL_CONFIG_DIRECTORY } from "./config.js"; + +const execFileAsync = promisify(execFile); +const STATE_FILE_NAME = "state.json"; + +interface CloneFailure { + failedAt: number; + reason: string; +} + +interface GjiState { + [key: string]: unknown; + syncDirs?: Record>; +} + +export interface CloneDirResult { + bytes: number; + ms: number; +} + +export interface CloneDirOptions { + platform?: NodeJS.Platform; + runCommand?: (command: string, args: string[]) => Promise; +} + +export type CloneDirectory = ( + source: string, + destination: string, +) => Promise; + +export async function cloneDir( + source: string, + destination: string, + options: CloneDirOptions = {}, +): Promise { + const strategy = cloneStrategy(options.platform ?? process.platform); + if (!strategy) { + throw new Error( + `copy-on-write cloning is not supported on ${options.platform ?? process.platform}`, + ); + } + + const sourcePath = await realpath(source); + const sourceStats = await lstat(sourcePath); + if (!sourceStats.isDirectory()) { + throw new Error("source is not a directory"); + } + if (await pathExists(destination)) { + throw new CloneDestinationExistsError(destination); + } + + const bytes = await directorySize(sourcePath); + const startedAt = Date.now(); + const parent = dirname(destination); + await mkdir(parent, { recursive: true }); + const temporaryRoot = await mkdtemp( + join(parent, `.${basename(destination)}.gji-clone-`), + ); + const temporaryDestination = join(temporaryRoot, basename(destination)); + + try { + const runCommand = options.runCommand ?? runCloneCommand; + await runCommand("cp", strategy(sourcePath, temporaryDestination)); + + if (await pathExists(destination)) { + throw new CloneDestinationExistsError(destination); + } + + await rename(temporaryDestination, destination); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } + + return { bytes, ms: Date.now() - startedAt }; +} + +export class CloneDestinationExistsError extends Error { + readonly code = "GJI_CLONE_DESTINATION_EXISTS"; + + constructor(destination: string) { + super(`destination already exists: ${destination}`); + this.name = "CloneDestinationExistsError"; + } +} + +export function isCloneDestinationExistsError( + error: unknown, +): error is CloneDestinationExistsError { + return error instanceof CloneDestinationExistsError; +} + +export async function isCloneFailureCached( + repoRoot: string, + directory: string, +): Promise { + const state = await readState(); + const repoState = state.syncDirs?.[repoRoot]; + return repoState?.[directory] !== undefined; +} + +export async function cacheCloneFailure( + repoRoot: string, + directory: string, + reason: string, +): Promise { + const state = await readState(); + const syncDirs = state.syncDirs ?? {}; + const repoState = syncDirs[repoRoot] ?? {}; + + syncDirs[repoRoot] = { + ...repoState, + [directory]: { failedAt: Date.now(), reason }, + }; + + await writeState({ ...state, syncDirs }); +} + +export async function clearCloneFailure( + repoRoot: string, + directory: string, +): Promise { + const state = await readState(); + const repoState = state.syncDirs?.[repoRoot]; + if (!repoState?.[directory]) return; + + const nextRepoState = { ...repoState }; + delete nextRepoState[directory]; + const syncDirs = { ...state.syncDirs }; + if (Object.keys(nextRepoState).length === 0) delete syncDirs[repoRoot]; + else syncDirs[repoRoot] = nextRepoState; + + await writeState({ ...state, syncDirs }); +} + +export async function directorySize(path: string): Promise { + const stats = await lstat(path); + if (!stats.isDirectory()) return stats.size; + + const entries = await readdir(path, { withFileTypes: true }); + let total = 0; + for (const entry of entries) { + total += await directorySize(join(path, entry.name)); + } + + return total; +} + +export function cloneStrategy( + platform: NodeJS.Platform, +): ((source: string, destination: string) => string[]) | null { + if (platform === "darwin") { + return (source, destination) => ["-Rc", source, destination]; + } + + if (platform === "linux") { + return (source, destination) => [ + "-a", + "--reflink=always", + source, + destination, + ]; + } + + return null; +} + +async function runCloneCommand(command: string, args: string[]): Promise { + await execFileAsync(command, args); +} + +async function readState(): Promise { + try { + const raw = await readFile(stateFilePath(), "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isPlainObject(parsed)) return {}; + return isPlainObject(parsed.syncDirs) + ? (parsed as GjiState) + : { ...parsed, syncDirs: {} }; + } catch { + return {}; + } +} + +async function writeState(state: GjiState): Promise { + try { + const path = stateFilePath(); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + } catch { + // The failure cache is advisory and must never block worktree creation. + } +} + +function stateFilePath(home: string = homedir()): string { + const configuredDirectory = process.env.GJI_CONFIG_DIR; + const directory = configuredDirectory + ? resolve(configuredDirectory) + : join(home, GLOBAL_CONFIG_DIRECTORY); + + return join(directory, STATE_FILE_NAME); +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} diff --git a/src/install-prompt.ts b/src/install-prompt.ts index da18f75..3c3755e 100644 --- a/src/install-prompt.ts +++ b/src/install-prompt.ts @@ -45,9 +45,10 @@ export async function maybeRunInstallPrompt( stderr: (chunk: string) => void, dependencies: InstallPromptDependencies = {}, nonInteractive = false, + skipAfterClone = false, ): Promise { // Skip in non-interactive mode — no prompt can be shown. - if (isHeadless() || nonInteractive) { + if (isHeadless() || nonInteractive || skipAfterClone) { return; } diff --git a/src/new.test.ts b/src/new.test.ts index 3e09874..50ee22a 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; @@ -22,14 +29,14 @@ import { } from "./repo.test-helpers.js"; const originalHome = process.env.HOME; +const originalConfigDir = process.env.GJI_CONFIG_DIR; afterEach(() => { if (originalHome === undefined) { delete process.env.HOME; - return; - } - - process.env.HOME = originalHome; + } else process.env.HOME = originalHome; + if (originalConfigDir === undefined) delete process.env.GJI_CONFIG_DIR; + else process.env.GJI_CONFIG_DIR = originalConfigDir; }); describe("gji new", () => { @@ -710,6 +717,264 @@ describe("gji new", () => { }); }); + describe("syncDirs CoW bootstrap", () => { + it("clones configured directories before sync files and after-create", async () => { + // Given a repository with a CoW directory, a sync file, and an after-create hook. + const repoRoot = await createRepository(); + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const branchName = "feature/cow-order"; + const worktreePath = resolveWorktreePath(repoRoot, branchName); + const marker = join(worktreePath, "bootstrap-order.txt"); + await mkdir(join(repoRoot, "node_modules")); + await writeFile(join(repoRoot, "node_modules", "ready.txt"), "ready\n"); + await writeFile(join(repoRoot, ".env.local"), "TOKEN=abc\n"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ + syncDirs: ["node_modules"], + syncFiles: [".env.local"], + hooks: { + "after-create": `test -f node_modules/ready.txt && test -f .env.local && touch "${marker}"`, + }, + }), + "utf8", + ); + process.env.HOME = home; + + // When gji new runs with an injected successful CoW cloner. + const runNew = createNewCommand({ + cloneDir: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "ready.txt"), "ready\n"); + return { bytes: 6, ms: 12 }; + }, + }); + const stderr: string[] = []; + const result = await runNew({ + branch: branchName, + cwd: repoRoot, + stderr: (chunk) => stderr.push(chunk), + stdout: () => undefined, + }); + + // Then the clone and file sync are complete before the hook runs. + expect(result).toBe(0); + await expect(readFile(marker, "utf8")).resolves.toBe(""); + expect(stderr.join("")).toContain("cloned node_modules"); + }); + + it("removes pnpm metadata and skips the install prompt after cloning node_modules", async () => { + // Given a pnpm repository whose cloned node_modules contains absolute-path metadata. + const repoRoot = await createRepository(); + const branchName = "feature/cow-pnpm"; + await writeFile( + join(repoRoot, "pnpm-lock.yaml"), + "lockfileVersion: '9'\n", + ); + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, "node_modules", ".modules.yaml"), + "store-dir: /main\n", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + let promptCalled = false; + let installCalled = false; + + // When gji new clones the directory and supplies install dependencies. + const runNew = createNewCommand({ + cloneDir: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile( + join(destination, ".modules.yaml"), + "store-dir: /main\n", + ); + return { bytes: 20, ms: 20 }; + }, + detectInstallPackageManager: async () => ({ + name: "pnpm", + installCommand: "pnpm install", + }), + promptForInstallChoice: async () => { + promptCalled = true; + return "yes"; + }, + runInstallCommand: async () => { + installCalled = true; + }, + }); + const result = await runNew({ + branch: branchName, + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then pnpm metadata is removed and neither prompt nor install runs. + expect(result).toBe(0); + expect(promptCalled).toBe(false); + expect(installCalled).toBe(false); + await expect( + readFile( + join( + resolveWorktreePath(repoRoot, branchName), + "node_modules", + ".modules.yaml", + ), + ), + ).rejects.toThrow(); + }); + + it("reports cloned directories in JSON output", async () => { + // Given a repository with one configured CoW directory. + const repoRoot = await createRepository(); + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + const stdout: string[] = []; + + // When gji new runs in JSON mode with a successful cloner. + const result = await createNewCommand({ + cloneDir: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + return { bytes: 42, ms: 7 }; + }, + })({ + branch: "feature/cow-json", + cwd: repoRoot, + json: true, + stderr: () => undefined, + stdout: (chunk) => stdout.push(chunk), + }); + + // Then stdout remains one parseable JSON document with clone timing. + expect(result).toBe(0); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + cloned: [{ dir: "node_modules", ms: 7 }], + }); + }); + + it("cleans partial clones and caches the failure for later worktrees", async () => { + // Given a repository and an isolated state directory. + const repoRoot = await createRepository(); + const configDir = await mkdtemp(join(tmpdir(), "gji-config-")); + process.env.GJI_CONFIG_DIR = configDir; + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + let cloneCalls = 0; + const runNew = createNewCommand({ + cloneDir: async (_source, destination) => { + cloneCalls += 1; + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "partial.txt"), "partial\n"); + await rm(destination, { force: true, recursive: true }); + throw new Error("reflink unsupported"); + }, + }); + + // When two worktrees are created after the first CoW attempt fails. + const first = await runNew({ + branch: "feature/cow-failure-one", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + const second = await runNew({ + branch: "feature/cow-failure-two", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then neither partial directory remains and the cached failure suppresses retry. + expect(first).toBe(0); + expect(second).toBe(0); + expect(cloneCalls).toBe(1); + await expect( + readFile(join(configDir, "state.json"), "utf8"), + ).resolves.toContain("node_modules"); + await expect( + readFile( + join( + resolveWorktreePath(repoRoot, "feature/cow-failure-one"), + "node_modules", + "partial.txt", + ), + ), + ).rejects.toThrow(); + }); + + it("skips a sync directory whose symlink points outside the repository", async () => { + // Given a node_modules symlink whose target is outside the repository. + const repoRoot = await createRepository(); + const outsideRoot = await mkdtemp(join(tmpdir(), "gji-outside-")); + await mkdir(join(outsideRoot, "node_modules")); + await symlink( + join(outsideRoot, "node_modules"), + join(repoRoot, "node_modules"), + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + let cloneCalled = false; + const stderr: string[] = []; + + // When gji new examines the configured source. + const result = await createNewCommand({ + cloneDir: async () => { + cloneCalled = true; + return { bytes: 0, ms: 0 }; + }, + })({ + branch: "feature/cow-symlink", + cwd: repoRoot, + stderr: (chunk) => stderr.push(chunk), + stdout: () => undefined, + }); + + // Then creation succeeds without copying data outside the repository. + expect(result).toBe(0); + expect(cloneCalled).toBe(false); + expect(stderr.join("")).toContain("outside the repository"); + }); + + it("lists CoW directories and their estimated size in dry-run mode", async () => { + // Given a repository with a configured directory that has one file. + const repoRoot = await createRepository(); + const source = join(repoRoot, "node_modules"); + await mkdir(source); + await writeFile(join(source, "ready.txt"), "ready\n", "utf8"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + const stdout: string[] = []; + + // When gji new runs without executing Git or clone operations. + const result = await runCli(["new", "--dry-run", "feature/cow-dry-run"], { + cwd: repoRoot, + stdout: (chunk) => stdout.push(chunk), + }); + + // Then the output names the directory and reports its expected size. + expect(result.exitCode).toBe(0); + expect(stdout.join("")).toContain("Would clone node_modules (6 B)"); + }); + }); + describe("install prompt", () => { const fakePm = { name: "pnpm", installCommand: "pnpm install" }; diff --git a/src/new.ts b/src/new.ts index 7ed38c0..0f85f9e 100644 --- a/src/new.ts +++ b/src/new.ts @@ -1,15 +1,38 @@ import { execFile } from "node:child_process"; -import { access, mkdir } from "node:fs/promises"; -import { basename, dirname, resolve } from "node:path"; +import { access, lstat, mkdir, realpath, unlink } from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import { promisify } from "node:util"; import { isCancel, text } from "@clack/prompts"; -import { loadEffectiveConfig, resolveConfigString } from "./config.js"; +import { + type GjiConfig, + loadEffectiveConfig, + resolveConfigString, + validateSyncDirPattern, +} from "./config.js"; import { type PathConflictChoice, pathExists, promptForPathConflict, } from "./conflict.js"; +import { + type CloneDirectory, + type CloneDirResult, + cacheCloneFailure, + clearCloneFailure, + cloneDir, + directorySize, + isCloneDestinationExistsError, + isCloneFailureCached, +} from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; import { syncFiles } from "./file-sync.js"; import { isHeadless } from "./headless.js"; @@ -23,6 +46,7 @@ import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js"; +import { detectPackageManager } from "./package-manager.js"; import { detectRepository, resolveWorktreePath, @@ -58,17 +82,34 @@ export interface NewCommandOptions { } export interface NewCommandDependencies extends InstallPromptDependencies { + cloneDir: CloneDirectory; createBranchPlaceholder: () => string; promptForBranch: (placeholder: string) => Promise; promptForPathConflict: (path: string) => Promise; spawnEditor: (cli: string, args: string[]) => Promise; } +export interface SyncDirEstimate { + bytes: number; + dir: string; +} + +interface ClonedDir extends SyncDirEstimate { + installSkipped: boolean; + ms: number; +} + +type SyncDirSource = + | { path: string; warning?: undefined } + | { path?: undefined; warning: string } + | null; + export function createNewCommand( dependencies: Partial = {}, ): (options: NewCommandOptions) => Promise { const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder; + const cloneDirectory = dependencies.cloneDir ?? cloneDir; const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch; const prompt = dependencies.promptForPathConflict ?? promptForPathConflict; @@ -90,11 +131,16 @@ export function createNewCommand( } const repository = await detectRepository(options.cwd); - const config = await loadEffectiveConfig( - repository.repoRoot, - undefined, - options.json ? undefined : options.stderr, - ); + let config: GjiConfig; + try { + config = await loadEffectiveConfig( + repository.repoRoot, + undefined, + options.json ? undefined : options.stderr, + ); + } catch (error) { + return emitNewError(options, toErrorMessage(error)); + } const usesGeneratedDetachedName = options.detached && options.branch === undefined; @@ -230,6 +276,10 @@ export function createNewCommand( } } + const dryRunSyncDirs = options.dryRun + ? await estimateSyncDirs(repository.repoRoot, worktreePath, config) + : []; + if (options.dryRun) { if (options.take) { const changedFiles = await listTakeFiles(options.cwd); @@ -262,23 +312,19 @@ export function createNewCommand( return 0; } if (options.json) { - options.stdout( - `${JSON.stringify( - { - ...createNavigationTarget( - createNavigationRepository( - repository.repoName, - repository.repoRoot, - ), - worktreePath, - worktreeName, - ), - dryRun: true, - }, - null, - 2, - )}\n`, - ); + const output: Record = { + ...createNavigationTarget( + createNavigationRepository( + repository.repoName, + repository.repoRoot, + ), + worktreePath, + worktreeName, + ), + dryRun: true, + }; + if (dryRunSyncDirs.length > 0) output.syncDirs = dryRunSyncDirs; + options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { const resolvedEditor = options.open ? (options.editor ?? resolveConfigString(config, "editor")) @@ -287,7 +333,7 @@ export function createNewCommand( ? `, then open in ${resolvedEditor}` : ""; options.stdout( - `Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n`, + `Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}`, ); } return 0; @@ -409,6 +455,15 @@ export function createNewCommand( ); } + const clonedDirs = await syncConfiguredDirs( + repository.repoRoot, + worktreePath, + config, + options.stderr, + !!options.json, + cloneDirectory, + ); + // Sync files from main worktree before afterCreate so synced files are available to install scripts. const syncPatterns = Array.isArray(config.syncFiles) ? (config.syncFiles as unknown[]).filter( @@ -432,6 +487,9 @@ export function createNewCommand( options.stderr, dependencies, !!options.json, + clonedDirs.some( + ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, + ), ); const hooks = extractHooks(config); @@ -452,22 +510,20 @@ export function createNewCommand( worktreePath, worktreeName, ); - options.stdout( - `${JSON.stringify( - options.take - ? { - ...navigation, - taken: { - files: takeFiles.length, - untracked: takeUntracked, - submodules: submoduleFiles, - }, - } - : navigation, - null, - 2, - )}\n`, - ); + const output: Record = options.take + ? { + ...navigation, + taken: { + files: takeFiles.length, + untracked: takeUntracked, + submodules: submoduleFiles, + }, + } + : { ...navigation }; + if (clonedDirs.length > 0) { + output.cloned = clonedDirs.map(({ dir, ms }) => ({ dir, ms })); + } + options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { if (options.take) options.stderr( @@ -627,6 +683,203 @@ function applyConfiguredBranchPrefix( return `${branchPrefix}${branch}`; } +export async function estimateSyncDirs( + repoRoot: string, + worktreePath: string, + config: GjiConfig, +): Promise { + const estimates: SyncDirEstimate[] = []; + for (const directory of configuredSyncDirs(config)) { + if (await pathExists(join(worktreePath, directory))) continue; + + let source: SyncDirSource; + try { + source = await resolveSyncDirSource(repoRoot, directory); + } catch { + continue; + } + if (!source?.path) continue; + + try { + estimates.push({ + bytes: await directorySize(source.path), + dir: directory, + }); + } catch { + // A dry-run is informational; an unreadable source is omitted. + } + } + + return estimates; +} + +export async function syncConfiguredDirs( + repoRoot: string, + worktreePath: string, + config: GjiConfig, + stderr: (chunk: string) => void, + json: boolean, + cloneDirectory: CloneDirectory = cloneDir, +): Promise { + const directories = configuredSyncDirs(config); + if (directories.length === 0) return []; + + const isPnpm = directories.includes("node_modules") + ? (await detectPackageManager(repoRoot))?.name === "pnpm" + : false; + const cloned: ClonedDir[] = []; + + for (const directory of directories) { + const destination = join(worktreePath, directory); + if (await pathExists(destination)) continue; + + let source: SyncDirSource; + try { + source = await resolveSyncDirSource(repoRoot, directory); + } catch (error) { + stderr( + `syncDirs: could not inspect ${directory}: ${toErrorMessage(error)}, skipped ${directory}\n`, + ); + continue; + } + if (!source) continue; + if (!source.path) { + stderr(`syncDirs: ${source.warning}, skipped ${directory}\n`); + continue; + } + + if (await isCloneFailureCached(repoRoot, directory)) { + if (!json) { + stderr( + `syncDirs: previous copy-on-write failure cached, skipped ${directory}\n`, + ); + } + continue; + } + + let result: CloneDirResult; + try { + result = await cloneDirectory(source.path, destination); + } catch (error) { + if (isCloneDestinationExistsError(error)) continue; + + const reason = toErrorMessage(error); + await cacheCloneFailure(repoRoot, directory, reason); + stderr( + `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${directory}\n`, + ); + continue; + } + + await clearCloneFailure(repoRoot, directory); + let installSkipped = false; + if (directory === "node_modules" && isPnpm) { + try { + await unlink(join(destination, ".modules.yaml")); + installSkipped = true; + } catch (error) { + if (isNotFoundError(error)) { + installSkipped = true; + } else { + stderr( + `syncDirs: could not remove pnpm .modules.yaml: ${toErrorMessage(error)}\n`, + ); + } + } + } else if (directory === "node_modules") { + installSkipped = true; + } + + const clonedDir = { + bytes: result.bytes, + dir: directory, + installSkipped, + ms: result.ms, + }; + cloned.push(clonedDir); + if (!json) { + stderr( + `⚡ cloned ${directory} (${formatBytes(result.bytes)} → ${formatDuration(result.ms)})${installSkipped ? " — run install only if lockfile changed" : ""}\n`, + ); + } + } + + return cloned; +} + +function configuredSyncDirs(config: GjiConfig): string[] { + if (!Array.isArray(config.syncDirs)) return []; + + return config.syncDirs + .filter((value): value is string => typeof value === "string") + .map(validateSyncDirPattern); +} + +async function resolveSyncDirSource( + repoRoot: string, + directory: string, +): Promise { + const source = join(repoRoot, directory); + let resolvedSource: string; + try { + resolvedSource = await realpath(source); + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; + } + + if (!isPathInside(repoRoot, resolvedSource)) { + return { + warning: `source symlink resolves outside the repository (${resolvedSource})`, + }; + } + + const stats = await lstat(resolvedSource); + if (!stats.isDirectory()) { + return { warning: "source is not a directory" }; + } + + return { path: resolvedSource }; +} + +function isPathInside(parent: string, child: string): boolean { + const relativePath = relative(resolve(parent), resolve(child)); + return ( + relativePath === "" || + (!isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`)) + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +function isNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + async function resolveUniqueDetachedWorktreePath( repoRoot: string, baseName: string, diff --git a/src/pr.test.ts b/src/pr.test.ts index ad2c25a..034e301 100644 --- a/src/pr.test.ts +++ b/src/pr.test.ts @@ -554,6 +554,57 @@ describe("gji pr", () => { return repoRoot; } + it("clones syncDirs before the PR install prompt", async () => { + // Given a PR repository with a configured node_modules directory. + const repoRoot = await setupPrRepo("2017"); + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, "node_modules", "ready.txt"), + "ready\n", + "utf8", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + let promptCalled = false; + const runPrCmd = createPrCommand({ + cloneDir: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "ready.txt"), "ready\n", "utf8"); + return { bytes: 6, ms: 4 }; + }, + detectInstallPackageManager: async () => fakePm, + promptForInstallChoice: async () => { + promptCalled = true; + return "yes"; + }, + }); + + // When gji pr creates the review worktree. + const result = await runPrCmd({ + cwd: repoRoot, + number: "2017", + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the cloned directory is available and the install prompt is skipped. + expect(result).toBe(0); + expect(promptCalled).toBe(false); + await expect( + readFile( + join( + resolveWorktreePath(repoRoot, "pr/2017"), + "node_modules", + "ready.txt", + ), + "utf8", + ), + ).resolves.toBe("ready\n"); + }); + it('runs install once and does not persist anything when "yes" is chosen', async () => { // Given a PR repo with a detected package manager and a "yes" prompt choice. const repoRoot = await setupPrRepo("2001"); diff --git a/src/pr.ts b/src/pr.ts index c7d2450..e4d477f 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -3,12 +3,17 @@ import { mkdir } from "node:fs/promises"; import { basename, dirname } from "node:path"; import { promisify } from "node:util"; -import { loadEffectiveConfig, resolveConfigString } from "./config.js"; +import { + type GjiConfig, + loadEffectiveConfig, + resolveConfigString, +} from "./config.js"; import { type PathConflictChoice, pathExists, promptForPathConflict, } from "./conflict.js"; +import type { CloneDirectory } from "./dir-clone.js"; import { syncFiles } from "./file-sync.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; @@ -21,6 +26,7 @@ import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js"; +import { estimateSyncDirs, syncConfiguredDirs } from "./new.js"; import { detectRepository, resolveWorktreePath } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; @@ -41,6 +47,7 @@ export interface PrCommandOptions { } export interface PrCommandDependencies extends InstallPromptDependencies { + cloneDir?: CloneDirectory; promptForPathConflict: (path: string) => Promise; } @@ -81,11 +88,22 @@ export function createPrCommand( } const repository = await detectRepository(options.cwd); - const config = await loadEffectiveConfig( - repository.repoRoot, - undefined, - options.json ? undefined : options.stderr, - ); + let config: GjiConfig; + try { + config = await loadEffectiveConfig( + repository.repoRoot, + undefined, + options.json ? undefined : options.stderr, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.json) { + options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`); + } else { + options.stderr(`gji pr: ${message}\n`); + } + return 1; + } const branchName = `pr/${prNumber}`; const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`; const rawBasePath = resolveConfigString(config, "worktreePath"); @@ -129,28 +147,28 @@ export function createPrCommand( return 1; } + const dryRunSyncDirs = options.dryRun + ? await estimateSyncDirs(repository.repoRoot, worktreePath, config) + : []; + if (options.dryRun) { if (options.json) { - options.stdout( - `${JSON.stringify( - { - ...createNavigationTarget( - createNavigationRepository( - repository.repoName, - repository.repoRoot, - ), - worktreePath, - branchName, - ), - dryRun: true, - }, - null, - 2, - )}\n`, - ); + const output: Record = { + ...createNavigationTarget( + createNavigationRepository( + repository.repoName, + repository.repoRoot, + ), + worktreePath, + branchName, + ), + dryRun: true, + }; + if (dryRunSyncDirs.length > 0) output.syncDirs = dryRunSyncDirs; + options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { options.stdout( - `Would create worktree at ${worktreePath} (branch: ${branchName})\n`, + `Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${bytes} bytes)\n`).join("")}`, ); } return 0; @@ -188,6 +206,15 @@ export function createPrCommand( await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot }); + const clonedDirs = await syncConfiguredDirs( + repository.repoRoot, + worktreePath, + config, + options.stderr, + !!options.json, + dependencies.cloneDir, + ); + // Sync files from main worktree before afterCreate so synced files are available to install scripts. const syncPatterns = Array.isArray(config.syncFiles) ? (config.syncFiles as unknown[]).filter( @@ -211,6 +238,9 @@ export function createPrCommand( options.stderr, dependencies, !!options.json, + clonedDirs.some( + ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, + ), ); const hooks = extractHooks(config); @@ -226,20 +256,17 @@ export function createPrCommand( ); if (options.json) { - options.stdout( - `${JSON.stringify( - createNavigationTarget( - createNavigationRepository( - repository.repoName, - repository.repoRoot, - ), - worktreePath, - branchName, - ), - null, - 2, - )}\n`, - ); + const output: Record = { + ...createNavigationTarget( + createNavigationRepository(repository.repoName, repository.repoRoot), + worktreePath, + branchName, + ), + }; + if (clonedDirs.length > 0) { + output.cloned = clonedDirs.map(({ dir, ms }) => ({ dir, ms })); + } + options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { await recordWorktreeUsage(worktreePath, branchName); await writeOutput(worktreePath, options.stdout, options.outputEnv); diff --git a/src/shell-completion.ts b/src/shell-completion.ts index c835285..9ce2ad8 100644 --- a/src/shell-completion.ts +++ b/src/shell-completion.ts @@ -3,7 +3,8 @@ import { KNOWN_GLOBAL_CONFIG_KEYS } from "./config.js"; const TOP_LEVEL_COMMANDS = [ { name: "new", - description: "create a new branch or detached linked worktree", + description: + "create a new branch or detached linked worktree and CoW-bootstrap configured directories", }, { name: "done", diff --git a/website/docs/commands.mdx b/website/docs/commands.mdx index 335c25d..600f675 100644 --- a/website/docs/commands.mdx +++ b/website/docs/commands.mdx @@ -16,7 +16,7 @@ image: /img/social-card.png | Command | What it does | | --- | --- | -| `gji new [branch] [--from-current] [--detached] [--take] [--copy] [--force] [--open] [--editor ] [--dry-run] [--json]` | Create a branch and worktree, optionally carrying uncommitted changes. | +| `gji new [branch] [--from-current] [--detached] [--take] [--copy] [--force] [--open] [--editor ] [--dry-run] [--json]` | Create a branch and worktree, optionally carrying uncommitted changes and CoW-bootstrap configured directories. | | `gji done [branch] [--force] [--keep-branch] [--json]` | Safely finish a linked worktree and return. | | `gji undo [id] [--list] [--json]` | Restore a journaled cleanup without overwriting work. | | `gji pr [--json]` | Fetch a pull request ref and open it in a dedicated worktree. | @@ -206,3 +206,5 @@ Several commands support `--json` so shell scripts and tools can consume structu - `gji go --json` `gji clean --stale` limits cleanup to clean branch worktrees whose upstream is gone and whose branch is already merged into the configured or remote default branch. + +When `syncDirs` is configured, `gji new` clones each available source with filesystem copy-on-write before syncing files, prompting to install, or running `afterCreate`. Unsupported filesystems are skipped without a slow-copy fallback. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, pnpm metadata handling, JSON output, and dry-run behavior. diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index d0495a2..d350f04 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -23,6 +23,7 @@ Use the global file for defaults you want everywhere. Use `.gji.json` when one r | `syncRemote` | Remote used by `gji sync`. | | `syncDefaultBranch` | Branch used as the rebase target. | | `syncFiles` | Files copied from the main worktree into new worktrees. Use global per-repo config for private files. | +| `syncDirs` | Directories cloned with filesystem copy-on-write before files, installs, and hooks. | | `skipInstallPrompt` | Disable the automatic install prompt permanently. | | `installSaveTarget` | Persist install prompt choices locally or globally. | | `hooks` | Lifecycle commands for create, enter, and remove events. String hooks run through a shell; array hooks run as argv without a shell. | @@ -35,7 +36,8 @@ Use the global file for defaults you want everywhere. Use `.gji.json` when one r "branchPrefix": "feature/", "syncRemote": "origin", "syncDefaultBranch": "main", - "syncFiles": [".env.example", ".nvmrc"] + "syncFiles": [".env.example", ".nvmrc"], + "syncDirs": ["node_modules", ".next"] } ``` @@ -116,6 +118,22 @@ The command stores the list under the current repository inside your global conf `gji new` copies configured files from the main worktree before install hooks run, skips missing source files, and never overwrites a file that already exists in the new worktree. +## Instant directory bootstrap + +Use `syncDirs` for large directories such as dependency trees and build caches: + +```json +{ + "syncDirs": ["node_modules", ".next"] +} +``` + +The setting is resolved through the same three layers as other normal config values: global defaults, per-repository global overrides, then `.gji.json`. Paths must be relative to the repository root; absolute paths, `..` segments, and any `.git` path are rejected. + +On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. For pnpm projects, `node_modules/.modules.yaml` is removed after cloning so pnpm can regenerate it safely. + +Cloning happens before `syncFiles`, install prompts, and `afterCreate`. A successful `node_modules` clone skips the install prompt; run the package manager manually when the lockfile changes. Human output reports size and duration, while `--json` exposes `cloned: [{ "dir": "node_modules", "ms": 1200 }]`. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. + ## CLI helpers ```bash diff --git a/website/docs/installation.mdx b/website/docs/installation.mdx index 94f1a83..55899f7 100644 --- a/website/docs/installation.mdx +++ b/website/docs/installation.mdx @@ -63,6 +63,16 @@ gji sync-files add .env.local .npmrc `gji` stores that list in your global per-repo config and copies the files before setup hooks run. +For large dependency trees, configure copy-on-write bootstrap instead of waiting for a full install: + +```json +{ + "syncDirs": ["node_modules", ".next"] +} +``` + +On supported macOS and Linux filesystems, `gji new` clones these directories before files, install prompts, and `afterCreate`. Unsupported filesystems are skipped without an ordinary-copy fallback. A successful `node_modules` clone skips the install prompt; use `gji new --dry-run` to inspect the estimated source sizes. + Add an `afterCreate` hook: ```json From c1f6423ae0f5da47a1dda06139da974a5e9793e3 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 21:43:01 +0900 Subject: [PATCH 02/19] Fix copy-on-write bootstrap safety --- package.json | 2 + pnpm-lock.yaml | 431 +++++++++++++++++++++++++++++++++++++ src/cli.ts | 1 + src/config-command.test.ts | 22 ++ src/config-command.ts | 16 +- src/config.ts | 20 +- src/dir-clone.test.ts | 75 ++++++- src/dir-clone.ts | 184 ++++++++++++++-- src/new.test.ts | 151 +++++++++++++ src/new.ts | 299 ++----------------------- src/pr.test.ts | 53 ++++- src/pr.ts | 65 +----- src/worktree-bootstrap.ts | 316 +++++++++++++++++++++++++++ 13 files changed, 1263 insertions(+), 372 deletions(-) create mode 100644 src/worktree-bootstrap.ts diff --git a/package.json b/package.json index 906f1eb..862ae2b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "docs:start": "pnpm --dir website start", "prepublishOnly": "pnpm test && pnpm build && pnpm generate-man", "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage --passWithNoTests", "test:watch": "vitest", "lint": "biome lint --write .", "format": "biome format --write .", @@ -65,6 +66,7 @@ "@biomejs/biome": "^2.4.15", "@j178/prek": "^0.3.13", "@types/node": "^24.6.0", + "@vitest/coverage-v8": "3.2.4", "esbuild": "^0.27.0", "tsx": "^4.22.4", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de5057..8e703ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@types/node': specifier: ^24.6.0 version: 24.12.0 + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@24.12.0)(tsx@4.22.4)) esbuild: specifier: ^0.27.0 version: 0.27.4 @@ -45,6 +48,31 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.4.15': resolution: {integrity: sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==} engines: {node: '>=14.21.3'} @@ -420,6 +448,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@j178/prek-android-arm64@0.3.13': resolution: {integrity: sha512-VBr2kIOAY0QpEuk0CrIBaiU8at/Xw5f47ga7Zrpcxzaz2KdJIYeWmbWsiIeNFv6Ko7y4xxY7Ho+EkZd3Pr/5DA==} engines: {node: '>=18'} @@ -538,9 +574,23 @@ packages: engines: {node: '>=18'} hasBin: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -703,6 +753,15 @@ packages: '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -743,6 +802,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -751,13 +814,30 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + boxen@8.0.1: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -782,6 +862,13 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -793,6 +880,10 @@ packages: resolution: {integrity: sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==} engines: {node: '>=18'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -814,12 +905,18 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -853,6 +950,10 @@ packages: picomatch: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -862,6 +963,11 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -872,6 +978,13 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -900,6 +1013,31 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -914,12 +1052,34 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -928,10 +1088,21 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-json@10.0.1: resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} engines: {node: '>=18'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -979,9 +1150,21 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -999,6 +1182,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -1024,6 +1211,14 @@ packages: stubborn-utils@1.0.2: resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1143,6 +1338,11 @@ packages: when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1152,6 +1352,14 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -1162,6 +1370,26 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.4.15': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.15 @@ -1364,6 +1592,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + '@j178/prek-android-arm64@0.3.13': optional: true @@ -1435,8 +1674,23 @@ snapshots: '@j178/prek-win32-ia32-msvc': 0.3.13 '@j178/prek-win32-x64-msvc': 0.3.13 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pkgjs/parseargs@0.11.0': + optional: true + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -1537,6 +1791,25 @@ snapshots: dependencies: undici-types: 7.16.0 + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.12.0)(tsx@4.22.4))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@24.12.0)(tsx@4.22.4) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1587,15 +1860,29 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@6.2.3: {} assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + atomically@2.1.1: dependencies: stubborn-fs: 2.0.0 when-exit: 2.1.5 + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + boxen@8.0.1: dependencies: ansi-align: 3.0.1 @@ -1607,6 +1894,14 @@ snapshots: widest-line: 5.0.0 wrap-ansi: 9.0.2 + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + cac@6.7.14: {} camelcase@8.0.0: {} @@ -1625,6 +1920,12 @@ snapshots: cli-boxes@3.0.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@14.0.3: {} config-chain@1.1.13: @@ -1639,6 +1940,12 @@ snapshots: graceful-fs: 4.2.11 xdg-basedir: 5.1.0 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1651,10 +1958,14 @@ snapshots: dependencies: type-fest: 4.41.0 + eastasianwidth@0.2.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + es-module-lexer@1.7.0: {} esbuild@0.27.4: @@ -1727,11 +2038,25 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true get-east-asian-width@1.5.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -1740,6 +2065,10 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + ini@1.3.8: {} ini@4.1.1: {} @@ -1757,6 +2086,37 @@ snapshots: is-path-inside@4.0.0: {} + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@10.0.0: {} + js-tokens@9.0.1: {} ky@1.14.3: {} @@ -1767,16 +2127,40 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + minimist@1.2.8: {} + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.11: {} + package-json-from-dist@1.0.1: {} + package-json@10.0.1: dependencies: ky: 1.14.3 @@ -1784,6 +2168,13 @@ snapshots: registry-url: 6.0.1 semver: 7.7.4 + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1852,8 +2243,16 @@ snapshots: semver@7.7.4: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} source-map-js@1.2.1: {} @@ -1868,6 +2267,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -1894,6 +2299,16 @@ snapshots: stubborn-utils@1.0.2: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2011,6 +2426,10 @@ snapshots: when-exit@2.1.5: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2020,6 +2439,18 @@ snapshots: dependencies: string-width: 7.2.0 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 diff --git a/src/cli.ts b/src/cli.ts index 0314b7f..e6ccb19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1022,6 +1022,7 @@ function attachCommandActions( action: "set", cwd: options.cwd, key, + stderr: options.stderr, stdout: options.stdout, value, }); diff --git a/src/config-command.test.ts b/src/config-command.test.ts index a68fa6f..1189263 100644 --- a/src/config-command.test.ts +++ b/src/config-command.test.ts @@ -101,6 +101,28 @@ describe("gji config", () => { fetchDepth: parseConfigValue("2"), }); }); + + it("rejects unsafe syncDirs before writing global config", async () => { + // Given an isolated home directory and an unsafe syncDirs value. + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const cwd = await mkdtemp(join(tmpdir(), "gji-cwd-")); + const stderr: string[] = []; + process.env.HOME = home; + + // When gji config attempts to persist a parent traversal. + const result = await runCli( + ["config", "set", "syncDirs", '["../escape"]'], + { + cwd, + stderr: (chunk) => stderr.push(chunk), + }, + ); + + // Then the command rejects the value and leaves no config behind. + expect(result.exitCode).toBe(1); + expect(stderr.join("")).toContain("'..' segments"); + await expect(readGlobalConfig(home)).rejects.toThrow(); + }); }); async function readGlobalConfig( diff --git a/src/config-command.ts b/src/config-command.ts index 1fa5257..72cee30 100644 --- a/src/config-command.ts +++ b/src/config-command.ts @@ -9,6 +9,7 @@ export interface ConfigCommandOptions { action?: string; cwd: string; key?: string; + stderr?: (chunk: string) => void; stdout: (chunk: string) => void; value?: string; } @@ -33,10 +34,17 @@ export async function runConfigCommand( } case "set": if (options.key && options.value !== undefined) { - await updateGlobalConfigKey( - options.key, - parseConfigValue(options.value), - ); + try { + await updateGlobalConfigKey( + options.key, + parseConfigValue(options.value), + ); + } catch (error) { + options.stderr?.( + `gji config: ${error instanceof Error ? error.message : String(error)}\n`, + ); + return 1; + } return 0; } break; diff --git a/src/config.ts b/src/config.ts index f3f0bef..156d0d7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -146,6 +146,7 @@ export async function saveLocalConfig( root: string, config: GjiConfig, ): Promise { + validateConfigSyncDirs(config); const path = join(root, CONFIG_FILE_NAME); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8"); @@ -173,6 +174,7 @@ export async function saveGlobalConfig( config: GjiConfig, home: string = homedir(), ): Promise { + validateConfigSyncDirs(config); const path = GLOBAL_CONFIG_FILE_PATH(home); await mkdir(dirname(path), { recursive: true }); @@ -273,6 +275,19 @@ export function validateSyncDirsConfig(value: unknown): void { } } +function validateConfigSyncDirs(config: GjiConfig): void { + validateSyncDirsConfig(config.syncDirs); + + const repos = config.repos; + if (!isPlainObject(repos)) return; + + for (const repoConfig of Object.values(repos)) { + if (isPlainObject(repoConfig)) { + validateSyncDirsConfig(repoConfig.syncDirs); + } + } +} + export function validateSyncDirPattern(pattern: string): string { if ( pattern.length === 0 || @@ -331,10 +346,7 @@ async function loadConfigFile(path: string): Promise { function mergeConfig(...values: Record[]): GjiConfig { return values.reduce( - (config, value) => ({ - ...config, - ...value, - }), + (config, value) => Object.assign(config, value), { ...DEFAULT_CONFIG }, ); } diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 7591ff2..fab3418 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -2,26 +2,35 @@ import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { + cacheCloneFailure, + clearCloneFailure, cloneDir, cloneStrategy, isCloneDestinationExistsError, + isCloneFailureCached, + isClonePlatformSupported, + isCloneUnsupportedError, } from "./dir-clone.js"; +const originalConfigDir = process.env.GJI_CONFIG_DIR; + +afterEach(() => { + if (originalConfigDir === undefined) delete process.env.GJI_CONFIG_DIR; + else process.env.GJI_CONFIG_DIR = originalConfigDir; +}); + describe("cloneStrategy", () => { - it("selects the APFS clone command on macOS", () => { + it("recognizes macOS as a native APFS clone platform", () => { // Given the macOS platform. - // When the CoW strategy is selected. - const strategy = cloneStrategy("darwin"); + // When platform support is checked. + const supported = isClonePlatformSupported("darwin"); - // Then it requests recursive clonefile copies. - expect(strategy?.("source", "destination")).toEqual([ - "-Rc", - "source", - "destination", - ]); + // Then the native clonefile implementation is selected by cloneDir. + expect(supported).toBe(true); + expect(cloneStrategy("darwin")).toBeNull(); }); it("selects mandatory reflinks on Linux", () => { @@ -72,7 +81,7 @@ describe("cloneDir", () => { }); // Then the final destination contains the clone and no temporary sibling remains. - expect(result.bytes).toBe(3); + expect(result.bytes).toBeGreaterThan(0); expect(result.ms).toBeGreaterThanOrEqual(0); await expect( readFile(join(destination, "package.json"), "utf8"), @@ -142,4 +151,48 @@ describe("cloneDir", () => { // Then no destination is created. await expect(readdir(root)).resolves.toEqual(["source"]); }); + + it("never falls back to an ordinary copy on the default platform", async () => { + // Given a source directory and a destination on the test filesystem. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-default-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await writeFile(join(source, "package.json"), "{}\n", "utf8"); + + // When cloneDir uses its production copy-on-write implementation. + const error = await cloneDir(source, destination).catch((caught) => caught); + + // Then unsupported filesystems fail cleanly, while supported ones publish the clone. + if (error) { + expect(isCloneUnsupportedError(error)).toBe(true); + expect((await readdir(root)).sort()).toEqual(["source"]); + } else { + await expect( + readFile(join(destination, "package.json"), "utf8"), + ).resolves.toBe("{}\n"); + } + }); + + it("does not treat inherited names as cached failures", async () => { + // Given an isolated state directory with one ordinary cached failure. + const root = await mkdtemp(join(tmpdir(), "gji-state-")); + process.env.GJI_CONFIG_DIR = root; + await cacheCloneFailure("/repo", "node_modules", "unsupported"); + + // When cache lookups use a valid directory named like an object property. + const inheritedNameCached = await isCloneFailureCached( + "/repo", + "constructor", + ); + const ordinaryNameCached = await isCloneFailureCached( + "/repo", + "node_modules", + ); + + // Then only the explicitly cached directory is suppressed. + expect(inheritedNameCached).toBe(false); + expect(ordinaryNameCached).toBe(true); + await clearCloneFailure("/repo", "node_modules"); + }); }); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 89f2d69..78595cf 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; +import { constants } from "node:fs"; import { + cp, lstat, mkdir, mkdtemp, @@ -24,6 +26,10 @@ interface CloneFailure { reason: string; } +const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000; +const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000; +const CLONE_LOCK_SUFFIX = ".gji-clone-lock"; + interface GjiState { [key: string]: unknown; syncDirs?: Record>; @@ -37,6 +43,7 @@ export interface CloneDirResult { export interface CloneDirOptions { platform?: NodeJS.Platform; runCommand?: (command: string, args: string[]) => Promise; + copyDirectory?: (source: string, destination: string) => Promise; } export type CloneDirectory = ( @@ -49,11 +56,10 @@ export async function cloneDir( destination: string, options: CloneDirOptions = {}, ): Promise { - const strategy = cloneStrategy(options.platform ?? process.platform); - if (!strategy) { - throw new Error( - `copy-on-write cloning is not supported on ${options.platform ?? process.platform}`, - ); + const platform = options.platform ?? process.platform; + const strategy = cloneStrategy(platform); + if (!isClonePlatformSupported(platform)) { + throw new CloneUnsupportedError(`platform ${platform} has no CoW strategy`); } const sourcePath = await realpath(source); @@ -65,18 +71,39 @@ export async function cloneDir( throw new CloneDestinationExistsError(destination); } - const bytes = await directorySize(sourcePath); const startedAt = Date.now(); const parent = dirname(destination); await mkdir(parent, { recursive: true }); - const temporaryRoot = await mkdtemp( - join(parent, `.${basename(destination)}.gji-clone-`), - ); - const temporaryDestination = join(temporaryRoot, basename(destination)); + const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; + await acquireCloneLock(lockPath, destination); + let temporaryRoot: string | undefined; try { - const runCommand = options.runCommand ?? runCloneCommand; - await runCommand("cp", strategy(sourcePath, temporaryDestination)); + temporaryRoot = await mkdtemp( + join(parent, `.${basename(destination)}.gji-clone-`), + ); + const temporaryDestination = join(temporaryRoot, basename(destination)); + const copyDirectory = + options.copyDirectory ?? + (platformIsDarwin(platform) + ? runNativeCloneDirectory + : async (source, target) => { + if (!strategy) { + throw new CloneUnsupportedError( + `platform ${platform} has no CoW strategy`, + ); + } + const runCommand = options.runCommand ?? runCloneCommand; + await runCommand("cp", strategy(source, target)); + }); + try { + await copyDirectory(sourcePath, temporaryDestination); + } catch (error) { + if (isUnsupportedCloneError(error)) { + throw new CloneUnsupportedError(toErrorMessage(error)); + } + throw error; + } if (await pathExists(destination)) { throw new CloneDestinationExistsError(destination); @@ -84,12 +111,29 @@ export async function cloneDir( await rename(temporaryDestination, destination); } finally { - await rm(temporaryRoot, { force: true, recursive: true }); + if (temporaryRoot) { + await rm(temporaryRoot, { force: true, recursive: true }); + } + await rm(lockPath, { force: true, recursive: true }); } + const bytes = await estimateCloneBytes(sourcePath); return { bytes, ms: Date.now() - startedAt }; } +async function runNativeCloneDirectory( + source: string, + destination: string, +): Promise { + await cp(source, destination, { + errorOnExist: true, + force: false, + mode: constants.COPYFILE_FICLONE_FORCE, + preserveTimestamps: true, + recursive: true, + }); +} + export class CloneDestinationExistsError extends Error { readonly code = "GJI_CLONE_DESTINATION_EXISTS"; @@ -99,19 +143,41 @@ export class CloneDestinationExistsError extends Error { } } +export class CloneUnsupportedError extends Error { + readonly code = "GJI_CLONE_UNSUPPORTED"; + + constructor(reason: string) { + super(`copy-on-write cloning is not supported: ${reason}`); + this.name = "CloneUnsupportedError"; + } +} + export function isCloneDestinationExistsError( error: unknown, ): error is CloneDestinationExistsError { return error instanceof CloneDestinationExistsError; } +export function isCloneUnsupportedError(error: unknown): boolean { + return ( + error instanceof CloneUnsupportedError || isUnsupportedCloneError(error) + ); +} + export async function isCloneFailureCached( repoRoot: string, directory: string, ): Promise { const state = await readState(); const repoState = state.syncDirs?.[repoRoot]; - return repoState?.[directory] !== undefined; + if (!repoState || !Object.hasOwn(repoState, directory)) return false; + + const failure = repoState[directory]; + return ( + isPlainObject(failure) && + typeof failure.failedAt === "number" && + Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS + ); } export async function cacheCloneFailure( @@ -137,7 +203,7 @@ export async function clearCloneFailure( ): Promise { const state = await readState(); const repoState = state.syncDirs?.[repoRoot]; - if (!repoState?.[directory]) return; + if (!repoState || !Object.hasOwn(repoState, directory)) return; const nextRepoState = { ...repoState }; delete nextRepoState[directory]; @@ -164,10 +230,6 @@ export async function directorySize(path: string): Promise { export function cloneStrategy( platform: NodeJS.Platform, ): ((source: string, destination: string) => string[]) | null { - if (platform === "darwin") { - return (source, destination) => ["-Rc", source, destination]; - } - if (platform === "linux") { return (source, destination) => [ "-a", @@ -180,8 +242,34 @@ export function cloneStrategy( return null; } +export function isClonePlatformSupported(platform: NodeJS.Platform): boolean { + return platform === "darwin" || platform === "linux"; +} + +async function estimateCloneBytes(path: string): Promise { + const args = + process.platform === "darwin" + ? ["-A", "-B", "1", "-s", path] + : ["--apparent-size", "--block-size=1", "-s", path]; + + try { + const { stdout } = await execFileAsync("du", args); + const bytes = Number.parseInt(stdout.trim().split(/\s+/u)[0] ?? "", 10); + return Number.isFinite(bytes) ? bytes : 0; + } catch { + return 0; + } +} + async function runCloneCommand(command: string, args: string[]): Promise { - await execFileAsync(command, args); + try { + await execFileAsync(command, args); + } catch (error) { + if (isUnsupportedCloneError(error)) { + throw new CloneUnsupportedError(toErrorMessage(error)); + } + throw error; + } } async function readState(): Promise { @@ -237,3 +325,59 @@ function isNotFoundError(error: unknown): boolean { (error as NodeJS.ErrnoException).code === "ENOENT" ); } + +function isAlreadyExistsError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "EEXIST" + ); +} + +async function acquireCloneLock( + lockPath: string, + destination: string, +): Promise { + try { + await mkdir(lockPath); + return; + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } + + try { + const lockStats = await lstat(lockPath); + if (Date.now() - lockStats.mtimeMs >= CLONE_LOCK_TTL_MS) { + await rm(lockPath, { force: true, recursive: true }); + await mkdir(lockPath); + return; + } + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + + throw new CloneDestinationExistsError(destination); +} + +function isUnsupportedCloneError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const code = "code" in error ? (error as NodeJS.ErrnoException).code : ""; + if ( + ["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"].includes(code ?? "") + ) { + return true; + } + + return /clonefile|reflink|unsupported|operation not supported|not supported/iu.test( + error.message, + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function platformIsDarwin(platform: NodeJS.Platform): boolean { + return platform === "darwin"; +} diff --git a/src/new.test.ts b/src/new.test.ts index 50ee22a..6bafd9a 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -914,6 +914,48 @@ describe("gji new", () => { ).rejects.toThrow(); }); + it("does not permanently cache operational clone failures", async () => { + // Given a repository whose CoW attempt fails for a transient permission error. + const repoRoot = await createRepository(); + const configDir = await mkdtemp(join(tmpdir(), "gji-config-")); + process.env.GJI_CONFIG_DIR = configDir; + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + let cloneCalls = 0; + const runNew = createNewCommand({ + cloneDir: async () => { + cloneCalls += 1; + throw new Error("permission denied"); + }, + }); + + // When two worktrees are created after the operational failure. + const first = await runNew({ + branch: "feature/cow-operational-one", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + const second = await runNew({ + branch: "feature/cow-operational-two", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then both creations succeed and the transient error is retried rather than cached. + expect(first).toBe(0); + expect(second).toBe(0); + expect(cloneCalls).toBe(2); + await expect(pathExists(join(configDir, "state.json"))).resolves.toBe( + false, + ); + }); + it("skips a sync directory whose symlink points outside the repository", async () => { // Given a node_modules symlink whose target is outside the repository. const repoRoot = await createRepository(); @@ -950,6 +992,115 @@ describe("gji new", () => { expect(stderr.join("")).toContain("outside the repository"); }); + it("skips missing, non-directory, and already checked-out sources safely", async () => { + // Given configured sources covering missing, file, and existing target paths. + const repoRoot = await createRepository(); + await writeFile(join(repoRoot, "file-target"), "file\n", "utf8"); + await mkdir(join(repoRoot, "existing-dir")); + await commitFile( + repoRoot, + "existing-dir/ready.txt", + "ready\n", + "Add existing directory source", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ + syncDirs: ["missing-dir", "file-target", "existing-dir"], + }), + "utf8", + ); + const stderr: string[] = []; + let cloneCalled = false; + + // When gji new examines each configured source. + const result = await createNewCommand({ + cloneDir: async () => { + cloneCalled = true; + return { bytes: 0, ms: 0 }; + }, + })({ + branch: "feature/cow-skips", + cwd: repoRoot, + stderr: (chunk) => stderr.push(chunk), + stdout: () => undefined, + }); + + // Then creation succeeds without invoking CoW or overwriting the checked-out directory. + expect(result).toBe(0); + expect(cloneCalled).toBe(false); + expect(stderr.join("")).toContain("source is not a directory"); + }); + + it("skips the install prompt after cloning node_modules for npm", async () => { + // Given an npm repository with a configured node_modules directory. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "package-lock.json", + "{}\n", + "Add npm lockfile", + ); + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + let promptCalled = false; + + // When gji new clones dependencies and an install manager is available. + const result = await createNewCommand({ + cloneDir: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + return { bytes: 1, ms: 1 }; + }, + detectInstallPackageManager: async () => ({ + name: "npm", + installCommand: "npm install", + }), + promptForInstallChoice: async () => { + promptCalled = true; + return "yes"; + }, + })({ + branch: "feature/cow-npm", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the successful dependency clone suppresses installation for npm too. + expect(result).toBe(0); + expect(promptCalled).toBe(false); + }); + + it("emits a JSON error and does not create a worktree for invalid syncDirs", async () => { + // Given a repository with an unsafe local syncDirs configuration. + const repoRoot = await createRepository(); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["../escape"] }), + "utf8", + ); + const stderr: string[] = []; + + // When gji new runs in JSON mode. + const result = await runCli(["new", "--json", "feature/cow-invalid"], { + cwd: repoRoot, + stderr: (chunk) => stderr.push(chunk), + }); + + // Then it reports structured validation failure before creating a worktree. + expect(result.exitCode).toBe(1); + expect(JSON.parse(stderr.join(""))).toMatchObject({ + error: expect.stringContaining("'..' segments"), + }); + await expect( + pathExists(resolveWorktreePath(repoRoot, "feature/cow-invalid")), + ).resolves.toBe(false); + }); + it("lists CoW directories and their estimated size in dry-run mode", async () => { // Given a repository with a configured directory that has one file. const repoRoot = await createRepository(); diff --git a/src/new.ts b/src/new.ts index 0f85f9e..b7399e1 100644 --- a/src/new.ts +++ b/src/new.ts @@ -1,14 +1,6 @@ import { execFile } from "node:child_process"; -import { access, lstat, mkdir, realpath, unlink } from "node:fs/promises"; -import { - basename, - dirname, - isAbsolute, - join, - relative, - resolve, - sep, -} from "node:path"; +import { access, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; import { isCancel, text } from "@clack/prompts"; @@ -16,43 +8,28 @@ import { type GjiConfig, loadEffectiveConfig, resolveConfigString, - validateSyncDirPattern, } from "./config.js"; import { type PathConflictChoice, pathExists, promptForPathConflict, } from "./conflict.js"; -import { - type CloneDirectory, - type CloneDirResult, - cacheCloneFailure, - clearCloneFailure, - cloneDir, - directorySize, - isCloneDestinationExistsError, - isCloneFailureCached, -} from "./dir-clone.js"; +import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; -import { syncFiles } from "./file-sync.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; -import { extractHooks, runHook } from "./hooks.js"; -import { - type InstallPromptDependencies, - maybeRunInstallPrompt, -} from "./install-prompt.js"; +import type { InstallPromptDependencies } from "./install-prompt.js"; import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js"; -import { detectPackageManager } from "./package-manager.js"; import { detectRepository, resolveWorktreePath, validateBranchName, } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; +import { bootstrapWorktree, estimateSyncDirs } from "./worktree-bootstrap.js"; const execFileAsync = promisify(execFile); @@ -89,21 +66,6 @@ export interface NewCommandDependencies extends InstallPromptDependencies { spawnEditor: (cli: string, args: string[]) => Promise; } -export interface SyncDirEstimate { - bytes: number; - dir: string; -} - -interface ClonedDir extends SyncDirEstimate { - installSkipped: boolean; - ms: number; -} - -type SyncDirSource = - | { path: string; warning?: undefined } - | { path?: undefined; warning: string } - | null; - export function createNewCommand( dependencies: Partial = {}, ): (options: NewCommandOptions) => Promise { @@ -139,7 +101,10 @@ export function createNewCommand( options.json ? undefined : options.stderr, ); } catch (error) { - return emitNewError(options, toErrorMessage(error)); + return emitNewError( + options, + error instanceof Error ? error.message : String(error), + ); } const usesGeneratedDetachedName = options.detached && options.branch === undefined; @@ -276,10 +241,6 @@ export function createNewCommand( } } - const dryRunSyncDirs = options.dryRun - ? await estimateSyncDirs(repository.repoRoot, worktreePath, config) - : []; - if (options.dryRun) { if (options.take) { const changedFiles = await listTakeFiles(options.cwd); @@ -311,6 +272,11 @@ export function createNewCommand( } return 0; } + const dryRunSyncDirs = await estimateSyncDirs( + repository.repoRoot, + worktreePath, + config, + ); if (options.json) { const output: Record = { ...createNavigationTarget( @@ -455,54 +421,16 @@ export function createNewCommand( ); } - const clonedDirs = await syncConfiguredDirs( - repository.repoRoot, - worktreePath, - config, - options.stderr, - !!options.json, + const clonedDirs = await bootstrapWorktree({ + branch: worktreeName, cloneDirectory, - ); - - // Sync files from main worktree before afterCreate so synced files are available to install scripts. - const syncPatterns = Array.isArray(config.syncFiles) - ? (config.syncFiles as unknown[]).filter( - (p): p is string => typeof p === "string", - ) - : []; - for (const pattern of syncPatterns) { - try { - await syncFiles(repository.repoRoot, worktreePath, [pattern]); - } catch (error) { - options.stderr( - `Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`, - ); - } - } - - await maybeRunInstallPrompt( - worktreePath, - repository.repoRoot, config, - options.stderr, - dependencies, - !!options.json, - clonedDirs.some( - ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, - ), - ); - - const hooks = extractHooks(config); - await runHook( - hooks["after-create"], + json: options.json, + repoRoot: repository.repoRoot, + stderr: options.stderr, worktreePath, - { - branch: worktreeName, - path: worktreePath, - repo: basename(repository.repoRoot), - }, - options.stderr, - ); + installDependencies: dependencies, + }); if (options.json) { const navigation = createNavigationTarget( @@ -683,175 +611,6 @@ function applyConfiguredBranchPrefix( return `${branchPrefix}${branch}`; } -export async function estimateSyncDirs( - repoRoot: string, - worktreePath: string, - config: GjiConfig, -): Promise { - const estimates: SyncDirEstimate[] = []; - for (const directory of configuredSyncDirs(config)) { - if (await pathExists(join(worktreePath, directory))) continue; - - let source: SyncDirSource; - try { - source = await resolveSyncDirSource(repoRoot, directory); - } catch { - continue; - } - if (!source?.path) continue; - - try { - estimates.push({ - bytes: await directorySize(source.path), - dir: directory, - }); - } catch { - // A dry-run is informational; an unreadable source is omitted. - } - } - - return estimates; -} - -export async function syncConfiguredDirs( - repoRoot: string, - worktreePath: string, - config: GjiConfig, - stderr: (chunk: string) => void, - json: boolean, - cloneDirectory: CloneDirectory = cloneDir, -): Promise { - const directories = configuredSyncDirs(config); - if (directories.length === 0) return []; - - const isPnpm = directories.includes("node_modules") - ? (await detectPackageManager(repoRoot))?.name === "pnpm" - : false; - const cloned: ClonedDir[] = []; - - for (const directory of directories) { - const destination = join(worktreePath, directory); - if (await pathExists(destination)) continue; - - let source: SyncDirSource; - try { - source = await resolveSyncDirSource(repoRoot, directory); - } catch (error) { - stderr( - `syncDirs: could not inspect ${directory}: ${toErrorMessage(error)}, skipped ${directory}\n`, - ); - continue; - } - if (!source) continue; - if (!source.path) { - stderr(`syncDirs: ${source.warning}, skipped ${directory}\n`); - continue; - } - - if (await isCloneFailureCached(repoRoot, directory)) { - if (!json) { - stderr( - `syncDirs: previous copy-on-write failure cached, skipped ${directory}\n`, - ); - } - continue; - } - - let result: CloneDirResult; - try { - result = await cloneDirectory(source.path, destination); - } catch (error) { - if (isCloneDestinationExistsError(error)) continue; - - const reason = toErrorMessage(error); - await cacheCloneFailure(repoRoot, directory, reason); - stderr( - `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${directory}\n`, - ); - continue; - } - - await clearCloneFailure(repoRoot, directory); - let installSkipped = false; - if (directory === "node_modules" && isPnpm) { - try { - await unlink(join(destination, ".modules.yaml")); - installSkipped = true; - } catch (error) { - if (isNotFoundError(error)) { - installSkipped = true; - } else { - stderr( - `syncDirs: could not remove pnpm .modules.yaml: ${toErrorMessage(error)}\n`, - ); - } - } - } else if (directory === "node_modules") { - installSkipped = true; - } - - const clonedDir = { - bytes: result.bytes, - dir: directory, - installSkipped, - ms: result.ms, - }; - cloned.push(clonedDir); - if (!json) { - stderr( - `⚡ cloned ${directory} (${formatBytes(result.bytes)} → ${formatDuration(result.ms)})${installSkipped ? " — run install only if lockfile changed" : ""}\n`, - ); - } - } - - return cloned; -} - -function configuredSyncDirs(config: GjiConfig): string[] { - if (!Array.isArray(config.syncDirs)) return []; - - return config.syncDirs - .filter((value): value is string => typeof value === "string") - .map(validateSyncDirPattern); -} - -async function resolveSyncDirSource( - repoRoot: string, - directory: string, -): Promise { - const source = join(repoRoot, directory); - let resolvedSource: string; - try { - resolvedSource = await realpath(source); - } catch (error) { - if (isNotFoundError(error)) return null; - throw error; - } - - if (!isPathInside(repoRoot, resolvedSource)) { - return { - warning: `source symlink resolves outside the repository (${resolvedSource})`, - }; - } - - const stats = await lstat(resolvedSource); - if (!stats.isDirectory()) { - return { warning: "source is not a directory" }; - } - - return { path: resolvedSource }; -} - -function isPathInside(parent: string, child: string): boolean { - const relativePath = relative(resolve(parent), resolve(child)); - return ( - relativePath === "" || - (!isAbsolute(relativePath) && - relativePath !== ".." && - !relativePath.startsWith(`..${sep}`)) - ); -} - function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; @@ -864,22 +623,6 @@ function formatBytes(bytes: number): string { return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; } -function formatDuration(ms: number): string { - return `${(ms / 1000).toFixed(1)}s`; -} - -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - async function resolveUniqueDetachedWorktreePath( repoRoot: string, baseName: string, diff --git a/src/pr.test.ts b/src/pr.test.ts index 034e301..c21871a 100644 --- a/src/pr.test.ts +++ b/src/pr.test.ts @@ -554,6 +554,50 @@ describe("gji pr", () => { return repoRoot; } + it("reports syncDirs in PR dry-run text and JSON output", async () => { + // Given a PR repository with an available but untracked CoW source. + const repoRoot = await setupPrRepo("2018"); + await mkdir(join(repoRoot, "node_modules")); + await writeFile(join(repoRoot, "node_modules", "ready.txt"), "ready\n"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + const textOutput: string[] = []; + const jsonOutput: string[] = []; + const runPrCmd = createPrCommand(); + + // When gji pr performs text and JSON dry-runs. + const textResult = await runPrCmd({ + cwd: repoRoot, + dryRun: true, + number: "2018", + stderr: () => undefined, + stdout: (chunk) => textOutput.push(chunk), + }); + const jsonResult = await runPrCmd({ + cwd: repoRoot, + dryRun: true, + json: true, + number: "2018", + stderr: () => undefined, + stdout: (chunk) => jsonOutput.push(chunk), + }); + + // Then both output modes describe the clone without creating a worktree. + expect(textResult).toBe(0); + expect(textOutput.join("")).toContain("Would clone node_modules"); + expect(jsonResult).toBe(0); + expect(JSON.parse(jsonOutput.join(""))).toMatchObject({ + dryRun: true, + syncDirs: [{ dir: "node_modules" }], + }); + await expect( + pathExists(resolveWorktreePath(repoRoot, "pr/2018")), + ).resolves.toBe(false); + }); + it("clones syncDirs before the PR install prompt", async () => { // Given a PR repository with a configured node_modules directory. const repoRoot = await setupPrRepo("2017"); @@ -569,6 +613,7 @@ describe("gji pr", () => { "utf8", ); let promptCalled = false; + const stdout: string[] = []; const runPrCmd = createPrCommand({ cloneDir: async (_source, destination) => { await mkdir(destination, { recursive: true }); @@ -585,9 +630,10 @@ describe("gji pr", () => { // When gji pr creates the review worktree. const result = await runPrCmd({ cwd: repoRoot, + json: true, number: "2017", stderr: () => undefined, - stdout: () => undefined, + stdout: (chunk) => stdout.push(chunk), }); // Then the cloned directory is available and the install prompt is skipped. @@ -603,6 +649,11 @@ describe("gji pr", () => { "utf8", ), ).resolves.toBe("ready\n"); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + branch: "pr/2017", + path: resolveWorktreePath(repoRoot, "pr/2017"), + cloned: [{ dir: "node_modules", ms: 4 }], + }); }); it('runs install once and does not persist anything when "yes" is chosen', async () => { diff --git a/src/pr.ts b/src/pr.ts index e4d477f..03b6c9a 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { mkdir } from "node:fs/promises"; -import { basename, dirname } from "node:path"; +import { dirname } from "node:path"; import { promisify } from "node:util"; import { @@ -14,21 +14,16 @@ import { promptForPathConflict, } from "./conflict.js"; import type { CloneDirectory } from "./dir-clone.js"; -import { syncFiles } from "./file-sync.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; -import { extractHooks, runHook } from "./hooks.js"; -import { - type InstallPromptDependencies, - maybeRunInstallPrompt, -} from "./install-prompt.js"; +import type { InstallPromptDependencies } from "./install-prompt.js"; import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js"; -import { estimateSyncDirs, syncConfiguredDirs } from "./new.js"; import { detectRepository, resolveWorktreePath } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; +import { bootstrapWorktree, estimateSyncDirs } from "./worktree-bootstrap.js"; const execFileAsync = promisify(execFile); @@ -206,54 +201,16 @@ export function createPrCommand( await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot }); - const clonedDirs = await syncConfiguredDirs( - repository.repoRoot, - worktreePath, + const clonedDirs = await bootstrapWorktree({ + branch: branchName, + cloneDirectory: dependencies.cloneDir, config, - options.stderr, - !!options.json, - dependencies.cloneDir, - ); - - // Sync files from main worktree before afterCreate so synced files are available to install scripts. - const syncPatterns = Array.isArray(config.syncFiles) - ? (config.syncFiles as unknown[]).filter( - (p): p is string => typeof p === "string", - ) - : []; - for (const pattern of syncPatterns) { - try { - await syncFiles(repository.repoRoot, worktreePath, [pattern]); - } catch (error) { - options.stderr( - `Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`, - ); - } - } - - await maybeRunInstallPrompt( - worktreePath, - repository.repoRoot, - config, - options.stderr, - dependencies, - !!options.json, - clonedDirs.some( - ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, - ), - ); - - const hooks = extractHooks(config); - await runHook( - hooks["after-create"], + json: options.json, + repoRoot: repository.repoRoot, + stderr: options.stderr, worktreePath, - { - branch: branchName, - path: worktreePath, - repo: basename(repository.repoRoot), - }, - options.stderr, - ); + installDependencies: dependencies, + }); if (options.json) { const output: Record = { diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts new file mode 100644 index 0000000..c39c44a --- /dev/null +++ b/src/worktree-bootstrap.ts @@ -0,0 +1,316 @@ +import { lstat, realpath, unlink } from "node:fs/promises"; +import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; + +import type { GjiConfig } from "./config.js"; +import { validateSyncDirPattern } from "./config.js"; +import { + type CloneDirectory, + type CloneDirResult, + cacheCloneFailure, + clearCloneFailure, + cloneDir, + directorySize, + isCloneDestinationExistsError, + isCloneFailureCached, + isCloneUnsupportedError, +} from "./dir-clone.js"; +import { syncFiles } from "./file-sync.js"; +import { extractHooks, runHook } from "./hooks.js"; +import { + type InstallPromptDependencies, + maybeRunInstallPrompt, +} from "./install-prompt.js"; +import { detectPackageManager } from "./package-manager.js"; + +export interface SyncDirEstimate { + bytes: number; + dir: string; +} + +export interface ClonedDir extends SyncDirEstimate { + installSkipped: boolean; + ms: number; +} + +export interface WorktreeBootstrapOptions { + branch: string; + cloneDirectory?: CloneDirectory; + config: GjiConfig; + json?: boolean; + repoRoot: string; + stderr: (chunk: string) => void; + worktreePath: string; + installDependencies?: InstallPromptDependencies; +} + +export async function bootstrapWorktree( + options: WorktreeBootstrapOptions, +): Promise { + const clonedDirs = await syncConfiguredDirs( + options.repoRoot, + options.worktreePath, + options.config, + options.stderr, + !!options.json, + options.cloneDirectory, + ); + + const syncPatterns = Array.isArray(options.config.syncFiles) + ? (options.config.syncFiles as unknown[]).filter( + (pattern): pattern is string => typeof pattern === "string", + ) + : []; + for (const pattern of syncPatterns) { + try { + await syncFiles(options.repoRoot, options.worktreePath, [pattern]); + } catch (error) { + options.stderr( + `Warning: failed to sync file "${pattern}": ${toErrorMessage(error)}\n`, + ); + } + } + + await maybeRunInstallPrompt( + options.worktreePath, + options.repoRoot, + options.config, + options.stderr, + options.installDependencies, + !!options.json, + clonedDirs.some( + ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, + ), + ); + + const hooks = extractHooks(options.config); + await runHook( + hooks["after-create"], + options.worktreePath, + { + branch: options.branch, + path: options.worktreePath, + repo: basename(options.repoRoot), + }, + options.stderr, + ); + + return clonedDirs; +} + +export async function estimateSyncDirs( + repoRoot: string, + worktreePath: string, + config: GjiConfig, +): Promise { + const estimates: SyncDirEstimate[] = []; + for (const directory of configuredSyncDirs(config)) { + if (await pathExists(join(worktreePath, directory))) continue; + + let source: SyncDirSource; + try { + source = await resolveSyncDirSource(repoRoot, directory); + } catch { + continue; + } + if (!source?.path) continue; + + try { + estimates.push({ + bytes: await directorySize(source.path), + dir: directory, + }); + } catch { + // A dry-run is informational; an unreadable source is omitted. + } + } + + return estimates; +} + +export async function syncConfiguredDirs( + repoRoot: string, + worktreePath: string, + config: GjiConfig, + stderr: (chunk: string) => void, + json: boolean, + cloneDirectory: CloneDirectory = cloneDir, +): Promise { + const directories = configuredSyncDirs(config); + if (directories.length === 0) return []; + + const targetPackageManager = directories.includes("node_modules") + ? ((await detectPackageManager(worktreePath)) ?? + (await detectPackageManager(repoRoot))) + : null; + const isPnpm = targetPackageManager?.name === "pnpm"; + const cloned: ClonedDir[] = []; + + for (const directory of directories) { + const destination = join(worktreePath, directory); + if (await pathExists(destination)) continue; + + let source: SyncDirSource; + try { + source = await resolveSyncDirSource(repoRoot, directory); + } catch (error) { + stderr( + `syncDirs: could not inspect ${directory}: ${toErrorMessage(error)}, skipped ${directory}\n`, + ); + continue; + } + if (!source) continue; + if (!source.path) { + stderr(`syncDirs: ${source.warning}, skipped ${directory}\n`); + continue; + } + + if (await isCloneFailureCached(repoRoot, directory)) { + if (!json) { + stderr( + `syncDirs: previous copy-on-write failure cached, skipped ${directory}\n`, + ); + } + continue; + } + + let result: CloneDirResult; + try { + result = await cloneDirectory(source.path, destination); + } catch (error) { + if (isCloneDestinationExistsError(error)) continue; + + const reason = toErrorMessage(error); + if (isCloneUnsupportedError(error)) { + await cacheCloneFailure(repoRoot, directory, reason); + stderr( + `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${directory}\n`, + ); + } else { + stderr(`syncDirs: clone failed (${reason}), skipped ${directory}\n`); + } + continue; + } + + await clearCloneFailure(repoRoot, directory); + let installSkipped = false; + if (directory === "node_modules" && isPnpm) { + try { + await unlink(join(destination, ".modules.yaml")); + installSkipped = true; + } catch (error) { + if (isNotFoundError(error)) { + installSkipped = true; + } else { + stderr( + `syncDirs: could not remove pnpm .modules.yaml: ${toErrorMessage(error)}\n`, + ); + } + } + } else if (directory === "node_modules") { + installSkipped = true; + } + + const clonedDir = { + bytes: result.bytes, + dir: directory, + installSkipped, + ms: result.ms, + }; + cloned.push(clonedDir); + if (!json) { + stderr( + `⚡ cloned ${directory} (${formatBytes(result.bytes)} → ${formatDuration(result.ms)})${installSkipped ? " — run install only if lockfile changed" : ""}\n`, + ); + } + } + + return cloned; +} + +type SyncDirSource = + | { path: string; warning?: undefined } + | { path?: undefined; warning: string } + | null; + +function configuredSyncDirs(config: GjiConfig): string[] { + if (!Array.isArray(config.syncDirs)) return []; + + return config.syncDirs + .filter((value): value is string => typeof value === "string") + .map(validateSyncDirPattern); +} + +async function resolveSyncDirSource( + repoRoot: string, + directory: string, +): Promise { + const source = join(repoRoot, directory); + let resolvedSource: string; + try { + resolvedSource = await realpath(source); + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; + } + + if (!isPathInside(repoRoot, resolvedSource)) { + return { + warning: `source symlink resolves outside the repository (${resolvedSource})`, + }; + } + + const stats = await lstat(resolvedSource); + if (!stats.isDirectory()) { + return { warning: "source is not a directory" }; + } + + return { path: resolvedSource }; +} + +function isPathInside(parent: string, child: string): boolean { + const relativePath = relative(resolve(parent), resolve(child)); + return ( + relativePath === "" || + (!isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`)) + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + +function isNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From 0d468f18c0d9f01b41301719c727aa98687fe163 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 22:11:44 +0900 Subject: [PATCH 03/19] Fix overlapping syncDirs and clone size reporting --- src/dir-clone.test.ts | 20 +++++++++++++++ src/dir-clone.ts | 9 +------ src/new.test.ts | 51 +++++++++++++++++++++++++++++++++++++++ src/worktree-bootstrap.ts | 7 +++++- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index fab3418..38b9610 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -114,6 +114,26 @@ describe("cloneDir", () => { expect((await readdir(root)).sort()).toEqual(["source"]); }); + it("reports the logical source size in bytes", async () => { + // Given a source directory containing a file whose size is not a filesystem block multiple. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-size-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await writeFile(join(source, "data.bin"), "x".repeat(2000), "utf8"); + + // When cloneDir completes a successful copy-on-write clone. + const result = await cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => { + await mkdir(args.at(-1) as string); + }, + }); + + // Then the reported size is the exact logical byte count, not filesystem blocks. + expect(result.bytes).toBe(2000); + }); + it("does not invoke the copy command when the destination already exists", async () => { // Given an existing destination and a valid source directory. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 78595cf..5c9421d 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -247,15 +247,8 @@ export function isClonePlatformSupported(platform: NodeJS.Platform): boolean { } async function estimateCloneBytes(path: string): Promise { - const args = - process.platform === "darwin" - ? ["-A", "-B", "1", "-s", path] - : ["--apparent-size", "--block-size=1", "-s", path]; - try { - const { stdout } = await execFileAsync("du", args); - const bytes = Number.parseInt(stdout.trim().split(/\s+/u)[0] ?? "", 10); - return Number.isFinite(bytes) ? bytes : 0; + return await directorySize(path); } catch { return 0; } diff --git a/src/new.test.ts b/src/new.test.ts index 6bafd9a..03aac14 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -763,6 +763,57 @@ describe("gji new", () => { expect(stderr.join("")).toContain("cloned node_modules"); }); + it("clones overlapping syncDirs from the ancestor outward", async () => { + // Given nested configured directories whose ancestor contains both source entries. + const repoRoot = await createRepository(); + const branchName = "feature/cow-overlap"; + const sourceRoot = join(repoRoot, "foo"); + await mkdir(join(sourceRoot, "bar"), { recursive: true }); + await writeFile(join(sourceRoot, "sibling.txt"), "sibling\n", "utf8"); + await writeFile(join(sourceRoot, "bar", "leaf.txt"), "leaf\n", "utf8"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["foo/bar", "foo"] }), + "utf8", + ); + const cloneDirectories: string[] = []; + + // When gji new bootstraps the worktree with an injected CoW cloner. + const result = await createNewCommand({ + cloneDir: async (source, destination) => { + cloneDirectories.push(source.slice(repoRoot.length + 1)); + await mkdir(join(destination, "bar"), { recursive: true }); + await writeFile( + join(destination, "sibling.txt"), + "sibling\n", + "utf8", + ); + await writeFile( + join(destination, "bar", "leaf.txt"), + "leaf\n", + "utf8", + ); + return { bytes: 13, ms: 1 }; + }, + })({ + branch: branchName, + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the ancestor supplies the full tree without a partial nested clone. + expect(result).toBe(0); + expect(cloneDirectories).toEqual(["foo"]); + const worktreePath = resolveWorktreePath(repoRoot, branchName); + await expect( + readFile(join(worktreePath, "foo", "sibling.txt"), "utf8"), + ).resolves.toBe("sibling\n"); + await expect( + readFile(join(worktreePath, "foo", "bar", "leaf.txt"), "utf8"), + ).resolves.toBe("leaf\n"); + }); + it("removes pnpm metadata and skips the install prompt after cloning node_modules", async () => { // Given a pnpm repository whose cloned node_modules contains absolute-path metadata. const repoRoot = await createRepository(); diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index c39c44a..1ec80eb 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -235,9 +235,14 @@ type SyncDirSource = function configuredSyncDirs(config: GjiConfig): string[] { if (!Array.isArray(config.syncDirs)) return []; - return config.syncDirs + const directories = config.syncDirs .filter((value): value is string => typeof value === "string") .map(validateSyncDirPattern); + + return directories.sort( + (left, right) => + left.split(/[\\/]+/u).length - right.split(/[\\/]+/u).length, + ); } async function resolveSyncDirSource( From e9295f486514fc7bc8112e592a2abf2eabf23fe4 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 22:29:11 +0900 Subject: [PATCH 04/19] Refactor syncDirs into deep modules --- src/bootstrap-output.ts | 35 ++++ src/clone-failure-store.test.ts | 36 ++++ src/clone-failure-store.ts | 146 +++++++++++++++ src/config.ts | 64 ++++++- src/dir-clone.test.ts | 92 +++------- src/dir-clone.ts | 124 ++----------- src/new.test.ts | 5 +- src/new.ts | 17 +- src/package-manager.ts | 50 +++++- src/pr.ts | 19 +- src/sync-directories.ts | 193 ++++++++++++++++++++ src/sync-plan.ts | 177 +++++++++++++++++++ src/worktree-bootstrap.ts | 302 ++++---------------------------- 13 files changed, 794 insertions(+), 466 deletions(-) create mode 100644 src/bootstrap-output.ts create mode 100644 src/clone-failure-store.test.ts create mode 100644 src/clone-failure-store.ts create mode 100644 src/sync-directories.ts create mode 100644 src/sync-plan.ts diff --git a/src/bootstrap-output.ts b/src/bootstrap-output.ts new file mode 100644 index 0000000..6043e1d --- /dev/null +++ b/src/bootstrap-output.ts @@ -0,0 +1,35 @@ +import type { SyncDirectoryReporter } from "./sync-directories.js"; + +export function createBootstrapReporter( + write: (chunk: string) => void, + json: boolean, +): SyncDirectoryReporter { + return { + emitCachedFailureWarnings: !json, + measureCloneSize: !json, + write, + cloned: (directory) => { + if (json) return; + write( + `⚡ cloned ${directory.dir} (${formatBytes(directory.bytes)} → ${formatDuration(directory.ms)})${directory.installSkipped ? " — run install only if lockfile changed" : ""}\n`, + ); + }, + }; +} + +function formatBytes(bytes: number | undefined): string { + if (bytes === undefined) return "size unavailable"; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts new file mode 100644 index 0000000..b56dcf7 --- /dev/null +++ b/src/clone-failure-store.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { FileCloneFailureStore } from "./clone-failure-store.js"; + +const originalConfigDir = process.env.GJI_CONFIG_DIR; + +afterEach(() => { + if (originalConfigDir === undefined) delete process.env.GJI_CONFIG_DIR; + else process.env.GJI_CONFIG_DIR = originalConfigDir; +}); + +describe("FileCloneFailureStore", () => { + it("caches only explicitly failed directory names", async () => { + // Given an isolated state directory with one ordinary cached failure. + const root = await mkdtemp(join(tmpdir(), "gji-state-")); + process.env.GJI_CONFIG_DIR = root; + const store = new FileCloneFailureStore(); + await store.cache("/repo", "node_modules", "unsupported"); + + // When cache lookups use an inherited object-property name and the cached name. + const inheritedNameCached = await store.isCached("/repo", "constructor"); + const ordinaryNameCached = await store.isCached("/repo", "node_modules"); + + // Then only the explicitly cached directory is suppressed. + expect(inheritedNameCached).toBe(false); + expect(ordinaryNameCached).toBe(true); + await store.clear("/repo", "node_modules"); + await expect(readFile(join(root, "state.json"), "utf8")).resolves.toContain( + "syncDirs", + ); + }); +}); diff --git a/src/clone-failure-store.ts b/src/clone-failure-store.ts new file mode 100644 index 0000000..7b21c93 --- /dev/null +++ b/src/clone-failure-store.ts @@ -0,0 +1,146 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { GLOBAL_CONFIG_DIRECTORY } from "./config.js"; + +const STATE_FILE_NAME = "state.json"; +const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000; + +interface CloneFailure { + failedAt: number; + reason: string; +} + +interface CloneFailureState { + syncDirs?: Record>; + [key: string]: unknown; +} + +export interface CloneFailureStore { + isCached(repoRoot: string, directory: string): Promise; + cache(repoRoot: string, directory: string, reason: string): Promise; + clear(repoRoot: string, directory: string): Promise; +} + +export class FileCloneFailureStore implements CloneFailureStore { + private updateQueue = Promise.resolve(); + + async isCached(repoRoot: string, directory: string): Promise { + const state = await this.readState(); + const repoState = state.syncDirs?.[repoRoot]; + if (!repoState || !Object.hasOwn(repoState, directory)) return false; + + const failure = repoState[directory]; + return ( + isPlainObject(failure) && + typeof failure.failedAt === "number" && + Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS + ); + } + + async cache( + repoRoot: string, + directory: string, + reason: string, + ): Promise { + await this.update(async () => { + const state = await this.readState(); + const syncDirs = state.syncDirs ?? {}; + const repoState = syncDirs[repoRoot] ?? {}; + + syncDirs[repoRoot] = { + ...repoState, + [directory]: { failedAt: Date.now(), reason }, + }; + + await this.writeState({ ...state, syncDirs }); + }); + } + + async clear(repoRoot: string, directory: string): Promise { + await this.update(async () => { + const state = await this.readState(); + const repoState = state.syncDirs?.[repoRoot]; + if (!repoState || !Object.hasOwn(repoState, directory)) return; + + const nextRepoState = { ...repoState }; + delete nextRepoState[directory]; + const syncDirs = { ...state.syncDirs }; + if (Object.keys(nextRepoState).length === 0) delete syncDirs[repoRoot]; + else syncDirs[repoRoot] = nextRepoState; + + await this.writeState({ ...state, syncDirs }); + }); + } + + private async update(operation: () => Promise): Promise { + const next = this.updateQueue.then(operation, operation); + this.updateQueue = next.then( + () => undefined, + () => undefined, + ); + await next; + } + + private async readState(): Promise { + try { + const raw = await readFile(this.stateFilePath(), "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isPlainObject(parsed)) return {}; + return isPlainObject(parsed.syncDirs) + ? (parsed as CloneFailureState) + : { ...parsed, syncDirs: {} }; + } catch { + return {}; + } + } + + private async writeState(state: CloneFailureState): Promise { + try { + const path = this.stateFilePath(); + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryDirectory = await mkdtemp( + join(directory, `.gji-state-${randomUUID()}-`), + ); + const temporaryPath = join(temporaryDirectory, STATE_FILE_NAME); + try { + await writeFile( + temporaryPath, + `${JSON.stringify(state, null, 2)}\n`, + "utf8", + ); + await rename(temporaryPath, path); + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } + } catch { + // The cache is advisory and must never block worktree creation. + } + } + + private stateFilePath(home: string = homedir()): string { + const configuredDirectory = process.env.GJI_CONFIG_DIR; + const directory = configuredDirectory + ? resolve(configuredDirectory) + : join(home, GLOBAL_CONFIG_DIRECTORY); + + return join(directory, STATE_FILE_NAME); + } +} + +export const defaultCloneFailureStore: CloneFailureStore = + new FileCloneFailureStore(); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/config.ts b/src/config.ts index 156d0d7..d67f88e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,20 @@ export const KNOWN_GLOBAL_CONFIG_KEYS: ReadonlySet = new Set([ export type GjiConfig = Record; +export interface EffectiveGjiConfig extends GjiConfig { + branchPrefix?: string; + editor?: string; + hooks?: Record; + installSaveTarget?: string; + shellIntegration?: string; + skipInstallPrompt?: boolean; + syncDirs?: readonly string[]; + syncFiles?: readonly string[]; + syncDefaultBranch?: string; + syncRemote?: string; + worktreePath?: string; +} + export interface LoadedConfig { config: GjiConfig; exists: boolean; @@ -52,7 +66,7 @@ export async function loadEffectiveConfig( root: string, home: string = homedir(), onWarning?: (message: string) => void, -): Promise { +): Promise { const [globalConfig, localConfig] = await Promise.all([ loadGlobalConfig(home), loadConfig(root), @@ -133,7 +147,7 @@ export async function loadEffectiveConfig( merged.hooks = { ...globalHooks, ...perRepoHooks, ...localHooks }; } - return merged; + return toEffectiveConfig(merged); } export async function loadGlobalConfig( @@ -260,19 +274,25 @@ export function resolveConfigString( } export function validateSyncDirsConfig(value: unknown): void { + normalizeSyncDirsConfig(value); +} + +export function normalizeSyncDirsConfig( + value: unknown, +): readonly string[] | undefined { if (value === undefined) return; if (!Array.isArray(value)) { throw new Error("syncDirs: expected an array of relative directory paths"); } - for (const pattern of value) { + return value.map((pattern) => { if (typeof pattern !== "string") { throw new Error("syncDirs: every entry must be a string"); } - validateSyncDirPattern(pattern); - } + return validateSyncDirPattern(pattern); + }); } function validateConfigSyncDirs(config: GjiConfig): void { @@ -351,6 +371,40 @@ function mergeConfig(...values: Record[]): GjiConfig { ); } +function toEffectiveConfig(config: GjiConfig): EffectiveGjiConfig { + const effective = { ...config } as EffectiveGjiConfig; + for (const key of [ + "branchPrefix", + "editor", + "installSaveTarget", + "shellIntegration", + "syncDefaultBranch", + "syncRemote", + "worktreePath", + ]) { + if (effective[key] !== undefined && typeof effective[key] !== "string") { + delete effective[key]; + } + } + if ( + effective.skipInstallPrompt !== undefined && + typeof effective.skipInstallPrompt !== "boolean" + ) { + delete effective.skipInstallPrompt; + } + if (config.syncDirs !== undefined) { + effective.syncDirs = normalizeSyncDirsConfig(config.syncDirs); + } + if (Array.isArray(config.syncFiles)) { + effective.syncFiles = config.syncFiles.filter( + (value): value is string => typeof value === "string", + ); + } else delete effective.syncFiles; + if (!isPlainObject(config.hooks)) delete effective.hooks; + + return effective; +} + function findPerRepoConfig( repos: Record, repoRoot: string, diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 38b9610..3c3c99b 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -2,61 +2,14 @@ import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - cacheCloneFailure, - clearCloneFailure, cloneDir, - cloneStrategy, isCloneDestinationExistsError, - isCloneFailureCached, - isClonePlatformSupported, isCloneUnsupportedError, } from "./dir-clone.js"; -const originalConfigDir = process.env.GJI_CONFIG_DIR; - -afterEach(() => { - if (originalConfigDir === undefined) delete process.env.GJI_CONFIG_DIR; - else process.env.GJI_CONFIG_DIR = originalConfigDir; -}); - -describe("cloneStrategy", () => { - it("recognizes macOS as a native APFS clone platform", () => { - // Given the macOS platform. - // When platform support is checked. - const supported = isClonePlatformSupported("darwin"); - - // Then the native clonefile implementation is selected by cloneDir. - expect(supported).toBe(true); - expect(cloneStrategy("darwin")).toBeNull(); - }); - - it("selects mandatory reflinks on Linux", () => { - // Given the Linux platform. - // When the CoW strategy is selected. - const strategy = cloneStrategy("linux"); - - // Then it requires reflinks instead of allowing a normal copy. - expect(strategy?.("source", "destination")).toEqual([ - "-a", - "--reflink=always", - "source", - "destination", - ]); - }); - - it("rejects platforms without a CoW strategy", () => { - // Given an unsupported platform. - // When the CoW strategy is selected. - const strategy = cloneStrategy("win32"); - - // Then no ordinary-copy strategy is returned. - expect(strategy).toBeNull(); - }); -}); - describe("cloneDir", () => { it("atomically publishes a successful clone", async () => { // Given a source directory and a fake CoW command that creates its temporary output. @@ -134,6 +87,27 @@ describe("cloneDir", () => { expect(result.bytes).toBe(2000); }); + it("can omit the size traversal for machine-readable bootstrap", async () => { + // Given a source directory with a measurable file. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-no-size-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await writeFile(join(source, "data.bin"), "x".repeat(2000), "utf8"); + + // When cloneDir is asked to skip optional size reporting. + const result = await cloneDir(source, destination, { + measureBytes: false, + platform: "linux", + runCommand: async (_command, args) => { + await mkdir(args.at(-1) as string); + }, + }); + + // Then cloning succeeds without traversing the source for a byte estimate. + expect(result.bytes).toBeUndefined(); + }); + it("does not invoke the copy command when the destination already exists", async () => { // Given an existing destination and a valid source directory. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); @@ -193,26 +167,4 @@ describe("cloneDir", () => { ).resolves.toBe("{}\n"); } }); - - it("does not treat inherited names as cached failures", async () => { - // Given an isolated state directory with one ordinary cached failure. - const root = await mkdtemp(join(tmpdir(), "gji-state-")); - process.env.GJI_CONFIG_DIR = root; - await cacheCloneFailure("/repo", "node_modules", "unsupported"); - - // When cache lookups use a valid directory named like an object property. - const inheritedNameCached = await isCloneFailureCached( - "/repo", - "constructor", - ); - const ordinaryNameCached = await isCloneFailureCached( - "/repo", - "node_modules", - ); - - // Then only the explicitly cached directory is suppressed. - expect(inheritedNameCached).toBe(false); - expect(ordinaryNameCached).toBe(true); - await clearCloneFailure("/repo", "node_modules"); - }); }); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 5c9421d..ca7236c 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -6,41 +6,27 @@ import { mkdir, mkdtemp, readdir, - readFile, realpath, rename, rm, - writeFile, } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; -import { GLOBAL_CONFIG_DIRECTORY } from "./config.js"; - const execFileAsync = promisify(execFile); -const STATE_FILE_NAME = "state.json"; - -interface CloneFailure { - failedAt: number; - reason: string; -} - -const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000; const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000; const CLONE_LOCK_SUFFIX = ".gji-clone-lock"; -interface GjiState { - [key: string]: unknown; - syncDirs?: Record>; -} - export interface CloneDirResult { - bytes: number; + bytes?: number; ms: number; } -export interface CloneDirOptions { +export interface CloneRequestOptions { + measureBytes?: boolean; +} + +export interface CloneDirOptions extends CloneRequestOptions { platform?: NodeJS.Platform; runCommand?: (command: string, args: string[]) => Promise; copyDirectory?: (source: string, destination: string) => Promise; @@ -49,6 +35,7 @@ export interface CloneDirOptions { export type CloneDirectory = ( source: string, destination: string, + options?: CloneRequestOptions, ) => Promise; export async function cloneDir( @@ -117,7 +104,10 @@ export async function cloneDir( await rm(lockPath, { force: true, recursive: true }); } - const bytes = await estimateCloneBytes(sourcePath); + const bytes = + options.measureBytes === false + ? undefined + : await estimateCloneBytes(sourcePath); return { bytes, ms: Date.now() - startedAt }; } @@ -164,56 +154,6 @@ export function isCloneUnsupportedError(error: unknown): boolean { ); } -export async function isCloneFailureCached( - repoRoot: string, - directory: string, -): Promise { - const state = await readState(); - const repoState = state.syncDirs?.[repoRoot]; - if (!repoState || !Object.hasOwn(repoState, directory)) return false; - - const failure = repoState[directory]; - return ( - isPlainObject(failure) && - typeof failure.failedAt === "number" && - Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS - ); -} - -export async function cacheCloneFailure( - repoRoot: string, - directory: string, - reason: string, -): Promise { - const state = await readState(); - const syncDirs = state.syncDirs ?? {}; - const repoState = syncDirs[repoRoot] ?? {}; - - syncDirs[repoRoot] = { - ...repoState, - [directory]: { failedAt: Date.now(), reason }, - }; - - await writeState({ ...state, syncDirs }); -} - -export async function clearCloneFailure( - repoRoot: string, - directory: string, -): Promise { - const state = await readState(); - const repoState = state.syncDirs?.[repoRoot]; - if (!repoState || !Object.hasOwn(repoState, directory)) return; - - const nextRepoState = { ...repoState }; - delete nextRepoState[directory]; - const syncDirs = { ...state.syncDirs }; - if (Object.keys(nextRepoState).length === 0) delete syncDirs[repoRoot]; - else syncDirs[repoRoot] = nextRepoState; - - await writeState({ ...state, syncDirs }); -} - export async function directorySize(path: string): Promise { const stats = await lstat(path); if (!stats.isDirectory()) return stats.size; @@ -227,7 +167,7 @@ export async function directorySize(path: string): Promise { return total; } -export function cloneStrategy( +function cloneStrategy( platform: NodeJS.Platform, ): ((source: string, destination: string) => string[]) | null { if (platform === "linux") { @@ -242,7 +182,7 @@ export function cloneStrategy( return null; } -export function isClonePlatformSupported(platform: NodeJS.Platform): boolean { +function isClonePlatformSupported(platform: NodeJS.Platform): boolean { return platform === "darwin" || platform === "linux"; } @@ -265,38 +205,6 @@ async function runCloneCommand(command: string, args: string[]): Promise { } } -async function readState(): Promise { - try { - const raw = await readFile(stateFilePath(), "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!isPlainObject(parsed)) return {}; - return isPlainObject(parsed.syncDirs) - ? (parsed as GjiState) - : { ...parsed, syncDirs: {} }; - } catch { - return {}; - } -} - -async function writeState(state: GjiState): Promise { - try { - const path = stateFilePath(); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - } catch { - // The failure cache is advisory and must never block worktree creation. - } -} - -function stateFilePath(home: string = homedir()): string { - const configuredDirectory = process.env.GJI_CONFIG_DIR; - const directory = configuredDirectory - ? resolve(configuredDirectory) - : join(home, GLOBAL_CONFIG_DIRECTORY); - - return join(directory, STATE_FILE_NAME); -} - async function pathExists(path: string): Promise { try { await lstat(path); @@ -307,10 +215,6 @@ async function pathExists(path: string): Promise { } } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isNotFoundError(error: unknown): boolean { return ( error instanceof Error && diff --git a/src/new.test.ts b/src/new.test.ts index 03aac14..b3698b3 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -889,10 +889,12 @@ describe("gji new", () => { "utf8", ); const stdout: string[] = []; + let measureBytes: boolean | undefined; // When gji new runs in JSON mode with a successful cloner. const result = await createNewCommand({ - cloneDir: async (_source, destination) => { + cloneDir: async (_source, destination, options) => { + measureBytes = options?.measureBytes; await mkdir(destination, { recursive: true }); return { bytes: 42, ms: 7 }; }, @@ -906,6 +908,7 @@ describe("gji new", () => { // Then stdout remains one parseable JSON document with clone timing. expect(result).toBe(0); + expect(measureBytes).toBe(false); expect(JSON.parse(stdout.join(""))).toMatchObject({ cloned: [{ dir: "node_modules", ms: 7 }], }); diff --git a/src/new.ts b/src/new.ts index b7399e1..2f6ee66 100644 --- a/src/new.ts +++ b/src/new.ts @@ -3,9 +3,9 @@ import { access, mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; import { isCancel, text } from "@clack/prompts"; - +import { createBootstrapReporter } from "./bootstrap-output.js"; import { - type GjiConfig, + type EffectiveGjiConfig, loadEffectiveConfig, resolveConfigString, } from "./config.js"; @@ -29,7 +29,8 @@ import { validateBranchName, } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; -import { bootstrapWorktree, estimateSyncDirs } from "./worktree-bootstrap.js"; +import { estimateSyncDirectories } from "./sync-plan.js"; +import { bootstrapWorktree } from "./worktree-bootstrap.js"; const execFileAsync = promisify(execFile); @@ -93,7 +94,7 @@ export function createNewCommand( } const repository = await detectRepository(options.cwd); - let config: GjiConfig; + let config: EffectiveGjiConfig; try { config = await loadEffectiveConfig( repository.repoRoot, @@ -272,10 +273,10 @@ export function createNewCommand( } return 0; } - const dryRunSyncDirs = await estimateSyncDirs( + const dryRunSyncDirs = await estimateSyncDirectories( repository.repoRoot, worktreePath, - config, + config.syncDirs ?? [], ); if (options.json) { const output: Record = { @@ -425,9 +426,9 @@ export function createNewCommand( branch: worktreeName, cloneDirectory, config, - json: options.json, + nonInteractive: !!options.json, repoRoot: repository.repoRoot, - stderr: options.stderr, + reporter: createBootstrapReporter(options.stderr, !!options.json), worktreePath, installDependencies: dependencies, }); diff --git a/src/package-manager.ts b/src/package-manager.ts index 581ed23..022ed1b 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -1,6 +1,8 @@ -import { access, readdir } from "node:fs/promises"; +import { access, readdir, unlink } from "node:fs/promises"; import { join } from "node:path"; +import type { SyncDirectoryPostProcessor } from "./sync-directories.js"; + export interface PackageManager { name: string; installCommand: string; @@ -147,6 +149,40 @@ export async function detectPackageManager( return null; } +export async function createDependencyClonePostProcessor( + worktreePath: string, + repoRoot: string, + directories: readonly string[], +): Promise { + if (!directories.includes("node_modules")) return undefined; + + const packageManager = + (await detectPackageManager(worktreePath)) ?? + (await detectPackageManager(repoRoot)); + + return { + async postProcess(directory, destination) { + if (directory !== "node_modules") return undefined; + + if (packageManager?.name === "pnpm") { + try { + await unlink(join(destination, ".modules.yaml")); + } catch (error) { + if (isNotFoundError(error)) { + return { installSkipped: true }; + } + return { + installSkipped: false, + warning: `could not remove pnpm .modules.yaml: ${toErrorMessage(error)}`, + }; + } + } + + return { installSkipped: true }; + }, + }; +} + async function matchesExact( repoRoot: string, signals: string[], @@ -180,6 +216,18 @@ async function matchesGlob( return files.some((file) => regexes.some((re) => re.test(file))); } +function isNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function patternToRegex(pattern: string): RegExp { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, "\\$&") diff --git a/src/pr.ts b/src/pr.ts index 03b6c9a..a3891ca 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -2,9 +2,9 @@ import { execFile } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { promisify } from "node:util"; - +import { createBootstrapReporter } from "./bootstrap-output.js"; import { - type GjiConfig, + type EffectiveGjiConfig, loadEffectiveConfig, resolveConfigString, } from "./config.js"; @@ -23,7 +23,8 @@ import { } from "./navigation-output.js"; import { detectRepository, resolveWorktreePath } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; -import { bootstrapWorktree, estimateSyncDirs } from "./worktree-bootstrap.js"; +import { estimateSyncDirectories } from "./sync-plan.js"; +import { bootstrapWorktree } from "./worktree-bootstrap.js"; const execFileAsync = promisify(execFile); @@ -83,7 +84,7 @@ export function createPrCommand( } const repository = await detectRepository(options.cwd); - let config: GjiConfig; + let config: EffectiveGjiConfig; try { config = await loadEffectiveConfig( repository.repoRoot, @@ -143,7 +144,11 @@ export function createPrCommand( } const dryRunSyncDirs = options.dryRun - ? await estimateSyncDirs(repository.repoRoot, worktreePath, config) + ? await estimateSyncDirectories( + repository.repoRoot, + worktreePath, + config.syncDirs ?? [], + ) : []; if (options.dryRun) { @@ -205,9 +210,9 @@ export function createPrCommand( branch: branchName, cloneDirectory: dependencies.cloneDir, config, - json: options.json, + nonInteractive: !!options.json, repoRoot: repository.repoRoot, - stderr: options.stderr, + reporter: createBootstrapReporter(options.stderr, !!options.json), worktreePath, installDependencies: dependencies, }); diff --git a/src/sync-directories.ts b/src/sync-directories.ts new file mode 100644 index 0000000..3b45eb4 --- /dev/null +++ b/src/sync-directories.ts @@ -0,0 +1,193 @@ +import { lstat } from "node:fs/promises"; +import { + type CloneFailureStore, + defaultCloneFailureStore, +} from "./clone-failure-store.js"; +import type { + CloneDirectory, + CloneDirResult, + CloneRequestOptions, +} from "./dir-clone.js"; +import { + isCloneDestinationExistsError, + isCloneUnsupportedError, +} from "./dir-clone.js"; +import type { SyncDirectoryPlan } from "./sync-plan.js"; + +export interface ClonedDirectory { + bytes?: number; + dir: string; + installSkipped: boolean; + ms: number; +} + +export type SyncDirectoryOutcome = + | { kind: "cloned"; directory: ClonedDirectory } + | { kind: "skipped"; dir: string; reason: string }; + +export interface SyncDirectoryPostProcessor { + postProcess( + directory: string, + destination: string, + ): Promise<{ installSkipped: boolean; warning?: string } | undefined>; +} + +export interface SyncDirectoryReporter { + readonly emitCachedFailureWarnings: boolean; + readonly measureCloneSize: boolean; + write(message: string): void; + cloned(directory: ClonedDirectory): void; +} + +export interface SyncDirectoryExecutionOptions { + cloneDirectory: CloneDirectory; + failureStore?: CloneFailureStore; + postProcessor?: SyncDirectoryPostProcessor; + repoRoot: string; + reporter: SyncDirectoryReporter; +} + +export async function executeSyncDirectoryPlan( + plan: readonly SyncDirectoryPlan[], + options: SyncDirectoryExecutionOptions, +): Promise { + const failureStore = options.failureStore ?? defaultCloneFailureStore; + const outcomes: SyncDirectoryOutcome[] = []; + + for (const entry of plan) { + if (await pathExists(entry.destination)) { + outcomes.push({ + kind: "skipped", + dir: entry.directory, + reason: "destination already exists", + }); + continue; + } + + if (entry.warning) { + options.reporter.write( + `syncDirs: ${entry.warning}, skipped ${entry.directory}\n`, + ); + outcomes.push({ + kind: "skipped", + dir: entry.directory, + reason: entry.warning, + }); + continue; + } + if (!entry.source) { + outcomes.push({ + kind: "skipped", + dir: entry.directory, + reason: "source does not exist", + }); + continue; + } + + if (await failureStore.isCached(options.repoRoot, entry.directory)) { + if (options.reporter.emitCachedFailureWarnings) { + options.reporter.write( + `syncDirs: previous copy-on-write failure cached, skipped ${entry.directory}\n`, + ); + } + outcomes.push({ + kind: "skipped", + dir: entry.directory, + reason: "copy-on-write failure cached", + }); + continue; + } + + let result: CloneDirResult; + try { + const cloneOptions: CloneRequestOptions = { + measureBytes: options.reporter.measureCloneSize, + }; + result = await options.cloneDirectory( + entry.source, + entry.destination, + cloneOptions, + ); + } catch (error) { + if (isCloneDestinationExistsError(error)) { + outcomes.push({ + kind: "skipped", + dir: entry.directory, + reason: "destination already exists", + }); + continue; + } + + const reason = toErrorMessage(error); + if (isCloneUnsupportedError(error)) { + await failureStore.cache(options.repoRoot, entry.directory, reason); + options.reporter.write( + `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${entry.directory}\n`, + ); + } else { + options.reporter.write( + `syncDirs: clone failed (${reason}), skipped ${entry.directory}\n`, + ); + } + outcomes.push({ + kind: "skipped", + dir: entry.directory, + reason, + }); + continue; + } + + await failureStore.clear(options.repoRoot, entry.directory); + let installSkipped = false; + if (options.postProcessor) { + try { + const postProcess = await options.postProcessor.postProcess( + entry.directory, + entry.destination, + ); + installSkipped = postProcess?.installSkipped ?? false; + if (postProcess?.warning) { + options.reporter.write(`syncDirs: ${postProcess.warning}\n`); + } + } catch (error) { + options.reporter.write( + `syncDirs: post-processing failed (${toErrorMessage(error)})\n`, + ); + } + } + + const clonedDirectory: ClonedDirectory = { + bytes: result.bytes, + dir: entry.directory, + installSkipped, + ms: result.ms, + }; + const outcome = { kind: "cloned" as const, directory: clonedDirectory }; + outcomes.push(outcome); + options.reporter.cloned(clonedDirectory); + } + + return outcomes; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + +function isNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/sync-plan.ts b/src/sync-plan.ts new file mode 100644 index 0000000..da92345 --- /dev/null +++ b/src/sync-plan.ts @@ -0,0 +1,177 @@ +import { lstat, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { validateSyncDirPattern } from "./config.js"; +import { directorySize } from "./dir-clone.js"; + +export interface SyncDirectoryPlan { + directory: string; + destination: string; + destinationExists: boolean; + source?: string; + warning?: string; +} + +export interface SyncDirectoryEstimate { + bytes: number; + dir: string; +} + +export async function prepareSyncDirectoryPlan( + repoRoot: string, + worktreePath: string, + directories: readonly string[], +): Promise { + const normalizedDirectories = directories + .map(validateSyncDirPattern) + .sort(directoryDepthAscending); + const plan: SyncDirectoryPlan[] = []; + + for (const directory of normalizedDirectories) { + const destination = join(worktreePath, directory); + const destinationExists = await pathExists(destination); + try { + const source = await resolveSyncDirectorySource(repoRoot, directory); + if (!source) { + plan.push({ directory, destination, destinationExists }); + } else if ("warning" in source) { + plan.push({ + directory, + destination, + destinationExists, + warning: source.warning, + }); + } else { + plan.push({ + directory, + destination, + destinationExists, + source: source.path, + }); + } + } catch (error) { + plan.push({ + directory, + destination, + destinationExists, + warning: `could not inspect ${directory}: ${toErrorMessage(error)}`, + }); + } + } + + return plan; +} + +export async function estimateSyncDirectoryPlan( + plan: readonly SyncDirectoryPlan[], +): Promise { + const estimates: SyncDirectoryEstimate[] = []; + for (const entry of plan) { + if ( + entry.destinationExists || + !entry.source || + entry.warning || + isCoveredByCloneAncestor(entry, plan) + ) { + continue; + } + + try { + estimates.push({ + bytes: await directorySize(entry.source), + dir: entry.directory, + }); + } catch { + // A dry-run is informational; an unreadable source is omitted. + } + } + + return estimates; +} + +export async function estimateSyncDirectories( + repoRoot: string, + worktreePath: string, + directories: readonly string[], +): Promise { + const plan = await prepareSyncDirectoryPlan( + repoRoot, + worktreePath, + directories, + ); + return estimateSyncDirectoryPlan(plan); +} + +async function resolveSyncDirectorySource( + repoRoot: string, + directory: string, +): Promise<{ path: string } | { warning: string } | null> { + const source = join(repoRoot, directory); + let resolvedSource: string; + try { + resolvedSource = await realpath(source); + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; + } + + if (!isPathInside(repoRoot, resolvedSource)) { + return { + warning: `source symlink resolves outside the repository (${resolvedSource})`, + }; + } + + const stats = await lstat(resolvedSource); + if (!stats.isDirectory()) return { warning: "source is not a directory" }; + + return { path: resolvedSource }; +} + +function isCoveredByCloneAncestor( + entry: SyncDirectoryPlan, + plan: readonly SyncDirectoryPlan[], +): boolean { + return plan.some( + (candidate) => + candidate !== entry && + !candidate.destinationExists && + !!candidate.source && + isPathInside(candidate.directory, entry.directory), + ); +} + +function directoryDepthAscending(left: string, right: string): number { + return left.split(/[\\/]+/u).length - right.split(/[\\/]+/u).length; +} + +function isPathInside(parent: string, child: string): boolean { + const relativePath = relative(resolve(parent), resolve(child)); + return ( + relativePath !== "" && + !isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`) + ); +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + +function isNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 1ec80eb..90ee63e 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -1,70 +1,63 @@ -import { lstat, realpath, unlink } from "node:fs/promises"; -import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename } from "node:path"; -import type { GjiConfig } from "./config.js"; -import { validateSyncDirPattern } from "./config.js"; -import { - type CloneDirectory, - type CloneDirResult, - cacheCloneFailure, - clearCloneFailure, - cloneDir, - directorySize, - isCloneDestinationExistsError, - isCloneFailureCached, - isCloneUnsupportedError, -} from "./dir-clone.js"; +import type { EffectiveGjiConfig } from "./config.js"; +import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { syncFiles } from "./file-sync.js"; import { extractHooks, runHook } from "./hooks.js"; import { type InstallPromptDependencies, maybeRunInstallPrompt, } from "./install-prompt.js"; -import { detectPackageManager } from "./package-manager.js"; - -export interface SyncDirEstimate { - bytes: number; - dir: string; -} +import { createDependencyClonePostProcessor } from "./package-manager.js"; +import { + type ClonedDirectory, + executeSyncDirectoryPlan, + type SyncDirectoryReporter, +} from "./sync-directories.js"; +import { prepareSyncDirectoryPlan } from "./sync-plan.js"; -export interface ClonedDir extends SyncDirEstimate { - installSkipped: boolean; - ms: number; -} +export type { ClonedDirectory as ClonedDir } from "./sync-directories.js"; export interface WorktreeBootstrapOptions { branch: string; cloneDirectory?: CloneDirectory; - config: GjiConfig; - json?: boolean; + config: EffectiveGjiConfig; + installDependencies?: InstallPromptDependencies; + nonInteractive: boolean; repoRoot: string; - stderr: (chunk: string) => void; + reporter: SyncDirectoryReporter; worktreePath: string; - installDependencies?: InstallPromptDependencies; } export async function bootstrapWorktree( options: WorktreeBootstrapOptions, -): Promise { - const clonedDirs = await syncConfiguredDirs( +): Promise { + const directories = options.config.syncDirs ?? []; + const plan = await prepareSyncDirectoryPlan( options.repoRoot, options.worktreePath, - options.config, - options.stderr, - !!options.json, - options.cloneDirectory, + directories, + ); + const postProcessor = await createDependencyClonePostProcessor( + options.worktreePath, + options.repoRoot, + directories, + ); + const outcomes = await executeSyncDirectoryPlan(plan, { + cloneDirectory: options.cloneDirectory ?? cloneDir, + postProcessor, + repoRoot: options.repoRoot, + reporter: options.reporter, + }); + const clonedDirs = outcomes.flatMap((outcome) => + outcome.kind === "cloned" ? [outcome.directory] : [], ); - const syncPatterns = Array.isArray(options.config.syncFiles) - ? (options.config.syncFiles as unknown[]).filter( - (pattern): pattern is string => typeof pattern === "string", - ) - : []; - for (const pattern of syncPatterns) { + for (const pattern of options.config.syncFiles ?? []) { try { await syncFiles(options.repoRoot, options.worktreePath, [pattern]); } catch (error) { - options.stderr( + options.reporter.write( `Warning: failed to sync file "${pattern}": ${toErrorMessage(error)}\n`, ); } @@ -74,9 +67,9 @@ export async function bootstrapWorktree( options.worktreePath, options.repoRoot, options.config, - options.stderr, + options.reporter.write, options.installDependencies, - !!options.json, + options.nonInteractive, clonedDirs.some( ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, ), @@ -91,231 +84,12 @@ export async function bootstrapWorktree( path: options.worktreePath, repo: basename(options.repoRoot), }, - options.stderr, + options.reporter.write, ); return clonedDirs; } -export async function estimateSyncDirs( - repoRoot: string, - worktreePath: string, - config: GjiConfig, -): Promise { - const estimates: SyncDirEstimate[] = []; - for (const directory of configuredSyncDirs(config)) { - if (await pathExists(join(worktreePath, directory))) continue; - - let source: SyncDirSource; - try { - source = await resolveSyncDirSource(repoRoot, directory); - } catch { - continue; - } - if (!source?.path) continue; - - try { - estimates.push({ - bytes: await directorySize(source.path), - dir: directory, - }); - } catch { - // A dry-run is informational; an unreadable source is omitted. - } - } - - return estimates; -} - -export async function syncConfiguredDirs( - repoRoot: string, - worktreePath: string, - config: GjiConfig, - stderr: (chunk: string) => void, - json: boolean, - cloneDirectory: CloneDirectory = cloneDir, -): Promise { - const directories = configuredSyncDirs(config); - if (directories.length === 0) return []; - - const targetPackageManager = directories.includes("node_modules") - ? ((await detectPackageManager(worktreePath)) ?? - (await detectPackageManager(repoRoot))) - : null; - const isPnpm = targetPackageManager?.name === "pnpm"; - const cloned: ClonedDir[] = []; - - for (const directory of directories) { - const destination = join(worktreePath, directory); - if (await pathExists(destination)) continue; - - let source: SyncDirSource; - try { - source = await resolveSyncDirSource(repoRoot, directory); - } catch (error) { - stderr( - `syncDirs: could not inspect ${directory}: ${toErrorMessage(error)}, skipped ${directory}\n`, - ); - continue; - } - if (!source) continue; - if (!source.path) { - stderr(`syncDirs: ${source.warning}, skipped ${directory}\n`); - continue; - } - - if (await isCloneFailureCached(repoRoot, directory)) { - if (!json) { - stderr( - `syncDirs: previous copy-on-write failure cached, skipped ${directory}\n`, - ); - } - continue; - } - - let result: CloneDirResult; - try { - result = await cloneDirectory(source.path, destination); - } catch (error) { - if (isCloneDestinationExistsError(error)) continue; - - const reason = toErrorMessage(error); - if (isCloneUnsupportedError(error)) { - await cacheCloneFailure(repoRoot, directory, reason); - stderr( - `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${directory}\n`, - ); - } else { - stderr(`syncDirs: clone failed (${reason}), skipped ${directory}\n`); - } - continue; - } - - await clearCloneFailure(repoRoot, directory); - let installSkipped = false; - if (directory === "node_modules" && isPnpm) { - try { - await unlink(join(destination, ".modules.yaml")); - installSkipped = true; - } catch (error) { - if (isNotFoundError(error)) { - installSkipped = true; - } else { - stderr( - `syncDirs: could not remove pnpm .modules.yaml: ${toErrorMessage(error)}\n`, - ); - } - } - } else if (directory === "node_modules") { - installSkipped = true; - } - - const clonedDir = { - bytes: result.bytes, - dir: directory, - installSkipped, - ms: result.ms, - }; - cloned.push(clonedDir); - if (!json) { - stderr( - `⚡ cloned ${directory} (${formatBytes(result.bytes)} → ${formatDuration(result.ms)})${installSkipped ? " — run install only if lockfile changed" : ""}\n`, - ); - } - } - - return cloned; -} - -type SyncDirSource = - | { path: string; warning?: undefined } - | { path?: undefined; warning: string } - | null; - -function configuredSyncDirs(config: GjiConfig): string[] { - if (!Array.isArray(config.syncDirs)) return []; - - const directories = config.syncDirs - .filter((value): value is string => typeof value === "string") - .map(validateSyncDirPattern); - - return directories.sort( - (left, right) => - left.split(/[\\/]+/u).length - right.split(/[\\/]+/u).length, - ); -} - -async function resolveSyncDirSource( - repoRoot: string, - directory: string, -): Promise { - const source = join(repoRoot, directory); - let resolvedSource: string; - try { - resolvedSource = await realpath(source); - } catch (error) { - if (isNotFoundError(error)) return null; - throw error; - } - - if (!isPathInside(repoRoot, resolvedSource)) { - return { - warning: `source symlink resolves outside the repository (${resolvedSource})`, - }; - } - - const stats = await lstat(resolvedSource); - if (!stats.isDirectory()) { - return { warning: "source is not a directory" }; - } - - return { path: resolvedSource }; -} - -function isPathInside(parent: string, child: string): boolean { - const relativePath = relative(resolve(parent), resolve(child)); - return ( - relativePath === "" || - (!isAbsolute(relativePath) && - relativePath !== ".." && - !relativePath.startsWith(`..${sep}`)) - ); -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"]; - let value = bytes; - let unit = -1; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; -} - -function formatDuration(ms: number): string { - return `${(ms / 1000).toFixed(1)}s`; -} - -async function pathExists(path: string): Promise { - try { - await lstat(path); - return true; - } catch (error) { - if (isNotFoundError(error)) return false; - throw error; - } -} - -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } From 1dcd0840acc7a4c3347c8938db4395ecca73fc1c Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 22:59:03 +0900 Subject: [PATCH 05/19] Separate dependency bootstrap from syncDirs --- README.md | 26 +- scripts/generate-man.mjs | 3 + src/bootstrap-output.ts | 7 +- src/completion.test.ts | 2 +- src/config.test.ts | 24 ++ src/config.ts | 40 +- src/dependency-bootstrap.test.ts | 422 +++++++++++++++++++++ src/dependency-bootstrap.ts | 616 +++++++++++++++++++++++++++++++ src/install-prompt.ts | 7 +- src/new.test.ts | 170 ++++++++- src/new.ts | 60 ++- src/package-manager.ts | 50 +-- src/pr.ts | 70 +++- src/sync-directories.ts | 41 +- src/worktree-bootstrap.ts | 77 ++-- website/docs/commands.mdx | 2 +- website/docs/configuration.mdx | 10 +- website/docs/installation.mdx | 5 +- 18 files changed, 1483 insertions(+), 149 deletions(-) create mode 100644 src/dependency-bootstrap.test.ts create mode 100644 src/dependency-bootstrap.ts diff --git a/README.md b/README.md index e687474..5199b67 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,8 @@ No setup required. Optional config lives in: | `syncRemote` | remote for `gji sync` (default: `origin`) | | `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) | | `syncFiles` | files to copy from main worktree into each new worktree; use global per-repo config for private files | -| `syncDirs` | directories to clone with filesystem copy-on-write before sync files, installs, and hooks | +| `syncDirs` | arbitrary directories to clone with filesystem copy-on-write before sync files | +| `dependencyBootstrap` | dependency/build-state policy: `off`, `cow-then-repair`, or `install-only` | | `skipInstallPrompt` | `true` to disable the auto-install prompt permanently | | `installSaveTarget` | `"local"` or `"global"` — where **Always**/**Never** choices are persisted (default: `"local"`); set during `gji init --write` | | `hooks` | lifecycle scripts (see [Hooks](#hooks)) | @@ -300,7 +301,8 @@ No setup required. Optional config lives in: "syncRemote": "upstream", "syncDefaultBranch": "main", "syncFiles": [".env.example", ".nvmrc"], - "syncDirs": ["node_modules", ".next"] + "syncDirs": [".next"], + "dependencyBootstrap": "cow-then-repair" } ``` @@ -338,17 +340,27 @@ Use `syncDirs` for large, reproducible directories that should be available imme } ``` -`gji new` clones these directories with APFS copy-on-write on macOS or mandatory reflinks on Linux, before `syncFiles`, install prompts, and `afterCreate` hooks. It never falls back to a slow ordinary copy. Unsupported filesystems, external symlink targets, missing sources, and existing destinations are skipped safely; failed CoW attempts are cached in `~/.config/gji/state.json` so repeated worktree creation does not keep waiting on the same unsupported filesystem. +`gji new` clones these directories with APFS copy-on-write on macOS or mandatory reflinks on Linux, before `syncFiles`. It never falls back to a slow ordinary copy. Unsupported filesystems, external symlink targets, missing sources, and existing destinations are skipped safely; failed CoW attempts are cached in `~/.config/gji/state.json` so repeated worktree creation does not keep waiting on the same unsupported filesystem. `syncDirs` is generic: it does not know or special-case package managers. -Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. For pnpm projects, the cloned `node_modules/.modules.yaml` is removed because pnpm recreates it for the new worktree. +Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. The human output includes the cloned size and elapsed time: ```text -⚡ cloned node_modules (2.1 GB → 1.2s) — run install only if lockfile changed +⚡ cloned .next (2.1 GB → 1.2s) +``` + +Use `dependencyBootstrap` when a package manager or build cache needs a reusable seed followed by authoritative repair: + +```json +{ + "dependencyBootstrap": "cow-then-repair" +} ``` -Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array with each directory and its clone time. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. +`cow-then-repair` supports Yarn (`--immutable`), pnpm (`--frozen-lockfile`), uv (`--locked`), and Cargo (`cargo check`). npm uses install-only `npm ci` because it can delete an existing dependency tree. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`. A successful dependency seed is reported as `reused and repaired`, not as an install skip. + +Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array and structured `dependencyBootstrap` events. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. ### Per-repo overrides in global config @@ -468,7 +480,7 @@ Run `pnpm install` in the new worktree? **Always** saves `hooks.afterCreate`; **Never** writes `skipInstallPrompt: true`. Where they are saved depends on `installSaveTarget` (see [Available keys](#available-keys)) — defaults to `.gji.json`. -When `syncDirs` successfully clones `node_modules`, the install prompt is skipped because dependencies are already present. Run the package manager manually if the lockfile changed. +`syncDirs` never suppresses the install prompt. To automate dependency setup, opt into `dependencyBootstrap: "cow-then-repair"`; the adapter always runs its lockfile/build repair after `syncFiles`. ## JSON output diff --git a/scripts/generate-man.mjs b/scripts/generate-man.mjs index e75673c..f89d8b6 100644 --- a/scripts/generate-man.mjs +++ b/scripts/generate-man.mjs @@ -131,6 +131,9 @@ function subcommandManPage(cmd) { out += `.SH SYNOPSIS\n${synopsisTokens.join(" ")}\n`; out += `.SH DESCRIPTION\n${esc(desc)}\n`; + if (cmd.name() === "new" || cmd.name() === "pr") { + out += `.SH BOOTSTRAP\n${esc("syncDirs performs generic copy-on-write directory seeding before syncFiles. It never falls back to ordinary copying and never suppresses installation prompts. Set dependencyBootstrap to off, cow-then-repair, or install-only to enable deterministic package-manager or build-cache repair. The lifecycle is CoW seed, syncFiles, repair or install, then after-create. CoW failures fall back to repair from an empty target; partial clones are removed and existing targets are never deleted.\n")}\n`; + } out += optionsSection(cmd); diff --git a/src/bootstrap-output.ts b/src/bootstrap-output.ts index 6043e1d..424e242 100644 --- a/src/bootstrap-output.ts +++ b/src/bootstrap-output.ts @@ -11,9 +11,14 @@ export function createBootstrapReporter( cloned: (directory) => { if (json) return; write( - `⚡ cloned ${directory.dir} (${formatBytes(directory.bytes)} → ${formatDuration(directory.ms)})${directory.installSkipped ? " — run install only if lockfile changed" : ""}\n`, + `⚡ cloned ${directory.dir} (${formatBytes(directory.bytes)} → ${formatDuration(directory.ms)})\n`, ); }, + dependency: (event) => { + if (json) return; + const target = event.target ? ` ${event.target}` : ""; + write(`gji: ${event.state}${target} — ${event.message}\n`); + }, }; } diff --git a/src/completion.test.ts b/src/completion.test.ts index 387ce6a..da7c7d6 100644 --- a/src/completion.test.ts +++ b/src/completion.test.ts @@ -95,7 +95,7 @@ describe("gji completion", () => { expect(stdout.join("")).toContain('branch = ($1 == "*" ? $2 : $1)'); expect(stdout.join("")).toContain("_values 'config action' get set unset"); expect(stdout.join("")).toContain( - "_values 'config key' branchPrefix editor hooks installSaveTarget shellIntegration skipInstallPrompt syncDirs syncDefaultBranch syncFiles syncRemote worktreePath repos", + "_values 'config key' branchPrefix dependencyBootstrap editor hooks installSaveTarget shellIntegration skipInstallPrompt syncDirs syncDefaultBranch syncFiles syncRemote worktreePath repos", ); expect(stdout.join("")).toContain(`case "\${words[3]}" in`); expect(stdout.join("")).toContain("get|unset)"); diff --git a/src/config.test.ts b/src/config.test.ts index bee8201..dd271e1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,6 +11,7 @@ import { KNOWN_CONFIG_KEYS, loadConfig, loadEffectiveConfig, + normalizeDependencyBootstrap, resolveConfigString, saveLocalConfig, updateGlobalRepoConfigKey, @@ -74,6 +75,28 @@ describe("syncDirs validation", () => { }); }); +describe("dependencyBootstrap validation", () => { + it.each(["auto", "copy", true])("rejects unsupported mode %s", (mode) => { + // Given a dependency bootstrap value outside the supported explicit modes. + // When the value is normalized. + // Then configuration validation rejects the ambiguous or malformed mode. + expect(() => normalizeDependencyBootstrap(mode)).toThrow( + "dependencyBootstrap", + ); + }); + + it("accepts off, cow-then-repair, and install-only", () => { + // Given the three supported dependency bootstrap policies. + // When each policy is normalized. + // Then it is preserved exactly for deterministic configuration behavior. + expect(normalizeDependencyBootstrap("off")).toBe("off"); + expect(normalizeDependencyBootstrap("cow-then-repair")).toBe( + "cow-then-repair", + ); + expect(normalizeDependencyBootstrap("install-only")).toBe("install-only"); + }); +}); + describe("loadConfig", () => { it("returns defaults when the config file does not exist", async () => { // Given @@ -591,6 +614,7 @@ describe("KNOWN_CONFIG_KEYS", () => { it("includes the keys used by commands", () => { for (const key of [ "branchPrefix", + "dependencyBootstrap", "hooks", "syncFiles", "syncRemote", diff --git a/src/config.ts b/src/config.ts index d67f88e..697adc1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ export const GLOBAL_CONFIG_NAME = "config.json"; export const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([ "branchPrefix", + "dependencyBootstrap", "editor", "hooks", "installSaveTarget", @@ -34,8 +35,14 @@ export const KNOWN_GLOBAL_CONFIG_KEYS: ReadonlySet = new Set([ export type GjiConfig = Record; +export type DependencyBootstrapMode = + | "off" + | "cow-then-repair" + | "install-only"; + export interface EffectiveGjiConfig extends GjiConfig { branchPrefix?: string; + dependencyBootstrap?: DependencyBootstrapMode; editor?: string; hooks?: Record; installSaveTarget?: string; @@ -160,7 +167,7 @@ export async function saveLocalConfig( root: string, config: GjiConfig, ): Promise { - validateConfigSyncDirs(config); + validateConfigValues(config); const path = join(root, CONFIG_FILE_NAME); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8"); @@ -188,7 +195,7 @@ export async function saveGlobalConfig( config: GjiConfig, home: string = homedir(), ): Promise { - validateConfigSyncDirs(config); + validateConfigValues(config); const path = GLOBAL_CONFIG_FILE_PATH(home); await mkdir(dirname(path), { recursive: true }); @@ -277,6 +284,23 @@ export function validateSyncDirsConfig(value: unknown): void { normalizeSyncDirsConfig(value); } +export function normalizeDependencyBootstrap( + value: unknown, +): DependencyBootstrapMode | undefined { + if (value === undefined) return; + if ( + value !== "off" && + value !== "cow-then-repair" && + value !== "install-only" + ) { + throw new Error( + 'dependencyBootstrap: expected "off", "cow-then-repair", or "install-only"', + ); + } + + return value; +} + export function normalizeSyncDirsConfig( value: unknown, ): readonly string[] | undefined { @@ -295,15 +319,16 @@ export function normalizeSyncDirsConfig( }); } -function validateConfigSyncDirs(config: GjiConfig): void { +function validateConfigValues(config: GjiConfig): void { validateSyncDirsConfig(config.syncDirs); + normalizeDependencyBootstrap(config.dependencyBootstrap); const repos = config.repos; if (!isPlainObject(repos)) return; for (const repoConfig of Object.values(repos)) { if (isPlainObject(repoConfig)) { - validateSyncDirsConfig(repoConfig.syncDirs); + validateConfigValues(repoConfig); } } } @@ -344,7 +369,7 @@ async function loadConfigFile(path: string): Promise { try { const rawConfig = await readFile(path, "utf8"); const parsedConfig = JSON.parse(rawConfig) as Record; - validateSyncDirsConfig(parsedConfig.syncDirs); + validateConfigValues(parsedConfig); return { config: mergeConfig(parsedConfig), @@ -373,6 +398,11 @@ function mergeConfig(...values: Record[]): GjiConfig { function toEffectiveConfig(config: GjiConfig): EffectiveGjiConfig { const effective = { ...config } as EffectiveGjiConfig; + if (config.dependencyBootstrap !== undefined) { + effective.dependencyBootstrap = normalizeDependencyBootstrap( + config.dependencyBootstrap, + ); + } for (const key of [ "branchPrefix", "editor", diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts new file mode 100644 index 0000000..d94b22e --- /dev/null +++ b/src/dependency-bootstrap.test.ts @@ -0,0 +1,422 @@ +import { + access, + mkdir, + mkdtemp, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + executeDependencyBootstrap, + prepareDependencyBootstrap, +} from "./dependency-bootstrap.js"; +import { CloneUnsupportedError } from "./dir-clone.js"; + +function createFailureStore() { + const failures = new Set(); + return { + isCached: async (repoRoot: string, directory: string) => + failures.has(`${repoRoot}:${directory}`), + cache: async (repoRoot: string, directory: string) => { + failures.add(`${repoRoot}:${directory}`); + }, + clear: async (repoRoot: string, directory: string) => { + failures.delete(`${repoRoot}:${directory}`); + }, + }; +} + +function createReporter() { + const events: string[] = []; + return { + emitCachedFailureWarnings: true, + measureCloneSize: false, + write: () => undefined, + cloned: () => undefined, + dependency: (event: { state: string; message: string }) => { + events.push(`${event.state}:${event.message}`); + }, + events, + }; +} + +async function prepareNodePlan( + mode: "cow-then-repair" | "install-only", +): Promise<{ + repoRoot: string; + worktreePath: string; + plan: Awaited>; +}> { + const repoRoot = await mkdtemp(join(tmpdir(), "gji-bootstrap-repo-")); + const worktreePath = await mkdtemp(join(tmpdir(), "gji-bootstrap-worktree-")); + await writeFile(join(repoRoot, "pnpm-lock.yaml"), "lockfileVersion: '9'\n"); + await mkdir(join(repoRoot, "node_modules")); + + const plan = await prepareDependencyBootstrap(mode, { + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + return { repoRoot, worktreePath, plan }; +} + +describe("dependencyBootstrap adapters", () => { + it("seeds node_modules with CoW and always runs the frozen pnpm repair", async () => { + // Given a pnpm source with a reusable dependency tree and a fresh worktree. + const { repoRoot, worktreePath, plan } = + await prepareNodePlan("cow-then-repair"); + const reporter = createReporter(); + const commands: string[] = []; + + // When dependency bootstrap executes with an injected CoW clone and repair runner. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, ".modules.yaml"), "stale\n"); + return { bytes: 1, ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async (command, cwd) => { + commands.push(command); + await expect( + readFile(join(cwd, "node_modules", ".modules.yaml"), "utf8"), + ).rejects.toThrow(); + await mkdir(join(cwd, "node_modules"), { recursive: true }); + await writeFile( + join(cwd, "node_modules", ".modules.yaml"), + "regenerated\n", + ); + }, + }); + + // Then cloning is a seed only, pnpm repair runs, and metadata is regenerated by the repair. + expect(result.ready).toBe(true); + expect(commands).toEqual(["pnpm install --frozen-lockfile"]); + expect(result.events.map(({ state }) => state)).toEqual([ + "seeded", + "repaired", + ]); + await expect( + readFile(join(worktreePath, "node_modules", ".modules.yaml"), "utf8"), + ).resolves.toBe("regenerated\n"); + }); + + it("uses npm install-only and never attempts a CoW seed", async () => { + // Given an npm lockfile and no dependency target in a new worktree. + const repoRoot = await mkdtemp(join(tmpdir(), "gji-bootstrap-npm-repo-")); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-npm-worktree-"), + ); + await writeFile(join(repoRoot, "package-lock.json"), "{}\n"); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + const reporter = createReporter(); + let cloneCalled = false; + const commands: string[] = []; + + // When dependency bootstrap executes for npm. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async () => { + cloneCalled = true; + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async (command) => { + commands.push(command); + }, + }); + + // Then npm runs destructive install-only repair and no clone is attempted. + expect(result.ready).toBe(true); + expect(cloneCalled).toBe(false); + expect(commands).toEqual(["npm ci"]); + expect(result.events.at(-1)?.state).toBe("installed"); + }); + + it("falls back to a clean repair when CoW is unsupported and caches the failure", async () => { + // Given a pnpm source whose filesystem rejects CoW. + const { repoRoot, worktreePath, plan } = + await prepareNodePlan("cow-then-repair"); + const reporter = createReporter(); + const failureStore = createFailureStore(); + let cloneCalled = false; + let repairCalled = false; + + // When the clone fails with an explicit unsupported-filesystem error. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async () => { + cloneCalled = true; + throw new CloneUnsupportedError("reflinks unavailable"); + }, + failureStore, + repoRoot, + reporter, + runCommand: async () => { + repairCalled = true; + }, + }); + + // Then ordinary copy is never attempted, while repair still makes progress and the failure is cached. + expect(result.ready).toBe(true); + expect(cloneCalled).toBe(true); + expect(repairCalled).toBe(true); + expect(result.events.map(({ state }) => state)).toEqual([ + "fallback", + "repaired", + ]); + expect(await failureStore.isCached(repoRoot, "node_modules")).toBe(true); + void worktreePath; + }); + + it("removes a failed seed before retrying clean and preserves no partial clone", async () => { + // Given a source whose first repair fails after a successful seed. + const { repoRoot, worktreePath, plan } = + await prepareNodePlan("cow-then-repair"); + const reporter = createReporter(); + let repairAttempts = 0; + let targetExistedOnRetry = true; + + // When the first repair fails and the clean retry is allowed to run. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "partial.txt"), "partial\n"); + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async (_command, cwd) => { + repairAttempts += 1; + if (repairAttempts === 1) throw new Error("stale seed"); + targetExistedOnRetry = await pathExists(join(cwd, "node_modules")); + await mkdir(join(cwd, "node_modules"), { recursive: true }); + }, + }); + + // Then the retry sees a clean target and leaves a successful repaired worktree. + expect(result.ready).toBe(true); + expect(repairAttempts).toBe(2); + expect(targetExistedOnRetry).toBe(false); + await expect( + readFile(join(worktreePath, "node_modules", "partial.txt"), "utf8"), + ).rejects.toThrow(); + }); + + it("never deletes a target that existed before the bootstrap command", async () => { + // Given a pre-existing dependency target with a user-owned sentinel file. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-existing-repo-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-existing-worktree-"), + ); + await writeFile(join(repoRoot, "pnpm-lock.yaml"), "lock\n"); + await mkdir(join(repoRoot, "node_modules")); + await mkdir(join(worktreePath, "node_modules")); + await writeFile(join(worktreePath, "node_modules", "keep.txt"), "keep\n"); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + const reporter = createReporter(); + + // When repair fails against the existing target. + const result = await executeDependencyBootstrap(plan, { + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async () => { + throw new Error("repair failed"); + }, + }); + + // Then bootstrap reports failure without removing user-owned state. + expect(result.ready).toBe(false); + await expect( + readFile(join(worktreePath, "node_modules", "keep.txt"), "utf8"), + ).resolves.toBe("keep\n"); + }); + + it("rejects a symlinked source outside the selected repository root", async () => { + // Given a lockfile and a node_modules symlink that escapes the repository. + const repoRoot = await mkdtemp(join(tmpdir(), "gji-bootstrap-link-repo-")); + const external = await mkdtemp( + join(tmpdir(), "gji-bootstrap-link-external-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-link-worktree-"), + ); + await writeFile(join(repoRoot, "pnpm-lock.yaml"), "lock\n"); + await mkdir(join(external, "package")); + await symlink(external, join(repoRoot, "node_modules")); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + const reporter = createReporter(); + let cloneCalled = false; + + // When bootstrap evaluates the source. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async () => { + cloneCalled = true; + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async () => undefined, + }); + + // Then the unsafe source is skipped without cloning and repair remains the only fallback. + expect(result.ready).toBe(true); + expect(cloneCalled).toBe(false); + expect(result.events[0]?.state).toBe("fallback"); + }); + + it("clones a compatible uv environment and repairs it with locked sync", async () => { + // Given a uv lockfile and a virtual environment with a compatible interpreter fingerprint. + const repoRoot = await mkdtemp(join(tmpdir(), "gji-bootstrap-uv-repo-")); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-uv-worktree-"), + ); + await writeFile(join(repoRoot, "uv.lock"), "versioned\n"); + await mkdir(join(repoRoot, ".venv", "bin"), { recursive: true }); + await writeFile(join(repoRoot, ".venv", "pyvenv.cfg"), "version = 3.13\n"); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + checkUvRuntime: async () => true, + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + const commands: string[] = []; + + // When uv bootstrap runs. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter: createReporter(), + runCommand: async (command) => { + commands.push(command); + }, + }); + + // Then uv reuses the environment only after the locked repair succeeds. + expect(result.ready).toBe(true); + expect(commands).toEqual(["uv sync --locked"]); + expect(result.events.map(({ state }) => state)).toEqual([ + "seeded", + "repaired", + ]); + }); + + it("falls back safely when the Python runtime is incompatible", async () => { + // Given a uv environment that cannot run under the current interpreter. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-uv-mismatch-repo-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-uv-mismatch-worktree-"), + ); + await writeFile(join(repoRoot, "uv.lock"), "versioned\n"); + await mkdir(join(repoRoot, ".venv"), { recursive: true }); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + checkUvRuntime: async () => false, + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + let cloneCalled = false; + + // When bootstrap evaluates the incompatible seed. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async () => { + cloneCalled = true; + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter: createReporter(), + runCommand: async () => undefined, + }); + + // Then it does not clone an incompatible interpreter environment and repairs from empty state. + expect(result.ready).toBe(true); + expect(cloneCalled).toBe(false); + expect(result.events.map(({ state }) => state)).toEqual([ + "fallback", + "repaired", + ]); + }); + + it("clones Cargo build state before cargo check repair", async () => { + // Given a Cargo lockfile and an existing target cache. + const repoRoot = await mkdtemp(join(tmpdir(), "gji-bootstrap-cargo-repo-")); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-cargo-worktree-"), + ); + await writeFile(join(repoRoot, "Cargo.lock"), "version = 3\n"); + await mkdir(join(repoRoot, "target", "debug"), { recursive: true }); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + runCommand: async () => undefined, + stderr: () => undefined, + worktreePath, + }); + const commands: string[] = []; + + // When the Cargo adapter bootstraps the worktree. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter: createReporter(), + runCommand: async (command) => { + commands.push(command); + }, + }); + + // Then the build cache is reported as repaired rather than as a dependency install. + expect(result.ready).toBe(true); + expect(commands).toEqual(["cargo check"]); + expect(result.events[0]?.kind).toBe("build-cache"); + expect(result.events.at(-1)?.state).toBe("repaired"); + }); +}); + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts new file mode 100644 index 0000000..7713981 --- /dev/null +++ b/src/dependency-bootstrap.ts @@ -0,0 +1,616 @@ +import { execFile } from "node:child_process"; +import { access, lstat, readFile, realpath, rm } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import { + type CloneFailureStore, + defaultCloneFailureStore, +} from "./clone-failure-store.js"; +import type { DependencyBootstrapMode } from "./config.js"; +import { + type CloneDirectory, + cloneDir, + isCloneUnsupportedError, +} from "./dir-clone.js"; +import type { SyncDirectoryReporter } from "./sync-directories.js"; + +const execFileAsync = promisify(execFile); + +export type BootstrapKind = "dependency" | "build-cache"; +export type BootstrapState = + | "seeded" + | "repaired" + | "installed" + | "fallback" + | "skipped" + | "failed"; + +export type BootstrapCommandRunner = ( + command: string, + cwd: string, + stderr: (chunk: string) => void, +) => Promise; + +export interface BootstrapContext { + repoRoot: string; + sourceRoot: string; + worktreePath: string; + runCommand: BootstrapCommandRunner; + stderr: (chunk: string) => void; + checkUvRuntime?: (target: BootstrapTarget) => Promise; +} + +export interface BootstrapTarget { + adapter: string; + kind: BootstrapKind; + relativePath: string; + sourceRoot: string; + worktreePath: string; + sourcePath: string; + targetPath: string; + repairCommand: string; + repairState: "repaired" | "installed"; + existingBeforeBootstrap: boolean; + runCommand: BootstrapCommandRunner; + stderr: (chunk: string) => void; +} + +export interface BootstrapAdapter { + readonly kind: BootstrapKind; + readonly name: string; + detect(context: BootstrapContext): Promise; + seedPath(target: BootstrapTarget): string; + repair(target: BootstrapTarget): Promise; + canSeed(target: BootstrapTarget): Promise; +} + +export interface DependencyBootstrapPlan { + mode: DependencyBootstrapMode; + targets: readonly PlannedBootstrapTarget[]; +} + +export interface PlannedBootstrapTarget { + adapter: BootstrapAdapter; + target: BootstrapTarget; +} + +export interface BootstrapEvent { + adapter: string; + kind: BootstrapKind; + state: BootstrapState; + target: string; + message: string; +} + +export interface DependencyBootstrapReport { + mode: DependencyBootstrapMode; + ready: boolean; + events: readonly BootstrapEvent[]; +} + +export interface DependencyBootstrapDependencies { + cloneDirectory?: CloneDirectory; + failureStore?: CloneFailureStore; + runCommand?: BootstrapCommandRunner; + checkUvRuntime?: (target: BootstrapTarget) => Promise; +} + +export interface DependencyBootstrapPreview { + mode: DependencyBootstrapMode; + targets: readonly { + adapter: string; + kind: BootstrapKind; + target: string; + repairCommand: string; + strategy: "cow-then-repair" | "install-only"; + }[]; +} + +export async function prepareDependencyBootstrap( + mode: DependencyBootstrapMode, + context: Omit & { currentRoot?: string }, +): Promise { + if (mode === "off") return { mode, targets: [] }; + + const adapters = createBootstrapAdapters( + context.checkUvRuntime ?? defaultCheckUvRuntime, + ); + const sourceRoots: string[] = []; + for (const sourceRoot of uniquePaths([ + context.repoRoot, + context.currentRoot, + ])) { + if (await isAllowedSourceRoot(context.repoRoot, sourceRoot)) { + sourceRoots.push(sourceRoot); + } + } + const targets: PlannedBootstrapTarget[] = []; + + for (const adapter of adapters) { + let firstCandidate: BootstrapTarget | null = null; + for (const sourceRoot of sourceRoots) { + const target = await adapter.detect({ ...context, sourceRoot }); + if (!target) continue; + firstCandidate ??= target; + if (mode === "install-only" || (await adapter.canSeed(target))) { + targets.push({ adapter, target }); + break; + } + } + if ( + firstCandidate && + !targets.some(({ adapter: selected }) => selected === adapter) + ) { + targets.push({ adapter, target: firstCandidate }); + } + } + + return { mode, targets }; +} + +export function previewDependencyBootstrap( + plan: DependencyBootstrapPlan, +): DependencyBootstrapPreview { + return { + mode: plan.mode, + targets: plan.targets.map(({ adapter, target }) => ({ + adapter: adapter.name, + kind: adapter.kind, + target: target.relativePath, + repairCommand: target.repairCommand, + strategy: + plan.mode === "install-only" ? "install-only" : "cow-then-repair", + })), + }; +} + +export async function executeDependencyBootstrap( + plan: DependencyBootstrapPlan, + options: DependencyBootstrapDependencies & { + repoRoot: string; + reporter: SyncDirectoryReporter; + }, +): Promise { + if (plan.mode === "off") return { mode: plan.mode, ready: true, events: [] }; + + const events: BootstrapEvent[] = []; + const failureStore = options.failureStore ?? defaultCloneFailureStore; + const cloneDirectory = options.cloneDirectory ?? cloneDir; + + if (plan.targets.length === 0) { + recordBootstrapEvent(events, options.reporter, { + adapter: "none", + kind: "dependency", + state: "skipped", + target: "", + message: "no supported dependency or build-state lockfile was detected", + }); + return { mode: plan.mode, ready: true, events }; + } + + for (const { adapter, target } of plan.targets) { + if (options.runCommand) target.runCommand = options.runCommand; + await executeBootstrapTarget( + adapter, + target, + plan.mode, + cloneDirectory, + failureStore, + events, + options.reporter, + options.repoRoot, + ); + } + + return { + mode: plan.mode, + ready: !events.some(({ state }) => state === "failed"), + events, + }; +} + +async function executeBootstrapTarget( + adapter: BootstrapAdapter, + target: BootstrapTarget, + mode: DependencyBootstrapMode, + cloneDirectory: CloneDirectory, + failureStore: CloneFailureStore, + events: BootstrapEvent[], + reporter: SyncDirectoryReporter, + repoRoot: string, +): Promise { + if (mode === "install-only") { + await repairTarget(adapter, target, false, events, reporter); + return; + } + + let seeded = false; + if (target.existingBeforeBootstrap) { + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "skipped", + target: target.relativePath, + message: "target already existed; using it as the repair input", + }); + } else if (await pathExists(target.targetPath)) { + seeded = true; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "seeded", + target: target.relativePath, + message: "reusing a seed created by syncDirs", + }); + } else if (await failureStore.isCached(repoRoot, target.relativePath)) { + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "fallback", + target: target.relativePath, + message: "previous CoW failure is cached; repairing from an empty target", + }); + } else if (await adapter.canSeed(target)) { + try { + await cloneDirectory(adapter.seedPath(target), target.targetPath, { + measureBytes: reporter.measureCloneSize, + }); + await failureStore.clear(repoRoot, target.relativePath); + seeded = true; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "seeded", + target: target.relativePath, + message: "seeded with copy-on-write", + }); + } catch (error) { + if (isCloneUnsupportedError(error)) { + await failureStore.cache( + repoRoot, + target.relativePath, + toErrorMessage(error), + ); + } + await removeCreatedTarget(target, true); + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "fallback", + target: target.relativePath, + message: `CoW seed failed; repairing from an empty target (${toErrorMessage(error)})`, + }); + } + } else { + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "fallback", + target: target.relativePath, + message: "CoW seed is unavailable; repairing from an empty target", + }); + } + + await repairTarget(adapter, target, seeded, events, reporter); +} + +async function repairTarget( + adapter: BootstrapAdapter, + target: BootstrapTarget, + seeded: boolean, + events: BootstrapEvent[], + reporter: SyncDirectoryReporter, +): Promise { + try { + await adapter.repair(target); + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: target.repairState, + target: target.relativePath, + message: seeded + ? "reused and repaired" + : "installed or repaired from a clean target", + }); + } catch (firstError) { + if (target.existingBeforeBootstrap) { + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "failed", + target: target.relativePath, + message: `repair failed: ${toErrorMessage(firstError)}`, + }); + return; + } + if (!seeded) { + await removeCreatedTarget(target, true); + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "failed", + target: target.relativePath, + message: `repair failed: ${toErrorMessage(firstError)}`, + }); + return; + } + + await removeCreatedTarget(target, true); + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "fallback", + target: target.relativePath, + message: `seed repair failed; removed the seed and retrying clean (${toErrorMessage(firstError)})`, + }); + + try { + await adapter.repair(target); + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: target.repairState, + target: target.relativePath, + message: "installed or repaired from a clean target", + }); + } catch (secondError) { + await removeCreatedTarget(target, true); + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "failed", + target: target.relativePath, + message: `clean repair failed: ${toErrorMessage(secondError)}`, + }); + } + } +} + +function recordBootstrapEvent( + events: BootstrapEvent[], + reporter: SyncDirectoryReporter, + event: BootstrapEvent, +): void { + events.push(event); + reporter.dependency?.(event); +} + +async function removeCreatedTarget( + target: BootstrapTarget, + createdByBootstrap: boolean, +): Promise { + if (target.existingBeforeBootstrap || !createdByBootstrap) return; + await rm(target.targetPath, { force: true, recursive: true }); +} + +function createBootstrapAdapters( + checkUvRuntime: ((target: BootstrapTarget) => Promise) | undefined, +): readonly BootstrapAdapter[] { + return [ + new LockfileBootstrapAdapter( + "pnpm", + "dependency", + "pnpm-lock.yaml", + "pnpm install --frozen-lockfile", + { + beforeRepair: async (target) => { + if (!target.existingBeforeBootstrap) { + await rm(join(target.targetPath, ".modules.yaml"), { + force: true, + }); + } + }, + }, + ), + new LockfileBootstrapAdapter( + "yarn", + "dependency", + "yarn.lock", + "yarn install --immutable", + ), + new LockfileBootstrapAdapter( + "npm", + "dependency", + "package-lock.json", + "npm ci", + { + canSeed: async () => false, + repairCommand: (target) => + target.existingBeforeBootstrap ? "npm install" : "npm ci", + repairState: "installed", + }, + ), + new LockfileBootstrapAdapter( + "uv", + "dependency", + "uv.lock", + "uv sync --locked", + { + canSeed: checkUvRuntime, + }, + ), + new LockfileBootstrapAdapter( + "cargo", + "build-cache", + "Cargo.lock", + "cargo check", + ), + ]; +} + +class LockfileBootstrapAdapter implements BootstrapAdapter { + readonly kind: BootstrapKind; + readonly name: string; + private readonly lockfile: string; + private readonly defaultRepairCommand: string; + private readonly canSeedOverride?: ( + target: BootstrapTarget, + ) => Promise; + private readonly beforeRepair?: (target: BootstrapTarget) => Promise; + private readonly repairCommandOverride?: (target: BootstrapTarget) => string; + private readonly repairState: "repaired" | "installed"; + + constructor( + name: string, + kind: BootstrapKind, + lockfile: string, + repairCommand: string, + options: { + canSeed?: (target: BootstrapTarget) => Promise; + beforeRepair?: (target: BootstrapTarget) => Promise; + repairCommand?: (target: BootstrapTarget) => string; + repairState?: "repaired" | "installed"; + } = {}, + ) { + this.name = name; + this.kind = kind; + this.lockfile = lockfile; + this.defaultRepairCommand = repairCommand; + this.canSeedOverride = options.canSeed; + this.beforeRepair = options.beforeRepair; + this.repairCommandOverride = options.repairCommand; + this.repairState = options.repairState ?? "repaired"; + } + + async detect(context: BootstrapContext): Promise { + if (!(await pathExists(join(context.sourceRoot, this.lockfile)))) + return null; + const relativePath = + this.name === "cargo" + ? "target" + : this.name === "uv" + ? ".venv" + : "node_modules"; + const targetPath = join(context.worktreePath, relativePath); + + return { + adapter: this.name, + kind: this.kind, + relativePath, + sourceRoot: context.sourceRoot, + worktreePath: context.worktreePath, + sourcePath: join(context.sourceRoot, relativePath), + targetPath, + repairCommand: + this.repairCommandOverride?.({ + adapter: this.name, + kind: this.kind, + relativePath, + sourceRoot: context.sourceRoot, + worktreePath: context.worktreePath, + sourcePath: join(context.sourceRoot, relativePath), + targetPath, + repairCommand: this.defaultRepairCommand, + repairState: this.repairState, + existingBeforeBootstrap: await pathExists(targetPath), + runCommand: context.runCommand, + stderr: context.stderr, + }) ?? this.defaultRepairCommand, + repairState: this.repairState, + existingBeforeBootstrap: await pathExists(targetPath), + runCommand: context.runCommand, + stderr: context.stderr, + }; + } + + seedPath(target: BootstrapTarget): string { + return target.sourcePath; + } + + async repair(target: BootstrapTarget): Promise { + await this.beforeRepair?.(target); + await target.runCommand( + target.repairCommand, + target.worktreePath, + target.stderr, + ); + } + + async canSeed(target: BootstrapTarget): Promise { + if (this.canSeedOverride) { + return ( + (await safeSourceDirectory(target)) && + (await this.canSeedOverride(target)) + ); + } + return safeSourceDirectory(target); + } +} + +async function safeSourceDirectory(target: BootstrapTarget): Promise { + try { + const [root, source] = await Promise.all([ + realpath(target.sourceRoot), + realpath(target.sourcePath), + ]); + const sourceStats = await lstat(source); + return sourceStats.isDirectory() && isWithin(root, source); + } catch { + return false; + } +} + +async function defaultCheckUvRuntime( + target: BootstrapTarget, +): Promise { + try { + const config = await readFile( + join(target.sourcePath, "pyvenv.cfg"), + "utf8", + ); + const expected = config.match(/^version\s*=\s*(\d+\.\d+)/mu)?.[1]; + if (!expected) return false; + const { stdout, stderr } = await execFileAsync("python3", ["--version"]); + const actual = `${stdout}${stderr}`.match(/Python\s+(\d+\.\d+)/u)?.[1]; + return actual === expected; + } catch { + return false; + } +} + +function uniquePaths(paths: readonly (string | undefined)[]): string[] { + return [...new Set(paths.filter((path): path is string => Boolean(path)))]; +} + +async function isAllowedSourceRoot( + repoRoot: string, + sourceRoot: string, +): Promise { + try { + const [resolvedRepoRoot, resolvedSourceRoot] = await Promise.all([ + realpath(repoRoot), + realpath(sourceRoot), + ]); + if (resolvedRepoRoot === resolvedSourceRoot) return true; + + const gitFile = await readFile(join(resolvedSourceRoot, ".git"), "utf8"); + const gitDirectory = gitFile.match(/^gitdir:\s*(.+)$/mu)?.[1]; + if (!gitDirectory) return false; + return isWithin( + join(resolvedRepoRoot, ".git", "worktrees"), + resolve(resolvedSourceRoot, gitDirectory), + ); + } catch { + return false; + } +} + +function isWithin(root: string, candidate: string): boolean { + const distance = relative(root, candidate); + return ( + distance === "" || (distance !== ".." && !distance.startsWith(`..${sep}`)) + ); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/install-prompt.ts b/src/install-prompt.ts index 3c3755e..9896b97 100644 --- a/src/install-prompt.ts +++ b/src/install-prompt.ts @@ -45,10 +45,9 @@ export async function maybeRunInstallPrompt( stderr: (chunk: string) => void, dependencies: InstallPromptDependencies = {}, nonInteractive = false, - skipAfterClone = false, ): Promise { // Skip in non-interactive mode — no prompt can be shown. - if (isHeadless() || nonInteractive || skipAfterClone) { + if (isHeadless() || nonInteractive) { return; } @@ -79,7 +78,7 @@ export async function maybeRunInstallPrompt( } if (choice === "yes" || choice === "always") { - const runner = dependencies.runInstallCommand ?? defaultRunInstallCommand; + const runner = dependencies.runInstallCommand ?? runInstallCommand; try { await runner(pm.installCommand, worktreePath, stderr); } catch (error) { @@ -145,7 +144,7 @@ export async function maybeRunInstallPrompt( } } -async function defaultRunInstallCommand( +export async function runInstallCommand( command: string, cwd: string, stderr: (chunk: string) => void, diff --git a/src/new.test.ts b/src/new.test.ts index b3698b3..ca462fc 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -814,7 +814,7 @@ describe("gji new", () => { ).resolves.toBe("leaf\n"); }); - it("removes pnpm metadata and skips the install prompt after cloning node_modules", async () => { + it("repairs a CoW pnpm seed and preserves pnpm metadata", async () => { // Given a pnpm repository whose cloned node_modules contains absolute-path metadata. const repoRoot = await createRepository(); const branchName = "feature/cow-pnpm"; @@ -827,13 +827,22 @@ describe("gji new", () => { join(repoRoot, "node_modules", ".modules.yaml"), "store-dir: /main\n", ); + await writeFile( + join(repoRoot, ".npmrc"), + "registry=https://example.test\n", + ); await writeFile( join(repoRoot, ".gji.json"), - JSON.stringify({ syncDirs: ["node_modules"] }), + JSON.stringify({ + dependencyBootstrap: "cow-then-repair", + syncDirs: ["node_modules"], + syncFiles: [".npmrc"], + hooks: { "after-create": "touch after-create-ran" }, + }), "utf8", ); let promptCalled = false; - let installCalled = false; + const installCommands: string[] = []; // When gji new clones the directory and supplies install dependencies. const runNew = createNewCommand({ @@ -853,8 +862,22 @@ describe("gji new", () => { promptCalled = true; return "yes"; }, - runInstallCommand: async () => { - installCalled = true; + runInstallCommand: async (command) => { + installCommands.push(command); + await expect( + readFile( + join(resolveWorktreePath(repoRoot, branchName), ".npmrc"), + "utf8", + ), + ).resolves.toBe("registry=https://example.test\n"); + await writeFile( + join( + resolveWorktreePath(repoRoot, branchName), + "node_modules", + ".modules.yaml", + ), + "regenerated\n", + ); }, }); const result = await runNew({ @@ -864,10 +887,10 @@ describe("gji new", () => { stdout: () => undefined, }); - // Then pnpm metadata is removed and neither prompt nor install runs. + // Then the adapter repairs the seed without using the interactive prompt. expect(result).toBe(0); expect(promptCalled).toBe(false); - expect(installCalled).toBe(false); + expect(installCommands).toEqual(["pnpm install --frozen-lockfile"]); await expect( readFile( join( @@ -875,8 +898,15 @@ describe("gji new", () => { "node_modules", ".modules.yaml", ), + "utf8", ), - ).rejects.toThrow(); + ).resolves.toBe("regenerated\n"); + await expect( + readFile( + join(resolveWorktreePath(repoRoot, branchName), "after-create-ran"), + "utf8", + ), + ).resolves.toBe(""); }); it("reports cloned directories in JSON output", async () => { @@ -914,6 +944,81 @@ describe("gji new", () => { }); }); + it("does not run after-create when dependency repair fails", async () => { + // Given a dependency bootstrap configuration whose repair command fails. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "pnpm-lock.yaml", + "lockfileVersion: '9'\n", + "Add pnpm lockfile", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ + dependencyBootstrap: "cow-then-repair", + hooks: { "after-create": "touch after-create-ran" }, + }), + "utf8", + ); + + // When gji new cannot complete the authoritative dependency repair. + const result = await createNewCommand({ + runInstallCommand: async () => { + throw new Error("repair failed"); + }, + })({ + branch: "feature/repair-failure", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the command fails closed and the after-create hook never runs. + expect(result).toBe(1); + await expect( + readFile( + join( + resolveWorktreePath(repoRoot, "feature/repair-failure"), + "after-create-ran", + ), + ), + ).rejects.toThrow(); + }); + + it("reports dependency bootstrap states in JSON output", async () => { + // Given a repository with an explicit install-only dependency policy. + const repoRoot = await createRepository(); + await writeFile(join(repoRoot, "package-lock.json"), "{}\n", "utf8"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ dependencyBootstrap: "install-only" }), + "utf8", + ); + const stdout: string[] = []; + + // When gji new runs headlessly with a structured output request. + const result = await createNewCommand({ + runInstallCommand: async () => undefined, + })({ + branch: "feature/bootstrap-json", + cwd: repoRoot, + json: true, + stderr: () => undefined, + stdout: (chunk) => stdout.push(chunk), + }); + + // Then JSON exposes the adapter, install state, and successful readiness. + expect(result).toBe(0); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + dependencyBootstrap: { + mode: "install-only", + ready: true, + events: [{ adapter: "npm", state: "installed" }], + }, + }); + }); + it("cleans partial clones and caches the failure for later worktrees", async () => { // Given a repository and an isolated state directory. const repoRoot = await createRepository(); @@ -1086,7 +1191,7 @@ describe("gji new", () => { expect(stderr.join("")).toContain("source is not a directory"); }); - it("skips the install prompt after cloning node_modules for npm", async () => { + it("does not let a generic node_modules clone skip the npm install prompt", async () => { // Given an npm repository with a configured node_modules directory. const repoRoot = await createRepository(); await commitFile( @@ -1124,9 +1229,9 @@ describe("gji new", () => { stdout: () => undefined, }); - // Then the successful dependency clone suppresses installation for npm too. + // Then syncDirs remains generic and the normal prompt still runs. expect(result).toBe(0); - expect(promptCalled).toBe(false); + expect(promptCalled).toBe(true); }); it("emits a JSON error and does not create a worktree for invalid syncDirs", async () => { @@ -1178,6 +1283,49 @@ describe("gji new", () => { expect(result.exitCode).toBe(0); expect(stdout.join("")).toContain("Would clone node_modules (6 B)"); }); + + it("previews dependency bootstrap strategy in text and JSON dry-runs", async () => { + // Given a pnpm repository with a dependency seed and explicit bootstrap mode. + const repoRoot = await createRepository(); + await writeFile(join(repoRoot, "pnpm-lock.yaml"), "lock\n", "utf8"); + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ dependencyBootstrap: "cow-then-repair" }), + "utf8", + ); + const textOutput: string[] = []; + const jsonOutput: string[] = []; + + // When both dry-run output modes inspect the bootstrap plan. + const textResult = await runCli( + ["new", "--dry-run", "feature/bootstrap-preview"], + { + cwd: repoRoot, + stdout: (chunk) => textOutput.push(chunk), + }, + ); + const jsonResult = await runCli( + ["new", "--json", "--dry-run", "feature/bootstrap-preview-json"], + { + cwd: repoRoot, + stdout: (chunk) => jsonOutput.push(chunk), + }, + ); + + // Then users can see the repair strategy without creating a worktree. + expect(textResult.exitCode).toBe(0); + expect(textOutput.join("")).toContain( + "Would seed and repair node_modules with pnpm", + ); + expect(jsonResult.exitCode).toBe(0); + expect(JSON.parse(jsonOutput.join(""))).toMatchObject({ + dependencyBootstrap: { + mode: "cow-then-repair", + targets: [{ adapter: "pnpm", target: "node_modules" }], + }, + }); + }); }); describe("install prompt", () => { diff --git a/src/new.ts b/src/new.ts index 2f6ee66..5b4da80 100644 --- a/src/new.ts +++ b/src/new.ts @@ -14,11 +14,18 @@ import { pathExists, promptForPathConflict, } from "./conflict.js"; +import { + prepareDependencyBootstrap, + previewDependencyBootstrap, +} from "./dependency-bootstrap.js"; import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; -import type { InstallPromptDependencies } from "./install-prompt.js"; +import { + type InstallPromptDependencies, + runInstallCommand, +} from "./install-prompt.js"; import { createNavigationRepository, createNavigationTarget, @@ -278,6 +285,15 @@ export function createNewCommand( worktreePath, config.syncDirs ?? [], ); + const dryRunDependencyBootstrap = previewDependencyBootstrap( + await prepareDependencyBootstrap(config.dependencyBootstrap ?? "off", { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + runCommand: dependencies.runInstallCommand ?? runInstallCommand, + stderr: options.stderr, + worktreePath, + }), + ); if (options.json) { const output: Record = { ...createNavigationTarget( @@ -291,6 +307,8 @@ export function createNewCommand( dryRun: true, }; if (dryRunSyncDirs.length > 0) output.syncDirs = dryRunSyncDirs; + if (dryRunDependencyBootstrap.targets.length > 0) + output.dependencyBootstrap = dryRunDependencyBootstrap; options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { const resolvedEditor = options.open @@ -300,7 +318,7 @@ export function createNewCommand( ? `, then open in ${resolvedEditor}` : ""; options.stdout( - `Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}`, + `Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`, ); } return 0; @@ -422,16 +440,22 @@ export function createNewCommand( ); } - const clonedDirs = await bootstrapWorktree({ + const bootstrap = await bootstrapWorktree({ branch: worktreeName, cloneDirectory, config, + currentRoot: repository.currentRoot, nonInteractive: !!options.json, repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), worktreePath, installDependencies: dependencies, }); + if (!bootstrap.ready) { + return emitNewError(options, "dependency bootstrap failed", { + dependencyBootstrap: bootstrap.dependencyBootstrap, + }); + } if (options.json) { const navigation = createNavigationTarget( @@ -449,9 +473,14 @@ export function createNewCommand( }, } : { ...navigation }; - if (clonedDirs.length > 0) { - output.cloned = clonedDirs.map(({ dir, ms }) => ({ dir, ms })); + if (bootstrap.clonedDirs.length > 0) { + output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({ + dir, + ms, + })); } + if (bootstrap.dependencyBootstrap.mode !== "off") + output.dependencyBootstrap = bootstrap.dependencyBootstrap; options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { if (options.take) @@ -922,9 +951,26 @@ async function restoreTakeStash( ); } } -function emitNewError(options: NewCommandOptions, message: string): number { +function formatDependencyBootstrapPreview(preview: { + targets: readonly { adapter: string; target: string; strategy: string }[]; +}): string { + return preview.targets + .map( + ({ adapter, target, strategy }) => + `Would ${strategy === "cow-then-repair" ? "seed and repair" : "install"} ${target} with ${adapter}\n`, + ) + .join(""); +} + +function emitNewError( + options: NewCommandOptions, + message: string, + details?: Record, +): number { if (options.json) - options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`); + options.stderr( + `${JSON.stringify({ error: message, ...details }, null, 2)}\n`, + ); else options.stderr(`gji new: ${message}\n`); return 1; } diff --git a/src/package-manager.ts b/src/package-manager.ts index 022ed1b..581ed23 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -1,8 +1,6 @@ -import { access, readdir, unlink } from "node:fs/promises"; +import { access, readdir } from "node:fs/promises"; import { join } from "node:path"; -import type { SyncDirectoryPostProcessor } from "./sync-directories.js"; - export interface PackageManager { name: string; installCommand: string; @@ -149,40 +147,6 @@ export async function detectPackageManager( return null; } -export async function createDependencyClonePostProcessor( - worktreePath: string, - repoRoot: string, - directories: readonly string[], -): Promise { - if (!directories.includes("node_modules")) return undefined; - - const packageManager = - (await detectPackageManager(worktreePath)) ?? - (await detectPackageManager(repoRoot)); - - return { - async postProcess(directory, destination) { - if (directory !== "node_modules") return undefined; - - if (packageManager?.name === "pnpm") { - try { - await unlink(join(destination, ".modules.yaml")); - } catch (error) { - if (isNotFoundError(error)) { - return { installSkipped: true }; - } - return { - installSkipped: false, - warning: `could not remove pnpm .modules.yaml: ${toErrorMessage(error)}`, - }; - } - } - - return { installSkipped: true }; - }, - }; -} - async function matchesExact( repoRoot: string, signals: string[], @@ -216,18 +180,6 @@ async function matchesGlob( return files.some((file) => regexes.some((re) => re.test(file))); } -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function patternToRegex(pattern: string): RegExp { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, "\\$&") diff --git a/src/pr.ts b/src/pr.ts index a3891ca..874bc05 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -13,10 +13,17 @@ import { pathExists, promptForPathConflict, } from "./conflict.js"; +import { + prepareDependencyBootstrap, + previewDependencyBootstrap, +} from "./dependency-bootstrap.js"; import type { CloneDirectory } from "./dir-clone.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; -import type { InstallPromptDependencies } from "./install-prompt.js"; +import { + type InstallPromptDependencies, + runInstallCommand, +} from "./install-prompt.js"; import { createNavigationRepository, createNavigationTarget, @@ -150,6 +157,20 @@ export function createPrCommand( config.syncDirs ?? [], ) : []; + const dryRunDependencyBootstrap = options.dryRun + ? previewDependencyBootstrap( + await prepareDependencyBootstrap( + config.dependencyBootstrap ?? "off", + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + runCommand: dependencies.runInstallCommand ?? runInstallCommand, + stderr: options.stderr, + worktreePath, + }, + ), + ) + : undefined; if (options.dryRun) { if (options.json) { @@ -165,10 +186,12 @@ export function createPrCommand( dryRun: true, }; if (dryRunSyncDirs.length > 0) output.syncDirs = dryRunSyncDirs; + if (dryRunDependencyBootstrap?.targets.length) + output.dependencyBootstrap = dryRunDependencyBootstrap; options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { options.stdout( - `Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${bytes} bytes)\n`).join("")}`, + `Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${bytes} bytes)\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`, ); } return 0; @@ -206,16 +229,28 @@ export function createPrCommand( await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot }); - const clonedDirs = await bootstrapWorktree({ + const bootstrap = await bootstrapWorktree({ branch: branchName, cloneDirectory: dependencies.cloneDir, config, + currentRoot: repository.currentRoot, nonInteractive: !!options.json, repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), worktreePath, installDependencies: dependencies, }); + if (!bootstrap.ready) { + const details = { dependencyBootstrap: bootstrap.dependencyBootstrap }; + if (options.json) { + options.stderr( + `${JSON.stringify({ error: "dependency bootstrap failed", ...details }, null, 2)}\n`, + ); + } else { + options.stderr("gji pr: dependency bootstrap failed\n"); + } + return 1; + } if (options.json) { const output: Record = { @@ -225,9 +260,14 @@ export function createPrCommand( branchName, ), }; - if (clonedDirs.length > 0) { - output.cloned = clonedDirs.map(({ dir, ms }) => ({ dir, ms })); + if (bootstrap.clonedDirs.length > 0) { + output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({ + dir, + ms, + })); } + if (bootstrap.dependencyBootstrap.mode !== "off") + output.dependencyBootstrap = bootstrap.dependencyBootstrap; options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { await recordWorktreeUsage(worktreePath, branchName); @@ -333,3 +373,23 @@ async function writeOutput( ): Promise { await writeShellOutput(outputEnv ?? PR_OUTPUT_FILE_ENV, worktreePath, stdout); } + +function formatDependencyBootstrapPreview( + preview: + | { + targets: readonly { + adapter: string; + target: string; + strategy: string; + }[]; + } + | undefined, +): string { + if (!preview) return ""; + return preview.targets + .map( + ({ adapter, target, strategy }) => + `Would ${strategy === "cow-then-repair" ? "seed and repair" : "install"} ${target} with ${adapter}\n`, + ) + .join(""); +} diff --git a/src/sync-directories.ts b/src/sync-directories.ts index 3b45eb4..ca31fa5 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -17,7 +17,6 @@ import type { SyncDirectoryPlan } from "./sync-plan.js"; export interface ClonedDirectory { bytes?: number; dir: string; - installSkipped: boolean; ms: number; } @@ -25,24 +24,29 @@ export type SyncDirectoryOutcome = | { kind: "cloned"; directory: ClonedDirectory } | { kind: "skipped"; dir: string; reason: string }; -export interface SyncDirectoryPostProcessor { - postProcess( - directory: string, - destination: string, - ): Promise<{ installSkipped: boolean; warning?: string } | undefined>; -} - export interface SyncDirectoryReporter { readonly emitCachedFailureWarnings: boolean; readonly measureCloneSize: boolean; write(message: string): void; cloned(directory: ClonedDirectory): void; + dependency?(event: { + adapter: string; + kind: "dependency" | "build-cache"; + state: + | "seeded" + | "repaired" + | "installed" + | "fallback" + | "skipped" + | "failed"; + target: string; + message: string; + }): void; } export interface SyncDirectoryExecutionOptions { cloneDirectory: CloneDirectory; failureStore?: CloneFailureStore; - postProcessor?: SyncDirectoryPostProcessor; repoRoot: string; reporter: SyncDirectoryReporter; } @@ -138,28 +142,9 @@ export async function executeSyncDirectoryPlan( } await failureStore.clear(options.repoRoot, entry.directory); - let installSkipped = false; - if (options.postProcessor) { - try { - const postProcess = await options.postProcessor.postProcess( - entry.directory, - entry.destination, - ); - installSkipped = postProcess?.installSkipped ?? false; - if (postProcess?.warning) { - options.reporter.write(`syncDirs: ${postProcess.warning}\n`); - } - } catch (error) { - options.reporter.write( - `syncDirs: post-processing failed (${toErrorMessage(error)})\n`, - ); - } - } - const clonedDirectory: ClonedDirectory = { bytes: result.bytes, dir: entry.directory, - installSkipped, ms: result.ms, }; const outcome = { kind: "cloned" as const, directory: clonedDirectory }; diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 90ee63e..3a30fe9 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -1,14 +1,19 @@ import { basename } from "node:path"; import type { EffectiveGjiConfig } from "./config.js"; +import { + type DependencyBootstrapReport, + executeDependencyBootstrap, + prepareDependencyBootstrap, +} from "./dependency-bootstrap.js"; import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { syncFiles } from "./file-sync.js"; import { extractHooks, runHook } from "./hooks.js"; import { type InstallPromptDependencies, maybeRunInstallPrompt, + runInstallCommand, } from "./install-prompt.js"; -import { createDependencyClonePostProcessor } from "./package-manager.js"; import { type ClonedDirectory, executeSyncDirectoryPlan, @@ -22,6 +27,7 @@ export interface WorktreeBootstrapOptions { branch: string; cloneDirectory?: CloneDirectory; config: EffectiveGjiConfig; + currentRoot?: string; installDependencies?: InstallPromptDependencies; nonInteractive: boolean; repoRoot: string; @@ -29,23 +35,31 @@ export interface WorktreeBootstrapOptions { worktreePath: string; } +export interface WorktreeBootstrapResult { + clonedDirs: readonly ClonedDirectory[]; + dependencyBootstrap: DependencyBootstrapReport; + ready: boolean; +} + export async function bootstrapWorktree( options: WorktreeBootstrapOptions, -): Promise { - const directories = options.config.syncDirs ?? []; - const plan = await prepareSyncDirectoryPlan( +): Promise { + const dependencyMode = options.config.dependencyBootstrap ?? "off"; + const dependencyPlan = await prepareDependencyBootstrap(dependencyMode, { + currentRoot: options.currentRoot, + repoRoot: options.repoRoot, + runCommand: + options.installDependencies?.runInstallCommand ?? runInstallCommand, + stderr: options.reporter.write, + worktreePath: options.worktreePath, + }); + const syncPlan = await prepareSyncDirectoryPlan( options.repoRoot, options.worktreePath, - directories, + options.config.syncDirs ?? [], ); - const postProcessor = await createDependencyClonePostProcessor( - options.worktreePath, - options.repoRoot, - directories, - ); - const outcomes = await executeSyncDirectoryPlan(plan, { + const outcomes = await executeSyncDirectoryPlan(syncPlan, { cloneDirectory: options.cloneDirectory ?? cloneDir, - postProcessor, repoRoot: options.repoRoot, reporter: options.reporter, }); @@ -63,17 +77,32 @@ export async function bootstrapWorktree( } } - await maybeRunInstallPrompt( - options.worktreePath, - options.repoRoot, - options.config, - options.reporter.write, - options.installDependencies, - options.nonInteractive, - clonedDirs.some( - ({ dir, installSkipped }) => dir === "node_modules" && installSkipped, - ), - ); + const dependencyBootstrap = await executeDependencyBootstrap(dependencyPlan, { + cloneDirectory: options.cloneDirectory, + repoRoot: options.repoRoot, + reporter: options.reporter, + runCommand: + options.installDependencies?.runInstallCommand ?? runInstallCommand, + }); + + if (dependencyMode === "off") { + await maybeRunInstallPrompt( + options.worktreePath, + options.repoRoot, + options.config, + options.reporter.write, + options.installDependencies, + options.nonInteractive, + ); + } + + if (!dependencyBootstrap.ready) { + return { + clonedDirs, + dependencyBootstrap, + ready: false, + }; + } const hooks = extractHooks(options.config); await runHook( @@ -87,7 +116,7 @@ export async function bootstrapWorktree( options.reporter.write, ); - return clonedDirs; + return { clonedDirs, dependencyBootstrap, ready: true }; } function toErrorMessage(error: unknown): string { diff --git a/website/docs/commands.mdx b/website/docs/commands.mdx index 600f675..fa61fe9 100644 --- a/website/docs/commands.mdx +++ b/website/docs/commands.mdx @@ -207,4 +207,4 @@ Several commands support `--json` so shell scripts and tools can consume structu `gji clean --stale` limits cleanup to clean branch worktrees whose upstream is gone and whose branch is already merged into the configured or remote default branch. -When `syncDirs` is configured, `gji new` clones each available source with filesystem copy-on-write before syncing files, prompting to install, or running `afterCreate`. Unsupported filesystems are skipped without a slow-copy fallback. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, pnpm metadata handling, JSON output, and dry-run behavior. +When `syncDirs` is configured, `gji new` clones each available arbitrary directory with filesystem copy-on-write before syncing files. It never suppresses installation prompts. Opt into `dependencyBootstrap` for deterministic package-manager/build-cache repair after `syncFiles`; unsupported filesystems fall back to install/repair without ordinary copying. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, adapter behavior, JSON output, and dry-run behavior. diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index d350f04..af4417e 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -23,7 +23,8 @@ Use the global file for defaults you want everywhere. Use `.gji.json` when one r | `syncRemote` | Remote used by `gji sync`. | | `syncDefaultBranch` | Branch used as the rebase target. | | `syncFiles` | Files copied from the main worktree into new worktrees. Use global per-repo config for private files. | -| `syncDirs` | Directories cloned with filesystem copy-on-write before files, installs, and hooks. | +| `syncDirs` | Arbitrary directories cloned with filesystem copy-on-write before `syncFiles`. | +| `dependencyBootstrap` | Dependency/build-state policy: `off`, `cow-then-repair`, or `install-only`. | | `skipInstallPrompt` | Disable the automatic install prompt permanently. | | `installSaveTarget` | Persist install prompt choices locally or globally. | | `hooks` | Lifecycle commands for create, enter, and remove events. String hooks run through a shell; array hooks run as argv without a shell. | @@ -37,7 +38,8 @@ Use the global file for defaults you want everywhere. Use `.gji.json` when one r "syncRemote": "origin", "syncDefaultBranch": "main", "syncFiles": [".env.example", ".nvmrc"], - "syncDirs": ["node_modules", ".next"] + "syncDirs": [".next"], + "dependencyBootstrap": "cow-then-repair" } ``` @@ -130,9 +132,9 @@ Use `syncDirs` for large directories such as dependency trees and build caches: The setting is resolved through the same three layers as other normal config values: global defaults, per-repository global overrides, then `.gji.json`. Paths must be relative to the repository root; absolute paths, `..` segments, and any `.git` path are rejected. -On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. For pnpm projects, `node_modules/.modules.yaml` is removed after cloning so pnpm can regenerate it safely. +On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. `syncDirs` remains generic and never special-cases `node_modules`, `.venv`, or `target`. -Cloning happens before `syncFiles`, install prompts, and `afterCreate`. A successful `node_modules` clone skips the install prompt; run the package manager manually when the lockfile changes. Human output reports size and duration, while `--json` exposes `cloned: [{ "dir": "node_modules", "ms": 1200 }]`. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. +Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. ## CLI helpers diff --git a/website/docs/installation.mdx b/website/docs/installation.mdx index 55899f7..a2b4ef4 100644 --- a/website/docs/installation.mdx +++ b/website/docs/installation.mdx @@ -67,11 +67,12 @@ For large dependency trees, configure copy-on-write bootstrap instead of waiting ```json { - "syncDirs": ["node_modules", ".next"] + "syncDirs": [".next"], + "dependencyBootstrap": "cow-then-repair" } ``` -On supported macOS and Linux filesystems, `gji new` clones these directories before files, install prompts, and `afterCreate`. Unsupported filesystems are skipped without an ordinary-copy fallback. A successful `node_modules` clone skips the install prompt; use `gji new --dry-run` to inspect the estimated source sizes. +On supported macOS and Linux filesystems, `gji new` clones configured directories before `syncFiles`. Dependency adapters then run authoritative repair before `afterCreate`: Yarn, pnpm, uv, and Cargo can reuse CoW seeds, while npm remains install-only. Unsupported filesystems never fall back to ordinary copying; they run repair/install from an empty target. Use `gji new --dry-run` to inspect the estimated source sizes and bootstrap strategy. Add an `afterCreate` hook: From 1ec40bca8cfd5703d6428fd57cccc27ce334b0f4 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 23:06:13 +0900 Subject: [PATCH 06/19] Harden bootstrap skip and lock handling --- README.md | 4 +- src/bootstrap-output.ts | 7 +- src/dir-clone.test.ts | 20 +++++ src/dir-clone.ts | 17 ++++- src/new.test.ts | 57 ++++++++++++++ src/new.ts | 3 + src/pr.ts | 7 +- src/sync-directories.ts | 131 +++++++++++++++++++++++---------- src/sync-plan.ts | 37 ++++++++-- src/worktree-bootstrap.ts | 9 ++- website/docs/configuration.mdx | 2 +- 11 files changed, 244 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 5199b67..2e40807 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,10 @@ Use `syncDirs` for large, reproducible directories that should be available imme Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. -The human output includes the cloned size and elapsed time: +The human output includes clone timing; dry-run can provide source-size estimates: ```text -⚡ cloned .next (2.1 GB → 1.2s) +⚡ cloned .next (size unavailable → 1.2s) ``` Use `dependencyBootstrap` when a package manager or build cache needs a reusable seed followed by authoritative repair: diff --git a/src/bootstrap-output.ts b/src/bootstrap-output.ts index 424e242..55457a6 100644 --- a/src/bootstrap-output.ts +++ b/src/bootstrap-output.ts @@ -3,10 +3,11 @@ import type { SyncDirectoryReporter } from "./sync-directories.js"; export function createBootstrapReporter( write: (chunk: string) => void, json: boolean, + measureCloneSize = false, ): SyncDirectoryReporter { return { emitCachedFailureWarnings: !json, - measureCloneSize: !json, + measureCloneSize: measureCloneSize && !json, write, cloned: (directory) => { if (json) return; @@ -14,6 +15,10 @@ export function createBootstrapReporter( `⚡ cloned ${directory.dir} (${formatBytes(directory.bytes)} → ${formatDuration(directory.ms)})\n`, ); }, + skipped: (directory) => { + if (json) return; + write(`gji: skipped ${directory.dir} — ${directory.reason}\n`); + }, dependency: (event) => { if (json) return; const target = event.target ? ` ${event.target}` : ""; diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 3c3c99b..3a0c73f 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { cloneDir, isCloneDestinationExistsError, + isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; @@ -146,6 +147,25 @@ describe("cloneDir", () => { await expect(readdir(root)).resolves.toEqual(["source"]); }); + it("reports an active clone lock separately from a real destination conflict", async () => { + // Given a fresh clone lock for an absent destination. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-lock-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await mkdir(`${destination}.gji-clone-lock`); + + // When another clone attempts the same destination. + const error = await cloneDir(source, destination, { + platform: "linux", + }).catch((caught) => caught); + + // Then the caller can report an active operation instead of pretending the destination exists. + expect(isCloneInProgressError(error)).toBe(true); + expect(isCloneDestinationExistsError(error)).toBe(false); + await expect(readdir(root)).resolves.toContain("source"); + }); + it("never falls back to an ordinary copy on the default platform", async () => { // Given a source directory and a destination on the test filesystem. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-default-")); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index ca7236c..63060b4 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -142,6 +142,15 @@ export class CloneUnsupportedError extends Error { } } +export class CloneInProgressError extends Error { + readonly code = "GJI_CLONE_IN_PROGRESS"; + + constructor(destination: string) { + super(`copy-on-write clone already in progress: ${destination}`); + this.name = "CloneInProgressError"; + } +} + export function isCloneDestinationExistsError( error: unknown, ): error is CloneDestinationExistsError { @@ -154,6 +163,12 @@ export function isCloneUnsupportedError(error: unknown): boolean { ); } +export function isCloneInProgressError( + error: unknown, +): error is CloneInProgressError { + return error instanceof CloneInProgressError; +} + export async function directorySize(path: string): Promise { const stats = await lstat(path); if (!stats.isDirectory()) return stats.size; @@ -253,7 +268,7 @@ async function acquireCloneLock( if (!isNotFoundError(error)) throw error; } - throw new CloneDestinationExistsError(destination); + throw new CloneInProgressError(destination); } function isUnsupportedCloneError(error: unknown): boolean { diff --git a/src/new.test.ts b/src/new.test.ts index ca462fc..29680b6 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -1019,6 +1019,32 @@ describe("gji new", () => { }); }); + it("reports skipped generic syncDirs outcomes in JSON output", async () => { + // Given a configured directory whose source does not exist. + const repoRoot = await createRepository(); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["missing-cache"] }), + "utf8", + ); + const stdout: string[] = []; + + // When gji new emits machine-readable bootstrap output. + const result = await createNewCommand()({ + branch: "feature/bootstrap-skipped-json", + cwd: repoRoot, + json: true, + stderr: () => undefined, + stdout: (chunk) => stdout.push(chunk), + }); + + // Then automation can distinguish a skipped configured directory from a successful clone. + expect(result).toBe(0); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + skipped: [{ dir: "missing-cache", reason: "source does not exist" }], + }); + }); + it("cleans partial clones and caches the failure for later worktrees", async () => { // Given a repository and an isolated state directory. const repoRoot = await createRepository(); @@ -1191,6 +1217,37 @@ describe("gji new", () => { expect(stderr.join("")).toContain("source is not a directory"); }); + it("skips a destination whose ancestor is a file", async () => { + // Given a branch that already contains a file where a sync directory ancestor is needed. + const repoRoot = await createRepository(); + await commitFile(repoRoot, "cache", "file\n", "Add file ancestor"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["cache/nested"] }), + "utf8", + ); + let cloneCalled = false; + const stderr: string[] = []; + + // When gji new prepares the blocked destination. + const result = await createNewCommand({ + cloneDir: async () => { + cloneCalled = true; + return { ms: 1 }; + }, + })({ + branch: "feature/destination-file", + cwd: repoRoot, + stderr: (chunk) => stderr.push(chunk), + stdout: () => undefined, + }); + + // Then creation continues without attempting an unsafe clone. + expect(result).toBe(0); + expect(cloneCalled).toBe(false); + expect(stderr.join("")).toContain("non-directory ancestor"); + }); + it("does not let a generic node_modules clone skip the npm install prompt", async () => { // Given an npm repository with a configured node_modules directory. const repoRoot = await createRepository(); diff --git a/src/new.ts b/src/new.ts index 5b4da80..98fd598 100644 --- a/src/new.ts +++ b/src/new.ts @@ -454,6 +454,7 @@ export function createNewCommand( if (!bootstrap.ready) { return emitNewError(options, "dependency bootstrap failed", { dependencyBootstrap: bootstrap.dependencyBootstrap, + skipped: bootstrap.skippedDirs, }); } @@ -479,6 +480,8 @@ export function createNewCommand( ms, })); } + if (bootstrap.skippedDirs.length > 0) + output.skipped = bootstrap.skippedDirs; if (bootstrap.dependencyBootstrap.mode !== "off") output.dependencyBootstrap = bootstrap.dependencyBootstrap; options.stdout(`${JSON.stringify(output, null, 2)}\n`); diff --git a/src/pr.ts b/src/pr.ts index 874bc05..553c554 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -241,7 +241,10 @@ export function createPrCommand( installDependencies: dependencies, }); if (!bootstrap.ready) { - const details = { dependencyBootstrap: bootstrap.dependencyBootstrap }; + const details = { + dependencyBootstrap: bootstrap.dependencyBootstrap, + skipped: bootstrap.skippedDirs, + }; if (options.json) { options.stderr( `${JSON.stringify({ error: "dependency bootstrap failed", ...details }, null, 2)}\n`, @@ -266,6 +269,8 @@ export function createPrCommand( ms, })); } + if (bootstrap.skippedDirs.length > 0) + output.skipped = bootstrap.skippedDirs; if (bootstrap.dependencyBootstrap.mode !== "off") output.dependencyBootstrap = bootstrap.dependencyBootstrap; options.stdout(`${JSON.stringify(output, null, 2)}\n`); diff --git a/src/sync-directories.ts b/src/sync-directories.ts index ca31fa5..3d54992 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -10,6 +10,7 @@ import type { } from "./dir-clone.js"; import { isCloneDestinationExistsError, + isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; import type { SyncDirectoryPlan } from "./sync-plan.js"; @@ -29,6 +30,7 @@ export interface SyncDirectoryReporter { readonly measureCloneSize: boolean; write(message: string): void; cloned(directory: ClonedDirectory): void; + skipped?(directory: { dir: string; reason: string }): void; dependency?(event: { adapter: string; kind: "dependency" | "build-cache"; @@ -59,46 +61,69 @@ export async function executeSyncDirectoryPlan( const outcomes: SyncDirectoryOutcome[] = []; for (const entry of plan) { - if (await pathExists(entry.destination)) { - outcomes.push({ - kind: "skipped", - dir: entry.directory, - reason: "destination already exists", - }); + if (entry.destinationWarning) { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + entry.destinationWarning, + ); continue; } - if (entry.warning) { - options.reporter.write( - `syncDirs: ${entry.warning}, skipped ${entry.directory}\n`, + const destinationState = await inspectDestination(entry.destination); + if (destinationState === "exists") { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "destination already exists", ); - outcomes.push({ - kind: "skipped", - dir: entry.directory, - reason: entry.warning, - }); + continue; + } + if (destinationState === "blocked") { + const reason = "destination has a non-directory ancestor"; + recordSkipped(outcomes, options.reporter, entry.directory, reason); + continue; + } + + if (entry.warning) { + recordSkipped(outcomes, options.reporter, entry.directory, entry.warning); continue; } if (!entry.source) { - outcomes.push({ - kind: "skipped", - dir: entry.directory, - reason: "source does not exist", - }); + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "source does not exist", + ); continue; } if (await failureStore.isCached(options.repoRoot, entry.directory)) { - if (options.reporter.emitCachedFailureWarnings) { + if ( + options.reporter.emitCachedFailureWarnings || + options.reporter.skipped + ) { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "copy-on-write failure cached", + ); + } else { options.reporter.write( `syncDirs: previous copy-on-write failure cached, skipped ${entry.directory}\n`, ); + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "copy-on-write failure cached", + false, + ); } - outcomes.push({ - kind: "skipped", - dir: entry.directory, - reason: "copy-on-write failure cached", - }); continue; } @@ -114,11 +139,20 @@ export async function executeSyncDirectoryPlan( ); } catch (error) { if (isCloneDestinationExistsError(error)) { - outcomes.push({ - kind: "skipped", - dir: entry.directory, - reason: "destination already exists", - }); + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "destination already exists", + ); + continue; + } + if (isCloneInProgressError(error)) { + const reason = "copy-on-write clone already in progress"; + options.reporter.write( + `syncDirs: ${reason}, skipped ${entry.directory}\n`, + ); + recordSkipped(outcomes, options.reporter, entry.directory, reason); continue; } @@ -133,11 +167,7 @@ export async function executeSyncDirectoryPlan( `syncDirs: clone failed (${reason}), skipped ${entry.directory}\n`, ); } - outcomes.push({ - kind: "skipped", - dir: entry.directory, - reason, - }); + recordSkipped(outcomes, options.reporter, entry.directory, reason, false); continue; } @@ -155,12 +185,29 @@ export async function executeSyncDirectoryPlan( return outcomes; } -async function pathExists(path: string): Promise { +function recordSkipped( + outcomes: SyncDirectoryOutcome[], + reporter: SyncDirectoryReporter, + dir: string, + reason: string, + notify = true, +): void { + outcomes.push({ kind: "skipped", dir, reason }); + if (notify) { + if (reporter.skipped) reporter.skipped({ dir, reason }); + else reporter.write(`syncDirs: ${reason}, skipped ${dir}\n`); + } +} + +async function inspectDestination( + path: string, +): Promise<"exists" | "missing" | "blocked"> { try { await lstat(path); - return true; + return "exists"; } catch (error) { - if (isNotFoundError(error)) return false; + if (isNotFoundError(error)) return "missing"; + if (isNotDirectoryError(error)) return "blocked"; throw error; } } @@ -173,6 +220,14 @@ function isNotFoundError(error: unknown): boolean { ); } +function isNotDirectoryError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOTDIR" + ); +} + function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/sync-plan.ts b/src/sync-plan.ts index da92345..1a92fcb 100644 --- a/src/sync-plan.ts +++ b/src/sync-plan.ts @@ -8,6 +8,7 @@ export interface SyncDirectoryPlan { directory: string; destination: string; destinationExists: boolean; + destinationWarning?: string; source?: string; warning?: string; } @@ -29,16 +30,27 @@ export async function prepareSyncDirectoryPlan( for (const directory of normalizedDirectories) { const destination = join(worktreePath, directory); - const destinationExists = await pathExists(destination); + const destinationState = await inspectDestination(destination); + const destinationExists = destinationState === "exists"; + const destinationWarning = + destinationState === "blocked" + ? "destination has a non-directory ancestor" + : undefined; try { const source = await resolveSyncDirectorySource(repoRoot, directory); if (!source) { - plan.push({ directory, destination, destinationExists }); + plan.push({ + directory, + destination, + destinationExists, + destinationWarning, + }); } else if ("warning" in source) { plan.push({ directory, destination, destinationExists, + destinationWarning, warning: source.warning, }); } else { @@ -46,6 +58,7 @@ export async function prepareSyncDirectoryPlan( directory, destination, destinationExists, + destinationWarning, source: source.path, }); } @@ -54,6 +67,7 @@ export async function prepareSyncDirectoryPlan( directory, destination, destinationExists, + destinationWarning, warning: `could not inspect ${directory}: ${toErrorMessage(error)}`, }); } @@ -69,6 +83,7 @@ export async function estimateSyncDirectoryPlan( for (const entry of plan) { if ( entry.destinationExists || + entry.destinationWarning || !entry.source || entry.warning || isCoveredByCloneAncestor(entry, plan) @@ -135,6 +150,7 @@ function isCoveredByCloneAncestor( (candidate) => candidate !== entry && !candidate.destinationExists && + !candidate.destinationWarning && !!candidate.source && isPathInside(candidate.directory, entry.directory), ); @@ -154,12 +170,15 @@ function isPathInside(parent: string, child: string): boolean { ); } -async function pathExists(path: string): Promise { +async function inspectDestination( + path: string, +): Promise<"exists" | "missing" | "blocked"> { try { await lstat(path); - return true; + return "exists"; } catch (error) { - if (isNotFoundError(error)) return false; + if (isNotFoundError(error)) return "missing"; + if (isNotDirectoryError(error)) return "blocked"; throw error; } } @@ -172,6 +191,14 @@ function isNotFoundError(error: unknown): boolean { ); } +function isNotDirectoryError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOTDIR" + ); +} + function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 3a30fe9..1c47960 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -39,6 +39,7 @@ export interface WorktreeBootstrapResult { clonedDirs: readonly ClonedDirectory[]; dependencyBootstrap: DependencyBootstrapReport; ready: boolean; + skippedDirs: readonly { dir: string; reason: string }[]; } export async function bootstrapWorktree( @@ -66,6 +67,11 @@ export async function bootstrapWorktree( const clonedDirs = outcomes.flatMap((outcome) => outcome.kind === "cloned" ? [outcome.directory] : [], ); + const skippedDirs = outcomes.flatMap((outcome) => + outcome.kind === "skipped" + ? [{ dir: outcome.dir, reason: outcome.reason }] + : [], + ); for (const pattern of options.config.syncFiles ?? []) { try { @@ -101,6 +107,7 @@ export async function bootstrapWorktree( clonedDirs, dependencyBootstrap, ready: false, + skippedDirs, }; } @@ -116,7 +123,7 @@ export async function bootstrapWorktree( options.reporter.write, ); - return { clonedDirs, dependencyBootstrap, ready: true }; + return { clonedDirs, dependencyBootstrap, ready: true, skippedDirs }; } function toErrorMessage(error: unknown): string { diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index af4417e..3413686 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -134,7 +134,7 @@ The setting is resolved through the same three layers as other normal config val On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. `syncDirs` remains generic and never special-cases `node_modules`, `.venv`, or `target`. -Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. +Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed` without an extra full-tree size traversal, while `--json` exposes structured events. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. ## CLI helpers From 63c5cb04a610b31202214b7cfc98a51d340013d5 Mon Sep 17 00:00:00 2001 From: sjquant Date: Wed, 22 Jul 2026 00:50:51 +0900 Subject: [PATCH 07/19] Harden dependency bootstrap planning and safety --- README.md | 3 +- scripts/generate-man.mjs | 1 + src/bootstrap-output.ts | 8 +- src/clone-failure-store.test.ts | 24 ++ src/clone-failure-store.ts | 67 +++- src/completion.test.ts | 2 +- src/config.test.ts | 1 + src/config.ts | 3 + src/dependency-bootstrap.test.ts | 99 +++++- src/dependency-bootstrap.ts | 519 ++++++++++++++++++++----------- src/new.test.ts | 57 +++- src/new.ts | 16 +- src/pr.ts | 16 +- src/sync-directories.ts | 34 +- src/worktree-bootstrap.ts | 55 +++- website/docs/configuration.mdx | 3 + 16 files changed, 645 insertions(+), 263 deletions(-) diff --git a/README.md b/README.md index 2e40807..efd6585 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ No setup required. Optional config lives in: | `syncFiles` | files to copy from main worktree into each new worktree; use global per-repo config for private files | | `syncDirs` | arbitrary directories to clone with filesystem copy-on-write before sync files | | `dependencyBootstrap` | dependency/build-state policy: `off`, `cow-then-repair`, or `install-only` | +| `dependencyBuildCommand` | optional Cargo repair command used by `dependencyBootstrap` (default: `cargo check`) | | `skipInstallPrompt` | `true` to disable the auto-install prompt permanently | | `installSaveTarget` | `"local"` or `"global"` — where **Always**/**Never** choices are persisted (default: `"local"`); set during `gji init --write` | | `hooks` | lifecycle scripts (see [Hooks](#hooks)) | @@ -358,7 +359,7 @@ Use `dependencyBootstrap` when a package manager or build cache needs a reusable } ``` -`cow-then-repair` supports Yarn (`--immutable`), pnpm (`--frozen-lockfile`), uv (`--locked`), and Cargo (`cargo check`). npm uses install-only `npm ci` because it can delete an existing dependency tree. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`. A successful dependency seed is reported as `reused and repaired`, not as an install skip. +`cow-then-repair` supports Yarn (`--immutable`), pnpm (`--frozen-lockfile`), uv (`--locked`), and Cargo (the configured `dependencyBuildCommand`, or `cargo check`). npm uses install-only `npm ci` because it can delete an existing dependency tree. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array and structured `dependencyBootstrap` events. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. diff --git a/scripts/generate-man.mjs b/scripts/generate-man.mjs index f89d8b6..2cfd1f8 100644 --- a/scripts/generate-man.mjs +++ b/scripts/generate-man.mjs @@ -132,6 +132,7 @@ function subcommandManPage(cmd) { out += `.SH DESCRIPTION\n${esc(desc)}\n`; if (cmd.name() === "new" || cmd.name() === "pr") { + out += `.SH BOOTSTRAP_POLICY\n${esc("dependencyBuildCommand overrides Cargo's default cargo check. npm is always install-only, and sync-file failures stop dependency repair and after-create hooks.\n")}\n`; out += `.SH BOOTSTRAP\n${esc("syncDirs performs generic copy-on-write directory seeding before syncFiles. It never falls back to ordinary copying and never suppresses installation prompts. Set dependencyBootstrap to off, cow-then-repair, or install-only to enable deterministic package-manager or build-cache repair. The lifecycle is CoW seed, syncFiles, repair or install, then after-create. CoW failures fall back to repair from an empty target; partial clones are removed and existing targets are never deleted.\n")}\n`; } diff --git a/src/bootstrap-output.ts b/src/bootstrap-output.ts index 55457a6..1b3f211 100644 --- a/src/bootstrap-output.ts +++ b/src/bootstrap-output.ts @@ -1,10 +1,14 @@ +import type { + BootstrapEvent, + DependencyBootstrapReporter, +} from "./dependency-bootstrap.js"; import type { SyncDirectoryReporter } from "./sync-directories.js"; export function createBootstrapReporter( write: (chunk: string) => void, json: boolean, measureCloneSize = false, -): SyncDirectoryReporter { +): SyncDirectoryReporter & DependencyBootstrapReporter { return { emitCachedFailureWarnings: !json, measureCloneSize: measureCloneSize && !json, @@ -19,7 +23,7 @@ export function createBootstrapReporter( if (json) return; write(`gji: skipped ${directory.dir} — ${directory.reason}\n`); }, - dependency: (event) => { + dependency: (event: BootstrapEvent) => { if (json) return; const target = event.target ? ` ${event.target}` : ""; write(`gji: ${event.state}${target} — ${event.message}\n`); diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts index b56dcf7..443c2cb 100644 --- a/src/clone-failure-store.test.ts +++ b/src/clone-failure-store.test.ts @@ -33,4 +33,28 @@ describe("FileCloneFailureStore", () => { "syncDirs", ); }); + + it("keeps scoped dependency failures separate", async () => { + // Given an isolated state directory with one scoped CoW failure. + const root = await mkdtemp(join(tmpdir(), "gji-state-scoped-")); + process.env.GJI_CONFIG_DIR = root; + const store = new FileCloneFailureStore(); + await store.cache("/repo", "node_modules", "unsupported", "dependency-a"); + + // When two bootstrap scopes query the same logical directory. + const sameScope = await store.isCached( + "/repo", + "node_modules", + "dependency-a", + ); + const differentScope = await store.isCached( + "/repo", + "node_modules", + "dependency-b", + ); + + // Then a failure from one source/filesystem scope does not suppress another. + expect(sameScope).toBe(true); + expect(differentScope).toBe(false); + }); }); diff --git a/src/clone-failure-store.ts b/src/clone-failure-store.ts index 7b21c93..d30756c 100644 --- a/src/clone-failure-store.ts +++ b/src/clone-failure-store.ts @@ -5,6 +5,7 @@ import { readFile, rename, rm, + stat, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; @@ -26,20 +27,34 @@ interface CloneFailureState { } export interface CloneFailureStore { - isCached(repoRoot: string, directory: string): Promise; - cache(repoRoot: string, directory: string, reason: string): Promise; - clear(repoRoot: string, directory: string): Promise; + isCached( + repoRoot: string, + directory: string, + scope?: string, + ): Promise; + cache( + repoRoot: string, + directory: string, + reason: string, + scope?: string, + ): Promise; + clear(repoRoot: string, directory: string, scope?: string): Promise; } export class FileCloneFailureStore implements CloneFailureStore { private updateQueue = Promise.resolve(); - async isCached(repoRoot: string, directory: string): Promise { + async isCached( + repoRoot: string, + directory: string, + scope?: string, + ): Promise { const state = await this.readState(); const repoState = state.syncDirs?.[repoRoot]; - if (!repoState || !Object.hasOwn(repoState, directory)) return false; + const key = failureKey(directory, scope); + if (!repoState || !Object.hasOwn(repoState, key)) return false; - const failure = repoState[directory]; + const failure = repoState[key]; return ( isPlainObject(failure) && typeof failure.failedAt === "number" && @@ -51,29 +66,36 @@ export class FileCloneFailureStore implements CloneFailureStore { repoRoot: string, directory: string, reason: string, + scope?: string, ): Promise { await this.update(async () => { const state = await this.readState(); const syncDirs = state.syncDirs ?? {}; const repoState = syncDirs[repoRoot] ?? {}; + const key = failureKey(directory, scope); syncDirs[repoRoot] = { ...repoState, - [directory]: { failedAt: Date.now(), reason }, + [key]: { failedAt: Date.now(), reason }, }; await this.writeState({ ...state, syncDirs }); }); } - async clear(repoRoot: string, directory: string): Promise { + async clear( + repoRoot: string, + directory: string, + scope?: string, + ): Promise { await this.update(async () => { const state = await this.readState(); const repoState = state.syncDirs?.[repoRoot]; - if (!repoState || !Object.hasOwn(repoState, directory)) return; + const key = failureKey(directory, scope); + if (!repoState || !Object.hasOwn(repoState, key)) return; const nextRepoState = { ...repoState }; - delete nextRepoState[directory]; + delete nextRepoState[key]; const syncDirs = { ...state.syncDirs }; if (Object.keys(nextRepoState).length === 0) delete syncDirs[repoRoot]; else syncDirs[repoRoot] = nextRepoState; @@ -141,6 +163,31 @@ export class FileCloneFailureStore implements CloneFailureStore { export const defaultCloneFailureStore: CloneFailureStore = new FileCloneFailureStore(); +export async function cloneFailureScope( + source: string, + destination: string, +): Promise { + const sourcePath = resolve(source); + const destinationParent = resolve(dirname(destination)); + const [sourceDevice, destinationDevice] = await Promise.all([ + readDevice(sourcePath), + readDevice(destinationParent), + ]); + return JSON.stringify([sourcePath, sourceDevice, destinationDevice]); +} + +async function readDevice(path: string): Promise { + try { + return (await stat(path)).dev; + } catch { + return undefined; + } +} + +function failureKey(directory: string, scope?: string): string { + return scope === undefined ? directory : JSON.stringify([scope, directory]); +} + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/completion.test.ts b/src/completion.test.ts index da7c7d6..64548ba 100644 --- a/src/completion.test.ts +++ b/src/completion.test.ts @@ -95,7 +95,7 @@ describe("gji completion", () => { expect(stdout.join("")).toContain('branch = ($1 == "*" ? $2 : $1)'); expect(stdout.join("")).toContain("_values 'config action' get set unset"); expect(stdout.join("")).toContain( - "_values 'config key' branchPrefix dependencyBootstrap editor hooks installSaveTarget shellIntegration skipInstallPrompt syncDirs syncDefaultBranch syncFiles syncRemote worktreePath repos", + "_values 'config key' branchPrefix dependencyBuildCommand dependencyBootstrap editor hooks installSaveTarget shellIntegration skipInstallPrompt syncDirs syncDefaultBranch syncFiles syncRemote worktreePath repos", ); expect(stdout.join("")).toContain(`case "\${words[3]}" in`); expect(stdout.join("")).toContain("get|unset)"); diff --git a/src/config.test.ts b/src/config.test.ts index dd271e1..687b592 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -614,6 +614,7 @@ describe("KNOWN_CONFIG_KEYS", () => { it("includes the keys used by commands", () => { for (const key of [ "branchPrefix", + "dependencyBuildCommand", "dependencyBootstrap", "hooks", "syncFiles", diff --git a/src/config.ts b/src/config.ts index 697adc1..9c0db0e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ export const GLOBAL_CONFIG_NAME = "config.json"; export const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([ "branchPrefix", + "dependencyBuildCommand", "dependencyBootstrap", "editor", "hooks", @@ -42,6 +43,7 @@ export type DependencyBootstrapMode = export interface EffectiveGjiConfig extends GjiConfig { branchPrefix?: string; + dependencyBuildCommand?: string; dependencyBootstrap?: DependencyBootstrapMode; editor?: string; hooks?: Record; @@ -405,6 +407,7 @@ function toEffectiveConfig(config: GjiConfig): EffectiveGjiConfig { } for (const key of [ "branchPrefix", + "dependencyBuildCommand", "editor", "installSaveTarget", "shellIntegration", diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts index d94b22e..3c8ea4f 100644 --- a/src/dependency-bootstrap.test.ts +++ b/src/dependency-bootstrap.test.ts @@ -14,8 +14,12 @@ import { describe, expect, it } from "vitest"; import { executeDependencyBootstrap, prepareDependencyBootstrap, + previewDependencyBootstrap, } from "./dependency-bootstrap.js"; -import { CloneUnsupportedError } from "./dir-clone.js"; +import { + CloneDestinationExistsError, + CloneUnsupportedError, +} from "./dir-clone.js"; function createFailureStore() { const failures = new Set(); @@ -59,14 +63,38 @@ async function prepareNodePlan( const plan = await prepareDependencyBootstrap(mode, { repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); return { repoRoot, worktreePath, plan }; } describe("dependencyBootstrap adapters", () => { + it("reports npm as install-only in the effective dry-run strategy", async () => { + // Given an npm lockfile and cow-then-repair configuration. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-npm-preview-repo-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-npm-preview-worktree-"), + ); + await writeFile(join(repoRoot, "package-lock.json"), "{}\n"); + + // When the bootstrap plan is previewed. + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + worktreePath, + }); + + // Then the preview describes the same install-only behavior as execution. + expect(previewDependencyBootstrap(plan).targets).toEqual([ + expect.objectContaining({ + adapter: "npm", + seedable: false, + strategy: "install-only", + }), + ]); + }); + it("seeds node_modules with CoW and always runs the frozen pnpm repair", async () => { // Given a pnpm source with a reusable dependency tree and a fresh worktree. const { repoRoot, worktreePath, plan } = @@ -118,8 +146,6 @@ describe("dependencyBootstrap adapters", () => { await writeFile(join(repoRoot, "package-lock.json"), "{}\n"); const plan = await prepareDependencyBootstrap("cow-then-repair", { repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); const reporter = createReporter(); @@ -182,6 +208,36 @@ describe("dependencyBootstrap adapters", () => { void worktreePath; }); + it("preserves a destination that appears during CoW seeding", async () => { + // Given a fresh target that another process publishes during the clone race. + const { repoRoot, worktreePath, plan } = + await prepareNodePlan("cow-then-repair"); + const reporter = createReporter(); + + // When the cloner reports that the destination appeared after creating it. + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "published.txt"), "keep\n"); + throw new CloneDestinationExistsError(destination); + }, + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async () => undefined, + }); + + // Then repair can use the published target without deleting it. + expect(result.ready).toBe(true); + await expect( + readFile(join(worktreePath, "node_modules", "published.txt"), "utf8"), + ).resolves.toBe("keep\n"); + expect(result.events.map(({ state }) => state)).toEqual([ + "skipped", + "repaired", + ]); + }); + it("removes a failed seed before retrying clean and preserves no partial clone", async () => { // Given a source whose first repair fails after a successful seed. const { repoRoot, worktreePath, plan } = @@ -231,8 +287,6 @@ describe("dependencyBootstrap adapters", () => { await writeFile(join(worktreePath, "node_modules", "keep.txt"), "keep\n"); const plan = await prepareDependencyBootstrap("cow-then-repair", { repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); const reporter = createReporter(); @@ -268,8 +322,6 @@ describe("dependencyBootstrap adapters", () => { await symlink(external, join(repoRoot, "node_modules")); const plan = await prepareDependencyBootstrap("cow-then-repair", { repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); const reporter = createReporter(); @@ -305,8 +357,6 @@ describe("dependencyBootstrap adapters", () => { const plan = await prepareDependencyBootstrap("cow-then-repair", { checkUvRuntime: async () => true, repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); const commands: string[] = []; @@ -347,8 +397,6 @@ describe("dependencyBootstrap adapters", () => { const plan = await prepareDependencyBootstrap("cow-then-repair", { checkUvRuntime: async () => false, repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); let cloneCalled = false; @@ -384,8 +432,6 @@ describe("dependencyBootstrap adapters", () => { await mkdir(join(repoRoot, "target", "debug"), { recursive: true }); const plan = await prepareDependencyBootstrap("cow-then-repair", { repoRoot, - runCommand: async () => undefined, - stderr: () => undefined, worktreePath, }); const commands: string[] = []; @@ -410,6 +456,29 @@ describe("dependencyBootstrap adapters", () => { expect(result.events[0]?.kind).toBe("build-cache"); expect(result.events.at(-1)?.state).toBe("repaired"); }); + + it("uses a configured Cargo repair command", async () => { + // Given a Cargo lockfile and a repository-specific build repair command. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-cargo-command-repo-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-cargo-command-worktree-"), + ); + await writeFile(join(repoRoot, "Cargo.lock"), "version = 3\n"); + + // When the bootstrap plan is prepared with the configured command. + const plan = await prepareDependencyBootstrap("cow-then-repair", { + cargoBuildCommand: "cargo check --workspace", + repoRoot, + worktreePath, + }); + + // Then Cargo uses the configured repair command in the plan. + expect(plan.targets[0]?.target.repairCommand).toBe( + "cargo check --workspace", + ); + }); }); async function pathExists(path: string): Promise { diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index 7713981..3812a79 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -2,17 +2,21 @@ import { execFile } from "node:child_process"; import { access, lstat, readFile, realpath, rm } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; + import { type CloneFailureStore, + cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js"; import type { DependencyBootstrapMode } from "./config.js"; import { type CloneDirectory, cloneDir, + isCloneDestinationExistsError, + isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; -import type { SyncDirectoryReporter } from "./sync-directories.js"; +import { runInstallCommand } from "./install-prompt.js"; const execFileAsync = promisify(execFile); @@ -24,6 +28,10 @@ export type BootstrapState = | "fallback" | "skipped" | "failed"; +export type BootstrapStrategy = + | "cow-then-repair" + | "repair-only" + | "install-only"; export type BootstrapCommandRunner = ( command: string, @@ -31,13 +39,14 @@ export type BootstrapCommandRunner = ( stderr: (chunk: string) => void, ) => Promise; -export interface BootstrapContext { - repoRoot: string; +export interface BootstrapPreparationContext { sourceRoot: string; worktreePath: string; +} + +export interface BootstrapExecutionContext { runCommand: BootstrapCommandRunner; stderr: (chunk: string) => void; - checkUvRuntime?: (target: BootstrapTarget) => Promise; } export interface BootstrapTarget { @@ -51,16 +60,17 @@ export interface BootstrapTarget { repairCommand: string; repairState: "repaired" | "installed"; existingBeforeBootstrap: boolean; - runCommand: BootstrapCommandRunner; - stderr: (chunk: string) => void; } export interface BootstrapAdapter { readonly kind: BootstrapKind; readonly name: string; - detect(context: BootstrapContext): Promise; + detect(context: BootstrapPreparationContext): Promise; seedPath(target: BootstrapTarget): string; - repair(target: BootstrapTarget): Promise; + repair( + target: BootstrapTarget, + context: BootstrapExecutionContext, + ): Promise; canSeed(target: BootstrapTarget): Promise; } @@ -72,6 +82,7 @@ export interface DependencyBootstrapPlan { export interface PlannedBootstrapTarget { adapter: BootstrapAdapter; target: BootstrapTarget; + seedable: boolean; } export interface BootstrapEvent { @@ -82,6 +93,11 @@ export interface BootstrapEvent { message: string; } +export interface DependencyBootstrapReporter { + readonly measureCloneSize: boolean; + dependency(event: BootstrapEvent): void; +} + export interface DependencyBootstrapReport { mode: DependencyBootstrapMode; ready: boolean; @@ -92,7 +108,8 @@ export interface DependencyBootstrapDependencies { cloneDirectory?: CloneDirectory; failureStore?: CloneFailureStore; runCommand?: BootstrapCommandRunner; - checkUvRuntime?: (target: BootstrapTarget) => Promise; + stderr?: (chunk: string) => void; + seededDirectories?: readonly string[]; } export interface DependencyBootstrapPreview { @@ -102,18 +119,26 @@ export interface DependencyBootstrapPreview { kind: BootstrapKind; target: string; repairCommand: string; - strategy: "cow-then-repair" | "install-only"; + seedable: boolean; + strategy: BootstrapStrategy; }[]; } export async function prepareDependencyBootstrap( mode: DependencyBootstrapMode, - context: Omit & { currentRoot?: string }, + context: { + repoRoot: string; + currentRoot?: string; + worktreePath: string; + checkUvRuntime?: (target: BootstrapTarget) => Promise; + cargoBuildCommand?: string; + }, ): Promise { if (mode === "off") return { mode, targets: [] }; const adapters = createBootstrapAdapters( context.checkUvRuntime ?? defaultCheckUvRuntime, + context.cargoBuildCommand, ); const sourceRoots: string[] = []; for (const sourceRoot of uniquePaths([ @@ -127,22 +152,28 @@ export async function prepareDependencyBootstrap( const targets: PlannedBootstrapTarget[] = []; for (const adapter of adapters) { - let firstCandidate: BootstrapTarget | null = null; + let fallback: PlannedBootstrapTarget | undefined; + let selected: PlannedBootstrapTarget | undefined; + for (const sourceRoot of sourceRoots) { - const target = await adapter.detect({ ...context, sourceRoot }); + const target = await adapter.detect({ + sourceRoot, + worktreePath: context.worktreePath, + }); if (!target) continue; - firstCandidate ??= target; - if (mode === "install-only" || (await adapter.canSeed(target))) { - targets.push({ adapter, target }); + + const seedable = + mode !== "install-only" && (await adapter.canSeed(target)); + const candidate = { adapter, target, seedable }; + fallback ??= candidate; + if (mode === "install-only" || seedable) { + selected = candidate; break; } } - if ( - firstCandidate && - !targets.some(({ adapter: selected }) => selected === adapter) - ) { - targets.push({ adapter, target: firstCandidate }); - } + + const plannedTarget = selected ?? fallback; + if (plannedTarget) targets.push(plannedTarget); } return { mode, targets }; @@ -153,13 +184,13 @@ export function previewDependencyBootstrap( ): DependencyBootstrapPreview { return { mode: plan.mode, - targets: plan.targets.map(({ adapter, target }) => ({ + targets: plan.targets.map(({ adapter, target, seedable }) => ({ adapter: adapter.name, kind: adapter.kind, target: target.relativePath, repairCommand: target.repairCommand, - strategy: - plan.mode === "install-only" ? "install-only" : "cow-then-repair", + seedable, + strategy: bootstrapStrategy(plan.mode, target, seedable), })), }; } @@ -168,7 +199,7 @@ export async function executeDependencyBootstrap( plan: DependencyBootstrapPlan, options: DependencyBootstrapDependencies & { repoRoot: string; - reporter: SyncDirectoryReporter; + reporter: DependencyBootstrapReporter; }, ): Promise { if (plan.mode === "off") return { mode: plan.mode, ready: true, events: [] }; @@ -176,6 +207,11 @@ export async function executeDependencyBootstrap( const events: BootstrapEvent[] = []; const failureStore = options.failureStore ?? defaultCloneFailureStore; const cloneDirectory = options.cloneDirectory ?? cloneDir; + const execution: BootstrapExecutionContext = { + runCommand: options.runCommand ?? runInstallCommand, + stderr: options.stderr ?? (() => undefined), + }; + const seededDirectories = new Set(options.seededDirectories ?? []); if (plan.targets.length === 0) { recordBootstrapEvent(events, options.reporter, { @@ -188,14 +224,16 @@ export async function executeDependencyBootstrap( return { mode: plan.mode, ready: true, events }; } - for (const { adapter, target } of plan.targets) { - if (options.runCommand) target.runCommand = options.runCommand; + for (const { adapter, target, seedable } of plan.targets) { await executeBootstrapTarget( adapter, target, + seedable, plan.mode, cloneDirectory, failureStore, + execution, + seededDirectories.has(target.relativePath), events, options.reporter, options.repoRoot, @@ -212,19 +250,36 @@ export async function executeDependencyBootstrap( async function executeBootstrapTarget( adapter: BootstrapAdapter, target: BootstrapTarget, + seedable: boolean, mode: DependencyBootstrapMode, cloneDirectory: CloneDirectory, failureStore: CloneFailureStore, + execution: BootstrapExecutionContext, + seededBySyncDirs: boolean, events: BootstrapEvent[], - reporter: SyncDirectoryReporter, + reporter: DependencyBootstrapReporter, repoRoot: string, ): Promise { if (mode === "install-only") { - await repairTarget(adapter, target, false, events, reporter); + await repairTarget( + adapter, + target, + execution, + false, + target.existingBeforeBootstrap ? "preserve" : "empty", + events, + reporter, + ); return; } let seeded = false; + const failureScope = seedable + ? await cloneFailureScope(adapter.seedPath(target), target.targetPath) + : undefined; + let ownership: BootstrapTargetOwnership = target.existingBeforeBootstrap + ? "preserve" + : "empty"; if (target.existingBeforeBootstrap) { recordBootstrapEvent(events, reporter, { adapter: adapter.name, @@ -233,16 +288,42 @@ async function executeBootstrapTarget( target: target.relativePath, message: "target already existed; using it as the repair input", }); + } else if (seededBySyncDirs && (await pathExists(target.targetPath))) { + if (seedable) { + seeded = true; + ownership = "syncDirs"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "seeded", + target: target.relativePath, + message: "reusing a seed created by syncDirs", + }); + } else { + ownership = "preserve"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "fallback", + target: target.relativePath, + message: + "syncDirs created a generic target; this adapter uses repair without CoW", + }); + } } else if (await pathExists(target.targetPath)) { - seeded = true; + ownership = "preserve"; recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, - state: "seeded", + state: "skipped", target: target.relativePath, - message: "reusing a seed created by syncDirs", + message: + "target appeared during bootstrap; preserving it as the repair input", }); - } else if (await failureStore.isCached(repoRoot, target.relativePath)) { + } else if ( + seedable && + (await failureStore.isCached(repoRoot, target.relativePath, failureScope)) + ) { recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -250,13 +331,14 @@ async function executeBootstrapTarget( target: target.relativePath, message: "previous CoW failure is cached; repairing from an empty target", }); - } else if (await adapter.canSeed(target)) { + } else if (seedable) { try { await cloneDirectory(adapter.seedPath(target), target.targetPath, { measureBytes: reporter.measureCloneSize, }); - await failureStore.clear(repoRoot, target.relativePath); + await failureStore.clear(repoRoot, target.relativePath, failureScope); seeded = true; + ownership = "adapter"; recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -265,14 +347,46 @@ async function executeBootstrapTarget( message: "seeded with copy-on-write", }); } catch (error) { + if (isCloneDestinationExistsError(error)) { + ownership = "preserve"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "skipped", + target: target.relativePath, + message: + "target appeared during CoW seeding; preserving it as the repair input", + }); + await repairTarget( + adapter, + target, + execution, + false, + ownership, + events, + reporter, + ); + return; + } + if (isCloneInProgressError(error)) { + recordBootstrapFailure( + events, + reporter, + adapter, + target, + `CoW seed is already in progress: ${toErrorMessage(error)}`, + ); + return; + } if (isCloneUnsupportedError(error)) { await failureStore.cache( repoRoot, target.relativePath, toErrorMessage(error), + failureScope, ); } - await removeCreatedTarget(target, true); + await removeTarget(target); recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -291,18 +405,31 @@ async function executeBootstrapTarget( }); } - await repairTarget(adapter, target, seeded, events, reporter); + await repairTarget( + adapter, + target, + execution, + seeded, + ownership, + events, + reporter, + ); } +type BootstrapTargetOwnership = "adapter" | "syncDirs" | "empty" | "preserve"; + async function repairTarget( adapter: BootstrapAdapter, target: BootstrapTarget, + execution: BootstrapExecutionContext, seeded: boolean, + ownership: BootstrapTargetOwnership, events: BootstrapEvent[], - reporter: SyncDirectoryReporter, + reporter: DependencyBootstrapReporter, ): Promise { + const presentBeforeRepair = await pathExists(target.targetPath); try { - await adapter.repair(target); + await adapter.repair(target, execution); recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -313,29 +440,19 @@ async function repairTarget( : "installed or repaired from a clean target", }); } catch (firstError) { - if (target.existingBeforeBootstrap) { - recordBootstrapEvent(events, reporter, { - adapter: adapter.name, - kind: adapter.kind, - state: "failed", - target: target.relativePath, - message: `repair failed: ${toErrorMessage(firstError)}`, - }); - return; - } - if (!seeded) { - await removeCreatedTarget(target, true); - recordBootstrapEvent(events, reporter, { - adapter: adapter.name, - kind: adapter.kind, - state: "failed", - target: target.relativePath, - message: `repair failed: ${toErrorMessage(firstError)}`, - }); + if (ownership !== "adapter" && ownership !== "syncDirs") { + if (!presentBeforeRepair) await removeTarget(target); + recordBootstrapFailure( + events, + reporter, + adapter, + target, + `repair failed: ${toErrorMessage(firstError)}`, + ); return; } - await removeCreatedTarget(target, true); + await removeTarget(target); recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -344,8 +461,9 @@ async function repairTarget( message: `seed repair failed; removed the seed and retrying clean (${toErrorMessage(firstError)})`, }); + const presentBeforeRetry = await pathExists(target.targetPath); try { - await adapter.repair(target); + await adapter.repair(target, execution); recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -354,95 +472,135 @@ async function repairTarget( message: "installed or repaired from a clean target", }); } catch (secondError) { - await removeCreatedTarget(target, true); - recordBootstrapEvent(events, reporter, { - adapter: adapter.name, - kind: adapter.kind, - state: "failed", - target: target.relativePath, - message: `clean repair failed: ${toErrorMessage(secondError)}`, - }); + if (!presentBeforeRetry) await removeTarget(target); + recordBootstrapFailure( + events, + reporter, + adapter, + target, + `clean repair failed: ${toErrorMessage(secondError)}`, + ); } } } +function recordBootstrapFailure( + events: BootstrapEvent[], + reporter: DependencyBootstrapReporter, + adapter: BootstrapAdapter, + target: BootstrapTarget, + message: string, +): void { + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "failed", + target: target.relativePath, + message, + }); +} + function recordBootstrapEvent( events: BootstrapEvent[], - reporter: SyncDirectoryReporter, + reporter: DependencyBootstrapReporter, event: BootstrapEvent, ): void { events.push(event); - reporter.dependency?.(event); + reporter.dependency(event); +} + +async function removeTarget(target: BootstrapTarget): Promise { + if (!target.existingBeforeBootstrap) { + await rm(target.targetPath, { force: true, recursive: true }); + } } -async function removeCreatedTarget( +function bootstrapStrategy( + mode: DependencyBootstrapMode, target: BootstrapTarget, - createdByBootstrap: boolean, -): Promise { - if (target.existingBeforeBootstrap || !createdByBootstrap) return; - await rm(target.targetPath, { force: true, recursive: true }); + seedable: boolean, +): BootstrapStrategy { + if (mode === "install-only" || target.repairState === "installed") { + return "install-only"; + } + return seedable ? "cow-then-repair" : "repair-only"; } function createBootstrapAdapters( - checkUvRuntime: ((target: BootstrapTarget) => Promise) | undefined, + checkUvRuntime: (target: BootstrapTarget) => Promise, + cargoBuildCommand?: string, ): readonly BootstrapAdapter[] { return [ - new LockfileBootstrapAdapter( - "pnpm", - "dependency", - "pnpm-lock.yaml", - "pnpm install --frozen-lockfile", - { - beforeRepair: async (target) => { - if (!target.existingBeforeBootstrap) { - await rm(join(target.targetPath, ".modules.yaml"), { - force: true, - }); - } - }, - }, - ), - new LockfileBootstrapAdapter( - "yarn", - "dependency", - "yarn.lock", - "yarn install --immutable", - ), - new LockfileBootstrapAdapter( - "npm", - "dependency", - "package-lock.json", - "npm ci", - { - canSeed: async () => false, - repairCommand: (target) => - target.existingBeforeBootstrap ? "npm install" : "npm ci", - repairState: "installed", - }, - ), - new LockfileBootstrapAdapter( - "uv", - "dependency", - "uv.lock", - "uv sync --locked", - { - canSeed: checkUvRuntime, + new LockfileBootstrapAdapter({ + name: "pnpm", + kind: "dependency", + lockfile: "pnpm-lock.yaml", + relativePath: "node_modules", + repairCommand: "pnpm install --frozen-lockfile", + beforeRepair: async (target) => { + if (!target.existingBeforeBootstrap) { + await rm(join(target.targetPath, ".modules.yaml"), { + force: true, + }); + } }, - ), - new LockfileBootstrapAdapter( - "cargo", - "build-cache", - "Cargo.lock", - "cargo check", - ), + }), + new LockfileBootstrapAdapter({ + name: "yarn", + kind: "dependency", + lockfile: "yarn.lock", + relativePath: "node_modules", + repairCommand: "yarn install --immutable", + }), + new LockfileBootstrapAdapter({ + name: "npm", + kind: "dependency", + lockfile: "package-lock.json", + relativePath: "node_modules", + repairCommand: "npm ci", + seedPolicy: "never", + repairCommandOverride: (target) => + target.existingBeforeBootstrap ? "npm install" : "npm ci", + repairState: "installed", + }), + new LockfileBootstrapAdapter({ + name: "uv", + kind: "dependency", + lockfile: "uv.lock", + relativePath: ".venv", + repairCommand: "uv sync --locked", + canSeedOverride: checkUvRuntime, + }), + new LockfileBootstrapAdapter({ + name: "cargo", + kind: "build-cache", + lockfile: "Cargo.lock", + relativePath: "target", + repairCommand: cargoBuildCommand?.trim() || "cargo check", + }), ]; } +interface LockfileBootstrapAdapterSpec { + name: string; + kind: BootstrapKind; + lockfile: string; + relativePath: string; + repairCommand: string; + seedPolicy?: "always" | "never"; + canSeedOverride?: (target: BootstrapTarget) => Promise; + beforeRepair?: (target: BootstrapTarget) => Promise; + repairCommandOverride?: (target: BootstrapTarget) => string; + repairState?: "repaired" | "installed"; +} + class LockfileBootstrapAdapter implements BootstrapAdapter { readonly kind: BootstrapKind; readonly name: string; private readonly lockfile: string; + private readonly relativePath: string; private readonly defaultRepairCommand: string; + private readonly seedPolicy: "always" | "never"; private readonly canSeedOverride?: ( target: BootstrapTarget, ) => Promise; @@ -450,66 +608,44 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { private readonly repairCommandOverride?: (target: BootstrapTarget) => string; private readonly repairState: "repaired" | "installed"; - constructor( - name: string, - kind: BootstrapKind, - lockfile: string, - repairCommand: string, - options: { - canSeed?: (target: BootstrapTarget) => Promise; - beforeRepair?: (target: BootstrapTarget) => Promise; - repairCommand?: (target: BootstrapTarget) => string; - repairState?: "repaired" | "installed"; - } = {}, - ) { - this.name = name; - this.kind = kind; - this.lockfile = lockfile; - this.defaultRepairCommand = repairCommand; - this.canSeedOverride = options.canSeed; - this.beforeRepair = options.beforeRepair; - this.repairCommandOverride = options.repairCommand; - this.repairState = options.repairState ?? "repaired"; + constructor(spec: LockfileBootstrapAdapterSpec) { + this.name = spec.name; + this.kind = spec.kind; + this.lockfile = spec.lockfile; + this.relativePath = spec.relativePath; + this.defaultRepairCommand = spec.repairCommand; + this.seedPolicy = spec.seedPolicy ?? "always"; + this.canSeedOverride = spec.canSeedOverride; + this.beforeRepair = spec.beforeRepair; + this.repairCommandOverride = spec.repairCommandOverride; + this.repairState = spec.repairState ?? "repaired"; } - async detect(context: BootstrapContext): Promise { + async detect( + context: BootstrapPreparationContext, + ): Promise { if (!(await pathExists(join(context.sourceRoot, this.lockfile)))) return null; - const relativePath = - this.name === "cargo" - ? "target" - : this.name === "uv" - ? ".venv" - : "node_modules"; - const targetPath = join(context.worktreePath, relativePath); - return { + const sourcePath = join(context.sourceRoot, this.relativePath); + const targetPath = join(context.worktreePath, this.relativePath); + const target = { adapter: this.name, kind: this.kind, - relativePath, + relativePath: this.relativePath, sourceRoot: context.sourceRoot, worktreePath: context.worktreePath, - sourcePath: join(context.sourceRoot, relativePath), + sourcePath, targetPath, - repairCommand: - this.repairCommandOverride?.({ - adapter: this.name, - kind: this.kind, - relativePath, - sourceRoot: context.sourceRoot, - worktreePath: context.worktreePath, - sourcePath: join(context.sourceRoot, relativePath), - targetPath, - repairCommand: this.defaultRepairCommand, - repairState: this.repairState, - existingBeforeBootstrap: await pathExists(targetPath), - runCommand: context.runCommand, - stderr: context.stderr, - }) ?? this.defaultRepairCommand, + repairCommand: this.defaultRepairCommand, repairState: this.repairState, existingBeforeBootstrap: await pathExists(targetPath), - runCommand: context.runCommand, - stderr: context.stderr, + }; + + return { + ...target, + repairCommand: + this.repairCommandOverride?.(target) ?? target.repairCommand, }; } @@ -517,23 +653,22 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { return target.sourcePath; } - async repair(target: BootstrapTarget): Promise { + async repair( + target: BootstrapTarget, + context: BootstrapExecutionContext, + ): Promise { await this.beforeRepair?.(target); - await target.runCommand( + await context.runCommand( target.repairCommand, target.worktreePath, - target.stderr, + context.stderr, ); } async canSeed(target: BootstrapTarget): Promise { - if (this.canSeedOverride) { - return ( - (await safeSourceDirectory(target)) && - (await this.canSeedOverride(target)) - ); - } - return safeSourceDirectory(target); + if (this.seedPolicy === "never") return false; + if (!(await safeSourceDirectory(target))) return false; + return (await this.canSeedOverride?.(target)) ?? true; } } @@ -560,9 +695,23 @@ async function defaultCheckUvRuntime( ); const expected = config.match(/^version\s*=\s*(\d+\.\d+)/mu)?.[1]; if (!expected) return false; - const { stdout, stderr } = await execFileAsync("python3", ["--version"]); - const actual = `${stdout}${stderr}`.match(/Python\s+(\d+\.\d+)/u)?.[1]; - return actual === expected; + + const sourceInterpreter = join( + target.sourcePath, + process.platform === "win32" ? "Scripts/python.exe" : "bin/python", + ); + const fingerprintScript = + "import platform, sys; print(f'{sys.version_info.major}.{sys.version_info.minor}|{platform.machine()}|{sys.implementation.cache_tag}')"; + const [source, current] = await Promise.all([ + execFileAsync(sourceInterpreter, ["-c", fingerprintScript]), + execFileAsync("python3", ["-c", fingerprintScript]), + ]); + const sourceFingerprint = source.stdout.trim(); + const currentFingerprint = current.stdout.trim(); + return ( + sourceFingerprint === currentFingerprint && + sourceFingerprint.startsWith(`${expected}|`) + ); } catch { return false; } diff --git a/src/new.test.ts b/src/new.test.ts index 29680b6..0eac402 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -649,14 +649,17 @@ describe("gji new", () => { ); }); - it("emits a warning for an invalid sync pattern but does not abort", async () => { + it("fails closed when an invalid sync pattern prevents dependency setup", async () => { // Given a repo with an absolute-path pattern in syncFiles (which syncFiles rejects). const repoRoot = await createRepository(); const branchName = "feature/sync-invalid"; const worktreePath = resolveWorktreePath(repoRoot, branchName); await writeFile( join(repoRoot, ".gji.json"), - JSON.stringify({ syncFiles: ["/etc/passwd"] }), + JSON.stringify({ + syncFiles: ["/etc/passwd"], + hooks: { "after-create": "touch after-create-ran" }, + }), "utf8", ); const stderr: string[] = []; @@ -669,11 +672,14 @@ describe("gji new", () => { stdout: () => undefined, }); - // Then the worktree is still created and a warning is emitted for the bad pattern. - expect(result).toBe(0); + // Then the worktree is created but setup stops before install or after-create. + expect(result).toBe(1); await expect(pathExists(worktreePath)).resolves.toBe(true); expect(stderr.join("")).toContain("Warning:"); expect(stderr.join("")).toContain("/etc/passwd"); + await expect( + pathExists(join(worktreePath, "after-create-ran")), + ).resolves.toBe(false); }); it("local syncFiles config overrides global (no array merging)", async () => { @@ -1383,6 +1389,49 @@ describe("gji new", () => { }, }); }); + + it("previews npm as install-only instead of promising a CoW seed", async () => { + // Given an npm repository with cow-then-repair configured. + const repoRoot = await createRepository(); + await writeFile(join(repoRoot, "package-lock.json"), "{}\n", "utf8"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ dependencyBootstrap: "cow-then-repair" }), + "utf8", + ); + const textOutput: string[] = []; + const jsonOutput: string[] = []; + + // When both dry-run output modes inspect the npm bootstrap plan. + const textResult = await runCli( + ["new", "--dry-run", "feature/npm-preview"], + { + cwd: repoRoot, + stdout: (chunk) => textOutput.push(chunk), + }, + ); + const jsonResult = await runCli( + ["new", "--json", "--dry-run", "feature/npm-preview-json"], + { + cwd: repoRoot, + stdout: (chunk) => jsonOutput.push(chunk), + }, + ); + + // Then both interfaces expose install-only behavior. + expect(textResult.exitCode).toBe(0); + expect(textOutput.join("")).toContain( + "Would install node_modules with npm", + ); + expect(jsonResult.exitCode).toBe(0); + expect(JSON.parse(jsonOutput.join(""))).toMatchObject({ + dependencyBootstrap: { + targets: [ + { adapter: "npm", seedable: false, strategy: "install-only" }, + ], + }, + }); + }); }); describe("install prompt", () => { diff --git a/src/new.ts b/src/new.ts index 98fd598..94b96a4 100644 --- a/src/new.ts +++ b/src/new.ts @@ -22,10 +22,7 @@ import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; -import { - type InstallPromptDependencies, - runInstallCommand, -} from "./install-prompt.js"; +import type { InstallPromptDependencies } from "./install-prompt.js"; import { createNavigationRepository, createNavigationTarget, @@ -289,8 +286,7 @@ export function createNewCommand( await prepareDependencyBootstrap(config.dependencyBootstrap ?? "off", { currentRoot: repository.currentRoot, repoRoot: repository.repoRoot, - runCommand: dependencies.runInstallCommand ?? runInstallCommand, - stderr: options.stderr, + cargoBuildCommand: config.dependencyBuildCommand, worktreePath, }), ); @@ -960,11 +956,17 @@ function formatDependencyBootstrapPreview(preview: { return preview.targets .map( ({ adapter, target, strategy }) => - `Would ${strategy === "cow-then-repair" ? "seed and repair" : "install"} ${target} with ${adapter}\n`, + `Would ${formatBootstrapStrategy(strategy)} ${target} with ${adapter}\n`, ) .join(""); } +function formatBootstrapStrategy(strategy: string): string { + if (strategy === "cow-then-repair") return "seed and repair"; + if (strategy === "repair-only") return "repair"; + return "install"; +} + function emitNewError( options: NewCommandOptions, message: string, diff --git a/src/pr.ts b/src/pr.ts index 553c554..2bfad85 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -20,10 +20,7 @@ import { import type { CloneDirectory } from "./dir-clone.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; -import { - type InstallPromptDependencies, - runInstallCommand, -} from "./install-prompt.js"; +import type { InstallPromptDependencies } from "./install-prompt.js"; import { createNavigationRepository, createNavigationTarget, @@ -164,8 +161,7 @@ export function createPrCommand( { currentRoot: repository.currentRoot, repoRoot: repository.repoRoot, - runCommand: dependencies.runInstallCommand ?? runInstallCommand, - stderr: options.stderr, + cargoBuildCommand: config.dependencyBuildCommand, worktreePath, }, ), @@ -394,7 +390,13 @@ function formatDependencyBootstrapPreview( return preview.targets .map( ({ adapter, target, strategy }) => - `Would ${strategy === "cow-then-repair" ? "seed and repair" : "install"} ${target} with ${adapter}\n`, + `Would ${formatBootstrapStrategy(strategy)} ${target} with ${adapter}\n`, ) .join(""); } + +function formatBootstrapStrategy(strategy: string): string { + if (strategy === "cow-then-repair") return "seed and repair"; + if (strategy === "repair-only") return "repair"; + return "install"; +} diff --git a/src/sync-directories.ts b/src/sync-directories.ts index 3d54992..087d9ba 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -1,6 +1,7 @@ import { lstat } from "node:fs/promises"; import { type CloneFailureStore, + cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js"; import type { @@ -31,19 +32,6 @@ export interface SyncDirectoryReporter { write(message: string): void; cloned(directory: ClonedDirectory): void; skipped?(directory: { dir: string; reason: string }): void; - dependency?(event: { - adapter: string; - kind: "dependency" | "build-cache"; - state: - | "seeded" - | "repaired" - | "installed" - | "fallback" - | "skipped" - | "failed"; - target: string; - message: string; - }): void; } export interface SyncDirectoryExecutionOptions { @@ -101,7 +89,16 @@ export async function executeSyncDirectoryPlan( continue; } - if (await failureStore.isCached(options.repoRoot, entry.directory)) { + const failureScope = entry.source + ? await cloneFailureScope(entry.source, entry.destination) + : undefined; + if ( + await failureStore.isCached( + options.repoRoot, + entry.directory, + failureScope, + ) + ) { if ( options.reporter.emitCachedFailureWarnings || options.reporter.skipped @@ -158,7 +155,12 @@ export async function executeSyncDirectoryPlan( const reason = toErrorMessage(error); if (isCloneUnsupportedError(error)) { - await failureStore.cache(options.repoRoot, entry.directory, reason); + await failureStore.cache( + options.repoRoot, + entry.directory, + reason, + failureScope, + ); options.reporter.write( `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${entry.directory}\n`, ); @@ -171,7 +173,7 @@ export async function executeSyncDirectoryPlan( continue; } - await failureStore.clear(options.repoRoot, entry.directory); + await failureStore.clear(options.repoRoot, entry.directory, failureScope); const clonedDirectory: ClonedDirectory = { bytes: result.bytes, dir: entry.directory, diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 1c47960..f901687 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -1,8 +1,10 @@ import { basename } from "node:path"; -import type { EffectiveGjiConfig } from "./config.js"; +import type { DependencyBootstrapMode, EffectiveGjiConfig } from "./config.js"; import { + type BootstrapEvent, type DependencyBootstrapReport, + type DependencyBootstrapReporter, executeDependencyBootstrap, prepareDependencyBootstrap, } from "./dependency-bootstrap.js"; @@ -31,7 +33,7 @@ export interface WorktreeBootstrapOptions { installDependencies?: InstallPromptDependencies; nonInteractive: boolean; repoRoot: string; - reporter: SyncDirectoryReporter; + reporter: SyncDirectoryReporter & DependencyBootstrapReporter; worktreePath: string; } @@ -49,9 +51,7 @@ export async function bootstrapWorktree( const dependencyPlan = await prepareDependencyBootstrap(dependencyMode, { currentRoot: options.currentRoot, repoRoot: options.repoRoot, - runCommand: - options.installDependencies?.runInstallCommand ?? runInstallCommand, - stderr: options.reporter.write, + cargoBuildCommand: options.config.dependencyBuildCommand, worktreePath: options.worktreePath, }); const syncPlan = await prepareSyncDirectoryPlan( @@ -73,23 +73,39 @@ export async function bootstrapWorktree( : [], ); + const syncFileFailures: BootstrapEvent[] = []; for (const pattern of options.config.syncFiles ?? []) { try { await syncFiles(options.repoRoot, options.worktreePath, [pattern]); } catch (error) { - options.reporter.write( - `Warning: failed to sync file "${pattern}": ${toErrorMessage(error)}\n`, - ); + const message = `failed to sync file "${pattern}": ${toErrorMessage(error)}`; + options.reporter.write(`Warning: ${message}\n`); + syncFileFailures.push({ + adapter: "syncFiles", + kind: "dependency", + state: "failed", + target: pattern, + message, + }); } } - const dependencyBootstrap = await executeDependencyBootstrap(dependencyPlan, { - cloneDirectory: options.cloneDirectory, - repoRoot: options.repoRoot, - reporter: options.reporter, - runCommand: - options.installDependencies?.runInstallCommand ?? runInstallCommand, - }); + const dependencyBootstrap = + syncFileFailures.length > 0 + ? reportSyncFileFailure( + dependencyMode, + syncFileFailures, + options.reporter, + ) + : await executeDependencyBootstrap(dependencyPlan, { + cloneDirectory: options.cloneDirectory, + repoRoot: options.repoRoot, + reporter: options.reporter, + stderr: options.reporter.write, + seededDirectories: clonedDirs.map(({ dir }) => dir), + runCommand: + options.installDependencies?.runInstallCommand ?? runInstallCommand, + }); if (dependencyMode === "off") { await maybeRunInstallPrompt( @@ -126,6 +142,15 @@ export async function bootstrapWorktree( return { clonedDirs, dependencyBootstrap, ready: true, skippedDirs }; } +function reportSyncFileFailure( + mode: DependencyBootstrapMode, + events: readonly BootstrapEvent[], + reporter: DependencyBootstrapReporter, +): DependencyBootstrapReport { + for (const event of events) reporter.dependency(event); + return { mode, ready: false, events }; +} + function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index 3413686..8da397e 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -25,6 +25,7 @@ Use the global file for defaults you want everywhere. Use `.gji.json` when one r | `syncFiles` | Files copied from the main worktree into new worktrees. Use global per-repo config for private files. | | `syncDirs` | Arbitrary directories cloned with filesystem copy-on-write before `syncFiles`. | | `dependencyBootstrap` | Dependency/build-state policy: `off`, `cow-then-repair`, or `install-only`. | +| `dependencyBuildCommand` | Optional Cargo repair command used by `dependencyBootstrap`; defaults to `cargo check`. | | `skipInstallPrompt` | Disable the automatic install prompt permanently. | | `installSaveTarget` | Persist install prompt choices locally or globally. | | `hooks` | Lifecycle commands for create, enter, and remove events. String hooks run through a shell; array hooks run as argv without a shell. | @@ -136,6 +137,8 @@ On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupp Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed` without an extra full-tree size traversal, while `--json` exposes structured events. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. +`dependencyBuildCommand` overrides Cargo's default `cargo check`. npm is always install-only, and a sync-file failure stops dependency repair and `after-create` hooks. Dry-run output reports the effective strategy, including repair-only cases where a seed is not compatible. + ## CLI helpers ```bash From 15e1269ed697f3e17f5ed6423e89c696e44f8477 Mon Sep 17 00:00:00 2001 From: sjquant Date: Wed, 22 Jul 2026 23:36:07 +0900 Subject: [PATCH 08/19] Harden worktree bootstrap concurrency and reporting --- README.md | 4 +- scripts/generate-man.mjs | 2 +- src/bootstrap-preview.ts | 40 +++++++ src/clone-failure-store.test.ts | 43 +++++++- src/command-runner.ts | 31 ++++++ src/dependency-bootstrap.test.ts | 52 +++++++-- src/dependency-bootstrap.ts | 41 ++++--- src/dir-clone.test.ts | 76 ++++++++++++- src/dir-clone.ts | 180 +++++++++++++++++++++++-------- src/fs-utils.ts | 31 ++++++ src/install-prompt.ts | 34 +----- src/new.test.ts | 41 ++++++- src/new.ts | 44 ++++---- src/pr.ts | 61 ++++------- src/sync-directories.ts | 24 +---- src/sync-plan.ts | 33 ++---- src/worktree-bootstrap.ts | 27 ++--- website/docs/commands.mdx | 2 +- website/docs/configuration.mdx | 4 +- 19 files changed, 546 insertions(+), 224 deletions(-) create mode 100644 src/bootstrap-preview.ts create mode 100644 src/command-runner.ts create mode 100644 src/fs-utils.ts diff --git a/README.md b/README.md index efd6585..388a9ba 100644 --- a/README.md +++ b/README.md @@ -359,9 +359,9 @@ Use `dependencyBootstrap` when a package manager or build cache needs a reusable } ``` -`cow-then-repair` supports Yarn (`--immutable`), pnpm (`--frozen-lockfile`), uv (`--locked`), and Cargo (the configured `dependencyBuildCommand`, or `cargo check`). npm uses install-only `npm ci` because it can delete an existing dependency tree. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. +`cow-then-repair` supports Yarn (`--immutable`), pnpm (`--frozen-lockfile`), uv (`--locked`), and Cargo (the configured `dependencyBuildCommand`, or `cargo check`). npm uses install-only `npm ci` because it can delete an existing dependency tree. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair, install prompts, and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. -Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array and structured `dependencyBootstrap` events. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. +Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array and structured `dependencyBootstrap` events, including machine-readable reasons for skips and failures. If bootstrap fails, the JSON error includes the created worktree path; text mode prints the same path and a cleanup hint. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. ### Per-repo overrides in global config diff --git a/scripts/generate-man.mjs b/scripts/generate-man.mjs index 2cfd1f8..859ce02 100644 --- a/scripts/generate-man.mjs +++ b/scripts/generate-man.mjs @@ -132,7 +132,7 @@ function subcommandManPage(cmd) { out += `.SH DESCRIPTION\n${esc(desc)}\n`; if (cmd.name() === "new" || cmd.name() === "pr") { - out += `.SH BOOTSTRAP_POLICY\n${esc("dependencyBuildCommand overrides Cargo's default cargo check. npm is always install-only, and sync-file failures stop dependency repair and after-create hooks.\n")}\n`; + out += `.SH BOOTSTRAP_POLICY\n${esc("dependencyBuildCommand overrides Cargo's default cargo check. npm is always install-only, and sync-file failures stop dependency repair, install prompts, and after-create hooks. JSON bootstrap events include structured reasons, and failed setup reports the created worktree path.\n")}\n`; out += `.SH BOOTSTRAP\n${esc("syncDirs performs generic copy-on-write directory seeding before syncFiles. It never falls back to ordinary copying and never suppresses installation prompts. Set dependencyBootstrap to off, cow-then-repair, or install-only to enable deterministic package-manager or build-cache repair. The lifecycle is CoW seed, syncFiles, repair or install, then after-create. CoW failures fall back to repair from an empty target; partial clones are removed and existing targets are never deleted.\n")}\n`; } diff --git a/src/bootstrap-preview.ts b/src/bootstrap-preview.ts new file mode 100644 index 0000000..83351db --- /dev/null +++ b/src/bootstrap-preview.ts @@ -0,0 +1,40 @@ +import type { DependencyBootstrapMode } from "./config.js"; +import { + type BootstrapTarget, + type DependencyBootstrapPreview, + prepareDependencyBootstrap, + previewDependencyBootstrap, +} from "./dependency-bootstrap.js"; + +export async function createDependencyBootstrapPreview( + mode: DependencyBootstrapMode, + context: { + repoRoot: string; + currentRoot?: string; + worktreePath: string; + cargoBuildCommand?: string; + checkUvRuntime?: (target: BootstrapTarget) => Promise; + }, +): Promise { + return previewDependencyBootstrap( + await prepareDependencyBootstrap(mode, context), + ); +} + +export function formatDependencyBootstrapPreview( + preview: DependencyBootstrapPreview | undefined, +): string { + if (!preview) return ""; + return preview.targets + .map( + ({ adapter, target, strategy }) => + `Would ${formatBootstrapStrategy(strategy)} ${target} with ${adapter}\n`, + ) + .join(""); +} + +function formatBootstrapStrategy(strategy: string): string { + if (strategy === "cow-then-repair") return "seed and repair"; + if (strategy === "repair-only") return "repair"; + return "install"; +} diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts index 443c2cb..3c2f77e 100644 --- a/src/clone-failure-store.test.ts +++ b/src/clone-failure-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -57,4 +57,45 @@ describe("FileCloneFailureStore", () => { expect(sameScope).toBe(true); expect(differentScope).toBe(false); }); + + it("does not reuse expired failures", async () => { + // Given an isolated state file containing a failure older than the cache TTL. + const root = await mkdtemp(join(tmpdir(), "gji-state-expired-")); + process.env.GJI_CONFIG_DIR = root; + await writeFile( + join(root, "state.json"), + JSON.stringify({ + syncDirs: { + "/repo": { + node_modules: { + failedAt: Date.now() - 2 * 24 * 60 * 60 * 1000, + reason: "unsupported", + }, + }, + }, + }), + "utf8", + ); + const store = new FileCloneFailureStore(); + + // When the expired entry is queried. + const cached = await store.isCached("/repo", "node_modules"); + + // Then the expired failure no longer suppresses a clone attempt. + expect(cached).toBe(false); + }); + + it("treats malformed state as an empty cache", async () => { + // Given an isolated state file containing invalid JSON. + const root = await mkdtemp(join(tmpdir(), "gji-state-malformed-")); + process.env.GJI_CONFIG_DIR = root; + await writeFile(join(root, "state.json"), "not-json", "utf8"); + const store = new FileCloneFailureStore(); + + // When the malformed cache is queried. + const cached = await store.isCached("/repo", "node_modules"); + + // Then cache corruption remains advisory and does not block setup. + expect(cached).toBe(false); + }); }); diff --git a/src/command-runner.ts b/src/command-runner.ts new file mode 100644 index 0000000..61298f0 --- /dev/null +++ b/src/command-runner.ts @@ -0,0 +1,31 @@ +import { spawn } from "node:child_process"; + +export type CommandRunner = ( + command: string, + cwd: string, + stderr: (chunk: string) => void, +) => Promise; + +export const runCommand: CommandRunner = async (command, cwd, stderr) => { + await new Promise((resolve, reject) => { + const child = spawn(command, { + cwd, + shell: true, + stdio: ["ignore", "inherit", "pipe"], + }); + + (child.stderr as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { + stderr(chunk.toString()); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`exited with code ${code}`)); + } else { + resolve(); + } + }); + + child.on("error", reject); + }); +}; diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts index 3c8ea4f..1b1242d 100644 --- a/src/dependency-bootstrap.test.ts +++ b/src/dependency-bootstrap.test.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { cloneFailureScope } from "./clone-failure-store.js"; import { executeDependencyBootstrap, prepareDependencyBootstrap, @@ -24,13 +25,18 @@ import { function createFailureStore() { const failures = new Set(); return { - isCached: async (repoRoot: string, directory: string) => - failures.has(`${repoRoot}:${directory}`), - cache: async (repoRoot: string, directory: string) => { - failures.add(`${repoRoot}:${directory}`); + isCached: async (repoRoot: string, directory: string, scope?: string) => + failures.has(`${repoRoot}:${directory}:${scope ?? ""}`), + cache: async ( + repoRoot: string, + directory: string, + _reason: string, + scope?: string, + ) => { + failures.add(`${repoRoot}:${directory}:${scope ?? ""}`); }, - clear: async (repoRoot: string, directory: string) => { - failures.delete(`${repoRoot}:${directory}`); + clear: async (repoRoot: string, directory: string, scope?: string) => { + failures.delete(`${repoRoot}:${directory}:${scope ?? ""}`); }, }; } @@ -95,6 +101,29 @@ describe("dependencyBootstrap adapters", () => { ]); }); + it("selects one package manager when multiple lockfiles share node_modules", async () => { + // Given a repository containing pnpm and npm lockfiles with one dependency target. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-multi-manager-repo-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-multi-manager-worktree-"), + ); + await writeFile(join(repoRoot, "pnpm-lock.yaml"), "lockfileVersion: '9'\n"); + await writeFile(join(repoRoot, "package-lock.json"), "{}\n"); + await mkdir(join(repoRoot, "node_modules")); + + // When the dependency plan is prepared. + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + worktreePath, + }); + + // Then the highest-priority adapter owns node_modules exclusively. + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]?.adapter.name).toBe("pnpm"); + }); + it("seeds node_modules with CoW and always runs the frozen pnpm repair", async () => { // Given a pnpm source with a reusable dependency tree and a fresh worktree. const { repoRoot, worktreePath, plan } = @@ -204,7 +233,16 @@ describe("dependencyBootstrap adapters", () => { "fallback", "repaired", ]); - expect(await failureStore.isCached(repoRoot, "node_modules")).toBe(true); + expect( + await failureStore.isCached( + repoRoot, + "node_modules", + await cloneFailureScope( + join(repoRoot, "node_modules"), + join(worktreePath, "node_modules"), + ), + ), + ).toBe(true); void worktreePath; }); diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index 3812a79..14065d4 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -2,12 +2,12 @@ import { execFile } from "node:child_process"; import { access, lstat, readFile, realpath, rm } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; - import { type CloneFailureStore, cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js"; +import { type CommandRunner, runCommand } from "./command-runner.js"; import type { DependencyBootstrapMode } from "./config.js"; import { type CloneDirectory, @@ -16,11 +16,10 @@ import { isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; -import { runInstallCommand } from "./install-prompt.js"; const execFileAsync = promisify(execFile); -export type BootstrapKind = "dependency" | "build-cache"; +export type BootstrapKind = "dependency" | "build-cache" | "sync-file"; export type BootstrapState = | "seeded" | "repaired" @@ -33,11 +32,7 @@ export type BootstrapStrategy = | "repair-only" | "install-only"; -export type BootstrapCommandRunner = ( - command: string, - cwd: string, - stderr: (chunk: string) => void, -) => Promise; +export type BootstrapCommandRunner = CommandRunner; export interface BootstrapPreparationContext { sourceRoot: string; @@ -65,6 +60,7 @@ export interface BootstrapTarget { export interface BootstrapAdapter { readonly kind: BootstrapKind; readonly name: string; + readonly relativePath: string; detect(context: BootstrapPreparationContext): Promise; seedPath(target: BootstrapTarget): string; repair( @@ -88,6 +84,7 @@ export interface PlannedBootstrapTarget { export interface BootstrapEvent { adapter: string; kind: BootstrapKind; + reason?: string; state: BootstrapState; target: string; message: string; @@ -150,8 +147,11 @@ export async function prepareDependencyBootstrap( } } const targets: PlannedBootstrapTarget[] = []; + const plannedRelativePaths = new Set(); for (const adapter of adapters) { + if (plannedRelativePaths.has(adapter.relativePath)) continue; + let fallback: PlannedBootstrapTarget | undefined; let selected: PlannedBootstrapTarget | undefined; @@ -173,7 +173,10 @@ export async function prepareDependencyBootstrap( } const plannedTarget = selected ?? fallback; - if (plannedTarget) targets.push(plannedTarget); + if (plannedTarget) { + targets.push(plannedTarget); + plannedRelativePaths.add(adapter.relativePath); + } } return { mode, targets }; @@ -208,7 +211,7 @@ export async function executeDependencyBootstrap( const failureStore = options.failureStore ?? defaultCloneFailureStore; const cloneDirectory = options.cloneDirectory ?? cloneDir; const execution: BootstrapExecutionContext = { - runCommand: options.runCommand ?? runInstallCommand, + runCommand: options.runCommand ?? runCommand, stderr: options.stderr ?? (() => undefined), }; const seededDirectories = new Set(options.seededDirectories ?? []); @@ -217,6 +220,7 @@ export async function executeDependencyBootstrap( recordBootstrapEvent(events, options.reporter, { adapter: "none", kind: "dependency", + reason: "no-lockfile", state: "skipped", target: "", message: "no supported dependency or build-state lockfile was detected", @@ -284,6 +288,7 @@ async function executeBootstrapTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "target-exists", state: "skipped", target: target.relativePath, message: "target already existed; using it as the repair input", @@ -295,6 +300,7 @@ async function executeBootstrapTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "generic-seed", state: "seeded", target: target.relativePath, message: "reusing a seed created by syncDirs", @@ -304,6 +310,7 @@ async function executeBootstrapTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "generic-seed", state: "fallback", target: target.relativePath, message: @@ -315,6 +322,7 @@ async function executeBootstrapTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "destination-race", state: "skipped", target: target.relativePath, message: @@ -327,6 +335,7 @@ async function executeBootstrapTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "cow-failure-cached", state: "fallback", target: target.relativePath, message: "previous CoW failure is cached; repairing from an empty target", @@ -375,6 +384,7 @@ async function executeBootstrapTarget( adapter, target, `CoW seed is already in progress: ${toErrorMessage(error)}`, + "clone-in-progress", ); return; } @@ -386,10 +396,10 @@ async function executeBootstrapTarget( failureScope, ); } - await removeTarget(target); recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "cow-unsupported", state: "fallback", target: target.relativePath, message: `CoW seed failed; repairing from an empty target (${toErrorMessage(error)})`, @@ -399,6 +409,7 @@ async function executeBootstrapTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "seed-unavailable", state: "fallback", target: target.relativePath, message: "CoW seed is unavailable; repairing from an empty target", @@ -448,6 +459,7 @@ async function repairTarget( adapter, target, `repair failed: ${toErrorMessage(firstError)}`, + "repair-failed", ); return; } @@ -456,6 +468,7 @@ async function repairTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "seed-repair-failed", state: "fallback", target: target.relativePath, message: `seed repair failed; removed the seed and retrying clean (${toErrorMessage(firstError)})`, @@ -467,6 +480,7 @@ async function repairTarget( recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason: "repair-retry", state: target.repairState, target: target.relativePath, message: "installed or repaired from a clean target", @@ -479,6 +493,7 @@ async function repairTarget( adapter, target, `clean repair failed: ${toErrorMessage(secondError)}`, + "repair-failed", ); } } @@ -490,10 +505,12 @@ function recordBootstrapFailure( adapter: BootstrapAdapter, target: BootstrapTarget, message: string, + reason?: string, ): void { recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, + reason, state: "failed", target: target.relativePath, message, @@ -597,8 +614,8 @@ interface LockfileBootstrapAdapterSpec { class LockfileBootstrapAdapter implements BootstrapAdapter { readonly kind: BootstrapKind; readonly name: string; + readonly relativePath: string; private readonly lockfile: string; - private readonly relativePath: string; private readonly defaultRepairCommand: string; private readonly seedPolicy: "always" | "never"; private readonly canSeedOverride?: ( diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 3a0c73f..6f42397 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -1,4 +1,12 @@ -import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + utimes, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -131,6 +139,30 @@ describe("cloneDir", () => { expect(commandCalled).toBe(false); }); + it("does not publish over a destination that appears during cloning", async () => { + // Given a destination that is created after the initial preflight check. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-publish-race-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + + // When another writer publishes the destination before clone publication. + const error = await cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => { + await mkdir(args.at(-1) as string); + await mkdir(destination); + await writeFile(join(destination, "sentinel"), "keep\n", "utf8"); + }, + }).catch((caught) => caught); + expect(isCloneDestinationExistsError(error)).toBe(true); + + // Then the appearing destination remains untouched. + await expect(readFile(join(destination, "sentinel"), "utf8")).resolves.toBe( + "keep\n", + ); + }); + it("skips unsupported filesystems without creating a destination", async () => { // Given a source directory on an unsupported platform. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); @@ -166,6 +198,48 @@ describe("cloneDir", () => { await expect(readdir(root)).resolves.toContain("source"); }); + it("reclaims an abandoned clone lock without leaving the replacement lock behind", async () => { + // Given an old lock for an absent destination. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-stale-lock-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + const lockPath = `${destination}.gji-clone-lock`; + await mkdir(lockPath); + await utimes(lockPath, new Date(0), new Date(0)); + + // When a new clone takes over the abandoned lock. + await cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => { + await mkdir(args.at(-1) as string); + }, + }); + + // Then the clone succeeds and only its destination remains. + await expect(readdir(root)).resolves.toEqual(["destination", "source"]); + }); + + it("reports an unavailable size instead of a false zero", async () => { + // Given a clone operation whose source disappears before optional measurement. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-size-error-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + + // When cloning completes but size measurement cannot read the source. + const result = await cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => { + await mkdir(args.at(-1) as string); + await rm(source, { force: true, recursive: true }); + }, + }); + + // Then the result distinguishes an unknown size from a zero-byte directory. + expect(result.bytes).toBeUndefined(); + }); + it("never falls back to an ordinary copy on the default platform", async () => { // Given a source directory and a destination on the test filesystem. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-default-")); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 63060b4..996c407 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { cp, @@ -6,13 +7,22 @@ import { mkdir, mkdtemp, readdir, + readFile, realpath, rename, rm, + utimes, + writeFile, } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; +import { + isAlreadyExistsError, + isNotFoundError, + pathExists, +} from "./fs-utils.js"; + const execFileAsync = promisify(execFile); const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000; const CLONE_LOCK_SUFFIX = ".gji-clone-lock"; @@ -62,7 +72,8 @@ export async function cloneDir( const parent = dirname(destination); await mkdir(parent, { recursive: true }); const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; - await acquireCloneLock(lockPath, destination); + const lockToken = await acquireCloneLock(lockPath, destination); + const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken); let temporaryRoot: string | undefined; try { @@ -92,16 +103,13 @@ export async function cloneDir( throw error; } - if (await pathExists(destination)) { - throw new CloneDestinationExistsError(destination); - } - - await rename(temporaryDestination, destination); + await publishCloneContents(temporaryDestination, destination); } finally { + stopLockHeartbeat(); if (temporaryRoot) { await rm(temporaryRoot, { force: true, recursive: true }); } - await rm(lockPath, { force: true, recursive: true }); + await releaseCloneLock(lockPath, lockToken); } const bytes = @@ -174,10 +182,18 @@ export async function directorySize(path: string): Promise { if (!stats.isDirectory()) return stats.size; const entries = await readdir(path, { withFileTypes: true }); + let nextIndex = 0; let total = 0; - for (const entry of entries) { - total += await directorySize(join(path, entry.name)); - } + const workerCount = Math.min(8, entries.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < entries.length) { + const entry = entries[nextIndex]; + nextIndex += 1; + total += await directorySize(join(path, entry.name)); + } + }), + ); return total; } @@ -201,11 +217,11 @@ function isClonePlatformSupported(platform: NodeJS.Platform): boolean { return platform === "darwin" || platform === "linux"; } -async function estimateCloneBytes(path: string): Promise { +async function estimateCloneBytes(path: string): Promise { try { return await directorySize(path); } catch { - return 0; + return undefined; } } @@ -220,55 +236,135 @@ async function runCloneCommand(command: string, args: string[]): Promise { } } -async function pathExists(path: string): Promise { +async function acquireCloneLock( + lockPath: string, + destination: string, +): Promise { + const lockToken = randomUUID(); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await mkdir(lockPath); + try { + await writeFile(join(lockPath, "owner"), `${lockToken}\n`, { + flag: "wx", + }); + } catch (error) { + await rm(lockPath, { force: true, recursive: true }); + throw error; + } + return lockToken; + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } + + let lockStats: Awaited>; + try { + lockStats = await lstat(lockPath); + } catch (error) { + if (isNotFoundError(error)) continue; + throw error; + } + if (Date.now() - lockStats.mtimeMs < CLONE_LOCK_TTL_MS) { + throw new CloneInProgressError(destination); + } + + const stalePath = `${lockPath}.stale-${randomUUID()}`; + try { + await rename(lockPath, stalePath); + } catch (error) { + if (isNotFoundError(error)) continue; + throw error; + } + + try { + await mkdir(lockPath); + try { + await writeFile(join(lockPath, "owner"), `${lockToken}\n`, { + flag: "wx", + }); + } catch (error) { + await rm(lockPath, { force: true, recursive: true }); + throw error; + } + await rm(stalePath, { force: true, recursive: true }); + return lockToken; + } catch (error) { + await rm(stalePath, { force: true, recursive: true }); + if (isAlreadyExistsError(error)) continue; + throw error; + } + } + + throw new CloneInProgressError(destination); +} + +async function publishCloneContents( + temporaryDestination: string, + destination: string, +): Promise { try { - await lstat(path); - return true; + await mkdir(destination); } catch (error) { - if (isNotFoundError(error)) return false; + if (isAlreadyExistsError(error)) { + throw new CloneDestinationExistsError(destination); + } throw error; } -} -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); + try { + const entries = await readdir(temporaryDestination, { + withFileTypes: true, + }); + for (const entry of entries) { + await rename( + join(temporaryDestination, entry.name), + join(destination, entry.name), + ); + } + } catch (error) { + await rm(destination, { force: true, recursive: true }); + if (isAlreadyExistsError(error)) { + throw new CloneDestinationExistsError(destination); + } + throw error; + } } -function isAlreadyExistsError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "EEXIST" - ); +function startLockHeartbeat(lockPath: string, lockToken: string): () => void { + const timer = setInterval(() => { + void refreshCloneLock(lockPath, lockToken); + }, CLONE_LOCK_TTL_MS / 3); + timer.unref?.(); + return () => clearInterval(timer); } -async function acquireCloneLock( +async function refreshCloneLock( lockPath: string, - destination: string, + lockToken: string, ): Promise { try { - await mkdir(lockPath); - return; - } catch (error) { - if (!isAlreadyExistsError(error)) throw error; + const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); + if (owner === lockToken) { + const now = new Date(); + await utimes(lockPath, now, now); + } + } catch { + // Lock refresh is advisory; the owner check prevents touching a replacement lock. } +} +async function releaseCloneLock( + lockPath: string, + lockToken: string, +): Promise { try { - const lockStats = await lstat(lockPath); - if (Date.now() - lockStats.mtimeMs >= CLONE_LOCK_TTL_MS) { + const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); + if (owner === lockToken) { await rm(lockPath, { force: true, recursive: true }); - await mkdir(lockPath); - return; } } catch (error) { if (!isNotFoundError(error)) throw error; } - - throw new CloneInProgressError(destination); } function isUnsupportedCloneError(error: unknown): boolean { @@ -281,7 +377,7 @@ function isUnsupportedCloneError(error: unknown): boolean { return true; } - return /clonefile|reflink|unsupported|operation not supported|not supported/iu.test( + return /clonefile|reflink|unsupported|operation not supported|not supported|invalid cross-device|cross-device/iu.test( error.message, ); } diff --git a/src/fs-utils.ts b/src/fs-utils.ts new file mode 100644 index 0000000..34f0974 --- /dev/null +++ b/src/fs-utils.ts @@ -0,0 +1,31 @@ +import { lstat } from "node:fs/promises"; + +export async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + +export function isAlreadyExistsError(error: unknown): boolean { + return hasErrorCode(error, "EEXIST"); +} + +export function isNotDirectoryError(error: unknown): boolean { + return hasErrorCode(error, "ENOTDIR"); +} + +export function isNotFoundError(error: unknown): boolean { + return hasErrorCode(error, "ENOENT"); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === code + ); +} diff --git a/src/install-prompt.ts b/src/install-prompt.ts index 9896b97..9135674 100644 --- a/src/install-prompt.ts +++ b/src/install-prompt.ts @@ -1,7 +1,5 @@ -import { spawn } from "node:child_process"; - import { isCancel, select } from "@clack/prompts"; - +import { runCommand } from "./command-runner.js"; import { type GjiConfig, loadConfig, @@ -144,35 +142,7 @@ export async function maybeRunInstallPrompt( } } -export async function runInstallCommand( - command: string, - cwd: string, - stderr: (chunk: string) => void, -): Promise { - await new Promise((resolve, reject) => { - const child = spawn(command, { - cwd, - shell: true, - stdio: ["ignore", "inherit", "pipe"], - }); - - (child.stderr as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { - stderr(chunk.toString()); - }); - - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(`exited with code ${code}`)); - } else { - resolve(); - } - }); - - child.on("error", (err) => { - reject(err); - }); - }); -} +export const runInstallCommand = runCommand; async function defaultWriteConfigKey( root: string, diff --git a/src/new.test.ts b/src/new.test.ts index 0eac402..eca476b 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -682,6 +682,45 @@ describe("gji new", () => { ).resolves.toBe(false); }); + it("does not prompt for install after syncFiles fails", async () => { + // Given an npm repository whose required sync file is invalid. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "package-lock.json", + "{}\n", + "Add npm lockfile", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncFiles: ["/etc/passwd"] }), + "utf8", + ); + let promptCalled = false; + + // When worktree creation encounters the sync-file failure. + const result = await createNewCommand({ + detectInstallPackageManager: async () => ({ + name: "npm", + installCommand: "npm install", + }), + promptForInstallChoice: async () => { + promptCalled = true; + return "yes"; + }, + runInstallCommand: async () => undefined, + })({ + branch: "feature/sync-failure-no-prompt", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then setup fails closed without offering a second, unsafe install path. + expect(result).toBe(1); + expect(promptCalled).toBe(false); + }); + it("local syncFiles config overrides global (no array merging)", async () => { // Given global config with syncFiles and local config with a different syncFiles. const home = await mkdtemp(join(tmpdir(), "gji-home-")); @@ -820,7 +859,7 @@ describe("gji new", () => { ).resolves.toBe("leaf\n"); }); - it("repairs a CoW pnpm seed and preserves pnpm metadata", async () => { + it("repairs a CoW pnpm seed and regenerates pnpm metadata", async () => { // Given a pnpm repository whose cloned node_modules contains absolute-path metadata. const repoRoot = await createRepository(); const branchName = "feature/cow-pnpm"; diff --git a/src/new.ts b/src/new.ts index 94b96a4..0c4370e 100644 --- a/src/new.ts +++ b/src/new.ts @@ -4,6 +4,10 @@ import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; import { isCancel, text } from "@clack/prompts"; import { createBootstrapReporter } from "./bootstrap-output.js"; +import { + createDependencyBootstrapPreview, + formatDependencyBootstrapPreview, +} from "./bootstrap-preview.js"; import { type EffectiveGjiConfig, loadEffectiveConfig, @@ -14,10 +18,6 @@ import { pathExists, promptForPathConflict, } from "./conflict.js"; -import { - prepareDependencyBootstrap, - previewDependencyBootstrap, -} from "./dependency-bootstrap.js"; import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; import { isHeadless } from "./headless.js"; @@ -282,13 +282,14 @@ export function createNewCommand( worktreePath, config.syncDirs ?? [], ); - const dryRunDependencyBootstrap = previewDependencyBootstrap( - await prepareDependencyBootstrap(config.dependencyBootstrap ?? "off", { + const dryRunDependencyBootstrap = await createDependencyBootstrapPreview( + config.dependencyBootstrap ?? "off", + { currentRoot: repository.currentRoot, repoRoot: repository.repoRoot, cargoBuildCommand: config.dependencyBuildCommand, worktreePath, - }), + }, ); if (options.json) { const output: Record = { @@ -444,12 +445,14 @@ export function createNewCommand( nonInteractive: !!options.json, repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), + runCommand: dependencies.runInstallCommand, worktreePath, installDependencies: dependencies, }); if (!bootstrap.ready) { return emitNewError(options, "dependency bootstrap failed", { dependencyBootstrap: bootstrap.dependencyBootstrap, + path: worktreePath, skipped: bootstrap.skippedDirs, }); } @@ -950,23 +953,6 @@ async function restoreTakeStash( ); } } -function formatDependencyBootstrapPreview(preview: { - targets: readonly { adapter: string; target: string; strategy: string }[]; -}): string { - return preview.targets - .map( - ({ adapter, target, strategy }) => - `Would ${formatBootstrapStrategy(strategy)} ${target} with ${adapter}\n`, - ) - .join(""); -} - -function formatBootstrapStrategy(strategy: string): string { - if (strategy === "cow-then-repair") return "seed and repair"; - if (strategy === "repair-only") return "repair"; - return "install"; -} - function emitNewError( options: NewCommandOptions, message: string, @@ -976,6 +962,14 @@ function emitNewError( options.stderr( `${JSON.stringify({ error: message, ...details }, null, 2)}\n`, ); - else options.stderr(`gji new: ${message}\n`); + else { + const path = typeof details?.path === "string" ? details.path : undefined; + options.stderr(`gji new: ${message}${path ? ` at ${path}` : ""}\n`); + if (path) { + options.stderr( + `Hint: inspect the worktree or remove it with 'gji done ${path}' before retrying\n`, + ); + } + } return 1; } diff --git a/src/pr.ts b/src/pr.ts index 2bfad85..cd6ac84 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -3,6 +3,10 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { promisify } from "node:util"; import { createBootstrapReporter } from "./bootstrap-output.js"; +import { + createDependencyBootstrapPreview, + formatDependencyBootstrapPreview, +} from "./bootstrap-preview.js"; import { type EffectiveGjiConfig, loadEffectiveConfig, @@ -13,10 +17,6 @@ import { pathExists, promptForPathConflict, } from "./conflict.js"; -import { - prepareDependencyBootstrap, - previewDependencyBootstrap, -} from "./dependency-bootstrap.js"; import type { CloneDirectory } from "./dir-clone.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; @@ -155,16 +155,14 @@ export function createPrCommand( ) : []; const dryRunDependencyBootstrap = options.dryRun - ? previewDependencyBootstrap( - await prepareDependencyBootstrap( - config.dependencyBootstrap ?? "off", - { - currentRoot: repository.currentRoot, - repoRoot: repository.repoRoot, - cargoBuildCommand: config.dependencyBuildCommand, - worktreePath, - }, - ), + ? await createDependencyBootstrapPreview( + config.dependencyBootstrap ?? "off", + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + cargoBuildCommand: config.dependencyBuildCommand, + worktreePath, + }, ) : undefined; @@ -233,12 +231,14 @@ export function createPrCommand( nonInteractive: !!options.json, repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), + runCommand: dependencies.runInstallCommand, worktreePath, installDependencies: dependencies, }); if (!bootstrap.ready) { const details = { dependencyBootstrap: bootstrap.dependencyBootstrap, + path: worktreePath, skipped: bootstrap.skippedDirs, }; if (options.json) { @@ -246,7 +246,12 @@ export function createPrCommand( `${JSON.stringify({ error: "dependency bootstrap failed", ...details }, null, 2)}\n`, ); } else { - options.stderr("gji pr: dependency bootstrap failed\n"); + options.stderr( + `gji pr: dependency bootstrap failed at ${worktreePath}\n`, + ); + options.stderr( + `Hint: inspect the worktree or remove it with 'gji done ${worktreePath}' before retrying\n`, + ); } return 1; } @@ -374,29 +379,3 @@ async function writeOutput( ): Promise { await writeShellOutput(outputEnv ?? PR_OUTPUT_FILE_ENV, worktreePath, stdout); } - -function formatDependencyBootstrapPreview( - preview: - | { - targets: readonly { - adapter: string; - target: string; - strategy: string; - }[]; - } - | undefined, -): string { - if (!preview) return ""; - return preview.targets - .map( - ({ adapter, target, strategy }) => - `Would ${formatBootstrapStrategy(strategy)} ${target} with ${adapter}\n`, - ) - .join(""); -} - -function formatBootstrapStrategy(strategy: string): string { - if (strategy === "cow-then-repair") return "seed and repair"; - if (strategy === "repair-only") return "repair"; - return "install"; -} diff --git a/src/sync-directories.ts b/src/sync-directories.ts index 087d9ba..750af36 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -14,6 +14,7 @@ import { isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; +import { isNotDirectoryError, isNotFoundError } from "./fs-utils.js"; import type { SyncDirectoryPlan } from "./sync-plan.js"; export interface ClonedDirectory { @@ -89,9 +90,10 @@ export async function executeSyncDirectoryPlan( continue; } - const failureScope = entry.source - ? await cloneFailureScope(entry.source, entry.destination) - : undefined; + const failureScope = await cloneFailureScope( + entry.source, + entry.destination, + ); if ( await failureStore.isCached( options.repoRoot, @@ -214,22 +216,6 @@ async function inspectDestination( } } -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - -function isNotDirectoryError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOTDIR" - ); -} - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/sync-plan.ts b/src/sync-plan.ts index 1a92fcb..a259cd3 100644 --- a/src/sync-plan.ts +++ b/src/sync-plan.ts @@ -3,11 +3,12 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { validateSyncDirPattern } from "./config.js"; import { directorySize } from "./dir-clone.js"; +import { isNotDirectoryError, isNotFoundError } from "./fs-utils.js"; export interface SyncDirectoryPlan { directory: string; destination: string; - destinationExists: boolean; + destinationWasPresent: boolean; destinationWarning?: string; source?: string; warning?: string; @@ -31,7 +32,7 @@ export async function prepareSyncDirectoryPlan( for (const directory of normalizedDirectories) { const destination = join(worktreePath, directory); const destinationState = await inspectDestination(destination); - const destinationExists = destinationState === "exists"; + const destinationWasPresent = destinationState === "exists"; const destinationWarning = destinationState === "blocked" ? "destination has a non-directory ancestor" @@ -42,14 +43,14 @@ export async function prepareSyncDirectoryPlan( plan.push({ directory, destination, - destinationExists, + destinationWasPresent, destinationWarning, }); } else if ("warning" in source) { plan.push({ directory, destination, - destinationExists, + destinationWasPresent, destinationWarning, warning: source.warning, }); @@ -57,7 +58,7 @@ export async function prepareSyncDirectoryPlan( plan.push({ directory, destination, - destinationExists, + destinationWasPresent, destinationWarning, source: source.path, }); @@ -66,7 +67,7 @@ export async function prepareSyncDirectoryPlan( plan.push({ directory, destination, - destinationExists, + destinationWasPresent, destinationWarning, warning: `could not inspect ${directory}: ${toErrorMessage(error)}`, }); @@ -82,7 +83,7 @@ export async function estimateSyncDirectoryPlan( const estimates: SyncDirectoryEstimate[] = []; for (const entry of plan) { if ( - entry.destinationExists || + entry.destinationWasPresent || entry.destinationWarning || !entry.source || entry.warning || @@ -149,7 +150,7 @@ function isCoveredByCloneAncestor( return plan.some( (candidate) => candidate !== entry && - !candidate.destinationExists && + !candidate.destinationWasPresent && !candidate.destinationWarning && !!candidate.source && isPathInside(candidate.directory, entry.directory), @@ -183,22 +184,6 @@ async function inspectDestination( } } -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - -function isNotDirectoryError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOTDIR" - ); -} - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index f901687..366d341 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -2,6 +2,7 @@ import { basename } from "node:path"; import type { DependencyBootstrapMode, EffectiveGjiConfig } from "./config.js"; import { + type BootstrapCommandRunner, type BootstrapEvent, type DependencyBootstrapReport, type DependencyBootstrapReporter, @@ -14,7 +15,6 @@ import { extractHooks, runHook } from "./hooks.js"; import { type InstallPromptDependencies, maybeRunInstallPrompt, - runInstallCommand, } from "./install-prompt.js"; import { type ClonedDirectory, @@ -31,6 +31,7 @@ export interface WorktreeBootstrapOptions { config: EffectiveGjiConfig; currentRoot?: string; installDependencies?: InstallPromptDependencies; + runCommand?: BootstrapCommandRunner; nonInteractive: boolean; repoRoot: string; reporter: SyncDirectoryReporter & DependencyBootstrapReporter; @@ -82,7 +83,8 @@ export async function bootstrapWorktree( options.reporter.write(`Warning: ${message}\n`); syncFileFailures.push({ adapter: "syncFiles", - kind: "dependency", + kind: "sync-file", + reason: "sync-file-failed", state: "failed", target: pattern, message, @@ -103,10 +105,18 @@ export async function bootstrapWorktree( reporter: options.reporter, stderr: options.reporter.write, seededDirectories: clonedDirs.map(({ dir }) => dir), - runCommand: - options.installDependencies?.runInstallCommand ?? runInstallCommand, + runCommand: options.runCommand, }); + if (!dependencyBootstrap.ready) { + return { + clonedDirs, + dependencyBootstrap, + ready: false, + skippedDirs, + }; + } + if (dependencyMode === "off") { await maybeRunInstallPrompt( options.worktreePath, @@ -118,15 +128,6 @@ export async function bootstrapWorktree( ); } - if (!dependencyBootstrap.ready) { - return { - clonedDirs, - dependencyBootstrap, - ready: false, - skippedDirs, - }; - } - const hooks = extractHooks(options.config); await runHook( hooks["after-create"], diff --git a/website/docs/commands.mdx b/website/docs/commands.mdx index fa61fe9..51338cb 100644 --- a/website/docs/commands.mdx +++ b/website/docs/commands.mdx @@ -207,4 +207,4 @@ Several commands support `--json` so shell scripts and tools can consume structu `gji clean --stale` limits cleanup to clean branch worktrees whose upstream is gone and whose branch is already merged into the configured or remote default branch. -When `syncDirs` is configured, `gji new` clones each available arbitrary directory with filesystem copy-on-write before syncing files. It never suppresses installation prompts. Opt into `dependencyBootstrap` for deterministic package-manager/build-cache repair after `syncFiles`; unsupported filesystems fall back to install/repair without ordinary copying. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, adapter behavior, JSON output, and dry-run behavior. +When `syncDirs` is configured, `gji new` clones each available arbitrary directory with filesystem copy-on-write before syncing files. It never suppresses installation prompts. Opt into `dependencyBootstrap` for deterministic package-manager/build-cache repair after `syncFiles`; unsupported filesystems fall back to install/repair without ordinary copying. JSON bootstrap events include structured reasons, and failed setup reports the created worktree path for recovery. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, adapter behavior, JSON output, and dry-run behavior. diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index 8da397e..add1cdd 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -135,9 +135,9 @@ The setting is resolved through the same three layers as other normal config val On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. `syncDirs` remains generic and never special-cases `node_modules`, `.venv`, or `target`. -Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed` without an extra full-tree size traversal, while `--json` exposes structured events. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. +Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events with machine-readable reasons. If setup fails, the created worktree path is included in the error output. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. -`dependencyBuildCommand` overrides Cargo's default `cargo check`. npm is always install-only, and a sync-file failure stops dependency repair and `after-create` hooks. Dry-run output reports the effective strategy, including repair-only cases where a seed is not compatible. +`dependencyBuildCommand` overrides Cargo's default `cargo check`. npm is always install-only, and a sync-file failure stops dependency repair, install prompts, and `after-create` hooks. Dry-run output reports the effective strategy, including repair-only cases where a seed is not compatible. ## CLI helpers From 258dd5d57d76f142657f59741e37611eabd17976 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 23 Jul 2026 00:43:56 +0900 Subject: [PATCH 09/19] Harden bootstrap output and filesystem safety --- src/bootstrap-output.ts | 14 +----- src/clone-failure-store.test.ts | 18 ++++++++ src/clone-failure-store.ts | 49 ++++++++++++++++++++- src/command-runner.test.ts | 24 ++++++++++ src/command-runner.ts | 14 +++++- src/dependency-bootstrap.ts | 48 +++++++++++++++++--- src/dir-clone.test.ts | 26 +++++++++++ src/dir-clone.ts | 78 +++++++++++++++++++++------------ src/format-bytes.ts | 12 +++++ src/new.ts | 14 +----- src/pr.ts | 1 + src/sync-plan.ts | 40 +++++++++++++++-- src/worktree-bootstrap.ts | 2 + 13 files changed, 276 insertions(+), 64 deletions(-) create mode 100644 src/command-runner.test.ts create mode 100644 src/format-bytes.ts diff --git a/src/bootstrap-output.ts b/src/bootstrap-output.ts index 1b3f211..b82623d 100644 --- a/src/bootstrap-output.ts +++ b/src/bootstrap-output.ts @@ -2,6 +2,7 @@ import type { BootstrapEvent, DependencyBootstrapReporter, } from "./dependency-bootstrap.js"; +import { formatBytes } from "./format-bytes.js"; import type { SyncDirectoryReporter } from "./sync-directories.js"; export function createBootstrapReporter( @@ -31,19 +32,6 @@ export function createBootstrapReporter( }; } -function formatBytes(bytes: number | undefined): string { - if (bytes === undefined) return "size unavailable"; - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"]; - let value = bytes; - let unit = -1; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; -} - function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts index 3c2f77e..63341c0 100644 --- a/src/clone-failure-store.test.ts +++ b/src/clone-failure-store.test.ts @@ -98,4 +98,22 @@ describe("FileCloneFailureStore", () => { // Then cache corruption remains advisory and does not block setup. expect(cached).toBe(false); }); + + it("preserves concurrent updates from separate store instances", async () => { + // Given two store instances sharing one state directory. + const root = await mkdtemp(join(tmpdir(), "gji-state-concurrent-")); + process.env.GJI_CONFIG_DIR = root; + const first = new FileCloneFailureStore(); + const second = new FileCloneFailureStore(); + + // When both processes update different failure entries concurrently. + await Promise.all([ + first.cache("/repo", "node_modules", "unsupported"), + second.cache("/repo", ".venv", "unsupported"), + ]); + + // Then neither update is lost. + await expect(first.isCached("/repo", "node_modules")).resolves.toBe(true); + await expect(first.isCached("/repo", ".venv")).resolves.toBe(true); + }); }); diff --git a/src/clone-failure-store.ts b/src/clone-failure-store.ts index d30756c..7322145 100644 --- a/src/clone-failure-store.ts +++ b/src/clone-failure-store.ts @@ -14,7 +14,9 @@ import { dirname, join, resolve } from "node:path"; import { GLOBAL_CONFIG_DIRECTORY } from "./config.js"; const STATE_FILE_NAME = "state.json"; +const STATE_LOCK_SUFFIX = ".lock"; const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000; +const STATE_LOCK_TTL_MS = 30 * 1000; interface CloneFailure { failedAt: number; @@ -105,7 +107,10 @@ export class FileCloneFailureStore implements CloneFailureStore { } private async update(operation: () => Promise): Promise { - const next = this.updateQueue.then(operation, operation); + const next = this.updateQueue.then( + () => this.withStateLock(operation), + () => this.withStateLock(operation), + ); this.updateQueue = next.then( () => undefined, () => undefined, @@ -113,6 +118,22 @@ export class FileCloneFailureStore implements CloneFailureStore { await next; } + private async withStateLock(operation: () => Promise): Promise { + const lockPath = `${this.stateFilePath()}${STATE_LOCK_SUFFIX}`; + const locked = await acquireStateLock(lockPath); + if (!locked) { + await operation(); + return; + } + try { + await operation(); + } finally { + await rm(lockPath, { force: true, recursive: true }).catch( + () => undefined, + ); + } + } + private async readState(): Promise { try { const raw = await readFile(this.stateFilePath(), "utf8"); @@ -160,6 +181,28 @@ export class FileCloneFailureStore implements CloneFailureStore { } } +async function acquireStateLock(lockPath: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + await mkdir(lockPath); + return true; + } catch (error) { + if (!isErrorCode(error, "EEXIST")) return false; + try { + const lockStats = await stat(lockPath); + if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) { + await rm(lockPath, { force: true, recursive: true }); + continue; + } + } catch (lockError) { + if (!isErrorCode(lockError, "ENOENT")) return false; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + return false; +} + export const defaultCloneFailureStore: CloneFailureStore = new FileCloneFailureStore(); @@ -191,3 +234,7 @@ function failureKey(directory: string, scope?: string): string { function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function isErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/src/command-runner.test.ts b/src/command-runner.test.ts new file mode 100644 index 0000000..18d987e --- /dev/null +++ b/src/command-runner.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { runCommand } from "./command-runner.js"; + +describe("runCommand", () => { + it("routes child stdout through the caller-provided stream", async () => { + // Given a command that writes output to stdout and stderr. + const stdout: string[] = []; + const stderr: string[] = []; + const command = `${JSON.stringify(process.execPath)} -e "process.stdout.write('out'); process.stderr.write('err')"`; + + // When the command runner executes it. + await runCommand( + command, + process.cwd(), + (chunk) => stderr.push(chunk), + (chunk) => stdout.push(chunk), + ); + + // Then each stream remains independently controllable for JSON callers. + expect(stdout.join("")).toBe("out"); + expect(stderr.join("")).toBe("err"); + }); +}); diff --git a/src/command-runner.ts b/src/command-runner.ts index 61298f0..48dc06e 100644 --- a/src/command-runner.ts +++ b/src/command-runner.ts @@ -4,14 +4,24 @@ export type CommandRunner = ( command: string, cwd: string, stderr: (chunk: string) => void, + stdout?: (chunk: string) => void, ) => Promise; -export const runCommand: CommandRunner = async (command, cwd, stderr) => { +export const runCommand: CommandRunner = async ( + command, + cwd, + stderr, + stdout = (chunk) => process.stdout.write(chunk), +) => { await new Promise((resolve, reject) => { const child = spawn(command, { cwd, shell: true, - stdio: ["ignore", "inherit", "pipe"], + stdio: ["ignore", "pipe", "pipe"], + }); + + (child.stdout as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { + stdout(chunk.toString()); }); (child.stderr as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index 14065d4..532831e 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -42,6 +42,7 @@ export interface BootstrapPreparationContext { export interface BootstrapExecutionContext { runCommand: BootstrapCommandRunner; stderr: (chunk: string) => void; + stdout: (chunk: string) => void; } export interface BootstrapTarget { @@ -106,6 +107,7 @@ export interface DependencyBootstrapDependencies { failureStore?: CloneFailureStore; runCommand?: BootstrapCommandRunner; stderr?: (chunk: string) => void; + stdout?: (chunk: string) => void; seededDirectories?: readonly string[]; } @@ -213,6 +215,7 @@ export async function executeDependencyBootstrap( const execution: BootstrapExecutionContext = { runCommand: options.runCommand ?? runCommand, stderr: options.stderr ?? (() => undefined), + stdout: options.stdout ?? ((chunk) => process.stdout.write(chunk)), }; const seededDirectories = new Set(options.seededDirectories ?? []); @@ -452,19 +455,32 @@ async function repairTarget( }); } catch (firstError) { if (ownership !== "adapter" && ownership !== "syncDirs") { - if (!presentBeforeRepair) await removeTarget(target); + const cleanupError = !presentBeforeRepair + ? await tryRemoveTarget(target) + : undefined; recordBootstrapFailure( events, reporter, adapter, target, - `repair failed: ${toErrorMessage(firstError)}`, + formatRepairFailure(firstError, cleanupError), "repair-failed", ); return; } - await removeTarget(target); + const cleanupError = await tryRemoveTarget(target); + if (cleanupError) { + recordBootstrapFailure( + events, + reporter, + adapter, + target, + formatRepairFailure(firstError, cleanupError), + "repair-cleanup-failed", + ); + return; + } recordBootstrapEvent(events, reporter, { adapter: adapter.name, kind: adapter.kind, @@ -486,13 +502,15 @@ async function repairTarget( message: "installed or repaired from a clean target", }); } catch (secondError) { - if (!presentBeforeRetry) await removeTarget(target); + const cleanupError = !presentBeforeRetry + ? await tryRemoveTarget(target) + : undefined; recordBootstrapFailure( events, reporter, adapter, target, - `clean repair failed: ${toErrorMessage(secondError)}`, + formatRepairFailure(secondError, cleanupError), "repair-failed", ); } @@ -532,6 +550,25 @@ async function removeTarget(target: BootstrapTarget): Promise { } } +async function tryRemoveTarget( + target: BootstrapTarget, +): Promise { + try { + await removeTarget(target); + return undefined; + } catch (error) { + return toErrorMessage(error); + } +} + +function formatRepairFailure( + error: unknown, + cleanupError: string | undefined, +): string { + const message = `repair failed: ${toErrorMessage(error)}`; + return cleanupError ? `${message}; cleanup failed: ${cleanupError}` : message; +} + function bootstrapStrategy( mode: DependencyBootstrapMode, target: BootstrapTarget, @@ -679,6 +716,7 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { target.repairCommand, target.worktreePath, context.stderr, + context.stdout, ); } diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 6f42397..4592f09 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -4,6 +4,7 @@ import { readdir, readFile, rm, + symlink, utimes, writeFile, } from "node:fs/promises"; @@ -163,6 +164,31 @@ describe("cloneDir", () => { ); }); + it("rejects a destination with a symbolic-link ancestor", async () => { + // Given a source and a destination parent that points outside the worktree. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-link-")); + const source = join(root, "source"); + const external = await mkdtemp(join(tmpdir(), "gji-dir-clone-external-")); + const destinationParent = join(root, "worktree", "cache"); + const destination = join(destinationParent, "packages"); + await mkdir(source); + await mkdir(join(root, "worktree")); + await symlink(external, destinationParent); + + // When cloneDir is asked to create a nested destination. + const error = await cloneDir(source, destination, { + platform: "linux", + runCommand: async () => undefined, + }).catch((caught) => caught); + + // Then it refuses to follow the link and leaves the external directory untouched. + expect(error).toHaveProperty( + "message", + expect.stringContaining("symbolic-link"), + ); + expect(await readdir(external)).toEqual([]); + }); + it("skips unsupported filesystems without creating a destination", async () => { // Given a source directory on an unsupported platform. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-")); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 996c407..489dfd0 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -70,6 +70,7 @@ export async function cloneDir( const startedAt = Date.now(); const parent = dirname(destination); + await assertSafeDestinationParent(parent); await mkdir(parent, { recursive: true }); const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; const lockToken = await acquireCloneLock(lockPath, destination); @@ -106,10 +107,18 @@ export async function cloneDir( await publishCloneContents(temporaryDestination, destination); } finally { stopLockHeartbeat(); - if (temporaryRoot) { - await rm(temporaryRoot, { force: true, recursive: true }); + try { + if (temporaryRoot) { + await rm(temporaryRoot, { force: true, recursive: true }); + } + } catch { + // Cleanup is best effort and must not mask the clone result. + } + try { + await releaseCloneLock(lockPath, lockToken); + } catch { + // A stale lock is reclaimed on a later attempt. } - await releaseCloneLock(lockPath, lockToken); } const bytes = @@ -181,16 +190,20 @@ export async function directorySize(path: string): Promise { const stats = await lstat(path); if (!stats.isDirectory()) return stats.size; - const entries = await readdir(path, { withFileTypes: true }); - let nextIndex = 0; + const pending = [path]; let total = 0; - const workerCount = Math.min(8, entries.length); + const workerCount = 8; await Promise.all( Array.from({ length: workerCount }, async () => { - while (nextIndex < entries.length) { - const entry = entries[nextIndex]; - nextIndex += 1; - total += await directorySize(join(path, entry.name)); + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = join(current, entry.name); + if (entry.isDirectory()) pending.push(entryPath); + else total += (await lstat(entryPath)).size; + } } }), ); @@ -303,30 +316,33 @@ async function publishCloneContents( destination: string, ): Promise { try { - await mkdir(destination); + await rename(temporaryDestination, destination); } catch (error) { - if (isAlreadyExistsError(error)) { + if (isAlreadyExistsError(error) || isDirectoryNotEmptyError(error)) { throw new CloneDestinationExistsError(destination); } throw error; } +} - try { - const entries = await readdir(temporaryDestination, { - withFileTypes: true, - }); - for (const entry of entries) { - await rename( - join(temporaryDestination, entry.name), - join(destination, entry.name), - ); - } - } catch (error) { - await rm(destination, { force: true, recursive: true }); - if (isAlreadyExistsError(error)) { - throw new CloneDestinationExistsError(destination); +async function assertSafeDestinationParent(parent: string): Promise { + let current = parent; + while (true) { + try { + const stats = await lstat(current); + if (stats.isSymbolicLink()) { + throw new Error(`destination has a symbolic-link ancestor: ${current}`); + } + if (!stats.isDirectory()) { + throw new Error(`destination has a non-directory ancestor: ${current}`); + } + return; + } catch (error) { + if (!isNotFoundError(error)) throw error; + const next = dirname(current); + if (next === current) return; + current = next; } - throw error; } } @@ -382,6 +398,14 @@ function isUnsupportedCloneError(error: unknown): boolean { ); } +function isDirectoryNotEmptyError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOTEMPTY" + ); +} + function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/format-bytes.ts b/src/format-bytes.ts new file mode 100644 index 0000000..c6fd552 --- /dev/null +++ b/src/format-bytes.ts @@ -0,0 +1,12 @@ +export function formatBytes(bytes: number | undefined): string { + if (bytes === undefined) return "size unavailable"; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; +} diff --git a/src/new.ts b/src/new.ts index 0c4370e..21433d1 100644 --- a/src/new.ts +++ b/src/new.ts @@ -20,6 +20,7 @@ import { } from "./conflict.js"; import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; +import { formatBytes } from "./format-bytes.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; import type { InstallPromptDependencies } from "./install-prompt.js"; @@ -446,6 +447,7 @@ export function createNewCommand( repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), runCommand: dependencies.runInstallCommand, + commandStdout: options.json ? () => undefined : options.stdout, worktreePath, installDependencies: dependencies, }); @@ -643,18 +645,6 @@ function applyConfiguredBranchPrefix( return `${branchPrefix}${branch}`; } -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"]; - let value = bytes; - let unit = -1; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; -} - async function resolveUniqueDetachedWorktreePath( repoRoot: string, baseName: string, diff --git a/src/pr.ts b/src/pr.ts index cd6ac84..495d490 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -232,6 +232,7 @@ export function createPrCommand( repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), runCommand: dependencies.runInstallCommand, + commandStdout: options.json ? () => undefined : options.stdout, worktreePath, installDependencies: dependencies, }); diff --git a/src/sync-plan.ts b/src/sync-plan.ts index a259cd3..aee703a 100644 --- a/src/sync-plan.ts +++ b/src/sync-plan.ts @@ -31,7 +31,10 @@ export async function prepareSyncDirectoryPlan( for (const directory of normalizedDirectories) { const destination = join(worktreePath, directory); - const destinationState = await inspectDestination(destination); + const destinationState = await inspectDestination( + worktreePath, + destination, + ); const destinationWasPresent = destinationState === "exists"; const destinationWarning = destinationState === "blocked" @@ -172,14 +175,43 @@ function isPathInside(parent: string, child: string): boolean { } async function inspectDestination( + root: string, path: string, ): Promise<"exists" | "missing" | "blocked"> { + const relativePath = relative(resolve(root), resolve(path)); + if ( + isAbsolute(relativePath) || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) + ) { + return "blocked"; + } + + let current = resolve(root); + const segments = relativePath.split(sep).filter(Boolean); + for (const [index, segment] of segments.entries()) { + current = join(current, segment); + try { + const stats = await lstat(current); + if (index < segments.length - 1) { + if (stats.isSymbolicLink() || !stats.isDirectory()) return "blocked"; + } else { + return "exists"; + } + } catch (error) { + if (isNotFoundError(error)) return "missing"; + if (isNotDirectoryError(error)) return "blocked"; + throw error; + } + } + try { - await lstat(path); - return "exists"; + const stats = await lstat(current); + return stats.isSymbolicLink() || !stats.isDirectory() + ? "blocked" + : "exists"; } catch (error) { if (isNotFoundError(error)) return "missing"; - if (isNotDirectoryError(error)) return "blocked"; throw error; } } diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 366d341..9e0930b 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -32,6 +32,7 @@ export interface WorktreeBootstrapOptions { currentRoot?: string; installDependencies?: InstallPromptDependencies; runCommand?: BootstrapCommandRunner; + commandStdout?: (chunk: string) => void; nonInteractive: boolean; repoRoot: string; reporter: SyncDirectoryReporter & DependencyBootstrapReporter; @@ -104,6 +105,7 @@ export async function bootstrapWorktree( repoRoot: options.repoRoot, reporter: options.reporter, stderr: options.reporter.write, + stdout: options.commandStdout, seededDirectories: clonedDirs.map(({ dir }) => dir), runCommand: options.runCommand, }); From 4f948c24215737cfb7e88149c30a6b7274a95b61 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 23 Jul 2026 01:07:16 +0900 Subject: [PATCH 10/19] Harden dependency destination safety and output --- src/clone-failure-store.test.ts | 27 ++++++++++- src/clone-failure-store.ts | 47 +++++++++++++----- src/dependency-bootstrap.test.ts | 37 ++++++++++++++ src/dependency-bootstrap.ts | 23 ++++++++- src/dir-clone.test.ts | 4 ++ src/dir-clone.ts | 80 +++++++++++++++++-------------- src/new.test.ts | 33 +++++++++++++ src/new.ts | 2 + src/pr.ts | 2 + src/safe-destination.ts | 82 ++++++++++++++++++++++++++++++++ src/sync-directories.ts | 28 ++++------- src/sync-plan.ts | 56 ++++------------------ src/worktree-bootstrap.ts | 6 ++- 13 files changed, 308 insertions(+), 119 deletions(-) create mode 100644 src/safe-destination.ts diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts index 63341c0..8ca5abd 100644 --- a/src/clone-failure-store.test.ts +++ b/src/clone-failure-store.test.ts @@ -1,4 +1,11 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { + access, + mkdir, + mkdtemp, + readFile, + utimes, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -116,4 +123,22 @@ describe("FileCloneFailureStore", () => { await expect(first.isCached("/repo", "node_modules")).resolves.toBe(true); await expect(first.isCached("/repo", ".venv")).resolves.toBe(true); }); + + it("reclaims a stale lock without deleting a replacement lock", async () => { + // Given a stale state lock left by an interrupted process. + const root = await mkdtemp(join(tmpdir(), "gji-state-stale-lock-")); + process.env.GJI_CONFIG_DIR = root; + const lockPath = join(root, "state.json.lock"); + await mkdir(lockPath); + await writeFile(join(lockPath, "owner"), "old-owner\n", "utf8"); + await utimes(lockPath, new Date(0), new Date(0)); + + // When a new store records a failure. + const store = new FileCloneFailureStore(); + await store.cache("/repo", "node_modules", "unsupported"); + + // Then the update succeeds and the stale lock is gone. + await expect(store.isCached("/repo", "node_modules")).resolves.toBe(true); + await expect(access(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); }); diff --git a/src/clone-failure-store.ts b/src/clone-failure-store.ts index 7322145..81cfb65 100644 --- a/src/clone-failure-store.ts +++ b/src/clone-failure-store.ts @@ -120,17 +120,12 @@ export class FileCloneFailureStore implements CloneFailureStore { private async withStateLock(operation: () => Promise): Promise { const lockPath = `${this.stateFilePath()}${STATE_LOCK_SUFFIX}`; - const locked = await acquireStateLock(lockPath); - if (!locked) { - await operation(); - return; - } + const lockToken = await acquireStateLock(lockPath); + if (lockToken === undefined) return; try { await operation(); } finally { - await rm(lockPath, { force: true, recursive: true }).catch( - () => undefined, - ); + await releaseStateLock(lockPath, lockToken); } } @@ -181,13 +176,13 @@ export class FileCloneFailureStore implements CloneFailureStore { } } -async function acquireStateLock(lockPath: string): Promise { +async function acquireStateLock(lockPath: string): Promise { + const lockToken = randomUUID(); for (let attempt = 0; attempt < 200; attempt += 1) { try { await mkdir(lockPath); - return true; } catch (error) { - if (!isErrorCode(error, "EEXIST")) return false; + if (!isErrorCode(error, "EEXIST")) return undefined; try { const lockStats = await stat(lockPath); if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) { @@ -195,12 +190,38 @@ async function acquireStateLock(lockPath: string): Promise { continue; } } catch (lockError) { - if (!isErrorCode(lockError, "ENOENT")) return false; + if (!isErrorCode(lockError, "ENOENT")) return undefined; } await new Promise((resolve) => setTimeout(resolve, 25)); + continue; + } + try { + await writeFile(join(lockPath, "owner"), `${lockToken}\n`, { + flag: "wx", + }); + return lockToken; + } catch { + await rm(lockPath, { force: true, recursive: true }).catch( + () => undefined, + ); + return undefined; + } + } + return undefined; +} + +async function releaseStateLock( + lockPath: string, + lockToken: string, +): Promise { + try { + const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); + if (owner === lockToken) { + await rm(lockPath, { force: true, recursive: true }); } + } catch (error) { + if (!isErrorCode(error, "ENOENT")) return; } - return false; } export const defaultCloneFailureStore: CloneFailureStore = diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts index 1b1242d..56901f6 100644 --- a/src/dependency-bootstrap.test.ts +++ b/src/dependency-bootstrap.test.ts @@ -383,6 +383,43 @@ describe("dependencyBootstrap adapters", () => { expect(result.events[0]?.state).toBe("fallback"); }); + it("fails safely when a dependency destination points outside the worktree", async () => { + // Given a dependency lockfile and an external node_modules destination. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-destination-repo-"), + ); + const external = await mkdtemp( + join(tmpdir(), "gji-bootstrap-destination-external-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-destination-worktree-"), + ); + await writeFile(join(repoRoot, "pnpm-lock.yaml"), "lock\n"); + await symlink(external, join(worktreePath, "node_modules")); + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + worktreePath, + }); + const reporter = createReporter(); + let repairCalled = false; + + // When dependency bootstrap evaluates and executes the unsafe target. + const result = await executeDependencyBootstrap(plan, { + failureStore: createFailureStore(), + repoRoot, + reporter, + runCommand: async () => { + repairCalled = true; + }, + }); + + // Then setup fails without invoking the repair command or touching the external directory. + expect(result.ready).toBe(false); + expect(repairCalled).toBe(false); + expect(result.events.at(-1)?.reason).toBe("destination-unsafe"); + expect(await access(external)).toBeUndefined(); + }); + it("clones a compatible uv environment and repairs it with locked sync", async () => { // Given a uv lockfile and a virtual environment with a compatible interpreter fingerprint. const repoRoot = await mkdtemp(join(tmpdir(), "gji-bootstrap-uv-repo-")); diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index 532831e..a011b05 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -16,6 +16,7 @@ import { isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; +import { inspectDestination } from "./safe-destination.js"; const execFileAsync = promisify(execFile); @@ -267,6 +268,21 @@ async function executeBootstrapTarget( reporter: DependencyBootstrapReporter, repoRoot: string, ): Promise { + const destinationInspection = await inspectDestination( + target.worktreePath, + target.targetPath, + ); + if (destinationInspection.kind === "unsafe") { + recordBootstrapFailure( + events, + reporter, + adapter, + target, + destinationInspection.reason, + "destination-unsafe", + ); + return; + } if (mode === "install-only") { await repairTarget( adapter, @@ -346,6 +362,7 @@ async function executeBootstrapTarget( } else if (seedable) { try { await cloneDirectory(adapter.seedPath(target), target.targetPath, { + destinationRoot: target.worktreePath, measureBytes: reporter.measureCloneSize, }); await failureStore.clear(repoRoot, target.relativePath, failureScope); @@ -683,6 +700,10 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { const sourcePath = join(context.sourceRoot, this.relativePath); const targetPath = join(context.worktreePath, this.relativePath); + const destinationInspection = await inspectDestination( + context.worktreePath, + targetPath, + ); const target = { adapter: this.name, kind: this.kind, @@ -693,7 +714,7 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { targetPath, repairCommand: this.defaultRepairCommand, repairState: this.repairState, - existingBeforeBootstrap: await pathExists(targetPath), + existingBeforeBootstrap: destinationInspection.kind === "exists", }; return { diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 4592f09..31fb4e8 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -28,11 +28,13 @@ describe("cloneDir", () => { const destination = join(root, "destination"); await mkdir(source); await writeFile(join(source, "package.json"), "{}\n", "utf8"); + let cloneArgs: string[] = []; // When cloneDir runs the injected platform command. const result = await cloneDir(source, destination, { platform: "linux", runCommand: async (_command, args) => { + cloneArgs = args; const temporaryDestination = args.at(-1) as string; await mkdir(temporaryDestination); await writeFile( @@ -45,6 +47,7 @@ describe("cloneDir", () => { // Then the final destination contains the clone and no temporary sibling remains. expect(result.bytes).toBeGreaterThan(0); + expect(cloneArgs).toContain("--reflink=always"); expect(result.ms).toBeGreaterThanOrEqual(0); await expect( readFile(join(destination, "package.json"), "utf8"), @@ -177,6 +180,7 @@ describe("cloneDir", () => { // When cloneDir is asked to create a nested destination. const error = await cloneDir(source, destination, { + destinationRoot: join(root, "worktree"), platform: "linux", runCommand: async () => undefined, }).catch((caught) => caught); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 489dfd0..edcaf92 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -6,7 +6,7 @@ import { lstat, mkdir, mkdtemp, - readdir, + opendir, readFile, realpath, rename, @@ -22,10 +22,13 @@ import { isNotFoundError, pathExists, } from "./fs-utils.js"; +import { inspectDestination } from "./safe-destination.js"; const execFileAsync = promisify(execFile); const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000; const CLONE_LOCK_SUFFIX = ".gji-clone-lock"; +const SIZE_ESTIMATE_MAX_ENTRIES = 1_000_000; +const SIZE_ESTIMATE_MAX_MS = 5_000; export interface CloneDirResult { bytes?: number; @@ -33,6 +36,7 @@ export interface CloneDirResult { } export interface CloneRequestOptions { + destinationRoot?: string; measureBytes?: boolean; } @@ -70,8 +74,25 @@ export async function cloneDir( const startedAt = Date.now(); const parent = dirname(destination); - await assertSafeDestinationParent(parent); + if (options.destinationRoot) { + const parentInspection = await inspectDestination( + options.destinationRoot, + parent, + ); + if (parentInspection.kind === "unsafe") { + throw new Error(parentInspection.reason); + } + } await mkdir(parent, { recursive: true }); + if (options.destinationRoot) { + const parentInspection = await inspectDestination( + options.destinationRoot, + parent, + ); + if (parentInspection.kind === "unsafe") { + throw new Error(parentInspection.reason); + } + } const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; const lockToken = await acquireCloneLock(lockPath, destination); const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken); @@ -192,21 +213,29 @@ export async function directorySize(path: string): Promise { const pending = [path]; let total = 0; - const workerCount = 8; - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (pending.length > 0) { - const current = pending.pop(); - if (!current) continue; - const entries = await readdir(current, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = join(current, entry.name); - if (entry.isDirectory()) pending.push(entryPath); - else total += (await lstat(entryPath)).size; + let entryCount = 0; + const startedAt = Date.now(); + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + const directory = await opendir(current); + try { + for await (const entry of directory) { + entryCount += 1; + if ( + entryCount > SIZE_ESTIMATE_MAX_ENTRIES || + Date.now() - startedAt > SIZE_ESTIMATE_MAX_MS + ) { + throw new Error("directory size estimate exceeded its safety limit"); } + const entryPath = join(current, entry.name); + if (entry.isDirectory()) pending.push(entryPath); + else total += (await lstat(entryPath)).size; } - }), - ); + } finally { + await directory.close().catch(() => undefined); + } + } return total; } @@ -325,27 +354,6 @@ async function publishCloneContents( } } -async function assertSafeDestinationParent(parent: string): Promise { - let current = parent; - while (true) { - try { - const stats = await lstat(current); - if (stats.isSymbolicLink()) { - throw new Error(`destination has a symbolic-link ancestor: ${current}`); - } - if (!stats.isDirectory()) { - throw new Error(`destination has a non-directory ancestor: ${current}`); - } - return; - } catch (error) { - if (!isNotFoundError(error)) throw error; - const next = dirname(current); - if (next === current) return; - current = next; - } - } -} - function startLockHeartbeat(lockPath: string, lockToken: string): () => void { const timer = setInterval(() => { void refreshCloneLock(lockPath, lockToken); diff --git a/src/new.test.ts b/src/new.test.ts index eca476b..bba5cd2 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -1064,6 +1064,39 @@ describe("gji new", () => { }); }); + it("suppresses dependency stderr in JSON mode", async () => { + // Given an install-only dependency bootstrap that writes raw command stderr. + const repoRoot = await createRepository(); + await writeFile(join(repoRoot, "package-lock.json"), "{}\n", "utf8"); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ dependencyBootstrap: "install-only" }), + "utf8", + ); + const stdout: string[] = []; + const stderr: string[] = []; + + // When gji new executes the command in JSON mode. + const result = await createNewCommand({ + runInstallCommand: async (_command, _cwd, writeStderr) => { + writeStderr("raw package-manager warning\n"); + }, + })({ + branch: "feature/bootstrap-json-stderr", + cwd: repoRoot, + json: true, + stderr: (chunk) => stderr.push(chunk), + stdout: (chunk) => stdout.push(chunk), + }); + + // Then stdout is valid JSON and stderr contains no raw dependency output. + expect(result).toBe(0); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + dependencyBootstrap: { ready: true }, + }); + expect(stderr).toEqual([]); + }); + it("reports skipped generic syncDirs outcomes in JSON output", async () => { // Given a configured directory whose source does not exist. const repoRoot = await createRepository(); diff --git a/src/new.ts b/src/new.ts index 21433d1..3ddd4e4 100644 --- a/src/new.ts +++ b/src/new.ts @@ -448,6 +448,8 @@ export function createNewCommand( reporter: createBootstrapReporter(options.stderr, !!options.json), runCommand: dependencies.runInstallCommand, commandStdout: options.json ? () => undefined : options.stdout, + commandStderr: options.json ? () => undefined : options.stderr, + json: options.json, worktreePath, installDependencies: dependencies, }); diff --git a/src/pr.ts b/src/pr.ts index 495d490..ee13a28 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -233,6 +233,8 @@ export function createPrCommand( reporter: createBootstrapReporter(options.stderr, !!options.json), runCommand: dependencies.runInstallCommand, commandStdout: options.json ? () => undefined : options.stdout, + commandStderr: options.json ? () => undefined : options.stderr, + json: options.json, worktreePath, installDependencies: dependencies, }); diff --git a/src/safe-destination.ts b/src/safe-destination.ts new file mode 100644 index 0000000..1905bd4 --- /dev/null +++ b/src/safe-destination.ts @@ -0,0 +1,82 @@ +import { lstat } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +import { isNotDirectoryError, isNotFoundError } from "./fs-utils.js"; + +export type DestinationInspection = + | { kind: "missing" } + | { kind: "exists" } + | { kind: "unsafe"; reason: string }; + +export async function inspectDestination( + root: string, + path: string, +): Promise { + const resolvedPath = resolve(path); + const resolvedRoot = resolve(root); + const distance = relative(resolvedRoot, resolvedPath); + if ( + isAbsolute(distance) || + distance === ".." || + distance.startsWith(`..${sep}`) + ) { + return { kind: "unsafe", reason: "destination escapes the worktree" }; + } + + let current = resolvedRoot; + try { + const rootStats = await lstat(current); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) { + return { kind: "unsafe", reason: "worktree root is not a directory" }; + } + } catch (error) { + if (isNotFoundError(error)) return { kind: "missing" }; + throw error; + } + const segments = distance.split(sep).filter(Boolean); + for (const [index, segment] of segments.entries()) { + current = resolve(current, segment); + try { + const stats = await lstat(current); + if (stats.isSymbolicLink()) { + return { + kind: "unsafe", + reason: `destination has a symbolic-link component: ${current}`, + }; + } + if (index < segments.length - 1 && !stats.isDirectory()) { + return { + kind: "unsafe", + reason: `destination has a non-directory ancestor: ${current}`, + }; + } + if (index === segments.length - 1) { + return stats.isDirectory() + ? { kind: "exists" } + : { + kind: "unsafe", + reason: `destination is not a directory: ${current}`, + }; + } + } catch (error) { + if (isNotFoundError(error)) return { kind: "missing" }; + if (isNotDirectoryError(error)) { + return { + kind: "unsafe", + reason: `destination has a non-directory ancestor: ${current}`, + }; + } + throw error; + } + } + + try { + const stats = await lstat(current); + return stats.isDirectory() + ? { kind: "exists" } + : { kind: "unsafe", reason: "destination is not a directory" }; + } catch (error) { + if (isNotFoundError(error)) return { kind: "missing" }; + throw error; + } +} diff --git a/src/sync-directories.ts b/src/sync-directories.ts index 750af36..e4afb80 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -1,4 +1,3 @@ -import { lstat } from "node:fs/promises"; import { type CloneFailureStore, cloneFailureScope, @@ -14,7 +13,7 @@ import { isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; -import { isNotDirectoryError, isNotFoundError } from "./fs-utils.js"; +import { inspectDestination } from "./safe-destination.js"; import type { SyncDirectoryPlan } from "./sync-plan.js"; export interface ClonedDirectory { @@ -60,8 +59,11 @@ export async function executeSyncDirectoryPlan( continue; } - const destinationState = await inspectDestination(entry.destination); - if (destinationState === "exists") { + const destinationState = await inspectDestination( + entry.worktreePath, + entry.destination, + ); + if (destinationState.kind === "exists") { recordSkipped( outcomes, options.reporter, @@ -70,8 +72,8 @@ export async function executeSyncDirectoryPlan( ); continue; } - if (destinationState === "blocked") { - const reason = "destination has a non-directory ancestor"; + if (destinationState.kind === "unsafe") { + const reason = destinationState.reason; recordSkipped(outcomes, options.reporter, entry.directory, reason); continue; } @@ -129,6 +131,7 @@ export async function executeSyncDirectoryPlan( let result: CloneDirResult; try { const cloneOptions: CloneRequestOptions = { + destinationRoot: entry.worktreePath, measureBytes: options.reporter.measureCloneSize, }; result = await options.cloneDirectory( @@ -203,19 +206,6 @@ function recordSkipped( } } -async function inspectDestination( - path: string, -): Promise<"exists" | "missing" | "blocked"> { - try { - await lstat(path); - return "exists"; - } catch (error) { - if (isNotFoundError(error)) return "missing"; - if (isNotDirectoryError(error)) return "blocked"; - throw error; - } -} - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/sync-plan.ts b/src/sync-plan.ts index aee703a..18910f9 100644 --- a/src/sync-plan.ts +++ b/src/sync-plan.ts @@ -3,11 +3,13 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { validateSyncDirPattern } from "./config.js"; import { directorySize } from "./dir-clone.js"; -import { isNotDirectoryError, isNotFoundError } from "./fs-utils.js"; +import { isNotFoundError } from "./fs-utils.js"; +import { inspectDestination } from "./safe-destination.js"; export interface SyncDirectoryPlan { directory: string; destination: string; + worktreePath: string; destinationWasPresent: boolean; destinationWarning?: string; source?: string; @@ -35,17 +37,16 @@ export async function prepareSyncDirectoryPlan( worktreePath, destination, ); - const destinationWasPresent = destinationState === "exists"; + const destinationWasPresent = destinationState.kind === "exists"; const destinationWarning = - destinationState === "blocked" - ? "destination has a non-directory ancestor" - : undefined; + destinationState.kind === "unsafe" ? destinationState.reason : undefined; try { const source = await resolveSyncDirectorySource(repoRoot, directory); if (!source) { plan.push({ directory, destination, + worktreePath, destinationWasPresent, destinationWarning, }); @@ -53,6 +54,7 @@ export async function prepareSyncDirectoryPlan( plan.push({ directory, destination, + worktreePath, destinationWasPresent, destinationWarning, warning: source.warning, @@ -61,6 +63,7 @@ export async function prepareSyncDirectoryPlan( plan.push({ directory, destination, + worktreePath, destinationWasPresent, destinationWarning, source: source.path, @@ -70,6 +73,7 @@ export async function prepareSyncDirectoryPlan( plan.push({ directory, destination, + worktreePath, destinationWasPresent, destinationWarning, warning: `could not inspect ${directory}: ${toErrorMessage(error)}`, @@ -174,48 +178,6 @@ function isPathInside(parent: string, child: string): boolean { ); } -async function inspectDestination( - root: string, - path: string, -): Promise<"exists" | "missing" | "blocked"> { - const relativePath = relative(resolve(root), resolve(path)); - if ( - isAbsolute(relativePath) || - relativePath === ".." || - relativePath.startsWith(`..${sep}`) - ) { - return "blocked"; - } - - let current = resolve(root); - const segments = relativePath.split(sep).filter(Boolean); - for (const [index, segment] of segments.entries()) { - current = join(current, segment); - try { - const stats = await lstat(current); - if (index < segments.length - 1) { - if (stats.isSymbolicLink() || !stats.isDirectory()) return "blocked"; - } else { - return "exists"; - } - } catch (error) { - if (isNotFoundError(error)) return "missing"; - if (isNotDirectoryError(error)) return "blocked"; - throw error; - } - } - - try { - const stats = await lstat(current); - return stats.isSymbolicLink() || !stats.isDirectory() - ? "blocked" - : "exists"; - } catch (error) { - if (isNotFoundError(error)) return "missing"; - throw error; - } -} - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 9e0930b..c9471ca 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -33,6 +33,8 @@ export interface WorktreeBootstrapOptions { installDependencies?: InstallPromptDependencies; runCommand?: BootstrapCommandRunner; commandStdout?: (chunk: string) => void; + commandStderr?: (chunk: string) => void; + json?: boolean; nonInteractive: boolean; repoRoot: string; reporter: SyncDirectoryReporter & DependencyBootstrapReporter; @@ -81,7 +83,7 @@ export async function bootstrapWorktree( await syncFiles(options.repoRoot, options.worktreePath, [pattern]); } catch (error) { const message = `failed to sync file "${pattern}": ${toErrorMessage(error)}`; - options.reporter.write(`Warning: ${message}\n`); + if (!options.json) options.reporter.write(`Warning: ${message}\n`); syncFileFailures.push({ adapter: "syncFiles", kind: "sync-file", @@ -104,7 +106,7 @@ export async function bootstrapWorktree( cloneDirectory: options.cloneDirectory, repoRoot: options.repoRoot, reporter: options.reporter, - stderr: options.reporter.write, + stderr: options.commandStderr ?? options.reporter.write, stdout: options.commandStdout, seededDirectories: clonedDirs.map(({ dir }) => dir), runCommand: options.runCommand, From 6cd0cf45f91bd44ce894b3775d20211f447cb21a Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 23 Jul 2026 01:20:21 +0900 Subject: [PATCH 11/19] Harden hook output and sync file safety --- src/clone-failure-store.ts | 29 +++++++++++++++++++++++++++++ src/file-sync.test.ts | 26 +++++++++++++++++++++++++- src/file-sync.ts | 16 +++++++++++++++- src/hooks.test.ts | 20 ++++++++++++++++++++ src/hooks.ts | 19 +++++++++++++++---- src/worktree-bootstrap.ts | 3 +++ 6 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/clone-failure-store.ts b/src/clone-failure-store.ts index 81cfb65..748901c 100644 --- a/src/clone-failure-store.ts +++ b/src/clone-failure-store.ts @@ -6,6 +6,7 @@ import { rename, rm, stat, + utimes, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; @@ -122,9 +123,11 @@ export class FileCloneFailureStore implements CloneFailureStore { const lockPath = `${this.stateFilePath()}${STATE_LOCK_SUFFIX}`; const lockToken = await acquireStateLock(lockPath); if (lockToken === undefined) return; + const stopHeartbeat = startStateLockHeartbeat(lockPath, lockToken); try { await operation(); } finally { + stopHeartbeat(); await releaseStateLock(lockPath, lockToken); } } @@ -210,6 +213,32 @@ async function acquireStateLock(lockPath: string): Promise { return undefined; } +function startStateLockHeartbeat( + lockPath: string, + lockToken: string, +): () => void { + const timer = setInterval(() => { + void refreshStateLock(lockPath, lockToken); + }, STATE_LOCK_TTL_MS / 3); + timer.unref?.(); + return () => clearInterval(timer); +} + +async function refreshStateLock( + lockPath: string, + lockToken: string, +): Promise { + try { + const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); + if (owner === lockToken) { + const now = new Date(); + await utimes(lockPath, now, now); + } + } catch { + // The cache is advisory; a failed heartbeat must not block worktree creation. + } +} + async function releaseStateLock( lockPath: string, lockToken: string, diff --git a/src/file-sync.test.ts b/src/file-sync.test.ts index 7900999..e8b4390 100644 --- a/src/file-sync.test.ts +++ b/src/file-sync.test.ts @@ -1,4 +1,11 @@ -import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -107,6 +114,23 @@ describe("syncFiles", () => { expect(content).toBe("SECRET=abc\n"); }); + it("rejects a symlinked destination parent", async () => { + // Given a source file and a destination parent that points outside the worktree. + const mainRoot = await makeTmpDir(); + const targetPath = await makeTmpDir(); + const outsidePath = await makeTmpDir(); + await mkdir(join(mainRoot, "nested"), { recursive: true }); + await writeFile(join(mainRoot, "nested/config.json"), "{}", "utf8"); + await symlink(outsidePath, join(targetPath, "nested")); + + // When syncFiles processes the nested file. + const result = syncFiles(mainRoot, targetPath, ["nested/config.json"]); + + // Then it refuses to write through the symlink. + await expect(result).rejects.toThrow("symbolic-link component"); + await expect(stat(join(outsidePath, "config.json"))).rejects.toThrow(); + }); + it("handles an empty patterns array without error", async () => { // Given const mainRoot = await makeTmpDir(); diff --git a/src/file-sync.ts b/src/file-sync.ts index 14e2a4f..ba8b783 100644 --- a/src/file-sync.ts +++ b/src/file-sync.ts @@ -1,6 +1,8 @@ import { copyFile, mkdir, stat } from "node:fs/promises"; import { dirname, isAbsolute, join, normalize } from "node:path"; +import { inspectDestination } from "./safe-destination.js"; + /** * Copies files matching each pattern (relative to mainRoot) into the equivalent * relative path under targetPath, creating parent directories as needed. @@ -32,7 +34,19 @@ export async function syncFiles( continue; } - await mkdir(dirname(destPath), { recursive: true }); + const destinationParent = dirname(destPath); + const beforeCreate = await inspectDestination( + targetPath, + destinationParent, + ); + if (beforeCreate.kind === "unsafe") { + throw new Error(beforeCreate.reason); + } + await mkdir(destinationParent, { recursive: true }); + const afterCreate = await inspectDestination(targetPath, destinationParent); + if (afterCreate.kind === "unsafe") { + throw new Error(afterCreate.reason); + } await copyFile(sourcePath, destPath); } } diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 9c72fce..7afced8 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -206,6 +206,26 @@ describe("runHook", () => { expect(stderr).toEqual([]); }); + it("forwards hook stdout through the provided callback", async () => { + // Given a hook that writes to stdout and separate output collectors. + const dir = await mkdtemp(join(tmpdir(), "gji-hooks-")); + const stderr: string[] = []; + const stdout: string[] = []; + + // When runHook executes the command. + await runHook( + "printf 'hook output'", + dir, + { path: dir, repo: "r" }, + (chunk) => stderr.push(chunk), + (chunk) => stdout.push(chunk), + ); + + // Then stdout is captured separately from stderr. + expect(stdout.join("")).toBe("hook output"); + expect(stderr).toEqual([]); + }); + it("executes an argv command in the given cwd", async () => { // Given a temporary directory and an argv hook that writes a marker file. const dir = await mkdtemp(join(tmpdir(), "gji-hooks-")); diff --git a/src/hooks.ts b/src/hooks.ts index 428f040..e47826c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -19,15 +19,16 @@ export async function runHook( cwd: string, context: HookContext, stderr: (chunk: string) => void, + stdout: (chunk: string) => void = (chunk) => process.stdout.write(chunk), ): Promise { if (!hookCmd) return; if (Array.isArray(hookCmd)) { - await runArgvHook(hookCmd, cwd, context, stderr); + await runArgvHook(hookCmd, cwd, context, stderr, stdout); return; } - await runShellHook(hookCmd, cwd, context, stderr); + await runShellHook(hookCmd, cwd, context, stderr, stdout); } async function runArgvHook( @@ -35,6 +36,7 @@ async function runArgvHook( cwd: string, context: HookContext, stderr: (chunk: string) => void, + stdout: (chunk: string) => void, ): Promise { const [command, ...args] = hookCmd.map((arg) => interpolate(arg, context)); if (!command) { @@ -46,10 +48,14 @@ async function runArgvHook( const child = spawn(command, args, { cwd, shell: false, - stdio: ["ignore", "inherit", "pipe"], + stdio: ["ignore", "pipe", "pipe"], env: hookEnvironment(context), }); + (child.stdout as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { + stdout(chunk.toString()); + }); + (child.stderr as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { stderr(chunk.toString()); }); @@ -75,6 +81,7 @@ async function runShellHook( cwd: string, context: HookContext, stderr: (chunk: string) => void, + stdout: (chunk: string) => void, ): Promise { const interpolated = interpolate(hookCmd, context); @@ -82,10 +89,14 @@ async function runShellHook( const child = spawn(interpolated, { cwd, shell: true, - stdio: ["ignore", "inherit", "pipe"], + stdio: ["ignore", "pipe", "pipe"], env: hookEnvironment(context), }); + (child.stdout as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { + stdout(chunk.toString()); + }); + (child.stderr as NodeJS.ReadableStream).on("data", (chunk: Buffer) => { stderr(chunk.toString()); }); diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index c9471ca..d089684 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -142,6 +142,9 @@ export async function bootstrapWorktree( repo: basename(options.repoRoot), }, options.reporter.write, + options.json + ? () => undefined + : (options.commandStdout ?? ((chunk) => process.stdout.write(chunk))), ); return { clonedDirs, dependencyBootstrap, ready: true, skippedDirs }; From c5c7fa73723d695b063b6b4db22a7d2fa0a8da4a Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 23 Jul 2026 01:59:51 +0900 Subject: [PATCH 12/19] Harden clone locks and destination handling --- src/clone-failure-store.test.ts | 22 +++++- src/clone-failure-store.ts | 62 ++++++++++++---- src/dir-clone.test.ts | 4 +- src/dir-clone.ts | 127 +++++++++++++++++++++++++------- src/file-sync.test.ts | 19 +++++ src/file-sync.ts | 37 ++++++++-- src/sync-directories.ts | 27 ++++++- 7 files changed, 249 insertions(+), 49 deletions(-) diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts index 8ca5abd..7021d8c 100644 --- a/src/clone-failure-store.test.ts +++ b/src/clone-failure-store.test.ts @@ -130,8 +130,9 @@ describe("FileCloneFailureStore", () => { process.env.GJI_CONFIG_DIR = root; const lockPath = join(root, "state.json.lock"); await mkdir(lockPath); - await writeFile(join(lockPath, "owner"), "old-owner\n", "utf8"); - await utimes(lockPath, new Date(0), new Date(0)); + const ownerPath = join(lockPath, "owner-old-owner"); + await writeFile(ownerPath, "old-owner\n", "utf8"); + await utimes(ownerPath, new Date(0), new Date(0)); // When a new store records a failure. const store = new FileCloneFailureStore(); @@ -141,4 +142,21 @@ describe("FileCloneFailureStore", () => { await expect(store.isCached("/repo", "node_modules")).resolves.toBe(true); await expect(access(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("creates the config directory before the first cache update", async () => { + // Given a configured directory that does not exist yet. + const root = await mkdtemp(join(tmpdir(), "gji-state-new-config-")); + const configDirectory = join(root, "nested", "config"); + process.env.GJI_CONFIG_DIR = configDirectory; + const store = new FileCloneFailureStore(); + + // When the first failure is cached. + await store.cache("/repo", "node_modules", "unsupported"); + + // Then the directory and state file are created successfully. + await expect(store.isCached("/repo", "node_modules")).resolves.toBe(true); + await expect( + access(join(configDirectory, "state.json")), + ).resolves.toBeUndefined(); + }); }); diff --git a/src/clone-failure-store.ts b/src/clone-failure-store.ts index 748901c..1b75f26 100644 --- a/src/clone-failure-store.ts +++ b/src/clone-failure-store.ts @@ -2,10 +2,13 @@ import { randomUUID } from "node:crypto"; import { mkdir, mkdtemp, + readdir, readFile, rename, rm, + rmdir, stat, + unlink, utimes, writeFile, } from "node:fs/promises"; @@ -181,15 +184,24 @@ export class FileCloneFailureStore implements CloneFailureStore { async function acquireStateLock(lockPath: string): Promise { const lockToken = randomUUID(); + await mkdir(dirname(lockPath), { recursive: true }).catch(() => undefined); for (let attempt = 0; attempt < 200; attempt += 1) { try { await mkdir(lockPath); } catch (error) { if (!isErrorCode(error, "EEXIST")) return undefined; try { - const lockStats = await stat(lockPath); + const freshnessPath = await lockFreshnessPath(lockPath); + const lockStats = await stat(freshnessPath); if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) { - await rm(lockPath, { force: true, recursive: true }); + const stalePath = `${lockPath}.stale-${randomUUID()}`; + try { + await rename(lockPath, stalePath); + } catch (renameError) { + if (isErrorCode(renameError, "ENOENT")) continue; + return undefined; + } + await rm(stalePath, { force: true, recursive: true }); continue; } } catch (lockError) { @@ -199,9 +211,13 @@ async function acquireStateLock(lockPath: string): Promise { continue; } try { - await writeFile(join(lockPath, "owner"), `${lockToken}\n`, { - flag: "wx", - }); + await writeFile( + join(lockPath, ownerFileName(lockToken)), + `${lockToken}\n`, + { + flag: "wx", + }, + ); return lockToken; } catch { await rm(lockPath, { force: true, recursive: true }).catch( @@ -213,6 +229,18 @@ async function acquireStateLock(lockPath: string): Promise { return undefined; } +async function lockFreshnessPath(lockPath: string): Promise { + try { + const owner = (await readdir(lockPath)).find((entry) => + entry.startsWith("owner-"), + ); + return owner ? join(lockPath, owner) : lockPath; + } catch (error) { + if (isErrorCode(error, "ENOENT")) throw error; + return lockPath; + } +} + function startStateLockHeartbeat( lockPath: string, lockToken: string, @@ -229,11 +257,10 @@ async function refreshStateLock( lockToken: string, ): Promise { try { - const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); - if (owner === lockToken) { - const now = new Date(); - await utimes(lockPath, now, now); - } + const ownerPath = join(lockPath, ownerFileName(lockToken)); + await readFile(ownerPath, "utf8"); + const now = new Date(); + await utimes(ownerPath, now, now); } catch { // The cache is advisory; a failed heartbeat must not block worktree creation. } @@ -244,15 +271,20 @@ async function releaseStateLock( lockToken: string, ): Promise { try { - const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); - if (owner === lockToken) { - await rm(lockPath, { force: true, recursive: true }); - } + const ownerPath = join(lockPath, ownerFileName(lockToken)); + await unlink(ownerPath); + await rmdir(lockPath); } catch (error) { - if (!isErrorCode(error, "ENOENT")) return; + if (!isErrorCode(error, "ENOENT") && !isErrorCode(error, "ENOTEMPTY")) { + return; + } } } +function ownerFileName(lockToken: string): string { + return `owner-${lockToken}`; +} + export const defaultCloneFailureStore: CloneFailureStore = new FileCloneFailureStore(); diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 31fb4e8..5a34e8c 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -236,7 +236,9 @@ describe("cloneDir", () => { await mkdir(source); const lockPath = `${destination}.gji-clone-lock`; await mkdir(lockPath); - await utimes(lockPath, new Date(0), new Date(0)); + const ownerPath = join(lockPath, "owner-old-owner"); + await writeFile(ownerPath, "old-owner\n", "utf8"); + await utimes(ownerPath, new Date(0), new Date(0)); // When a new clone takes over the abandoned lock. await cloneDir(source, destination, { diff --git a/src/dir-clone.ts b/src/dir-clone.ts index edcaf92..31b9fa2 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -7,21 +7,20 @@ import { mkdir, mkdtemp, opendir, + readdir, readFile, realpath, rename, rm, + rmdir, + unlink, utimes, writeFile, } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; -import { - isAlreadyExistsError, - isNotFoundError, - pathExists, -} from "./fs-utils.js"; +import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js"; import { inspectDestination } from "./safe-destination.js"; const execFileAsync = promisify(execFile); @@ -52,6 +51,45 @@ export type CloneDirectory = ( options?: CloneRequestOptions, ) => Promise; +export async function waitForCloneLock( + destination: string, + timeoutMs = 5_000, +): Promise { + const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; + const deadline = Date.now() + timeoutMs; + while (await cloneLockExists(lockPath)) { + if (await cloneLockIsStale(lockPath)) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return true; +} + +async function cloneLockIsStale(lockPath: string): Promise { + try { + const freshnessPath = await lockFreshnessPath(lockPath); + const stats = await lstat(freshnessPath); + return Date.now() - stats.mtimeMs >= CLONE_LOCK_TTL_MS; + } catch (error) { + if (isNotFoundError(error)) return false; + return false; + } +} + +async function cloneLockExists(lockPath: string): Promise { + try { + return await destinationExists(lockPath); + } catch (error) { + if ( + "code" in (error as object) && + (error as NodeJS.ErrnoException).code === "ENOTDIR" + ) { + return false; + } + throw error; + } +} + export async function cloneDir( source: string, destination: string, @@ -68,7 +106,7 @@ export async function cloneDir( if (!sourceStats.isDirectory()) { throw new Error("source is not a directory"); } - if (await pathExists(destination)) { + if (await destinationExists(destination)) { throw new CloneDestinationExistsError(destination); } @@ -287,9 +325,13 @@ async function acquireCloneLock( try { await mkdir(lockPath); try { - await writeFile(join(lockPath, "owner"), `${lockToken}\n`, { - flag: "wx", - }); + await writeFile( + join(lockPath, ownerFileName(lockToken)), + `${lockToken}\n`, + { + flag: "wx", + }, + ); } catch (error) { await rm(lockPath, { force: true, recursive: true }); throw error; @@ -301,7 +343,7 @@ async function acquireCloneLock( let lockStats: Awaited>; try { - lockStats = await lstat(lockPath); + lockStats = await lstat(await lockFreshnessPath(lockPath)); } catch (error) { if (isNotFoundError(error)) continue; throw error; @@ -321,17 +363,28 @@ async function acquireCloneLock( try { await mkdir(lockPath); try { - await writeFile(join(lockPath, "owner"), `${lockToken}\n`, { - flag: "wx", - }); + await writeFile( + join(lockPath, ownerFileName(lockToken)), + `${lockToken}\n`, + { + flag: "wx", + }, + ); } catch (error) { await rm(lockPath, { force: true, recursive: true }); throw error; } - await rm(stalePath, { force: true, recursive: true }); + try { + await rm(stalePath, { force: true, recursive: true }); + } catch (cleanupError) { + await releaseCloneLock(lockPath, lockToken).catch(() => undefined); + throw cleanupError; + } return lockToken; } catch (error) { - await rm(stalePath, { force: true, recursive: true }); + await rm(stalePath, { force: true, recursive: true }).catch( + () => undefined, + ); if (isAlreadyExistsError(error)) continue; throw error; } @@ -340,6 +393,17 @@ async function acquireCloneLock( throw new CloneInProgressError(destination); } +async function lockFreshnessPath(lockPath: string): Promise { + try { + const entries = await readdir(lockPath); + const owner = entries.find((entry) => entry.startsWith("owner-")); + return owner ? join(lockPath, owner) : lockPath; + } catch (error) { + if (isNotFoundError(error)) throw error; + return lockPath; + } +} + async function publishCloneContents( temporaryDestination: string, destination: string, @@ -354,6 +418,16 @@ async function publishCloneContents( } } +async function destinationExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + function startLockHeartbeat(lockPath: string, lockToken: string): () => void { const timer = setInterval(() => { void refreshCloneLock(lockPath, lockToken); @@ -367,11 +441,10 @@ async function refreshCloneLock( lockToken: string, ): Promise { try { - const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); - if (owner === lockToken) { - const now = new Date(); - await utimes(lockPath, now, now); - } + const ownerPath = join(lockPath, ownerFileName(lockToken)); + await readFile(ownerPath, "utf8"); + const now = new Date(); + await utimes(ownerPath, now, now); } catch { // Lock refresh is advisory; the owner check prevents touching a replacement lock. } @@ -382,15 +455,19 @@ async function releaseCloneLock( lockToken: string, ): Promise { try { - const owner = (await readFile(join(lockPath, "owner"), "utf8")).trim(); - if (owner === lockToken) { - await rm(lockPath, { force: true, recursive: true }); - } + const ownerPath = join(lockPath, ownerFileName(lockToken)); + await unlink(ownerPath); + await rmdir(lockPath); } catch (error) { - if (!isNotFoundError(error)) throw error; + if (!isNotFoundError(error) && !isDirectoryNotEmptyError(error)) + throw error; } } +function ownerFileName(lockToken: string): string { + return `owner-${lockToken}`; +} + function isUnsupportedCloneError(error: unknown): boolean { if (!(error instanceof Error)) return false; diff --git a/src/file-sync.test.ts b/src/file-sync.test.ts index e8b4390..32cdf2b 100644 --- a/src/file-sync.test.ts +++ b/src/file-sync.test.ts @@ -131,6 +131,25 @@ describe("syncFiles", () => { await expect(stat(join(outsidePath, "config.json"))).rejects.toThrow(); }); + it("rejects a dangling symlink at the destination file", async () => { + // Given a source file and a dangling destination symlink pointing outside the worktree. + const mainRoot = await makeTmpDir(); + const targetPath = await makeTmpDir(); + const outsidePath = await makeTmpDir(); + await writeFile(join(mainRoot, "config.json"), "{}", "utf8"); + await symlink( + join(outsidePath, "missing.json"), + join(targetPath, "config.json"), + ); + + // When syncFiles processes the destination. + const result = syncFiles(mainRoot, targetPath, ["config.json"]); + + // Then it refuses to follow the dangling symlink. + await expect(result).rejects.toThrow("symbolic link"); + await expect(stat(join(outsidePath, "missing.json"))).rejects.toThrow(); + }); + it("handles an empty patterns array without error", async () => { // Given const mainRoot = await makeTmpDir(); diff --git a/src/file-sync.ts b/src/file-sync.ts index ba8b783..8108ed7 100644 --- a/src/file-sync.ts +++ b/src/file-sync.ts @@ -1,4 +1,5 @@ -import { copyFile, mkdir, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { copyFile, lstat, mkdir, stat } from "node:fs/promises"; import { dirname, isAbsolute, join, normalize } from "node:path"; import { inspectDestination } from "./safe-destination.js"; @@ -28,9 +29,12 @@ export async function syncFiles( continue; } - // Skip silently if target already exists - const destExists = await fileExists(destPath); - if (destExists) { + // Skip ordinary existing targets, but fail closed on any symlink. + const existingDestination = await readDestinationEntry(destPath); + if (existingDestination?.isSymbolicLink()) { + throw new Error(`destination is a symbolic link: ${destPath}`); + } + if (existingDestination) { continue; } @@ -47,7 +51,11 @@ export async function syncFiles( if (afterCreate.kind === "unsafe") { throw new Error(afterCreate.reason); } - await copyFile(sourcePath, destPath); + try { + await copyFile(sourcePath, destPath, constants.COPYFILE_EXCL); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } } } @@ -80,6 +88,17 @@ async function fileExists(path: string): Promise { } } +async function readDestinationEntry( + path: string, +): Promise> | undefined> { + try { + return await lstat(path); + } catch (error) { + if (isNotFoundError(error)) return undefined; + throw error; + } +} + function isNotFoundError(error: unknown): boolean { return ( error instanceof Error && @@ -87,3 +106,11 @@ function isNotFoundError(error: unknown): boolean { (error as NodeJS.ErrnoException).code === "ENOENT" ); } + +function isAlreadyExistsError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "EEXIST" + ); +} diff --git a/src/sync-directories.ts b/src/sync-directories.ts index e4afb80..fbeed4d 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -12,6 +12,7 @@ import { isCloneDestinationExistsError, isCloneInProgressError, isCloneUnsupportedError, + waitForCloneLock, } from "./dir-clone.js"; import { inspectDestination } from "./safe-destination.js"; import type { SyncDirectoryPlan } from "./sync-plan.js"; @@ -77,7 +78,6 @@ export async function executeSyncDirectoryPlan( recordSkipped(outcomes, options.reporter, entry.directory, reason); continue; } - if (entry.warning) { recordSkipped(outcomes, options.reporter, entry.directory, entry.warning); continue; @@ -128,6 +128,31 @@ export async function executeSyncDirectoryPlan( continue; } + if (!(await waitForCloneLock(entry.destination))) { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "another clone is still in progress", + ); + continue; + } + const refreshedDestinationState = await inspectDestination( + entry.worktreePath, + entry.destination, + ); + if (refreshedDestinationState.kind !== "missing") { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + refreshedDestinationState.kind === "unsafe" + ? refreshedDestinationState.reason + : "destination already exists", + ); + continue; + } + let result: CloneDirResult; try { const cloneOptions: CloneRequestOptions = { From 488822782c3e558daa501e15dedb9a43885da626 Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 24 Jul 2026 21:42:54 +0900 Subject: [PATCH 13/19] Document destination safety scope --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 388a9ba..fc75210 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,8 @@ Use `syncDirs` for large, reproducible directories that should be available imme `gji new` clones these directories with APFS copy-on-write on macOS or mandatory reflinks on Linux, before `syncFiles`. It never falls back to a slow ordinary copy. Unsupported filesystems, external symlink targets, missing sources, and existing destinations are skipped safely; failed CoW attempts are cached in `~/.config/gji/state.json` so repeated worktree creation does not keep waiting on the same unsupported filesystem. `syncDirs` is generic: it does not know or special-case package managers. +Destination safety covers pre-existing symlink components and concurrent gji operations. The CLI does not claim to defend against a hostile same-user process that replaces a checked path component between validation and the subsequent filesystem operation; fully closing that TOCTOU window requires platform-specific descriptor-relative filesystem APIs. + Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. The human output includes clone timing; dry-run can provide source-size estimates: From df3a728fbdf7cc41d024561ea3a7d0e273e666de Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 24 Jul 2026 21:49:27 +0900 Subject: [PATCH 14/19] Move destination safety scope to docs --- README.md | 2 +- docs/destination-safety.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 docs/destination-safety.md diff --git a/README.md b/README.md index fc75210..f72a5ae 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ Use `syncDirs` for large, reproducible directories that should be available imme `gji new` clones these directories with APFS copy-on-write on macOS or mandatory reflinks on Linux, before `syncFiles`. It never falls back to a slow ordinary copy. Unsupported filesystems, external symlink targets, missing sources, and existing destinations are skipped safely; failed CoW attempts are cached in `~/.config/gji/state.json` so repeated worktree creation does not keep waiting on the same unsupported filesystem. `syncDirs` is generic: it does not know or special-case package managers. -Destination safety covers pre-existing symlink components and concurrent gji operations. The CLI does not claim to defend against a hostile same-user process that replaces a checked path component between validation and the subsequent filesystem operation; fully closing that TOCTOU window requires platform-specific descriptor-relative filesystem APIs. +See [destination safety](docs/destination-safety.md) for the filesystem safety scope and limitations. Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. diff --git a/docs/destination-safety.md b/docs/destination-safety.md new file mode 100644 index 0000000..890935a --- /dev/null +++ b/docs/destination-safety.md @@ -0,0 +1,7 @@ +# Destination safety + +`syncDirs` and `syncFiles` protect worktree destinations against pre-existing symlink components, unsafe ancestors, and ordinary concurrent `gji` operations. Clone and sync operations also use temporary output, destination checks, and operation locks to avoid publishing incomplete results through normal `gji` workflows. + +The CLI does not claim to defend against a hostile same-user process that replaces a checked path component between validation and the subsequent filesystem operation. Fully closing that TOCTOU window requires platform-specific descriptor-relative filesystem APIs, such as `openat`/`O_NOFOLLOW` or an equivalent native no-replace operation. + +This limitation is relevant when an untrusted local process can modify the worktree or its parent directories concurrently. It is not expected in the normal single-user developer workflow supported by `gji`. From 25df778ffe2a7acfff20d185e435e393d2834fd0 Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 24 Jul 2026 21:51:28 +0900 Subject: [PATCH 15/19] Clarify destination safety impact --- docs/destination-safety.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/destination-safety.md b/docs/destination-safety.md index 890935a..33ba155 100644 --- a/docs/destination-safety.md +++ b/docs/destination-safety.md @@ -1,7 +1,9 @@ # Destination safety -`syncDirs` and `syncFiles` protect worktree destinations against pre-existing symlink components, unsafe ancestors, and ordinary concurrent `gji` operations. Clone and sync operations also use temporary output, destination checks, and operation locks to avoid publishing incomplete results through normal `gji` workflows. +`syncDirs` and `syncFiles` reject pre-existing symlink components, unsafe ancestors, and ordinary concurrent `gji` operations. They use destination checks, operation locks, and temporary output to avoid publishing incomplete results during normal worktree creation. -The CLI does not claim to defend against a hostile same-user process that replaces a checked path component between validation and the subsequent filesystem operation. Fully closing that TOCTOU window requires platform-specific descriptor-relative filesystem APIs, such as `openat`/`O_NOFOLLOW` or an equivalent native no-replace operation. +## Scope -This limitation is relevant when an untrusted local process can modify the worktree or its parent directories concurrently. It is not expected in the normal single-user developer workflow supported by `gji`. +The remaining check-to-use race requires a hostile same-user process to replace a path component while `gji` is operating. In a normal single-user workflow, this is primarily a reliability and data-integrity concern, not a privilege-escalation vulnerability. + +It can become a security issue when `gji` runs with elevated or separate service-account privileges, or when its output is consumed by privileged automation. Fully eliminating that case requires platform-specific descriptor-relative APIs such as `openat`/`O_NOFOLLOW` or an equivalent native no-replace operation. From 6958d7c785c839869406eac2fd63a19255717e99 Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 24 Jul 2026 21:52:10 +0900 Subject: [PATCH 16/19] Remove unnecessary safety documentation --- README.md | 2 -- docs/destination-safety.md | 9 --------- 2 files changed, 11 deletions(-) delete mode 100644 docs/destination-safety.md diff --git a/README.md b/README.md index f72a5ae..388a9ba 100644 --- a/README.md +++ b/README.md @@ -343,8 +343,6 @@ Use `syncDirs` for large, reproducible directories that should be available imme `gji new` clones these directories with APFS copy-on-write on macOS or mandatory reflinks on Linux, before `syncFiles`. It never falls back to a slow ordinary copy. Unsupported filesystems, external symlink targets, missing sources, and existing destinations are skipped safely; failed CoW attempts are cached in `~/.config/gji/state.json` so repeated worktree creation does not keep waiting on the same unsupported filesystem. `syncDirs` is generic: it does not know or special-case package managers. -See [destination safety](docs/destination-safety.md) for the filesystem safety scope and limitations. - Paths are relative to the repository root. Absolute paths, `..` segments, and `.git` paths are rejected in all three config layers. The human output includes clone timing; dry-run can provide source-size estimates: diff --git a/docs/destination-safety.md b/docs/destination-safety.md deleted file mode 100644 index 33ba155..0000000 --- a/docs/destination-safety.md +++ /dev/null @@ -1,9 +0,0 @@ -# Destination safety - -`syncDirs` and `syncFiles` reject pre-existing symlink components, unsafe ancestors, and ordinary concurrent `gji` operations. They use destination checks, operation locks, and temporary output to avoid publishing incomplete results during normal worktree creation. - -## Scope - -The remaining check-to-use race requires a hostile same-user process to replace a path component while `gji` is operating. In a normal single-user workflow, this is primarily a reliability and data-integrity concern, not a privilege-escalation vulnerability. - -It can become a security issue when `gji` runs with elevated or separate service-account privileges, or when its output is consumed by privileged automation. Fully eliminating that case requires platform-specific descriptor-relative APIs such as `openat`/`O_NOFOLLOW` or an equivalent native no-replace operation. From ee2afbcc5061a50d439300af01b95ce9957c392a Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 24 Jul 2026 22:45:57 +0900 Subject: [PATCH 17/19] Add interactive dependency bootstrap policy --- README.md | 18 ++- scripts/generate-man.mjs | 4 +- src/config.test.ts | 46 ++++++++ src/config.ts | 21 +++- src/dependency-bootstrap-prompt.ts | 172 +++++++++++++++++++++++++++++ src/dependency-bootstrap.test.ts | 38 +++++++ src/dependency-bootstrap.ts | 40 ++++++- src/new.test.ts | 163 +++++++++++++++++++++++++++ src/new.ts | 40 ++++++- src/pr.test.ts | 34 ++++++ src/pr.ts | 40 ++++++- src/worktree-bootstrap.ts | 3 +- website/docs/commands.mdx | 2 +- website/docs/configuration.mdx | 12 +- 14 files changed, 610 insertions(+), 23 deletions(-) create mode 100644 src/dependency-bootstrap-prompt.ts diff --git a/README.md b/README.md index 388a9ba..642bd08 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ No setup required. Optional config lives in: | `dependencyBootstrap` | dependency/build-state policy: `off`, `cow-then-repair`, or `install-only` | | `dependencyBuildCommand` | optional Cargo repair command used by `dependencyBootstrap` (default: `cargo check`) | | `skipInstallPrompt` | `true` to disable the auto-install prompt permanently | -| `installSaveTarget` | `"local"` or `"global"` — where **Always**/**Never** choices are persisted (default: `"local"`); set during `gji init --write` | +| `installSaveTarget` | `"local"` or `"global"` — where dependency policy and legacy **Always**/**Never** choices are persisted (default: `"local"`); set during `gji init --write` | | `hooks` | lifecycle scripts (see [Hooks](#hooks)) | | `repos` | per-repo overrides inside the global config (see below) | @@ -333,11 +333,11 @@ This stores: ### Instant directory bootstrap -Use `syncDirs` for large, reproducible directories that should be available immediately in each new worktree: +Use `syncDirs` for advanced, arbitrary directories that should be available immediately in each new worktree. Dependency adapters discover their own project-local targets, so you do not need to list `node_modules`, `.venv`, or `target` here: ```json { - "syncDirs": ["node_modules", ".next"] + "syncDirs": [".next", ".cache"] } ``` @@ -359,7 +359,11 @@ Use `dependencyBootstrap` when a package manager or build cache needs a reusable } ``` -`cow-then-repair` supports Yarn (`--immutable`), pnpm (`--frozen-lockfile`), uv (`--locked`), and Cargo (the configured `dependencyBuildCommand`, or `cargo check`). npm uses install-only `npm ci` because it can delete an existing dependency tree. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair, install prompts, and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. +`cow-then-repair` supports pnpm (`node_modules` + `pnpm install --frozen-lockfile`), Yarn (`node_modules` + `yarn install --immutable`), uv (`.venv` + `uv sync --locked`), Cargo (`target` + the configured `dependencyBuildCommand`, or `cargo check`), and Bundler (`vendor/bundle` + `bundle install`). npm is install-only (`npm ci`) because it can delete an existing dependency tree; it never seeds `node_modules`. Only project-local targets are eligible, so global Ruby gems and package-manager caches are never cloned. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair, install prompts, and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. + +When a supported lockfile is detected and no `dependencyBootstrap` policy is configured, interactive `gji new` and `gji pr` ask which policy to persist: **Reuse and repair (recommended)**, **Install fresh each time**, or **Skip dependency setup**. The prompt explains the detected tool—for example, pnpm reuses local `node_modules` through CoW and then runs `pnpm install --frozen-lockfile`. The choice is saved locally or in the per-repo global config according to `installSaveTarget`. Headless, JSON, and dry-run commands never prompt and retain the safe `off` default. + +Ecosystems dominated by global caches, such as Gradle, Maven, and Go, are not seeded until a safe project-local target and deterministic repair rule are available. Future adapters can add Composer, Poetry/PDM, Mix, Dart/Flutter, or .NET without changing `syncDirs`. Use `gji new --dry-run` to see the directories and estimated sizes without creating anything. `gji new --json` adds a `cloned` array and structured `dependencyBootstrap` events, including machine-readable reasons for skips and failures. If bootstrap fails, the JSON error includes the created worktree path; text mode prints the same path and a cleanup hint. The benchmark target for a 2 GB dependency tree on supported APFS/Btrfs or XFS filesystems is under 5 seconds; benchmark your repository locally because filesystem and storage behavior determine the result. @@ -469,7 +473,9 @@ This is useful after cloning on a new machine, recovering a broken worktree, or ## Install prompt -When `gji new` or `gji pr` creates a worktree, `gji` detects the project's package manager from its lockfile and offers to run the install command: +For supported lockfiles, the dependency policy prompt above is the primary setup choice. Keep `after-create` hooks for project-specific work such as `pnpm run generate`, code generation, local-service setup, or environment-specific commands; hooks are not a second dependency-bootstrap configuration. + +Projects without a supported dependency adapter can still use the legacy one-shot install prompt: ``` Run `pnpm install` in the new worktree? @@ -481,7 +487,7 @@ Run `pnpm install` in the new worktree? **Always** saves `hooks.afterCreate`; **Never** writes `skipInstallPrompt: true`. Where they are saved depends on `installSaveTarget` (see [Available keys](#available-keys)) — defaults to `.gji.json`. -`syncDirs` never suppresses the install prompt. To automate dependency setup, opt into `dependencyBootstrap: "cow-then-repair"`; the adapter always runs its lockfile/build repair after `syncFiles`. +`syncDirs` remains generic and never suppresses installation or repair. To automate supported dependency/build setup, use `dependencyBootstrap`; the adapter always runs its lockfile/build repair after `syncFiles`. Explicit policies in global defaults, per-repo global config, or `.gji.json` always win over prompting. ## JSON output diff --git a/scripts/generate-man.mjs b/scripts/generate-man.mjs index 859ce02..d482b9e 100644 --- a/scripts/generate-man.mjs +++ b/scripts/generate-man.mjs @@ -132,8 +132,8 @@ function subcommandManPage(cmd) { out += `.SH DESCRIPTION\n${esc(desc)}\n`; if (cmd.name() === "new" || cmd.name() === "pr") { - out += `.SH BOOTSTRAP_POLICY\n${esc("dependencyBuildCommand overrides Cargo's default cargo check. npm is always install-only, and sync-file failures stop dependency repair, install prompts, and after-create hooks. JSON bootstrap events include structured reasons, and failed setup reports the created worktree path.\n")}\n`; - out += `.SH BOOTSTRAP\n${esc("syncDirs performs generic copy-on-write directory seeding before syncFiles. It never falls back to ordinary copying and never suppresses installation prompts. Set dependencyBootstrap to off, cow-then-repair, or install-only to enable deterministic package-manager or build-cache repair. The lifecycle is CoW seed, syncFiles, repair or install, then after-create. CoW failures fall back to repair from an empty target; partial clones are removed and existing targets are never deleted.\n")}\n`; + out += `.SH BOOTSTRAP_POLICY\n${esc("dependencyBuildCommand overrides Cargo's default cargo check. Supported lockfiles trigger an interactive dependencyBootstrap choice when no explicit policy exists; the choice is saved according to installSaveTarget. JSON and headless modes never prompt and keep off. npm is always install-only, and sync-file failures stop dependency repair, install prompts, and after-create hooks. JSON bootstrap events include structured reasons, and failed setup reports the created worktree path.\n")}\n`; + out += `.SH BOOTSTRAP\n${esc("syncDirs performs generic copy-on-write directory seeding before syncFiles. It never falls back to ordinary copying and never suppresses installation or repair. Set dependencyBootstrap to off, cow-then-repair, or install-only to enable deterministic project-local package-manager or build-cache repair. The lifecycle is CoW seed, syncFiles, repair or install, then after-create. CoW failures fall back to repair from an empty target; partial clones are removed and existing targets are never deleted. Bundler uses vendor/bundle only; global gem stores and caches are not cloned.\n")}\n`; } out += optionsSection(cmd); diff --git a/src/config.test.ts b/src/config.test.ts index 687b592..f74beb9 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,6 +11,7 @@ import { KNOWN_CONFIG_KEYS, loadConfig, loadEffectiveConfig, + loadEffectiveConfigResult, normalizeDependencyBootstrap, resolveConfigString, saveLocalConfig, @@ -271,6 +272,51 @@ describe("updateLocalConfigKey", () => { }); describe("loadEffectiveConfig – per-repo global config", () => { + it("tracks explicit bootstrap policy across all config layers", async () => { + // Given global, per-repo global, and local policies with local precedence. + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const repoRoot = await mkdtemp(join(tmpdir(), "gji-repo-")); + const globalConfigPath = GLOBAL_CONFIG_FILE_PATH(home); + process.env.HOME = home; + + await mkdir(dirname(globalConfigPath), { recursive: true }); + await writeFile( + globalConfigPath, + JSON.stringify({ + dependencyBootstrap: "off", + repos: { + [repoRoot]: { dependencyBootstrap: "install-only" }, + }, + }), + "utf8", + ); + await writeFile( + join(repoRoot, CONFIG_FILE_NAME), + JSON.stringify({ dependencyBootstrap: "cow-then-repair" }), + "utf8", + ); + + // When the effective config is resolved. + const result = await loadEffectiveConfigResult(repoRoot, home); + + // Then local precedence is preserved and prompting is disabled because a layer was explicit. + expect(result.config.dependencyBootstrap).toBe("cow-then-repair"); + expect(result.dependencyBootstrapExplicit).toBe(true); + }); + + it("defaults bootstrap provenance to implicit when no layer configures it", async () => { + // Given a repository with no dependencyBootstrap key in any config layer. + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const repoRoot = await mkdtemp(join(tmpdir(), "gji-repo-")); + + // When the effective config is resolved. + const result = await loadEffectiveConfigResult(repoRoot, home); + + // Then the safe default remains off and the command may offer the interactive policy prompt. + expect(result.config.dependencyBootstrap).toBeUndefined(); + expect(result.dependencyBootstrapExplicit).toBe(false); + }); + it("applies per-repo global config when the repo path matches", async () => { // Given a global config that has a repos entry keyed by the repo root. const home = await mkdtemp(join(tmpdir(), "gji-home-")); diff --git a/src/config.ts b/src/config.ts index 9c0db0e..6b2e8a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,6 +63,11 @@ export interface LoadedConfig { path: string; } +export interface EffectiveConfigResult { + config: EffectiveGjiConfig; + dependencyBootstrapExplicit: boolean; +} + export const DEFAULT_CONFIG: GjiConfig = Object.freeze({}); export async function loadConfig(root: string): Promise { @@ -76,6 +81,14 @@ export async function loadEffectiveConfig( home: string = homedir(), onWarning?: (message: string) => void, ): Promise { + return (await loadEffectiveConfigResult(root, home, onWarning)).config; +} + +export async function loadEffectiveConfigResult( + root: string, + home: string = homedir(), + onWarning?: (message: string) => void, +): Promise { const [globalConfig, localConfig] = await Promise.all([ loadGlobalConfig(home), loadConfig(root), @@ -156,7 +169,13 @@ export async function loadEffectiveConfig( merged.hooks = { ...globalHooks, ...perRepoHooks, ...localHooks }; } - return toEffectiveConfig(merged); + return { + config: toEffectiveConfig(merged), + dependencyBootstrapExplicit: + Object.hasOwn(globalBase, "dependencyBootstrap") || + Object.hasOwn(perRepoConfig, "dependencyBootstrap") || + Object.hasOwn(localConfig.config, "dependencyBootstrap"), + }; } export async function loadGlobalConfig( diff --git a/src/dependency-bootstrap-prompt.ts b/src/dependency-bootstrap-prompt.ts new file mode 100644 index 0000000..bc93923 --- /dev/null +++ b/src/dependency-bootstrap-prompt.ts @@ -0,0 +1,172 @@ +import { isCancel, select } from "@clack/prompts"; +import { + type DependencyBootstrapMode, + type EffectiveGjiConfig, + updateGlobalRepoConfigKey, + updateLocalConfigKey, +} from "./config.js"; +import { + type DependencyBootstrapCandidate, + detectDependencyBootstrapCandidate, +} from "./dependency-bootstrap.js"; +import { isHeadless } from "./headless.js"; + +export interface DependencyBootstrapPromptDependencies { + promptForDependencyBootstrap?: ( + candidate: DependencyBootstrapCandidate, + ) => Promise; + writeConfigKey?: (root: string, key: string, value: unknown) => Promise; + writeGlobalRepoConfigKey?: ( + repoRoot: string, + key: string, + value: unknown, + ) => Promise; +} + +export interface DependencyBootstrapPolicyResolution { + mode: DependencyBootstrapMode; + prompted: boolean; +} + +export async function resolveDependencyBootstrapPolicy( + context: { + repoRoot: string; + currentRoot?: string; + worktreePath: string; + }, + config: EffectiveGjiConfig, + dependencyBootstrapExplicit: boolean, + options: { + dryRun?: boolean; + legacyInstallPromptConfigured?: boolean; + nonInteractive?: boolean; + stderr: (chunk: string) => void; + dependencies?: DependencyBootstrapPromptDependencies; + }, +): Promise { + if (dependencyBootstrapExplicit) { + return { + mode: config.dependencyBootstrap ?? "off", + prompted: false, + }; + } + + if (options.dryRun || options.nonInteractive || isHeadless()) { + return { mode: "off", prompted: false }; + } + + // Keep callers that inject the legacy install prompt on the old path. The + // command's default dependencies use the policy prompt below. + if ( + options.legacyInstallPromptConfigured && + !options.dependencies?.promptForDependencyBootstrap + ) { + return { mode: "off", prompted: false }; + } + + let candidate: DependencyBootstrapCandidate | null; + try { + candidate = await detectDependencyBootstrapCandidate(context); + } catch (error) { + options.stderr( + `gji: dependency setup detection failed: ${toErrorMessage(error)}\n`, + ); + return { mode: "off", prompted: false }; + } + + if (!candidate) return { mode: "off", prompted: false }; + + const prompt = + options.dependencies?.promptForDependencyBootstrap ?? + defaultPromptForDependencyBootstrap; + const choice = await prompt(candidate); + if (choice === null) return { mode: "off", prompted: true }; + const mode = choice; + + await persistDependencyBootstrapPolicy( + context.repoRoot, + config, + mode, + options.dependencies, + options.stderr, + ); + + return { mode, prompted: true }; +} + +async function persistDependencyBootstrapPolicy( + repoRoot: string, + config: EffectiveGjiConfig, + mode: DependencyBootstrapMode, + dependencies: DependencyBootstrapPromptDependencies | undefined, + stderr: (chunk: string) => void, +): Promise { + const writeLocal = + dependencies?.writeConfigKey ?? + (async (root, key, value) => { + await updateLocalConfigKey(root, key, value); + }); + const writeGlobal = + dependencies?.writeGlobalRepoConfigKey ?? + (async (root, key, value) => { + await updateGlobalRepoConfigKey(root, key, value); + }); + + try { + if (config.installSaveTarget === "global") { + await writeGlobal(repoRoot, "dependencyBootstrap", mode); + } else { + await writeLocal(repoRoot, "dependencyBootstrap", mode); + } + } catch (error) { + stderr( + `gji: failed to save dependencyBootstrap: ${toErrorMessage(error)}\n`, + ); + } +} + +async function defaultPromptForDependencyBootstrap( + candidate: DependencyBootstrapCandidate, +): Promise { + const reuseHint = formatReuseHint(candidate); + const choice = await select({ + message: `Set up ${candidate.adapter} dependencies for new worktrees? (${candidate.lockfile})`, + options: [ + { + value: "cow-then-repair", + label: "Reuse and repair (recommended)", + hint: reuseHint, + }, + { + value: "install-only", + label: "Install fresh each time", + hint: candidate.repairCommand, + }, + { + value: "off", + label: "Skip dependency setup", + hint: "leave dependencies to project hooks or manual setup", + }, + ], + }); + + if (isCancel(choice)) return null; + return choice as DependencyBootstrapMode; +} + +function formatReuseHint(candidate: DependencyBootstrapCandidate): string { + if (candidate.adapter === "pnpm") { + return "reuse local node_modules through CoW, then run pnpm install --frozen-lockfile"; + } + if (candidate.adapter === "npm") { + return "npm uses a clean install because node_modules is not seeded"; + } + if (!candidate.seedable) { + return `repair ${candidate.target} with ${candidate.repairCommand}`; + } + return `reuse ${candidate.target} through CoW, then run ${candidate.repairCommand}`; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts index 56901f6..14084be 100644 --- a/src/dependency-bootstrap.test.ts +++ b/src/dependency-bootstrap.test.ts @@ -202,6 +202,44 @@ describe("dependencyBootstrap adapters", () => { expect(result.events.at(-1)?.state).toBe("installed"); }); + it("seeds only project-local Bundler state and repairs it deterministically", async () => { + // Given a Ruby project with a locked bundle and a project-local vendor/bundle tree. + const repoRoot = await mkdtemp( + join(tmpdir(), "gji-bootstrap-bundler-repo-"), + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-bundler-worktree-"), + ); + await writeFile(join(repoRoot, "Gemfile.lock"), "GEM\n specs:\n"); + await mkdir(join(repoRoot, "vendor", "bundle"), { recursive: true }); + + // When the Bundler adapter is planned and executed with an injected CoW runner. + const plan = await prepareDependencyBootstrap("cow-then-repair", { + repoRoot, + worktreePath, + }); + const commands: string[] = []; + const result = await executeDependencyBootstrap(plan, { + cloneDirectory: async (_source, destination) => { + await mkdir(destination, { recursive: true }); + return { ms: 1 }; + }, + failureStore: createFailureStore(), + repoRoot, + reporter: createReporter(), + runCommand: async (command) => { + commands.push(command); + }, + }); + + // Then only vendor/bundle is seeded and bundle install repairs the worktree. + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]?.adapter.name).toBe("bundler"); + expect(plan.targets[0]?.target.relativePath).toBe("vendor/bundle"); + expect(result.ready).toBe(true); + expect(commands).toEqual(["bundle install"]); + }); + it("falls back to a clean repair when CoW is unsupported and caches the failure", async () => { // Given a pnpm source whose filesystem rejects CoW. const { repoRoot, worktreePath, plan } = diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index a011b05..8e7d60a 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -61,6 +61,7 @@ export interface BootstrapTarget { export interface BootstrapAdapter { readonly kind: BootstrapKind; + readonly lockfile: string; readonly name: string; readonly relativePath: string; detect(context: BootstrapPreparationContext): Promise; @@ -124,6 +125,15 @@ export interface DependencyBootstrapPreview { }[]; } +export interface DependencyBootstrapCandidate { + adapter: string; + kind: BootstrapKind; + lockfile: string; + target: string; + repairCommand: string; + seedable: boolean; +} + export async function prepareDependencyBootstrap( mode: DependencyBootstrapMode, context: { @@ -185,6 +195,27 @@ export async function prepareDependencyBootstrap( return { mode, targets }; } +export async function detectDependencyBootstrapCandidate(context: { + repoRoot: string; + currentRoot?: string; + worktreePath: string; + checkUvRuntime?: (target: BootstrapTarget) => Promise; + cargoBuildCommand?: string; +}): Promise { + const plan = await prepareDependencyBootstrap("cow-then-repair", context); + const planned = plan.targets[0]; + if (!planned) return null; + + return { + adapter: planned.adapter.name, + kind: planned.adapter.kind, + lockfile: planned.adapter.lockfile, + target: planned.target.relativePath, + repairCommand: planned.target.repairCommand, + seedable: planned.seedable, + }; +} + export function previewDependencyBootstrap( plan: DependencyBootstrapPlan, ): DependencyBootstrapPreview { @@ -634,6 +665,13 @@ function createBootstrapAdapters( target.existingBeforeBootstrap ? "npm install" : "npm ci", repairState: "installed", }), + new LockfileBootstrapAdapter({ + name: "bundler", + kind: "dependency", + lockfile: "Gemfile.lock", + relativePath: "vendor/bundle", + repairCommand: "bundle install", + }), new LockfileBootstrapAdapter({ name: "uv", kind: "dependency", @@ -667,9 +705,9 @@ interface LockfileBootstrapAdapterSpec { class LockfileBootstrapAdapter implements BootstrapAdapter { readonly kind: BootstrapKind; + readonly lockfile: string; readonly name: string; readonly relativePath: string; - private readonly lockfile: string; private readonly defaultRepairCommand: string; private readonly seedPolicy: "always" | "never"; private readonly canSeedOverride?: ( diff --git a/src/new.test.ts b/src/new.test.ts index bba5cd2..7eadfbb 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -763,6 +763,169 @@ describe("gji new", () => { }); describe("syncDirs CoW bootstrap", () => { + it("prompts for policy and persists an interactive local selection", async () => { + // Given a pnpm repository with no explicit dependencyBootstrap policy. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "pnpm-lock.yaml", + "lockfileVersion: '9'\n", + "Add pnpm lockfile", + ); + const seenCandidates: Array<{ + adapter: string; + lockfile: string; + target: string; + }> = []; + const commands: string[] = []; + + // When gji new asks for dependency setup and the user selects reuse and repair. + const result = await createNewCommand({ + promptForDependencyBootstrap: async (candidate) => { + seenCandidates.push(candidate); + return "cow-then-repair"; + }, + runInstallCommand: async (command) => { + commands.push(command); + }, + })({ + branch: "feature/prompt-local-policy", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the tool-aware selection is persisted locally and pnpm repair runs. + expect(result).toBe(0); + expect(seenCandidates).toEqual([ + expect.objectContaining({ + adapter: "pnpm", + lockfile: "pnpm-lock.yaml", + target: "node_modules", + }), + ]); + expect(commands).toEqual(["pnpm install --frozen-lockfile"]); + expect( + JSON.parse(await readFile(join(repoRoot, ".gji.json"), "utf8")), + ).toMatchObject({ dependencyBootstrap: "cow-then-repair" }); + }); + + it("persists the selected policy in the per-repo global target", async () => { + // Given a Ruby repository configured to save install choices globally. + const home = await mkdtemp(join(tmpdir(), "gji-home-")); + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "Gemfile.lock", + "GEM\n specs:\n", + "Add bundle lockfile", + ); + process.env.HOME = home; + const globalConfigPath = GLOBAL_CONFIG_FILE_PATH(home); + await mkdir(dirname(globalConfigPath), { recursive: true }); + await writeFile( + globalConfigPath, + JSON.stringify({ installSaveTarget: "global" }), + "utf8", + ); + + // When gji new selects skip dependency setup. + const result = await createNewCommand({ + promptForDependencyBootstrap: async () => "off", + })({ + branch: "feature/prompt-global-policy", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the local file is untouched and the per-repo global policy is saved. + expect(result).toBe(0); + await expect(pathExists(join(repoRoot, ".gji.json"))).resolves.toBe( + false, + ); + expect( + JSON.parse(await readFile(globalConfigPath, "utf8")), + ).toMatchObject({ + repos: { + [repoRoot]: { dependencyBootstrap: "off" }, + }, + }); + }); + + it("does not prompt when an explicit policy is configured", async () => { + // Given a pnpm repository with an explicit install-only local policy. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "pnpm-lock.yaml", + "lockfileVersion: '9'\n", + "Add pnpm lockfile", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ dependencyBootstrap: "install-only" }), + "utf8", + ); + let promptCalled = false; + const commands: string[] = []; + + // When gji new creates the worktree with a prompt that must never be reached. + const result = await createNewCommand({ + promptForDependencyBootstrap: async () => { + promptCalled = true; + throw new Error("explicit policy must suppress prompt"); + }, + runInstallCommand: async (command) => { + commands.push(command); + }, + })({ + branch: "feature/explicit-policy", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the configured policy wins without prompting and runs its deterministic command. + expect(result).toBe(0); + expect(promptCalled).toBe(false); + expect(commands).toEqual(["pnpm install --frozen-lockfile"]); + }); + + it("keeps the safe off default and never prompts in JSON mode", async () => { + // Given a supported pnpm lockfile but no explicit policy. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "pnpm-lock.yaml", + "lockfileVersion: '9'\n", + "Add pnpm lockfile", + ); + let promptCalled = false; + const stdout: string[] = []; + + // When gji new runs in JSON mode. + const result = await createNewCommand({ + promptForDependencyBootstrap: async () => { + promptCalled = true; + throw new Error("JSON mode must not prompt"); + }, + })({ + branch: "feature/json-policy-default", + cwd: repoRoot, + json: true, + stderr: () => undefined, + stdout: (chunk) => stdout.push(chunk), + }); + + // Then no policy is executed or persisted and the JSON result remains valid. + expect(result).toBe(0); + expect(promptCalled).toBe(false); + expect(JSON.parse(stdout.join(""))).not.toHaveProperty( + "dependencyBootstrap", + ); + }); + it("clones configured directories before sync files and after-create", async () => { // Given a repository with a CoW directory, a sync file, and an after-create hook. const repoRoot = await createRepository(); diff --git a/src/new.ts b/src/new.ts index 3ddd4e4..e82e4dd 100644 --- a/src/new.ts +++ b/src/new.ts @@ -10,7 +10,7 @@ import { } from "./bootstrap-preview.js"; import { type EffectiveGjiConfig, - loadEffectiveConfig, + loadEffectiveConfigResult, resolveConfigString, } from "./config.js"; import { @@ -18,6 +18,10 @@ import { pathExists, promptForPathConflict, } from "./conflict.js"; +import { + type DependencyBootstrapPromptDependencies, + resolveDependencyBootstrapPolicy, +} from "./dependency-bootstrap-prompt.js"; import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { defaultSpawnEditor, EDITORS } from "./editor.js"; import { formatBytes } from "./format-bytes.js"; @@ -64,7 +68,9 @@ export interface NewCommandOptions { stdout: (chunk: string) => void; } -export interface NewCommandDependencies extends InstallPromptDependencies { +export interface NewCommandDependencies + extends InstallPromptDependencies, + DependencyBootstrapPromptDependencies { cloneDir: CloneDirectory; createBranchPlaceholder: () => string; promptForBranch: (placeholder: string) => Promise; @@ -100,12 +106,15 @@ export function createNewCommand( const repository = await detectRepository(options.cwd); let config: EffectiveGjiConfig; + let dependencyBootstrapExplicit: boolean; try { - config = await loadEffectiveConfig( + const loaded = await loadEffectiveConfigResult( repository.repoRoot, undefined, options.json ? undefined : options.stderr, ); + config = loaded.config; + dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit; } catch (error) { return emitNewError( options, @@ -247,6 +256,30 @@ export function createNewCommand( } } + const dependencyPolicy = await resolveDependencyBootstrapPolicy( + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + worktreePath, + }, + config, + dependencyBootstrapExplicit, + { + dependencies, + dryRun: options.dryRun, + legacyInstallPromptConfigured: + dependencies.promptForInstallChoice !== undefined, + nonInteractive: !!options.json, + stderr: options.stderr, + }, + ); + config = { + ...config, + dependencyBootstrap: dependencyPolicy.mode, + }; + const dependencyBootstrapPolicyResolved = + dependencyBootstrapExplicit || dependencyPolicy.prompted; + if (options.dryRun) { if (options.take) { const changedFiles = await listTakeFiles(options.cwd); @@ -452,6 +485,7 @@ export function createNewCommand( json: options.json, worktreePath, installDependencies: dependencies, + dependencyBootstrapPolicyResolved, }); if (!bootstrap.ready) { return emitNewError(options, "dependency bootstrap failed", { diff --git a/src/pr.test.ts b/src/pr.test.ts index c21871a..0943d03 100644 --- a/src/pr.test.ts +++ b/src/pr.test.ts @@ -656,6 +656,40 @@ describe("gji pr", () => { }); }); + it("prompts for dependency policy before bootstrapping a PR worktree", async () => { + // Given a PR repository whose base worktree exposes a pnpm lockfile. + const repoRoot = await setupPrRepo("2016"); + await commitFile( + repoRoot, + "pnpm-lock.yaml", + "lockfileVersion: '9'\n", + "Add pnpm lockfile", + ); + let prompted = false; + const commands: string[] = []; + + // When gji pr selects install-only from the interactive policy prompt. + const result = await createPrCommand({ + promptForDependencyBootstrap: async (candidate) => { + prompted = candidate.adapter === "pnpm"; + return "install-only"; + }, + runInstallCommand: async (command) => { + commands.push(command); + }, + })({ + cwd: repoRoot, + number: "2016", + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the PR command persists the policy and runs the frozen pnpm repair. + expect(result).toBe(0); + expect(prompted).toBe(true); + expect(commands).toEqual(["pnpm install --frozen-lockfile"]); + }); + it('runs install once and does not persist anything when "yes" is chosen', async () => { // Given a PR repo with a detected package manager and a "yes" prompt choice. const repoRoot = await setupPrRepo("2001"); diff --git a/src/pr.ts b/src/pr.ts index ee13a28..9babaec 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -9,7 +9,7 @@ import { } from "./bootstrap-preview.js"; import { type EffectiveGjiConfig, - loadEffectiveConfig, + loadEffectiveConfigResult, resolveConfigString, } from "./config.js"; import { @@ -17,6 +17,10 @@ import { pathExists, promptForPathConflict, } from "./conflict.js"; +import { + type DependencyBootstrapPromptDependencies, + resolveDependencyBootstrapPolicy, +} from "./dependency-bootstrap-prompt.js"; import type { CloneDirectory } from "./dir-clone.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; @@ -46,7 +50,9 @@ export interface PrCommandOptions { stdout: (chunk: string) => void; } -export interface PrCommandDependencies extends InstallPromptDependencies { +export interface PrCommandDependencies + extends InstallPromptDependencies, + DependencyBootstrapPromptDependencies { cloneDir?: CloneDirectory; promptForPathConflict: (path: string) => Promise; } @@ -89,12 +95,15 @@ export function createPrCommand( const repository = await detectRepository(options.cwd); let config: EffectiveGjiConfig; + let dependencyBootstrapExplicit: boolean; try { - config = await loadEffectiveConfig( + const loaded = await loadEffectiveConfigResult( repository.repoRoot, undefined, options.json ? undefined : options.stderr, ); + config = loaded.config; + dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (options.json) { @@ -147,6 +156,30 @@ export function createPrCommand( return 1; } + const dependencyPolicy = await resolveDependencyBootstrapPolicy( + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + worktreePath, + }, + config, + dependencyBootstrapExplicit, + { + dependencies, + dryRun: options.dryRun, + legacyInstallPromptConfigured: + dependencies.promptForInstallChoice !== undefined, + nonInteractive: !!options.json, + stderr: options.stderr, + }, + ); + config = { + ...config, + dependencyBootstrap: dependencyPolicy.mode, + }; + const dependencyBootstrapPolicyResolved = + dependencyBootstrapExplicit || dependencyPolicy.prompted; + const dryRunSyncDirs = options.dryRun ? await estimateSyncDirectories( repository.repoRoot, @@ -237,6 +270,7 @@ export function createPrCommand( json: options.json, worktreePath, installDependencies: dependencies, + dependencyBootstrapPolicyResolved, }); if (!bootstrap.ready) { const details = { diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index d089684..d6e5d25 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -34,6 +34,7 @@ export interface WorktreeBootstrapOptions { runCommand?: BootstrapCommandRunner; commandStdout?: (chunk: string) => void; commandStderr?: (chunk: string) => void; + dependencyBootstrapPolicyResolved?: boolean; json?: boolean; nonInteractive: boolean; repoRoot: string; @@ -121,7 +122,7 @@ export async function bootstrapWorktree( }; } - if (dependencyMode === "off") { + if (dependencyMode === "off" && !options.dependencyBootstrapPolicyResolved) { await maybeRunInstallPrompt( options.worktreePath, options.repoRoot, diff --git a/website/docs/commands.mdx b/website/docs/commands.mdx index 51338cb..025147e 100644 --- a/website/docs/commands.mdx +++ b/website/docs/commands.mdx @@ -207,4 +207,4 @@ Several commands support `--json` so shell scripts and tools can consume structu `gji clean --stale` limits cleanup to clean branch worktrees whose upstream is gone and whose branch is already merged into the configured or remote default branch. -When `syncDirs` is configured, `gji new` clones each available arbitrary directory with filesystem copy-on-write before syncing files. It never suppresses installation prompts. Opt into `dependencyBootstrap` for deterministic package-manager/build-cache repair after `syncFiles`; unsupported filesystems fall back to install/repair without ordinary copying. JSON bootstrap events include structured reasons, and failed setup reports the created worktree path for recovery. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, adapter behavior, JSON output, and dry-run behavior. +When `syncDirs` is configured, `gji new` clones each available arbitrary directory with filesystem copy-on-write before syncing files. It remains generic and never suppresses installation or repair. When a supported lockfile is detected without an explicit `dependencyBootstrap` policy, interactive `gji new` and `gji pr` offer **Reuse and repair**, **Install fresh each time**, or **Skip dependency setup**, and persist the choice using `installSaveTarget`. JSON, headless, and dry-run modes never prompt. Opt into `dependencyBootstrap` for deterministic project-local package-manager/build-cache repair after `syncFiles`; unsupported filesystems fall back to install/repair without ordinary copying. Keep `after-create` hooks for project-specific commands such as generation and local-service setup. JSON bootstrap events include structured reasons, and failed setup reports the created worktree path for recovery. See the [configuration guide](./configuration#instant-directory-bootstrap) for validation, adapter behavior, JSON output, and dry-run behavior. diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index add1cdd..955a52f 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -27,7 +27,7 @@ Use the global file for defaults you want everywhere. Use `.gji.json` when one r | `dependencyBootstrap` | Dependency/build-state policy: `off`, `cow-then-repair`, or `install-only`. | | `dependencyBuildCommand` | Optional Cargo repair command used by `dependencyBootstrap`; defaults to `cargo check`. | | `skipInstallPrompt` | Disable the automatic install prompt permanently. | -| `installSaveTarget` | Persist install prompt choices locally or globally. | +| `installSaveTarget` | Persist dependency policy and legacy install prompt choices locally or globally. | | `hooks` | Lifecycle commands for create, enter, and remove events. String hooks run through a shell; array hooks run as argv without a shell. | | `repos` | Per-repository overrides inside the global config. | @@ -123,11 +123,11 @@ The command stores the list under the current repository inside your global conf ## Instant directory bootstrap -Use `syncDirs` for large directories such as dependency trees and build caches: +Use `syncDirs` for advanced, arbitrary directories such as build caches. Dependency adapters discover their own project-local targets, so `node_modules`, `.venv`, and `target` do not need to be listed here: ```json { - "syncDirs": ["node_modules", ".next"] + "syncDirs": [".next", ".cache"] } ``` @@ -135,9 +135,11 @@ The setting is resolved through the same three layers as other normal config val On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. `syncDirs` remains generic and never special-cases `node_modules`, `.venv`, or `target`. -Set `dependencyBootstrap` to `cow-then-repair` to reuse package-manager or build-cache state and then run authoritative repair. Yarn uses immutable install, pnpm uses frozen-lockfile install and regenerates metadata, uv uses locked sync, and Cargo runs `cargo check`. npm uses install-only because `npm ci` can delete a dependency tree. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events with machine-readable reasons. If setup fails, the created worktree path is included in the error output. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. +Set `dependencyBootstrap` to `cow-then-repair` to reuse project-local package-manager or build-cache state and then run authoritative repair. pnpm uses `node_modules` plus frozen-lockfile install and regenerates metadata, Yarn uses `node_modules` plus immutable install, uv uses a compatible `.venv` plus locked sync, Cargo uses `target` plus `cargo check`, and Bundler uses `vendor/bundle` plus `bundle install`. npm is install-only because `npm ci` can delete a dependency tree; it never seeds `node_modules`. Global caches and gem stores are never cloned. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events with machine-readable reasons. If setup fails, the created worktree path is included in the error output. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. -`dependencyBuildCommand` overrides Cargo's default `cargo check`. npm is always install-only, and a sync-file failure stops dependency repair, install prompts, and `after-create` hooks. Dry-run output reports the effective strategy, including repair-only cases where a seed is not compatible. +`dependencyBuildCommand` overrides Cargo's default `cargo check`. npm is always install-only, and a sync-file failure stops dependency repair, install prompts, and `after-create` hooks. If no policy is explicit, interactive `gji new` and `gji pr` prompt when one of the supported lockfiles is detected. The choices are persisted using `installSaveTarget`; JSON, headless, and dry-run modes never prompt and keep `off`. Dry-run output reports the effective strategy, including repair-only cases where a seed is not compatible. + +Ecosystems dominated by global caches, such as Gradle, Maven, and Go, remain out of scope until a safe project-local target and deterministic repair rule are established. Future adapters can add Composer, Poetry/PDM, Mix, Dart/Flutter, or .NET without adding package-manager behavior to `syncDirs`. ## CLI helpers From f0fa62831dff5877dbf9d2b298a58296da360b2a Mon Sep 17 00:00:00 2001 From: sjquant Date: Fri, 24 Jul 2026 23:37:38 +0900 Subject: [PATCH 18/19] Harden dependency bootstrap safety --- README.md | 2 +- src/bootstrap-preview.ts | 14 +++-- src/clone-failure-store.test.ts | 12 +++- src/command-runner.test.ts | 31 ++++++++++ src/command-runner.ts | 7 +++ src/dependency-bootstrap-prompt.ts | 22 ++++--- src/dependency-bootstrap.test.ts | 49 ++++++++++++++- src/dependency-bootstrap.ts | 70 ++++++++++++++++----- src/dir-clone.test.ts | 19 +++--- src/dir-clone.ts | 98 ++++++++++++++++++++++++++++-- src/file-sync.ts | 27 +++----- src/new.test.ts | 66 ++++++++++++++++++++ src/new.ts | 7 +-- src/pr.test.ts | 43 ++++++++++--- src/pr.ts | 85 ++++++++++++++++++-------- src/safe-destination.ts | 51 +++++++++++++++- src/sync-directories.ts | 12 +--- src/worktree-bootstrap.ts | 40 ++++++------ website/docs/configuration.mdx | 2 +- 19 files changed, 516 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 642bd08..61edeb9 100644 --- a/README.md +++ b/README.md @@ -359,7 +359,7 @@ Use `dependencyBootstrap` when a package manager or build cache needs a reusable } ``` -`cow-then-repair` supports pnpm (`node_modules` + `pnpm install --frozen-lockfile`), Yarn (`node_modules` + `yarn install --immutable`), uv (`.venv` + `uv sync --locked`), Cargo (`target` + the configured `dependencyBuildCommand`, or `cargo check`), and Bundler (`vendor/bundle` + `bundle install`). npm is install-only (`npm ci`) because it can delete an existing dependency tree; it never seeds `node_modules`. Only project-local targets are eligible, so global Ruby gems and package-manager caches are never cloned. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair, install prompts, and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. +`cow-then-repair` supports pnpm (`node_modules` + `pnpm install --frozen-lockfile`), Yarn (`node_modules` + `yarn install --immutable`), uv (`.venv` + `uv sync --locked`), Cargo (`target` + the configured `dependencyBuildCommand`, or `cargo check`), and Bundler (`vendor/bundle` + `BUNDLE_PATH=vendor/bundle bundle install`). npm is install-only (`npm ci`) because it can delete an existing dependency tree; it never seeds `node_modules`. Only project-local targets are eligible, so global Ruby gems and package-manager caches are never cloned. CoW failure never triggers ordinary copying: repair runs from an empty target instead. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; a sync-file failure stops repair, install prompts, and hooks. A successful dependency seed is reported as `reused and repaired`, not as an install skip. When a supported lockfile is detected and no `dependencyBootstrap` policy is configured, interactive `gji new` and `gji pr` ask which policy to persist: **Reuse and repair (recommended)**, **Install fresh each time**, or **Skip dependency setup**. The prompt explains the detected tool—for example, pnpm reuses local `node_modules` through CoW and then runs `pnpm install --frozen-lockfile`. The choice is saved locally or in the per-repo global config according to `installSaveTarget`. Headless, JSON, and dry-run commands never prompt and retain the safe `off` default. diff --git a/src/bootstrap-preview.ts b/src/bootstrap-preview.ts index 83351db..09f0da4 100644 --- a/src/bootstrap-preview.ts +++ b/src/bootstrap-preview.ts @@ -1,5 +1,6 @@ import type { DependencyBootstrapMode } from "./config.js"; import { + type BootstrapStrategy, type BootstrapTarget, type DependencyBootstrapPreview, prepareDependencyBootstrap, @@ -33,8 +34,13 @@ export function formatDependencyBootstrapPreview( .join(""); } -function formatBootstrapStrategy(strategy: string): string { - if (strategy === "cow-then-repair") return "seed and repair"; - if (strategy === "repair-only") return "repair"; - return "install"; +function formatBootstrapStrategy(strategy: BootstrapStrategy): string { + switch (strategy) { + case "cow-then-repair": + return "seed and repair"; + case "repair-only": + return "repair"; + case "install-only": + return "install"; + } } diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts index 7021d8c..f433ccc 100644 --- a/src/clone-failure-store.test.ts +++ b/src/clone-failure-store.test.ts @@ -27,6 +27,7 @@ describe("FileCloneFailureStore", () => { process.env.GJI_CONFIG_DIR = root; const store = new FileCloneFailureStore(); await store.cache("/repo", "node_modules", "unsupported"); + await store.cache("/repo", ".venv", "unsupported"); // When cache lookups use an inherited object-property name and the cached name. const inheritedNameCached = await store.isCached("/repo", "constructor"); @@ -36,9 +37,14 @@ describe("FileCloneFailureStore", () => { expect(inheritedNameCached).toBe(false); expect(ordinaryNameCached).toBe(true); await store.clear("/repo", "node_modules"); - await expect(readFile(join(root, "state.json"), "utf8")).resolves.toContain( - "syncDirs", - ); + await expect(store.isCached("/repo", "node_modules")).resolves.toBe(false); + await expect(store.isCached("/repo", ".venv")).resolves.toBe(true); + const state = JSON.parse( + await readFile(join(root, "state.json"), "utf8"), + ) as { + syncDirs: Record>; + }; + expect(state.syncDirs["/repo"]).not.toHaveProperty("node_modules"); }); it("keeps scoped dependency failures separate", async () => { diff --git a/src/command-runner.test.ts b/src/command-runner.test.ts index 18d987e..2102f6b 100644 --- a/src/command-runner.test.ts +++ b/src/command-runner.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { runCommand } from "./command-runner.js"; @@ -21,4 +25,31 @@ describe("runCommand", () => { expect(stdout.join("")).toBe("out"); expect(stderr.join("")).toBe("err"); }); + + it("rejects a non-zero child and preserves its stderr", async () => { + // Given a command that reports an error and exits unsuccessfully. + const stderr: string[] = []; + const command = `${JSON.stringify(process.execPath)} -e "process.stderr.write('failure'); process.exit(7)"`; + + // When the command runner executes it. + const result = runCommand(command, process.cwd(), (chunk) => + stderr.push(chunk), + ); + + // Then the failure rejects instead of being mistaken for a successful repair. + await expect(result).rejects.toThrow("exited with code 7"); + expect(stderr.join("")).toBe("failure"); + }); + + it("rejects when the child process cannot start", async () => { + // Given a command whose working directory does not exist. + const root = await mkdtemp(join(tmpdir(), "gji-command-runner-")); + const missingCwd = join(root, "missing"); + + // When the command runner tries to start the child process. + const result = runCommand("true", missingCwd, () => undefined); + + // Then the spawn error is surfaced to the caller. + await expect(result).rejects.toMatchObject({ code: "ENOENT" }); + }); }); diff --git a/src/command-runner.ts b/src/command-runner.ts index 48dc06e..79a123f 100644 --- a/src/command-runner.ts +++ b/src/command-runner.ts @@ -5,17 +5,24 @@ export type CommandRunner = ( cwd: string, stderr: (chunk: string) => void, stdout?: (chunk: string) => void, + options?: CommandRunnerOptions, ) => Promise; +export interface CommandRunnerOptions { + env?: NodeJS.ProcessEnv; +} + export const runCommand: CommandRunner = async ( command, cwd, stderr, stdout = (chunk) => process.stdout.write(chunk), + options, ) => { await new Promise((resolve, reject) => { const child = spawn(command, { cwd, + env: options?.env ? { ...process.env, ...options.env } : undefined, shell: true, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/src/dependency-bootstrap-prompt.ts b/src/dependency-bootstrap-prompt.ts index bc93923..5fe3fe6 100644 --- a/src/dependency-bootstrap-prompt.ts +++ b/src/dependency-bootstrap-prompt.ts @@ -26,12 +26,14 @@ export interface DependencyBootstrapPromptDependencies { export interface DependencyBootstrapPolicyResolution { mode: DependencyBootstrapMode; prompted: boolean; + source: "explicit" | "prompted" | "default"; } export async function resolveDependencyBootstrapPolicy( context: { repoRoot: string; currentRoot?: string; + detectionRoot?: string; worktreePath: string; }, config: EffectiveGjiConfig, @@ -48,11 +50,12 @@ export async function resolveDependencyBootstrapPolicy( return { mode: config.dependencyBootstrap ?? "off", prompted: false, + source: "explicit", }; } if (options.dryRun || options.nonInteractive || isHeadless()) { - return { mode: "off", prompted: false }; + return { mode: "off", prompted: false, source: "default" }; } // Keep callers that inject the legacy install prompt on the old path. The @@ -61,7 +64,7 @@ export async function resolveDependencyBootstrapPolicy( options.legacyInstallPromptConfigured && !options.dependencies?.promptForDependencyBootstrap ) { - return { mode: "off", prompted: false }; + return { mode: "off", prompted: false, source: "default" }; } let candidate: DependencyBootstrapCandidate | null; @@ -71,16 +74,17 @@ export async function resolveDependencyBootstrapPolicy( options.stderr( `gji: dependency setup detection failed: ${toErrorMessage(error)}\n`, ); - return { mode: "off", prompted: false }; + return { mode: "off", prompted: false, source: "default" }; } - if (!candidate) return { mode: "off", prompted: false }; + if (!candidate) return { mode: "off", prompted: false, source: "default" }; const prompt = options.dependencies?.promptForDependencyBootstrap ?? defaultPromptForDependencyBootstrap; const choice = await prompt(candidate); - if (choice === null) return { mode: "off", prompted: true }; + if (choice === null) + return { mode: "off", prompted: true, source: "prompted" }; const mode = choice; await persistDependencyBootstrapPolicy( @@ -91,7 +95,7 @@ export async function resolveDependencyBootstrapPolicy( options.stderr, ); - return { mode, prompted: true }; + return { mode, prompted: true, source: "prompted" }; } async function persistDependencyBootstrapPolicy( @@ -129,8 +133,12 @@ async function defaultPromptForDependencyBootstrap( candidate: DependencyBootstrapCandidate, ): Promise { const reuseHint = formatReuseHint(candidate); + const subject = + candidate.kind === "build-cache" + ? "dependencies and build state" + : "dependencies"; const choice = await select({ - message: `Set up ${candidate.adapter} dependencies for new worktrees? (${candidate.lockfile})`, + message: `Set up ${candidate.adapter} ${subject} for new worktrees? (${candidate.lockfile})`, options: [ { value: "cow-then-repair", diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts index 14084be..a8b76a1 100644 --- a/src/dependency-bootstrap.test.ts +++ b/src/dependency-bootstrap.test.ts @@ -21,23 +21,34 @@ import { CloneDestinationExistsError, CloneUnsupportedError, } from "./dir-clone.js"; +import { addLinkedWorktree, createRepository } from "./repo.test-helpers.js"; function createFailureStore() { const failures = new Set(); + const calls = { + cache: [] as string[][], + clear: [] as string[][], + isCached: [] as string[][], + }; return { - isCached: async (repoRoot: string, directory: string, scope?: string) => - failures.has(`${repoRoot}:${directory}:${scope ?? ""}`), + isCached: async (repoRoot: string, directory: string, scope?: string) => { + calls.isCached.push([repoRoot, directory, scope ?? ""]); + return failures.has(`${repoRoot}:${directory}:${scope ?? ""}`); + }, cache: async ( repoRoot: string, directory: string, _reason: string, scope?: string, ) => { + calls.cache.push([repoRoot, directory, scope ?? ""]); failures.add(`${repoRoot}:${directory}:${scope ?? ""}`); }, clear: async (repoRoot: string, directory: string, scope?: string) => { + calls.clear.push([repoRoot, directory, scope ?? ""]); failures.delete(`${repoRoot}:${directory}:${scope ?? ""}`); }, + calls, }; } @@ -75,6 +86,34 @@ async function prepareNodePlan( } describe("dependencyBootstrap adapters", () => { + it("selects an eligible linked worktree as the seed source", async () => { + // Given a repository whose lockfile and seed exist only in the current linked worktree. + const repoRoot = await createRepository(); + const currentRoot = await addLinkedWorktree( + repoRoot, + "feature/bootstrap-source", + ); + const worktreePath = await mkdtemp( + join(tmpdir(), "gji-bootstrap-current-source-worktree-"), + ); + await writeFile( + join(currentRoot, "pnpm-lock.yaml"), + "lockfileVersion: '9'\n", + ); + await mkdir(join(currentRoot, "node_modules")); + + // When the dependency plan is prepared with both repository roots. + const plan = await prepareDependencyBootstrap("cow-then-repair", { + currentRoot, + repoRoot, + worktreePath, + }); + + // Then the eligible current worktree supplies the CoW seed. + expect(plan.targets[0]?.target.sourceRoot).toBe(currentRoot); + expect(plan.targets[0]?.adapter.name).toBe("pnpm"); + }); + it("reports npm as install-only in the effective dry-run strategy", async () => { // Given an npm lockfile and cow-then-repair configuration. const repoRoot = await mkdtemp( @@ -219,6 +258,7 @@ describe("dependencyBootstrap adapters", () => { worktreePath, }); const commands: string[] = []; + const commandOptions: unknown[] = []; const result = await executeDependencyBootstrap(plan, { cloneDirectory: async (_source, destination) => { await mkdir(destination, { recursive: true }); @@ -227,8 +267,9 @@ describe("dependencyBootstrap adapters", () => { failureStore: createFailureStore(), repoRoot, reporter: createReporter(), - runCommand: async (command) => { + runCommand: async (command, _cwd, _stderr, _stdout, options) => { commands.push(command); + commandOptions.push(options); }, }); @@ -238,6 +279,7 @@ describe("dependencyBootstrap adapters", () => { expect(plan.targets[0]?.target.relativePath).toBe("vendor/bundle"); expect(result.ready).toBe(true); expect(commands).toEqual(["bundle install"]); + expect(commandOptions).toEqual([{ env: { BUNDLE_PATH: "vendor/bundle" } }]); }); it("falls back to a clean repair when CoW is unsupported and caches the failure", async () => { @@ -281,6 +323,7 @@ describe("dependencyBootstrap adapters", () => { ), ), ).toBe(true); + expect(failureStore.calls.cache).toHaveLength(1); void worktreePath; }); diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index 8e7d60a..9746bca 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { access, lstat, readFile, realpath, rm } from "node:fs/promises"; +import { lstat, readFile, realpath, rm } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { @@ -16,6 +16,7 @@ import { isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js"; +import { pathExists } from "./fs-utils.js"; import { inspectDestination } from "./safe-destination.js"; const execFileAsync = promisify(execFile); @@ -36,6 +37,7 @@ export type BootstrapStrategy = export type BootstrapCommandRunner = CommandRunner; export interface BootstrapPreparationContext { + detectionRoot?: string; sourceRoot: string; worktreePath: string; } @@ -139,6 +141,7 @@ export async function prepareDependencyBootstrap( context: { repoRoot: string; currentRoot?: string; + detectionRoot?: string; worktreePath: string; checkUvRuntime?: (target: BootstrapTarget) => Promise; cargoBuildCommand?: string; @@ -170,6 +173,7 @@ export async function prepareDependencyBootstrap( for (const sourceRoot of sourceRoots) { const target = await adapter.detect({ + detectionRoot: context.detectionRoot, sourceRoot, worktreePath: context.worktreePath, }); @@ -198,6 +202,7 @@ export async function prepareDependencyBootstrap( export async function detectDependencyBootstrapCandidate(context: { repoRoot: string; currentRoot?: string; + detectionRoot?: string; worktreePath: string; checkUvRuntime?: (target: BootstrapTarget) => Promise; cargoBuildCommand?: string; @@ -334,6 +339,7 @@ async function executeBootstrapTarget( let ownership: BootstrapTargetOwnership = target.existingBeforeBootstrap ? "preserve" : "empty"; + const destinationExistsNow = destinationInspection.kind === "exists"; if (target.existingBeforeBootstrap) { recordBootstrapEvent(events, reporter, { adapter: adapter.name, @@ -343,7 +349,7 @@ async function executeBootstrapTarget( target: target.relativePath, message: "target already existed; using it as the repair input", }); - } else if (seededBySyncDirs && (await pathExists(target.targetPath))) { + } else if (seededBySyncDirs && destinationExistsNow) { if (seedable) { seeded = true; ownership = "syncDirs"; @@ -367,7 +373,7 @@ async function executeBootstrapTarget( "syncDirs created a generic target; this adapter uses repair without CoW", }); } - } else if (await pathExists(target.targetPath)) { + } else if (destinationExistsNow) { ownership = "preserve"; recordBootstrapEvent(events, reporter, { adapter: adapter.name, @@ -489,7 +495,22 @@ async function repairTarget( events: BootstrapEvent[], reporter: DependencyBootstrapReporter, ): Promise { - const presentBeforeRepair = await pathExists(target.targetPath); + const beforeRepairInspection = await inspectDestination( + target.worktreePath, + target.targetPath, + ); + if (beforeRepairInspection.kind === "unsafe") { + recordBootstrapFailure( + events, + reporter, + adapter, + target, + beforeRepairInspection.reason, + "destination-unsafe", + ); + return; + } + const presentBeforeRepair = beforeRepairInspection.kind === "exists"; try { await adapter.repair(target, execution); recordBootstrapEvent(events, reporter, { @@ -538,7 +559,22 @@ async function repairTarget( message: `seed repair failed; removed the seed and retrying clean (${toErrorMessage(firstError)})`, }); - const presentBeforeRetry = await pathExists(target.targetPath); + const beforeRetryInspection = await inspectDestination( + target.worktreePath, + target.targetPath, + ); + if (beforeRetryInspection.kind === "unsafe") { + recordBootstrapFailure( + events, + reporter, + adapter, + target, + beforeRetryInspection.reason, + "destination-unsafe", + ); + return; + } + const presentBeforeRetry = beforeRetryInspection.kind === "exists"; try { await adapter.repair(target, execution); recordBootstrapEvent(events, reporter, { @@ -671,6 +707,9 @@ function createBootstrapAdapters( lockfile: "Gemfile.lock", relativePath: "vendor/bundle", repairCommand: "bundle install", + commandOptions: () => ({ + env: { BUNDLE_PATH: "vendor/bundle" }, + }), }), new LockfileBootstrapAdapter({ name: "uv", @@ -699,6 +738,9 @@ interface LockfileBootstrapAdapterSpec { seedPolicy?: "always" | "never"; canSeedOverride?: (target: BootstrapTarget) => Promise; beforeRepair?: (target: BootstrapTarget) => Promise; + commandOptions?: ( + target: BootstrapTarget, + ) => Parameters[4]; repairCommandOverride?: (target: BootstrapTarget) => string; repairState?: "repaired" | "installed"; } @@ -714,6 +756,9 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { target: BootstrapTarget, ) => Promise; private readonly beforeRepair?: (target: BootstrapTarget) => Promise; + private readonly commandOptions?: ( + target: BootstrapTarget, + ) => Parameters[4]; private readonly repairCommandOverride?: (target: BootstrapTarget) => string; private readonly repairState: "repaired" | "installed"; @@ -726,6 +771,7 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { this.seedPolicy = spec.seedPolicy ?? "always"; this.canSeedOverride = spec.canSeedOverride; this.beforeRepair = spec.beforeRepair; + this.commandOptions = spec.commandOptions; this.repairCommandOverride = spec.repairCommandOverride; this.repairState = spec.repairState ?? "repaired"; } @@ -733,8 +779,8 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { async detect( context: BootstrapPreparationContext, ): Promise { - if (!(await pathExists(join(context.sourceRoot, this.lockfile)))) - return null; + const detectionRoot = context.detectionRoot ?? context.sourceRoot; + if (!(await pathExists(join(detectionRoot, this.lockfile)))) return null; const sourcePath = join(context.sourceRoot, this.relativePath); const targetPath = join(context.worktreePath, this.relativePath); @@ -776,6 +822,7 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { target.worktreePath, context.stderr, context.stdout, + this.commandOptions?.(target), ); } @@ -865,15 +912,6 @@ function isWithin(root: string, candidate: string): boolean { ); } -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 5a34e8c..645108a 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -143,28 +143,23 @@ describe("cloneDir", () => { expect(commandCalled).toBe(false); }); - it("does not publish over a destination that appears during cloning", async () => { - // Given a destination that is created after the initial preflight check. + it("does not publish over an existing empty destination", async () => { + // Given an empty destination that exists before the clone starts. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-publish-race-")); const source = join(root, "source"); const destination = join(root, "destination"); await mkdir(source); + await mkdir(destination); - // When another writer publishes the destination before clone publication. + // When cloneDir attempts to publish the clone. const error = await cloneDir(source, destination, { platform: "linux", - runCommand: async (_command, args) => { - await mkdir(args.at(-1) as string); - await mkdir(destination); - await writeFile(join(destination, "sentinel"), "keep\n", "utf8"); - }, + runCommand: async (_command, args) => mkdir(args.at(-1) as string), }).catch((caught) => caught); expect(isCloneDestinationExistsError(error)).toBe(true); - // Then the appearing destination remains untouched. - await expect(readFile(join(destination, "sentinel"), "utf8")).resolves.toBe( - "keep\n", - ); + // Then the existing destination remains empty and untouched. + expect(await readdir(destination)).toEqual([]); }); it("rejects a destination with a symbolic-link ancestor", async () => { diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 31b9fa2..36cc3a5 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -21,7 +21,10 @@ import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js"; -import { inspectDestination } from "./safe-destination.js"; +import { + ensureDestinationDirectory, + inspectDestination, +} from "./safe-destination.js"; const execFileAsync = promisify(execFile); const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000; @@ -121,7 +124,11 @@ export async function cloneDir( throw new Error(parentInspection.reason); } } - await mkdir(parent, { recursive: true }); + if (options.destinationRoot) { + await ensureDestinationDirectory(options.destinationRoot, parent); + } else { + await mkdir(parent, { recursive: true }); + } if (options.destinationRoot) { const parentInspection = await inspectDestination( options.destinationRoot, @@ -136,7 +143,10 @@ export async function cloneDir( const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken); let temporaryRoot: string | undefined; + let reservationPath: string | undefined; + const reservationEntries: string[] = []; try { + reservationPath = await reserveDestination(destination); temporaryRoot = await mkdtemp( join(parent, `.${basename(destination)}.gji-clone-`), ); @@ -163,9 +173,22 @@ export async function cloneDir( throw error; } - await publishCloneContents(temporaryDestination, destination); + await publishCloneContents( + temporaryDestination, + destination, + reservationPath, + (entry) => reservationEntries.push(entry), + ); + reservationPath = undefined; } finally { stopLockHeartbeat(); + if (reservationPath) { + await cleanupReservedDestination( + destination, + reservationPath, + reservationEntries, + ); + } try { if (temporaryRoot) { await rm(temporaryRoot, { force: true, recursive: true }); @@ -407,15 +430,80 @@ async function lockFreshnessPath(lockPath: string): Promise { async function publishCloneContents( temporaryDestination: string, destination: string, + reservationPath: string, + onEntryPublished: (entry: string) => void, ): Promise { + const reservationName = basename(reservationPath); + const destinationEntries = await readdir(destination); + if ( + destinationEntries.length !== 1 || + destinationEntries[0] !== reservationName + ) { + throw new CloneDestinationExistsError(destination); + } + + const temporaryEntries = await readdir(temporaryDestination); + for (const entry of temporaryEntries) { + const destinationEntry = join(destination, entry); + if (await destinationExists(destinationEntry)) { + throw new CloneDestinationExistsError(destination); + } + try { + await rename(join(temporaryDestination, entry), destinationEntry); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CloneDestinationExistsError(destination); + } + throw error; + } + onEntryPublished(entry); + } + + await unlink(reservationPath); +} + +async function reserveDestination(destination: string): Promise { try { - await rename(temporaryDestination, destination); + await mkdir(destination); } catch (error) { - if (isAlreadyExistsError(error) || isDirectoryNotEmptyError(error)) { + if (isAlreadyExistsError(error)) { throw new CloneDestinationExistsError(destination); } throw error; } + + const reservationPath = join( + destination, + `.gji-clone-reservation-${randomUUID()}`, + ); + try { + await writeFile(reservationPath, "gji clone reservation\n", { + flag: "wx", + }); + return reservationPath; + } catch (error) { + await rmdir(destination).catch(() => undefined); + throw error; + } +} + +async function cleanupReservedDestination( + destination: string, + reservationPath: string, + reservationEntries: readonly string[], +): Promise { + try { + const entries = await readdir(destination); + const ownedEntries = new Set([ + basename(reservationPath), + ...reservationEntries, + ]); + if (entries.every((entry) => ownedEntries.has(entry))) { + await rm(destination, { force: true, recursive: true }); + } + } catch { + // Preserve a destination that was changed by another process. + } } async function destinationExists(path: string): Promise { diff --git a/src/file-sync.ts b/src/file-sync.ts index 8108ed7..e36314e 100644 --- a/src/file-sync.ts +++ b/src/file-sync.ts @@ -1,8 +1,11 @@ import { constants } from "node:fs"; -import { copyFile, lstat, mkdir, stat } from "node:fs/promises"; +import { copyFile, lstat, stat } from "node:fs/promises"; import { dirname, isAbsolute, join, normalize } from "node:path"; - -import { inspectDestination } from "./safe-destination.js"; +import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js"; +import { + ensureDestinationDirectory, + inspectDestination, +} from "./safe-destination.js"; /** * Copies files matching each pattern (relative to mainRoot) into the equivalent @@ -46,7 +49,7 @@ export async function syncFiles( if (beforeCreate.kind === "unsafe") { throw new Error(beforeCreate.reason); } - await mkdir(destinationParent, { recursive: true }); + await ensureDestinationDirectory(targetPath, destinationParent); const afterCreate = await inspectDestination(targetPath, destinationParent); if (afterCreate.kind === "unsafe") { throw new Error(afterCreate.reason); @@ -98,19 +101,3 @@ async function readDestinationEntry( throw error; } } - -function isNotFoundError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - -function isAlreadyExistsError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "EEXIST" - ); -} diff --git a/src/new.test.ts b/src/new.test.ts index 7eadfbb..599ad7c 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { runCli } from "./cli.js"; import { GLOBAL_CONFIG_FILE_PATH } from "./config.js"; +import { CloneUnsupportedError } from "./dir-clone.js"; import { createNewCommand, generateBranchPlaceholder, @@ -853,6 +854,38 @@ describe("gji new", () => { }); }); + it("does not persist or repair after dependency policy cancellation", async () => { + // Given a pnpm repository with no explicit dependencyBootstrap policy. + const repoRoot = await createRepository(); + await commitFile( + repoRoot, + "pnpm-lock.yaml", + "lockfileVersion: '9'\n", + "Add pnpm lockfile", + ); + const commands: string[] = []; + + // When the interactive dependency policy prompt is cancelled. + const result = await createNewCommand({ + promptForDependencyBootstrap: async () => null, + runInstallCommand: async (command) => { + commands.push(command); + }, + })({ + branch: "feature/cancel-bootstrap-policy", + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then setup completes safely without persistence, repair, or a legacy prompt. + expect(result).toBe(0); + expect(commands).toEqual([]); + await expect(pathExists(join(repoRoot, ".gji.json"))).resolves.toBe( + false, + ); + }); + it("does not prompt when an explicit policy is configured", async () => { // Given a pnpm repository with an explicit install-only local policy. const repoRoot = await createRepository(); @@ -1286,6 +1319,39 @@ describe("gji new", () => { }); }); + it("keeps syncDirs clone failures out of JSON stderr", async () => { + // Given a configured directory whose CoW operation fails. + const repoRoot = await createRepository(); + await mkdir(join(repoRoot, "node_modules")); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncDirs: ["node_modules"] }), + "utf8", + ); + const stdout: string[] = []; + const stderr: string[] = []; + + // When gji new runs in JSON mode with an unsupported CoW result. + const result = await createNewCommand({ + cloneDir: async () => { + throw new CloneUnsupportedError("test filesystem"); + }, + })({ + branch: "feature/sync-json-failure", + cwd: repoRoot, + json: true, + stderr: (chunk) => stderr.push(chunk), + stdout: (chunk) => stdout.push(chunk), + }); + + // Then the failure is represented in JSON rather than as raw human output. + expect(result).toBe(0); + expect(stderr).toEqual([]); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + skipped: [{ dir: "node_modules" }], + }); + }); + it("cleans partial clones and caches the failure for later worktrees", async () => { // Given a repository and an isolated state directory. const repoRoot = await createRepository(); diff --git a/src/new.ts b/src/new.ts index e82e4dd..8ff23c7 100644 --- a/src/new.ts +++ b/src/new.ts @@ -277,8 +277,6 @@ export function createNewCommand( ...config, dependencyBootstrap: dependencyPolicy.mode, }; - const dependencyBootstrapPolicyResolved = - dependencyBootstrapExplicit || dependencyPolicy.prompted; if (options.dryRun) { if (options.take) { @@ -485,13 +483,14 @@ export function createNewCommand( json: options.json, worktreePath, installDependencies: dependencies, - dependencyBootstrapPolicyResolved, + dependencyBootstrapPolicy: dependencyPolicy, }); if (!bootstrap.ready) { - return emitNewError(options, "dependency bootstrap failed", { + return emitNewError(options, "worktree bootstrap failed", { dependencyBootstrap: bootstrap.dependencyBootstrap, path: worktreePath, skipped: bootstrap.skippedDirs, + syncFiles: bootstrap.syncFileFailures, }); } diff --git a/src/pr.test.ts b/src/pr.test.ts index 0943d03..41a7959 100644 --- a/src/pr.test.ts +++ b/src/pr.test.ts @@ -536,7 +536,10 @@ describe("gji pr", () => { describe("install prompt", () => { const fakePm = { name: "pnpm", installCommand: "pnpm install" }; - async function setupPrRepo(prNumber: string): Promise { + async function setupPrRepo( + prNumber: string, + extraFiles: readonly [string, string][] = [], + ): Promise { const { repoRoot } = await createRepositoryWithOrigin(); await runGit(repoRoot, [ "checkout", @@ -549,6 +552,9 @@ describe("gji pr", () => { "content\n", `pr ${prNumber}`, ); + for (const [path, content] of extraFiles) { + await commitFile(repoRoot, path, content, `pr ${prNumber} ${path}`); + } await pushPullRequestRef(repoRoot, prNumber); await runGit(repoRoot, ["checkout", "-"]); return repoRoot; @@ -658,13 +664,9 @@ describe("gji pr", () => { it("prompts for dependency policy before bootstrapping a PR worktree", async () => { // Given a PR repository whose base worktree exposes a pnpm lockfile. - const repoRoot = await setupPrRepo("2016"); - await commitFile( - repoRoot, - "pnpm-lock.yaml", - "lockfileVersion: '9'\n", - "Add pnpm lockfile", - ); + const repoRoot = await setupPrRepo("2016", [ + ["pnpm-lock.yaml", "lockfileVersion: '9'\n"], + ]); let prompted = false; const commands: string[] = []; @@ -690,6 +692,31 @@ describe("gji pr", () => { expect(commands).toEqual(["pnpm install --frozen-lockfile"]); }); + it("stops before reporting success when PR dependency repair fails", async () => { + // Given a PR ref with a supported lockfile and a failing repair command. + const repoRoot = await setupPrRepo("2015", [ + ["pnpm-lock.yaml", "lockfileVersion: '9'\n"], + ]); + const stderr: string[] = []; + + // When gji pr bootstraps the worktree. + const result = await createPrCommand({ + promptForDependencyBootstrap: async () => "install-only", + runInstallCommand: async () => { + throw new Error("repair failed"); + }, + })({ + cwd: repoRoot, + number: "2015", + stderr: (chunk) => stderr.push(chunk), + stdout: () => undefined, + }); + + // Then the command fails and does not emit a normal navigation success. + expect(result).toBe(1); + expect(stderr.join("")).toContain("worktree bootstrap failed"); + }); + it('runs install once and does not persist anything when "yes" is chosen', async () => { // Given a PR repo with a detected package manager and a "yes" prompt choice. const repoRoot = await setupPrRepo("2001"); diff --git a/src/pr.ts b/src/pr.ts index 9babaec..9379451 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -18,10 +18,12 @@ import { promptForPathConflict, } from "./conflict.js"; import { + type DependencyBootstrapPolicyResolution, type DependencyBootstrapPromptDependencies, resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js"; import type { CloneDirectory } from "./dir-clone.js"; +import { formatBytes } from "./format-bytes.js"; import { isHeadless } from "./headless.js"; import { recordWorktreeUsage } from "./history.js"; import type { InstallPromptDependencies } from "./install-prompt.js"; @@ -156,29 +158,34 @@ export function createPrCommand( return 1; } - const dependencyPolicy = await resolveDependencyBootstrapPolicy( - { - currentRoot: repository.currentRoot, - repoRoot: repository.repoRoot, - worktreePath, - }, - config, - dependencyBootstrapExplicit, - { - dependencies, - dryRun: options.dryRun, - legacyInstallPromptConfigured: - dependencies.promptForInstallChoice !== undefined, - nonInteractive: !!options.json, - stderr: options.stderr, - }, - ); - config = { - ...config, - dependencyBootstrap: dependencyPolicy.mode, + let dependencyPolicy: DependencyBootstrapPolicyResolution = { + mode: config.dependencyBootstrap ?? "off", + prompted: false, + source: dependencyBootstrapExplicit ? "explicit" : "default", }; - const dependencyBootstrapPolicyResolved = - dependencyBootstrapExplicit || dependencyPolicy.prompted; + if (options.dryRun) { + dependencyPolicy = await resolveDependencyBootstrapPolicy( + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + worktreePath, + }, + config, + dependencyBootstrapExplicit, + { + dependencies, + dryRun: true, + legacyInstallPromptConfigured: + dependencies.promptForInstallChoice !== undefined, + nonInteractive: !!options.json, + stderr: options.stderr, + }, + ); + config = { + ...config, + dependencyBootstrap: dependencyPolicy.mode, + }; + } const dryRunSyncDirs = options.dryRun ? await estimateSyncDirectories( @@ -218,7 +225,7 @@ export function createPrCommand( options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { options.stdout( - `Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${bytes} bytes)\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`, + `Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`, ); } return 0; @@ -256,11 +263,36 @@ export function createPrCommand( await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot }); + if (!dependencyBootstrapExplicit) { + dependencyPolicy = await resolveDependencyBootstrapPolicy( + { + currentRoot: repository.currentRoot, + detectionRoot: worktreePath, + repoRoot: repository.repoRoot, + worktreePath, + }, + config, + false, + { + dependencies, + legacyInstallPromptConfigured: + dependencies.promptForInstallChoice !== undefined, + nonInteractive: !!options.json, + stderr: options.stderr, + }, + ); + config = { + ...config, + dependencyBootstrap: dependencyPolicy.mode, + }; + } + const bootstrap = await bootstrapWorktree({ branch: branchName, cloneDirectory: dependencies.cloneDir, config, currentRoot: repository.currentRoot, + dependencyDetectionRoot: worktreePath, nonInteractive: !!options.json, repoRoot: repository.repoRoot, reporter: createBootstrapReporter(options.stderr, !!options.json), @@ -270,21 +302,22 @@ export function createPrCommand( json: options.json, worktreePath, installDependencies: dependencies, - dependencyBootstrapPolicyResolved, + dependencyBootstrapPolicy: dependencyPolicy, }); if (!bootstrap.ready) { const details = { dependencyBootstrap: bootstrap.dependencyBootstrap, path: worktreePath, skipped: bootstrap.skippedDirs, + syncFiles: bootstrap.syncFileFailures, }; if (options.json) { options.stderr( - `${JSON.stringify({ error: "dependency bootstrap failed", ...details }, null, 2)}\n`, + `${JSON.stringify({ error: "worktree bootstrap failed", ...details }, null, 2)}\n`, ); } else { options.stderr( - `gji pr: dependency bootstrap failed at ${worktreePath}\n`, + `gji pr: worktree bootstrap failed at ${worktreePath}\n`, ); options.stderr( `Hint: inspect the worktree or remove it with 'gji done ${worktreePath}' before retrying\n`, diff --git a/src/safe-destination.ts b/src/safe-destination.ts index 1905bd4..13c775d 100644 --- a/src/safe-destination.ts +++ b/src/safe-destination.ts @@ -1,7 +1,11 @@ -import { lstat } from "node:fs/promises"; +import { lstat, mkdir } from "node:fs/promises"; import { isAbsolute, relative, resolve, sep } from "node:path"; -import { isNotDirectoryError, isNotFoundError } from "./fs-utils.js"; +import { + isAlreadyExistsError, + isNotDirectoryError, + isNotFoundError, +} from "./fs-utils.js"; export type DestinationInspection = | { kind: "missing" } @@ -80,3 +84,46 @@ export async function inspectDestination( throw error; } } + +export async function ensureDestinationDirectory( + root: string, + path: string, +): Promise { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(path); + const distance = relative(resolvedRoot, resolvedPath); + if ( + isAbsolute(distance) || + distance === ".." || + distance.startsWith(`..${sep}`) + ) { + throw new Error("destination escapes the worktree"); + } + + let current = resolvedRoot; + const rootStats = await lstat(current); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) { + throw new Error("worktree root is not a directory"); + } + + for (const segment of distance.split(sep).filter(Boolean)) { + current = resolve(current, segment); + try { + const stats = await lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`destination has an unsafe component: ${current}`); + } + } catch (error) { + if (!isNotFoundError(error)) throw error; + try { + await mkdir(current); + } catch (mkdirError) { + if (!isAlreadyExistsError(mkdirError)) throw mkdirError; + } + const stats = await lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`destination has an unsafe component: ${current}`); + } + } + } +} diff --git a/src/sync-directories.ts b/src/sync-directories.ts index fbeed4d..e9a2385 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -176,9 +176,6 @@ export async function executeSyncDirectoryPlan( } if (isCloneInProgressError(error)) { const reason = "copy-on-write clone already in progress"; - options.reporter.write( - `syncDirs: ${reason}, skipped ${entry.directory}\n`, - ); recordSkipped(outcomes, options.reporter, entry.directory, reason); continue; } @@ -191,15 +188,8 @@ export async function executeSyncDirectoryPlan( reason, failureScope, ); - options.reporter.write( - `syncDirs: filesystem doesn't support copy-on-write (${reason}), skipped ${entry.directory}\n`, - ); - } else { - options.reporter.write( - `syncDirs: clone failed (${reason}), skipped ${entry.directory}\n`, - ); } - recordSkipped(outcomes, options.reporter, entry.directory, reason, false); + recordSkipped(outcomes, options.reporter, entry.directory, reason); continue; } diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index d6e5d25..727d352 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -1,6 +1,6 @@ import { basename } from "node:path"; -import type { DependencyBootstrapMode, EffectiveGjiConfig } from "./config.js"; +import type { EffectiveGjiConfig } from "./config.js"; import { type BootstrapCommandRunner, type BootstrapEvent, @@ -9,6 +9,7 @@ import { executeDependencyBootstrap, prepareDependencyBootstrap, } from "./dependency-bootstrap.js"; +import type { DependencyBootstrapPolicyResolution } from "./dependency-bootstrap-prompt.js"; import { type CloneDirectory, cloneDir } from "./dir-clone.js"; import { syncFiles } from "./file-sync.js"; import { extractHooks, runHook } from "./hooks.js"; @@ -30,11 +31,12 @@ export interface WorktreeBootstrapOptions { cloneDirectory?: CloneDirectory; config: EffectiveGjiConfig; currentRoot?: string; + dependencyDetectionRoot?: string; installDependencies?: InstallPromptDependencies; runCommand?: BootstrapCommandRunner; commandStdout?: (chunk: string) => void; commandStderr?: (chunk: string) => void; - dependencyBootstrapPolicyResolved?: boolean; + dependencyBootstrapPolicy?: DependencyBootstrapPolicyResolution; json?: boolean; nonInteractive: boolean; repoRoot: string; @@ -46,6 +48,7 @@ export interface WorktreeBootstrapResult { clonedDirs: readonly ClonedDirectory[]; dependencyBootstrap: DependencyBootstrapReport; ready: boolean; + syncFileFailures: readonly BootstrapEvent[]; skippedDirs: readonly { dir: string; reason: string }[]; } @@ -55,6 +58,7 @@ export async function bootstrapWorktree( const dependencyMode = options.config.dependencyBootstrap ?? "off"; const dependencyPlan = await prepareDependencyBootstrap(dependencyMode, { currentRoot: options.currentRoot, + detectionRoot: options.dependencyDetectionRoot, repoRoot: options.repoRoot, cargoBuildCommand: options.config.dependencyBuildCommand, worktreePath: options.worktreePath, @@ -96,13 +100,12 @@ export async function bootstrapWorktree( } } + if (syncFileFailures.length > 0) { + for (const event of syncFileFailures) options.reporter.dependency(event); + } const dependencyBootstrap = syncFileFailures.length > 0 - ? reportSyncFileFailure( - dependencyMode, - syncFileFailures, - options.reporter, - ) + ? { mode: dependencyMode, ready: false, events: [] } : await executeDependencyBootstrap(dependencyPlan, { cloneDirectory: options.cloneDirectory, repoRoot: options.repoRoot, @@ -118,11 +121,15 @@ export async function bootstrapWorktree( clonedDirs, dependencyBootstrap, ready: false, + syncFileFailures, skippedDirs, }; } - if (dependencyMode === "off" && !options.dependencyBootstrapPolicyResolved) { + if ( + dependencyMode === "off" && + (options.dependencyBootstrapPolicy?.source ?? "default") === "default" + ) { await maybeRunInstallPrompt( options.worktreePath, options.repoRoot, @@ -148,16 +155,13 @@ export async function bootstrapWorktree( : (options.commandStdout ?? ((chunk) => process.stdout.write(chunk))), ); - return { clonedDirs, dependencyBootstrap, ready: true, skippedDirs }; -} - -function reportSyncFileFailure( - mode: DependencyBootstrapMode, - events: readonly BootstrapEvent[], - reporter: DependencyBootstrapReporter, -): DependencyBootstrapReport { - for (const event of events) reporter.dependency(event); - return { mode, ready: false, events }; + return { + clonedDirs, + dependencyBootstrap, + ready: true, + syncFileFailures: [], + skippedDirs, + }; } function toErrorMessage(error: unknown): string { diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index 955a52f..f28bb9e 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -135,7 +135,7 @@ The setting is resolved through the same three layers as other normal config val On macOS, gji uses APFS clonefile copies. On Linux, it requires reflinks. Unsupported filesystems are skipped rather than falling back to an ordinary recursive copy, and failed attempts are cached in `state.json`. Missing sources, external symlink targets, and existing destinations are also skipped without overwriting work. `syncDirs` remains generic and never special-cases `node_modules`, `.venv`, or `target`. -Set `dependencyBootstrap` to `cow-then-repair` to reuse project-local package-manager or build-cache state and then run authoritative repair. pnpm uses `node_modules` plus frozen-lockfile install and regenerates metadata, Yarn uses `node_modules` plus immutable install, uv uses a compatible `.venv` plus locked sync, Cargo uses `target` plus `cargo check`, and Bundler uses `vendor/bundle` plus `bundle install`. npm is install-only because `npm ci` can delete a dependency tree; it never seeds `node_modules`. Global caches and gem stores are never cloned. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events with machine-readable reasons. If setup fails, the created worktree path is included in the error output. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. +Set `dependencyBootstrap` to `cow-then-repair` to reuse project-local package-manager or build-cache state and then run authoritative repair. pnpm uses `node_modules` plus frozen-lockfile install and regenerates metadata, Yarn uses `node_modules` plus immutable install, uv uses a compatible `.venv` plus locked sync, Cargo uses `target` plus `cargo check`, and Bundler uses `vendor/bundle` plus `BUNDLE_PATH=vendor/bundle bundle install`. npm is install-only because `npm ci` can delete a dependency tree; it never seeds `node_modules`. Global caches and gem stores are never cloned. The lifecycle is `CoW seed → syncFiles → repair/install → after-create`; CoW failure falls back to repair from an empty target without ordinary copying. Human output reports `seeded`, `repaired`, `installed`, `fallback`, `skipped`, or `failed`, while `--json` exposes structured events with machine-readable reasons. If setup fails, the created worktree path is included in the error output. Use `gji new --dry-run` to list configured sources and estimated sizes without creating a worktree. The benchmark target for a 2 GB dependency tree on a supported filesystem is under 5 seconds. `dependencyBuildCommand` overrides Cargo's default `cargo check`. npm is always install-only, and a sync-file failure stops dependency repair, install prompts, and `after-create` hooks. If no policy is explicit, interactive `gji new` and `gji pr` prompt when one of the supported lockfiles is detected. The choices are persisted using `installSaveTarget`; JSON, headless, and dry-run modes never prompt and keep `off`. Dry-run output reports the effective strategy, including repair-only cases where a seed is not compatible. From e555e33c9c19f133fd111ad008a52ac1f4f235ac Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 25 Jul 2026 01:45:27 +0900 Subject: [PATCH 19/19] Harden CoW bootstrap safety and boundaries --- src/command-runner.test.ts | 18 ++ src/command-runner.ts | 17 +- src/dependency-bootstrap-prompt.ts | 6 +- src/dependency-bootstrap.test.ts | 4 +- src/dependency-bootstrap.ts | 124 ++++++++++---- src/dir-clone.test.ts | 37 ++++ src/dir-clone.ts | 261 +++++++++++++++++++---------- src/file-sync.test.ts | 19 +++ src/file-sync.ts | 91 +++++++--- src/fs-utils.ts | 4 +- src/new.test.ts | 27 +++ src/safe-destination.ts | 72 +++++++- src/sync-directories.ts | 10 -- src/worktree-bootstrap.ts | 4 +- 14 files changed, 529 insertions(+), 165 deletions(-) diff --git a/src/command-runner.test.ts b/src/command-runner.test.ts index 2102f6b..d1240a6 100644 --- a/src/command-runner.test.ts +++ b/src/command-runner.test.ts @@ -52,4 +52,22 @@ describe("runCommand", () => { // Then the spawn error is surfaced to the caller. await expect(result).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("runs fixed adapter commands without a shell", async () => { + // Given a quoted executable and argument list for a fixed adapter command. + const stdout: string[] = []; + const command = `${JSON.stringify(process.execPath)} -e "process.stdout.write('ok')"`; + + // When the command runner is explicitly given shell-free execution. + await runCommand( + command, + process.cwd(), + () => undefined, + (chunk) => stdout.push(chunk), + { shell: false }, + ); + + // Then the executable starts successfully without shell expansion. + expect(stdout.join("")).toBe("ok"); + }); }); diff --git a/src/command-runner.ts b/src/command-runner.ts index 79a123f..1e66c19 100644 --- a/src/command-runner.ts +++ b/src/command-runner.ts @@ -10,6 +10,7 @@ export type CommandRunner = ( export interface CommandRunnerOptions { env?: NodeJS.ProcessEnv; + shell?: boolean; } export const runCommand: CommandRunner = async ( @@ -20,10 +21,12 @@ export const runCommand: CommandRunner = async ( options, ) => { await new Promise((resolve, reject) => { - const child = spawn(command, { + const shell = options?.shell ?? true; + const [executable, args] = shell ? [command, []] : splitCommand(command); + const child = spawn(executable, args, { cwd, env: options?.env ? { ...process.env, ...options.env } : undefined, - shell: true, + shell, stdio: ["ignore", "pipe", "pipe"], }); @@ -46,3 +49,13 @@ export const runCommand: CommandRunner = async ( child.on("error", reject); }); }; + +function splitCommand(command: string): [string, string[]] { + const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/gu) ?? []; + const [first, ...rest] = tokens; + if (!first) throw new Error("command must not be empty"); + return [ + first.replace(/^(["'])(.*)\1$/u, "$2"), + rest.map((token) => token.replace(/^(["'])(.*)\1$/u, "$2")), + ]; +} diff --git a/src/dependency-bootstrap-prompt.ts b/src/dependency-bootstrap-prompt.ts index 5fe3fe6..bff2216 100644 --- a/src/dependency-bootstrap-prompt.ts +++ b/src/dependency-bootstrap-prompt.ts @@ -26,7 +26,7 @@ export interface DependencyBootstrapPromptDependencies { export interface DependencyBootstrapPolicyResolution { mode: DependencyBootstrapMode; prompted: boolean; - source: "explicit" | "prompted" | "default"; + source: "explicit" | "prompted" | "default" | "legacy"; } export async function resolveDependencyBootstrapPolicy( @@ -58,13 +58,11 @@ export async function resolveDependencyBootstrapPolicy( return { mode: "off", prompted: false, source: "default" }; } - // Keep callers that inject the legacy install prompt on the old path. The - // command's default dependencies use the policy prompt below. if ( options.legacyInstallPromptConfigured && !options.dependencies?.promptForDependencyBootstrap ) { - return { mode: "off", prompted: false, source: "default" }; + return { mode: "off", prompted: false, source: "legacy" }; } let candidate: DependencyBootstrapCandidate | null; diff --git a/src/dependency-bootstrap.test.ts b/src/dependency-bootstrap.test.ts index a8b76a1..ee0f70b 100644 --- a/src/dependency-bootstrap.test.ts +++ b/src/dependency-bootstrap.test.ts @@ -279,7 +279,9 @@ describe("dependencyBootstrap adapters", () => { expect(plan.targets[0]?.target.relativePath).toBe("vendor/bundle"); expect(result.ready).toBe(true); expect(commands).toEqual(["bundle install"]); - expect(commandOptions).toEqual([{ env: { BUNDLE_PATH: "vendor/bundle" } }]); + expect(commandOptions).toEqual([ + { env: { BUNDLE_PATH: "vendor/bundle" }, shell: false }, + ]); }); it("falls back to a clean repair when CoW is unsupported and caches the failure", async () => { diff --git a/src/dependency-bootstrap.ts b/src/dependency-bootstrap.ts index 9746bca..d959bd0 100644 --- a/src/dependency-bootstrap.ts +++ b/src/dependency-bootstrap.ts @@ -61,6 +61,25 @@ export interface BootstrapTarget { existingBeforeBootstrap: boolean; } +export type BootstrapTargetOwnership = + | "adapter" + | "syncDirs" + | "empty" + | "preserve"; + +export interface BootstrapRepairFailureContext { + target: BootstrapTarget; + ownership: BootstrapTargetOwnership; + targetExistedBeforeRepair: boolean; +} + +export interface BootstrapOutputMetadata { + adapter: string; + kind: BootstrapKind; + target: string; + repairCommand: string; +} + export interface BootstrapAdapter { readonly kind: BootstrapKind; readonly lockfile: string; @@ -73,6 +92,13 @@ export interface BootstrapAdapter { context: BootstrapExecutionContext, ): Promise; canSeed(target: BootstrapTarget): Promise; + output(target: BootstrapTarget): BootstrapOutputMetadata; + shouldRetryAfterRepairFailure( + context: BootstrapRepairFailureContext, + ): boolean; + cleanupAfterRepairFailure( + context: BootstrapRepairFailureContext, + ): Promise; } export interface DependencyBootstrapPlan { @@ -227,9 +253,9 @@ export function previewDependencyBootstrap( return { mode: plan.mode, targets: plan.targets.map(({ adapter, target, seedable }) => ({ - adapter: adapter.name, + adapter: adapter.output(target).adapter, kind: adapter.kind, - target: target.relativePath, + target: adapter.output(target).target, repairCommand: target.repairCommand, seedable, strategy: bootstrapStrategy(plan.mode, target, seedable), @@ -484,8 +510,6 @@ async function executeBootstrapTarget( ); } -type BootstrapTargetOwnership = "adapter" | "syncDirs" | "empty" | "preserve"; - async function repairTarget( adapter: BootstrapAdapter, target: BootstrapTarget, @@ -523,22 +547,30 @@ async function repairTarget( : "installed or repaired from a clean target", }); } catch (firstError) { - if (ownership !== "adapter" && ownership !== "syncDirs") { - const cleanupError = !presentBeforeRepair - ? await tryRemoveTarget(target) - : undefined; + const firstFailureContext = { + target, + ownership, + targetExistedBeforeRepair: presentBeforeRepair, + }; + const cleanupError = await adapter + .cleanupAfterRepairFailure(firstFailureContext) + .then(() => undefined) + .catch((error) => toErrorMessage(error)); + if (!adapter.shouldRetryAfterRepairFailure(firstFailureContext)) { recordBootstrapFailure( events, reporter, adapter, target, - formatRepairFailure(firstError, cleanupError), + formatRepairFailure( + firstError, + cleanupError === undefined ? undefined : cleanupError, + ), "repair-failed", ); return; } - const cleanupError = await tryRemoveTarget(target); if (cleanupError) { recordBootstrapFailure( events, @@ -586,9 +618,14 @@ async function repairTarget( message: "installed or repaired from a clean target", }); } catch (secondError) { - const cleanupError = !presentBeforeRetry - ? await tryRemoveTarget(target) - : undefined; + const cleanupError = await adapter + .cleanupAfterRepairFailure({ + target, + ownership, + targetExistedBeforeRepair: presentBeforeRetry, + }) + .then(() => undefined) + .catch((error) => toErrorMessage(error)); recordBootstrapFailure( events, reporter, @@ -628,23 +665,6 @@ function recordBootstrapEvent( reporter.dependency(event); } -async function removeTarget(target: BootstrapTarget): Promise { - if (!target.existingBeforeBootstrap) { - await rm(target.targetPath, { force: true, recursive: true }); - } -} - -async function tryRemoveTarget( - target: BootstrapTarget, -): Promise { - try { - await removeTarget(target); - return undefined; - } catch (error) { - return toErrorMessage(error); - } -} - function formatRepairFailure( error: unknown, cleanupError: string | undefined, @@ -675,6 +695,7 @@ function createBootstrapAdapters( lockfile: "pnpm-lock.yaml", relativePath: "node_modules", repairCommand: "pnpm install --frozen-lockfile", + shell: false, beforeRepair: async (target) => { if (!target.existingBeforeBootstrap) { await rm(join(target.targetPath, ".modules.yaml"), { @@ -689,6 +710,7 @@ function createBootstrapAdapters( lockfile: "yarn.lock", relativePath: "node_modules", repairCommand: "yarn install --immutable", + shell: false, }), new LockfileBootstrapAdapter({ name: "npm", @@ -696,6 +718,7 @@ function createBootstrapAdapters( lockfile: "package-lock.json", relativePath: "node_modules", repairCommand: "npm ci", + shell: false, seedPolicy: "never", repairCommandOverride: (target) => target.existingBeforeBootstrap ? "npm install" : "npm ci", @@ -707,6 +730,7 @@ function createBootstrapAdapters( lockfile: "Gemfile.lock", relativePath: "vendor/bundle", repairCommand: "bundle install", + shell: false, commandOptions: () => ({ env: { BUNDLE_PATH: "vendor/bundle" }, }), @@ -717,6 +741,7 @@ function createBootstrapAdapters( lockfile: "uv.lock", relativePath: ".venv", repairCommand: "uv sync --locked", + shell: false, canSeedOverride: checkUvRuntime, }), new LockfileBootstrapAdapter({ @@ -725,6 +750,7 @@ function createBootstrapAdapters( lockfile: "Cargo.lock", relativePath: "target", repairCommand: cargoBuildCommand?.trim() || "cargo check", + shell: cargoBuildCommand ? undefined : false, }), ]; } @@ -735,6 +761,7 @@ interface LockfileBootstrapAdapterSpec { lockfile: string; relativePath: string; repairCommand: string; + shell?: boolean; seedPolicy?: "always" | "never"; canSeedOverride?: (target: BootstrapTarget) => Promise; beforeRepair?: (target: BootstrapTarget) => Promise; @@ -751,6 +778,7 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { readonly name: string; readonly relativePath: string; private readonly defaultRepairCommand: string; + private readonly shell?: boolean; private readonly seedPolicy: "always" | "never"; private readonly canSeedOverride?: ( target: BootstrapTarget, @@ -768,6 +796,7 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { this.lockfile = spec.lockfile; this.relativePath = spec.relativePath; this.defaultRepairCommand = spec.repairCommand; + this.shell = spec.shell; this.seedPolicy = spec.seedPolicy ?? "always"; this.canSeedOverride = spec.canSeedOverride; this.beforeRepair = spec.beforeRepair; @@ -822,7 +851,10 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { target.worktreePath, context.stderr, context.stdout, - this.commandOptions?.(target), + { + ...this.commandOptions?.(target), + shell: this.shell, + }, ); } @@ -831,6 +863,36 @@ class LockfileBootstrapAdapter implements BootstrapAdapter { if (!(await safeSourceDirectory(target))) return false; return (await this.canSeedOverride?.(target)) ?? true; } + + output(target: BootstrapTarget): BootstrapOutputMetadata { + return { + adapter: this.name, + kind: this.kind, + target: target.relativePath, + repairCommand: target.repairCommand, + }; + } + + shouldRetryAfterRepairFailure( + context: BootstrapRepairFailureContext, + ): boolean { + return context.ownership === "adapter" || context.ownership === "syncDirs"; + } + + async cleanupAfterRepairFailure( + context: BootstrapRepairFailureContext, + ): Promise { + if (context.target.existingBeforeBootstrap) { + return; + } + if (context.ownership === "adapter" || context.ownership === "syncDirs") { + await rm(context.target.targetPath, { force: true, recursive: true }); + return; + } + if (context.ownership === "empty" && !context.targetExistedBeforeRepair) { + await rm(context.target.targetPath, { force: true, recursive: true }); + } + } } async function safeSourceDirectory(target: BootstrapTarget): Promise { diff --git a/src/dir-clone.test.ts b/src/dir-clone.test.ts index 645108a..8ea9dd1 100644 --- a/src/dir-clone.test.ts +++ b/src/dir-clone.test.ts @@ -1,4 +1,6 @@ +import { constants } from "node:fs"; import { + copyFile, mkdir, mkdtemp, readdir, @@ -32,6 +34,8 @@ describe("cloneDir", () => { // When cloneDir runs the injected platform command. const result = await cloneDir(source, destination, { + copyFile: (sourcePath, destinationPath) => + copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL), platform: "linux", runCommand: async (_command, args) => { cloneArgs = args; @@ -162,6 +166,39 @@ describe("cloneDir", () => { expect(await readdir(destination)).toEqual([]); }); + it("does not overwrite an entry created during clone publication", async () => { + // Given a fake CoW clone whose destination entry appears during final publication. + const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-no-overwrite-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + await mkdir(source); + await writeFile(join(source, "package.json"), "new\n", "utf8"); + + // When publication races with a process that creates the same destination file. + const error = await cloneDir(source, destination, { + copyFile: async (sourcePath, destinationPath) => { + await writeFile(destinationPath, "external\n", "utf8"); + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL); + }, + platform: "linux", + runCommand: async (_command, args) => { + const temporaryDestination = args.at(-1) as string; + await mkdir(temporaryDestination); + await writeFile( + join(temporaryDestination, "package.json"), + "new\n", + "utf8", + ); + }, + }).catch((caught) => caught); + + // Then the concurrent file is preserved and the clone reports a conflict. + expect(isCloneDestinationExistsError(error)).toBe(true); + await expect( + readFile(join(destination, "package.json"), "utf8"), + ).resolves.toBe("external\n"); + }); + it("rejects a destination with a symbolic-link ancestor", async () => { // Given a source and a destination parent that points outside the worktree. const root = await mkdtemp(join(tmpdir(), "gji-dir-clone-link-")); diff --git a/src/dir-clone.ts b/src/dir-clone.ts index 36cc3a5..3e16082 100644 --- a/src/dir-clone.ts +++ b/src/dir-clone.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { + chmod, cp, lstat, mkdir, @@ -9,10 +10,12 @@ import { opendir, readdir, readFile, + readlink, realpath, rename, rm, rmdir, + symlink, unlink, utimes, writeFile, @@ -22,8 +25,9 @@ import { promisify } from "node:util"; import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js"; import { - ensureDestinationDirectory, inspectDestination, + type OpenDestinationDirectory, + openDestinationDirectory, } from "./safe-destination.js"; const execFileAsync = promisify(execFile); @@ -46,6 +50,7 @@ export interface CloneDirOptions extends CloneRequestOptions { platform?: NodeJS.Platform; runCommand?: (command: string, args: string[]) => Promise; copyDirectory?: (source: string, destination: string) => Promise; + copyFile?: (source: string, destination: string) => Promise; } export type CloneDirectory = ( @@ -115,99 +120,102 @@ export async function cloneDir( const startedAt = Date.now(); const parent = dirname(destination); - if (options.destinationRoot) { - const parentInspection = await inspectDestination( - options.destinationRoot, - parent, - ); - if (parentInspection.kind === "unsafe") { - throw new Error(parentInspection.reason); + let safeParent: OpenDestinationDirectory | undefined; + try { + if (options.destinationRoot) { + const parentInspection = await inspectDestination( + options.destinationRoot, + parent, + ); + if (parentInspection.kind === "unsafe") { + throw new Error(parentInspection.reason); + } + safeParent = await openDestinationDirectory( + options.destinationRoot, + parent, + ); + } else { + await mkdir(parent, { recursive: true }); } - } - if (options.destinationRoot) { - await ensureDestinationDirectory(options.destinationRoot, parent); - } else { - await mkdir(parent, { recursive: true }); - } - if (options.destinationRoot) { - const parentInspection = await inspectDestination( - options.destinationRoot, - parent, - ); - if (parentInspection.kind === "unsafe") { - throw new Error(parentInspection.reason); + const operationParent = safeParent?.path ?? parent; + const operationDestination = join(operationParent, basename(destination)); + if (await destinationExists(operationDestination)) { + throw new CloneDestinationExistsError(destination); } - } - const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; - const lockToken = await acquireCloneLock(lockPath, destination); - const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken); + const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`; + const lockToken = await acquireCloneLock(lockPath, destination); + const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken); - let temporaryRoot: string | undefined; - let reservationPath: string | undefined; - const reservationEntries: string[] = []; - try { - reservationPath = await reserveDestination(destination); - temporaryRoot = await mkdtemp( - join(parent, `.${basename(destination)}.gji-clone-`), - ); - const temporaryDestination = join(temporaryRoot, basename(destination)); - const copyDirectory = - options.copyDirectory ?? - (platformIsDarwin(platform) - ? runNativeCloneDirectory - : async (source, target) => { - if (!strategy) { - throw new CloneUnsupportedError( - `platform ${platform} has no CoW strategy`, - ); - } - const runCommand = options.runCommand ?? runCloneCommand; - await runCommand("cp", strategy(source, target)); - }); + let temporaryRoot: string | undefined; + let reservationPath: string | undefined; + const reservationEntries: string[] = []; try { - await copyDirectory(sourcePath, temporaryDestination); - } catch (error) { - if (isUnsupportedCloneError(error)) { - throw new CloneUnsupportedError(toErrorMessage(error)); + reservationPath = await reserveDestination(operationDestination); + temporaryRoot = await mkdtemp( + join(operationParent, `.${basename(destination)}.gji-clone-`), + ); + const temporaryDestination = join(temporaryRoot, basename(destination)); + const copyDirectory = + options.copyDirectory ?? + (platformIsDarwin(platform) + ? runNativeCloneDirectory + : async (source, target) => { + if (!strategy) { + throw new CloneUnsupportedError( + `platform ${platform} has no CoW strategy`, + ); + } + const runCommand = options.runCommand ?? runCloneCommand; + await runCommand("cp", strategy(source, target)); + }); + try { + await copyDirectory(sourcePath, temporaryDestination); + } catch (error) { + if (isUnsupportedCloneError(error)) { + throw new CloneUnsupportedError(toErrorMessage(error)); + } + throw error; } - throw error; - } - await publishCloneContents( - temporaryDestination, - destination, - reservationPath, - (entry) => reservationEntries.push(entry), - ); - reservationPath = undefined; - } finally { - stopLockHeartbeat(); - if (reservationPath) { - await cleanupReservedDestination( - destination, + await publishCloneContents( + temporaryDestination, + operationDestination, reservationPath, - reservationEntries, + (entry) => reservationEntries.push(entry), + options.copyFile ?? runForcedCloneFileCopy, ); - } - try { - if (temporaryRoot) { - await rm(temporaryRoot, { force: true, recursive: true }); + reservationPath = undefined; + } finally { + stopLockHeartbeat(); + if (reservationPath) { + await cleanupReservedDestination( + operationDestination, + reservationPath, + reservationEntries, + ); + } + try { + if (temporaryRoot) { + await rm(temporaryRoot, { force: true, recursive: true }); + } + } catch { + // Cleanup is best effort and must not mask the clone result. + } + try { + await releaseCloneLock(lockPath, lockToken); + } catch { + // A stale lock is reclaimed on a later attempt. } - } catch { - // Cleanup is best effort and must not mask the clone result. - } - try { - await releaseCloneLock(lockPath, lockToken); - } catch { - // A stale lock is reclaimed on a later attempt. } - } - const bytes = - options.measureBytes === false - ? undefined - : await estimateCloneBytes(sourcePath); - return { bytes, ms: Date.now() - startedAt }; + const bytes = + options.measureBytes === false + ? undefined + : await estimateCloneBytes(sourcePath); + return { bytes, ms: Date.now() - startedAt }; + } finally { + await safeParent?.close().catch(() => undefined); + } } async function runNativeCloneDirectory( @@ -432,6 +440,7 @@ async function publishCloneContents( destination: string, reservationPath: string, onEntryPublished: (entry: string) => void, + copyFileEntry: (source: string, destination: string) => Promise, ): Promise { const reservationName = basename(reservationPath); const destinationEntries = await readdir(destination); @@ -444,22 +453,96 @@ async function publishCloneContents( const temporaryEntries = await readdir(temporaryDestination); for (const entry of temporaryEntries) { - const destinationEntry = join(destination, entry); - if (await destinationExists(destinationEntry)) { - throw new CloneDestinationExistsError(destination); + await publishCloneEntry( + join(temporaryDestination, entry), + join(destination, entry), + () => onEntryPublished(entry), + copyFileEntry, + ); + } + + await unlink(reservationPath); +} + +async function publishCloneEntry( + source: string, + destination: string, + onCreated: () => void, + copyFileEntry: (source: string, destination: string) => Promise, +): Promise { + const sourceStats = await lstat(source); + if (sourceStats.isDirectory()) { + try { + await mkdir(destination); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CloneDestinationExistsError(destination); + } + throw error; + } + onCreated(); + for (const entry of await readdir(source)) { + await publishCloneEntry( + join(source, entry), + join(destination, entry), + () => undefined, + copyFileEntry, + ); } + await copyCloneMetadata(sourceStats, destination); + return; + } + + if (sourceStats.isSymbolicLink()) { try { - await rename(join(temporaryDestination, entry), destinationEntry); + await symlink(await readlink(source), destination); } catch (error) { if (isAlreadyExistsError(error)) { throw new CloneDestinationExistsError(destination); } throw error; } - onEntryPublished(entry); + onCreated(); + return; } - await unlink(reservationPath); + if (!sourceStats.isFile()) { + throw new Error(`unsupported clone entry type: ${source}`); + } + + try { + await copyFileEntry(source, destination); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CloneDestinationExistsError(destination); + } + if (isUnsupportedCloneError(error)) { + throw new CloneUnsupportedError(toErrorMessage(error)); + } + throw error; + } + onCreated(); + await copyCloneMetadata(sourceStats, destination); +} + +async function runForcedCloneFileCopy( + source: string, + destination: string, +): Promise { + await cp(source, destination, { + errorOnExist: true, + force: false, + mode: constants.COPYFILE_FICLONE_FORCE, + preserveTimestamps: true, + }); +} + +async function copyCloneMetadata( + sourceStats: Awaited>, + destination: string, +): Promise { + await chmod(destination, Number(sourceStats.mode) & 0o7777); + await utimes(destination, sourceStats.atime, sourceStats.mtime); } async function reserveDestination(destination: string): Promise { diff --git a/src/file-sync.test.ts b/src/file-sync.test.ts index 32cdf2b..7bf29a8 100644 --- a/src/file-sync.test.ts +++ b/src/file-sync.test.ts @@ -114,6 +114,25 @@ describe("syncFiles", () => { expect(content).toBe("SECRET=abc\n"); }); + it("rejects a source symlink that escapes the main worktree", async () => { + // Given a configured source file whose symlink points outside the repository. + const mainRoot = await makeTmpDir(); + const targetPath = await makeTmpDir(); + const externalPath = await makeTmpDir(); + await writeFile(join(externalPath, "secret.env"), "SECRET=external\n"); + await symlink( + join(externalPath, "secret.env"), + join(mainRoot, ".env.local"), + ); + + // When syncFiles evaluates the source. + const result = syncFiles(mainRoot, targetPath, [".env.local"]); + + // Then it refuses to copy external content into the new worktree. + await expect(result).rejects.toThrow("resolves outside the repository"); + await expect(stat(join(targetPath, ".env.local"))).rejects.toThrow(); + }); + it("rejects a symlinked destination parent", async () => { // Given a source file and a destination parent that points outside the worktree. const mainRoot = await makeTmpDir(); diff --git a/src/file-sync.ts b/src/file-sync.ts index e36314e..4dd3333 100644 --- a/src/file-sync.ts +++ b/src/file-sync.ts @@ -1,10 +1,19 @@ import { constants } from "node:fs"; -import { copyFile, lstat, stat } from "node:fs/promises"; -import { dirname, isAbsolute, join, normalize } from "node:path"; +import { copyFile, lstat, realpath } from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + normalize, + relative, + resolve, + sep, +} from "node:path"; import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js"; import { - ensureDestinationDirectory, inspectDestination, + openDestinationDirectory, } from "./safe-destination.js"; /** @@ -23,15 +32,10 @@ export async function syncFiles( for (const pattern of patterns) { const normalized = validateSyncFilePattern(pattern); - const sourcePath = join(mainRoot, normalized); + const sourcePath = await resolveSyncFileSource(mainRoot, normalized); + if (!sourcePath) continue; const destPath = join(targetPath, normalized); - // Skip silently if source does not exist - const sourceExists = await fileExists(sourcePath); - if (!sourceExists) { - continue; - } - // Skip ordinary existing targets, but fail closed on any symlink. const existingDestination = await readDestinationEntry(destPath); if (existingDestination?.isSymbolicLink()) { @@ -49,15 +53,25 @@ export async function syncFiles( if (beforeCreate.kind === "unsafe") { throw new Error(beforeCreate.reason); } - await ensureDestinationDirectory(targetPath, destinationParent); - const afterCreate = await inspectDestination(targetPath, destinationParent); - if (afterCreate.kind === "unsafe") { - throw new Error(afterCreate.reason); - } + const safeParent = await openDestinationDirectory( + targetPath, + destinationParent, + ); try { - await copyFile(sourcePath, destPath, constants.COPYFILE_EXCL); - } catch (error) { - if (!isAlreadyExistsError(error)) throw error; + const safeDestination = join(safeParent.path, basename(destPath)); + const safeExistingDestination = + await readDestinationEntry(safeDestination); + if (safeExistingDestination?.isSymbolicLink()) { + throw new Error(`destination is a symbolic link: ${destPath}`); + } + if (safeExistingDestination) continue; + try { + await copyFile(sourcePath, safeDestination, constants.COPYFILE_EXCL); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } + } finally { + await safeParent.close().catch(() => undefined); } } } @@ -79,16 +93,35 @@ export function validateSyncFilePattern(pattern: string): string { return normalized; } -async function fileExists(path: string): Promise { +async function resolveSyncFileSource( + mainRoot: string, + pattern: string, +): Promise { + let resolvedRoot: string; + let resolvedSource: string; try { - await stat(path); - return true; + [resolvedRoot, resolvedSource] = await Promise.all([ + realpath(mainRoot), + realpath(join(mainRoot, pattern)), + ]); } catch (error) { - if (isNotFoundError(error)) { - return false; - } + if (isNotFoundError(error)) return undefined; throw error; } + + if (!isPathInside(resolvedRoot, resolvedSource)) { + throw new Error( + `syncFiles: source symlink resolves outside the repository: ${resolvedSource}`, + ); + } + + const sourceStats = await lstat(resolvedSource); + if (!sourceStats.isFile()) { + throw new Error( + `syncFiles: source is not a file: ${join(mainRoot, pattern)}`, + ); + } + return resolvedSource; } async function readDestinationEntry( @@ -101,3 +134,13 @@ async function readDestinationEntry( throw error; } } + +function isPathInside(root: string, candidate: string): boolean { + const distance = relative(resolve(root), resolve(candidate)); + return ( + distance === "" || + (!isAbsolute(distance) && + distance !== ".." && + !distance.startsWith(`..${sep}`)) + ); +} diff --git a/src/fs-utils.ts b/src/fs-utils.ts index 34f0974..06f2c65 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -11,7 +11,9 @@ export async function pathExists(path: string): Promise { } export function isAlreadyExistsError(error: unknown): boolean { - return hasErrorCode(error, "EEXIST"); + return ( + hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ERR_FS_CP_EEXIST") + ); } export function isNotDirectoryError(error: unknown): boolean { diff --git a/src/new.test.ts b/src/new.test.ts index 599ad7c..22ce41b 100644 --- a/src/new.test.ts +++ b/src/new.test.ts @@ -722,6 +722,33 @@ describe("gji new", () => { expect(promptCalled).toBe(false); }); + it("reports sync-file failures as structured JSON errors", async () => { + // Given a repository with an invalid syncFiles pattern and JSON output enabled. + const repoRoot = await createRepository(); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ syncFiles: ["/etc/passwd"] }), + "utf8", + ); + const stderr: string[] = []; + + // When worktree creation stops at the sync-file stage. + const result = await createNewCommand()({ + branch: "feature/sync-failure-json", + cwd: repoRoot, + json: true, + stderr: (chunk) => stderr.push(chunk), + stdout: () => undefined, + }); + + // Then the JSON error identifies the sync-file failure and no bootstrap result. + expect(result).toBe(1); + expect(JSON.parse(stderr.join(""))).toMatchObject({ + error: "worktree bootstrap failed", + syncFiles: [{ adapter: "syncFiles", state: "failed" }], + }); + }); + it("local syncFiles config overrides global (no array merging)", async () => { // Given global config with syncFiles and local config with a different syncFiles. const home = await mkdtemp(join(tmpdir(), "gji-home-")); diff --git a/src/safe-destination.ts b/src/safe-destination.ts index 13c775d..0e75262 100644 --- a/src/safe-destination.ts +++ b/src/safe-destination.ts @@ -1,5 +1,6 @@ -import { lstat, mkdir } from "node:fs/promises"; -import { isAbsolute, relative, resolve, sep } from "node:path"; +import { constants } from "node:fs"; +import { lstat, mkdir, open } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { isAlreadyExistsError, @@ -12,6 +13,46 @@ export type DestinationInspection = | { kind: "exists" } | { kind: "unsafe"; reason: string }; +export interface OpenDestinationDirectory { + path: string; + close(): Promise; +} + +export async function openDestinationDirectory( + root: string, + path: string, +): Promise { + const segments = destinationSegments(root, path); + if (process.platform !== "linux") { + await ensureDestinationDirectory(root, path); + return { path: resolve(path), close: async () => undefined }; + } + let handle = await open(root, destinationDirectoryFlags()); + + try { + for (const segment of segments) { + const childPath = join(fileDescriptorPath(handle.fd), segment); + try { + await mkdir(childPath); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } + + const childHandle = await open(childPath, destinationDirectoryFlags()); + await handle.close(); + handle = childHandle; + } + + return { + path: fileDescriptorPath(handle.fd), + close: async () => handle.close(), + }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + export async function inspectDestination( root: string, path: string, @@ -85,6 +126,33 @@ export async function inspectDestination( } } +function destinationSegments(root: string, path: string): string[] { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(path); + const distance = relative(resolvedRoot, resolvedPath); + if ( + isAbsolute(distance) || + distance === ".." || + distance.startsWith(`..${sep}`) + ) { + throw new Error("destination escapes the worktree"); + } + + return distance.split(sep).filter(Boolean); +} + +function destinationDirectoryFlags(): number { + return constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; +} + +function fileDescriptorPath(fd: number): string { + if (process.platform === "linux") return `/proc/self/fd/${fd}`; + if (process.platform === "darwin") return `/dev/fd/${fd}`; + throw new Error( + `safe destination handles are unsupported on ${process.platform}`, + ); +} + export async function ensureDestinationDirectory( root: string, path: string, diff --git a/src/sync-directories.ts b/src/sync-directories.ts index e9a2385..8ce133e 100644 --- a/src/sync-directories.ts +++ b/src/sync-directories.ts @@ -12,7 +12,6 @@ import { isCloneDestinationExistsError, isCloneInProgressError, isCloneUnsupportedError, - waitForCloneLock, } from "./dir-clone.js"; import { inspectDestination } from "./safe-destination.js"; import type { SyncDirectoryPlan } from "./sync-plan.js"; @@ -128,15 +127,6 @@ export async function executeSyncDirectoryPlan( continue; } - if (!(await waitForCloneLock(entry.destination))) { - recordSkipped( - outcomes, - options.reporter, - entry.directory, - "another clone is still in progress", - ); - continue; - } const refreshedDestinationState = await inspectDestination( entry.worktreePath, entry.destination, diff --git a/src/worktree-bootstrap.ts b/src/worktree-bootstrap.ts index 727d352..6a81e8b 100644 --- a/src/worktree-bootstrap.ts +++ b/src/worktree-bootstrap.ts @@ -128,7 +128,9 @@ export async function bootstrapWorktree( if ( dependencyMode === "off" && - (options.dependencyBootstrapPolicy?.source ?? "default") === "default" + ["default", "legacy"].includes( + options.dependencyBootstrapPolicy?.source ?? "default", + ) ) { await maybeRunInstallPrompt( options.worktreePath,