diff --git a/README.md b/README.md index ada922c..61edeb9 100644 --- a/README.md +++ b/README.md @@ -288,8 +288,11 @@ 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` | 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` | +| `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) | @@ -298,7 +301,9 @@ No setup required. Optional config lives in: "branchPrefix": "feature/", "syncRemote": "upstream", "syncDefaultBranch": "main", - "syncFiles": [".env.example", ".nvmrc"] + "syncFiles": [".env.example", ".nvmrc"], + "syncDirs": [".next"], + "dependencyBootstrap": "cow-then-repair" } ``` @@ -326,6 +331,42 @@ This stores: } ``` +### Instant directory bootstrap + +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": [".next", ".cache"] +} +``` + +`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. + +The human output includes clone timing; dry-run can provide source-size estimates: + +```text +⚡ cloned .next (size unavailable → 1.2s) +``` + +Use `dependencyBootstrap` when a package manager or build cache needs a reusable seed followed by authoritative repair: + +```json +{ + "dependencyBootstrap": "cow-then-repair" +} +``` + +`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. + +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. + ### 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: @@ -432,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? @@ -444,6 +487,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`. +`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 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/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/scripts/generate-man.mjs b/scripts/generate-man.mjs index e75673c..d482b9e 100644 --- a/scripts/generate-man.mjs +++ b/scripts/generate-man.mjs @@ -131,6 +131,10 @@ 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_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/bootstrap-output.ts b/src/bootstrap-output.ts new file mode 100644 index 0000000..b82623d --- /dev/null +++ b/src/bootstrap-output.ts @@ -0,0 +1,37 @@ +import type { + BootstrapEvent, + DependencyBootstrapReporter, +} from "./dependency-bootstrap.js"; +import { formatBytes } from "./format-bytes.js"; +import type { SyncDirectoryReporter } from "./sync-directories.js"; + +export function createBootstrapReporter( + write: (chunk: string) => void, + json: boolean, + measureCloneSize = false, +): SyncDirectoryReporter & DependencyBootstrapReporter { + return { + emitCachedFailureWarnings: !json, + measureCloneSize: measureCloneSize && !json, + write, + cloned: (directory) => { + if (json) return; + write( + `⚡ 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: BootstrapEvent) => { + if (json) return; + const target = event.target ? ` ${event.target}` : ""; + write(`gji: ${event.state}${target} — ${event.message}\n`); + }, + }; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} diff --git a/src/bootstrap-preview.ts b/src/bootstrap-preview.ts new file mode 100644 index 0000000..09f0da4 --- /dev/null +++ b/src/bootstrap-preview.ts @@ -0,0 +1,46 @@ +import type { DependencyBootstrapMode } from "./config.js"; +import { + type BootstrapStrategy, + 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: 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/cli.ts b/src/cli.ts index 23b5941..e6ccb19 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", @@ -1020,6 +1022,7 @@ function attachCommandActions( action: "set", cwd: options.cwd, key, + stderr: options.stderr, stdout: options.stdout, value, }); diff --git a/src/clone-failure-store.test.ts b/src/clone-failure-store.test.ts new file mode 100644 index 0000000..f433ccc --- /dev/null +++ b/src/clone-failure-store.test.ts @@ -0,0 +1,168 @@ +import { + access, + mkdir, + mkdtemp, + readFile, + utimes, + writeFile, +} 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"); + 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"); + 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(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 () => { + // 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); + }); + + 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); + }); + + 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); + }); + + 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); + 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(); + 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" }); + }); + + 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 new file mode 100644 index 0000000..1b75f26 --- /dev/null +++ b/src/clone-failure-store.ts @@ -0,0 +1,322 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + rmdir, + stat, + unlink, + utimes, + 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 STATE_LOCK_SUFFIX = ".lock"; +const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000; +const STATE_LOCK_TTL_MS = 30 * 1000; + +interface CloneFailure { + failedAt: number; + reason: string; +} + +interface CloneFailureState { + syncDirs?: Record>; + [key: string]: unknown; +} + +export interface CloneFailureStore { + 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, + scope?: string, + ): Promise { + const state = await this.readState(); + const repoState = state.syncDirs?.[repoRoot]; + const key = failureKey(directory, scope); + if (!repoState || !Object.hasOwn(repoState, key)) return false; + + const failure = repoState[key]; + return ( + isPlainObject(failure) && + typeof failure.failedAt === "number" && + Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS + ); + } + + async cache( + 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, + [key]: { failedAt: Date.now(), reason }, + }; + + await this.writeState({ ...state, syncDirs }); + }); + } + + async clear( + repoRoot: string, + directory: string, + scope?: string, + ): Promise { + await this.update(async () => { + const state = await this.readState(); + const repoState = state.syncDirs?.[repoRoot]; + const key = failureKey(directory, scope); + if (!repoState || !Object.hasOwn(repoState, key)) return; + + const nextRepoState = { ...repoState }; + delete nextRepoState[key]; + 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( + () => this.withStateLock(operation), + () => this.withStateLock(operation), + ); + this.updateQueue = next.then( + () => undefined, + () => undefined, + ); + await next; + } + + private async withStateLock(operation: () => Promise): Promise { + 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); + } + } + + 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); + } +} + +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 freshnessPath = await lockFreshnessPath(lockPath); + const lockStats = await stat(freshnessPath); + if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) { + 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) { + if (!isErrorCode(lockError, "ENOENT")) return undefined; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + continue; + } + try { + await writeFile( + join(lockPath, ownerFileName(lockToken)), + `${lockToken}\n`, + { + flag: "wx", + }, + ); + return lockToken; + } catch { + await rm(lockPath, { force: true, recursive: true }).catch( + () => undefined, + ); + return undefined; + } + } + 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, +): () => 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 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. + } +} + +async function releaseStateLock( + lockPath: string, + lockToken: string, +): Promise { + try { + const ownerPath = join(lockPath, ownerFileName(lockToken)); + await unlink(ownerPath); + await rmdir(lockPath); + } catch (error) { + if (!isErrorCode(error, "ENOENT") && !isErrorCode(error, "ENOTEMPTY")) { + return; + } + } +} + +function ownerFileName(lockToken: string): string { + return `owner-${lockToken}`; +} + +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); +} + +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..d1240a6 --- /dev/null +++ b/src/command-runner.test.ts @@ -0,0 +1,73 @@ +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"; + +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"); + }); + + 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" }); + }); + + 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 new file mode 100644 index 0000000..1e66c19 --- /dev/null +++ b/src/command-runner.ts @@ -0,0 +1,61 @@ +import { spawn } from "node:child_process"; + +export type CommandRunner = ( + command: string, + cwd: string, + stderr: (chunk: string) => void, + stdout?: (chunk: string) => void, + options?: CommandRunnerOptions, +) => Promise; + +export interface CommandRunnerOptions { + env?: NodeJS.ProcessEnv; + shell?: boolean; +} + +export const runCommand: CommandRunner = async ( + command, + cwd, + stderr, + stdout = (chunk) => process.stdout.write(chunk), + options, +) => { + await new Promise((resolve, reject) => { + 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, + 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) => { + stderr(chunk.toString()); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`exited with code ${code}`)); + } else { + resolve(); + } + }); + + 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/completion.test.ts b/src/completion.test.ts index 441a717..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 editor hooks installSaveTarget shellIntegration skipInstallPrompt 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-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.test.ts b/src/config.test.ts index 6de16f3..f74beb9 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,10 +11,14 @@ import { KNOWN_CONFIG_KEYS, loadConfig, loadEffectiveConfig, + loadEffectiveConfigResult, + normalizeDependencyBootstrap, resolveConfigString, saveLocalConfig, updateGlobalRepoConfigKey, updateLocalConfigKey, + validateSyncDirPattern, + validateSyncDirsConfig, } from "./config.js"; const originalHome = process.env.HOME; @@ -51,6 +55,49 @@ 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("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 @@ -89,6 +136,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", () => { @@ -175,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-")); @@ -518,6 +660,8 @@ describe("KNOWN_CONFIG_KEYS", () => { it("includes the keys used by commands", () => { for (const key of [ "branchPrefix", + "dependencyBuildCommand", + "dependencyBootstrap", "hooks", "syncFiles", "syncRemote", diff --git a/src/config.ts b/src/config.ts index 3699516..6b2e8a6 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"; @@ -8,11 +15,14 @@ export const GLOBAL_CONFIG_NAME = "config.json"; export const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([ "branchPrefix", + "dependencyBuildCommand", + "dependencyBootstrap", "editor", "hooks", "installSaveTarget", "shellIntegration", "skipInstallPrompt", + "syncDirs", "syncDefaultBranch", "syncFiles", "syncRemote", @@ -26,12 +36,38 @@ 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; + dependencyBuildCommand?: string; + dependencyBootstrap?: DependencyBootstrapMode; + 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; path: string; } +export interface EffectiveConfigResult { + config: EffectiveGjiConfig; + dependencyBootstrapExplicit: boolean; +} + export const DEFAULT_CONFIG: GjiConfig = Object.freeze({}); export async function loadConfig(root: string): Promise { @@ -44,7 +80,15 @@ export async function loadEffectiveConfig( root: string, home: string = homedir(), onWarning?: (message: string) => void, -): Promise { +): 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), @@ -56,6 +100,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 }; @@ -124,7 +169,13 @@ export async function loadEffectiveConfig( merged.hooks = { ...globalHooks, ...perRepoHooks, ...localHooks }; } - return merged; + return { + config: toEffectiveConfig(merged), + dependencyBootstrapExplicit: + Object.hasOwn(globalBase, "dependencyBootstrap") || + Object.hasOwn(perRepoConfig, "dependencyBootstrap") || + Object.hasOwn(localConfig.config, "dependencyBootstrap"), + }; } export async function loadGlobalConfig( @@ -137,6 +188,7 @@ export async function saveLocalConfig( root: string, config: GjiConfig, ): Promise { + validateConfigValues(config); const path = join(root, CONFIG_FILE_NAME); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8"); @@ -164,6 +216,7 @@ export async function saveGlobalConfig( config: GjiConfig, home: string = homedir(), ): Promise { + validateConfigValues(config); const path = GLOBAL_CONFIG_FILE_PATH(home); await mkdir(dirname(path), { recursive: true }); @@ -248,10 +301,96 @@ export function resolveConfigString( return typeof value === "string" && value.length > 0 ? value : undefined; } +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 { + if (value === undefined) return; + + if (!Array.isArray(value)) { + throw new Error("syncDirs: expected an array of relative directory paths"); + } + + return value.map((pattern) => { + if (typeof pattern !== "string") { + throw new Error("syncDirs: every entry must be a string"); + } + + return validateSyncDirPattern(pattern); + }); +} + +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)) { + validateConfigValues(repoConfig); + } + } +} + +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; + validateConfigValues(parsedConfig); return { config: mergeConfig(parsedConfig), @@ -273,14 +412,51 @@ 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 }, ); } +function toEffectiveConfig(config: GjiConfig): EffectiveGjiConfig { + const effective = { ...config } as EffectiveGjiConfig; + if (config.dependencyBootstrap !== undefined) { + effective.dependencyBootstrap = normalizeDependencyBootstrap( + config.dependencyBootstrap, + ); + } + for (const key of [ + "branchPrefix", + "dependencyBuildCommand", + "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/dependency-bootstrap-prompt.ts b/src/dependency-bootstrap-prompt.ts new file mode 100644 index 0000000..bff2216 --- /dev/null +++ b/src/dependency-bootstrap-prompt.ts @@ -0,0 +1,178 @@ +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; + source: "explicit" | "prompted" | "default" | "legacy"; +} + +export async function resolveDependencyBootstrapPolicy( + context: { + repoRoot: string; + currentRoot?: string; + detectionRoot?: 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, + source: "explicit", + }; + } + + if (options.dryRun || options.nonInteractive || isHeadless()) { + return { mode: "off", prompted: false, source: "default" }; + } + + if ( + options.legacyInstallPromptConfigured && + !options.dependencies?.promptForDependencyBootstrap + ) { + return { mode: "off", prompted: false, source: "legacy" }; + } + + 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, source: "default" }; + } + + 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, source: "prompted" }; + const mode = choice; + + await persistDependencyBootstrapPolicy( + context.repoRoot, + config, + mode, + options.dependencies, + options.stderr, + ); + + return { mode, prompted: true, source: "prompted" }; +} + +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 subject = + candidate.kind === "build-cache" + ? "dependencies and build state" + : "dependencies"; + const choice = await select({ + message: `Set up ${candidate.adapter} ${subject} 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 new file mode 100644 index 0000000..ee0f70b --- /dev/null +++ b/src/dependency-bootstrap.test.ts @@ -0,0 +1,649 @@ +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 { cloneFailureScope } from "./clone-failure-store.js"; +import { + executeDependencyBootstrap, + prepareDependencyBootstrap, + previewDependencyBootstrap, +} from "./dependency-bootstrap.js"; +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) => { + 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, + }; +} + +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, + worktreePath, + }); + return { repoRoot, worktreePath, plan }; +} + +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( + 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("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 } = + 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, + 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("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 commandOptions: unknown[] = []; + 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, _cwd, _stderr, _stdout, options) => { + commands.push(command); + commandOptions.push(options); + }, + }); + + // 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"]); + 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 () => { + // 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", + await cloneFailureScope( + join(repoRoot, "node_modules"), + join(worktreePath, "node_modules"), + ), + ), + ).toBe(true); + expect(failureStore.calls.cache).toHaveLength(1); + 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 } = + 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, + 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, + 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("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-")); + 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, + 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, + 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, + 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"); + }); + + 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 { + 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..d959bd0 --- /dev/null +++ b/src/dependency-bootstrap.ts @@ -0,0 +1,979 @@ +import { execFile } from "node:child_process"; +import { 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, + cloneDir, + isCloneDestinationExistsError, + isCloneInProgressError, + isCloneUnsupportedError, +} from "./dir-clone.js"; +import { pathExists } from "./fs-utils.js"; +import { inspectDestination } from "./safe-destination.js"; + +const execFileAsync = promisify(execFile); + +export type BootstrapKind = "dependency" | "build-cache" | "sync-file"; +export type BootstrapState = + | "seeded" + | "repaired" + | "installed" + | "fallback" + | "skipped" + | "failed"; +export type BootstrapStrategy = + | "cow-then-repair" + | "repair-only" + | "install-only"; + +export type BootstrapCommandRunner = CommandRunner; + +export interface BootstrapPreparationContext { + detectionRoot?: string; + sourceRoot: string; + worktreePath: string; +} + +export interface BootstrapExecutionContext { + runCommand: BootstrapCommandRunner; + stderr: (chunk: string) => void; + stdout: (chunk: string) => void; +} + +export interface BootstrapTarget { + adapter: string; + kind: BootstrapKind; + relativePath: string; + sourceRoot: string; + worktreePath: string; + sourcePath: string; + targetPath: string; + repairCommand: string; + repairState: "repaired" | "installed"; + 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; + readonly name: string; + readonly relativePath: string; + detect(context: BootstrapPreparationContext): Promise; + seedPath(target: BootstrapTarget): string; + repair( + target: BootstrapTarget, + context: BootstrapExecutionContext, + ): Promise; + canSeed(target: BootstrapTarget): Promise; + output(target: BootstrapTarget): BootstrapOutputMetadata; + shouldRetryAfterRepairFailure( + context: BootstrapRepairFailureContext, + ): boolean; + cleanupAfterRepairFailure( + context: BootstrapRepairFailureContext, + ): Promise; +} + +export interface DependencyBootstrapPlan { + mode: DependencyBootstrapMode; + targets: readonly PlannedBootstrapTarget[]; +} + +export interface PlannedBootstrapTarget { + adapter: BootstrapAdapter; + target: BootstrapTarget; + seedable: boolean; +} + +export interface BootstrapEvent { + adapter: string; + kind: BootstrapKind; + reason?: string; + state: BootstrapState; + target: string; + message: string; +} + +export interface DependencyBootstrapReporter { + readonly measureCloneSize: boolean; + dependency(event: BootstrapEvent): void; +} + +export interface DependencyBootstrapReport { + mode: DependencyBootstrapMode; + ready: boolean; + events: readonly BootstrapEvent[]; +} + +export interface DependencyBootstrapDependencies { + cloneDirectory?: CloneDirectory; + failureStore?: CloneFailureStore; + runCommand?: BootstrapCommandRunner; + stderr?: (chunk: string) => void; + stdout?: (chunk: string) => void; + seededDirectories?: readonly string[]; +} + +export interface DependencyBootstrapPreview { + mode: DependencyBootstrapMode; + targets: readonly { + adapter: string; + kind: BootstrapKind; + target: string; + repairCommand: string; + seedable: boolean; + strategy: BootstrapStrategy; + }[]; +} + +export interface DependencyBootstrapCandidate { + adapter: string; + kind: BootstrapKind; + lockfile: string; + target: string; + repairCommand: string; + seedable: boolean; +} + +export async function prepareDependencyBootstrap( + mode: DependencyBootstrapMode, + context: { + repoRoot: string; + currentRoot?: string; + detectionRoot?: 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([ + context.repoRoot, + context.currentRoot, + ])) { + if (await isAllowedSourceRoot(context.repoRoot, sourceRoot)) { + sourceRoots.push(sourceRoot); + } + } + 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; + + for (const sourceRoot of sourceRoots) { + const target = await adapter.detect({ + detectionRoot: context.detectionRoot, + sourceRoot, + worktreePath: context.worktreePath, + }); + if (!target) continue; + + const seedable = + mode !== "install-only" && (await adapter.canSeed(target)); + const candidate = { adapter, target, seedable }; + fallback ??= candidate; + if (mode === "install-only" || seedable) { + selected = candidate; + break; + } + } + + const plannedTarget = selected ?? fallback; + if (plannedTarget) { + targets.push(plannedTarget); + plannedRelativePaths.add(adapter.relativePath); + } + } + + return { mode, targets }; +} + +export async function detectDependencyBootstrapCandidate(context: { + repoRoot: string; + currentRoot?: string; + detectionRoot?: 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 { + return { + mode: plan.mode, + targets: plan.targets.map(({ adapter, target, seedable }) => ({ + adapter: adapter.output(target).adapter, + kind: adapter.kind, + target: adapter.output(target).target, + repairCommand: target.repairCommand, + seedable, + strategy: bootstrapStrategy(plan.mode, target, seedable), + })), + }; +} + +export async function executeDependencyBootstrap( + plan: DependencyBootstrapPlan, + options: DependencyBootstrapDependencies & { + repoRoot: string; + reporter: DependencyBootstrapReporter; + }, +): 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; + 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 ?? []); + + if (plan.targets.length === 0) { + recordBootstrapEvent(events, options.reporter, { + adapter: "none", + kind: "dependency", + reason: "no-lockfile", + state: "skipped", + target: "", + message: "no supported dependency or build-state lockfile was detected", + }); + return { mode: plan.mode, ready: true, events }; + } + + 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, + ); + } + + return { + mode: plan.mode, + ready: !events.some(({ state }) => state === "failed"), + events, + }; +} + +async function executeBootstrapTarget( + adapter: BootstrapAdapter, + target: BootstrapTarget, + seedable: boolean, + mode: DependencyBootstrapMode, + cloneDirectory: CloneDirectory, + failureStore: CloneFailureStore, + execution: BootstrapExecutionContext, + seededBySyncDirs: boolean, + events: BootstrapEvent[], + 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, + 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"; + const destinationExistsNow = destinationInspection.kind === "exists"; + if (target.existingBeforeBootstrap) { + 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", + }); + } else if (seededBySyncDirs && destinationExistsNow) { + if (seedable) { + seeded = true; + ownership = "syncDirs"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + reason: "generic-seed", + state: "seeded", + target: target.relativePath, + message: "reusing a seed created by syncDirs", + }); + } else { + ownership = "preserve"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + reason: "generic-seed", + state: "fallback", + target: target.relativePath, + message: + "syncDirs created a generic target; this adapter uses repair without CoW", + }); + } + } else if (destinationExistsNow) { + ownership = "preserve"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + reason: "destination-race", + state: "skipped", + target: target.relativePath, + message: + "target appeared during bootstrap; preserving it as the repair input", + }); + } else if ( + seedable && + (await failureStore.isCached(repoRoot, target.relativePath, failureScope)) + ) { + 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", + }); + } else if (seedable) { + try { + await cloneDirectory(adapter.seedPath(target), target.targetPath, { + destinationRoot: target.worktreePath, + measureBytes: reporter.measureCloneSize, + }); + await failureStore.clear(repoRoot, target.relativePath, failureScope); + seeded = true; + ownership = "adapter"; + recordBootstrapEvent(events, reporter, { + adapter: adapter.name, + kind: adapter.kind, + state: "seeded", + target: target.relativePath, + 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)}`, + "clone-in-progress", + ); + return; + } + if (isCloneUnsupportedError(error)) { + await failureStore.cache( + repoRoot, + target.relativePath, + toErrorMessage(error), + failureScope, + ); + } + 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)})`, + }); + } + } else { + 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", + }); + } + + await repairTarget( + adapter, + target, + execution, + seeded, + ownership, + events, + reporter, + ); +} + +async function repairTarget( + adapter: BootstrapAdapter, + target: BootstrapTarget, + execution: BootstrapExecutionContext, + seeded: boolean, + ownership: BootstrapTargetOwnership, + events: BootstrapEvent[], + reporter: DependencyBootstrapReporter, +): Promise { + 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, { + 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) { + 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 === undefined ? undefined : cleanupError, + ), + "repair-failed", + ); + return; + } + + if (cleanupError) { + recordBootstrapFailure( + events, + reporter, + adapter, + target, + formatRepairFailure(firstError, cleanupError), + "repair-cleanup-failed", + ); + return; + } + 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)})`, + }); + + 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, { + adapter: adapter.name, + kind: adapter.kind, + reason: "repair-retry", + state: target.repairState, + target: target.relativePath, + message: "installed or repaired from a clean target", + }); + } catch (secondError) { + const cleanupError = await adapter + .cleanupAfterRepairFailure({ + target, + ownership, + targetExistedBeforeRepair: presentBeforeRetry, + }) + .then(() => undefined) + .catch((error) => toErrorMessage(error)); + recordBootstrapFailure( + events, + reporter, + adapter, + target, + formatRepairFailure(secondError, cleanupError), + "repair-failed", + ); + } + } +} + +function recordBootstrapFailure( + events: BootstrapEvent[], + reporter: DependencyBootstrapReporter, + 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, + }); +} + +function recordBootstrapEvent( + events: BootstrapEvent[], + reporter: DependencyBootstrapReporter, + event: BootstrapEvent, +): void { + events.push(event); + reporter.dependency(event); +} + +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, + 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, + cargoBuildCommand?: string, +): readonly BootstrapAdapter[] { + return [ + new LockfileBootstrapAdapter({ + name: "pnpm", + kind: "dependency", + 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"), { + force: true, + }); + } + }, + }), + new LockfileBootstrapAdapter({ + name: "yarn", + kind: "dependency", + lockfile: "yarn.lock", + relativePath: "node_modules", + repairCommand: "yarn install --immutable", + shell: false, + }), + new LockfileBootstrapAdapter({ + name: "npm", + kind: "dependency", + lockfile: "package-lock.json", + relativePath: "node_modules", + repairCommand: "npm ci", + shell: false, + seedPolicy: "never", + repairCommandOverride: (target) => + target.existingBeforeBootstrap ? "npm install" : "npm ci", + repairState: "installed", + }), + new LockfileBootstrapAdapter({ + name: "bundler", + kind: "dependency", + lockfile: "Gemfile.lock", + relativePath: "vendor/bundle", + repairCommand: "bundle install", + shell: false, + commandOptions: () => ({ + env: { BUNDLE_PATH: "vendor/bundle" }, + }), + }), + new LockfileBootstrapAdapter({ + name: "uv", + kind: "dependency", + lockfile: "uv.lock", + relativePath: ".venv", + repairCommand: "uv sync --locked", + shell: false, + canSeedOverride: checkUvRuntime, + }), + new LockfileBootstrapAdapter({ + name: "cargo", + kind: "build-cache", + lockfile: "Cargo.lock", + relativePath: "target", + repairCommand: cargoBuildCommand?.trim() || "cargo check", + shell: cargoBuildCommand ? undefined : false, + }), + ]; +} + +interface LockfileBootstrapAdapterSpec { + name: string; + kind: BootstrapKind; + lockfile: string; + relativePath: string; + repairCommand: string; + shell?: boolean; + seedPolicy?: "always" | "never"; + canSeedOverride?: (target: BootstrapTarget) => Promise; + beforeRepair?: (target: BootstrapTarget) => Promise; + commandOptions?: ( + target: BootstrapTarget, + ) => Parameters[4]; + repairCommandOverride?: (target: BootstrapTarget) => string; + repairState?: "repaired" | "installed"; +} + +class LockfileBootstrapAdapter implements BootstrapAdapter { + readonly kind: BootstrapKind; + readonly lockfile: string; + readonly name: string; + readonly relativePath: string; + private readonly defaultRepairCommand: string; + private readonly shell?: boolean; + private readonly seedPolicy: "always" | "never"; + private readonly canSeedOverride?: ( + 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"; + + constructor(spec: LockfileBootstrapAdapterSpec) { + this.name = spec.name; + this.kind = spec.kind; + 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; + this.commandOptions = spec.commandOptions; + this.repairCommandOverride = spec.repairCommandOverride; + this.repairState = spec.repairState ?? "repaired"; + } + + async detect( + context: BootstrapPreparationContext, + ): Promise { + 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); + const destinationInspection = await inspectDestination( + context.worktreePath, + targetPath, + ); + const target = { + adapter: this.name, + kind: this.kind, + relativePath: this.relativePath, + sourceRoot: context.sourceRoot, + worktreePath: context.worktreePath, + sourcePath, + targetPath, + repairCommand: this.defaultRepairCommand, + repairState: this.repairState, + existingBeforeBootstrap: destinationInspection.kind === "exists", + }; + + return { + ...target, + repairCommand: + this.repairCommandOverride?.(target) ?? target.repairCommand, + }; + } + + seedPath(target: BootstrapTarget): string { + return target.sourcePath; + } + + async repair( + target: BootstrapTarget, + context: BootstrapExecutionContext, + ): Promise { + await this.beforeRepair?.(target); + await context.runCommand( + target.repairCommand, + target.worktreePath, + context.stderr, + context.stdout, + { + ...this.commandOptions?.(target), + shell: this.shell, + }, + ); + } + + async canSeed(target: BootstrapTarget): Promise { + if (this.seedPolicy === "never") return false; + 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 { + 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 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; + } +} + +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}`)) + ); +} + +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 new file mode 100644 index 0000000..8ea9dd1 --- /dev/null +++ b/src/dir-clone.test.ts @@ -0,0 +1,328 @@ +import { constants } from "node:fs"; +import { + copyFile, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + cloneDir, + isCloneDestinationExistsError, + isCloneInProgressError, + isCloneUnsupportedError, +} from "./dir-clone.js"; + +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"); + let cloneArgs: string[] = []; + + // 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; + 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).toBeGreaterThan(0); + expect(cloneArgs).toContain("--reflink=always"); + 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("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("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-")); + 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("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 cloneDir attempts to publish the clone. + const error = await cloneDir(source, destination, { + platform: "linux", + runCommand: async (_command, args) => mkdir(args.at(-1) as string), + }).catch((caught) => caught); + expect(isCloneDestinationExistsError(error)).toBe(true); + + // Then the existing destination remains empty and untouched. + 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-")); + 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, { + destinationRoot: join(root, "worktree"), + 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-")); + 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"]); + }); + + 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("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); + 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, { + 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-")); + 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"); + } + }); +}); diff --git a/src/dir-clone.ts b/src/dir-clone.ts new file mode 100644 index 0000000..3e16082 --- /dev/null +++ b/src/dir-clone.ts @@ -0,0 +1,671 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + opendir, + readdir, + readFile, + readlink, + realpath, + rename, + rm, + rmdir, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { promisify } from "node:util"; + +import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js"; +import { + inspectDestination, + type OpenDestinationDirectory, + openDestinationDirectory, +} 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; + ms: number; +} + +export interface CloneRequestOptions { + destinationRoot?: string; + measureBytes?: boolean; +} + +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 = ( + source: string, + destination: string, + 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, + options: CloneDirOptions = {}, +): Promise { + 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); + const sourceStats = await lstat(sourcePath); + if (!sourceStats.isDirectory()) { + throw new Error("source is not a directory"); + } + if (await destinationExists(destination)) { + throw new CloneDestinationExistsError(destination); + } + + const startedAt = Date.now(); + const parent = dirname(destination); + 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 }); + } + 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); + + let temporaryRoot: string | undefined; + let reservationPath: string | undefined; + const reservationEntries: string[] = []; + try { + 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; + } + + await publishCloneContents( + temporaryDestination, + operationDestination, + reservationPath, + (entry) => reservationEntries.push(entry), + options.copyFile ?? runForcedCloneFileCopy, + ); + 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. + } + } + + const bytes = + options.measureBytes === false + ? undefined + : await estimateCloneBytes(sourcePath); + return { bytes, ms: Date.now() - startedAt }; + } finally { + await safeParent?.close().catch(() => undefined); + } +} + +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"; + + constructor(destination: string) { + super(`destination already exists: ${destination}`); + this.name = "CloneDestinationExistsError"; + } +} + +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 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 { + return error instanceof CloneDestinationExistsError; +} + +export function isCloneUnsupportedError(error: unknown): boolean { + return ( + error instanceof CloneUnsupportedError || isUnsupportedCloneError(error) + ); +} + +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; + + const pending = [path]; + let total = 0; + 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; +} + +function cloneStrategy( + platform: NodeJS.Platform, +): ((source: string, destination: string) => string[]) | null { + if (platform === "linux") { + return (source, destination) => [ + "-a", + "--reflink=always", + source, + destination, + ]; + } + + return null; +} + +function isClonePlatformSupported(platform: NodeJS.Platform): boolean { + return platform === "darwin" || platform === "linux"; +} + +async function estimateCloneBytes(path: string): Promise { + try { + return await directorySize(path); + } catch { + return undefined; + } +} + +async function runCloneCommand(command: string, args: string[]): Promise { + try { + await execFileAsync(command, args); + } catch (error) { + if (isUnsupportedCloneError(error)) { + throw new CloneUnsupportedError(toErrorMessage(error)); + } + throw error; + } +} + +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, ownerFileName(lockToken)), + `${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(await lockFreshnessPath(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, ownerFileName(lockToken)), + `${lockToken}\n`, + { + flag: "wx", + }, + ); + } catch (error) { + await rm(lockPath, { force: true, recursive: true }); + throw error; + } + 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 }).catch( + () => undefined, + ); + if (isAlreadyExistsError(error)) continue; + throw error; + } + } + + 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, + reservationPath: string, + onEntryPublished: (entry: string) => void, + copyFileEntry: (source: string, destination: string) => Promise, +): 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) { + 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 symlink(await readlink(source), destination); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CloneDestinationExistsError(destination); + } + throw error; + } + onCreated(); + return; + } + + 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 { + try { + await mkdir(destination); + } catch (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 { + 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); + }, CLONE_LOCK_TTL_MS / 3); + timer.unref?.(); + return () => clearInterval(timer); +} + +async function refreshCloneLock( + lockPath: string, + lockToken: string, +): Promise { + try { + 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. + } +} + +async function releaseCloneLock( + lockPath: string, + lockToken: string, +): Promise { + try { + const ownerPath = join(lockPath, ownerFileName(lockToken)); + await unlink(ownerPath); + await rmdir(lockPath); + } catch (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; + + 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|invalid cross-device|cross-device/iu.test( + error.message, + ); +} + +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); +} + +function platformIsDarwin(platform: NodeJS.Platform): boolean { + return platform === "darwin"; +} diff --git a/src/file-sync.test.ts b/src/file-sync.test.ts index 7900999..7bf29a8 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,61 @@ 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(); + 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("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 14e2a4f..4dd3333 100644 --- a/src/file-sync.ts +++ b/src/file-sync.ts @@ -1,5 +1,20 @@ -import { copyFile, mkdir, stat } from "node:fs/promises"; -import { dirname, isAbsolute, join, normalize } from "node:path"; +import { constants } from "node:fs"; +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 { + inspectDestination, + openDestinationDirectory, +} from "./safe-destination.js"; /** * Copies files matching each pattern (relative to mainRoot) into the equivalent @@ -17,23 +32,47 @@ 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()) { + throw new Error(`destination is a symbolic link: ${destPath}`); } - - // Skip silently if target already exists - const destExists = await fileExists(destPath); - if (destExists) { + if (existingDestination) { continue; } - await mkdir(dirname(destPath), { recursive: true }); - await copyFile(sourcePath, destPath); + const destinationParent = dirname(destPath); + const beforeCreate = await inspectDestination( + targetPath, + destinationParent, + ); + if (beforeCreate.kind === "unsafe") { + throw new Error(beforeCreate.reason); + } + const safeParent = await openDestinationDirectory( + targetPath, + destinationParent, + ); + try { + 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); + } } } @@ -54,22 +93,54 @@ 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( + path: string, +): Promise> | undefined> { + try { + return await lstat(path); + } catch (error) { + if (isNotFoundError(error)) return undefined; throw error; } } -function isNotFoundError(error: unknown): boolean { +function isPathInside(root: string, candidate: string): boolean { + const distance = relative(resolve(root), resolve(candidate)); return ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" + distance === "" || + (!isAbsolute(distance) && + distance !== ".." && + !distance.startsWith(`..${sep}`)) ); } 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/fs-utils.ts b/src/fs-utils.ts new file mode 100644 index 0000000..06f2c65 --- /dev/null +++ b/src/fs-utils.ts @@ -0,0 +1,33 @@ +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") || hasErrorCode(error, "ERR_FS_CP_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/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/install-prompt.ts b/src/install-prompt.ts index da18f75..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, @@ -78,7 +76,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) { @@ -144,35 +142,7 @@ export async function maybeRunInstallPrompt( } } -async function defaultRunInstallCommand( - 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 3e09874..22ce41b 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"; @@ -6,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, @@ -22,14 +30,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", () => { @@ -642,14 +650,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[] = []; @@ -662,11 +673,80 @@ 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("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("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 () => { @@ -710,6 +790,978 @@ 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 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(); + 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(); + 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("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("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"; + 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, ".npmrc"), + "registry=https://example.test\n", + ); + await writeFile( + join(repoRoot, ".gji.json"), + JSON.stringify({ + dependencyBootstrap: "cow-then-repair", + syncDirs: ["node_modules"], + syncFiles: [".npmrc"], + hooks: { "after-create": "touch after-create-ran" }, + }), + "utf8", + ); + let promptCalled = false; + const installCommands: string[] = []; + + // 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 (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({ + branch: branchName, + cwd: repoRoot, + stderr: () => undefined, + stdout: () => undefined, + }); + + // Then the adapter repairs the seed without using the interactive prompt. + expect(result).toBe(0); + expect(promptCalled).toBe(false); + expect(installCommands).toEqual(["pnpm install --frozen-lockfile"]); + await expect( + readFile( + join( + resolveWorktreePath(repoRoot, branchName), + "node_modules", + ".modules.yaml", + ), + "utf8", + ), + ).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 () => { + // 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[] = []; + let measureBytes: boolean | undefined; + + // When gji new runs in JSON mode with a successful cloner. + const result = await createNewCommand({ + cloneDir: async (_source, destination, options) => { + measureBytes = options?.measureBytes; + 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(measureBytes).toBe(false); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + cloned: [{ dir: "node_modules", ms: 7 }], + }); + }); + + 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("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(); + 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("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(); + 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("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(); + 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("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 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(); + 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 syncDirs remains generic and the normal prompt still runs. + expect(result).toBe(0); + expect(promptCalled).toBe(true); + }); + + 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(); + 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)"); + }); + + 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" }], + }, + }); + }); + + 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", () => { const fakePm = { name: "pnpm", installCommand: "pnpm install" }; diff --git a/src/new.ts b/src/new.ts index 7ed38c0..8ff23c7 100644 --- a/src/new.ts +++ b/src/new.ts @@ -1,24 +1,33 @@ import { execFile } from "node:child_process"; import { access, mkdir } from "node:fs/promises"; -import { basename, dirname, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; import { isCancel, text } from "@clack/prompts"; - -import { loadEffectiveConfig, resolveConfigString } from "./config.js"; +import { createBootstrapReporter } from "./bootstrap-output.js"; +import { + createDependencyBootstrapPreview, + formatDependencyBootstrapPreview, +} from "./bootstrap-preview.js"; +import { + type EffectiveGjiConfig, + loadEffectiveConfigResult, + resolveConfigString, +} from "./config.js"; import { type PathConflictChoice, 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 { syncFiles } from "./file-sync.js"; +import { formatBytes } from "./format-bytes.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, @@ -29,6 +38,8 @@ import { validateBranchName, } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; +import { estimateSyncDirectories } from "./sync-plan.js"; +import { bootstrapWorktree } from "./worktree-bootstrap.js"; const execFileAsync = promisify(execFile); @@ -57,7 +68,10 @@ 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; promptForPathConflict: (path: string) => Promise; @@ -69,6 +83,7 @@ export function createNewCommand( ): (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 +105,22 @@ export function createNewCommand( } const repository = await detectRepository(options.cwd); - const config = await loadEffectiveConfig( - repository.repoRoot, - undefined, - options.json ? undefined : options.stderr, - ); + let config: EffectiveGjiConfig; + let dependencyBootstrapExplicit: boolean; + try { + const loaded = await loadEffectiveConfigResult( + repository.repoRoot, + undefined, + options.json ? undefined : options.stderr, + ); + config = loaded.config; + dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit; + } catch (error) { + return emitNewError( + options, + error instanceof Error ? error.message : String(error), + ); + } const usesGeneratedDetachedName = options.detached && options.branch === undefined; @@ -230,6 +256,28 @@ 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, + }; + if (options.dryRun) { if (options.take) { const changedFiles = await listTakeFiles(options.cwd); @@ -261,24 +309,36 @@ export function createNewCommand( } return 0; } + const dryRunSyncDirs = await estimateSyncDirectories( + repository.repoRoot, + worktreePath, + config.syncDirs ?? [], + ); + const dryRunDependencyBootstrap = await createDependencyBootstrapPreview( + config.dependencyBootstrap ?? "off", + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + cargoBuildCommand: config.dependencyBuildCommand, + worktreePath, + }, + ); 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; + if (dryRunDependencyBootstrap.targets.length > 0) + output.dependencyBootstrap = dryRunDependencyBootstrap; + options.stdout(`${JSON.stringify(output, null, 2)}\n`); } else { const resolvedEditor = options.open ? (options.editor ?? resolveConfigString(config, "editor")) @@ -287,7 +347,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("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`, ); } return 0; @@ -409,42 +469,30 @@ export function createNewCommand( ); } - // 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, + const bootstrap = await bootstrapWorktree({ + branch: worktreeName, + cloneDirectory, config, - options.stderr, - dependencies, - !!options.json, - ); - - const hooks = extractHooks(config); - await runHook( - hooks["after-create"], + currentRoot: repository.currentRoot, + nonInteractive: !!options.json, + repoRoot: repository.repoRoot, + 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, - { - branch: worktreeName, + installDependencies: dependencies, + dependencyBootstrapPolicy: dependencyPolicy, + }); + if (!bootstrap.ready) { + return emitNewError(options, "worktree bootstrap failed", { + dependencyBootstrap: bootstrap.dependencyBootstrap, path: worktreePath, - repo: basename(repository.repoRoot), - }, - options.stderr, - ); + skipped: bootstrap.skippedDirs, + syncFiles: bootstrap.syncFileFailures, + }); + } if (options.json) { const navigation = createNavigationTarget( @@ -452,22 +500,27 @@ 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 (bootstrap.clonedDirs.length > 0) { + output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({ + dir, + 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`); } else { if (options.take) options.stderr( @@ -925,9 +978,23 @@ async function restoreTakeStash( ); } } -function emitNewError(options: NewCommandOptions, message: string): number { +function emitNewError( + options: NewCommandOptions, + message: string, + details?: Record, +): number { if (options.json) - options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`); - else options.stderr(`gji new: ${message}\n`); + options.stderr( + `${JSON.stringify({ error: message, ...details }, null, 2)}\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.test.ts b/src/pr.test.ts index ad2c25a..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,11 +552,171 @@ 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; } + 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"); + 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 stdout: string[] = []; + 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, + json: true, + number: "2017", + stderr: () => undefined, + stdout: (chunk) => stdout.push(chunk), + }); + + // 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"); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + branch: "pr/2017", + path: resolveWorktreePath(repoRoot, "pr/2017"), + cloned: [{ dir: "node_modules", ms: 4 }], + }); + }); + + 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", [ + ["pnpm-lock.yaml", "lockfileVersion: '9'\n"], + ]); + 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("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 c7d2450..9379451 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -1,28 +1,40 @@ 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 { loadEffectiveConfig, resolveConfigString } from "./config.js"; +import { createBootstrapReporter } from "./bootstrap-output.js"; +import { + createDependencyBootstrapPreview, + formatDependencyBootstrapPreview, +} from "./bootstrap-preview.js"; +import { + type EffectiveGjiConfig, + loadEffectiveConfigResult, + resolveConfigString, +} from "./config.js"; import { type PathConflictChoice, pathExists, promptForPathConflict, } from "./conflict.js"; -import { syncFiles } from "./file-sync.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 { 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 { detectRepository, resolveWorktreePath } from "./repo.js"; import { writeShellOutput } from "./shell-handoff.js"; +import { estimateSyncDirectories } from "./sync-plan.js"; +import { bootstrapWorktree } from "./worktree-bootstrap.js"; const execFileAsync = promisify(execFile); @@ -40,7 +52,10 @@ export interface PrCommandOptions { stdout: (chunk: string) => void; } -export interface PrCommandDependencies extends InstallPromptDependencies { +export interface PrCommandDependencies + extends InstallPromptDependencies, + DependencyBootstrapPromptDependencies { + cloneDir?: CloneDirectory; promptForPathConflict: (path: string) => Promise; } @@ -81,11 +96,25 @@ export function createPrCommand( } const repository = await detectRepository(options.cwd); - const config = await loadEffectiveConfig( - repository.repoRoot, - undefined, - options.json ? undefined : options.stderr, - ); + let config: EffectiveGjiConfig; + let dependencyBootstrapExplicit: boolean; + try { + 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) { + 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 +158,74 @@ export function createPrCommand( return 1; } + let dependencyPolicy: DependencyBootstrapPolicyResolution = { + mode: config.dependencyBootstrap ?? "off", + prompted: false, + source: dependencyBootstrapExplicit ? "explicit" : "default", + }; + 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( + repository.repoRoot, + worktreePath, + config.syncDirs ?? [], + ) + : []; + const dryRunDependencyBootstrap = options.dryRun + ? await createDependencyBootstrapPreview( + config.dependencyBootstrap ?? "off", + { + currentRoot: repository.currentRoot, + repoRoot: repository.repoRoot, + cargoBuildCommand: config.dependencyBuildCommand, + worktreePath, + }, + ) + : undefined; + 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; + 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`, + `Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`, ); } return 0; @@ -188,58 +263,88 @@ export function createPrCommand( await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot }); - // 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`, - ); - } + 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, + }; } - await maybeRunInstallPrompt( - worktreePath, - repository.repoRoot, + const bootstrap = await bootstrapWorktree({ + branch: branchName, + cloneDirectory: dependencies.cloneDir, config, - options.stderr, - dependencies, - !!options.json, - ); - - const hooks = extractHooks(config); - await runHook( - hooks["after-create"], + currentRoot: repository.currentRoot, + dependencyDetectionRoot: worktreePath, + nonInteractive: !!options.json, + repoRoot: repository.repoRoot, + 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, - { - branch: branchName, + installDependencies: dependencies, + dependencyBootstrapPolicy: dependencyPolicy, + }); + if (!bootstrap.ready) { + const details = { + dependencyBootstrap: bootstrap.dependencyBootstrap, path: worktreePath, - repo: basename(repository.repoRoot), - }, - options.stderr, - ); + skipped: bootstrap.skippedDirs, + syncFiles: bootstrap.syncFileFailures, + }; + if (options.json) { + options.stderr( + `${JSON.stringify({ error: "worktree bootstrap failed", ...details }, null, 2)}\n`, + ); + } else { + options.stderr( + `gji pr: worktree bootstrap failed at ${worktreePath}\n`, + ); + options.stderr( + `Hint: inspect the worktree or remove it with 'gji done ${worktreePath}' before retrying\n`, + ); + } + return 1; + } 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 (bootstrap.clonedDirs.length > 0) { + output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({ + dir, + 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`); } else { await recordWorktreeUsage(worktreePath, branchName); await writeOutput(worktreePath, options.stdout, options.outputEnv); diff --git a/src/safe-destination.ts b/src/safe-destination.ts new file mode 100644 index 0000000..0e75262 --- /dev/null +++ b/src/safe-destination.ts @@ -0,0 +1,197 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, open } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { + isAlreadyExistsError, + isNotDirectoryError, + isNotFoundError, +} from "./fs-utils.js"; + +export type DestinationInspection = + | { kind: "missing" } + | { 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, +): 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; + } +} + +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, +): 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/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/src/sync-directories.ts b/src/sync-directories.ts new file mode 100644 index 0000000..8ce133e --- /dev/null +++ b/src/sync-directories.ts @@ -0,0 +1,216 @@ +import { + type CloneFailureStore, + cloneFailureScope, + defaultCloneFailureStore, +} from "./clone-failure-store.js"; +import type { + CloneDirectory, + CloneDirResult, + CloneRequestOptions, +} from "./dir-clone.js"; +import { + isCloneDestinationExistsError, + isCloneInProgressError, + isCloneUnsupportedError, +} from "./dir-clone.js"; +import { inspectDestination } from "./safe-destination.js"; +import type { SyncDirectoryPlan } from "./sync-plan.js"; + +export interface ClonedDirectory { + bytes?: number; + dir: string; + ms: number; +} + +export type SyncDirectoryOutcome = + | { kind: "cloned"; directory: ClonedDirectory } + | { kind: "skipped"; dir: string; reason: string }; + +export interface SyncDirectoryReporter { + readonly emitCachedFailureWarnings: boolean; + readonly measureCloneSize: boolean; + write(message: string): void; + cloned(directory: ClonedDirectory): void; + skipped?(directory: { dir: string; reason: string }): void; +} + +export interface SyncDirectoryExecutionOptions { + cloneDirectory: CloneDirectory; + failureStore?: CloneFailureStore; + 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 (entry.destinationWarning) { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + entry.destinationWarning, + ); + continue; + } + + const destinationState = await inspectDestination( + entry.worktreePath, + entry.destination, + ); + if (destinationState.kind === "exists") { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "destination already exists", + ); + continue; + } + if (destinationState.kind === "unsafe") { + const reason = destinationState.reason; + recordSkipped(outcomes, options.reporter, entry.directory, reason); + continue; + } + if (entry.warning) { + recordSkipped(outcomes, options.reporter, entry.directory, entry.warning); + continue; + } + if (!entry.source) { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "source does not exist", + ); + continue; + } + + const failureScope = await cloneFailureScope( + entry.source, + entry.destination, + ); + if ( + await failureStore.isCached( + options.repoRoot, + entry.directory, + failureScope, + ) + ) { + 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, + ); + } + 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 = { + destinationRoot: entry.worktreePath, + measureBytes: options.reporter.measureCloneSize, + }; + result = await options.cloneDirectory( + entry.source, + entry.destination, + cloneOptions, + ); + } catch (error) { + if (isCloneDestinationExistsError(error)) { + recordSkipped( + outcomes, + options.reporter, + entry.directory, + "destination already exists", + ); + continue; + } + if (isCloneInProgressError(error)) { + const reason = "copy-on-write clone already in progress"; + recordSkipped(outcomes, options.reporter, entry.directory, reason); + continue; + } + + const reason = toErrorMessage(error); + if (isCloneUnsupportedError(error)) { + await failureStore.cache( + options.repoRoot, + entry.directory, + reason, + failureScope, + ); + } + recordSkipped(outcomes, options.reporter, entry.directory, reason); + continue; + } + + await failureStore.clear(options.repoRoot, entry.directory, failureScope); + const clonedDirectory: ClonedDirectory = { + bytes: result.bytes, + dir: entry.directory, + ms: result.ms, + }; + const outcome = { kind: "cloned" as const, directory: clonedDirectory }; + outcomes.push(outcome); + options.reporter.cloned(clonedDirectory); + } + + return outcomes; +} + +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`); + } +} + +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..18910f9 --- /dev/null +++ b/src/sync-plan.ts @@ -0,0 +1,183 @@ +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"; +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; + 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 destinationState = await inspectDestination( + worktreePath, + destination, + ); + const destinationWasPresent = destinationState.kind === "exists"; + const destinationWarning = + destinationState.kind === "unsafe" ? destinationState.reason : undefined; + try { + const source = await resolveSyncDirectorySource(repoRoot, directory); + if (!source) { + plan.push({ + directory, + destination, + worktreePath, + destinationWasPresent, + destinationWarning, + }); + } else if ("warning" in source) { + plan.push({ + directory, + destination, + worktreePath, + destinationWasPresent, + destinationWarning, + warning: source.warning, + }); + } else { + plan.push({ + directory, + destination, + worktreePath, + destinationWasPresent, + destinationWarning, + source: source.path, + }); + } + } catch (error) { + plan.push({ + directory, + destination, + worktreePath, + destinationWasPresent, + destinationWarning, + 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.destinationWasPresent || + entry.destinationWarning || + !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.destinationWasPresent && + !candidate.destinationWarning && + !!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}`) + ); +} + +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 new file mode 100644 index 0000000..6a81e8b --- /dev/null +++ b/src/worktree-bootstrap.ts @@ -0,0 +1,171 @@ +import { basename } from "node:path"; + +import type { EffectiveGjiConfig } from "./config.js"; +import { + type BootstrapCommandRunner, + type BootstrapEvent, + type DependencyBootstrapReport, + type DependencyBootstrapReporter, + 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"; +import { + type InstallPromptDependencies, + maybeRunInstallPrompt, +} from "./install-prompt.js"; +import { + type ClonedDirectory, + executeSyncDirectoryPlan, + type SyncDirectoryReporter, +} from "./sync-directories.js"; +import { prepareSyncDirectoryPlan } from "./sync-plan.js"; + +export type { ClonedDirectory as ClonedDir } from "./sync-directories.js"; + +export interface WorktreeBootstrapOptions { + branch: string; + cloneDirectory?: CloneDirectory; + config: EffectiveGjiConfig; + currentRoot?: string; + dependencyDetectionRoot?: string; + installDependencies?: InstallPromptDependencies; + runCommand?: BootstrapCommandRunner; + commandStdout?: (chunk: string) => void; + commandStderr?: (chunk: string) => void; + dependencyBootstrapPolicy?: DependencyBootstrapPolicyResolution; + json?: boolean; + nonInteractive: boolean; + repoRoot: string; + reporter: SyncDirectoryReporter & DependencyBootstrapReporter; + worktreePath: string; +} + +export interface WorktreeBootstrapResult { + clonedDirs: readonly ClonedDirectory[]; + dependencyBootstrap: DependencyBootstrapReport; + ready: boolean; + syncFileFailures: readonly BootstrapEvent[]; + skippedDirs: readonly { dir: string; reason: string }[]; +} + +export async function bootstrapWorktree( + options: WorktreeBootstrapOptions, +): Promise { + 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, + }); + const syncPlan = await prepareSyncDirectoryPlan( + options.repoRoot, + options.worktreePath, + options.config.syncDirs ?? [], + ); + const outcomes = await executeSyncDirectoryPlan(syncPlan, { + cloneDirectory: options.cloneDirectory ?? cloneDir, + repoRoot: options.repoRoot, + reporter: options.reporter, + }); + const clonedDirs = outcomes.flatMap((outcome) => + outcome.kind === "cloned" ? [outcome.directory] : [], + ); + const skippedDirs = outcomes.flatMap((outcome) => + outcome.kind === "skipped" + ? [{ dir: outcome.dir, reason: outcome.reason }] + : [], + ); + + const syncFileFailures: BootstrapEvent[] = []; + for (const pattern of options.config.syncFiles ?? []) { + try { + await syncFiles(options.repoRoot, options.worktreePath, [pattern]); + } catch (error) { + const message = `failed to sync file "${pattern}": ${toErrorMessage(error)}`; + if (!options.json) options.reporter.write(`Warning: ${message}\n`); + syncFileFailures.push({ + adapter: "syncFiles", + kind: "sync-file", + reason: "sync-file-failed", + state: "failed", + target: pattern, + message, + }); + } + } + + if (syncFileFailures.length > 0) { + for (const event of syncFileFailures) options.reporter.dependency(event); + } + const dependencyBootstrap = + syncFileFailures.length > 0 + ? { mode: dependencyMode, ready: false, events: [] } + : await executeDependencyBootstrap(dependencyPlan, { + cloneDirectory: options.cloneDirectory, + repoRoot: options.repoRoot, + reporter: options.reporter, + stderr: options.commandStderr ?? options.reporter.write, + stdout: options.commandStdout, + seededDirectories: clonedDirs.map(({ dir }) => dir), + runCommand: options.runCommand, + }); + + if (!dependencyBootstrap.ready) { + return { + clonedDirs, + dependencyBootstrap, + ready: false, + syncFileFailures, + skippedDirs, + }; + } + + if ( + dependencyMode === "off" && + ["default", "legacy"].includes( + options.dependencyBootstrapPolicy?.source ?? "default", + ) + ) { + await maybeRunInstallPrompt( + options.worktreePath, + options.repoRoot, + options.config, + options.reporter.write, + options.installDependencies, + options.nonInteractive, + ); + } + + const hooks = extractHooks(options.config); + await runHook( + hooks["after-create"], + options.worktreePath, + { + branch: options.branch, + path: options.worktreePath, + repo: basename(options.repoRoot), + }, + options.reporter.write, + options.json + ? () => undefined + : (options.commandStdout ?? ((chunk) => process.stdout.write(chunk))), + ); + + return { + clonedDirs, + dependencyBootstrap, + ready: true, + syncFileFailures: [], + skippedDirs, + }; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/website/docs/commands.mdx b/website/docs/commands.mdx index 335c25d..025147e 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 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 d0495a2..f28bb9e 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -23,8 +23,11 @@ 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` | 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. | +| `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. | @@ -35,7 +38,9 @@ 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": [".next"], + "dependencyBootstrap": "cow-then-repair" } ``` @@ -116,6 +121,26 @@ 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 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": [".next", ".cache"] +} +``` + +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. `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_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. + +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 ```bash diff --git a/website/docs/installation.mdx b/website/docs/installation.mdx index 94f1a83..a2b4ef4 100644 --- a/website/docs/installation.mdx +++ b/website/docs/installation.mdx @@ -63,6 +63,17 @@ 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": [".next"], + "dependencyBootstrap": "cow-then-repair" +} +``` + +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: ```json