diff --git a/package.json b/package.json index 3a9a788..e1905b5 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "prepack": "pnpm build && clean-pkg-json" }, "dependencies": { + "fast-glob": "^3.3.3", "gunzip-maybe": "^1.4.2", "tar-fs": "^3.1.1", "tasuku": "^2.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0de687f..19f9043 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 gunzip-maybe: specifier: ^1.4.2 version: 1.4.2 diff --git a/src/index.ts b/src/index.ts index da4c6ac..bcf976d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,8 @@ const { stringify } = JSON; const localTemporaryBranch = `git-publish-${Date.now()}-${process.pid}`; const temporaryDirectory = path.join(os.tmpdir(), 'git-publish', localTemporaryBranch); - const worktreePath = path.join(temporaryDirectory, 'worktree'); + const publishWorktreePath = path.join(temporaryDirectory, 'publish-worktree'); + const packWorktreePath = path.join(temporaryDirectory, 'pack-worktree'); const packTemporaryDirectory = path.join(temporaryDirectory, 'pack'); let success = false; @@ -115,7 +116,7 @@ const { stringify } = JSON; let commitSha: string; const packageManager = await detectPackageManager(cwd, gitRootPath); - const creatingWorkTree = await task('Creating worktree', async ({ setWarning }) => { + const creatingWorktrees = await task('Creating worktrees', async ({ setWarning }) => { if (dry) { setWarning(''); return; @@ -123,11 +124,15 @@ const { stringify } = JSON; // TODO: maybe delete all worktrees starting with `git-publish-`? - await spawn('git', ['worktree', 'add', '--force', worktreePath, 'HEAD']); + // Create publish worktree + await spawn('git', ['worktree', 'add', '--force', publishWorktreePath, 'HEAD']); + + // Create pack worktree for isolated pack execution + await spawn('git', ['worktree', 'add', '--force', packWorktreePath, 'HEAD']); }); if (!dry) { - creatingWorkTree.clear(); + creatingWorktrees.clear(); } try { @@ -146,7 +151,7 @@ const { stringify } = JSON; '--depth=1', remote, `${publishBranch}:${localTemporaryBranch}`, - ], { cwd: worktreePath }).catch(error => error as SubprocessError); + ], { cwd: publishWorktreePath }).catch(error => error as SubprocessError); // If fetch fails, remote branch doesnt exist yet, so fallback to orphan orphan = 'exitCode' in fetchResult; @@ -154,19 +159,19 @@ const { stringify } = JSON; if (orphan) { // Fresh orphan branch with no history - await spawn('git', ['checkout', '--orphan', localTemporaryBranch], { cwd: worktreePath }); + await spawn('git', ['checkout', '--orphan', localTemporaryBranch], { cwd: publishWorktreePath }); } else { // Repoint HEAD to the fetched branch without checkout - await spawn('git', ['symbolic-ref', 'HEAD', `refs/heads/${localTemporaryBranch}`], { cwd: worktreePath }); + await spawn('git', ['symbolic-ref', 'HEAD', `refs/heads/${localTemporaryBranch}`], { cwd: publishWorktreePath }); } // Remove all files from index and working directory // removes tracked files from index (.catch() since it fails on empty orphan branches) - await spawn('git', ['rm', '--cached', '-r', ':/'], { cwd: worktreePath }).catch(() => {}); + await spawn('git', ['rm', '--cached', '-r', ':/'], { cwd: publishWorktreePath }).catch(() => {}); // removes all untracked files from the working directory - await spawn('git', ['clean', '-fdx'], { cwd: worktreePath }); + await spawn('git', ['clean', '-fdx'], { cwd: publishWorktreePath }); }); if (!dry) { @@ -181,11 +186,14 @@ const { stringify } = JSON; const tarballPath = await packPackage( packageManager, - cwd, + packWorktreePath, packTemporaryDirectory, + cwd, + gitRootPath, + gitSubdirectory, ); - return await extractTarball(tarballPath, worktreePath); + return await extractTarball(tarballPath, publishWorktreePath); }); if (!dry) { @@ -198,7 +206,7 @@ const { stringify } = JSON; return; } - await spawn('git', ['add', '-A'], { cwd: worktreePath }); + await spawn('git', ['add', '-A'], { cwd: publishWorktreePath }); const publishFiles = await packTask.result; if (!publishFiles || publishFiles.length === 0) { @@ -211,7 +219,7 @@ const { stringify } = JSON; console.log(publishFiles.map(({ file, size }) => `${file} ${dim(byteSize(size).toString())}`).join('\n')); console.log(`\n${lightBlue('Total size')}`, byteSize(totalSize).toString()); - const trackedFiles = await gitStatusTracked({ cwd: worktreePath }); + const trackedFiles = await gitStatusTracked({ cwd: publishWorktreePath }); if (trackedFiles.length === 0) { console.warn('⚠️ No new changes found to commit.'); } else { @@ -233,11 +241,11 @@ const { stringify } = JSON; commitMessage, '--author=git-publish ', ], - { cwd: worktreePath }, + { cwd: publishWorktreePath }, ); } - commitSha = (await getCurrentCommit({ cwd: worktreePath }))!; + commitSha = (await getCurrentCommit({ cwd: publishWorktreePath }))!; }); if (!dry) { @@ -258,7 +266,7 @@ const { stringify } = JSON; '--no-verify', remote, `HEAD:${publishBranch}`, - ], { cwd: worktreePath }); + ], { cwd: publishWorktreePath }); success = true; }, ); @@ -273,7 +281,8 @@ const { stringify } = JSON; return; } - await spawn('git', ['worktree', 'remove', '--force', worktreePath]); + await spawn('git', ['worktree', 'remove', '--force', publishWorktreePath]); + await spawn('git', ['worktree', 'remove', '--force', packWorktreePath]); await spawn('git', ['branch', '-D', localTemporaryBranch]); await fs.rm(temporaryDirectory, { recursive: true, diff --git a/src/utils/pack-package.ts b/src/utils/pack-package.ts index e4d29e9..c4f3f59 100644 --- a/src/utils/pack-package.ts +++ b/src/utils/pack-package.ts @@ -1,23 +1,154 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import spawn from 'nano-spawn'; +import glob from 'fast-glob'; import type { PackageManager } from './detect-package-manager.js'; +import { readJson } from './read-json.js'; + +const copyFile = async (source: string, destination: string): Promise => { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(source, destination); +}; + +const isNotEnoent = (error: unknown): boolean => ( + typeof error === 'object' + && error !== null + && 'code' in error + && error.code !== 'ENOENT' +); export const packPackage = async ( packageManager: PackageManager, - cwd: string, + packWorktreePath: string, packDestinationDirectory: string, + cwd: string, + gitRootPath: string, + gitSubdirectory: string, ): Promise => { - // Create temp directory for pack + // Create temp directory for pack output await fs.mkdir(packDestinationDirectory, { recursive: true }); + // Determine if this is a monorepo package (in a subdirectory) + const isMonorepo = gitSubdirectory.length > 0; + + // Copy gitignored files from user's directory if they're specified in files field + // This handles cases where dist/ or other build artifacts are gitignored but need to be packed + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJson = await readJson(packageJsonPath) as { files?: string[] }; + + if (packageJson.files && packageJson.files.length > 0) { + const packWorktreePackageRoot = isMonorepo + ? path.join(packWorktreePath, gitSubdirectory) + : packWorktreePath; + + // Transform directory entries to glob patterns + // npm/pnpm treat 'dist' as 'dist/**', but fast-glob needs explicit patterns + const patterns = await Promise.all( + packageJson.files.map(async (entry) => { + const fullPath = path.join(cwd, entry); + try { + const stats = await fs.stat(fullPath); + // If it's a directory, expand to recursive pattern + return stats.isDirectory() ? `${entry}/**` : entry; + } catch (error: unknown) { + // Only catch ENOENT (file not found) - treat as glob pattern + // Re-throw other errors like EPERM (permission denied) + if (isNotEnoent(error)) { + throw error; + } + return entry; + } + }), + ); + + // Use fast-glob to resolve patterns in files field + // This handles glob patterns like "dist/*.js", directories like "dist", and dotfiles + // fast-glob doesn't respect .gitignore by default, so gitignored files are included + const matchedFiles = await glob(patterns, { + cwd, + dot: true, // Include dotfiles like .env.production + }); + + // Copy all matched files to pack worktree + for (const relativePath of matchedFiles) { + const sourcePath = path.join(cwd, relativePath); + const destinationPath = path.join(packWorktreePackageRoot, relativePath); + + await copyFile(sourcePath, destinationPath); + } + } + + // Symlink node_modules so hooks have access to dependencies + // Note: Remove any existing node_modules directory in worktree first + // (git might have checked it out) + if (isMonorepo) { + // Root node_modules + const rootNodeModulesTarget = path.join(packWorktreePath, 'node_modules'); + await fs.rm(rootNodeModulesTarget, { + recursive: true, + force: true, + }); + try { + await fs.symlink( + path.join(gitRootPath, 'node_modules'), + rootNodeModulesTarget, + 'dir', + ); + } catch (error: unknown) { + // If node_modules doesn't exist, ignore (pack will likely fail later) + if (isNotEnoent(error)) { + throw error; + } + } + + // Package node_modules (if exists) + const packageNodeModulesTarget = path.join(packWorktreePath, gitSubdirectory, 'node_modules'); + await fs.rm(packageNodeModulesTarget, { + recursive: true, + force: true, + }); + try { + await fs.symlink( + path.join(cwd, 'node_modules'), + packageNodeModulesTarget, + 'dir', + ); + } catch (error: unknown) { + if (isNotEnoent(error)) { + throw error; + } + } + } else { + // Regular package node_modules + const nodeModulesTarget = path.join(packWorktreePath, 'node_modules'); + await fs.rm(nodeModulesTarget, { + recursive: true, + force: true, + }); + try { + await fs.symlink( + path.join(cwd, 'node_modules'), + nodeModulesTarget, + 'dir', + ); + } catch (error: unknown) { + if (isNotEnoent(error)) { + throw error; + } + } + } + // Determine pack command based on package manager const packArgs = packageManager === 'bun' ? ['pm', 'pack', '--destination', packDestinationDirectory] : ['pack', '--pack-destination', packDestinationDirectory]; - // Run pack from the current working directory - await spawn(packageManager, packArgs, { cwd }); + // Run pack from the appropriate directory in pack worktree + const packCwd = gitSubdirectory + ? path.join(packWorktreePath, gitSubdirectory) + : packWorktreePath; + + await spawn(packageManager, packArgs, { cwd: packCwd }); // Find the generated tarball (package managers create it with their own naming) const files = await fs.readdir(packDestinationDirectory); diff --git a/tests/index.ts b/tests/index.ts index 12df3ee..3d8bf3e 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -304,6 +304,108 @@ describe('git-publish', ({ describe }) => { // Catalog should be resolved to actual version expect(packageJson.dependencies.ms).toBe(msVersion); }); + + test('monorepo prepack hook can access root node_modules', async ({ onTestFail }) => { + const branchName = 'test-monorepo-root-deps'; + const packageName = '@org/root-deps-test'; + + // Test that prepack hooks can access binaries from root node_modules + await using fixture = await createFixture({ + 'pnpm-workspace.yaml': yaml.dump({ + packages: ['packages/*'], + }), + 'package.json': JSON.stringify({ + private: true, + devDependencies: { + 'clean-pkg-json': '^1.0.0', + }, + }, null, 2), + 'packages/test-pkg': { + 'package.json': JSON.stringify({ + name: packageName, + version: '0.0.0', + scripts: { + prepack: 'clean-pkg-json', + }, + }, null, 2), + 'index.js': 'export const main = true;', + }, + }); + + await spawn('pnpm', ['install'], { cwd: fixture.path }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + const monorepoPackagePath = path.join(fixture.path, 'packages/test-pkg'); + const gitPublishProcess = await gitPublish(monorepoPackagePath, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toMatch('✔'); + + // Verify clean-pkg-json ran (scripts field should be removed) + const publishedBranch = `npm/${branchName}-${packageName}`; + const packageJsonString = await git('show', [`origin/${publishedBranch}:package.json`]); + const packageJson = JSON.parse(packageJsonString); + expect(packageJson.scripts).toBeUndefined(); + }); + + test('monorepo prepack hook can access package-level node_modules', async ({ onTestFail }) => { + const branchName = 'test-monorepo-pkg-deps'; + const packageName = '@org/pkg-deps-test'; + + // Test that prepack hooks can access binaries from package-level node_modules + await using fixture = await createFixture({ + 'pnpm-workspace.yaml': yaml.dump({ + packages: ['packages/*'], + }), + 'package.json': JSON.stringify({ + private: true, + }, null, 2), + 'packages/test-pkg': { + 'package.json': JSON.stringify({ + name: packageName, + version: '0.0.0', + scripts: { + prepack: 'mkdirp dist && echo "built" > dist/output.txt', + }, + devDependencies: { + mkdirp: '^3.0.0', + }, + files: ['dist'], + }, null, 2), + 'index.js': 'export const main = true;', + }, + }); + + await spawn('pnpm', ['install'], { cwd: fixture.path }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + const monorepoPackagePath = path.join(fixture.path, 'packages/test-pkg'); + const gitPublishProcess = await gitPublish(monorepoPackagePath, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toMatch('✔'); + + // Verify mkdirp ran and created dist/output.txt + const publishedBranch = `npm/${branchName}-${packageName}`; + const outputContent = await git('show', [`origin/${publishedBranch}:dist/output.txt`]); + expect(outputContent.trim()).toBe('built'); + }); }); test('npm pack is used', async ({ onTestFail }) => { @@ -462,5 +564,230 @@ describe('git-publish', ({ describe }) => { const utilsContent = await git('show', [`origin/${publishedBranch}:dist/utils.js`]); expect(utilsContent).toBe('export const util = () => {};'); }); + + test('prepack hook does not modify working directory', async ({ onTestFail }) => { + const branchName = 'test-prepack-isolation'; + + // This test verifies that prepack hooks don't pollute the working directory + // The hook creates a file, but it should only exist in the published branch + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-prepack-isolation', + version: '1.0.0', + scripts: { + prepack: 'echo "hook-ran" > prepack-created-file.txt', + }, + }, null, 2), + 'index.js': 'export const main = true;', + }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + // Run git-publish + const gitPublishProcess = await gitPublish(fixture.path, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toMatch('✔'); + + // Verify working directory is still clean (no new files created) + const statusOutput = await git('status', ['--porcelain']); + expect(statusOutput).toBe(''); + + // Verify the file created by prepack hook doesn't exist in working directory + const fileExists = await fixture.exists('prepack-created-file.txt'); + expect(fileExists).toBe(false); + + // Verify the published branch has the file created by the hook + const publishedBranch = `npm/${branchName}`; + const publishedFileContent = await git('show', [`origin/${publishedBranch}:prepack-created-file.txt`]); + expect(publishedFileContent.trim()).toBe('hook-ran'); + }); + + test('fails gracefully when pack hook dependencies are missing', async ({ onTestFail }) => { + const branchName = 'test-missing-deps'; + + // Test that script doesn't crash on ENOENT when symlinking node_modules + // Pack should fail gracefully with proper error message + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-missing-deps', + version: '1.0.0', + scripts: { + prepack: 'nonexistent-binary', + }, + }, null, 2), + 'index.js': 'export const main = true;', + }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + // Do NOT run npm install - node_modules won't exist + const gitPublishProcess = await gitPublish(fixture.path, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + // Should fail with exit code + expect('exitCode' in gitPublishProcess).toBe(true); + if ('exitCode' in gitPublishProcess) { + expect(gitPublishProcess.exitCode).not.toBe(0); + + // Verify failure is from pack command (not from fs.symlink crash) + // Exit code 127 means "command not found" - proves pack ran and failed + // (If fs.symlink crashed, we wouldn't get this far) + expect(gitPublishProcess.stdout).toMatch(/exit code 127/); + } + }); + + test('publishes gitignored files specified by glob pattern', async ({ onTestFail }) => { + const branchName = 'test-glob-pattern'; + + // Test that glob patterns in "files" field work correctly + // Pattern "dist/*.js" should only match .js files in dist, not subdirectories + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-glob-pattern', + version: '1.0.0', + files: ['dist/*.js'], + }, null, 2), + dist: { + 'index.js': 'export const main = true;', + 'utils.js': 'export const util = () => {};', + 'types.ts': '// This should not be published', + nested: { + 'deep.js': '// This should not be published (not matched by dist/*.js)', + }, + }, + '.gitignore': 'dist', + }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + const gitPublishProcess = await gitPublish(fixture.path, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toMatch('✔'); + + // Verify only .js files in dist root are published + const publishedBranch = `npm/${branchName}`; + const filesInTreeString = await git('ls-tree', ['-r', '--name-only', `origin/${publishedBranch}`]); + const filesInTree = filesInTreeString.split('\n').filter(Boolean).sort(); + expect(filesInTree).toEqual([ + 'dist/index.js', + 'dist/utils.js', + 'package.json', + ]); + }); + + test('publishes gitignored directory recursively', async ({ onTestFail }) => { + const branchName = 'test-directory-recursive'; + + // Test that directory in "files" field includes all files recursively + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-directory-recursive', + version: '1.0.0', + files: ['dist'], + }, null, 2), + dist: { + 'index.js': 'export const main = true;', + nested: { + 'deep.js': 'export const deep = true;', + 'utils.js': 'export const util = () => {};', + }, + }, + '.gitignore': 'dist', + }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + const gitPublishProcess = await gitPublish(fixture.path, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toMatch('✔'); + + // Verify all files in dist are published recursively + const publishedBranch = `npm/${branchName}`; + const filesInTreeString = await git('ls-tree', ['-r', '--name-only', `origin/${publishedBranch}`]); + const filesInTree = filesInTreeString.split('\n').filter(Boolean).sort(); + expect(filesInTree).toEqual([ + 'dist/index.js', + 'dist/nested/deep.js', + 'dist/nested/utils.js', + 'package.json', + ]); + }); + + test('publishes gitignored dotfiles', async ({ onTestFail }) => { + const branchName = 'test-dotfiles'; + + // Test that dotfiles specified in "files" field are published + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-dotfiles', + version: '1.0.0', + files: ['.env.production', 'dist'], + }, null, 2), + '.env.production': 'PRODUCTION=true', + dist: { + 'index.js': 'export const main = true;', + }, + '.env.development': '// This should not be published', + '.gitignore': 'dist\n.env.*', + }); + + const git = createGit(fixture.path); + await git.init([`--initial-branch=${branchName}`]); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remoteFixture.path]); + + const gitPublishProcess = await gitPublish(fixture.path, ['--fresh']); + onTestFail(() => { + console.log(gitPublishProcess); + }); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toMatch('✔'); + + // Verify dotfile and dist files are published + const publishedBranch = `npm/${branchName}`; + const filesInTreeString = await git('ls-tree', ['-r', '--name-only', `origin/${publishedBranch}`]); + const filesInTree = filesInTreeString.split('\n').filter(Boolean).sort(); + expect(filesInTree).toEqual([ + '.env.production', + 'dist/index.js', + 'package.json', + ]); + + // Verify dotfile content + const dotfileContent = await git('show', [`origin/${publishedBranch}:.env.production`]); + expect(dotfileContent).toBe('PRODUCTION=true'); + }); }); });