Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 26 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { readJson } from './utils/read-json.js';
import { detectPackageManager } from './utils/detect-package-manager.js';
import { packPackage } from './utils/pack-package.js';
import { extractTarball } from './utils/extract-tarball.js';

Check warning on line 19 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Maximum number of dependencies (15) exceeded

const { stringify } = JSON;

Expand Down Expand Up @@ -100,7 +100,8 @@

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;
Expand All @@ -115,19 +116,23 @@
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;
}

// 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 {
Expand All @@ -146,27 +151,27 @@
'--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;
}

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) {
Expand All @@ -181,11 +186,14 @@

const tarballPath = await packPackage(
packageManager,
cwd,
packWorktreePath,
packTemporaryDirectory,
cwd,
gitRootPath,
gitSubdirectory,
);

return await extractTarball(tarballPath, worktreePath);
return await extractTarball(tarballPath, publishWorktreePath);
});

if (!dry) {
Expand All @@ -198,7 +206,7 @@
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) {
Expand All @@ -207,13 +215,13 @@

const totalSize = publishFiles.reduce((accumulator, { size }) => accumulator + size, 0);

console.log(lightBlue(`Publishing ${packageJson.name}`));

Check warning on line 218 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected console statement
console.log(publishFiles.map(({ file, size }) => `${file} ${dim(byteSize(size).toString())}`).join('\n'));

Check warning on line 219 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected console statement
console.log(`\n${lightBlue('Total size')}`, byteSize(totalSize).toString());

Check warning on line 220 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected console statement

const trackedFiles = await gitStatusTracked({ cwd: worktreePath });
const trackedFiles = await gitStatusTracked({ cwd: publishWorktreePath });
if (trackedFiles.length === 0) {
console.warn('⚠️ No new changes found to commit.');

Check warning on line 224 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected console statement
} else {
let commitMessage = `Published from "${currentBranch}"`;
if (currentBranchSha) {
Expand All @@ -233,11 +241,11 @@
commitMessage,
'--author=git-publish <bot@git-publish>',
],
{ cwd: worktreePath },
{ cwd: publishWorktreePath },
);
}

commitSha = (await getCurrentCommit({ cwd: worktreePath }))!;
commitSha = (await getCurrentCommit({ cwd: publishWorktreePath }))!;
});

if (!dry) {
Expand All @@ -258,7 +266,7 @@
'--no-verify',
remote,
`HEAD:${publishBranch}`,
], { cwd: worktreePath });
], { cwd: publishWorktreePath });
success = true;
},
);
Expand All @@ -273,7 +281,8 @@
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,
Expand Down Expand Up @@ -306,6 +315,6 @@
},
);
})().catch((error) => {
console.error('Error:', error.message);

Check warning on line 318 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected console statement
process.exit(1);
});
139 changes: 135 additions & 4 deletions src/utils/pack-package.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<string> => {

Check warning on line 27 in src/utils/pack-package.ts

View workflow job for this annotation

GitHub Actions / Test

Async arrow function has a complexity of 15. Maximum allowed is 10

Check warning on line 27 in src/utils/pack-package.ts

View workflow job for this annotation

GitHub Actions / Test

Async arrow function has too many parameters (6). Maximum allowed is 5
// 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);
Expand Down
Loading
Loading