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', () => {