diff --git a/dist/cli.js b/dist/cli.js index e0e02a2..c62b117 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -33,7 +33,7 @@ var __toESM = (mod, isNodeMode, target) => { var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __require = /* @__PURE__ */ createRequire(import.meta.url); -// ../../node_modules/commander/lib/error.js +// node_modules/commander/lib/error.js var require_error = __commonJS((exports) => { class CommanderError extends Error { constructor(exitCode, code, message) { @@ -57,7 +57,7 @@ var require_error = __commonJS((exports) => { exports.InvalidArgumentError = InvalidArgumentError; }); -// ../../node_modules/commander/lib/argument.js +// node_modules/commander/lib/argument.js var require_argument = __commonJS((exports) => { var { InvalidArgumentError } = require_error(); @@ -136,7 +136,7 @@ var require_argument = __commonJS((exports) => { exports.humanReadableArgName = humanReadableArgName; }); -// ../../node_modules/commander/lib/help.js +// node_modules/commander/lib/help.js var require_help = __commonJS((exports) => { var { humanReadableArgName } = require_argument(); @@ -385,7 +385,7 @@ var require_help = __commonJS((exports) => { exports.Help = Help; }); -// ../../node_modules/commander/lib/option.js +// node_modules/commander/lib/option.js var require_option = __commonJS((exports) => { var { InvalidArgumentError } = require_error(); @@ -536,7 +536,7 @@ var require_option = __commonJS((exports) => { exports.DualOptions = DualOptions; }); -// ../../node_modules/commander/lib/suggestSimilar.js +// node_modules/commander/lib/suggestSimilar.js var require_suggestSimilar = __commonJS((exports) => { var maxDistance = 3; function editDistance(a, b) { @@ -609,7 +609,7 @@ var require_suggestSimilar = __commonJS((exports) => { exports.suggestSimilar = suggestSimilar; }); -// ../../node_modules/commander/lib/command.js +// node_modules/commander/lib/command.js var require_command = __commonJS((exports) => { var EventEmitter = __require("node:events").EventEmitter; var childProcess = __require("node:child_process"); @@ -1852,7 +1852,7 @@ Expecting one of '${allowedValues.join("', '")}'`); exports.Command = Command; }); -// ../../node_modules/commander/index.js +// node_modules/commander/index.js var require_commander = __commonJS((exports) => { var { Argument } = require_argument(); var { Command } = require_command(); @@ -1878,7 +1878,7 @@ import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { createInterface } from "readline/promises"; -// ../../node_modules/commander/esm.mjs +// node_modules/commander/esm.mjs var import__ = __toESM(require_commander(), 1); var { program, @@ -1895,7 +1895,7 @@ var { } = import__.default; // src/install.ts -import fs from "fs/promises"; +import fs2 from "fs/promises"; // src/utils/logger.ts var c = { @@ -1965,6 +1965,7 @@ var logger = new Logger; // src/utils/paths.ts import path from "path"; import os from "os"; +import fs from "fs/promises"; function getHomeDir() { if (process.env.HOME) { return process.env.HOME; @@ -1990,13 +1991,28 @@ function getConfig() { function toGitPath(filePath) { return filePath.replace(/\\/g, "/"); } +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} // src/pattern-catalog.json var pattern_catalog_default = [ { id: "claude", signatureAliases: ["Claude", "Anthropic"], - authorTokens: ["claude", "claude code", "claude opus", "claude sonnet", "claude haiku", "anthropic"], + authorTokens: [ + "claude", + "claude code", + "claude opus", + "claude sonnet", + "claude haiku", + "anthropic" + ], emails: ["noreply@anthropic.com", "claude@anthropic.com"] }, { @@ -2101,10 +2117,7 @@ function buildNamePattern(aliases) { return `${CO_AUTHORED_BY_PREFIX}(?:${aliases.map(escapeRegex).join("|")}).*`; } function buildEmailPattern(emails, emailPatterns) { - const emailAlternatives = [ - ...emails.map(escapeRegex), - ...emailPatterns - ]; + const emailAlternatives = [...emails.map(escapeRegex), ...emailPatterns]; return `${CO_AUTHORED_BY_PREFIX}.*\\b(?:${emailAlternatives.join("|")})\\b.*`; } var AI_SIGNATURE_CATALOG = pattern_catalog_default; @@ -2280,6 +2293,11 @@ function createHookInstallPlan(options) { ] }; } +var NOCO_HOOK_MARKERS = ["Generated by noco", "nococli:"]; +var HOOK_BACKUP_EXT = ".bak"; +function isNococliHook(content) { + return NOCO_HOOK_MARKERS.some((marker) => content.includes(marker)); +} function getPatternNames() { return DEFAULT_AI_PATTERNS.map((pattern) => pattern.name); } @@ -2380,7 +2398,7 @@ async function install(options = {}) { const platform = options.platform ?? process.platform; try { logger2.info("Creating git templates directory..."); - await fs.mkdir(config.hooksDir, { recursive: true }); + await fs2.mkdir(config.hooksDir, { recursive: true }); const powerShellRuntime = detectPowerShellRuntime(platform); if (platform === "win32" && !powerShellRuntime) { return { @@ -2393,8 +2411,20 @@ async function install(options = {}) { platform, powerShellCommand: powerShellRuntime ?? undefined }); + if (!options.force) { + for (const file of installPlan.files) { + try { + const existingContent = await fs2.readFile(file.path, "utf8"); + if (!isNococliHook(existingContent)) { + const backupPath = `${file.path}${HOOK_BACKUP_EXT}`; + await fs2.copyFile(file.path, backupPath); + logger2.warning(`Existing hook backed up to ${backupPath}`); + } + } catch {} + } + } for (const file of installPlan.files) { - await fs.writeFile(file.path, file.content, { + await fs2.writeFile(file.path, file.content, { mode: file.mode }); logger2.success(`Hook file created at ${file.path}`); @@ -2444,7 +2474,7 @@ async function install(options = {}) { } // src/uninstall.ts -import fs2 from "fs/promises"; +import fs3 from "fs/promises"; async function uninstall(options = {}) { const logger2 = new Logger(options.silent); const config = getConfig(); @@ -2453,32 +2483,37 @@ async function uninstall(options = {}) { try { logger2.info("Removing hook file..."); try { - await fs2.unlink(config.hookFile); + await fs3.unlink(config.hookFile); logger2.success(`Removed ${config.hookFile}`); } catch { logger2.info("Hook file not found (already removed?)"); } try { - await fs2.unlink(config.powerShellHookFile); + await fs3.unlink(config.powerShellHookFile); removedPowerShellHook = true; logger2.success(`Removed ${config.powerShellHookFile}`); } catch { logger2.info("PowerShell hook file not found (already removed?)"); } + for (const hookPath of [config.hookFile, config.powerShellHookFile]) { + const backupPath = `${hookPath}${HOOK_BACKUP_EXT}`; + try { + await fs3.rename(backupPath, hookPath); + logger2.success(`Restored previous hook from ${backupPath}`); + } catch {} + } try { - const hooksExists = await fs2.access(config.hooksDir).then(() => true).catch(() => false); - if (hooksExists) { - const files = await fs2.readdir(config.hooksDir); + if (await pathExists(config.hooksDir)) { + const files = await fs3.readdir(config.hooksDir); if (files.length === 0) { - await fs2.rmdir(config.hooksDir); + await fs3.rmdir(config.hooksDir); logger2.info("Removed empty hooks directory"); } } - const templateExists = await fs2.access(config.templateDir).then(() => true).catch(() => false); - if (templateExists) { - const files = await fs2.readdir(config.templateDir); + if (await pathExists(config.templateDir)) { + const files = await fs3.readdir(config.templateDir); if (files.length === 0) { - await fs2.rmdir(config.templateDir); + await fs3.rmdir(config.templateDir); logger2.info("Removed empty templates directory"); } } @@ -2515,7 +2550,6 @@ async function uninstall(options = {}) { } // src/cli.ts -import { access } from "fs/promises"; var logger2 = new Logger; var __dirname2 = dirname(fileURLToPath(import.meta.url)); var version = "0.0.0"; @@ -2536,14 +2570,6 @@ async function promptConfirm(message) { rl.close(); return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"; } -async function fileExists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} async function runInstallCommand(options) { const currentName = getGitUserName(); if (currentName.exists && currentName.value && isAIAuthor(currentName.value)) { @@ -2574,7 +2600,7 @@ async function runInstallCommand(options) { } else { logger2.info("No existing template directory found"); } - const result = await install({ silent: options.silent }); + const result = await install({ silent: options.silent, force: options.force }); if (!result.success) { logger2.blank(); logger2.error(result.message); @@ -2630,8 +2656,8 @@ program2.command("status").description("Check if noco is properly installed and } else { logger2.warning("Not installed"); } - const hookExists = await fileExists(config.hookFile); - const powerShellHookExists = await fileExists(config.powerShellHookFile); + const hookExists = await pathExists(config.hookFile); + const powerShellHookExists = await pathExists(config.powerShellHookFile); if (hookExists) { logger2.success("Hook entrypoint exists"); } else { diff --git a/package.json b/package.json index 86f5925..9b67114 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nococli", "version": "1.1.0", - "description": "Keep your code yours — remove AI co-author signatures from git commits", + "description": "Keep your code yours -- remove AI co-author signatures from git commits", "type": "module", "bin": { "noco": "./dist/cli.js" diff --git a/scripts/e2e-install.mjs b/scripts/e2e-install.mjs index a53d026..68b8e70 100644 --- a/scripts/e2e-install.mjs +++ b/scripts/e2e-install.mjs @@ -233,7 +233,7 @@ Co-Authored-By: Claude `; `AI signature should be removed. Got: ${lastMessage}`); assert.ok(lastMessage.includes('feat: add new feature'), 'Message body should be preserved'); - console.log('✓ Basic signature removal works'); + console.log('Basic signature removal works'); } // ========================================================================= @@ -263,7 +263,7 @@ Co-Authored-By: Claude `; assert.ok(!containsAISignature(lastMessage), `Case variation "${testCase.label}" should be removed. Got: ${lastMessage}`); } - console.log('✓ All case variations are matched and removed'); + console.log('All case variations are matched and removed'); } // ========================================================================= @@ -305,7 +305,7 @@ Co-Authored-By: Claude `; assert.ok(!containsAISignature(lastMessage), `AI signature "${ai.name}" should be removed. Got: ${lastMessage}`); } - console.log(`✓ All ${aiNames.length} AI names are matched and removed`); + console.log(`All ${aiNames.length} AI names are matched and removed`); } // ========================================================================= @@ -334,7 +334,7 @@ Co-Authored-By: Cursor AI `; `All AI signatures should be removed. Got: ${lastMessage}`); assert.ok(lastMessage.includes('feat: add new feature'), 'Message body should be preserved'); - console.log('✓ Multiple AI signatures are all removed'); + console.log('Multiple AI signatures are all removed'); } // ========================================================================= @@ -364,7 +364,7 @@ Co-Authored-By: GitHub Copilot `; 'Human co-author John Doe should be preserved'); assert.ok(containsHumanCoAuthor(lastMessage, 'Jane Smith'), 'Human co-author Jane Smith should be preserved'); - console.log('✓ AI signatures removed, human co-authors preserved'); + console.log('AI signatures removed, human co-authors preserved'); } // ========================================================================= @@ -393,7 +393,7 @@ Co-Authored-By: GitHub Copilot `; assert.ok(!containsAISignature(lastMessage), `Whitespace variation "${testCase.label}" should be removed. Got: ${lastMessage}`); } - console.log('✓ All whitespace variations are handled'); + console.log('All whitespace variations are handled'); } // ========================================================================= @@ -425,7 +425,7 @@ Co-Authored-By: Claude `Trailing blank lines should be cleaned. Got ending with: ${JSON.stringify(lastMessage.slice(-20))}`); assert.ok(!containsAISignature(lastMessage), 'AI signature should be removed'); - console.log('✓ Trailing blank lines are cleaned up'); + console.log('Trailing blank lines are cleaned up'); } // ========================================================================= @@ -467,7 +467,7 @@ Fixes #123`; 'List items should be preserved'); assert.ok(lastMessage.includes('Fixes #123'), 'Trailer should be preserved'); - console.log('✓ Message integrity preserved'); + console.log('Message integrity preserved'); } // ========================================================================= @@ -490,7 +490,7 @@ Fixes #123`; assertSuccess(result3, 'install with --force'); assertHookArtifacts(env); - console.log('✓ Installation idempotency verified'); + console.log('Installation idempotency verified'); } // ========================================================================= @@ -522,7 +522,7 @@ Fixes #123`; const uninstallWithConfigResult = runNode([cliPath, 'uninstall', '--silent', '--remove-config'], { env }); assertSuccess(uninstallWithConfigResult, 'uninstall with --remove-config'); - console.log('✓ Uninstallation commands verified'); + console.log('Uninstallation commands verified'); } // ========================================================================= @@ -554,7 +554,7 @@ Fixes #123`; assert.ok(!containsAISignature(lastMessage), `Signature should be removed in full flow. Got: ${lastMessage}`); - console.log('✓ Full installation and commit flow verified'); + console.log('Full installation and commit flow verified'); } // ========================================================================= @@ -590,7 +590,7 @@ Fixes #123`; const lastMessage = getLastCommitMessage(repoDir, env); assert.ok(!containsAISignature(lastMessage), 'Signature should be removed'); - console.log('✓ Existing repository hook application verified'); + console.log('Existing repository hook application verified'); } // ========================================================================= @@ -623,7 +623,7 @@ Fixes #123`; const lastMessage = getLastCommitMessage(repoDir, env); assert.ok(lastMessage.includes('John Doe'), 'Human co-author should be preserved'); - console.log('✓ Hook non-blocking behavior verified'); + console.log('Hook non-blocking behavior verified'); } // ========================================================================= @@ -656,7 +656,7 @@ Fixes #123`; assert.ok(!containsAISignature(allMessagesResult.stdout), 'All commit messages should be clean of AI signatures'); - console.log('✓ Concurrent hook safety verified'); + console.log('Concurrent hook safety verified'); } // ========================================================================= @@ -678,7 +678,7 @@ Fixes #123`; assertSuccess(commitResult, 'performance commit'); assert.ok(elapsed < 5000, `Hook should complete quickly (took ${elapsed}ms)`); - console.log(`✓ Hook performance: ${elapsed}ms (target < 5000ms)`); + console.log(`Hook performance: ${elapsed}ms (target < 5000ms)`); } // ========================================================================= @@ -711,7 +711,7 @@ Fixes #123`; assert.ok(!containsAISignature(lastMessage), `All AI signatures should be removed from amended commit. Got: ${lastMessage}`); - console.log('✓ Amended commit handling verified'); + console.log('Amended commit handling verified'); } // ========================================================================= @@ -770,7 +770,7 @@ Fixes #123`; assert.ok(!containsAISignature(lastMessage), `AI signatures should be removed from rebased commit. Got: ${lastMessage}`); - console.log('✓ Interactive rebase safety verified'); + console.log('Interactive rebase safety verified'); } // ========================================================================= @@ -799,7 +799,7 @@ Fixes #123`; assert.ok(hookContent.includes("require('fs')"), 'Hook should use Node.js fs'); } - console.log('✓ Platform-specific installation verified'); + console.log('Platform-specific installation verified'); } // ========================================================================= @@ -831,7 +831,7 @@ Fixes #123`; assert.ok(!containsAISignature(lastMessage), 'Signature should be removed in CI environment'); - console.log('✓ CI/CD environment compatibility verified'); + console.log('CI/CD environment compatibility verified'); } // ========================================================================= @@ -880,7 +880,7 @@ Fixes #123`; assert.ok(lastMessage.includes("Merge branch 'feature-branch'"), 'Merge message should be preserved'); - console.log('✓ Merge commit handling verified'); + console.log('Merge commit handling verified'); } console.log('\n========================================'); diff --git a/src/cli.ts b/src/cli.ts index 01b0429..7979b2d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,11 +18,10 @@ import { setGitUserName, setGitUserEmail, } from './utils/git.js'; -import { getConfig } from './utils/paths.js'; +import { getConfig, pathExists } from './utils/paths.js'; import { Logger } from './utils/logger.js'; import { detectPowerShellRuntime } from './utils/runtime.js'; import { isAIAuthor } from './types.js'; -import { access } from 'fs/promises'; const logger = new Logger(); @@ -52,15 +51,6 @@ async function promptConfirm(message: string): Promise { return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; } -async function fileExists(filePath: string): Promise { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - async function runInstallCommand(options: { force?: boolean; silent?: boolean }): Promise { // Check if current git author is an AI const currentName = getGitUserName(); @@ -186,8 +176,8 @@ program logger.warning('Not installed'); } - const hookExists = await fileExists(config.hookFile); - const powerShellHookExists = await fileExists(config.powerShellHookFile); + const hookExists = await pathExists(config.hookFile); + const powerShellHookExists = await pathExists(config.powerShellHookFile); if (hookExists) { logger.success('Hook entrypoint exists'); diff --git a/src/install.ts b/src/install.ts index a63627c..7ab7f97 100644 --- a/src/install.ts +++ b/src/install.ts @@ -4,8 +4,8 @@ import fs from 'fs/promises'; import { Logger } from './utils/logger.js'; -import { getConfig, toGitPath, pathExists } from './utils/paths.js'; -import { createHookInstallPlan, isNococliHook } from './utils/hook.js'; +import { getConfig, toGitPath } from './utils/paths.js'; +import { createHookInstallPlan, isNococliHook, HOOK_BACKUP_EXT } from './utils/hook.js'; import { getTemplateDir, setTemplateDir } from './utils/git.js'; import { detectPowerShellRuntime } from './utils/runtime.js'; import type { InstallOptions } from './types.js'; @@ -44,16 +44,17 @@ export async function install(options: InstallOptions = {}): Promise true) - .catch(() => false); - if (hooksExists) { + if (await pathExists(config.hooksDir)) { const files = await fs.readdir(config.hooksDir); if (files.length === 0) { await fs.rmdir(config.hooksDir); @@ -61,11 +59,7 @@ export async function uninstall(options: UninstallOptions = {}): Promise true) - .catch(() => false); - if (templateExists) { + if (await pathExists(config.templateDir)) { const files = await fs.readdir(config.templateDir); if (files.length === 0) { await fs.rmdir(config.templateDir); diff --git a/src/utils/hook.test.ts b/src/utils/hook.test.ts index f08ee61..18fd517 100644 --- a/src/utils/hook.test.ts +++ b/src/utils/hook.test.ts @@ -10,7 +10,7 @@ import { import { AI_AUTHOR_NAMES, AI_SIGNATURE_CATALOG } from '../types'; /** - * Unit tests for nococli hook — Node.js cross-platform hook + * Unit tests for nococli hook -- Node.js cross-platform hook */ const windowsConfig = { @@ -301,8 +301,8 @@ describe('Pattern Matching - Mixed AI and Human Co-Authors (VAL-PATTERN-007)', ( const emailRegex = new RegExp(patterns[1].pattern, 'i'); // These have AI-sounding names but human emails - // Name pattern matches (greedy .*) — known false positive - // Email pattern does NOT match — provides safety net + // Name pattern matches (greedy .*) -- known false positive + // Email pattern does NOT match -- provides safety net const humanWithAIName = [ 'Co-Authored-By: Claude Smith ', 'Co-Authored-By: Gemini Wong ', @@ -310,9 +310,9 @@ describe('Pattern Matching - Mixed AI and Human Co-Authors (VAL-PATTERN-007)', ( ]; humanWithAIName.forEach((human) => { - // Name pattern still matches — known limitation + // Name pattern still matches -- known limitation expect(nameRegex.test(human)).toBe(true); - // Email pattern does NOT match — email-based safety net + // Email pattern does NOT match -- email-based safety net expect(emailRegex.test(human)).toBe(false); }); }); diff --git a/src/utils/hook.ts b/src/utils/hook.ts index 371c60b..cdbe476 100644 --- a/src/utils/hook.ts +++ b/src/utils/hook.ts @@ -220,15 +220,12 @@ export function createHookInstallPlan(options: HookInstallPlanOptions): HookInst }; } -/** - * Markers used to identify a nococli-generated hook file. - * Covers both Node.js (//) and PowerShell (#) comment styles. - */ +/** Markers that identify a nococli-generated hook file. */ export const NOCO_HOOK_MARKERS = ['Generated by noco', 'nococli:'] as const; -/** - * Check if hook content was generated by nococli. - */ +/** File extension used for backing up existing hooks before overwrite. */ +export const HOOK_BACKUP_EXT = '.bak'; + export function isNococliHook(content: string): boolean { return NOCO_HOOK_MARKERS.some((marker) => content.includes(marker)); } diff --git a/test.sh b/test.sh index c591fb6..355bc2b 100644 --- a/test.sh +++ b/test.sh @@ -1,25 +1,25 @@ #!/bin/bash # Test script to verify git-no-ai-author hook works -echo "🧪 Testing git-no-ai-author hook..." +echo "Testing git-no-ai-author hook..." echo "" # Create temp directory TEMP_DIR=$(mktemp -d) cd "$TEMP_DIR" || exit 1 -echo "📁 Created test directory: $TEMP_DIR" +echo "Created test directory: $TEMP_DIR" # Initialize git repo git init > /dev/null 2>&1 -echo "✓ Initialized git repo" +echo "Initialized git repo" # Check if hook exists if [ -f ".git/hooks/commit-msg" ]; then - echo "✓ Hook file exists" + echo "Hook file exists" else - echo "✗ Hook file NOT found!" + echo "Hook file NOT found!" echo " Make sure git template directory is set correctly:" echo " git config --global init.templatedir" exit 1 @@ -41,22 +41,22 @@ git commit -m "$COMMIT_MSG" > /dev/null 2>&1 ACTUAL_MSG=$(git log -1 --format=%B) echo "" -echo "📝 Commit message:" -echo "────────────────────────────────────────" +echo "Commit message:" +echo "----------------------------------------" echo "$ACTUAL_MSG" -echo "────────────────────────────────────────" +echo "----------------------------------------" echo "" # Check if AI co-author was removed if echo "$ACTUAL_MSG" | grep -q "Co-Authored-By: Claude"; then - echo "❌ FAILED: AI co-author was NOT removed" + echo "FAILED: AI co-author was NOT removed" exit 1 else - echo "✅ PASSED: AI co-author was removed successfully!" + echo "PASSED: AI co-author was removed successfully!" fi # Cleanup cd - rm -rf "$TEMP_DIR" echo "" -echo "🧹 Cleaned up test directory" +echo "Cleaned up test directory"