diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f978513..aa2e3908 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: 'Build / Test / Artifacts' +name: 'Build & Test' on: push: branches: @@ -42,8 +42,8 @@ jobs: - name: Test run: pnpm test - artifacts: - name: Artifacts Upload + build: + name: Build needs: test runs-on: ubuntu-latest permissions: @@ -77,7 +77,7 @@ jobs: run: ./dist/craft --help - name: NPM Pack run: npm pack - - name: Archive Artifacts + - name: Upload Build Artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ github.sha }}-build @@ -111,7 +111,7 @@ jobs: run: | cp .nojekyll docs/dist/ cd docs/dist && zip -r ../../gh-pages.zip . - - name: Archive Docs Artifacts + - name: Upload Docs Artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ github.sha }}-docs @@ -120,9 +120,9 @@ jobs: merge-artifacts: name: Merge Artifacts needs: - - artifacts + - build - docs - if: always() && needs.artifacts.result == 'success' && needs.docs.result == 'success' + if: always() && needs.build.result == 'success' && needs.docs.result == 'success' runs-on: ubuntu-latest permissions: contents: read diff --git a/build.mjs b/build.mjs index 9b09a094..473ef07e 100644 --- a/build.mjs +++ b/build.mjs @@ -12,6 +12,7 @@ if (process.env.SENTRY_AUTH_TOKEN) { project: 'craft', authToken: process.env.SENTRY_AUTH_TOKEN, sourcemaps: { + assets: ['dist/craft', 'dist/craft.map'], filesToDeleteAfterUpload: ['dist/**/*.map'], }, }) diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index ff8540d8..2afef2c7 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -207,17 +207,32 @@ CRAFT_NO_INPUT=0 ### Dry-Run Mode -The `--dry-run` flag prevents destructive operations while still allowing reads: +The `--dry-run` flag lets you preview what would happen without making real changes. -**Blocked:** -- File writes (create, modify, delete) -- Git mutations (commit, push, checkout, merge, tag) -- GitHub API mutations (create release, upload assets) +**How it works:** -**Allowed:** -- Reading files and git history -- Fetching from GitHub API -- Git fetch and status checks +Craft creates a temporary git worktree where all local operations run normally (branch creation, file modifications, commits). At the end, it shows a diff of what would change: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Dry-run complete. Here's what would change: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Files changed: 2 + M CHANGELOG.md + M package.json + +diff --git a/CHANGELOG.md b/CHANGELOG.md +... +``` + +**What's blocked:** +- Git push (nothing leaves your machine) +- GitHub API mutations (no releases, uploads, or changes) + +**What's allowed:** +- All local operations (in a temporary worktree) +- Reading from GitHub API (requires `GITHUB_TOKEN`) :::note Dry-run still requires `GITHUB_TOKEN` for commands that fetch PR information from GitHub. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df59cab6..f992f69f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,7 +194,7 @@ importers: version: 8.50.1(eslint@9.39.2)(typescript@5.9.3) vitest: specifier: ^3.0.2 - version: 3.2.4(@types/node@22.19.1) + version: 3.2.4(@types/node@22.19.1)(tsx@4.21.0) yargs: specifier: ^18 version: 18.0.0 @@ -2227,6 +2227,9 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + git-up@8.1.1: resolution: {integrity: sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==} @@ -2763,6 +2766,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -3006,6 +3012,11 @@ packages: peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5055,13 +5066,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.19.1))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.19.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@22.19.1) + vite: 7.3.0(@types/node@22.19.1)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5670,6 +5681,11 @@ snapshots: dependencies: pump: 3.0.3 + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + optional: true + git-up@8.1.1: dependencies: is-ssh: 1.4.1 @@ -6165,6 +6181,9 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -6427,6 +6446,14 @@ snapshots: tslib: 1.14.1 typescript: 5.9.3 + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -6471,13 +6498,13 @@ snapshots: uuid@9.0.1: {} - vite-node@3.2.4(@types/node@22.19.1): + vite-node@3.2.4(@types/node@22.19.1)(tsx@4.21.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.0(@types/node@22.19.1) + vite: 7.3.0(@types/node@22.19.1)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -6492,7 +6519,7 @@ snapshots: - tsx - yaml - vite@7.3.0(@types/node@22.19.1): + vite@7.3.0(@types/node@22.19.1)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -6503,12 +6530,13 @@ snapshots: optionalDependencies: '@types/node': 22.19.1 fsevents: 2.3.3 + tsx: 4.21.0 - vitest@3.2.4(@types/node@22.19.1): + vitest@3.2.4(@types/node@22.19.1)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@22.19.1)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@22.19.1)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -6526,8 +6554,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@22.19.1) - vite-node: 3.2.4(@types/node@22.19.1) + vite: 7.3.0(@types/node@22.19.1)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.1 diff --git a/src/__tests__/__snapshots__/prepare-dry-run.e2e.test.ts.snap b/src/__tests__/__snapshots__/prepare-dry-run.e2e.test.ts.snap new file mode 100644 index 00000000..a4bdc689 --- /dev/null +++ b/src/__tests__/__snapshots__/prepare-dry-run.e2e.test.ts.snap @@ -0,0 +1,57 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`prepare --dry-run e2e > executes pre-release command and shows diff of changes > pre-release-diff 1`] = ` +" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Dry-run complete. Here's what would change: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Files changed: 1 + M package.json + +diff --git a/package.json b/package.json +index HASH..HASH 100644 +--- a/package.json ++++ b/package.json +@@ -X,Y +X,Y @@ + { + "name": "test-package", +- "version": "1.0.0" ++ "version": "1.0.1" + } +\\ No newline at end of file + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[dry-run] No actual changes were made to your repository. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[info] Checking the local repository status... +[info] Releasing version 1.0.1 from DEFAULT_BRANCH +[info] Preparing to release the version: 1.0.1 +[info] [dry-run] Creating temporary worktree at /tmp/craft-XXXXX +[info] Created a new release branch: "release/1.0.1" +[info] Switched to branch "release/1.0.1" +[info] Running the pre-release command... +[info] Pushing the release branch "release/1.0.1"... +[info] [dry-run] Would execute: git.push(origin release/1.0.1 ["--set-upstream"]) +[info] View diff at: https://github.com/test-owner/test-repo/compare/release/1.0.1 +[success] Done. Do not forget to run "craft publish" to publish the artifacts: $ craft publish 1.0.1 +" +`; + +exports[`prepare --dry-run e2e > produces consistent output format > dry-run-output 1`] = ` +"[info] Checking the local repository status... +[info] Releasing version 1.0.1 from DEFAULT_BRANCH +[info] Preparing to release the version: 1.0.1 +[info] [dry-run] Creating temporary worktree at /tmp/craft-XXXXX +[info] Created a new release branch: "release/1.0.1" +[info] Switched to branch "release/1.0.1" +[warn] Not running the pre-release command: no command specified +[info] +[dry-run] No changes would be made. +[info] Pushing the release branch "release/1.0.1"... +[info] [dry-run] Would execute: git.push(origin release/1.0.1 ["--set-upstream"]) +[info] View diff at: https://github.com/test-owner/test-repo/compare/release/1.0.1 +[success] Done. Do not forget to run "craft publish" to publish the artifacts: $ craft publish 1.0.1 +" +`; diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 3787200b..bf276232 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,12 +1,21 @@ -import { vi, describe, test, expect } from 'vitest'; -import { execFile } from 'child_process'; +import { describe, test, expect, beforeAll } from 'vitest'; +import { execFile, execSync } from 'child_process'; import { promisify } from 'util'; import { resolve } from 'path'; +import { existsSync } from 'fs'; const execFileAsync = promisify(execFile); -// Path to the TypeScript source file - we use ts-node to run it directly -const CLI_ENTRY = resolve(__dirname, '../index.ts'); +// Path to the built CLI binary - e2e tests should use the actual artifact +const CLI_BIN = resolve(__dirname, '../../dist/craft'); + +// Ensure the binary is built before running e2e tests +beforeAll(() => { + if (!existsSync(CLI_BIN)) { + console.log('Building craft binary for e2e tests...'); + execSync('pnpm build', { cwd: resolve(__dirname, '../..'), stdio: 'inherit' }); + } +}, 60000); describe('CLI smoke tests', () => { test('CLI starts and shows help without runtime errors', async () => { @@ -14,11 +23,9 @@ describe('CLI smoke tests', () => { // - Missing dependencies // - Syntax errors // - Runtime initialization errors (e.g., yargs singleton usage in v18) - const { stdout, stderr } = await execFileAsync( - 'npx', - ['ts-node', '--transpile-only', CLI_ENTRY, '--help'], - { env: { ...process.env, NODE_ENV: 'test' } } - ); + const { stdout, stderr } = await execFileAsync(CLI_BIN, ['--help'], { + env: { ...process.env, NODE_ENV: 'test' }, + }); expect(stdout).toMatch(//); expect(stdout).toContain('prepare NEW-VERSION'); @@ -30,11 +37,9 @@ describe('CLI smoke tests', () => { }, 30000); test('CLI shows version without errors', async () => { - const { stdout } = await execFileAsync( - 'npx', - ['ts-node', '--transpile-only', CLI_ENTRY, '--version'], - { env: { ...process.env, NODE_ENV: 'test' } } - ); + const { stdout } = await execFileAsync(CLI_BIN, ['--version'], { + env: { ...process.env, NODE_ENV: 'test' }, + }); // Version should be a semver-like string expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); @@ -43,11 +48,9 @@ describe('CLI smoke tests', () => { test('CLI exits with error for unknown command', async () => { // This ensures yargs command parsing works and async handlers are awaited await expect( - execFileAsync( - 'npx', - ['ts-node', '--transpile-only', CLI_ENTRY, 'nonexistent-command'], - { env: { ...process.env, NODE_ENV: 'test' } } - ) + execFileAsync(CLI_BIN, ['nonexistent-command'], { + env: { ...process.env, NODE_ENV: 'test' }, + }) ).rejects.toMatchObject({ code: 1, }); @@ -59,14 +62,10 @@ describe('CLI smoke tests', () => { // We expect it to fail due to missing config, but it should fail gracefully // not due to premature exit try { - await execFileAsync( - 'npx', - ['ts-node', '--transpile-only', CLI_ENTRY, 'targets'], - { - env: { ...process.env, NODE_ENV: 'test' }, - cwd: '/tmp', // No .craft.yml here - } - ); + await execFileAsync(CLI_BIN, ['targets'], { + env: { ...process.env, NODE_ENV: 'test' }, + cwd: '/tmp', // No .craft.yml here + }); } catch (error: any) { // Should fail with a config error, not a silent exit or unhandled promise expect(error.stderr || error.stdout).toMatch( diff --git a/src/__tests__/prepare-dry-run.e2e.test.ts b/src/__tests__/prepare-dry-run.e2e.test.ts new file mode 100644 index 00000000..8dc01913 --- /dev/null +++ b/src/__tests__/prepare-dry-run.e2e.test.ts @@ -0,0 +1,387 @@ +/** + * E2E tests for `craft prepare --dry-run` with worktree mode. + * + * These tests verify that: + * 1. Dry-run creates a worktree for isolated operations + * 2. Original repository working directory is not modified + * 3. Worktree is cleaned up after execution + */ +import { describe, test, expect, afterEach, beforeAll } from 'vitest'; +import { execFile, execSync } from 'child_process'; +import { promisify } from 'util'; +import { resolve, join } from 'path'; +import { mkdtemp, rm, writeFile, readFile, mkdir, chmod } from 'fs/promises'; +import { existsSync } from 'fs'; +import { tmpdir } from 'os'; +// eslint-disable-next-line no-restricted-imports, no-restricted-syntax -- Test file needs direct git access for setup/verification +import simpleGit from 'simple-git'; + +const execFileAsync = promisify(execFile); + +// Path to the built CLI binary - e2e tests use the actual artifact +const CLI_BIN = resolve(__dirname, '../../dist/craft'); + +// Ensure the binary is built before running e2e tests +beforeAll(() => { + if (!existsSync(CLI_BIN)) { + console.log('Building craft binary for e2e tests...'); + execSync('pnpm build', { cwd: resolve(__dirname, '../..'), stdio: 'inherit' }); + } +}, 60000); + +/** + * Creates a test git repository with: + * - Initial commit + * - A tag (1.0.0) + * - .craft.yml configuration + * - CHANGELOG.md file + */ +async function createTestRepo(): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'craft-e2e-')); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + + // Initialize git repo + await git.init(); + await git.addConfig('user.email', 'test@example.com'); + await git.addConfig('user.name', 'Test User'); + + // Create .craft.yml with explicit GitHub config + const craftConfig = ` +minVersion: "2.0.0" +github: + owner: test-owner + repo: test-repo +changelog: + policy: none +preReleaseCommand: "" +targets: [] +`; + await writeFile(join(tempDir, '.craft.yml'), craftConfig); + + // Create CHANGELOG.md + const changelog = `# Changelog + +## 1.0.0 + +- Initial release +`; + await writeFile(join(tempDir, 'CHANGELOG.md'), changelog); + + // Create package.json for version tracking + const packageJson = { + name: 'test-package', + version: '1.0.0', + }; + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify(packageJson, null, 2) + ); + + // Initial commit and tag + await git.add('.'); + await git.commit('Initial commit'); + await git.addTag('1.0.0'); + + // Add a feature commit + await writeFile(join(tempDir, 'feature.ts'), 'export const foo = 1;'); + await git.add('.'); + await git.commit('feat: Add foo feature'); + + // Add a fix commit + await writeFile(join(tempDir, 'fix.ts'), 'export const bar = 2;'); + await git.add('.'); + await git.commit('fix: Fix bar issue'); + + // Create a bare remote repo to satisfy git remote operations + const remoteDir = await mkdtemp(join(tmpdir(), 'craft-e2e-remote-')); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const remoteGit = simpleGit(remoteDir); + await remoteGit.init(true); // bare repo + await git.addRemote('origin', remoteDir); + // Push the main branch to set up tracking + const status = await git.status(); + await git.push('origin', status.current!, ['--set-upstream']); + + return tempDir; +} + +/** + * Normalizes output for snapshot comparison. + * Removes dynamic parts like commit hashes, timestamps, and paths. + */ +function normalizeOutput(output: string): string { + return ( + output + // Remove ANSI color codes + // eslint-disable-next-line no-control-regex -- Need to match ANSI escape sequences + .replace(/\x1b\[[0-9;]*m/g, '') + // Normalize temp directory paths + .replace(/\/tmp\/craft-[a-z0-9-]+/g, '/tmp/craft-XXXXX') + // Normalize commit hashes (7-40 hex chars) + .replace(/\b[a-f0-9]{7,40}\b/g, 'HASH') + // Normalize index lines in diffs + .replace(/index [a-f0-9]+\.\.[a-f0-9]+/g, 'index HASH..HASH') + // Normalize worktree paths in messages + .replace(/craft-dry-run-[a-f0-9]+/g, 'craft-dry-run-XXXXX') + // Normalize line counts that might vary + .replace(/@@ -\d+,\d+ \+\d+,\d+ @@/g, '@@ -X,Y +X,Y @@') + // Remove node warnings and experimental warnings + .replace(/\(node:\d+\)[^\n]*\n/g, '') + .replace(/\(Use `node --trace-warnings.*\n/g, '') + .replace(/\(Use `node --trace-deprecation.*\n/g, '') + .replace(/Support for loading ES Module.*\n/g, '') + // Normalize PID references + .replace(/node:\d+/g, 'node:PID') + // Normalize branch names (main vs master) + .replace(/from (main|master)/g, 'from DEFAULT_BRANCH') + ); +} + +describe('prepare --dry-run e2e', () => { + let tempDir: string; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test( + 'creates worktree, operates within it, and cleans up', + async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test verification needs direct git access + const git = simpleGit(tempDir); + + // Get state before + const statusBefore = await git.status(); + const logBefore = await git.log(); + const packageJsonBefore = await readFile( + join(tempDir, 'package.json'), + 'utf8' + ); + const changelogBefore = await readFile( + join(tempDir, 'CHANGELOG.md'), + 'utf8' + ); + + // Run prepare --dry-run + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '1.0.1', '--dry-run', '--no-input'], + { + cwd: tempDir, + env: { + ...process.env, + NODE_ENV: 'test', + GITHUB_TOKEN: 'test-token', + }, + } + ); + + const combinedOutput = stdout + stderr; + + // Verify worktree was created + expect(combinedOutput).toContain('[dry-run] Creating temporary worktree'); + // Verify release branch was created in worktree + expect(combinedOutput).toContain('release/1.0.1'); + // Verify push was blocked + expect(combinedOutput).toContain('[dry-run] Would execute'); + expect(combinedOutput).toContain('git.push'); + + // Verify original repo working directory is unchanged + const statusAfter = await git.status(); + const logAfter = await git.log(); + const packageJsonAfter = await readFile( + join(tempDir, 'package.json'), + 'utf8' + ); + const changelogAfter = await readFile( + join(tempDir, 'CHANGELOG.md'), + 'utf8' + ); + + // Same working directory status - no uncommitted changes + expect(statusAfter.files).toEqual(statusBefore.files); + // Same commit history in main branch + expect(logAfter.total).toEqual(logBefore.total); + // Files unchanged + expect(packageJsonAfter).toEqual(packageJsonBefore); + expect(changelogAfter).toEqual(changelogBefore); + + // Verify worktree is cleaned up (no leftover worktrees) + const worktrees = await git.raw(['worktree', 'list']); + const worktreeLines = worktrees.trim().split('\n'); + expect(worktreeLines.length).toBe(1); // Only the main worktree + + // Note: The release branch may still exist in refs because git worktrees + // share the same object store. What matters is that the working directory + // is unchanged and the worktree is cleaned up. + }, + 60000 + ); + + test( + 'produces consistent output format', + async () => { + tempDir = await createTestRepo(); + + // Run prepare --dry-run + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '1.0.1', '--dry-run', '--no-input'], + { + cwd: tempDir, + env: { + ...process.env, + NODE_ENV: 'test', + GITHUB_TOKEN: 'test-token', + }, + } + ); + + const combinedOutput = stdout + stderr; + + // Verify expected messages appear in order + expect(combinedOutput).toContain('Checking the local repository status'); + expect(combinedOutput).toContain('Releasing version 1.0.1'); + expect(combinedOutput).toContain('[dry-run] Creating temporary worktree'); + expect(combinedOutput).toContain('Created a new release branch'); + expect(combinedOutput).toContain('Pushing the release branch'); + expect(combinedOutput).toContain('[dry-run] Would execute'); + + // Snapshot the normalized output + const normalizedOutput = normalizeOutput(combinedOutput); + expect(normalizedOutput).toMatchSnapshot('dry-run-output'); + }, + 60000 + ); + + test( + 'executes pre-release command and shows diff of changes', + async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + + // Get the current branch name (could be 'main' or 'master') + const status = await git.status(); + const currentBranch = status.current!; + + // Create a version bump script + const scriptsDir = join(tempDir, 'scripts'); + await mkdir(scriptsDir, { recursive: true }); + const versionBumpScript = `#!/bin/bash +VERSION="$2" +# Update package.json version +sed -i 's/"version": "[^"]*"/"version": "'"$VERSION"'"/' package.json +`; + const scriptPath = join(scriptsDir, 'bump-version.sh'); + await writeFile(scriptPath, versionBumpScript); + await chmod(scriptPath, '755'); + + // Update .craft.yml with pre-release command + const craftConfig = ` +minVersion: "2.0.0" +github: + owner: test-owner + repo: test-repo +changelog: + policy: none +preReleaseCommand: bash scripts/bump-version.sh +targets: [] +`; + await writeFile(join(tempDir, '.craft.yml'), craftConfig); + await git.add('.'); + await git.commit('Add version bump script'); + await git.push('origin', currentBranch); + + // Get original package.json + const packageJsonBefore = await readFile( + join(tempDir, 'package.json'), + 'utf8' + ); + expect(packageJsonBefore).toContain('"version": "1.0.0"'); + + // Run prepare --dry-run + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '1.0.1', '--dry-run', '--no-input'], + { + cwd: tempDir, + env: { + ...process.env, + NODE_ENV: 'test', + GITHUB_TOKEN: 'test-token', + }, + } + ); + + const combinedOutput = stdout + stderr; + + // Verify pre-release command ran (should show "Running the pre-release command") + expect(combinedOutput).toContain('Running the pre-release command'); + // Should NOT say "Not spawning process" - the command should actually run + expect(combinedOutput).not.toContain('[dry-run] Not spawning process'); + + // Should show the diff with version change + expect(combinedOutput).toContain("Here's what would change"); + expect(combinedOutput).toContain('package.json'); + + // Original file should be unchanged + const packageJsonAfter = await readFile( + join(tempDir, 'package.json'), + 'utf8' + ); + expect(packageJsonAfter).toEqual(packageJsonBefore); + expect(packageJsonAfter).toContain('"version": "1.0.0"'); + + // Snapshot the diff output + const normalizedOutput = normalizeOutput(combinedOutput); + expect(normalizedOutput).toMatchSnapshot('pre-release-diff'); + }, + 60000 + ); + + test( + 'cleans up worktree even on error', + async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test verification needs direct git access + const git = simpleGit(tempDir); + + // Get the current branch name + const status = await git.status(); + const currentBranch = status.current; + + // Create the release branch locally to cause a conflict in the worktree + await git.checkoutLocalBranch('release/1.0.1'); + await git.checkout(currentBranch!); + + try { + await execFileAsync( + CLI_BIN, + ['prepare', '1.0.1', '--dry-run', '--no-input'], + { + cwd: tempDir, + env: { + ...process.env, + NODE_ENV: 'test', + GITHUB_TOKEN: 'test-token', + }, + } + ); + // If it doesn't throw, that's also fine (branch might be reused) + } catch { + // Expected to fail due to existing branch + } + + // Even after error, worktree should be cleaned up + const worktrees = await git.raw(['worktree', 'list']); + const worktreeLines = worktrees.trim().split('\n'); + expect(worktreeLines.length).toBe(1); + }, + 60000 + ); +}); diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 950b6957..8dd9af9f 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fsPromises } from 'fs'; import { join, relative } from 'path'; -import { safeFs } from '../utils/dryRun'; +import { safeFs, createDryRunIsolation } from '../utils/dryRun'; import * as shellQuote from 'shell-quote'; import { SimpleGit, StatusResult } from 'simple-git'; import { Arguments, Argv, CommandBuilder } from 'yargs'; @@ -620,7 +620,7 @@ async function resolveVersion( * @param argv Command-line arguments */ export async function prepareMain(argv: PrepareOptions): Promise { - const git = await getGitClient(); + let git = await getGitClient(); // Handle --config-from: load config from remote branch if (argv.configFrom) { @@ -671,84 +671,100 @@ export async function prepareMain(argv: PrepareOptions): Promise { logger.info(`Preparing to release the version: ${newVersion}`); - // Create a new release branch and check it out. Fail if it already exists. - const branchName = await createReleaseBranch( - git, - rev, - newVersion, - argv.remote, - config.releaseBranchPrefix - ); + // Create isolation context (worktree in dry-run mode, passthrough otherwise) + const isolation = await createDryRunIsolation(git, rev); + git = isolation.git; - // Do this once we are on the release branch as we might be releasing from - // a custom revision and it is harder to tell git to give us the tag right - // before a specific revision. - // TL;DR - WARNING: - // The order matters here, do not move this command above createReleaseBranch! - const oldVersion = await getLatestTag(git); - - // Check & update the changelog - // Extract changelog path from config (can be string or object) - const changelogPath = - typeof config.changelog === 'string' - ? config.changelog - : config.changelog?.filePath; - // Get policy from new format or legacy changelogPolicy - const changelogPolicy = ( - typeof config.changelog === 'object' && config.changelog?.policy - ? config.changelog.policy - : config.changelogPolicy - ) as ChangelogPolicy | undefined; - const changelogBody = await prepareChangelog( - git, - oldVersion, - newVersion, - argv.noChangelog ? ChangelogPolicy.None : changelogPolicy, - changelogPath - ); + try { + // Create a new release branch and check it out. Fail if it already exists. + const branchName = await createReleaseBranch( + git, + rev, + newVersion, + argv.remote, + config.releaseBranchPrefix + ); - // Run a pre-release script (e.g. for version bumping) - const preReleaseCommandRan = await runPreReleaseCommand( - oldVersion, - newVersion, - config.preReleaseCommand - ); + // Do this once we are on the release branch as we might be releasing from + // a custom revision and it is harder to tell git to give us the tag right + // before a specific revision. + // TL;DR - WARNING: + // The order matters here, do not move this command above createReleaseBranch! + const oldVersion = await getLatestTag(git); + + // Check & update the changelog + // Extract changelog path from config (can be string or object) + const changelogPath = + typeof config.changelog === 'string' + ? config.changelog + : config.changelog?.filePath; + // Get policy from new format or legacy changelogPolicy + const changelogPolicy = ( + typeof config.changelog === 'object' && config.changelog?.policy + ? config.changelog.policy + : config.changelogPolicy + ) as ChangelogPolicy | undefined; + const changelogBody = await prepareChangelog( + git, + oldVersion, + newVersion, + argv.noChangelog ? ChangelogPolicy.None : changelogPolicy, + changelogPath + ); - if (preReleaseCommandRan) { - // Commit the pending changes - await commitNewVersion(git, newVersion); - } else { - logger.debug('Not committing anything since preReleaseCommand is empty.'); - } + // Run a pre-release script (e.g. for version bumping) + const preReleaseCommandRan = await runPreReleaseCommand( + oldVersion, + newVersion, + config.preReleaseCommand + ); - // Push the release branch - await pushReleaseBranch(git, branchName, argv.remote, !argv.noPush); + if (preReleaseCommandRan) { + // Commit the pending changes + await commitNewVersion(git, newVersion); + } else { + logger.debug('Not committing anything since preReleaseCommand is empty.'); + } - // Emit GitHub Actions outputs for downstream steps - const releaseSha = await git.revparse(['HEAD']); - setGitHubActionsOutput('branch', branchName); - setGitHubActionsOutput('sha', releaseSha); - setGitHubActionsOutput('previous_tag', oldVersion || ''); - if (changelogBody) { - setGitHubActionsOutput('changelog', changelogBody); - } + // Show diff preview (no-op in non-dry-run mode) + await isolation.showDiff(); - logger.info( - `View diff at: https://github.com/${githubConfig.owner}/${githubConfig.repo}/compare/${branchName}` - ); + // Push the release branch (blocked in dry-run mode) + await pushReleaseBranch(git, branchName, argv.remote, !argv.noPush); - if (argv.publish) { - logger.success(`Release branch "${branchName}" has been pushed.`); - await execPublish(argv.remote, newVersion, argv.noGitChecks); - } else { - logger.success( - 'Done. Do not forget to run "craft publish" to publish the artifacts:', - ` $ craft publish ${newVersion}` + // Emit GitHub Actions outputs for downstream steps + const releaseSha = await git.revparse(['HEAD']); + setGitHubActionsOutput('branch', branchName); + setGitHubActionsOutput('sha', releaseSha); + setGitHubActionsOutput('previous_tag', oldVersion || ''); + if (changelogBody) { + setGitHubActionsOutput('changelog', changelogBody); + } + + logger.info( + `View diff at: https://github.com/${githubConfig.owner}/${githubConfig.repo}/compare/${branchName}` ); - } - if (!argv.rev) { - await switchToDefaultBranch(git, defaultBranch); + if (argv.publish) { + if (isolation.isIsolated) { + logger.info(`[dry-run] Would run: craft publish ${newVersion}`); + } else { + logger.success(`Release branch "${branchName}" has been pushed.`); + await execPublish(argv.remote, newVersion, argv.noGitChecks); + } + } else { + logger.success( + 'Done. Do not forget to run "craft publish" to publish the artifacts:', + ` $ craft publish ${newVersion}` + ); + } + + if (!argv.rev && !isolation.isIsolated) { + await switchToDefaultBranch(git, defaultBranch); + } + } finally { + // Clean up (no-op in non-dry-run mode) + await isolation.cleanup(); } } diff --git a/src/utils/__tests__/dryRun.test.ts b/src/utils/__tests__/dryRun.test.ts index e2b0815c..24f4f57b 100644 --- a/src/utils/__tests__/dryRun.test.ts +++ b/src/utils/__tests__/dryRun.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as helpers from '../helpers'; // Mock the helpers module to control isDryRun @@ -28,12 +28,22 @@ import { safeExec, safeExecSync, logDryRun, + enableWorktreeMode, + disableWorktreeMode, + isInWorktreeMode, } from '../dryRun'; import { logger } from '../../logger'; describe('dryRun utilities', () => { beforeEach(() => { vi.clearAllMocks(); + // Ensure worktree mode is disabled before each test + disableWorktreeMode(); + }); + + afterEach(() => { + // Clean up worktree mode after each test + disableWorktreeMode(); }); describe('logDryRun', () => { @@ -292,6 +302,18 @@ describe('dryRun utilities', () => { '[dry-run] Would execute: test action' ); }); + + it('executes action in dry-run mode when worktree mode is enabled', async () => { + vi.mocked(helpers.isDryRun).mockReturnValue(true); + enableWorktreeMode(); + const action = vi.fn().mockResolvedValue('result'); + + const result = await safeExec(action, 'test action'); + + expect(action).toHaveBeenCalled(); + expect(result).toBe('result'); + expect(logger.info).not.toHaveBeenCalled(); + }); }); describe('safeExecSync', () => { @@ -317,5 +339,102 @@ describe('dryRun utilities', () => { '[dry-run] Would execute: test action' ); }); + + it('executes action in dry-run mode when worktree mode is enabled', () => { + vi.mocked(helpers.isDryRun).mockReturnValue(true); + enableWorktreeMode(); + const action = vi.fn().mockReturnValue('result'); + + const result = safeExecSync(action, 'test action'); + + expect(action).toHaveBeenCalled(); + expect(result).toBe('result'); + expect(logger.info).not.toHaveBeenCalled(); + }); + }); + + describe('worktree mode', () => { + it('starts disabled', () => { + expect(isInWorktreeMode()).toBe(false); + }); + + it('can be enabled and disabled', () => { + enableWorktreeMode(); + expect(isInWorktreeMode()).toBe(true); + + disableWorktreeMode(); + expect(isInWorktreeMode()).toBe(false); + }); + + describe('git operations in worktree mode', () => { + const mockGit = { + push: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue({ commit: 'abc123' }), + checkout: vi.fn().mockResolvedValue(undefined), + add: vi.fn().mockResolvedValue(undefined), + status: vi.fn().mockResolvedValue({ current: 'main' }), + }; + + it('allows local git operations in worktree mode', async () => { + vi.mocked(helpers.isDryRun).mockReturnValue(true); + enableWorktreeMode(); + + const git = createDryRunGit(mockGit as any); + + // Local operations should be allowed + mockGit.commit.mockClear(); + await git.commit('test'); + expect(mockGit.commit).toHaveBeenCalledWith('test'); + + mockGit.checkout.mockClear(); + await git.checkout('test-branch'); + expect(mockGit.checkout).toHaveBeenCalledWith('test-branch'); + + mockGit.add.mockClear(); + await git.add(['.']); + expect(mockGit.add).toHaveBeenCalledWith(['.']); + }); + + it('still blocks remote git operations in worktree mode', async () => { + vi.mocked(helpers.isDryRun).mockReturnValue(true); + enableWorktreeMode(); + + const git = createDryRunGit(mockGit as any); + + // Push should still be blocked + mockGit.push.mockClear(); + await git.push(); + expect(mockGit.push).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('[dry-run]') + ); + }); + }); + + describe('fs operations in worktree mode', () => { + it('allows file operations in worktree mode', async () => { + vi.mocked(helpers.isDryRun).mockReturnValue(true); + enableWorktreeMode(); + + // In worktree mode, safeFs should not block or log + vi.mocked(logger.info).mockClear(); + await safeFs.writeFile('/tmp/test.txt', 'content'); + + // Should NOT have logged a dry-run message (operation is allowed) + expect(logger.info).not.toHaveBeenCalledWith( + expect.stringContaining('[dry-run] Would execute: fs.writeFile') + ); + }); + + it('blocks file operations in strict dry-run mode', async () => { + vi.mocked(helpers.isDryRun).mockReturnValue(true); + disableWorktreeMode(); + + await safeFs.writeFile('/tmp/test.txt', 'content'); + expect(logger.info).toHaveBeenCalledWith( + '[dry-run] Would execute: fs.writeFile(/tmp/test.txt)' + ); + }); + }); }); }); diff --git a/src/utils/dryRun.ts b/src/utils/dryRun.ts index 36d7dfe6..a4c43286 100644 --- a/src/utils/dryRun.ts +++ b/src/utils/dryRun.ts @@ -4,15 +4,69 @@ * This module provides Proxy-wrapped versions of external libraries/APIs that * automatically respect the --dry-run flag. Instead of checking isDryRun() in * every function, use these wrapped versions which intercept mutating operations. + * + * Dry-run has two modes: + * 1. Worktree mode: Operations run in a temp worktree, only remote ops are blocked + * 2. Strict mode: All mutating operations are blocked (fallback) + * + * For commands that need to preview changes (like `prepare`), use `createDryRunIsolation()` + * which provides a unified interface for worktree-based dry-run with automatic cleanup. */ import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; -import type { SimpleGit } from 'simple-git'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; +import { existsSync, symlinkSync, lstatSync } from 'fs'; +import { rm } from 'fs/promises'; + +import simpleGit, { type SimpleGit } from 'simple-git'; import type { Octokit } from '@octokit/rest'; import { logger } from '../logger'; import { isDryRun } from './helpers'; +// ============================================================================ +// Worktree Mode Context (Internal State) +// ============================================================================ + +/** + * When true, we're running in a worktree and local operations are allowed. + * Only remote operations (push, GitHub API mutations) are blocked. + */ +let _inWorktreeMode = false; + +/** + * Enable worktree mode for dry-run. + * In this mode, local git and fs operations are allowed since they happen + * in a temporary worktree that will be cleaned up. + * + * @internal This is managed by createDryRunIsolation() and exposed for testing. + */ +export function enableWorktreeMode(): void { + _inWorktreeMode = true; + logger.debug('[dry-run] Worktree mode enabled - local operations allowed'); +} + +/** + * Disable worktree mode and return to strict dry-run. + * + * @internal This is managed by createDryRunIsolation() and exposed for testing. + */ +export function disableWorktreeMode(): void { + _inWorktreeMode = false; + logger.debug('[dry-run] Worktree mode disabled'); +} + +/** + * Check if we're currently in worktree mode. + * This is used by other modules (system.ts, etc.) to determine if + * operations should be allowed in dry-run mode. + */ +export function isInWorktreeMode(): boolean { + return _inWorktreeMode; +} + /** * Log a dry-run message with consistent formatting. */ @@ -20,18 +74,297 @@ export function logDryRun(operation: string): void { logger.info(`[dry-run] Would execute: ${operation}`); } +// ============================================================================ +// Dry-Run Isolation (Worktree-based) +// ============================================================================ + +/** + * Represents an isolated dry-run environment. + * + * This interface provides a clean way to run operations in a temporary + * worktree without affecting the user's repository. All local operations + * (file writes, git commits) happen in the isolated environment, while + * remote operations (push, GitHub API) are blocked. + */ +export interface DryRunIsolation { + /** Path to the temporary worktree directory */ + worktreePath: string; + /** Original working directory before switching to worktree */ + originalCwd: string; + /** Git client for the worktree */ + git: SimpleGit; + /** Whether operating in an isolated worktree (true in dry-run, false in real mode) */ + isIsolated: boolean; + /** Shows a formatted diff of changes made in the worktree */ + showDiff(): Promise; + /** Cleans up the worktree and restores original state */ + cleanup(): Promise; +} + +// Track active worktree for cleanup on unexpected exit +let _activeWorktreeCleanup: (() => Promise) | null = null; + +// Store references to our handlers so we can remove only them (not Sentry's handlers) +let _sigintHandler: (() => void) | null = null; +let _sigtermHandler: (() => void) | null = null; + +/** + * Register signal handlers for cleanup on Ctrl+C or unexpected exit. + */ +function registerCleanupHandlers(cleanup: () => Promise): void { + _activeWorktreeCleanup = cleanup; + + const handleSignal = async (signal: string): Promise => { + if (_activeWorktreeCleanup) { + logger.info(`\n[dry-run] Received ${signal}, cleaning up worktree...`); + try { + await _activeWorktreeCleanup(); + } catch (err) { + logger.debug(`[dry-run] Cleanup error: ${err}`); + } + _activeWorktreeCleanup = null; + } + // Note: We don't call process.exit() here. Instead, we let the signal + // propagate to other handlers (like Sentry's) and allow Node.js to + // terminate naturally after all handlers complete. This gives Sentry + // time to flush telemetry data. + }; + + // Create wrapper functions so we can remove them specifically later + _sigintHandler = () => { + handleSignal('SIGINT'); + }; + _sigtermHandler = () => { + handleSignal('SIGTERM'); + }; + + process.once('SIGINT', _sigintHandler); + process.once('SIGTERM', _sigtermHandler); +} + +/** + * Unregister cleanup handlers after normal cleanup. + * Only removes our handlers, preserving other signal handlers (e.g., Sentry's). + */ +function unregisterCleanupHandlers(): void { + _activeWorktreeCleanup = null; + if (_sigintHandler) { + process.removeListener('SIGINT', _sigintHandler); + _sigintHandler = null; + } + if (_sigtermHandler) { + process.removeListener('SIGTERM', _sigtermHandler); + _sigtermHandler = null; + } +} + +/** + * Directories that should be symlinked from the original repo to the worktree. + */ +const SYMLINK_DIRS = [ + 'node_modules', + 'vendor', + '.venv', + 'venv', + '__pycache__', + '.gradle', + 'build', + 'target', + 'Pods', +]; + +/** + * Symlinks dependency directories from the original repo to the worktree. + */ +function symlinkDependencyDirs( + originalCwd: string, + worktreePath: string +): void { + for (const dir of SYMLINK_DIRS) { + const srcPath = join(originalCwd, dir); + const destPath = join(worktreePath, dir); + + if (existsSync(srcPath) && lstatSync(srcPath).isDirectory()) { + try { + symlinkSync(srcPath, destPath); + logger.debug(`[dry-run] Symlinked ${dir} to worktree`); + } catch (err) { + logger.debug(`[dry-run] Could not symlink ${dir}: ${err}`); + } + } + } +} + +/** + * Creates an isolated dry-run environment using a git worktree. + * + * This function: + * 1. In non-dry-run mode: returns a passthrough object (no-op methods, original git) + * 2. In dry-run mode: creates a temporary git worktree at /tmp/craft-dry-run-* + * 3. Symlinks dependency directories (node_modules, etc.) + * 4. Enables worktree mode so local operations are allowed + * 5. Changes process.cwd() to the worktree + * 6. Returns an isolation object with cleanup and diff methods + * + * Usage: + * ```typescript + * const isolation = await createDryRunIsolation(git, rev); + * git = isolation.git; + * try { + * // Use isolation.git for git operations + * // All local changes happen in isolation.worktreePath (or real repo in non-dry-run) + * await isolation.showDiff(); + * } finally { + * await isolation.cleanup(); + * } + * ``` + * + * @param git Git client for the original repository + * @param rev Revision to base the worktree on (defaults to HEAD) + * @returns DryRunIsolation object (passthrough in non-dry-run mode) + */ +export async function createDryRunIsolation( + git: SimpleGit, + rev?: string +): Promise { + // If not in dry-run mode, return a passthrough that does nothing + if (!isDryRun()) { + return { + worktreePath: process.cwd(), + originalCwd: process.cwd(), + git, // Use original git client + isIsolated: false, // Not in isolated worktree + showDiff: async () => {}, // No-op + cleanup: async () => {}, // No-op + }; + } + + const originalCwd = process.cwd(); + const originalHead = (await git.revparse(['HEAD'])).trim(); + const revision = rev || originalHead; + + // Generate a unique temp directory name + const randomSuffix = randomBytes(8).toString('hex'); + const worktreePath = join(tmpdir(), `craft-dry-run-${randomSuffix}`); + + logger.info(`[dry-run] Creating temporary worktree at ${worktreePath}`); + + // Create the worktree using raw git (bypassing dry-run proxy) + // eslint-disable-next-line no-restricted-syntax -- This is the wrapper module + const rawGit = simpleGit(originalCwd); + await rawGit.raw(['worktree', 'add', '--detach', worktreePath, revision]); + + // Symlink large directories + symlinkDependencyDirs(originalCwd, worktreePath); + + // Create git client for worktree + // eslint-disable-next-line no-restricted-syntax -- This is the wrapper module + const worktreeGit = createDryRunGit(simpleGit(worktreePath)); + + // Enable worktree mode so local operations are allowed + enableWorktreeMode(); + + // Change to worktree directory + process.chdir(worktreePath); + + const cleanup = async (): Promise => { + logger.debug(`[dry-run] Cleaning up temporary worktree at ${worktreePath}`); + unregisterCleanupHandlers(); + disableWorktreeMode(); + process.chdir(originalCwd); + + try { + // eslint-disable-next-line no-restricted-syntax -- This is the wrapper module + const cleanupGit = simpleGit(originalCwd); + await cleanupGit.raw(['worktree', 'remove', '--force', worktreePath]); + } catch (err) { + logger.debug( + `[dry-run] Git worktree remove failed, cleaning up manually: ${err}` + ); + try { + await rm(worktreePath, { recursive: true, force: true }); + // eslint-disable-next-line no-restricted-syntax -- This is the wrapper module + const pruneGit = simpleGit(originalCwd); + await pruneGit.raw(['worktree', 'prune']); + } catch (rmErr) { + logger.warn(`[dry-run] Failed to clean up worktree: ${rmErr}`); + } + } + }; + + // Register signal handlers for cleanup on Ctrl+C + registerCleanupHandlers(cleanup); + + const showDiff = async (): Promise => { + // eslint-disable-next-line no-restricted-syntax -- This is the wrapper module + const diffGit = simpleGit(worktreePath); + const worktreeHead = (await diffGit.revparse(['HEAD'])).trim(); + const diffSummary = await diffGit.diffSummary([originalHead, worktreeHead]); + + if (diffSummary.files.length === 0) { + logger.info('\n[dry-run] No changes would be made.'); + return; + } + + console.log('\n' + '━'.repeat(70)); + console.log(" Dry-run complete. Here's what would change:"); + console.log('━'.repeat(70) + '\n'); + + console.log(`Files changed: ${diffSummary.files.length}`); + for (const file of diffSummary.files) { + const status = file.binary + ? 'B' + : file.insertions > 0 && file.deletions > 0 + ? 'M' + : file.insertions > 0 + ? 'A' + : 'D'; + console.log(` ${status} ${file.file}`); + } + console.log(''); + + const diff = await diffGit.diff([ + originalHead, + worktreeHead, + '--color=always', + ]); + if (diff) { + console.log(diff); + } + + console.log('━'.repeat(70)); + console.log('[dry-run] No actual changes were made to your repository.'); + console.log('━'.repeat(70) + '\n'); + }; + + return { + worktreePath, + originalCwd, + git: worktreeGit, + isIsolated: true, // Operating in isolated worktree + showDiff, + cleanup, + }; +} + // ============================================================================ // Git Proxy // ============================================================================ /** - * Git methods that modify state and should be blocked in dry-run mode. + * Git methods that affect remote state and should ALWAYS be blocked in dry-run. + */ +const GIT_REMOTE_METHODS = new Set(['push']); + +/** + * Git methods that modify local state only. + * Blocked in strict dry-run, but allowed in worktree mode. */ -const GIT_MUTATING_METHODS = new Set([ - 'push', +const GIT_LOCAL_METHODS = new Set([ 'commit', 'checkout', 'checkoutBranch', + 'checkoutLocalBranch', 'merge', 'branch', 'addTag', @@ -42,15 +375,17 @@ const GIT_MUTATING_METHODS = new Set([ 'revert', 'stash', 'tag', - // Note: 'clone' is intentionally NOT included - it creates a local copy - // which is safe to do in dry-run mode and needed for subsequent operations ]); /** - * Git raw commands that modify state and should be blocked in dry-run mode. + * Git raw commands that affect remote state - always blocked. + */ +const GIT_RAW_REMOTE_COMMANDS = new Set(['push']); + +/** + * Git raw commands that modify local state only. */ -const GIT_RAW_MUTATING_COMMANDS = new Set([ - 'push', +const GIT_RAW_LOCAL_COMMANDS = new Set([ 'commit', 'checkout', 'merge', @@ -62,23 +397,41 @@ const GIT_RAW_MUTATING_COMMANDS = new Set([ 'stash', 'branch', 'pull', - // Note: 'clone' is intentionally NOT included - it creates a local copy - // which is safe to do in dry-run mode and needed for subsequent operations ]); -// WeakMap to cache wrapped git instances, avoiding recreation on chaining +/** + * Check if a git method should be blocked based on current mode. + */ +function shouldBlockGitMethod(method: string): boolean { + if (GIT_REMOTE_METHODS.has(method)) { + return true; + } + if (GIT_LOCAL_METHODS.has(method)) { + return !isInWorktreeMode(); + } + return false; +} + +/** + * Check if a git raw command should be blocked based on current mode. + */ +function shouldBlockGitRawCommand(command: string): boolean { + if (GIT_RAW_REMOTE_COMMANDS.has(command)) { + return true; + } + if (GIT_RAW_LOCAL_COMMANDS.has(command)) { + return !isInWorktreeMode(); + } + return false; +} + +// WeakMap to cache wrapped git instances const gitProxyCache = new WeakMap(); /** * Mock results for git methods that return data structures consumers access. - * Methods not listed here will return the proxy for chaining compatibility. - * - * IMPORTANT: Only add methods here if their return value properties are actually - * accessed in the codebase. Methods used in chains (like pull, push, branch) - * should NOT be listed here, as returning a mock object breaks chaining. */ const GIT_MOCK_RESULTS: Record = { - // commit: Used in upm.ts where commitResult.commit is accessed commit: { commit: 'dry-run-commit-hash', author: null, @@ -86,8 +439,6 @@ const GIT_MOCK_RESULTS: Record = { root: false, summary: { changes: 0, insertions: 0, deletions: 0 }, }, - // NOTE: pull and push are intentionally NOT included here because they are - // used in method chains like git.pull().merge().push() in publish.ts }; /** @@ -100,7 +451,6 @@ const GIT_MOCK_RESULTS: Record = { * @returns A proxied SimpleGit that respects dry-run mode */ export function createDryRunGit(git: SimpleGit): SimpleGit { - // Return cached proxy if we've already wrapped this instance const cached = gitProxyCache.get(git); if (cached) { return cached; @@ -110,53 +460,39 @@ export function createDryRunGit(git: SimpleGit): SimpleGit { get(target, prop: string) { const value = target[prop as keyof SimpleGit]; - // If it's not a function, return as-is if (typeof value !== 'function') { return value; } - // Handle the special 'raw' method if (prop === 'raw') { return function (...args: string[]) { const command = args[0]; - if (isDryRun() && GIT_RAW_MUTATING_COMMANDS.has(command)) { + if (isDryRun() && shouldBlockGitRawCommand(command)) { logDryRun(`git ${args.join(' ')}`); - // Return a resolved promise for async compatibility return Promise.resolve(''); } return value.apply(target, args); }; } - // Check if this is a mutating method - if (GIT_MUTATING_METHODS.has(prop)) { + if (isDryRun() && shouldBlockGitMethod(prop)) { return function (...args: unknown[]) { - if (isDryRun()) { - const argsStr = args - .map(a => (typeof a === 'string' ? a : JSON.stringify(a))) - .join(' '); - logDryRun(`git.${prop}(${argsStr})`); - // Return a mock result if the method's return value is accessed, - // otherwise return the proxy directly for chaining compatibility. - // SimpleGit is thenable, so `await proxy` works correctly. - const mockResult = GIT_MOCK_RESULTS[prop]; - if (mockResult) { - return Promise.resolve(mockResult); - } - // Return proxy directly (not wrapped in Promise) to support - // chaining like git.pull().merge().push() - return proxy; + const argsStr = args + .map(a => (typeof a === 'string' ? a : JSON.stringify(a))) + .join(' '); + logDryRun(`git.${prop}(${argsStr})`); + const mockResult = GIT_MOCK_RESULTS[prop]; + if (mockResult) { + return Promise.resolve(mockResult); } - return value.apply(target, args); + return proxy; }; } - // For non-mutating methods, bind and return return value.bind(target); }, }); - // Cache the proxy for this git instance gitProxyCache.set(git, proxy); return proxy; } @@ -190,7 +526,6 @@ function isGitHubMutatingMethod(methodName: string): boolean { /** * Creates a recursive proxy that intercepts GitHub API calls. - * Handles nested namespaces like github.repos.createRelease(). */ function createGitHubNamespaceProxy( target: Record, @@ -200,28 +535,23 @@ function createGitHubNamespaceProxy( get(obj, prop: string) { const value = obj[prop]; - // Skip non-existent properties and symbols if (value === undefined || typeof prop === 'symbol') { return value; } const currentPath = [...path, prop]; - // If it's a function, potentially intercept it if (typeof value === 'function') { return function (...args: unknown[]) { if (isDryRun() && isGitHubMutatingMethod(prop)) { const pathStr = currentPath.join('.'); logDryRun(`github.${pathStr}(...)`); - // Return a mock response for compatibility - // status: 0 ensures status-based checks (e.g., === 204) fail gracefully return Promise.resolve({ data: {}, status: 0 }); } return (value as (...a: unknown[]) => unknown).apply(obj, args); }; } - // If it's an object (namespace), recursively proxy it if (typeof value === 'object' && value !== null) { return createGitHubNamespaceProxy( value as Record, @@ -255,10 +585,8 @@ export function createDryRunOctokit(octokit: Octokit): Octokit { /** * File system methods that modify state and should be blocked in dry-run mode. - * Maps method names to the number of path arguments to include in the log. */ const FS_MUTATING_METHODS: Record = { - // Single path methods writeFile: 1, writeFileSync: 1, unlink: 1, @@ -277,7 +605,6 @@ const FS_MUTATING_METHODS: Record = { chownSync: 1, truncate: 1, truncateSync: 1, - // Two path methods (source, dest) rename: 2, renameSync: 2, copyFile: 2, @@ -290,7 +617,6 @@ const FS_MUTATING_METHODS: Record = { /** * Creates a proxy handler for file system modules. - * Intercepts mutating operations and blocks them in dry-run mode. */ function createFsProxyHandler( isAsync: boolean @@ -299,26 +625,22 @@ function createFsProxyHandler( get(target, prop: string) { const value = target[prop as keyof typeof target]; - // If it's not a function, return as-is if (typeof value !== 'function') { return value; } - // Check if this is a mutating method const pathArgCount = FS_MUTATING_METHODS[prop]; if (pathArgCount !== undefined) { return function (...args: unknown[]) { - if (isDryRun()) { + if (isDryRun() && !isInWorktreeMode()) { const paths = args.slice(0, pathArgCount).join(', '); logDryRun(`fs.${prop}(${paths})`); - // Return appropriate value for async vs sync return isAsync ? Promise.resolve(undefined) : undefined; } return (value as (...a: unknown[]) => unknown).apply(target, args); }; } - // For non-mutating methods, bind and return return (value as (...a: unknown[]) => unknown).bind(target); }, }; @@ -326,9 +648,6 @@ function createFsProxyHandler( /** * Dry-run-aware file system operations (async). - * - * Write operations are blocked and logged in dry-run mode. - * Read operations always execute normally. */ export const safeFsPromises = new Proxy( fsPromises, @@ -337,9 +656,6 @@ export const safeFsPromises = new Proxy( /** * Dry-run-aware file system operations (sync). - * - * Write operations are blocked and logged in dry-run mode. - * Read operations always execute normally. */ export const safeFsSync = new Proxy( fs, @@ -348,10 +664,8 @@ export const safeFsSync = new Proxy( /** * Convenience object that provides the most commonly used fs operations. - * Combines async and sync methods in one object for backwards compatibility. */ export const safeFs = { - // Async methods (from fs/promises) writeFile: safeFsPromises.writeFile.bind(safeFsPromises), unlink: safeFsPromises.unlink.bind(safeFsPromises), rename: safeFsPromises.rename.bind(safeFsPromises), @@ -360,7 +674,6 @@ export const safeFs = { appendFile: safeFsPromises.appendFile.bind(safeFsPromises), copyFile: safeFsPromises.copyFile.bind(safeFsPromises), - // Sync methods (from fs) writeFileSync: safeFsSync.writeFileSync.bind(safeFsSync), unlinkSync: safeFsSync.unlinkSync.bind(safeFsSync), renameSync: safeFsSync.renameSync.bind(safeFsSync), @@ -375,20 +688,17 @@ export const safeFs = { // ============================================================================ /** - * Execute an action only if not in dry-run mode. - * - * This is useful for wrapping arbitrary async operations that don't fit - * into the git/github/fs categories. + * Execute an action only if not in dry-run mode, or if in worktree mode. * * @param action The action to execute * @param description Human-readable description for dry-run logging - * @returns The result of the action, or undefined in dry-run mode + * @returns The result of the action, or undefined in strict dry-run mode */ export async function safeExec( action: () => Promise, description: string ): Promise { - if (isDryRun()) { + if (isDryRun() && !isInWorktreeMode()) { logDryRun(description); return undefined; } @@ -396,17 +706,17 @@ export async function safeExec( } /** - * Execute a synchronous action only if not in dry-run mode. + * Execute a synchronous action only if not in dry-run mode, or if in worktree mode. * * @param action The action to execute * @param description Human-readable description for dry-run logging - * @returns The result of the action, or undefined in dry-run mode + * @returns The result of the action, or undefined in strict dry-run mode */ export function safeExecSync( action: () => T, description: string ): T | undefined { - if (isDryRun()) { + if (isDryRun() && !isInWorktreeMode()) { logDryRun(description); return undefined; } diff --git a/src/utils/system.ts b/src/utils/system.ts index 4ef1f10d..3de18fef 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -10,6 +10,7 @@ import { logger } from '../logger'; import { reportError } from './errors'; import { isDryRun } from './helpers'; +import { isInWorktreeMode } from './dryRun'; /** * Types of supported hashing algorithms @@ -134,7 +135,8 @@ export async function spawnProcess( ): Promise { const argsString = args.map(arg => `"${arg}"`).join(' '); - if (isDryRun() && !spawnProcessOptions.enableInDryRunMode) { + // Allow spawning in worktree mode (isolated environment) or when explicitly enabled + if (isDryRun() && !spawnProcessOptions.enableInDryRunMode && !isInWorktreeMode()) { logger.info('[dry-run] Not spawning process:', `${command} ${argsString}`); return undefined; } diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts