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
6 changes: 3 additions & 3 deletions packages/core/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ const describeHook = (cwd: string, hook: HookResult): string => {
return `installed at ${where}${doppler} 🪝`;
};

const installHooks = (cwd: string): Promise<readonly [HookResult, HookResult]> =>
Promise.all([installHook(cwd, POST_CHECKOUT_HOOK), installHook(cwd, POST_MERGE_HOOK)]);
const installHooks = (cwd: string, manager: PackageManager): Promise<readonly [HookResult, HookResult]> =>
Promise.all([installHook(cwd, POST_CHECKOUT_HOOK, { manager }), installHook(cwd, POST_MERGE_HOOK, { manager })]);

const writeConfigFile = async (cwd: string, content: string): Promise<boolean> => {
const path = join(cwd, 'branchly.config.ts');
Expand Down Expand Up @@ -117,7 +117,7 @@ export const runInit = async (options: InitOptions): Promise<void> => {

const wroteConfig = await writeConfigFile(cwd, renderConfig({ ...detected, databaseUrlEnv: DATABASE_URL_ENV }));
await updateGitignore(cwd);
const [checkoutHook, mergeHook] = await installHooks(cwd);
const [checkoutHook, mergeHook] = await installHooks(cwd, manager);
reporter.step(`config: ${wroteConfig ? 'wrote branchly.config.ts 📝' : 'kept your existing branchly.config.ts'}`);
reporter.step('gitignore: .env is covered (branchly keeps its state in .git)');
reporter.step(`checkout: ${describeHook(cwd, checkoutHook)}`);
Expand Down
61 changes: 50 additions & 11 deletions packages/core/src/init/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@ import {

describe('hookCommand', () => {
it('returns the plain command without doppler', () => {
expect(hookCommand(POST_CHECKOUT_HOOK, false)).toBe('npx branchly on-checkout "$@"');
expect(hookCommand(POST_CHECKOUT_HOOK, false, 'npm')).toBe('npx branchly on-checkout "$@"');
});

it('wraps the command with doppler run', () => {
expect(hookCommand(POST_CHECKOUT_HOOK, true)).toBe('doppler run -- npx branchly on-checkout "$@"');
expect(hookCommand(POST_CHECKOUT_HOOK, true, 'npm')).toBe('doppler run -- npx branchly on-checkout "$@"');
});

it('uses the spec subcommand for other hooks', () => {
expect(hookCommand(POST_MERGE_HOOK, false)).toBe('npx branchly post-merge "$@"');
expect(hookCommand(POST_MERGE_HOOK, false, 'npm')).toBe('npx branchly post-merge "$@"');
});

it('uses the runner that matches the package manager', () => {
expect(hookCommand(POST_CHECKOUT_HOOK, false, 'pnpm')).toBe('pnpm exec branchly on-checkout "$@"');
expect(hookCommand(POST_CHECKOUT_HOOK, false, 'yarn')).toBe('yarn branchly on-checkout "$@"');
expect(hookCommand(POST_CHECKOUT_HOOK, false, 'bun')).toBe('bunx branchly on-checkout "$@"');
});
});

Expand Down Expand Up @@ -86,7 +92,7 @@ describe('installHook', () => {
it('installs a raw post-checkout hook in .git/hooks', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: false });
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: false, manager: 'npm' });
expect(result.status).toBe('installed');
expect(result.path).toBe(join(root, '.git', 'hooks', 'post-checkout'));
const content = await readFile(result.path, 'utf8');
Expand All @@ -100,7 +106,7 @@ describe('installHook', () => {
it('installs a raw post-merge hook without a checkout guard', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
const result = await installHook(root, POST_MERGE_HOOK, { hooksPath: null, doppler: false });
const result = await installHook(root, POST_MERGE_HOOK, { hooksPath: null, doppler: false, manager: 'npm' });
expect(result.path).toBe(join(root, '.git', 'hooks', 'post-merge'));
const content = await readFile(result.path, 'utf8');
expect(content).toContain('exec npx branchly post-merge');
Expand All @@ -113,7 +119,11 @@ describe('installHook', () => {
it('installs a husky hook at .husky/post-checkout without a shebang', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: '.husky/_', doppler: false });
const result = await installHook(root, POST_CHECKOUT_HOOK, {
hooksPath: '.husky/_',
doppler: false,
manager: 'npm',
});
expect(result.path).toBe(join(root, '.husky', 'post-checkout'));
expect(await readFile(result.path, 'utf8')).toBe('npx branchly on-checkout "$@"\n');
} finally {
Expand All @@ -124,7 +134,7 @@ describe('installHook', () => {
it('wraps the hook with doppler run when Doppler is detected', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: true });
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: true, manager: 'npm' });
expect(result.doppler).toBe(true);
expect(await readFile(result.path, 'utf8')).toContain('doppler run -- npx branchly on-checkout');
} finally {
Expand All @@ -135,9 +145,13 @@ describe('installHook', () => {
it('chains onto an existing foreign hook instead of overwriting it', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: '.husky/_', doppler: false });
await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: '.husky/_', doppler: false, manager: 'npm' });
await writeFile(join(root, '.husky', 'post-checkout'), 'echo existing\n', 'utf8');
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: '.husky/_', doppler: false });
const result = await installHook(root, POST_CHECKOUT_HOOK, {
hooksPath: '.husky/_',
doppler: false,
manager: 'npm',
});
expect(result.status).toBe('chained');
const content = await readFile(result.path, 'utf8');
expect(content).toContain('echo existing');
Expand All @@ -150,11 +164,36 @@ describe('installHook', () => {
it('is idempotent when a branchly hook is already present', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: false });
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: false });
await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: false, manager: 'npm' });
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, doppler: false, manager: 'npm' });
expect(result.status).toBe('present');
} finally {
await rm(root, { recursive: true, force: true });
}
});

