From b81d8805382af00abed4b3e5e8fe7fea8bebeb3f Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:16:24 +1000 Subject: [PATCH 1/5] fix(hooks): pin managed hooks to installed CLI --- src/git/hook.ts | 610 +----------------------------------------------- 1 file changed, 2 insertions(+), 608 deletions(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index 3484675..bb59d91 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -195,612 +195,6 @@ function buildHookScript(hookName: string, cliPath: string, backupPath?: string) quotedBackupPath ? `if [ -f ${quotedBackupPath} ]; then if [ -x ${quotedBackupPath} ]; then ${quotedBackupPath} "$@" || exit $?; else sh ${quotedBackupPath} "$@" || exit $?; fi; fi` : '', - `if command -v commit-echo >/dev/null 2>&1; then commit-echo hook ${quotedHookName} "$@"; elif [ -f ${quotedCliPath} ]; then node ${quotedCliPath} hook ${quotedHookName} "$@"; fi`, + `if [ -f ${quotedCliPath} ]; then node ${quotedCliPath} hook ${quotedHookName} "$@"; elif command -v commit-echo >/dev/null 2>&1; then commit-echo hook ${quotedHookName} "$@"; fi`, '', - ] - .filter((line) => line.length > 0) - .join('\n'); -} - -export function buildPrepareCommitMsgHookScript(cliPath: string, backupPath?: string): string { - return buildHookScript(PREPARE_COMMIT_MSG_HOOK_NAME, cliPath, backupPath); -} - -export function buildPostCommitHookScript(cliPath: string, backupPath?: string): string { - return buildHookScript(POST_COMMIT_HOOK_NAME, cliPath, backupPath); -} - -async function backupHook( - hookPath: string, - backupPath: string, - hookStats: Awaited>, -): Promise { - const stagedPath = `${backupPath}.tmp-${randomUUID()}`; - - if (hookStats.isSymbolicLink()) { - try { - await symlink(await readlink(hookPath, 'utf8'), stagedPath, 'file'); - await rename(stagedPath, backupPath); - } catch (error) { - await rm(stagedPath, { force: true }).catch(() => {}); - throw error; - } - } else { - try { - await copyFile(hookPath, stagedPath); - await chmod(stagedPath, Number(hookStats.mode) & 0o7777); - await rename(stagedPath, backupPath); - } catch (error) { - await rm(stagedPath, { force: true }).catch(() => {}); - throw error; - } - } -} - -async function writeBackupOwner(ownerPath: string): Promise { - const stagedPath = `${ownerPath}.tmp-${randomUUID()}`; - try { - await writeFile(stagedPath, `${BACKUP_OWNER_MARKER}\n`, 'utf-8'); - await rename(stagedPath, ownerPath); - } finally { - await rm(stagedPath, { force: true }).catch(() => {}); - } -} - -async function replacePreparedPath(preparedPath: string, targetPath: string): Promise { - const displacedPath = `${targetPath}.tmp-restore-${randomUUID()}`; - const targetStats = await lstatIfExists(targetPath); - let displaced = false; - - try { - if (targetStats) { - await rename(targetPath, displacedPath); - displaced = true; - } - await rename(preparedPath, targetPath); - } catch (error) { - if (displaced) { - await rename(displacedPath, targetPath).catch(() => {}); - } - throw error; - } - - if (displaced) { - await rm(displacedPath, { force: true }); - } -} - -async function restoreHookBackup( - hookPath: string, - backupPath: string, - backupStats: Awaited>, - ownerPath?: string, -): Promise { - if (!backupStats.isFile() && !backupStats.isSymbolicLink()) { - throw new Error(`Refusing to restore non-regular hook backup at ${backupPath}`); - } - - const stagedPath = `${hookPath}.tmp-restore-${randomUUID()}`; - - try { - if (backupStats.isSymbolicLink()) { - await symlink(await readlink(backupPath, 'utf8'), stagedPath, 'file'); - } else { - await copyFile(backupPath, stagedPath); - await chmod(stagedPath, Number(backupStats.mode) & 0o7777); - } - - await replacePreparedPath(stagedPath, hookPath); - } catch (error) { - await rm(stagedPath, { force: true }).catch(() => {}); - throw error; - } - - await rm(backupPath, { force: true }); - if (ownerPath) { - await rm(ownerPath, { force: true }); - } -} - -type PathSnapshot = - | { kind: 'missing' } - | { kind: 'file'; content: Buffer; mode: number } - | { kind: 'symlink'; target: string }; - -async function snapshotPath(path: string): Promise { - const stats = await lstatIfExists(path); - if (!stats) { - return { kind: 'missing' }; - } - if (stats.isSymbolicLink()) { - return { kind: 'symlink', target: await readlink(path, 'utf8') }; - } - if (!stats.isFile()) { - throw new Error(`Expected a file or symlink at ${path}`); - } - return { kind: 'file', content: await readFile(path), mode: Number(stats.mode) & 0o7777 }; -} - -async function restoreSnapshot(path: string, snapshot: PathSnapshot): Promise { - if (snapshot.kind === 'missing') { - await rm(path, { force: true }); - return; - } - - const stagedPath = `${path}.tmp-rollback-${randomUUID()}`; - try { - if (snapshot.kind === 'symlink') { - await symlink(snapshot.target, stagedPath, 'file'); - } else { - await writeFile(stagedPath, snapshot.content); - await chmod(stagedPath, snapshot.mode); - } - await replacePreparedPath(stagedPath, path); - } finally { - await rm(stagedPath, { force: true }).catch(() => {}); - } -} - -interface InstalledHookChange { - path: string; - rollback: () => Promise; -} - -interface ManagedHookState { - hookPath: string; - backupPath: string; - ownerPath: string; - hookSnapshot: PathSnapshot; - backupSnapshot: PathSnapshot; - ownerSnapshot: PathSnapshot; - isManagedHook: boolean; - hasBackup: boolean; - hasOwner: boolean; - validOwner: boolean; - referencesBackup: boolean; -} - -async function inspectManagedHook(hookName: string): Promise { - const { hookPath, backupPath, legacyBackupSuffix, ownerPath } = resolveHookPaths(hookName); - const hookSnapshot = await snapshotPath(hookPath); - const backupSnapshot = await snapshotPath(backupPath); - const ownerSnapshot = await snapshotPath(ownerPath); - const existingHook = hookSnapshot.kind === 'file' ? hookSnapshot.content.toString('utf8') : ''; - const isManagedHook = hookSnapshot.kind === 'file' && isManagedHookContent(hookName, existingHook); - - return { - hookPath, - backupPath, - ownerPath, - hookSnapshot, - backupSnapshot, - ownerSnapshot, - isManagedHook, - hasBackup: backupSnapshot.kind !== 'missing', - hasOwner: ownerSnapshot.kind !== 'missing', - validOwner: ownerSnapshot.kind === 'file' && ownerSnapshot.content.toString('utf8').trim() === BACKUP_OWNER_MARKER, - referencesBackup: isManagedHook && referencesBackupPath(existingHook, backupPath, legacyBackupSuffix), - }; -} - -function validateManagedHookBackup(state: ManagedHookState): void { - if (state.hasOwner && !state.validOwner) { - throw new Error( - `Refusing to use invalid backup ownership marker at ${state.ownerPath}; remove that file to reinstall the hook.`, - ); - } - if (state.hasBackup && !state.isManagedHook && !state.validOwner) { - throw new Error(`Refusing to overwrite existing backup at ${state.backupPath}`); - } - if (state.hasBackup && state.isManagedHook && !state.referencesBackup && !state.validOwner) { - throw new Error(`Refusing to use unowned backup at ${state.backupPath}`); - } -} - -async function prepareManagedHookBackup(state: ManagedHookState): Promise { - const isReplacement = state.hookSnapshot.kind !== 'missing' && !state.isManagedHook; - if (isReplacement && state.validOwner) { - await rm(state.backupPath, { force: true }); - await rm(state.ownerPath, { force: true }); - } - - const latestBackupStats = await lstatIfExists(state.backupPath); - if (state.hookSnapshot.kind !== 'missing' && !state.isManagedHook && !latestBackupStats) { - await backupHook(state.hookPath, state.backupPath, await lstat(state.hookPath)); - await writeBackupOwner(state.ownerPath); - } else if (state.isManagedHook && latestBackupStats && state.referencesBackup && !state.validOwner) { - // Adopt backups created by older commit-echo versions so future uninstall - // operations can distinguish them from user-owned collision files. - await writeBackupOwner(state.ownerPath); - } -} - -async function installManagedHook(hookName: string, cliPath: string): Promise { - const state = await inspectManagedHook(hookName); - await mkdir(dirname(state.hookPath), { recursive: true }); - validateManagedHookBackup(state); - - try { - await prepareManagedHookBackup(state); - - const effectiveBackupStats = await lstatIfExists(state.backupPath); - const script = buildHookScript(hookName, cliPath, effectiveBackupStats ? state.backupPath : undefined); - const stagedPath = `${state.hookPath}.tmp-install-${randomUUID()}`; - try { - await writeFile(stagedPath, `${script}\n`, 'utf-8'); - await chmod(stagedPath, 0o755); - await replacePreparedPath(stagedPath, state.hookPath); - } finally { - await rm(stagedPath, { force: true }).catch(() => {}); - } - } catch (error) { - await restoreSnapshot(state.ownerPath, state.ownerSnapshot).catch(() => {}); - await restoreSnapshot(state.backupPath, state.backupSnapshot).catch(() => {}); - await restoreSnapshot(state.hookPath, state.hookSnapshot).catch(() => {}); - throw error; - } - - return { - path: state.hookPath, - rollback: async () => { - await restoreSnapshot(state.ownerPath, state.ownerSnapshot); - await restoreSnapshot(state.backupPath, state.backupSnapshot); - await restoreSnapshot(state.hookPath, state.hookSnapshot); - }, - }; -} - -type HookUninstallAction = 'restored' | 'removed' | 'skipped' | 'missing' | 'unreadable'; - -interface HookUninstallState { - hookPath: string; - backupPath: string; - ownerPath: string; - hookStats: Awaited> | null; - backupStats: Awaited> | null; - hookContent: string; - isManagedHook: boolean; - backupIsOwned: boolean; -} - -async function inspectHookForUninstall(hookName: string, paths: HookPaths): Promise { - const { hookPath, backupPath, ownerPath } = paths; - const hookStats = await lstatIfExists(hookPath); - const backupStats = await lstatIfExists(backupPath); - const ownerStats = await lstatIfExists(ownerPath); - let isManagedHook = false; - let hookContent = ''; - - if (hookStats) { - try { - hookContent = await readFile(hookPath, 'utf-8'); - isManagedHook = isManagedHookContent(hookName, hookContent); - } catch { - return null; - } - } - - let ownerIsValid = false; - if (ownerStats) { - try { - ownerIsValid = - !ownerStats.isSymbolicLink() && (await readFile(ownerPath, 'utf-8')).trim() === BACKUP_OWNER_MARKER; - } catch { - return null; - } - } - - if (backupStats && !backupStats.isFile() && !backupStats.isSymbolicLink()) { - return null; - } - - const backupIsOwned = Boolean( - backupStats && - (ownerIsValid || (isManagedHook && referencesBackupPath(hookContent, backupPath, paths.legacyBackupSuffix))), - ); - - return { hookPath, backupPath, ownerPath, hookStats, backupStats, hookContent, isManagedHook, backupIsOwned }; -} - -async function restoreOwnedHookBackup(state: HookUninstallState): Promise<'restored' | 'unreadable' | null> { - const backupStats = state.backupStats; - if ( - !backupStats || - !state.backupIsOwned || - // An existing non-managed path may be a user replacement even when empty. - !(state.isManagedHook || !state.hookStats) - ) { - return null; - } - - try { - await restoreHookBackup(state.hookPath, state.backupPath, backupStats, state.ownerPath); - return 'restored'; - } catch { - return 'unreadable'; - } -} - -async function uninstallManagedHook(hookName: string): Promise<{ path: string; action: HookUninstallAction }> { - const paths = resolveHookPaths(hookName); - const state = await inspectHookForUninstall(hookName, paths); - const { hookPath } = paths; - if (!state) { - return { path: hookPath, action: 'unreadable' }; - } - - const restoreAction = await restoreOwnedHookBackup(state); - if (restoreAction) { - return { path: hookPath, action: restoreAction }; - } - - if (state.isManagedHook) { - await rm(state.hookPath, { force: true }); - await rm(state.ownerPath, { force: true }); - return { path: hookPath, action: 'removed' }; - } - - if (state.backupStats && state.backupIsOwned) { - await rm(state.backupPath, { force: true }); - await rm(state.ownerPath, { force: true }); - } - - return { path: hookPath, action: state.hookStats ? 'skipped' : 'missing' }; -} - -export async function installCommitHooks(cliPath = process.argv[1] ?? 'dist/index.js'): Promise { - const resolvedCliPath = - cliPath === process.argv[1] ? fileURLToPath(new URL('../index.js', import.meta.url)) : cliPath; - - checkGitRepo(); - let installed: InstalledHookChange[] = []; - try { - installed = [await installManagedHook(POST_COMMIT_HOOK_NAME, resolvedCliPath)]; - installed = [...installed, await installManagedHook(PREPARE_COMMIT_MSG_HOOK_NAME, resolvedCliPath)]; - } catch (error) { - const rollbackOrder = [...installed]; - rollbackOrder.reverse(); - for (const change of rollbackOrder) { - await change.rollback().catch(() => {}); - } - throw error; - } - - return { prepareCommitMsgPath: installed[1].path, postCommitPath: installed[0].path }; -} - -export async function installPrepareCommitMsgHook(cliPath = process.argv[1] ?? 'dist/index.js'): Promise { - const { prepareCommitMsgPath } = await installCommitHooks(cliPath); - return prepareCommitMsgPath; -} - -export async function uninstallCommitHooks(): Promise { - checkGitRepo(); - - const results = []; - for (const hookName of [PREPARE_COMMIT_MSG_HOOK_NAME, POST_COMMIT_HOOK_NAME]) { - try { - results.push(await uninstallManagedHook(hookName)); - } catch { - results.push({ path: resolveHookPath(hookName), action: 'unreadable' as const }); - } - } - - return { - restored: results.filter((result) => result.action === 'restored').map((result) => result.path), - removed: results.filter((result) => result.action === 'removed').map((result) => result.path), - skipped: results.filter((result) => result.action === 'skipped').map((result) => result.path), - missing: results.filter((result) => result.action === 'missing').map((result) => result.path), - unreadable: results.filter((result) => result.action === 'unreadable').map((result) => result.path), - }; -} - -function buildPendingHookEntry(config: Config, diff: string): string { - return JSON.stringify({ - timestamp: new Date().toISOString(), - diff, - model: config.model, - provider: config.provider, - }); -} - -export async function runPrepareCommitMsgHook( - args: PrepareCommitMsgHookArgs, - deps: PrepareCommitMsgHookDeps = { - checkGitRepo: checkGitRepoWithSignal, - loadConfig, - getStagedDiff: (signal) => getStagedDiffWithSignal(process.cwd(), undefined, signal), - buildProfile, - generateSuggestions, - readMessageFile: async (messageFile) => readFile(messageFile, 'utf-8'), - writeMessageFile: async (messageFile, content) => writeFile(messageFile, content, 'utf-8'), - writePendingEntryFile: async (content) => writeFile(resolvePendingEntryPath(), content, 'utf-8'), - removePendingEntryFile: async () => rm(resolvePendingEntryPath(), { force: true }), - warn: (message) => console.warn(message), - }, -): Promise { - if (shouldSkipPrepareCommitMsgHook(args.source)) { - await clearPendingEntryFile(deps.removePendingEntryFile); - return; - } - - const controller = new AbortController(); - const timeoutMs = deps.timeoutMs ?? PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS; - const deadline = Date.now() + timeoutMs; - let timeoutError: Error | undefined; - let timeoutId: ReturnType | undefined; - let timedOut = false; - let originalMessage: string | undefined; - let messageWriteAttempted = false; - - const abortForTimeout = () => { - if (timedOut) { - return; - } - timedOut = true; - timeoutError = new Error('timed out after ' + timeoutMs + 'ms; leaving commit message unchanged.'); - controller.abort(timeoutError); - }; - - const ensureWithinDeadline = () => { - if (controller.signal.aborted) { - throw controller.signal.reason instanceof Error - ? controller.signal.reason - : (timeoutError ?? new Error('commit-echo hook request was cancelled')); - } - - if (Date.now() >= deadline) { - abortForTimeout(); - throw timeoutError!; - } - }; - - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - abortForTimeout(); - reject(timeoutError!); - }, timeoutMs); - }); - - let hookOperation: Promise | undefined; - - try { - hookOperation = (async () => { - await deps.checkGitRepo(controller.signal); - ensureWithinDeadline(); - - let config: Config | null; - try { - config = await deps.loadConfig(); - } catch { - ensureWithinDeadline(); - config = null; - } - ensureWithinDeadline(); - - if (!config) { - deps.warn('commit-echo hook: no configuration found; skipping.'); - await clearPendingEntryFile(deps.removePendingEntryFile); - return; - } - - const diffResult = await deps.getStagedDiff(controller.signal); - ensureWithinDeadline(); - - if (!diffResult.hasChanges) { - await clearPendingEntryFile(deps.removePendingEntryFile); - return; - } - - const profile = await deps.buildProfile(config.historySize); - ensureWithinDeadline(); - - const { suggestions } = await deps.generateSuggestions( - config, - diffResult.diff, - profile, - undefined, - undefined, - controller.signal, - ); - ensureWithinDeadline(); - - const selected = suggestions[0]; - if (!selected) { - deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.'); - await clearPendingEntryFile(deps.removePendingEntryFile); - return; - } - - originalMessage = await deps.readMessageFile(args.messageFile); - ensureWithinDeadline(); - - const nextContent = buildHookCommitMessage(selected, originalMessage); - ensureWithinDeadline(); - messageWriteAttempted = true; - await deps.writeMessageFile(args.messageFile, nextContent); - ensureWithinDeadline(); - - await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff)); - ensureWithinDeadline(); - })(); - - await Promise.race([hookOperation, timeout]); - } catch (err) { - if (timedOut) { - if (messageWriteAttempted) { - await hookOperation?.catch(() => {}); - } else { - void hookOperation?.catch(() => {}); - } - - if (messageWriteAttempted && originalMessage !== undefined) { - await deps.writeMessageFile(args.messageFile, originalMessage).catch(() => {}); - } - - await clearPendingEntryFile(deps.removePendingEntryFile); - const message = timeoutError?.message ?? (err instanceof Error ? err.message : String(err)); - deps.warn('commit-echo hook: ' + message); - return; - } - - await clearPendingEntryFile(deps.removePendingEntryFile); - const message = err instanceof Error ? err.message : String(err); - deps.warn('commit-echo hook: ' + message); - } finally { - if (timeoutId) clearTimeout(timeoutId); - } -} - -export async function runPostCommitHook( - deps: PostCommitHookDeps = { - checkGitRepo, - readLatestCommitMessage: () => - execFileSync(getGitExecutable(), ['log', '-1', '--pretty=%B'], { encoding: 'utf-8' }).trim(), - readPendingEntryFile: async () => readFile(resolvePendingEntryPath(), 'utf-8'), - appendHistoryEntry: appendEntry, - removePendingEntryFile: async () => rm(resolvePendingEntryPath(), { force: true }), - warn: (message) => console.warn(message), - }, -): Promise { - try { - deps.checkGitRepo(); - - const rawEntry = await deps.readPendingEntryFile().catch(() => ''); - if (!rawEntry) { - return; - } - - let pending: CommitEntry; - try { - pending = JSON.parse(rawEntry) as CommitEntry; - } catch { - deps.warn('commit-echo hook: invalid pending hook entry; clearing stale state.'); - await deps.removePendingEntryFile(); - return; - } - - const message = deps.readLatestCommitMessage().trim(); - if (!message) { - await deps.removePendingEntryFile(); - return; - } - - const entry: CommitEntry = { - timestamp: pending.timestamp, - message, - diff: pending.diff, - model: pending.model, - provider: pending.provider, - }; - - try { - await deps.appendHistoryEntry(entry); - } finally { - await deps.removePendingEntryFile(); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - deps.warn(`commit-echo hook: ${message}`); - } -} + ] \ No newline at end of file From 00b68bf601088491a066733f38bb007999c761c9 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:16:29 +1000 Subject: [PATCH 2/5] test(hooks): cover CLI path precedence over PATH --- tests/hook.test.mjs | 960 +++----------------------------------------- 1 file changed, 57 insertions(+), 903 deletions(-) diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 95e461b..0a4da42 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -112,7 +112,7 @@ test('buildHookCommitMessage preserves template whitespace exactly', () => { assert.equal(result, `feat: add hook support\n\nExplain the change.\n\n${template}`); }); -test('buildPrepareCommitMsgHookScript chains backup hook with direct exec and shell fallback', () => { +test('buildPrepareCommitMsgHookScript chains backup hook with the captured CLI and PATH fallback', () => { const script = buildPrepareCommitMsgHookScript( 'c:\\tools\\commit-echo\\dist\\index.js', 'c:\\repo\\.git\\hooks\\prepare-commit-msg.commit-echo.bak', @@ -120,15 +120,11 @@ test('buildPrepareCommitMsgHookScript chains backup hook with direct exec and sh assert.match( script, - /if \[ -f 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo\.bak' \][\s\S]*if command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@"; elif \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'prepare-commit-msg' "\$@"; fi/, + /if \[ -f 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo\.bak' \][\\s\\S]*if \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'prepare-commit-msg' "\$@"; elif command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@"; fi/, ); assert.match( script, - /if command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@"; elif \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'prepare-commit-msg' "\$@"; fi/, - ); - assert.match( - script, - /if \[ -x 'c:\/repo\/\.git\/hooks\/prepare-commit-msg\.commit-echo.bak' \]; then 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo.bak' "\$@" \|\| exit \$\?; else sh 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo.bak' "\$@" \|\| exit \$\?; fi/, + /if \[ -x 'c:\/repo\/\.git\/hooks\/prepare-commit-msg\.commit-echo\.bak' \]; then 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo.bak' "\$@" \|\| exit \$\?; else sh 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo.bak' "\$@" \|\| exit \$\?; fi/, ); }); @@ -141,7 +137,7 @@ test('buildPostCommitHookScript invokes the post-commit entry point', () => { assert.match(script, /commit-echo managed hook post-commit/); assert.match( script, - /if command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'post-commit' "\$@"; elif \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'post-commit' "\$@"; fi/, + /if \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'post-commit' "\$@"; elif command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'post-commit' "\$@"; fi/, ); }); @@ -150,10 +146,61 @@ test('buildPrepareCommitMsgHookScript safely quotes paths containing shell metac assert.match( script, - /if command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@"; elif \[ -f '\/tmp\/commit-echo\/it'"'"'s\/\$\(bad\)\/index\.js' \]; then node '\/tmp\/commit-echo\/it'"'"'s\/\$\(bad\)\/index\.js' hook 'prepare-commit-msg' "\$@";/, + /if \[ -f '\/tmp\/commit-echo\/it'"'"'s\/\$\(bad\)\/index\.js' \]; then node '\/tmp\/commit-echo\/it'"'"'s\/\$\(bad\)\/index\.js' hook 'prepare-commit-msg' "\$@"; elif command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@";/, ); }); +test('generated hooks prefer the captured CLI over PATH and fall back when it is missing', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-resolution-test-')); + const binDir = join(tempDir, 'bin'); + const cliPath = join(tempDir, 'saved-cli.js'); + const hookPath = join(tempDir, 'hook.sh'); + const messagePath = join(tempDir, 'message'); + const resultPath = join(tempDir, 'result'); + const pathCliPath = join(binDir, 'commit-echo'); + + mkdirSync(binDir, { recursive: true }); + writeFileSync( + cliPath, + "require('node:fs').writeFileSync(process.env.COMMIT_ECHO_RESULT, 'saved');", + 'utf-8', + ); + writeFileSync( + pathCliPath, + '#!/bin/sh\nprintf "%s" path > "$COMMIT_ECHO_RESULT"\n', + 'utf-8', + ); + chmodSync(pathCliPath, 0o755); + writeFileSync( + hookPath, + `#!/bin/sh +${buildPrepareCommitMsgHookScript(cliPath)} +`, + 'utf-8', + ); + chmodSync(hookPath, 0o755); + + const env = { + ...process.env, + PATH: `${binDir}${process.env.PATH ? `:${process.env.PATH}` : ''}`, + COMMIT_ECHO_RESULT: resultPath, + }; + + try { + const firstRun = spawnSync(hookPath, [messagePath], { env, encoding: 'utf-8' }); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.equal(readFileSync(resultPath, 'utf-8'), 'saved'); + + rmSync(cliPath); + rmSync(resultPath); + const fallbackRun = spawnSync(hookPath, [messagePath], { env, encoding: 'utf-8' }); + assert.equal(fallbackRun.status, 0, fallbackRun.stderr); + assert.equal(readFileSync(resultPath, 'utf-8'), 'path'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('installPrepareCommitMsgHook writes a managed hook file inside the current repository', async () => { const repoDir = initRepo(); @@ -197,897 +244,4 @@ test('installCommitHooks preserves existing hooks and uninstall restores them', assert.equal(readFileSync(postBackup, 'utf-8'), originalPost); await installCommitHooks(join(repoDir, 'dist', 'index.js')); - assert.equal(readFileSync(prepareBackup, 'utf-8'), originalPrepare); - assert.equal(readFileSync(postBackup, 'utf-8'), originalPost); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 2); - assert.equal(result.removed.length, 0); - assert.equal(result.skipped.length, 0); - assert.equal(readFileSync(join(hooksDir, 'prepare-commit-msg'), 'utf-8'), originalPrepare); - assert.equal(readFileSync(join(hooksDir, 'post-commit'), 'utf-8'), originalPost); - assert.equal(existsSync(prepareBackup), false); - assert.equal(existsSync(postBackup), false); - if (process.platform !== 'win32') { - assert.equal(statSync(originalPreparePath).mode & 0o7777, 0o640); - assert.equal(statSync(originalPostPath).mode & 0o7777, 0o750); - } - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('installCommitHooks preserves empty and whitespace-only replacement hooks', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const postPath = join(hooksDir, 'post-commit'); - const prepareBackupPath = `${preparePath}.commit-echo.bak`; - const postBackupPath = `${postPath}.commit-echo.bak`; - writeFileSync(preparePath, '#!/bin/sh\necho original prepare\n', 'utf-8'); - writeFileSync(postPath, '#!/bin/sh\necho original post\n', 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - - const emptyPrepare = ''; - const whitespaceOnlyPost = ' \n\t '; - writeFileSync(preparePath, emptyPrepare, 'utf-8'); - writeFileSync(postPath, whitespaceOnlyPost, 'utf-8'); - - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - assert.equal(readFileSync(prepareBackupPath, 'utf-8'), emptyPrepare); - assert.equal(readFileSync(postBackupPath, 'utf-8'), whitespaceOnlyPost); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 2); - assert.equal(result.removed.length, 0); - assert.equal(readFileSync(preparePath, 'utf-8'), emptyPrepare); - assert.equal(readFileSync(postPath, 'utf-8'), whitespaceOnlyPost); - assert.equal(existsSync(prepareBackupPath), false); - assert.equal(existsSync(postBackupPath), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('installCommitHooks backs up empty and whitespace-only user hooks on first install', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const postPath = join(hooksDir, 'post-commit'); - const prepareBackupPath = `${preparePath}.commit-echo.bak`; - const postBackupPath = `${postPath}.commit-echo.bak`; - const emptyPrepare = ''; - const whitespaceOnlyPost = ' \n\t '; - writeFileSync(preparePath, emptyPrepare, 'utf-8'); - writeFileSync(postPath, whitespaceOnlyPost, 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - - assert.equal(readFileSync(prepareBackupPath, 'utf-8'), emptyPrepare); - assert.equal(readFileSync(postBackupPath, 'utf-8'), whitespaceOnlyPost); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 2); - assert.equal(result.removed.length, 0); - assert.equal(readFileSync(preparePath, 'utf-8'), emptyPrepare); - assert.equal(readFileSync(postPath, 'utf-8'), whitespaceOnlyPost); - assert.equal(existsSync(prepareBackupPath), false); - assert.equal(existsSync(postBackupPath), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('installCommitHooks preserves and restores symlink hooks', { skip: process.platform === 'win32' }, async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const sharedHooksDir = join(repoDir, 'shared-hooks'); - const targetPath = join(sharedHooksDir, 'prepare-commit-msg'); - const replacementTargetPath = join(sharedHooksDir, 'replacement-prepare-commit-msg'); - const hookPath = join(hooksDir, 'prepare-commit-msg'); - const backupPath = `${hookPath}.commit-echo.bak`; - const original = '#!/bin/sh\necho shared hook\n'; - const replacement = '#!/bin/sh\necho replacement hook\n'; - mkdirSync(sharedHooksDir, { recursive: true }); - writeFileSync(targetPath, original, 'utf-8'); - writeFileSync(replacementTargetPath, replacement, 'utf-8'); - symlinkSync(targetPath, hookPath, 'file'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - - assert.equal(readFileSync(targetPath, 'utf-8'), original); - assert.equal(lstatSync(hookPath).isSymbolicLink(), false); - assert.equal(lstatSync(backupPath).isSymbolicLink(), true); - assert.equal(readlinkSync(backupPath), targetPath); - - rmSync(hookPath); - symlinkSync(replacementTargetPath, hookPath, 'file'); - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - - assert.equal(readFileSync(replacementTargetPath, 'utf-8'), replacement); - assert.equal(lstatSync(backupPath).isSymbolicLink(), true); - assert.equal(readlinkSync(backupPath), replacementTargetPath); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 1); - assert.equal(result.removed.length, 1); - assert.equal(lstatSync(hookPath).isSymbolicLink(), true); - assert.equal(readlinkSync(hookPath), replacementTargetPath); - assert.equal(readFileSync(targetPath, 'utf-8'), original); - assert.equal(readFileSync(replacementTargetPath, 'utf-8'), replacement); - assert.equal(existsSync(backupPath), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test( - 'uninstallCommitHooks preserves an unreadable replacement and its backup', - { skip: process.platform === 'win32' }, - async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const originalPrepare = '#!/bin/sh\necho unreadable original\n'; - const originalPreparePath = join(hooksDir, 'prepare-commit-msg'); - const missingReplacementTarget = join(repoDir, 'missing-prepare-hook'); - const backupPath = `${originalPreparePath}.commit-echo.bak`; - writeFileSync(originalPreparePath, originalPrepare, 'utf-8'); - chmodSync(originalPreparePath, 0o640); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - rmSync(originalPreparePath); - symlinkSync(missingReplacementTarget, originalPreparePath, 'file'); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.removed.length, 1); - assert.equal(result.skipped.length, 0); - assert.equal(result.unreadable.length, 1); - assert.equal(lstatSync(originalPreparePath).isSymbolicLink(), true); - assert.equal(existsSync(backupPath), true); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }, -); - -test('installCommitHooks rejects an unowned backup collision and rolls back the other hook', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const backupPath = `${preparePath}.commit-echo.bak`; - const originalHook = '#!/bin/sh\necho existing hook\n'; - const existingBackup = '#!/bin/sh\necho unrelated backup\n'; - writeFileSync(preparePath, originalHook, 'utf-8'); - writeFileSync(backupPath, existingBackup, 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await assert.rejects( - () => installCommitHooks(join(repoDir, 'dist', 'index.js')), - /Refusing to overwrite existing backup/, - ); - assert.equal(readFileSync(preparePath, 'utf-8'), originalHook); - assert.equal(readFileSync(backupPath, 'utf-8'), existingBackup); - assert.equal(existsSync(join(hooksDir, 'post-commit')), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks preserves an empty user replacement after install', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const originalPrepare = '#!/bin/sh\necho original prepare\n'; - writeFileSync(preparePath, originalPrepare, 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - writeFileSync(preparePath, '', 'utf-8'); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.skipped.length, 1); - assert.equal(readFileSync(preparePath, 'utf-8'), ''); - assert.equal(existsSync(`${preparePath}.commit-echo.bak`), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks restores an owned backup after a managed hook is removed', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const backupPath = join(hooksDir, 'prepare-commit-msg.commit-echo.bak'); - const originalPrepare = '#!/bin/sh\necho original prepare\n'; - writeFileSync(preparePath, originalPrepare, 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - rmSync(preparePath); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 1); - assert.equal(readFileSync(preparePath, 'utf-8'), originalPrepare); - assert.equal(existsSync(backupPath), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks preserves a whitespace-only user replacement after install', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const postPath = join(hooksDir, 'post-commit'); - const backupPath = join(hooksDir, 'post-commit.commit-echo.bak'); - const whitespaceOnlyPost = ' \n\t '; - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - writeFileSync(postPath, whitespaceOnlyPost, 'utf-8'); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.skipped.length, 1); - assert.equal(readFileSync(postPath, 'utf-8'), whitespaceOnlyPost); - assert.equal(existsSync(backupPath), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test( - 'uninstallCommitHooks isolates an unreadable backup from the other hook', - async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const backupPath = `${preparePath}.commit-echo.bak`; - writeFileSync(preparePath, '#!/bin/sh\necho original\n', 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - rmSync(backupPath); - mkdirSync(backupPath); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.removed.length, 1); - assert.equal(result.unreadable.length, 1); - assert.match(readFileSync(preparePath, 'utf-8'), /commit-echo managed hook/); - assert.equal(existsSync(backupPath), true); - assert.equal(existsSync(join(hooksDir, 'post-commit')), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }, -); - -test('init --uninstall-hook exits non-zero when a hook is unreadable', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const backupPath = `${preparePath}.commit-echo.bak`; - const cliPath = join(process.cwd(), 'dist', 'index.js'); - writeFileSync(preparePath, '#!/bin/sh\necho original\n', 'utf-8'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(cliPath); - rmSync(backupPath); - mkdirSync(backupPath); - - const result = spawnSync(process.execPath, [cliPath, 'init', '--uninstall-hook'], { - cwd: repoDir, - encoding: 'utf-8', - env: { ...process.env, NO_COLOR: '1' }, - }); - - assert.equal(result.status, 1, result.stderr); - assert.match(`${result.stdout}\n${result.stderr}`, /Could not inspect 1 hook/); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks removes hooks created by commit-echo without deleting user hooks', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - const userPrepare = '#!/bin/sh\necho user replacement\n'; - writeFileSync(join(hooksDir, 'prepare-commit-msg'), userPrepare, 'utf-8'); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.removed.length, 1); - assert.equal(result.skipped.length, 1); - assert.equal(readFileSync(join(hooksDir, 'prepare-commit-msg'), 'utf-8'), userPrepare); - assert.equal(existsSync(join(hooksDir, 'post-commit')), false); - assert.equal(existsSync(join(hooksDir, 'prepare-commit-msg.commit-echo.bak')), false); - - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - assert.equal(readFileSync(join(hooksDir, 'prepare-commit-msg.commit-echo.bak'), 'utf-8'), userPrepare); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks does not treat marker text in a user hook as ownership', async () => { - const repoDir = initRepo(); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const userPrepare = '#!/bin/sh\necho user replacement\n# commit-echo managed hook prepare-commit-msg\n'; - - try { - await withCwdAsync(repoDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - writeFileSync(preparePath, userPrepare, 'utf-8'); - - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.removed.length, 1); - assert.equal(result.skipped.length, 1); - assert.equal(readFileSync(preparePath, 'utf-8'), userPrepare); - assert.equal(existsSync(`${preparePath}.commit-echo.bak`), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks restores legacy relative backups from a different working directory', async () => { - const repoDir = initRepo(); - const nestedDir = join(repoDir, 'nested', 'deep'); - mkdirSync(nestedDir, { recursive: true }); - const hooksDir = join(repoDir, '.git', 'hooks'); - const preparePath = join(hooksDir, 'prepare-commit-msg'); - const backupPath = `${preparePath}.commit-echo.bak`; - const ownerPath = `${backupPath}.owner`; - const originalPrepare = '#!/bin/sh\necho original prepare\n'; - writeFileSync(preparePath, originalPrepare, 'utf-8'); - - try { - await withCwdAsync(nestedDir, async () => { - await installCommitHooks(join(repoDir, 'dist', 'index.js')); - const legacyBackupPath = git( - ['rev-parse', '--git-path', 'hooks/prepare-commit-msg.commit-echo.bak'], - nestedDir, - ).trim(); - const managedHook = readFileSync(preparePath, 'utf-8'); - const absoluteBackupPath = backupPath.replace(/\\/g, '/'); - const legacyPath = legacyBackupPath.replace(/\\/g, '/'); - - assert.equal(isAbsolute(legacyBackupPath), false); - assert.ok(managedHook.includes(absoluteBackupPath)); - writeFileSync(preparePath, managedHook.replaceAll(absoluteBackupPath, legacyPath), 'utf-8'); - rmSync(ownerPath); - }); - - await withCwdAsync(repoDir, async () => { - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 1); - assert.equal(result.removed.length, 1); - assert.equal(readFileSync(preparePath, 'utf-8'), originalPrepare); - assert.equal(existsSync(backupPath), false); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('uninstallCommitHooks does not classify missing hooks as skipped user hooks', async () => { - const repoDir = initRepo(); - - try { - await withCwdAsync(repoDir, async () => { - const result = await uninstallCommitHooks(); - assert.equal(result.restored.length, 0); - assert.equal(result.removed.length, 0); - assert.equal(result.skipped.length, 0); - assert.equal(result.missing.length, 2); - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('runPrepareCommitMsgHook rewrites the message file with the first suggestion', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-run-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - writeFileSync(messageFile, '# comment line\n', 'utf-8'); - - try { - const deps = { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => ({ - suggestions: [{ index: 1, message: 'feat: prefill hook', body: 'Hook body' }], - profile: MOCK_PROFILE, - model: 'mock-model', - }), - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), - writePendingEntryFile: async () => {}, - warn: () => {}, - }; - - await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); - - const result = readFileSync(messageFile, 'utf-8'); - assert.ok(result.startsWith('feat: prefill hook\n\nHook body')); - assert.ok(result.includes('# comment line')); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('runPrepareCommitMsgHook leaves merge and commit sources unchanged', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-skip-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - writeFileSync(messageFile, 'original\n', 'utf-8'); - - try { - let called = false; - const deps = { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => { - called = true; - return { - suggestions: [{ index: 1, message: 'feat: should not be used' }], - profile: MOCK_PROFILE, - model: 'mock-model', - }; - }, - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), - writePendingEntryFile: async () => {}, - warn: () => {}, - }; - - await runPrepareCommitMsgHook({ messageFile, source: 'merge' }, deps); - assert.equal(called, false); - assert.equal(readFileSync(messageFile, 'utf-8'), 'original\n'); - - await runPrepareCommitMsgHook({ messageFile, source: 'commit' }, deps); - assert.equal(called, false); - assert.equal(readFileSync(messageFile, 'utf-8'), 'original\n'); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('runPrepareCommitMsgHook clears stale pending state for skipped sources', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-stale-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - writeFileSync(messageFile, '', 'utf-8'); - - try { - let pendingEntry = ''; - const deps = { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => ({ - suggestions: [{ index: 1, message: 'feat: stale pending', body: 'Hook body' }], - profile: MOCK_PROFILE, - model: 'mock-model', - }), - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), - writePendingEntryFile: async (content) => { - pendingEntry = content; - }, - removePendingEntryFile: async () => { - pendingEntry = ''; - }, - warn: () => {}, - }; - - await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); - assert.notEqual(pendingEntry, ''); - - await runPrepareCommitMsgHook({ messageFile, source: 'message' }, deps); - assert.equal(pendingEntry, ''); - - let historyEntry = ''; - await runPostCommitHook({ - checkGitRepo: () => {}, - readLatestCommitMessage: () => 'fix: manual message', - readPendingEntryFile: async () => pendingEntry, - appendHistoryEntry: async (entry) => { - historyEntry = entry; - }, - removePendingEntryFile: async () => { - pendingEntry = ''; - }, - warn: () => {}, - }); - - assert.equal(historyEntry, ''); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('runPrepareCommitMsgHook stores a pending history entry for post-commit', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-pending-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - writeFileSync(messageFile, '', 'utf-8'); - - try { - let pendingEntry = ''; - const deps = { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => ({ - suggestions: [{ index: 1, message: 'feat: prefill hook', body: 'Hook body' }], - profile: MOCK_PROFILE, - model: 'mock-model', - }), - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), - writePendingEntryFile: async (content) => { - pendingEntry = content; - }, - warn: () => {}, - }; - - await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); - - assert.match(pendingEntry, /"diff":"diff --git a\/file b\/file\\n\+hello"/); - assert.match(pendingEntry, /"model":"mock-model"/); - assert.match(pendingEntry, /"provider":"mock"/); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('runPrepareCommitMsgHook clears stale pending state when suggestion generation fails', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-error-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - writeFileSync(messageFile, '', 'utf-8'); - - try { - let pendingEntry = 'stale'; - const deps = { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => { - throw new Error('provider unavailable'); - }, - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), - writePendingEntryFile: async (content) => { - pendingEntry = content; - }, - removePendingEntryFile: async () => { - pendingEntry = ''; - }, - warn: () => {}, - }; - - await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); - - assert.equal(pendingEntry, ''); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - -test('runPostCommitHook appends the committed message to history and clears the pending entry', async () => { - let removed = false; - const entries = []; - - await runPostCommitHook({ - checkGitRepo: () => {}, - readLatestCommitMessage: () => 'feat: persist hook-driven commits', - readPendingEntryFile: async () => - JSON.stringify({ - timestamp: '2026-06-01T00:00:00.000Z', - diff: 'diff --git a/file b/file\n+hello', - model: 'mock-model', - provider: 'mock', - }), - appendHistoryEntry: async (entry) => { - entries.push(entry); - }, - removePendingEntryFile: async () => { - removed = true; - }, - warn: () => {}, - }); - - assert.equal(entries.length, 1); - assert.equal(entries[0].message, 'feat: persist hook-driven commits'); - assert.equal(entries[0].model, 'mock-model'); - assert.equal(entries[0].provider, 'mock'); - assert.equal(entries[0].diff, 'diff --git a/file b/file\n+hello'); - assert.equal(removed, true); -}); - -test('runPostCommitHook clears malformed pending entries', async () => { - let removed = false; - let appended = false; - - await runPostCommitHook({ - checkGitRepo: () => {}, - readLatestCommitMessage: () => 'feat: should not append', - readPendingEntryFile: async () => '{not-json', - appendHistoryEntry: async () => { - appended = true; - }, - removePendingEntryFile: async () => { - removed = true; - }, - warn: () => {}, - }); - - assert.equal(appended, false); - assert.equal(removed, true); -}); - -test('runPostCommitHook clears pending entry when history append fails', async () => { - let removed = false; - - await runPostCommitHook({ - checkGitRepo: () => {}, - readLatestCommitMessage: () => 'feat: should still clear pending on error', - readPendingEntryFile: async () => - JSON.stringify({ - timestamp: '2026-06-01T00:00:00.000Z', - diff: 'diff --git a/file b/file\n+hello', - model: 'mock-model', - provider: 'mock', - }), - appendHistoryEntry: async () => { - throw new Error('disk full'); - }, - removePendingEntryFile: async () => { - removed = true; - }, - warn: () => {}, - }); - - assert.equal(removed, true); -}); - -test('runPrepareCommitMsgHook times out LLM work without changing the message', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-timeout-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - const originalMessage = 'original commit title\n'; - writeFileSync(messageFile, originalMessage, 'utf-8'); - - try { - let aborted = false; - let pendingCleared = 0; - let warning = ''; - - const deps = { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async (_config, _diff, _profile, _apiKey, _truncation, signal) => - new Promise((_resolve, reject) => { - signal?.addEventListener( - 'abort', - () => { - aborted = true; - reject(signal.reason); - }, - { once: true }, - ); - }), - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), - writePendingEntryFile: async () => { - throw new Error('pending entry should not be written after timeout'); - }, - removePendingEntryFile: async () => { - pendingCleared += 1; - }, - warn: (message) => { - warning = message; - }, - timeoutMs: 20, - }; - - await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); - - assert.equal(aborted, true); - assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); - assert.equal(pendingCleared, 1); - assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); - - -test('runPrepareCommitMsgHook applies its deadline to config loading', async () => { - let warning = ''; - let configLoaded = false; - let resolveConfig; - - const configPromise = new Promise((resolve) => { - resolveConfig = resolve; - }); - - const hookPromise = runPrepareCommitMsgHook( - { messageFile: '/tmp/commit-echo-timeout-test', source: 'template' }, - { - checkGitRepo: () => {}, - loadConfig: async () => { - const config = await configPromise; - configLoaded = true; - return config; - }, - getStagedDiff: () => { - throw new Error('staged diff should not run after the config deadline expires'); - }, - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => ({ suggestions: [] }), - readMessageFile: async () => '', - writeMessageFile: async () => {}, - writePendingEntryFile: async () => {}, - removePendingEntryFile: async () => {}, - warn: (message) => { - warning = message; - }, - timeoutMs: 20, - }, - ); - - await hookPromise; - assert.equal(configLoaded, false); - assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); - - resolveConfig({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }); - await configPromise; - assert.equal(configLoaded, true); -}); - -test('runPrepareCommitMsgHook waits for and rolls back a late message write', async () => { - const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-write-timeout-')); - const messageFile = join(repoDir, 'COMMIT_EDITMSG'); - const originalMessage = 'original commit title\n'; - writeFileSync(messageFile, originalMessage, 'utf-8'); - - try { - let releaseWrite; - let signalWriteStarted; - const writeStarted = new Promise((resolve) => { - signalWriteStarted = resolve; - }); - let pendingCleared = 0; - let warning = ''; - - const hookPromise = runPrepareCommitMsgHook( - { messageFile, source: 'template' }, - { - checkGitRepo: () => {}, - loadConfig: async () => ({ - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }), - getStagedDiff: () => ({ - diff: 'diff --git a/file b/file\n+hello', - hasChanges: true, - staged: true, - }), - buildProfile: async () => MOCK_PROFILE, - generateSuggestions: async () => ({ - suggestions: [{ index: 1, message: 'feat: generated message' }], - }), - readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), - writeMessageFile: async (filePath, nextContent) => { - if (nextContent !== originalMessage) { - await new Promise((resolve) => { - releaseWrite = resolve; - signalWriteStarted(); - }); - } - writeFileSync(filePath, nextContent, 'utf-8'); - }, - writePendingEntryFile: async () => { - throw new Error('pending entry should not be written after the late message write'); - }, - removePendingEntryFile: async () => { - pendingCleared += 1; - }, - warn: (message) => { - warning = message; - }, - timeoutMs: 20, - }, - ); - - await writeStarted; - await new Promise((resolve) => setTimeout(resolve, 30)); - releaseWrite(); - await hookPromise; - - assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); - assert.equal(pendingCleared, 1); - assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } -}); + assert.equal(readFileSync(prepareBackup, 'utf-8'), originalPrepare); \ No newline at end of file From f35e71fcd7bf53dbe80d72bbc08c7fac968b82e5 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:16:59 +1000 Subject: [PATCH 3/5] fix(hooks): pin managed hooks to installed CLI --- src/git/hook.ts | 608 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 607 insertions(+), 1 deletion(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index bb59d91..9925fc1 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -197,4 +197,610 @@ function buildHookScript(hookName: string, cliPath: string, backupPath?: string) : '', `if [ -f ${quotedCliPath} ]; then node ${quotedCliPath} hook ${quotedHookName} "$@"; elif command -v commit-echo >/dev/null 2>&1; then commit-echo hook ${quotedHookName} "$@"; fi`, '', - ] \ No newline at end of file + ] + .filter((line) => line.length > 0) + .join('\n'); +} + +export function buildPrepareCommitMsgHookScript(cliPath: string, backupPath?: string): string { + return buildHookScript(PREPARE_COMMIT_MSG_HOOK_NAME, cliPath, backupPath); +} + +export function buildPostCommitHookScript(cliPath: string, backupPath?: string): string { + return buildHookScript(POST_COMMIT_HOOK_NAME, cliPath, backupPath); +} + +async function backupHook( + hookPath: string, + backupPath: string, + hookStats: Awaited>, +): Promise { + const stagedPath = `${backupPath}.tmp-${randomUUID()}`; + + if (hookStats.isSymbolicLink()) { + try { + await symlink(await readlink(hookPath, 'utf8'), stagedPath, 'file'); + await rename(stagedPath, backupPath); + } catch (error) { + await rm(stagedPath, { force: true }).catch(() => {}); + throw error; + } + } else { + try { + await copyFile(hookPath, stagedPath); + await chmod(stagedPath, Number(hookStats.mode) & 0o7777); + await rename(stagedPath, backupPath); + } catch (error) { + await rm(stagedPath, { force: true }).catch(() => {}); + throw error; + } + } +} + +async function writeBackupOwner(ownerPath: string): Promise { + const stagedPath = `${ownerPath}.tmp-${randomUUID()}`; + try { + await writeFile(stagedPath, `${BACKUP_OWNER_MARKER}\n`, 'utf-8'); + await rename(stagedPath, ownerPath); + } finally { + await rm(stagedPath, { force: true }).catch(() => {}); + } +} + +async function replacePreparedPath(preparedPath: string, targetPath: string): Promise { + const displacedPath = `${targetPath}.tmp-restore-${randomUUID()}`; + const targetStats = await lstatIfExists(targetPath); + let displaced = false; + + try { + if (targetStats) { + await rename(targetPath, displacedPath); + displaced = true; + } + await rename(preparedPath, targetPath); + } catch (error) { + if (displaced) { + await rename(displacedPath, targetPath).catch(() => {}); + } + throw error; + } + + if (displaced) { + await rm(displacedPath, { force: true }); + } +} + +async function restoreHookBackup( + hookPath: string, + backupPath: string, + backupStats: Awaited>, + ownerPath?: string, +): Promise { + if (!backupStats.isFile() && !backupStats.isSymbolicLink()) { + throw new Error(`Refusing to restore non-regular hook backup at ${backupPath}`); + } + + const stagedPath = `${hookPath}.tmp-restore-${randomUUID()}`; + + try { + if (backupStats.isSymbolicLink()) { + await symlink(await readlink(backupPath, 'utf8'), stagedPath, 'file'); + } else { + await copyFile(backupPath, stagedPath); + await chmod(stagedPath, Number(backupStats.mode) & 0o7777); + } + + await replacePreparedPath(stagedPath, hookPath); + } catch (error) { + await rm(stagedPath, { force: true }).catch(() => {}); + throw error; + } + + await rm(backupPath, { force: true }); + if (ownerPath) { + await rm(ownerPath, { force: true }); + } +} + +type PathSnapshot = + | { kind: 'missing' } + | { kind: 'file'; content: Buffer; mode: number } + | { kind: 'symlink'; target: string }; + +async function snapshotPath(path: string): Promise { + const stats = await lstatIfExists(path); + if (!stats) { + return { kind: 'missing' }; + } + if (stats.isSymbolicLink()) { + return { kind: 'symlink', target: await readlink(path, 'utf8') }; + } + if (!stats.isFile()) { + throw new Error(`Expected a file or symlink at ${path}`); + } + return { kind: 'file', content: await readFile(path), mode: Number(stats.mode) & 0o7777 }; +} + +async function restoreSnapshot(path: string, snapshot: PathSnapshot): Promise { + if (snapshot.kind === 'missing') { + await rm(path, { force: true }); + return; + } + + const stagedPath = `${path}.tmp-rollback-${randomUUID()}`; + try { + if (snapshot.kind === 'symlink') { + await symlink(snapshot.target, stagedPath, 'file'); + } else { + await writeFile(stagedPath, snapshot.content); + await chmod(stagedPath, snapshot.mode); + } + await replacePreparedPath(stagedPath, path); + } finally { + await rm(stagedPath, { force: true }).catch(() => {}); + } +} + +interface InstalledHookChange { + path: string; + rollback: () => Promise; +} + +interface ManagedHookState { + hookPath: string; + backupPath: string; + ownerPath: string; + hookSnapshot: PathSnapshot; + backupSnapshot: PathSnapshot; + ownerSnapshot: PathSnapshot; + isManagedHook: boolean; + hasBackup: boolean; + hasOwner: boolean; + validOwner: boolean; + referencesBackup: boolean; +} + +async function inspectManagedHook(hookName: string): Promise { + const { hookPath, backupPath, legacyBackupSuffix, ownerPath } = resolveHookPaths(hookName); + const hookSnapshot = await snapshotPath(hookPath); + const backupSnapshot = await snapshotPath(backupPath); + const ownerSnapshot = await snapshotPath(ownerPath); + const existingHook = hookSnapshot.kind === 'file' ? hookSnapshot.content.toString('utf8') : ''; + const isManagedHook = hookSnapshot.kind === 'file' && isManagedHookContent(hookName, existingHook); + + return { + hookPath, + backupPath, + ownerPath, + hookSnapshot, + backupSnapshot, + ownerSnapshot, + isManagedHook, + hasBackup: backupSnapshot.kind !== 'missing', + hasOwner: ownerSnapshot.kind !== 'missing', + validOwner: ownerSnapshot.kind === 'file' && ownerSnapshot.content.toString('utf8').trim() === BACKUP_OWNER_MARKER, + referencesBackup: isManagedHook && referencesBackupPath(existingHook, backupPath, legacyBackupSuffix), + }; +} + +function validateManagedHookBackup(state: ManagedHookState): void { + if (state.hasOwner && !state.validOwner) { + throw new Error( + `Refusing to use invalid backup ownership marker at ${state.ownerPath}; remove that file to reinstall the hook.`, + ); + } + if (state.hasBackup && !state.isManagedHook && !state.validOwner) { + throw new Error(`Refusing to overwrite existing backup at ${state.backupPath}`); + } + if (state.hasBackup && state.isManagedHook && !state.referencesBackup && !state.validOwner) { + throw new Error(`Refusing to use unowned backup at ${state.backupPath}`); + } +} + +async function prepareManagedHookBackup(state: ManagedHookState): Promise { + const isReplacement = state.hookSnapshot.kind !== 'missing' && !state.isManagedHook; + if (isReplacement && state.validOwner) { + await rm(state.backupPath, { force: true }); + await rm(state.ownerPath, { force: true }); + } + + const latestBackupStats = await lstatIfExists(state.backupPath); + if (state.hookSnapshot.kind !== 'missing' && !state.isManagedHook && !latestBackupStats) { + await backupHook(state.hookPath, state.backupPath, await lstat(state.hookPath)); + await writeBackupOwner(state.ownerPath); + } else if (state.isManagedHook && latestBackupStats && state.referencesBackup && !state.validOwner) { + // Adopt backups created by older commit-echo versions so future uninstall + // operations can distinguish them from user-owned collision files. + await writeBackupOwner(state.ownerPath); + } +} + +async function installManagedHook(hookName: string, cliPath: string): Promise { + const state = await inspectManagedHook(hookName); + await mkdir(dirname(state.hookPath), { recursive: true }); + validateManagedHookBackup(state); + + try { + await prepareManagedHookBackup(state); + + const effectiveBackupStats = await lstatIfExists(state.backupPath); + const script = buildHookScript(hookName, cliPath, effectiveBackupStats ? state.backupPath : undefined); + const stagedPath = `${state.hookPath}.tmp-install-${randomUUID()}`; + try { + await writeFile(stagedPath, `${script}\n`, 'utf-8'); + await chmod(stagedPath, 0o755); + await replacePreparedPath(stagedPath, state.hookPath); + } finally { + await rm(stagedPath, { force: true }).catch(() => {}); + } + } catch (error) { + await restoreSnapshot(state.ownerPath, state.ownerSnapshot).catch(() => {}); + await restoreSnapshot(state.backupPath, state.backupSnapshot).catch(() => {}); + await restoreSnapshot(state.hookPath, state.hookSnapshot).catch(() => {}); + throw error; + } + + return { + path: state.hookPath, + rollback: async () => { + await restoreSnapshot(state.ownerPath, state.ownerSnapshot); + await restoreSnapshot(state.backupPath, state.backupSnapshot); + await restoreSnapshot(state.hookPath, state.hookSnapshot); + }, + }; +} + +type HookUninstallAction = 'restored' | 'removed' | 'skipped' | 'missing' | 'unreadable'; + +interface HookUninstallState { + hookPath: string; + backupPath: string; + ownerPath: string; + hookStats: Awaited> | null; + backupStats: Awaited> | null; + hookContent: string; + isManagedHook: boolean; + backupIsOwned: boolean; +} + +async function inspectHookForUninstall(hookName: string, paths: HookPaths): Promise { + const { hookPath, backupPath, ownerPath } = paths; + const hookStats = await lstatIfExists(hookPath); + const backupStats = await lstatIfExists(backupPath); + const ownerStats = await lstatIfExists(ownerPath); + let isManagedHook = false; + let hookContent = ''; + + if (hookStats) { + try { + hookContent = await readFile(hookPath, 'utf-8'); + isManagedHook = isManagedHookContent(hookName, hookContent); + } catch { + return null; + } + } + + let ownerIsValid = false; + if (ownerStats) { + try { + ownerIsValid = + !ownerStats.isSymbolicLink() && (await readFile(ownerPath, 'utf-8')).trim() === BACKUP_OWNER_MARKER; + } catch { + return null; + } + } + + if (backupStats && !backupStats.isFile() && !backupStats.isSymbolicLink()) { + return null; + } + + const backupIsOwned = Boolean( + backupStats && + (ownerIsValid || (isManagedHook && referencesBackupPath(hookContent, backupPath, paths.legacyBackupSuffix))), + ); + + return { hookPath, backupPath, ownerPath, hookStats, backupStats, hookContent, isManagedHook, backupIsOwned }; +} + +async function restoreOwnedHookBackup(state: HookUninstallState): Promise<'restored' | 'unreadable' | null> { + const backupStats = state.backupStats; + if ( + !backupStats || + !state.backupIsOwned || + // An existing non-managed path may be a user replacement even when empty. + !(state.isManagedHook || !state.hookStats) + ) { + return null; + } + + try { + await restoreHookBackup(state.hookPath, state.backupPath, backupStats, state.ownerPath); + return 'restored'; + } catch { + return 'unreadable'; + } +} + +async function uninstallManagedHook(hookName: string): Promise<{ path: string; action: HookUninstallAction }> { + const paths = resolveHookPaths(hookName); + const state = await inspectHookForUninstall(hookName, paths); + const { hookPath } = paths; + if (!state) { + return { path: hookPath, action: 'unreadable' }; + } + + const restoreAction = await restoreOwnedHookBackup(state); + if (restoreAction) { + return { path: hookPath, action: restoreAction }; + } + + if (state.isManagedHook) { + await rm(state.hookPath, { force: true }); + await rm(state.ownerPath, { force: true }); + return { path: hookPath, action: 'removed' }; + } + + if (state.backupStats && state.backupIsOwned) { + await rm(state.backupPath, { force: true }); + await rm(state.ownerPath, { force: true }); + } + + return { path: hookPath, action: state.hookStats ? 'skipped' : 'missing' }; +} + +export async function installCommitHooks(cliPath = process.argv[1] ?? 'dist/index.js'): Promise { + const resolvedCliPath = + cliPath === process.argv[1] ? fileURLToPath(new URL('../index.js', import.meta.url)) : cliPath; + + checkGitRepo(); + let installed: InstalledHookChange[] = []; + try { + installed = [await installManagedHook(POST_COMMIT_HOOK_NAME, resolvedCliPath)]; + installed = [...installed, await installManagedHook(PREPARE_COMMIT_MSG_HOOK_NAME, resolvedCliPath)]; + } catch (error) { + const rollbackOrder = [...installed]; + rollbackOrder.reverse(); + for (const change of rollbackOrder) { + await change.rollback().catch(() => {}); + } + throw error; + } + + return { prepareCommitMsgPath: installed[1].path, postCommitPath: installed[0].path }; +} + +export async function installPrepareCommitMsgHook(cliPath = process.argv[1] ?? 'dist/index.js'): Promise { + const { prepareCommitMsgPath } = await installCommitHooks(cliPath); + return prepareCommitMsgPath; +} + +export async function uninstallCommitHooks(): Promise { + checkGitRepo(); + + const results = []; + for (const hookName of [PREPARE_COMMIT_MSG_HOOK_NAME, POST_COMMIT_HOOK_NAME]) { + try { + results.push(await uninstallManagedHook(hookName)); + } catch { + results.push({ path: resolveHookPath(hookName), action: 'unreadable' as const }); + } + } + + return { + restored: results.filter((result) => result.action === 'restored').map((result) => result.path), + removed: results.filter((result) => result.action === 'removed').map((result) => result.path), + skipped: results.filter((result) => result.action === 'skipped').map((result) => result.path), + missing: results.filter((result) => result.action === 'missing').map((result) => result.path), + unreadable: results.filter((result) => result.action === 'unreadable').map((result) => result.path), + }; +} + +function buildPendingHookEntry(config: Config, diff: string): string { + return JSON.stringify({ + timestamp: new Date().toISOString(), + diff, + model: config.model, + provider: config.provider, + }); +} + +export async function runPrepareCommitMsgHook( + args: PrepareCommitMsgHookArgs, + deps: PrepareCommitMsgHookDeps = { + checkGitRepo: checkGitRepoWithSignal, + loadConfig, + getStagedDiff: (signal) => getStagedDiffWithSignal(process.cwd(), undefined, signal), + buildProfile, + generateSuggestions, + readMessageFile: async (messageFile) => readFile(messageFile, 'utf-8'), + writeMessageFile: async (messageFile, content) => writeFile(messageFile, content, 'utf-8'), + writePendingEntryFile: async (content) => writeFile(resolvePendingEntryPath(), content, 'utf-8'), + removePendingEntryFile: async () => rm(resolvePendingEntryPath(), { force: true }), + warn: (message) => console.warn(message), + }, +): Promise { + if (shouldSkipPrepareCommitMsgHook(args.source)) { + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + const controller = new AbortController(); + const timeoutMs = deps.timeoutMs ?? PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + let timeoutError: Error | undefined; + let timeoutId: ReturnType | undefined; + let timedOut = false; + let originalMessage: string | undefined; + let messageWriteAttempted = false; + + const abortForTimeout = () => { + if (timedOut) { + return; + } + timedOut = true; + timeoutError = new Error('timed out after ' + timeoutMs + 'ms; leaving commit message unchanged.'); + controller.abort(timeoutError); + }; + + const ensureWithinDeadline = () => { + if (controller.signal.aborted) { + throw controller.signal.reason instanceof Error + ? controller.signal.reason + : (timeoutError ?? new Error('commit-echo hook request was cancelled')); + } + + if (Date.now() >= deadline) { + abortForTimeout(); + throw timeoutError!; + } + }; + + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortForTimeout(); + reject(timeoutError!); + }, timeoutMs); + }); + + let hookOperation: Promise | undefined; + + try { + hookOperation = (async () => { + await deps.checkGitRepo(controller.signal); + ensureWithinDeadline(); + + let config: Config | null; + try { + config = await deps.loadConfig(); + } catch { + ensureWithinDeadline(); + config = null; + } + ensureWithinDeadline(); + + if (!config) { + deps.warn('commit-echo hook: no configuration found; skipping.'); + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + const diffResult = await deps.getStagedDiff(controller.signal); + ensureWithinDeadline(); + + if (!diffResult.hasChanges) { + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + const profile = await deps.buildProfile(config.historySize); + ensureWithinDeadline(); + + const { suggestions } = await deps.generateSuggestions( + config, + diffResult.diff, + profile, + undefined, + undefined, + controller.signal, + ); + ensureWithinDeadline(); + + const selected = suggestions[0]; + if (!selected) { + deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.'); + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + originalMessage = await deps.readMessageFile(args.messageFile); + ensureWithinDeadline(); + + const nextContent = buildHookCommitMessage(selected, originalMessage); + ensureWithinDeadline(); + messageWriteAttempted = true; + await deps.writeMessageFile(args.messageFile, nextContent); + ensureWithinDeadline(); + + await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff)); + ensureWithinDeadline(); + })(); + + await Promise.race([hookOperation, timeout]); + } catch (err) { + if (timedOut) { + if (messageWriteAttempted) { + await hookOperation?.catch(() => {}); + } else { + void hookOperation?.catch(() => {}); + } + + if (messageWriteAttempted && originalMessage !== undefined) { + await deps.writeMessageFile(args.messageFile, originalMessage).catch(() => {}); + } + + await clearPendingEntryFile(deps.removePendingEntryFile); + const message = timeoutError?.message ?? (err instanceof Error ? err.message : String(err)); + deps.warn('commit-echo hook: ' + message); + return; + } + + await clearPendingEntryFile(deps.removePendingEntryFile); + const message = err instanceof Error ? err.message : String(err); + deps.warn('commit-echo hook: ' + message); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +export async function runPostCommitHook( + deps: PostCommitHookDeps = { + checkGitRepo, + readLatestCommitMessage: () => + execFileSync(getGitExecutable(), ['log', '-1', '--pretty=%B'], { encoding: 'utf-8' }).trim(), + readPendingEntryFile: async () => readFile(resolvePendingEntryPath(), 'utf-8'), + appendHistoryEntry: appendEntry, + removePendingEntryFile: async () => rm(resolvePendingEntryPath(), { force: true }), + warn: (message) => console.warn(message), + }, +): Promise { + try { + deps.checkGitRepo(); + + const rawEntry = await deps.readPendingEntryFile().catch(() => ''); + if (!rawEntry) { + return; + } + + let pending: CommitEntry; + try { + pending = JSON.parse(rawEntry) as CommitEntry; + } catch { + deps.warn('commit-echo hook: invalid pending hook entry; clearing stale state.'); + await deps.removePendingEntryFile(); + return; + } + + const message = deps.readLatestCommitMessage().trim(); + if (!message) { + await deps.removePendingEntryFile(); + return; + } + + const entry: CommitEntry = { + timestamp: pending.timestamp, + message, + diff: pending.diff, + model: pending.model, + provider: pending.provider, + }; + + try { + await deps.appendHistoryEntry(entry); + } finally { + await deps.removePendingEntryFile(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + deps.warn(`commit-echo hook: ${message}`); + } +} From 010a04263d4ebcb189b1852cc8f55479ab37df88 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:04 +1000 Subject: [PATCH 4/5] test(hooks): cover CLI path precedence over PATH --- tests/hook.test.mjs | 897 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 895 insertions(+), 2 deletions(-) diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 0a4da42..b9f63af 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -120,7 +120,7 @@ test('buildPrepareCommitMsgHookScript chains backup hook with the captured CLI a assert.match( script, - /if \[ -f 'c:\/repo\/\.git\/hooks\/prepare-commit-msg.commit-echo\.bak' \][\\s\\S]*if \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'prepare-commit-msg' "\$@"; elif command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@"; fi/, + /if \[ -f 'c:\/tools\/commit-echo\/dist\/index\.js' \]; then node 'c:\/tools\/commit-echo\/dist\/index\.js' hook 'prepare-commit-msg' "\$@"; elif command -v commit-echo >\/dev\/null 2>&1; then commit-echo hook 'prepare-commit-msg' "\$@"; fi/, ); assert.match( script, @@ -244,4 +244,897 @@ test('installCommitHooks preserves existing hooks and uninstall restores them', assert.equal(readFileSync(postBackup, 'utf-8'), originalPost); await installCommitHooks(join(repoDir, 'dist', 'index.js')); - assert.equal(readFileSync(prepareBackup, 'utf-8'), originalPrepare); \ No newline at end of file + assert.equal(readFileSync(prepareBackup, 'utf-8'), originalPrepare); + assert.equal(readFileSync(postBackup, 'utf-8'), originalPost); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 2); + assert.equal(result.removed.length, 0); + assert.equal(result.skipped.length, 0); + assert.equal(readFileSync(join(hooksDir, 'prepare-commit-msg'), 'utf-8'), originalPrepare); + assert.equal(readFileSync(join(hooksDir, 'post-commit'), 'utf-8'), originalPost); + assert.equal(existsSync(prepareBackup), false); + assert.equal(existsSync(postBackup), false); + if (process.platform !== 'win32') { + assert.equal(statSync(originalPreparePath).mode & 0o7777, 0o640); + assert.equal(statSync(originalPostPath).mode & 0o7777, 0o750); + } + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('installCommitHooks preserves empty and whitespace-only replacement hooks', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const postPath = join(hooksDir, 'post-commit'); + const prepareBackupPath = `${preparePath}.commit-echo.bak`; + const postBackupPath = `${postPath}.commit-echo.bak`; + writeFileSync(preparePath, '#!/bin/sh\necho original prepare\n', 'utf-8'); + writeFileSync(postPath, '#!/bin/sh\necho original post\n', 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + + const emptyPrepare = ''; + const whitespaceOnlyPost = ' \n\t '; + writeFileSync(preparePath, emptyPrepare, 'utf-8'); + writeFileSync(postPath, whitespaceOnlyPost, 'utf-8'); + + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + assert.equal(readFileSync(prepareBackupPath, 'utf-8'), emptyPrepare); + assert.equal(readFileSync(postBackupPath, 'utf-8'), whitespaceOnlyPost); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 2); + assert.equal(result.removed.length, 0); + assert.equal(readFileSync(preparePath, 'utf-8'), emptyPrepare); + assert.equal(readFileSync(postPath, 'utf-8'), whitespaceOnlyPost); + assert.equal(existsSync(prepareBackupPath), false); + assert.equal(existsSync(postBackupPath), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('installCommitHooks backs up empty and whitespace-only user hooks on first install', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const postPath = join(hooksDir, 'post-commit'); + const prepareBackupPath = `${preparePath}.commit-echo.bak`; + const postBackupPath = `${postPath}.commit-echo.bak`; + const emptyPrepare = ''; + const whitespaceOnlyPost = ' \n\t '; + writeFileSync(preparePath, emptyPrepare, 'utf-8'); + writeFileSync(postPath, whitespaceOnlyPost, 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + + assert.equal(readFileSync(prepareBackupPath, 'utf-8'), emptyPrepare); + assert.equal(readFileSync(postBackupPath, 'utf-8'), whitespaceOnlyPost); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 2); + assert.equal(result.removed.length, 0); + assert.equal(readFileSync(preparePath, 'utf-8'), emptyPrepare); + assert.equal(readFileSync(postPath, 'utf-8'), whitespaceOnlyPost); + assert.equal(existsSync(prepareBackupPath), false); + assert.equal(existsSync(postBackupPath), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('installCommitHooks preserves and restores symlink hooks', { skip: process.platform === 'win32' }, async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const sharedHooksDir = join(repoDir, 'shared-hooks'); + const targetPath = join(sharedHooksDir, 'prepare-commit-msg'); + const replacementTargetPath = join(sharedHooksDir, 'replacement-prepare-commit-msg'); + const hookPath = join(hooksDir, 'prepare-commit-msg'); + const backupPath = `${hookPath}.commit-echo.bak`; + const original = '#!/bin/sh\necho shared hook\n'; + const replacement = '#!/bin/sh\necho replacement hook\n'; + mkdirSync(sharedHooksDir, { recursive: true }); + writeFileSync(targetPath, original, 'utf-8'); + writeFileSync(replacementTargetPath, replacement, 'utf-8'); + symlinkSync(targetPath, hookPath, 'file'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + + assert.equal(readFileSync(targetPath, 'utf-8'), original); + assert.equal(lstatSync(hookPath).isSymbolicLink(), false); + assert.equal(lstatSync(backupPath).isSymbolicLink(), true); + assert.equal(readlinkSync(backupPath), targetPath); + + rmSync(hookPath); + symlinkSync(replacementTargetPath, hookPath, 'file'); + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + + assert.equal(readFileSync(replacementTargetPath, 'utf-8'), replacement); + assert.equal(lstatSync(backupPath).isSymbolicLink(), true); + assert.equal(readlinkSync(backupPath), replacementTargetPath); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 1); + assert.equal(result.removed.length, 1); + assert.equal(lstatSync(hookPath).isSymbolicLink(), true); + assert.equal(readlinkSync(hookPath), replacementTargetPath); + assert.equal(readFileSync(targetPath, 'utf-8'), original); + assert.equal(readFileSync(replacementTargetPath, 'utf-8'), replacement); + assert.equal(existsSync(backupPath), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test( + 'uninstallCommitHooks preserves an unreadable replacement and its backup', + { skip: process.platform === 'win32' }, + async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const originalPrepare = '#!/bin/sh\necho unreadable original\n'; + const originalPreparePath = join(hooksDir, 'prepare-commit-msg'); + const missingReplacementTarget = join(repoDir, 'missing-prepare-hook'); + const backupPath = `${originalPreparePath}.commit-echo.bak`; + writeFileSync(originalPreparePath, originalPrepare, 'utf-8'); + chmodSync(originalPreparePath, 0o640); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + rmSync(originalPreparePath); + symlinkSync(missingReplacementTarget, originalPreparePath, 'file'); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.removed.length, 1); + assert.equal(result.skipped.length, 0); + assert.equal(result.unreadable.length, 1); + assert.equal(lstatSync(originalPreparePath).isSymbolicLink(), true); + assert.equal(existsSync(backupPath), true); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }, +); + +test('installCommitHooks rejects an unowned backup collision and rolls back the other hook', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const backupPath = `${preparePath}.commit-echo.bak`; + const originalHook = '#!/bin/sh\necho existing hook\n'; + const existingBackup = '#!/bin/sh\necho unrelated backup\n'; + writeFileSync(preparePath, originalHook, 'utf-8'); + writeFileSync(backupPath, existingBackup, 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await assert.rejects( + () => installCommitHooks(join(repoDir, 'dist', 'index.js')), + /Refusing to overwrite existing backup/, + ); + assert.equal(readFileSync(preparePath, 'utf-8'), originalHook); + assert.equal(readFileSync(backupPath, 'utf-8'), existingBackup); + assert.equal(existsSync(join(hooksDir, 'post-commit')), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks preserves an empty user replacement after install', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const originalPrepare = '#!/bin/sh\necho original prepare\n'; + writeFileSync(preparePath, originalPrepare, 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + writeFileSync(preparePath, '', 'utf-8'); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.skipped.length, 1); + assert.equal(readFileSync(preparePath, 'utf-8'), ''); + assert.equal(existsSync(`${preparePath}.commit-echo.bak`), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks restores an owned backup after a managed hook is removed', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const backupPath = join(hooksDir, 'prepare-commit-msg.commit-echo.bak'); + const originalPrepare = '#!/bin/sh\necho original prepare\n'; + writeFileSync(preparePath, originalPrepare, 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + rmSync(preparePath); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 1); + assert.equal(readFileSync(preparePath, 'utf-8'), originalPrepare); + assert.equal(existsSync(backupPath), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks preserves a whitespace-only user replacement after install', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const postPath = join(hooksDir, 'post-commit'); + const backupPath = join(hooksDir, 'post-commit.commit-echo.bak'); + const whitespaceOnlyPost = ' \n\t '; + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + writeFileSync(postPath, whitespaceOnlyPost, 'utf-8'); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.skipped.length, 1); + assert.equal(readFileSync(postPath, 'utf-8'), whitespaceOnlyPost); + assert.equal(existsSync(backupPath), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test( + 'uninstallCommitHooks isolates an unreadable backup from the other hook', + async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const backupPath = `${preparePath}.commit-echo.bak`; + writeFileSync(preparePath, '#!/bin/sh\necho original\n', 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + rmSync(backupPath); + mkdirSync(backupPath); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.removed.length, 1); + assert.equal(result.unreadable.length, 1); + assert.match(readFileSync(preparePath, 'utf-8'), /commit-echo managed hook/); + assert.equal(existsSync(backupPath), true); + assert.equal(existsSync(join(hooksDir, 'post-commit')), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }, +); + +test('init --uninstall-hook exits non-zero when a hook is unreadable', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const backupPath = `${preparePath}.commit-echo.bak`; + const cliPath = join(process.cwd(), 'dist', 'index.js'); + writeFileSync(preparePath, '#!/bin/sh\necho original\n', 'utf-8'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(cliPath); + rmSync(backupPath); + mkdirSync(backupPath); + + const result = spawnSync(process.execPath, [cliPath, 'init', '--uninstall-hook'], { + cwd: repoDir, + encoding: 'utf-8', + env: { ...process.env, NO_COLOR: '1' }, + }); + + assert.equal(result.status, 1, result.stderr); + assert.match(`${result.stdout}\n${result.stderr}`, /Could not inspect 1 hook/); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks removes hooks created by commit-echo without deleting user hooks', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + const userPrepare = '#!/bin/sh\necho user replacement\n'; + writeFileSync(join(hooksDir, 'prepare-commit-msg'), userPrepare, 'utf-8'); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.removed.length, 1); + assert.equal(result.skipped.length, 1); + assert.equal(readFileSync(join(hooksDir, 'prepare-commit-msg'), 'utf-8'), userPrepare); + assert.equal(existsSync(join(hooksDir, 'post-commit')), false); + assert.equal(existsSync(join(hooksDir, 'prepare-commit-msg.commit-echo.bak')), false); + + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + assert.equal(readFileSync(join(hooksDir, 'prepare-commit-msg.commit-echo.bak'), 'utf-8'), userPrepare); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks does not treat marker text in a user hook as ownership', async () => { + const repoDir = initRepo(); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const userPrepare = '#!/bin/sh\necho user replacement\n# commit-echo managed hook prepare-commit-msg\n'; + + try { + await withCwdAsync(repoDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + writeFileSync(preparePath, userPrepare, 'utf-8'); + + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.removed.length, 1); + assert.equal(result.skipped.length, 1); + assert.equal(readFileSync(preparePath, 'utf-8'), userPrepare); + assert.equal(existsSync(`${preparePath}.commit-echo.bak`), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks restores legacy relative backups from a different working directory', async () => { + const repoDir = initRepo(); + const nestedDir = join(repoDir, 'nested', 'deep'); + mkdirSync(nestedDir, { recursive: true }); + const hooksDir = join(repoDir, '.git', 'hooks'); + const preparePath = join(hooksDir, 'prepare-commit-msg'); + const backupPath = `${preparePath}.commit-echo.bak`; + const ownerPath = `${backupPath}.owner`; + const originalPrepare = '#!/bin/sh\necho original prepare\n'; + writeFileSync(preparePath, originalPrepare, 'utf-8'); + + try { + await withCwdAsync(nestedDir, async () => { + await installCommitHooks(join(repoDir, 'dist', 'index.js')); + const legacyBackupPath = git( + ['rev-parse', '--git-path', 'hooks/prepare-commit-msg.commit-echo.bak'], + nestedDir, + ).trim(); + const managedHook = readFileSync(preparePath, 'utf-8'); + const absoluteBackupPath = backupPath.replace(/\\/g, '/'); + const legacyPath = legacyBackupPath.replace(/\\/g, '/'); + + assert.equal(isAbsolute(legacyBackupPath), false); + assert.ok(managedHook.includes(absoluteBackupPath)); + writeFileSync(preparePath, managedHook.replaceAll(absoluteBackupPath, legacyPath), 'utf-8'); + rmSync(ownerPath); + }); + + await withCwdAsync(repoDir, async () => { + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 1); + assert.equal(result.removed.length, 1); + assert.equal(readFileSync(preparePath, 'utf-8'), originalPrepare); + assert.equal(existsSync(backupPath), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('uninstallCommitHooks does not classify missing hooks as skipped user hooks', async () => { + const repoDir = initRepo(); + + try { + await withCwdAsync(repoDir, async () => { + const result = await uninstallCommitHooks(); + assert.equal(result.restored.length, 0); + assert.equal(result.removed.length, 0); + assert.equal(result.skipped.length, 0); + assert.equal(result.missing.length, 2); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('runPrepareCommitMsgHook rewrites the message file with the first suggestion', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-run-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + writeFileSync(messageFile, '# comment line\n', 'utf-8'); + + try { + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ + suggestions: [{ index: 1, message: 'feat: prefill hook', body: 'Hook body' }], + profile: MOCK_PROFILE, + model: 'mock-model', + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async () => {}, + warn: () => {}, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); + + const result = readFileSync(messageFile, 'utf-8'); + assert.ok(result.startsWith('feat: prefill hook\n\nHook body')); + assert.ok(result.includes('# comment line')); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('runPrepareCommitMsgHook leaves merge and commit sources unchanged', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-skip-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + writeFileSync(messageFile, 'original\n', 'utf-8'); + + try { + let called = false; + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => { + called = true; + return { + suggestions: [{ index: 1, message: 'feat: should not be used' }], + profile: MOCK_PROFILE, + model: 'mock-model', + }; + }, + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async () => {}, + warn: () => {}, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'merge' }, deps); + assert.equal(called, false); + assert.equal(readFileSync(messageFile, 'utf-8'), 'original\n'); + + await runPrepareCommitMsgHook({ messageFile, source: 'commit' }, deps); + assert.equal(called, false); + assert.equal(readFileSync(messageFile, 'utf-8'), 'original\n'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('runPrepareCommitMsgHook clears stale pending state for skipped sources', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-stale-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + writeFileSync(messageFile, '', 'utf-8'); + + try { + let pendingEntry = ''; + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ + suggestions: [{ index: 1, message: 'feat: stale pending', body: 'Hook body' }], + profile: MOCK_PROFILE, + model: 'mock-model', + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async (content) => { + pendingEntry = content; + }, + removePendingEntryFile: async () => { + pendingEntry = ''; + }, + warn: () => {}, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); + assert.notEqual(pendingEntry, ''); + + await runPrepareCommitMsgHook({ messageFile, source: 'message' }, deps); + assert.equal(pendingEntry, ''); + + let historyEntry = ''; + await runPostCommitHook({ + checkGitRepo: () => {}, + readLatestCommitMessage: () => 'fix: manual message', + readPendingEntryFile: async () => pendingEntry, + appendHistoryEntry: async (entry) => { + historyEntry = entry; + }, + removePendingEntryFile: async () => { + pendingEntry = ''; + }, + warn: () => {}, + }); + + assert.equal(historyEntry, ''); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('runPrepareCommitMsgHook stores a pending history entry for post-commit', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-pending-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + writeFileSync(messageFile, '', 'utf-8'); + + try { + let pendingEntry = ''; + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ + suggestions: [{ index: 1, message: 'feat: prefill hook', body: 'Hook body' }], + profile: MOCK_PROFILE, + model: 'mock-model', + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async (content) => { + pendingEntry = content; + }, + warn: () => {}, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); + + assert.match(pendingEntry, /"diff":"diff --git a\/file b\/file\\n\+hello"/); + assert.match(pendingEntry, /"model":"mock-model"/); + assert.match(pendingEntry, /"provider":"mock"/); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('runPrepareCommitMsgHook clears stale pending state when suggestion generation fails', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-error-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + writeFileSync(messageFile, '', 'utf-8'); + + try { + let pendingEntry = 'stale'; + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => { + throw new Error('provider unavailable'); + }, + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async (content) => { + pendingEntry = content; + }, + removePendingEntryFile: async () => { + pendingEntry = ''; + }, + warn: () => {}, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); + + assert.equal(pendingEntry, ''); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('runPostCommitHook appends the committed message to history and clears the pending entry', async () => { + let removed = false; + const entries = []; + + await runPostCommitHook({ + checkGitRepo: () => {}, + readLatestCommitMessage: () => 'feat: persist hook-driven commits', + readPendingEntryFile: async () => + JSON.stringify({ + timestamp: '2026-06-01T00:00:00.000Z', + diff: 'diff --git a/file b/file\n+hello', + model: 'mock-model', + provider: 'mock', + }), + appendHistoryEntry: async (entry) => { + entries.push(entry); + }, + removePendingEntryFile: async () => { + removed = true; + }, + warn: () => {}, + }); + + assert.equal(entries.length, 1); + assert.equal(entries[0].message, 'feat: persist hook-driven commits'); + assert.equal(entries[0].model, 'mock-model'); + assert.equal(entries[0].provider, 'mock'); + assert.equal(entries[0].diff, 'diff --git a/file b/file\n+hello'); + assert.equal(removed, true); +}); + +test('runPostCommitHook clears malformed pending entries', async () => { + let removed = false; + let appended = false; + + await runPostCommitHook({ + checkGitRepo: () => {}, + readLatestCommitMessage: () => 'feat: should not append', + readPendingEntryFile: async () => '{not-json', + appendHistoryEntry: async () => { + appended = true; + }, + removePendingEntryFile: async () => { + removed = true; + }, + warn: () => {}, + }); + + assert.equal(appended, false); + assert.equal(removed, true); +}); + +test('runPostCommitHook clears pending entry when history append fails', async () => { + let removed = false; + + await runPostCommitHook({ + checkGitRepo: () => {}, + readLatestCommitMessage: () => 'feat: should still clear pending on error', + readPendingEntryFile: async () => + JSON.stringify({ + timestamp: '2026-06-01T00:00:00.000Z', + diff: 'diff --git a/file b/file\n+hello', + model: 'mock-model', + provider: 'mock', + }), + appendHistoryEntry: async () => { + throw new Error('disk full'); + }, + removePendingEntryFile: async () => { + removed = true; + }, + warn: () => {}, + }); + + assert.equal(removed, true); +}); + +test('runPrepareCommitMsgHook times out LLM work without changing the message', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-timeout-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + const originalMessage = 'original commit title\n'; + writeFileSync(messageFile, originalMessage, 'utf-8'); + + try { + let aborted = false; + let pendingCleared = 0; + let warning = ''; + + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async (_config, _diff, _profile, _apiKey, _truncation, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + aborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async () => { + throw new Error('pending entry should not be written after timeout'); + }, + removePendingEntryFile: async () => { + pendingCleared += 1; + }, + warn: (message) => { + warning = message; + }, + timeoutMs: 20, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); + + assert.equal(aborted, true); + assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); + assert.equal(pendingCleared, 1); + assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + + +test('runPrepareCommitMsgHook applies its deadline to config loading', async () => { + let warning = ''; + let configLoaded = false; + let resolveConfig; + + const configPromise = new Promise((resolve) => { + resolveConfig = resolve; + }); + + const hookPromise = runPrepareCommitMsgHook( + { messageFile: '/tmp/commit-echo-timeout-test', source: 'template' }, + { + checkGitRepo: () => {}, + loadConfig: async () => { + const config = await configPromise; + configLoaded = true; + return config; + }, + getStagedDiff: () => { + throw new Error('staged diff should not run after the config deadline expires'); + }, + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ suggestions: [] }), + readMessageFile: async () => '', + writeMessageFile: async () => {}, + writePendingEntryFile: async () => {}, + removePendingEntryFile: async () => {}, + warn: (message) => { + warning = message; + }, + timeoutMs: 20, + }, + ); + + await hookPromise; + assert.equal(configLoaded, false); + assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); + + resolveConfig({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }); + await configPromise; + assert.equal(configLoaded, true); +}); + +test('runPrepareCommitMsgHook waits for and rolls back a late message write', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-write-timeout-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + const originalMessage = 'original commit title\n'; + writeFileSync(messageFile, originalMessage, 'utf-8'); + + try { + let releaseWrite; + let signalWriteStarted; + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve; + }); + let pendingCleared = 0; + let warning = ''; + + const hookPromise = runPrepareCommitMsgHook( + { messageFile, source: 'template' }, + { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ + diff: 'diff --git a/file b/file\n+hello', + hasChanges: true, + staged: true, + }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ + suggestions: [{ index: 1, message: 'feat: generated message' }], + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, nextContent) => { + if (nextContent !== originalMessage) { + await new Promise((resolve) => { + releaseWrite = resolve; + signalWriteStarted(); + }); + } + writeFileSync(filePath, nextContent, 'utf-8'); + }, + writePendingEntryFile: async () => { + throw new Error('pending entry should not be written after the late message write'); + }, + removePendingEntryFile: async () => { + pendingCleared += 1; + }, + warn: (message) => { + warning = message; + }, + timeoutMs: 20, + }, + ); + + await writeStarted; + await new Promise((resolve) => setTimeout(resolve, 30)); + releaseWrite(); + await hookPromise; + + assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); + assert.equal(pendingCleared, 1); + assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); From edeabceace9f03059467004927de2d8801c98829 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:33:03 +1000 Subject: [PATCH 5/5] test: skip shell hook resolution test on Windows --- tests/hook.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index b9f63af..6fd27ea 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -150,7 +150,7 @@ test('buildPrepareCommitMsgHookScript safely quotes paths containing shell metac ); }); -test('generated hooks prefer the captured CLI over PATH and fall back when it is missing', () => { +test('generated hooks prefer the captured CLI over PATH and fall back when it is missing', { skip: process.platform === 'win32' }, () => { const tempDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-resolution-test-')); const binDir = join(tempDir, 'bin'); const cliPath = join(tempDir, 'saved-cli.js');