diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 0be3bb3..6af87a6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .factory/ *.tgz dist/ +.omx/ diff --git a/.omx/logs/turns-2026-04-03.jsonl b/.omx/logs/turns-2026-04-03.jsonl new file mode 100644 index 0000000..bed6761 --- /dev/null +++ b/.omx/logs/turns-2026-04-03.jsonl @@ -0,0 +1,6 @@ +{"timestamp":"2026-04-03T08:36:29.417Z","type":"agent-turn-complete","thread_id":"019d527a-9c41-7222-a71a-ffd085d9092d","turn_id":"019d527c-4bd4-7312-b940-101cb912a4f1","input_preview":"mở branch mới; Tạo branch mới nhằm cải thiện Patterns hardcoded","output_preview":"Đã tạo và chuyển sang branch `refactor/improve-hardcoded-patterns` tại commit `ceb29e9`.\n\nBranch hiện tại là `refactor/improve-hardcoded-patterns`. Nếu muốn, tôi có thể tiếp tục xử lý luôn phần cải th"} +{"timestamp":"2026-04-03T08:37:33.238Z","type":"agent-turn-complete","thread_id":"019d5279-7596-7333-a40f-444877df215e","turn_id":"019d527a-240f-7102-abfe-f3da3fffeed1","input_preview":"Tạo team hoàn thiện hỗ trợ PowerSHell native Windows Support","output_preview":"Tôi đã khóa intent: v1 sẽ là full native Windows support, baseline PowerShell 5.1 và 7+. Trước khi chốt spec, tôi যাচাই cơ chế hook trên Windows từ tài liệu chính thức để tránh plan sai ở phần runtime"} +{"timestamp":"2026-04-03T08:38:14.346Z","type":"agent-turn-complete","thread_id":"019d5279-7596-7333-a40f-444877df215e","turn_id":"019d527d-b2f0-7870-94aa-baec8d626726","input_preview":"Tạo team hoàn thiện hỗ trợ PowerSHell native Windows Support; Mở branch mới và thực hiện","output_preview":""} +{"timestamp":"2026-04-03T08:38:18.035Z","type":"agent-turn-complete","thread_id":"019d527a-9c41-7222-a71a-ffd085d9092d","turn_id":"019d527c-f666-7971-9a47-4b859e2bb040","input_preview":"mở branch mới; Tạo branch mới nhằm cải thiện Patterns hardcoded; đọc dự án nhằm cải thiện phần patterns hard coded","output_preview":""} +{"timestamp":"2026-04-03T08:47:10.069Z","type":"agent-turn-complete","thread_id":"019d527a-9c41-7222-a71a-ffd085d9092d","turn_id":"019d527e-40ef-7f32-bbcf-4a842c534329","input_preview":"mở branch mới; Tạo branch mới nhằm cải thiện Patterns hardcoded; đọc dự án nhằm cải thiện phần patterns hard coded; Implement the plan.","output_preview":"Implemented the refactor around a single AI-pattern source of truth.\n\nChanged files:\n- [src/pattern-catalog.json](/mnt/c/Users/kisde/Desktop/nococli/src/pattern-catalog.json): new shared catalog for s"} +{"timestamp":"2026-04-03T08:49:50.009Z","type":"agent-turn-complete","thread_id":"019d5279-7596-7333-a40f-444877df215e","turn_id":"019d527e-39d3-7b43-b6ef-c5821a078795","input_preview":"Tạo team hoàn thiện hỗ trợ PowerSHell native Windows Support; Mở branch mới và thực hiện; Implement the plan.","output_preview":"Implemented on branch `feat/windows-powershell-native-support`.\n\nWindows installs now write a Git `commit-msg` wrapper plus a PowerShell runtime, while non-Windows keeps the Node hook path. `status` r"} diff --git a/.omx/metrics.json b/.omx/metrics.json new file mode 100644 index 0000000..d1501ca --- /dev/null +++ b/.omx/metrics.json @@ -0,0 +1,8 @@ +{ + "total_turns": 6, + "session_turns": 6, + "last_activity": "2026-04-03T08:49:50.070Z", + "session_input_tokens": 0, + "session_output_tokens": 0, + "session_total_tokens": 0 +} \ No newline at end of file diff --git a/.omx/state/hud-state.json b/.omx/state/hud-state.json new file mode 100644 index 0000000..bc9cd1f --- /dev/null +++ b/.omx/state/hud-state.json @@ -0,0 +1,5 @@ +{ + "last_turn_at": "2026-04-03T08:49:50.614Z", + "turn_count": 6, + "last_agent_output": "Implemented on branch `feat/windows-powershell-native-support`.\n\nWindows installs now write a Git `c" +} \ No newline at end of file diff --git a/.omx/state/notify-hook-state.json b/.omx/state/notify-hook-state.json new file mode 100644 index 0000000..402117c --- /dev/null +++ b/.omx/state/notify-hook-state.json @@ -0,0 +1,11 @@ +{ + "recent_turns": { + "019d527a-9c41-7222-a71a-ffd085d9092d|019d527c-4bd4-7312-b940-101cb912a4f1|agent-turn-complete": 1775205389411, + "019d5279-7596-7333-a40f-444877df215e|019d527a-240f-7102-abfe-f3da3fffeed1|agent-turn-complete": 1775205453228, + "019d5279-7596-7333-a40f-444877df215e|019d527d-b2f0-7870-94aa-baec8d626726|agent-turn-complete": 1775205494338, + "019d527a-9c41-7222-a71a-ffd085d9092d|019d527c-f666-7971-9a47-4b859e2bb040|agent-turn-complete": 1775205498027, + "019d527a-9c41-7222-a71a-ffd085d9092d|019d527e-40ef-7f32-bbcf-4a842c534329|agent-turn-complete": 1775206030049, + "019d5279-7596-7333-a40f-444877df215e|019d527e-39d3-7b43-b6ef-c5821a078795|agent-turn-complete": 1775206189987 + }, + "last_event_at": "2026-04-03T08:49:49.998Z" +} \ No newline at end of file diff --git a/.omx/state/team-leader-nudge.json b/.omx/state/team-leader-nudge.json new file mode 100644 index 0000000..96f0509 --- /dev/null +++ b/.omx/state/team-leader-nudge.json @@ -0,0 +1,5 @@ +{ + "last_nudged_by_team": {}, + "last_idle_nudged_by_team": {}, + "progress_by_team": {} +} \ No newline at end of file diff --git a/.omx/state/tmux-hook-state.json b/.omx/state/tmux-hook-state.json new file mode 100644 index 0000000..5c59edc --- /dev/null +++ b/.omx/state/tmux-hook-state.json @@ -0,0 +1,9 @@ +{ + "total_injections": 0, + "pane_counts": {}, + "session_counts": {}, + "recent_keys": {}, + "last_injection_ts": 0, + "last_reason": "disabled", + "last_event_at": "2026-04-03T08:49:50.655Z" +} \ No newline at end of file diff --git a/README.md b/README.md index 8ca731e..e286112 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ That's it. nococli installs a Git `commit-msg` hook that automatically strips AI co-author signatures from your commit messages before they're saved. The hook is installed globally via `git init.templatedir`, so it applies to **all new repositories** automatically. +On Windows, nococli installs a PowerShell-native hook runtime with a Git hook wrapper entrypoint so commits work from PowerShell 5.1 and PowerShell 7+. + For existing repositories, just run `git init` to pick up the hook. ## CLI Commands @@ -76,6 +78,7 @@ Removes co-author signatures from: - **Zero-config** — one command to install, works everywhere - **Lightweight** — single runtime dependency (commander), ~85KB bundle +- **Windows PowerShell-native support** — works with PowerShell 5.1 and 7+ on Git for Windows - **AI author detection** — warns if your git author name looks AI-generated and helps you fix it - **Preserves human co-authors** — only strips AI signatures, keeps real collaborators - **Works with all git workflows** — rebase, amend, merge, interactive rebase @@ -83,8 +86,8 @@ Removes co-author signatures from: ## Requirements - **Node.js** >= 18.0.0 -- **Unix/Linux/macOS**: Git with bash hook support -- **Windows**: Git Bash, WSL, or MSYS2 (native PowerShell not supported) +- **Unix/Linux/macOS**: Git with standard hook support +- **Windows**: Git for Windows plus PowerShell 5.1 or PowerShell 7+ ## Install diff --git a/dist/cli.js b/dist/cli.js index 98a12ac..e0e02a2 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, @@ -1966,6 +1966,12 @@ var logger = new Logger; import path from "path"; import os from "os"; function getHomeDir() { + if (process.env.HOME) { + return process.env.HOME; + } + if (process.env.USERPROFILE) { + return process.env.USERPROFILE; + } return os.homedir(); } function getConfig() { @@ -1973,67 +1979,165 @@ function getConfig() { const templateDir = path.join(homeDir, ".git-templates"); const hooksDir = path.join(templateDir, "hooks"); const hookFile = path.join(hooksDir, "commit-msg"); + const powerShellHookFile = path.join(hooksDir, "commit-msg.ps1"); return { templateDir, hooksDir, - hookFile + hookFile, + powerShellHookFile }; } function toGitPath(filePath) { return filePath.replace(/\\/g, "/"); } +// src/pattern-catalog.json +var pattern_catalog_default = [ + { + id: "claude", + signatureAliases: ["Claude", "Anthropic"], + authorTokens: ["claude", "claude code", "claude opus", "claude sonnet", "claude haiku", "anthropic"], + emails: ["noreply@anthropic.com", "claude@anthropic.com"] + }, + { + id: "copilot", + signatureAliases: ["GitHub Copilot"], + authorTokens: ["github copilot", "copilot"], + emails: ["copilot@github.com"] + }, + { + id: "chatgpt", + signatureAliases: ["ChatGPT", "OpenAI"], + authorTokens: ["chatgpt", "openai"], + emails: ["chatgpt@openai.com", "noreply@openai.com"] + }, + { + id: "cursor", + signatureAliases: ["Cursor AI"], + authorTokens: ["cursor ai", "cursor"], + emails: ["cursor@cursor.sh"] + }, + { + id: "ai-assistant", + signatureAliases: ["AI Assistant"], + authorTokens: ["ai assistant"], + emails: [] + }, + { + id: "tabnine", + signatureAliases: ["Tabnine"], + authorTokens: ["tabnine"], + emails: ["tabnine@tabnine.com"] + }, + { + id: "codewhisperer", + signatureAliases: ["CodeWhisperer"], + authorTokens: ["codewhisperer"], + emails: ["codewhisperer@amazon.com"] + }, + { + id: "codeium", + signatureAliases: ["Codeium"], + authorTokens: ["codeium"], + emails: ["codeium@codeium.com"] + }, + { + id: "replit-ghostwriter", + signatureAliases: ["Replit Ghostwriter"], + authorTokens: ["replit ghostwriter"], + emails: ["ghostwriter@replit.com"] + }, + { + id: "sourcegraph-cody", + signatureAliases: ["Sourcegraph Cody", "Cody"], + authorTokens: ["sourcegraph cody", "cody"], + emails: ["cody@sourcegraph.com"] + }, + { + id: "factory-droid", + signatureAliases: ["Factory Droid", "factory-droid[bot]"], + authorTokens: ["factory droid", "factory-droid", "factory-droid[bot]"], + emails: [], + emailPatterns: ["\\d+\\+factory-droid\\[bot\\]@users\\.noreply\\.github\\.com"] + }, + { + id: "gemini", + signatureAliases: ["Gemini", "Google Gemini", "Gemini Pro"], + authorTokens: ["gemini", "google gemini"], + emails: ["gemini@google.com"] + }, + { + id: "perplexity", + signatureAliases: ["Perplexity", "Perplexity AI"], + authorTokens: ["perplexity", "perplexity ai"], + emails: ["perplexity@perplexity.ai"] + }, + { + id: "amazon-q", + signatureAliases: ["Amazon Q"], + authorTokens: ["amazon q"], + emails: ["q@amazon.com"] + }, + { + id: "amp", + signatureAliases: ["Amp", "Amp AI"], + authorTokens: ["amp", "amp ai"], + emails: ["amp@amp.ai"] + } +]; + // src/types.ts -var AI_NAME_PATTERN = "^\\s*Co-Authored-By\\s*:\\s*(Claude|GitHub Copilot|ChatGPT|Anthropic|OpenAI|Cursor AI|AI Assistant|Tabnine|CodeWhisperer|Codeium|Replit Ghostwriter|Sourcegraph Cody|Cody|Factory Droid|factory-droid\\[bot\\]|Gemini|Google Gemini|Gemini Pro|Perplexity|Perplexity AI|Amazon Q|Amp|Amp AI).*"; -var AI_EMAIL_PATTERN = "^\\s*Co-Authored-By\\s*:\\s*.*\\b(?:noreply@anthropic\\.com|claude@anthropic\\.com|copilot@github\\.com|chatgpt@openai\\.com|noreply@openai\\.com|cursor@cursor\\.sh|tabnine@tabnine\\.com|codewhisperer@amazon\\.com|codeium@codeium\\.com|ghostwriter@replit\\.com|cody@sourcegraph\\.com|\\d+\\+factory-droid\\[bot\\]@users\\.noreply\\.github\\.com|gemini@google\\.com|perplexity@perplexity\\.ai|q@amazon\\.com|amp@amp\\.ai)\\b.*"; +var CO_AUTHORED_BY_PREFIX = "^\\s*Co-Authored-By\\s*:\\s*"; +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function uniqueOrdered(values) { + return [...new Set(values)]; +} +function flattenCatalogValues(key) { + return uniqueOrdered(AI_SIGNATURE_CATALOG.flatMap((provider) => provider[key] ?? [])); +} +function buildNamePattern(aliases) { + return `${CO_AUTHORED_BY_PREFIX}(?:${aliases.map(escapeRegex).join("|")}).*`; +} +function buildEmailPattern(emails, emailPatterns) { + const emailAlternatives = [ + ...emails.map(escapeRegex), + ...emailPatterns + ]; + return `${CO_AUTHORED_BY_PREFIX}.*\\b(?:${emailAlternatives.join("|")})\\b.*`; +} +var AI_SIGNATURE_CATALOG = pattern_catalog_default; +var AI_SIGNATURE_ALIASES = flattenCatalogValues("signatureAliases"); +var AI_AUTHOR_TOKENS = flattenCatalogValues("authorTokens"); +var AI_EMAILS = flattenCatalogValues("emails"); +var AI_EMAIL_PATTERNS = flattenCatalogValues("emailPatterns"); +var AI_NAME_PATTERN = buildNamePattern(AI_SIGNATURE_ALIASES); +var AI_EMAIL_PATTERN = buildEmailPattern(AI_EMAILS, AI_EMAIL_PATTERNS); var DEFAULT_AI_PATTERNS = [ { name: "AI Co-Author Names", pattern: AI_NAME_PATTERN }, { name: "AI Co-Author Emails", pattern: AI_EMAIL_PATTERN } ]; -var AI_AUTHOR_NAMES = [ - "claude", - "claude code", - "claude opus", - "claude sonnet", - "claude haiku", - "anthropic", - "github copilot", - "copilot", - "chatgpt", - "openai", - "cursor ai", - "cursor", - "tabnine", - "codewhisperer", - "codeium", - "replit ghostwriter", - "sourcegraph cody", - "cody", - "factory droid", - "factory-droid", - "factory-droid[bot]", - "gemini", - "google gemini", - "perplexity", - "perplexity ai", - "amazon q", - "amp", - "amp ai", - "ai assistant" -]; +var AI_AUTHOR_NAMES = AI_AUTHOR_TOKENS; function isAIAuthor(name) { const lowerName = name.toLowerCase().trim(); return AI_AUTHOR_NAMES.some((aiName) => lowerName.includes(aiName.toLowerCase())); } // src/utils/hook.ts -function generateHookContent(options = {}) { - const patterns = options.patterns || DEFAULT_AI_PATTERNS; - const patternLiterals = patterns.map((p) => { - const flags = "i"; - return `new RegExp(${JSON.stringify(p.pattern)}, '${flags}')`; - }).join(`, +function getPatternLiterals(patterns) { + return patterns.map((pattern) => `new RegExp(${JSON.stringify(pattern.pattern)}, 'i')`).join(`, `); +} +function escapePowerShellSingleQuotedString(value) { + return value.replace(/'/g, "''"); +} +function quotePosixShell(value) { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} +function generateNodeHookContent(options = {}) { + const patterns = options.patterns || DEFAULT_AI_PATTERNS; + const patternLiterals = getPatternLiterals(patterns); return `#!/usr/bin/env node // nococli: Remove AI co-author signatures from commit messages // Generated by noco (https://github.com/doanbactam/noco) @@ -2053,19 +2157,131 @@ try { process.exit(0); } -const lines = content.split('\\n'); +const lines = content.split(/\\r?\\n/); const filtered = lines.filter(line => !patterns.some(p => p.test(line))); -// Strip trailing blank lines while (filtered.length > 0 && /^\\s*$/.test(filtered[filtered.length - 1])) { filtered.pop(); } -fs.writeFileSync(file, filtered.join('\\n')); +try { + fs.writeFileSync(file, filtered.join('\\n')); +} catch { + process.exit(0); +} +`; +} +function generatePowerShellHookContent(options = {}) { + const patterns = options.patterns || DEFAULT_AI_PATTERNS; + const serializedPatterns = patterns.map((pattern) => ` '${escapePowerShellSingleQuotedString(pattern.pattern)}'`).join(` +`); + return `# nococli: Remove AI co-author signatures from commit messages +# Generated by noco (https://github.com/doanbactam/noco) + +param( + [string]$CommitMessagePath +) + +if (-not $CommitMessagePath) { + exit 0 +} + +$patterns = @( +${serializedPatterns} +) + +try { + $content = Get-Content -LiteralPath $CommitMessagePath -Raw -ErrorAction Stop +} catch { + exit 0 +} + +$lines = $content -split "\\r?\\n" +$filtered = New-Object System.Collections.Generic.List[string] + +foreach ($line in $lines) { + $isAiLine = $false + + foreach ($pattern in $patterns) { + if ($line -match $pattern) { + $isAiLine = $true + break + } + } + + if (-not $isAiLine) { + [void]$filtered.Add($line) + } +} + +while ($filtered.Count -gt 0 -and [string]::IsNullOrWhiteSpace($filtered[$filtered.Count - 1])) { + $filtered.RemoveAt($filtered.Count - 1) +} + +try { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($CommitMessagePath, [string]::Join("\`n", $filtered), $encoding) +} catch { + exit 0 +} `; } +function generateWindowsHookWrapperContent(config, powerShellCommand) { + const runtime = quotePosixShell(toGitPath(powerShellCommand)); + const powerShellHookFile = quotePosixShell(toGitPath(config.powerShellHookFile)); + return `#!/bin/sh +# nococli: Windows hook wrapper +# Generated by noco (https://github.com/doanbactam/noco) + +HOOK_RUNTIME=${runtime} +HOOK_SCRIPT=${powerShellHookFile} + +if [ ! -f "$HOOK_SCRIPT" ]; then + exit 0 +fi + +"$HOOK_RUNTIME" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "$HOOK_SCRIPT" "$1" +exit $? +`; +} +function createHookInstallPlan(options) { + const platform = options.platform ?? process.platform; + const patterns = options.patterns || DEFAULT_AI_PATTERNS; + if (platform === "win32") { + if (!options.powerShellCommand) { + throw new Error("PowerShell runtime is required for Windows hook installation"); + } + return { + mode: "powershell", + runtime: options.powerShellCommand, + files: [ + { + path: options.config.hookFile, + content: generateWindowsHookWrapperContent(options.config, options.powerShellCommand), + mode: 493 + }, + { + path: options.config.powerShellHookFile, + content: generatePowerShellHookContent({ patterns }), + mode: 420 + } + ] + }; + } + return { + mode: "node", + runtime: "node", + files: [ + { + path: options.config.hookFile, + content: generateNodeHookContent({ patterns }), + mode: 493 + } + ] + }; +} function getPatternNames() { - return DEFAULT_AI_PATTERNS.map((p) => p.name); + return DEFAULT_AI_PATTERNS.map((pattern) => pattern.name); } // src/utils/git.ts @@ -2137,16 +2353,52 @@ function setGitUserEmail(email) { setGitConfig("user.email", email); } +// src/utils/runtime.ts +import { execFileSync as execFileSync2 } from "child_process"; +function resolveWindowsCommand(command) { + try { + const output = execFileSync2("where.exe", [command], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"] + }).split(/\r?\n/).map((line) => line.trim()).find(Boolean); + return output || null; + } catch { + return null; + } +} +function detectPowerShellRuntime(platform = process.platform) { + if (platform !== "win32") { + return null; + } + return resolveWindowsCommand("pwsh") ?? resolveWindowsCommand("powershell.exe"); +} + // src/install.ts async function install(options = {}) { const logger2 = new Logger(options.silent); const config = getConfig(); + const platform = options.platform ?? process.platform; try { logger2.info("Creating git templates directory..."); await fs.mkdir(config.hooksDir, { recursive: true }); - const hookContent = generateHookContent(); - await fs.writeFile(config.hookFile, hookContent, { mode: 493 }); - logger2.success(`Hook created at ${config.hookFile}`); + const powerShellRuntime = detectPowerShellRuntime(platform); + if (platform === "win32" && !powerShellRuntime) { + return { + success: false, + message: "PowerShell runtime not found. Install PowerShell 7+ or ensure powershell.exe is available." + }; + } + const installPlan = createHookInstallPlan({ + config, + platform, + powerShellCommand: powerShellRuntime ?? undefined + }); + for (const file of installPlan.files) { + await fs.writeFile(file.path, file.content, { + mode: file.mode + }); + logger2.success(`Hook file created at ${file.path}`); + } logger2.info("Configuring git templates..."); const currentTemplate = getTemplateDir(); const templateDirForGit = toGitPath(config.templateDir); @@ -2160,7 +2412,9 @@ async function install(options = {}) { success: true, message: "Hook installed but git config needs update", hookPath: config.hookFile, - needsInit: true + needsInit: true, + hookMode: installPlan.mode, + runtime: installPlan.runtime }; } if (!currentTemplate.exists) { @@ -2173,7 +2427,9 @@ async function install(options = {}) { success: true, message: "Successfully installed nococli", hookPath: config.hookFile, - needsInit: false + needsInit: false, + hookMode: installPlan.mode, + runtime: installPlan.runtime }; } catch (error) { logger2.error("Installation failed"); @@ -2193,6 +2449,7 @@ async function uninstall(options = {}) { const logger2 = new Logger(options.silent); const config = getConfig(); let removedConfig = false; + let removedPowerShellHook = false; try { logger2.info("Removing hook file..."); try { @@ -2201,6 +2458,13 @@ async function uninstall(options = {}) { } catch { logger2.info("Hook file not found (already removed?)"); } + try { + await fs2.unlink(config.powerShellHookFile); + removedPowerShellHook = true; + logger2.success(`Removed ${config.powerShellHookFile}`); + } catch { + logger2.info("PowerShell hook file not found (already removed?)"); + } try { const hooksExists = await fs2.access(config.hooksDir).then(() => true).catch(() => false); if (hooksExists) { @@ -2235,7 +2499,8 @@ async function uninstall(options = {}) { return { success: true, message: "Successfully uninstalled nococli", - removedConfig + removedConfig, + hookMode: removedPowerShellHook ? "powershell" : "node" }; } catch (error) { logger2.error("Uninstallation failed"); @@ -2271,6 +2536,14 @@ 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)) { @@ -2315,6 +2588,14 @@ async function runInstallCommand(options) { logger2.success("Installation complete!"); } logger2.blank(); + if (result.hookMode === "powershell") { + logger2.info(`Hook mode: ${logger2.cyan("PowerShell native")}`); + logger2.info(`PowerShell runtime: ${logger2.cyan(result.runtime || "unavailable")}`); + logger2.blank(); + } else { + logger2.info(`Hook mode: ${logger2.cyan("Node.js")}`); + logger2.blank(); + } logger2.info("AI signatures that will be removed:"); getPatternNames().forEach((p) => logger2.info(` ${logger2.dim("•")} ${p}`)); logger2.blank(); @@ -2342,19 +2623,35 @@ program2.command("uninstall").description("Remove noco hook from your system").o }); program2.command("status").description("Check if noco is properly installed and configured").action(async () => { logger2.header("noco Status"); + const config = getConfig(); const current = getTemplateDir(); if (current.exists && current.value) { logger2.success(`Installed at ${current.value}`); } else { logger2.warning("Not installed"); } - const config = getConfig(); - try { - await access(config.hookFile); - logger2.success("Hook file exists"); - } catch { + const hookExists = await fileExists(config.hookFile); + const powerShellHookExists = await fileExists(config.powerShellHookFile); + if (hookExists) { + logger2.success("Hook entrypoint exists"); + } else { logger2.warning("Hook file not found"); } + if (powerShellHookExists) { + logger2.success("PowerShell hook runtime exists"); + const runtime = detectPowerShellRuntime(); + if (runtime) { + logger2.info(`Hook mode: ${logger2.cyan("PowerShell native")}`); + logger2.info(`Runtime: ${logger2.cyan(runtime)}`); + } else { + logger2.warning("PowerShell hook installed, but no PowerShell runtime was detected"); + } + } else if (hookExists) { + logger2.info(`Hook mode: ${logger2.cyan("Node.js")}`); + if (process.platform === "win32") { + logger2.warning("Legacy Windows hook detected. Re-run `npx nococli install` to install PowerShell support."); + } + } logger2.blank(); logger2.info(logger2.bold("Supported AI signatures:")); getPatternNames().forEach((p) => logger2.info(` ${logger2.dim("•")} ${p}`)); diff --git a/scripts/e2e-install.mjs b/scripts/e2e-install.mjs index c6136f1..a53d026 100644 --- a/scripts/e2e-install.mjs +++ b/scripts/e2e-install.mjs @@ -7,6 +7,31 @@ import { spawnSync } from 'node:child_process'; const repoRoot = process.cwd(); const cliPath = path.join(repoRoot, 'dist', 'cli.js'); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nococli-e2e-')); +const isWindows = process.platform === 'win32'; +const patternCatalogPath = path.join(repoRoot, 'src', 'pattern-catalog.json'); + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildDefaultPatterns(catalog) { + const uniqueOrdered = (values) => [...new Set(values)]; + const flatten = (key) => uniqueOrdered(catalog.flatMap((provider) => provider[key] ?? [])); + + const signatureAliases = flatten('signatureAliases'); + const emails = flatten('emails'); + const emailPatterns = flatten('emailPatterns'); + const prefix = '^\\s*Co-Authored-By\\s*:\\s*'; + + return [ + new RegExp(`${prefix}(?:${signatureAliases.map(escapeRegex).join('|')}).*`, 'i'), + new RegExp(`${prefix}.*\\b(?:${[...emails.map(escapeRegex), ...emailPatterns].join('|')})\\b.*`, 'i'), + ]; +} + +const aiSignatureRegexes = buildDefaultPatterns( + JSON.parse(fs.readFileSync(patternCatalogPath, 'utf8')), +); function toGitPath(filePath) { return filePath.replace(/\\/g, '/'); @@ -60,6 +85,35 @@ function assertSuccess(result, label) { ); } +function getHookPaths(env) { + return { + hookPath: path.join(env.HOME, '.git-templates', 'hooks', 'commit-msg'), + powerShellHookPath: path.join(env.HOME, '.git-templates', 'hooks', 'commit-msg.ps1'), + }; +} + +function stripAnsi(value) { + return value.replace(/\x1B\[[0-9;]*m/g, ''); +} + +function assertHookArtifacts(env) { + const { hookPath, powerShellHookPath } = getHookPaths(env); + + assert.ok(fs.existsSync(hookPath), `Hook not found at ${hookPath}`); + + if (isWindows) { + assert.ok( + fs.existsSync(powerShellHookPath), + `PowerShell hook runtime not found at ${powerShellHookPath}`, + ); + } else { + assert.ok( + !fs.existsSync(powerShellHookPath), + `Unexpected PowerShell hook runtime found at ${powerShellHookPath}`, + ); + } +} + // Helper to create a git repo with the hook installed function createTestRepo(name, env) { const repoDir = path.join(tempRoot, name); @@ -93,8 +147,9 @@ function getLastCommitMessage(repoDir, env) { // Helper to check if message contains AI signature function containsAISignature(message) { - const aiPattern = /^Co-Authored-By:\s*(Claude|GitHub Copilot|ChatGPT|Anthropic|OpenAI|Cursor AI|AI Assistant|Tabnine|CodeWhisperer|Codeium|Replit Ghostwriter|Sourcegraph Cody|Cody|Factory Droid|factory-droid|Gemini|Google Gemini|Gemini Pro|Perplexity|Perplexity AI|Amazon Q|Amp|Amp AI)/im; - return aiPattern.test(message); + return message + .split(/\r?\n/) + .some((line) => aiSignatureRegexes.some((regex) => regex.test(line))); } // Helper to check if message contains human co-author (non-AI) @@ -118,8 +173,14 @@ try { const expectedTemplateDir = toGitPath(path.join(env.HOME, '.git-templates')); assert.equal(gitConfig.stdout.trim(), expectedTemplateDir); - const hookPath = path.join(env.HOME, '.git-templates', 'hooks', 'commit-msg'); - assert.ok(fs.existsSync(hookPath), `Hook not found at ${hookPath}`); + assertHookArtifacts(env); + + const statusResult = runNode([cliPath, 'status'], { env }); + assertSuccess(statusResult, 'status after default install'); + assert.match( + stripAnsi(statusResult.stdout), + isWindows ? /Hook mode: PowerShell native/i : /Hook mode: Node\.js/i, + ); const repoDir = path.join(tempRoot, 'default-repo'); fs.mkdirSync(repoDir, { recursive: true }); @@ -145,8 +206,7 @@ try { assertSuccess(gitConfig, 'read conflicting git config'); assert.equal(gitConfig.stdout.trim(), presetTemplate); - const hookPath = path.join(env.HOME, '.git-templates', 'hooks', 'commit-msg'); - assert.ok(fs.existsSync(hookPath), `Conflict case did not create hook at ${hookPath}`); + assertHookArtifacts(env); } // ========================================================================= @@ -429,8 +489,7 @@ Fixes #123`; const result3 = runNode([cliPath, 'install', '--force'], { env }); assertSuccess(result3, 'install with --force'); - const hookPath = path.join(env.HOME, '.git-templates', 'hooks', 'commit-msg'); - assert.ok(fs.existsSync(hookPath), 'Hook should exist after multiple installs'); + assertHookArtifacts(env); console.log('✓ Installation idempotency verified'); } @@ -443,6 +502,10 @@ Fixes #123`; console.log('\n--- VAL-INSTALL-006: Uninstallation ---'); { const env = createHome('uninstall-home'); + + const installResult = runNode([cliPath, 'install'], { env }); + assertSuccess(installResult, 'install before uninstall'); + assertHookArtifacts(env); // Verify uninstall command runs without errors (use --silent to skip confirmation) const uninstallResult = runNode([cliPath, 'uninstall', '--silent'], { env }); @@ -450,12 +513,16 @@ Fixes #123`; assert.ok(uninstallResult.stdout.includes('Uninstallation complete') || uninstallResult.stdout.includes('Hook file not found'), 'Uninstall should report completion'); + + const { hookPath, powerShellHookPath } = getHookPaths(env); + assert.ok(!fs.existsSync(hookPath), 'Hook entrypoint should be removed'); + assert.ok(!fs.existsSync(powerShellHookPath), 'PowerShell hook runtime should be removed'); // Verify uninstall with --remove-config runs without errors const uninstallWithConfigResult = runNode([cliPath, 'uninstall', '--silent', '--remove-config'], { env }); assertSuccess(uninstallWithConfigResult, 'uninstall with --remove-config'); - console.log('✓ Uninstallation commands verified (note: tests against real home dir)'); + console.log('✓ Uninstallation commands verified'); } // ========================================================================= @@ -665,32 +732,37 @@ Fixes #123`; assertSuccess(commitResult, `commit ${i} for rebase test`); } - // Simulate rebase squash by using git rebase with automated commands - // Use GIT_SEQUENCE_EDITOR to automate the rebase todo list (replace pick with squash) - // Cross-platform: use Node.js instead of sed for Windows support - const sequenceEditor = `node -e "const fs=require('fs'),f=process.argv[1];let c=fs.readFileSync(f,'utf8');c=c.replace(/^pick/gm,(m,o)=>(c.substring(0,o).split('\\n').length<=3?'squash':m));fs.writeFileSync(f,c)"`; + const squashMessage = `feat: squashed commits\n\nCombined multiple commits.\n\nCo-Authored-By: Claude \nCo-Authored-By: GitHub Copilot `; + + // Simulate rebase squash by using git rebase with automated commands. + // GIT_SEQUENCE_EDITOR rewrites the todo list. GIT_EDITOR intentionally fails + // once so Git leaves the squash message on disk, letting the test rewrite the + // message with AI signatures and continue non-interactively. + const sequenceEditor = `node -e "const fs=require('fs'),f=process.argv[1];const lines=fs.readFileSync(f,'utf8').split(/\\r?\\n/);let seenPick=false;const next=lines.map((line)=>{if(!line.startsWith('pick '))return line;if(!seenPick){seenPick=true;return line;}return line.replace(/^pick /,'squash ');});fs.writeFileSync(f,next.join('\\n'))"`; const rebaseEnv = { ...env, GIT_SEQUENCE_EDITOR: sequenceEditor, + GIT_EDITOR: `node -e "process.exit(1)"`, }; // Run rebase to squash the last 2 commits into the first const rebaseResult = run('git', ['rebase', '-i', '--root'], { cwd: repoDir, env: rebaseEnv }); - // Rebase may require a commit message edit - handle by writing to the file - // The hook should still work during the rebase process if (rebaseResult.status !== 0) { - // If rebase is waiting for message, the commit msg file should exist const commitMsgPath = path.join(repoDir, '.git', 'COMMIT_EDITMSG'); - if (fs.existsSync(commitMsgPath)) { - // Rewrite the message with AI signature - const squashMessage = `feat: squashed commits\n\nCombined multiple commits.\n\nCo-Authored-By: Claude \nCo-Authored-By: GitHub Copilot `; - fs.writeFileSync(commitMsgPath, squashMessage); - - // Continue the rebase - const continueResult = run('git', ['rebase', '--continue'], { cwd: repoDir, env }); - assertSuccess(continueResult, 'continue rebase after message edit'); - } + assert.ok(fs.existsSync(commitMsgPath), 'rebase should stop with COMMIT_EDITMSG ready'); + fs.writeFileSync(commitMsgPath, squashMessage); + + const continueResult = run('git', ['rebase', '--continue'], { + cwd: repoDir, + env: { + ...env, + GIT_EDITOR: `node -e "process.exit(0)"`, + }, + }); + assertSuccess(continueResult, 'continue rebase after rewriting squash message'); + } else { + assert.fail('interactive rebase squash should require message rewrite'); } // Verify AI signatures removed from final commit @@ -710,16 +782,22 @@ Fixes #123`; const result = runNode([cliPath, 'install'], { env }); assertSuccess(result, 'install for platform test'); - const hookPath = path.join(env.HOME, '.git-templates', 'hooks', 'commit-msg'); + const { hookPath, powerShellHookPath } = getHookPaths(env); const hookContent = fs.readFileSync(hookPath, 'utf8'); // Check that hook uses LF line endings (Unix-style) const hasCRLF = hookContent.includes('\r\n'); assert.ok(!hasCRLF, 'Hook should use LF line endings, not CRLF'); - // Check hook content has expected elements - assert.ok(hookContent.includes('#!/usr/bin/env node'), 'Hook should have Node.js shebang'); - assert.ok(hookContent.includes("require('fs')"), 'Hook should use Node.js fs'); + if (isWindows) { + assert.ok(hookContent.includes('#!/bin/sh'), 'Windows hook should use POSIX wrapper entrypoint'); + assert.ok(fs.existsSync(powerShellHookPath), 'Windows PowerShell runtime should exist'); + const powerShellContent = fs.readFileSync(powerShellHookPath, 'utf8'); + assert.ok(powerShellContent.includes('Get-Content -LiteralPath')); + } else { + assert.ok(hookContent.includes('#!/usr/bin/env node'), 'Hook should have Node.js shebang'); + assert.ok(hookContent.includes("require('fs')"), 'Hook should use Node.js fs'); + } console.log('✓ Platform-specific installation verified'); } diff --git a/src/cli.ts b/src/cli.ts index 2a5bc16..ab44790 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { } from './utils/git.js'; import { getConfig } 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'; @@ -51,6 +52,15 @@ 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(); @@ -107,6 +117,15 @@ async function runInstallCommand(options: { force?: boolean; silent?: boolean }) } logger.blank(); + if (result.hookMode === 'powershell') { + logger.info(`Hook mode: ${logger.cyan('PowerShell native')}`); + logger.info(`PowerShell runtime: ${logger.cyan(result.runtime || 'unavailable')}`); + logger.blank(); + } else { + logger.info(`Hook mode: ${logger.cyan('Node.js')}`); + logger.blank(); + } + logger.info('AI signatures that will be removed:'); getPatternNames().forEach((p) => logger.info(` ${logger.dim('\u2022')} ${p}`)); logger.blank(); @@ -159,6 +178,7 @@ program .action(async () => { logger.header('noco Status'); + const config = getConfig(); const current = getTemplateDir(); if (current.exists && current.value) { logger.success(`Installed at ${current.value}`); @@ -166,14 +186,33 @@ program logger.warning('Not installed'); } - const config = getConfig(); - try { - await access(config.hookFile); - logger.success('Hook file exists'); - } catch { + const hookExists = await fileExists(config.hookFile); + const powerShellHookExists = await fileExists(config.powerShellHookFile); + + if (hookExists) { + logger.success('Hook entrypoint exists'); + } else { logger.warning('Hook file not found'); } + if (powerShellHookExists) { + logger.success('PowerShell hook runtime exists'); + const runtime = detectPowerShellRuntime(); + if (runtime) { + logger.info(`Hook mode: ${logger.cyan('PowerShell native')}`); + logger.info(`Runtime: ${logger.cyan(runtime)}`); + } else { + logger.warning('PowerShell hook installed, but no PowerShell runtime was detected'); + } + } else if (hookExists) { + logger.info(`Hook mode: ${logger.cyan('Node.js')}`); + if (process.platform === 'win32') { + logger.warning( + 'Legacy Windows hook detected. Re-run `npx nococli install` to install PowerShell support.', + ); + } + } + logger.blank(); logger.info(logger.bold('Supported AI signatures:')); getPatternNames().forEach((p) => logger.info(` ${logger.dim('\u2022')} ${p}`)); diff --git a/src/install.ts b/src/install.ts index 1c1d5ea..c85f446 100644 --- a/src/install.ts +++ b/src/install.ts @@ -5,28 +5,51 @@ import fs from 'fs/promises'; import { Logger } from './utils/logger.js'; import { getConfig, toGitPath } from './utils/paths.js'; -import { generateHookContent } from './utils/hook.js'; +import { createHookInstallPlan } from './utils/hook.js'; import { getTemplateDir, setTemplateDir } from './utils/git.js'; +import { detectPowerShellRuntime } from './utils/runtime.js'; import type { InstallOptions } from './types.js'; +import type { HookMode } from './types.js'; export interface InstallResult { success: boolean; message: string; hookPath?: string; needsInit?: boolean; + hookMode?: HookMode; + runtime?: string; } export async function install(options: InstallOptions = {}): Promise { const logger = new Logger(options.silent); const config = getConfig(); + const platform = options.platform ?? process.platform; try { logger.info('Creating git templates directory...'); await fs.mkdir(config.hooksDir, { recursive: true }); - const hookContent = generateHookContent(); - await fs.writeFile(config.hookFile, hookContent, { mode: 0o755 }); - logger.success(`Hook created at ${config.hookFile}`); + const powerShellRuntime = detectPowerShellRuntime(platform); + if (platform === 'win32' && !powerShellRuntime) { + return { + success: false, + message: + 'PowerShell runtime not found. Install PowerShell 7+ or ensure powershell.exe is available.', + }; + } + + const installPlan = createHookInstallPlan({ + config, + platform, + powerShellCommand: powerShellRuntime ?? undefined, + }); + + for (const file of installPlan.files) { + await fs.writeFile(file.path, file.content, { + mode: file.mode, + }); + logger.success(`Hook file created at ${file.path}`); + } logger.info('Configuring git templates...'); const currentTemplate = getTemplateDir(); @@ -43,6 +66,8 @@ export async function install(options: InstallOptions = {}): Promise provider[key] ?? [])); +} + +function buildNamePattern(aliases: readonly string[]): string { + return `${CO_AUTHORED_BY_PREFIX}(?:${aliases.map(escapeRegex).join('|')}).*`; +} + +function buildEmailPattern(emails: readonly string[], emailPatterns: readonly string[]): string { + const emailAlternatives = [...emails.map(escapeRegex), ...emailPatterns]; + + return `${CO_AUTHORED_BY_PREFIX}.*\\b(?:${emailAlternatives.join('|')})\\b.*`; +} + +export const AI_SIGNATURE_CATALOG: readonly AIProviderPatternCatalogEntry[] = + patternCatalog as readonly AIProviderPatternCatalogEntry[]; + +const AI_SIGNATURE_ALIASES = flattenCatalogValues('signatureAliases'); +const AI_AUTHOR_TOKENS = flattenCatalogValues('authorTokens'); +const AI_EMAILS = flattenCatalogValues('emails'); +const AI_EMAIL_PATTERNS = flattenCatalogValues('emailPatterns'); + +const AI_NAME_PATTERN = buildNamePattern(AI_SIGNATURE_ALIASES); +const AI_EMAIL_PATTERN = buildEmailPattern(AI_EMAILS, AI_EMAIL_PATTERNS); export const DEFAULT_AI_PATTERNS: readonly AI_PATTERN[] = [ { name: 'AI Co-Author Names', pattern: AI_NAME_PATTERN }, @@ -47,37 +87,7 @@ export const DEFAULT_AI_PATTERNS: readonly AI_PATTERN[] = [ ] as const; // AI author names to detect in git config (case-insensitive) -export const AI_AUTHOR_NAMES = [ - 'claude', - 'claude code', - 'claude opus', - 'claude sonnet', - 'claude haiku', - 'anthropic', - 'github copilot', - 'copilot', - 'chatgpt', - 'openai', - 'cursor ai', - 'cursor', - 'tabnine', - 'codewhisperer', - 'codeium', - 'replit ghostwriter', - 'sourcegraph cody', - 'cody', - 'factory droid', - 'factory-droid', - 'factory-droid[bot]', - 'gemini', - 'google gemini', - 'perplexity', - 'perplexity ai', - 'amazon q', - 'amp', - 'amp ai', - 'ai assistant', -] as const; +export const AI_AUTHOR_NAMES = AI_AUTHOR_TOKENS; export function isAIAuthor(name: string): boolean { const lowerName = name.toLowerCase().trim(); diff --git a/src/uninstall.ts b/src/uninstall.ts index 49b7617..aa31af1 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -7,17 +7,20 @@ import { Logger } from './utils/logger.js'; import { getConfig } from './utils/paths.js'; import { unsetGitConfig } from './utils/git.js'; import type { UninstallOptions } from './types.js'; +import type { HookMode } from './types.js'; export interface UninstallResult { success: boolean; message: string; removedConfig?: boolean; + hookMode?: HookMode; } export async function uninstall(options: UninstallOptions = {}): Promise { const logger = new Logger(options.silent); const config = getConfig(); let removedConfig = false; + let removedPowerShellHook = false; try { logger.info('Removing hook file...'); @@ -28,6 +31,14 @@ export async function uninstall(options: UninstallOptions = {}): Promise { test('generates valid Node.js hook content', () => { - const content = generateHookContent(); + const content = generateNodeHookContent(); expect(content).toContain('#!/usr/bin/env node'); expect(content).toContain('process.argv[2]'); expect(content).toContain("require('fs')"); }); test('includes trailing blank line cleanup', () => { - const content = generateHookContent(); + const content = generateNodeHookContent(); expect(content).toContain('filtered.pop()'); }); + + test('generates valid PowerShell hook content', () => { + const content = generatePowerShellHookContent(); + expect(content).toContain('param('); + expect(content).toContain('Get-Content -LiteralPath'); + expect(content).toContain('System.Text.UTF8Encoding($false)'); + }); + + test('creates a Windows install plan with wrapper and PowerShell runtime', () => { + const plan = createHookInstallPlan({ + config: windowsConfig, + platform: 'win32', + powerShellCommand: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + }); + + expect(plan.mode).toBe('powershell'); + expect(plan.files).toHaveLength(2); + expect(plan.files[0].content).toContain('HOOK_RUNTIME='); + expect(plan.files[0].content).toContain('C:/Program Files/PowerShell/7/pwsh.exe'); + expect(plan.files[0].content).toContain( + 'C:/Users/Test User/.git-templates/hooks/commit-msg.ps1', + ); + expect(plan.files[1].content).toContain('Get-Content -LiteralPath'); + }); + + test('creates a Node install plan on non-Windows', () => { + const plan = createHookInstallPlan({ + config: windowsConfig, + platform: 'linux', + }); + + expect(plan.mode).toBe('node'); + expect(plan.files).toHaveLength(1); + expect(plan.files[0].content).toContain('#!/usr/bin/env node'); + }); +}); + +describe('Commit Message Sanitization', () => { + test('removes AI co-authors and trailing blank lines', () => { + const sanitized = sanitizeCommitMessage(`feat: add feature + +Body line + +Co-Authored-By: Claude + +`); + + expect(sanitized).toBe('feat: add feature\n\nBody line'); + }); + + test('preserves human co-authors', () => { + const sanitized = sanitizeCommitMessage(`feat: add feature + +Co-Authored-By: John Doe `); + + expect(sanitized).toContain('John Doe'); + }); }); describe('Pattern Matching - Case Variations (VAL-PATTERN-001)', () => { @@ -386,29 +458,61 @@ describe('getDefaultPatterns', () => { expect(() => new RegExp(p.pattern, 'i')).not.toThrow(); }); }); + + test('default patterns are derived from the shared catalog data', () => { + const [namePattern, emailPattern] = getDefaultPatterns(); + const nameRegex = new RegExp(namePattern.pattern, 'i'); + const emailRegex = new RegExp(emailPattern.pattern, 'i'); + + AI_SIGNATURE_CATALOG.forEach((provider) => { + provider.signatureAliases.forEach((alias) => { + expect(nameRegex.test(`Co-Authored-By: ${alias} `)).toBe(true); + }); + + provider.emails.forEach((email) => { + expect(emailRegex.test(`Co-Authored-By: Shared Source <${email}>`)).toBe(true); + }); + + provider.emailPatterns?.forEach((emailPatternValue) => { + const sample = + emailPatternValue === '\\d+\\+factory-droid\\[bot\\]@users\\.noreply\\.github\\.com' + ? '138933559+factory-droid[bot]@users.noreply.github.com' + : emailPatternValue; + + expect(emailRegex.test(`Co-Authored-By: Shared Source <${sample}>`)).toBe(true); + }); + }); + }); +}); + +describe('AI author detection', () => { + test('author name tokens are derived from the shared catalog data', () => { + const catalogTokens = AI_SIGNATURE_CATALOG.flatMap((provider) => provider.authorTokens); + expect(AI_AUTHOR_NAMES).toEqual([...new Set(catalogTokens)]); + }); }); -describe('generateHookContent', () => { +describe('generateNodeHookContent', () => { test('accepts custom patterns option', () => { const customPatterns = [{ name: 'Custom AI', pattern: 'Co-Authored-By: CustomAI.*' }]; - const content = generateHookContent({ patterns: customPatterns }); + const content = generateNodeHookContent({ patterns: customPatterns }); expect(content).toContain('CustomAI'); }); test('uses default patterns when no options provided', () => { - const content = generateHookContent(); + const content = generateNodeHookContent(); expect(content).toContain('Claude'); }); test('generates Node.js hook with require("fs")', () => { - const content = generateHookContent(); + const content = generateNodeHookContent(); expect(content).toContain("require('fs')"); expect(content).toContain('#!/usr/bin/env node'); expect(content).toContain('process.argv[2]'); }); test('generates filter logic with patterns.some(p => p.test(line))', () => { - const content = generateHookContent(); + const content = generateNodeHookContent(); expect(content).toContain('patterns.some'); expect(content).toContain('.test(line)'); }); diff --git a/src/utils/hook.ts b/src/utils/hook.ts index bfd198a..ba34e7e 100644 --- a/src/utils/hook.ts +++ b/src/utils/hook.ts @@ -1,27 +1,65 @@ /** - * Git hook template generator — produces a cross-platform Node.js hook + * Git hook template generators */ -import { AI_PATTERN, DEFAULT_AI_PATTERNS } from '../types.js'; +import type { AI_PATTERN, Config, HookMode } from '../types.js'; +import { DEFAULT_AI_PATTERNS } from '../types.js'; +import { toGitPath } from './paths.js'; export interface HookTemplateOptions { patterns?: readonly AI_PATTERN[]; } -/** - * Generate a standalone Node.js hook that strips AI co-author lines. - * Uses Node.js instead of sed for cross-platform support (Windows). - */ -export function generateHookContent(options: HookTemplateOptions = {}): string { - const patterns = options.patterns || DEFAULT_AI_PATTERNS; +export interface HookInstallPlanOptions extends HookTemplateOptions { + config: Config; + platform?: NodeJS.Platform; + powerShellCommand?: string; +} + +export interface HookArtifact { + path: string; + content: string; + mode?: number; +} + +export interface HookInstallPlan { + mode: HookMode; + runtime: string; + files: HookArtifact[]; +} + +export function sanitizeCommitMessage( + content: string, + patterns: readonly AI_PATTERN[] = DEFAULT_AI_PATTERNS, +): string { + const regexes = patterns.map((pattern) => new RegExp(pattern.pattern, 'i')); + const lines = content.split(/\r?\n/); + const filtered = lines.filter((line) => !regexes.some((pattern) => pattern.test(line))); + + while (filtered.length > 0 && /^\s*$/.test(filtered[filtered.length - 1])) { + filtered.pop(); + } + + return filtered.join('\n'); +} - // Convert pattern strings to JS RegExp literal strings for embedding - const patternLiterals = patterns - .map((p) => { - const flags = 'i'; // All patterns are case-insensitive - return `new RegExp(${JSON.stringify(p.pattern)}, '${flags}')`; - }) +function getPatternLiterals(patterns: readonly AI_PATTERN[]): string { + return patterns + .map((pattern) => `new RegExp(${JSON.stringify(pattern.pattern)}, 'i')`) .join(',\n '); +} + +function escapePowerShellSingleQuotedString(value: string): string { + return value.replace(/'/g, "''"); +} + +function quotePosixShell(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; +} + +export function generateNodeHookContent(options: HookTemplateOptions = {}): string { + const patterns = options.patterns || DEFAULT_AI_PATTERNS; + const patternLiterals = getPatternLiterals(patterns); return `#!/usr/bin/env node // nococli: Remove AI co-author signatures from commit messages @@ -42,18 +80,146 @@ try { process.exit(0); } -const lines = content.split('\\n'); +const lines = content.split(/\\r?\\n/); const filtered = lines.filter(line => !patterns.some(p => p.test(line))); -// Strip trailing blank lines while (filtered.length > 0 && /^\\s*$/.test(filtered[filtered.length - 1])) { filtered.pop(); } -fs.writeFileSync(file, filtered.join('\\n')); +try { + fs.writeFileSync(file, filtered.join('\\n')); +} catch { + process.exit(0); +} +`; +} + +export function generatePowerShellHookContent(options: HookTemplateOptions = {}): string { + const patterns = options.patterns || DEFAULT_AI_PATTERNS; + const serializedPatterns = patterns + .map((pattern) => ` '${escapePowerShellSingleQuotedString(pattern.pattern)}'`) + .join('\n'); + + return `# nococli: Remove AI co-author signatures from commit messages +# Generated by noco (https://github.com/doanbactam/noco) + +param( + [string]$CommitMessagePath +) + +if (-not $CommitMessagePath) { + exit 0 +} + +$patterns = @( +${serializedPatterns} +) + +try { + $content = Get-Content -LiteralPath $CommitMessagePath -Raw -ErrorAction Stop +} catch { + exit 0 +} + +$lines = $content -split "\\r?\\n" +$filtered = New-Object System.Collections.Generic.List[string] + +foreach ($line in $lines) { + $isAiLine = $false + + foreach ($pattern in $patterns) { + if ($line -match $pattern) { + $isAiLine = $true + break + } + } + + if (-not $isAiLine) { + [void]$filtered.Add($line) + } +} + +while ($filtered.Count -gt 0 -and [string]::IsNullOrWhiteSpace($filtered[$filtered.Count - 1])) { + $filtered.RemoveAt($filtered.Count - 1) +} + +try { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($CommitMessagePath, [string]::Join("\`n", $filtered), $encoding) +} catch { + exit 0 +} +`; +} + +export function generateHookContent(options: HookTemplateOptions = {}): string { + return generateNodeHookContent(options); +} + +export function generateWindowsHookWrapperContent( + config: Config, + powerShellCommand: string, +): string { + const runtime = quotePosixShell(toGitPath(powerShellCommand)); + const powerShellHookFile = quotePosixShell(toGitPath(config.powerShellHookFile)); + + return `#!/bin/sh +# nococli: Windows hook wrapper +# Generated by noco (https://github.com/doanbactam/noco) + +HOOK_RUNTIME=${runtime} +HOOK_SCRIPT=${powerShellHookFile} + +if [ ! -f "$HOOK_SCRIPT" ]; then + exit 0 +fi + +"$HOOK_RUNTIME" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "$HOOK_SCRIPT" "$1" +exit $? `; } +export function createHookInstallPlan(options: HookInstallPlanOptions): HookInstallPlan { + const platform = options.platform ?? process.platform; + const patterns = options.patterns || DEFAULT_AI_PATTERNS; + + if (platform === 'win32') { + if (!options.powerShellCommand) { + throw new Error('PowerShell runtime is required for Windows hook installation'); + } + + return { + mode: 'powershell', + runtime: options.powerShellCommand, + files: [ + { + path: options.config.hookFile, + content: generateWindowsHookWrapperContent(options.config, options.powerShellCommand), + mode: 0o755, + }, + { + path: options.config.powerShellHookFile, + content: generatePowerShellHookContent({ patterns }), + mode: 0o644, + }, + ], + }; + } + + return { + mode: 'node', + runtime: 'node', + files: [ + { + path: options.config.hookFile, + content: generateNodeHookContent({ patterns }), + mode: 0o755, + }, + ], + }; +} + /** * Get list of default AI patterns */ @@ -65,5 +231,5 @@ export function getDefaultPatterns(): readonly AI_PATTERN[] { * Get list of pattern names for display */ export function getPatternNames(): string[] { - return DEFAULT_AI_PATTERNS.map((p) => p.name); + return DEFAULT_AI_PATTERNS.map((pattern) => pattern.name); } diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts new file mode 100644 index 0000000..d150f81 --- /dev/null +++ b/src/utils/paths.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { getConfig, getHomeDir, toGitPath } from './paths'; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } +}); + +describe('Path Utilities', () => { + test('prefers HOME when available', () => { + process.env.HOME = '/tmp/noco-home'; + process.env.USERPROFILE = 'C:\\Users\\Ignored'; + + expect(getHomeDir()).toBe('/tmp/noco-home'); + }); + + test('falls back to USERPROFILE when HOME is missing', () => { + delete process.env.HOME; + process.env.USERPROFILE = 'C:\\Users\\PowerShell'; + + expect(getHomeDir()).toBe('C:\\Users\\PowerShell'); + }); + + test('builds config paths from the resolved home directory', () => { + const home = '/tmp/noco-home'; + process.env.HOME = home; + + const config = getConfig(); + expect(toGitPath(config.templateDir)).toBe('/tmp/noco-home/.git-templates'); + expect(toGitPath(config.hookFile)).toBe('/tmp/noco-home/.git-templates/hooks/commit-msg'); + expect(toGitPath(config.powerShellHookFile)).toBe( + '/tmp/noco-home/.git-templates/hooks/commit-msg.ps1', + ); + }); + + test('converts Windows paths to git-friendly slashes', () => { + expect(toGitPath('C:\\Users\\Test User\\.git-templates')).toBe( + 'C:/Users/Test User/.git-templates', + ); + }); +}); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 175ff06..3e7d657 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -8,6 +8,14 @@ import fs from 'fs/promises'; import { Config } from '../types.js'; export function getHomeDir(): string { + if (process.env.HOME) { + return process.env.HOME; + } + + if (process.env.USERPROFILE) { + return process.env.USERPROFILE; + } + return os.homedir(); } @@ -16,11 +24,13 @@ export function getConfig(): Config { const templateDir = path.join(homeDir, '.git-templates'); const hooksDir = path.join(templateDir, 'hooks'); const hookFile = path.join(hooksDir, 'commit-msg'); + const powerShellHookFile = path.join(hooksDir, 'commit-msg.ps1'); return { templateDir, hooksDir, hookFile, + powerShellHookFile, }; } diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts new file mode 100644 index 0000000..5066af1 --- /dev/null +++ b/src/utils/runtime.ts @@ -0,0 +1,31 @@ +/** + * Runtime resolution helpers + */ + +import { execFileSync } from 'child_process'; + +function resolveWindowsCommand(command: string): string | null { + try { + const output = execFileSync('where.exe', [command], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + return output || null; + } catch { + return null; + } +} + +export function detectPowerShellRuntime( + platform: NodeJS.Platform = process.platform, +): string | null { + if (platform !== 'win32') { + return null; + } + + return resolveWindowsCommand('pwsh') ?? resolveWindowsCommand('powershell.exe'); +}