diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 018f0384..b6be6fb4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "commitlore", "displayName": "CommitLore", - "version": "1.2.3", + "version": "1.2.4", "description": "Recorded decisions from git history, delivered to the agent before it edits. Constraints, alternatives already ruled out, and warnings left by whoever was here last.", "author": { "name": "MongLong0214", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 15cc39b1..da184b0d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "description": "Decision memory from Git history, with verified capture for coding sessions.", "author": { "name": "MongLong0214", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8150c2b6..2bf2c752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ Release notes for 1.0.0, 1.0.1 and 1.0.2 are on the [GitHub releases page](https://github.com/MongLong0214/commitlore/releases); they were not written here. +## 1.2.4 + +`doctor` blamed the hook that worked, and prescribed reinstalling it. + +**`hook-runtime` attributed a preserved hook's failure to commitlore's hook +(#876).** The installed stub runs the hook it preserved at install time first and +exits with that hook's code, verbatim, before commitlore is resolved. Probed as +one process, a preserved hook that called `node` by name died with 127 under +git's PATH and the row read "the hook cannot find a node interpreter", with +`commitlore hooks install` as the fix. That command rewrites only commitlore's +own file; it reported the file unchanged, and the next `doctor` failed +identically. The reporter spent several minutes repairing the wrong component, +because the diagnostic named it. + +**The preserved hook now runs on its own first**, under the same PATH-less +environment and through `sh` the way the stub invokes it. If it exits non-zero, +the row says commitlore's hook is not what failed, names the preserved hook by +path, classifies its first stderr line the same way the stub's was (node +missing, node threw, unclear), and prescribes a fix aimed at that file. Only once +it has passed does the stub run, so every remaining failure is commitlore's own +resolution and `hooks install` is once again a remedy that can move it. + +**`commit-msg-hook` inherits the fix along with the outcome.** It was the row the +reporter read: it already carried the runtime row's outcome when blocked on it, +and kept saying `hooks install` under an outcome that had just explained why +that could not help. + +Not changed: a broken preserved hook still blocks every commit. That is the +chaining contract — a hook that was rejecting commits before commitlore arrived +must keep rejecting them — and whether a hook that is broken rather than +rejecting deserves different treatment is a separate decision from a diagnostic +one. + ## 1.2.3 The capture prompt was the session, so on a long session there was no prompt. diff --git a/README.ja.md b/README.ja.md index 1ccf4a88..2fb227b1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
先にインストーラーを読みたいですか? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。 -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore はその判断をコードのそばに残します。 macOS と Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。 diff --git a/README.ko.md b/README.ko.md index c5bb0b9f..a5883f2c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
먼저 설치기를 읽어 보고 싶나요? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다. -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore는 그 판단을 코드 곁에 보관합니다. macOS와 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다. diff --git a/README.md b/README.md index 9c0d0634..365acbd0 100644 --- a/README.md +++ b/README.md @@ -48,18 +48,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
Prefer to read the installer first? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # Or skip the script: the checkout it makes is one you can make yourself. -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -109,13 +109,13 @@ preserve, not for narrating every change. macOS and Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` Requires Node.js 22.23.2+ and Git. The script checks both before it writes anything. diff --git a/README.zh-CN.md b/README.zh-CN.md index 337ab275..8790036f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
想先阅读安装器吗? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # 或者跳过脚本:它创建的检出,你自己也能创建。 -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -105,13 +105,13 @@ CommitLore 把那份判断留在代码旁边。 macOS 和 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` 需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。 diff --git a/install.ps1 b/install.ps1 index 055f8561..24558bcb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ <# Installs commitlore from source on Windows, for any agent that is not Claude Code. - irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1 | iex - & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 + irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1 | iex + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 Claude Code users do not need this script. The repository is itself a plugin marketplace (ADR-0011), so two /plugin commands register the MCP server, the diff --git a/install.sh b/install.sh index f77ed7cb..c69df874 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ #!/bin/sh # Installs commitlore from source, for any agent that is not Claude Code. # -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 # # **Claude Code users do not need this script.** The repository is itself a # plugin marketplace (ADR-0011), so two `/plugin` commands register the MCP diff --git a/package-lock.json b/package-lock.json index 282256ab..5730f28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index e3d0c2a1..b1b7b646 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "description": "Git-native, lifecycle-aware decision memory for coding agents", "license": "MIT", "private": true, diff --git a/server.json b/server.json index 503946f5..6e4f9cb6 100644 --- a/server.json +++ b/server.json @@ -8,7 +8,7 @@ "source": "github" }, "websiteUrl": "https://github.com/MongLong0214/commitlore#readme", - "version": "1.2.3", + "version": "1.2.4", "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { "registryFit": "Distribution is a tagged git checkout plus a Claude Code plugin marketplace (ADR-0011 registry-free git distribution, ADR-0026 no compiled executables and no uploaded release asset), so no official package type applies and this record relies on websiteUrl plus publisher metadata.", @@ -18,8 +18,8 @@ "/plugin marketplace add MongLong0214/commitlore", "/plugin install commitlore@commitlore" ], - "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3", - "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.3" + "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4", + "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.4" }, "runtime": { "transport": "stdio", diff --git a/src/commands/doctor/checks/capture-commit-msg-hook.ts b/src/commands/doctor/checks/capture-commit-msg-hook.ts index dc7471f9..73f3cbf1 100644 --- a/src/commands/doctor/checks/capture-commit-msg-hook.ts +++ b/src/commands/doctor/checks/capture-commit-msg-hook.ts @@ -115,6 +115,11 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec ]; if (runtime !== undefined && runtime.status !== 'ok') { const inherited = `installed at ${path}; ${targetDetail}; outcome: ${runtime.detail}`; + // This row is blocked on the runtime's finding, so the fix that moves that + // finding is the only fix that moves this one. Prescribing `hooks install` + // here regardless is how a preserved hook's failure came to carry a remedy + // that reinstalls the hook which worked (#876). + const inheritedFix = runtime.fix ?? install; // A skipped runtime would make this row a skip too, and a skip has to name // a reason. Inheriting the runtime's is the only answer that stays true — // this row did not look for the same reason that one did not. The branch is @@ -130,7 +135,7 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec title, 'skipped', inherited, - install, + inheritedFix, false, false, { @@ -148,7 +153,7 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec title, runtime.status, inherited, - install, + inheritedFix, false, undefined, { evidence: { ...hookEvidence, runtime_status: runtime.status } }, diff --git a/src/commands/doctor/checks/capture-hook-runtime.ts b/src/commands/doctor/checks/capture-hook-runtime.ts index 9d2b018b..b7f554a2 100644 --- a/src/commands/doctor/checks/capture-hook-runtime.ts +++ b/src/commands/doctor/checks/capture-hook-runtime.ts @@ -5,12 +5,39 @@ * receive its completed row through the registry rather than importing it. */ -import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { accessSync, constants as fsConstants, existsSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir as tmpdirPath } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { CHAINED_HOOK_NAME } from '../../../hooks/commit-msg.js'; import { check, gitOptions, PROBE_MESSAGE, streamEvidence, type Category, type DoctorCheck, type DoctorContext } from '../model.js'; +/** + * The stub runs `"$chained" "$@"` only when `[ -x "$chained" ]` holds, so a + * preserved hook without its execute bit is inert to git and to the stub alike. + * The same test here, so this check does not probe a file the hook will skip. + */ +const isExecutable = (path: string): boolean => { + try { + accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +}; + +/** + * How the stub's failure reads on its first stderr line: node was never found, + * node ran and threw, or neither. Shared between the two hooks this check runs, + * because the preserved hook fails in the same three shapes and the + * classification is about the line, not about who wrote it. + */ +const classifyFailure = (status: number | null, said: string): 'node-missing' | 'node-threw' | 'unclear' => { + if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said)) return 'node-missing'; + if (/^\s*at\s|\.js:\d+/.test(said)) return 'node-threw'; + return 'unclear'; +}; + /** * Whether the installed hook actually runs, in the environment git gives it. * @@ -29,6 +56,12 @@ import { check, gitOptions, PROBE_MESSAGE, streamEvidence, type Category, type D * The probe message is valid, so a healthy hook exits 0. A hook that cannot find * a runtime exits non-zero having parsed nothing, which is indistinguishable * from "your message was fine" to everyone except this check. + * + * Two hooks run here, not one. The stub hands the message to the hook it + * preserved at install time before it resolves commitlore, and exits with that + * hook's code if it fails -- so the preserved hook is probed on its own first, + * and its failure is reported as its own, with a fix aimed at it. `hooks + * install` cannot move a finding about a file it does not write (#876). */ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => { const { opts, git, spawn, env } = ctx; @@ -77,15 +110,71 @@ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => { } const probe = join(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`); + // No node, and no PATH entry that could supply one. `git` must stay + // reachable: the hook reads its own config through it. + const hookEnv = { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' }; try { + // The stub runs the hook it preserved at install time first, and that + // hook's non-zero exit is the stub's exit, verbatim, before commitlore is + // reached. Probed through the stub alone, the two are one process with one + // stderr, and the row attributed a preserved hook's `node: command not + // found` to the installed hook and prescribed `hooks install` -- which + // reports the file unchanged, because the file it writes was never the one + // failing (#876). So the preserved hook runs on its own first, the way the + // stub runs it: through sh, so a script without a shebang behaves the same + // here as it does there. + const chained = join(dirname(hook), CHAINED_HOOK_NAME); + if (isExecutable(chained)) { + writeFileSync(probe, PROBE_MESSAGE); + const preserved = spawn('/bin/sh', ['-c', '"$0" "$1"', chained, probe], { + shell: false, + encoding: 'utf8', + cwd, + env: hookEnv, + }); + const exit = preserved.error === undefined ? preserved.status : null; + if (preserved.error !== undefined || exit !== 0) { + const spoke = `${preserved.stderr ?? ''}`.trim(); + const said = preserved.error?.message ?? (spoke.split('\n')[0] ?? ''); + const shape = preserved.error === undefined ? classifyFailure(exit, said) : 'unclear'; + const because = + shape === 'node-missing' + ? `it calls node by name and git's PATH has none: ${said}` + : shape === 'node-threw' + ? `its node process ran but threw (exit ${String(exit)}): ${said}` + : `it exited ${String(exit ?? 'unavailable')} under the restricted PATH: ${said || 'no output'}`; + return check( + id, + category, + title, + 'fail', + `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`, + shape === 'node-missing' + ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)` + : `fix or remove ${chained}`, + false, + undefined, + { + evidence: { + hook_path: hook, + chained_hook_path: chained, + exit_code: String(exit ?? 'unavailable'), + ...(preserved.error === undefined ? {} : { error: preserved.error.message }), + ...streamEvidence('stderr', preserved.stderr ?? ''), + }, + }, + ); + } + } + + // A commit-msg hook may rewrite the message it is given; the probe is + // written again so the stub reads the same bytes the preserved hook did. writeFileSync(probe, PROBE_MESSAGE); const run = spawn('/bin/sh', [hook, probe], { shell: false, encoding: 'utf8', cwd, - // No node, and no PATH entry that could supply one. `git` must stay - // reachable: the hook reads its own config through it. - env: { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' }, + env: hookEnv, }); if (run.error !== undefined) { @@ -111,10 +200,11 @@ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => { if (run.status !== 0) { const spoke = `${run.stderr ?? ''}`.trim(); const said = spoke.split('\n')[0] ?? ''; - const nodeMissing = - run.status === 127 || - /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said); - const nodeThrew = /^\s*at\s|\.js:\d+/.test(said); + // The preserved hook, if any, has already exited 0 on its own above, so + // whatever follows is commitlore's resolution failing, not a hand-off. + const shape = classifyFailure(run.status, said); + const nodeMissing = shape === 'node-missing'; + const nodeThrew = shape === 'node-threw'; // The stub says this when the recorded pair resolved and the containment // check refused it: present, executable, and under a tree this install // did not record. An upgrade produces it, because `commitlore.bin` follows diff --git a/test/doctor.test.ts b/test/doctor.test.ts index ad80e0e2..c66f8488 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -42,7 +42,7 @@ import { POLICY_FILE_NAME } from '../src/core/capture-policy.js'; import { REQUIRE_SIGNED_DIRECTIVE_KEY } from '../src/core/trusted-authors.js'; // The real stub T-202 installs — doctor must recognize that exact file, so the // fixture is the installer's own output rather than a lookalike. -import { HOOK_MARKER, commitMsgStub } from '../src/hooks/commit-msg.js'; +import { CHAINED_HOOK_NAME, HOOK_MARKER, commitMsgStub } from '../src/hooks/commit-msg.js'; import { CLAUDE_HOOK_MARKER, claudeSettingsPath, @@ -655,6 +655,96 @@ describe('doctor: hook runtime', () => { expect(runtime?.detail).toMatch(/unclear|cannot determine/i); expect(runtime?.detail).not.toContain('carries no node'); }); + + /** + * #876. The stub runs the hook it preserved at install time first and exits + * with that hook's code, so a preserved hook that calls `node` by name dies + * with 127 before commitlore is reached. Probed as one process, the row read + * that as commitlore's hook failing and prescribed `hooks install`, which + * reported the file unchanged and left the failure exactly where it was. The + * row has to name the file that produced the exit and offer a fix that can + * move it. + */ + const chainedPath = (repo: string): string => join(dirname(hookPath(repo)), CHAINED_HOOK_NAME); + + it('names the preserved hook, not the installed one, when the preserved hook cannot find node', () => { + const repo = initRepo('doctor-runtime-chained-no-node'); + installedHook(repo); + // The shape of the reporter's hook: a repository's own commit-msg calling + // node by name, which is fine on an interactive PATH and 127 on git's. + writeScript(chainedPath(repo), '#!/bin/sh\nexec node /nonexistent/lint-commit.js "$@"\n'); + chmodSync(chainedPath(repo), 0o755); + + const report = runDoctor({ cwd: repo }); + const runtime = report.checks.find((entry) => entry.id === 'hook-runtime'); + expect(runtime?.status).toBe('fail'); + expect(runtime?.detail).toContain(chainedPath(repo)); + expect(runtime?.detail).toMatch(/commitlore's hook is not what failed/); + expect(runtime?.detail).toContain('node'); + expect(runtime?.fix).toContain(chainedPath(repo)); + expect(runtime?.fix).not.toContain('hooks install'); + // The row the reporter read was `commit-msg-hook`, which inherits the + // runtime's outcome. It has to inherit the remedy too, or it keeps saying + // `hooks install` under an outcome that just explained why that cannot help. + const installation = report.checks.find((entry) => entry.id === 'commit-msg-hook'); + expect(installation?.status).toBe('fail'); + expect(installation?.blockedBy).toBe('hook-runtime'); + expect(installation?.fix).toContain(chainedPath(repo)); + expect(installation?.fix).not.toContain('hooks install'); + // Evidence paths are normalised (a home prefix becomes `~`), so the name + // is what is pinned, not the absolute string. + expect(runtime?.evidence['chained_hook_path']?.endsWith(`/${CHAINED_HOOK_NAME}`)).toBe(true); + expect(runtime?.evidence['exit_code']).toBe('127'); + }); + + it('names the preserved hook when it exits non-zero for a reason unrelated to node', () => { + const repo = initRepo('doctor-runtime-chained-broken'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\necho "lint config missing" >&2\nexit 3\n'); + chmodSync(chainedPath(repo), 0o755); + + const runtime = runtimeCheck(repo); + expect(runtime?.status).toBe('fail'); + expect(runtime?.detail).toContain(chainedPath(repo)); + expect(runtime?.detail).toContain('lint config missing'); + expect(runtime?.detail).not.toMatch(/the hook cannot find a node interpreter/); + expect(runtime?.fix).toContain(chainedPath(repo)); + expect(runtime?.fix).not.toContain('hooks install'); + expect(runtime?.evidence['exit_code']).toBe('3'); + }); + + it('still runs the installed hook, and blames it, once the preserved hook has passed', () => { + const repo = initRepo('doctor-runtime-chained-ok-then-node-gone'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\nexit 0\n'); + chmodSync(chainedPath(repo), 0o755); + git(repo, ['config', '--local', 'commitlore.node', '/nonexistent/node']); + + const runtime = runtimeCheck(repo); + expect(runtime?.status).toBe('fail'); + expect(runtime?.detail).not.toMatch(/commitlore's hook is not what failed/); + expect(runtime?.fix).toContain('hooks install'); + expect(runtime?.evidence['chained_hook_path']).toBeUndefined(); + }); + + it('reports ok when the preserved hook passes and so does the installed one', () => { + const repo = initRepo('doctor-runtime-chained-ok'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\nexit 0\n'); + chmodSync(chainedPath(repo), 0o755); + + expect(runtimeCheck(repo)?.status).toBe('ok'); + }); + + it('ignores a preserved hook without its execute bit, as the stub does', () => { + // `[ -x "$chained" ]` in the stub: git would not have run this file either. + const repo = initRepo('doctor-runtime-chained-inert'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\nexit 1\n'); + chmodSync(chainedPath(repo), 0o644); + + expect(runtimeCheck(repo)?.status).toBe('ok'); + }); }); describe('doctor: PreToolUse hook runtime', () => {