From 0460dfe3ac46edc99601a6596690ff3ed750b13c Mon Sep 17 00:00:00 2001 From: konlanx Date: Mon, 8 Jun 2026 09:02:05 +0200 Subject: [PATCH] feat: Automatically detect the package manager for the git hook --- packages/core/src/commands/init.ts | 6 +-- packages/core/src/init/hook.test.ts | 61 +++++++++++++++++++---- packages/core/src/init/hook.ts | 10 ++-- packages/core/src/init/package-manager.ts | 9 ++++ 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/packages/core/src/commands/init.ts b/packages/core/src/commands/init.ts index d521a49..8bba2ff 100644 --- a/packages/core/src/commands/init.ts +++ b/packages/core/src/commands/init.ts @@ -58,8 +58,8 @@ const describeHook = (cwd: string, hook: HookResult): string => { return `installed at ${where}${doppler} 🪝`; }; -const installHooks = (cwd: string): Promise => - Promise.all([installHook(cwd, POST_CHECKOUT_HOOK), installHook(cwd, POST_MERGE_HOOK)]); +const installHooks = (cwd: string, manager: PackageManager): Promise => + Promise.all([installHook(cwd, POST_CHECKOUT_HOOK, { manager }), installHook(cwd, POST_MERGE_HOOK, { manager })]); const writeConfigFile = async (cwd: string, content: string): Promise => { const path = join(cwd, 'branchly.config.ts'); @@ -117,7 +117,7 @@ export const runInit = async (options: InitOptions): Promise => { 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)}`); diff --git a/packages/core/src/init/hook.test.ts b/packages/core/src/init/hook.test.ts index 35fb517..9d24dc7 100644 --- a/packages/core/src/init/hook.test.ts +++ b/packages/core/src/init/hook.test.ts @@ -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 "$@"'); }); }); @@ -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'); @@ -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'); @@ -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 { @@ -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 { @@ -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'); @@ -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 }); + } + }); }); diff --git a/packages/core/src/init/hook.ts b/packages/core/src/init/hook.ts index 329adb7..fcad911 100644 --- a/packages/core/src/init/hook.ts +++ b/packages/core/src/init/hook.ts @@ -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 { @@ -33,6 +35,7 @@ export interface HookResult { export interface InstallHookDeps { readonly hooksPath?: string | null; readonly doppler?: boolean; + readonly manager?: PackageManager; } export interface HookTarget { @@ -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; }; @@ -100,7 +103,8 @@ const readExisting = (path: string): Promise => export const installHook = async (cwd: string, spec: HookSpec, deps: InstallHookDeps = {}): Promise => { 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); diff --git a/packages/core/src/init/package-manager.ts b/packages/core/src/init/package-manager.ts index 0ea1ea9..917c838 100644 --- a/packages/core/src/init/package-manager.ts +++ b/packages/core/src/init/package-manager.ts @@ -14,6 +14,13 @@ const LOCKFILES: { readonly file: string; readonly manager: PackageManager }[] = const SUBCOMMAND: Record = { npm: 'install', pnpm: 'add', yarn: 'add', bun: 'add' }; const DEV_FLAG: Record = { npm: '--save-dev', pnpm: '--save-dev', yarn: '--dev', bun: '--dev' }; +const HOOK_RUNNER: Record = { + npm: 'npx branchly', + pnpm: 'pnpm exec branchly', + yarn: 'yarn branchly', + bun: 'bunx branchly', +}; + const fileExists = (path: string): Promise => access(path) .then(() => true) @@ -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],