it('writes the runner for the given package manager', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
const result = await installHook(root, POST_CHECKOUT_HOOK, {
hooksPath: '.husky/_',
doppler: false,
manager: 'yarn',
});
expect(await readFile(result.path, 'utf8')).toBe('yarn branchly on-checkout "$@"\n');
} finally {
await rm(root, { recursive: true, force: true });
}
});

it('detects the package manager from a lockfile when none is given', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
await writeFile(join(root, 'pnpm-lock.yaml'), '', 'utf8');
const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: '.husky/_', doppler: false });
expect(await readFile(result.path, 'utf8')).toBe('pnpm exec branchly on-checkout "$@"\n');
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
10 changes: 7 additions & 3 deletions packages/core/src/init/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { access, chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, join } from 'node:path';
import { promisify } from 'node:util';

import { detectPackageManager, hookRunner, type PackageManager } from './package-manager';

const execFileAsync = promisify(execFile);

export interface HookSpec {
Expand Down Expand Up @@ -33,6 +35,7 @@ export interface HookResult {
export interface InstallHookDeps {
readonly hooksPath?: string | null;
readonly doppler?: boolean;
readonly manager?: PackageManager;
}

export interface HookTarget {
Expand All @@ -42,8 +45,8 @@ export interface HookTarget {

export const hookMarker = (spec: HookSpec): string => `branchly ${spec.subcommand}`;

export const hookCommand = (spec: HookSpec, useDoppler: boolean): string => {
const base = `npx branchly ${spec.subcommand} "$@"`;
export const hookCommand = (spec: HookSpec, useDoppler: boolean, manager: PackageManager): string => {
const base = `${hookRunner(manager)} ${spec.subcommand} "$@"`;
return useDoppler ? `doppler run -- ${base}` : base;
};

Expand Down Expand Up @@ -100,7 +103,8 @@ const readExisting = (path: string): Promise<string | null> =>
export const installHook = async (cwd: string, spec: HookSpec, deps: InstallHookDeps = {}): Promise<HookResult> => {
const hooksPath = 'hooksPath' in deps ? (deps.hooksPath ?? null) : await readHooksPath(cwd);
const useDoppler = 'doppler' in deps ? deps.doppler === true : await detectDoppler(cwd);
const command = hookCommand(spec, useDoppler);
const manager = deps.manager ?? (await detectPackageManager(cwd));
const command = hookCommand(spec, useDoppler, manager);
const target = hookTarget(cwd, hooksPath, spec.name);
const existing = await readExisting(target.path);

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/init/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ const LOCKFILES: { readonly file: string; readonly manager: PackageManager }[] =
const SUBCOMMAND: Record<PackageManager, string> = { npm: 'install', pnpm: 'add', yarn: 'add', bun: 'add' };
const DEV_FLAG: Record<PackageManager, string> = { npm: '--save-dev', pnpm: '--save-dev', yarn: '--dev', bun: '--dev' };

const HOOK_RUNNER: Record<PackageManager, string> = {
npm: 'npx branchly',
pnpm: 'pnpm exec branchly',
yarn: 'yarn branchly',
bun: 'bunx branchly',
};

const fileExists = (path: string): Promise<boolean> =>
access(path)
.then(() => true)
Expand Down Expand Up @@ -58,6 +65,8 @@ export const detectPackageManager = async (
return fromLockfile ?? fromUserAgent(userAgent) ?? 'npm';
};

export const hookRunner = (manager: PackageManager): string => HOOK_RUNNER[manager];

export const installArgs = (manager: PackageManager, packages: readonly string[]): string[] => [
SUBCOMMAND[manager],
DEV_FLAG[manager],
Expand Down
Loading