@marcomigozzi/trailhead 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,9 @@
5
5
  // Drives the SAME fixtures through two paths:
6
6
  // 1. checkCommitMessage() directly (the source of truth).
7
7
  // 2. the template hook (templates/trailhead-commit-msg) end-to-end via
8
- // execFileSync, feeding it the message as a temp file.
8
+ // execFileSync, feeding it the message as a temp file, run from a fresh
9
+ // mkdtemp cwd (so the template's own .trailhead/session-ticket lookup is
10
+ // independent of this repo's real marker).
9
11
  // The two verdicts must always agree: that agreement is the anti-drift
10
12
  // guarantee that keeps the Claude Code hook and the git commit-msg hook from
11
13
  // ever diverging.
@@ -15,7 +17,7 @@ const path = require('path');
15
17
  const fs = require('fs');
16
18
  const os = require('os');
17
19
 
18
- const { checkCommitMessage } = require('./commit-message-check.js');
20
+ const { checkCommitMessage, parseSessionTicket, hasRefsFor } = require('./commit-message-check.js');
19
21
 
20
22
  const templatePath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-commit-msg');
21
23
 
@@ -37,7 +39,7 @@ const FIXTURES = [
37
39
  { name: 'empty message', message: '', expectOk: true },
38
40
  ];
39
41
 
40
- // --- unit: checkCommitMessage() directly ---
42
+ // --- unit: checkCommitMessage() directly (no ticket, no marker) ---
41
43
  for (const f of FIXTURES) {
42
44
  const verdict = checkCommitMessage(f.message);
43
45
  ok(`checkCommitMessage: ${f.name} -> ok=${f.expectOk}`, verdict.ok === f.expectOk);
@@ -46,22 +48,30 @@ for (const f of FIXTURES) {
46
48
  }
47
49
  }
48
50
 
49
- // --- end-to-end: drive the template hook on the SAME fixtures ---
50
- function runTemplateHook(message) {
51
- const tmpFile = path.join(os.tmpdir(), `trailhead-commit-msg-test-${process.pid}-${Math.random().toString(36).slice(2)}.txt`);
51
+ // --- end-to-end: drive the template hook on the SAME fixtures, no marker ---
52
+ function runTemplateHook(message, marker) {
53
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-commit-msg-test-'));
54
+ const tmpFile = path.join(tmp, 'COMMIT_EDITMSG');
52
55
  fs.writeFileSync(tmpFile, message);
56
+ if (marker !== undefined) {
57
+ fs.mkdirSync(path.join(tmp, '.trailhead'));
58
+ fs.writeFileSync(path.join(tmp, '.trailhead', 'session-ticket'), marker);
59
+ }
60
+ let allowed = true;
61
+ let stderr = '';
53
62
  try {
54
- execFileSync('node', [templatePath, tmpFile]);
55
- return true; // exit 0 -> allow
56
- } catch {
57
- return false; // non-zero exit -> block
63
+ execFileSync('node', [templatePath, tmpFile], { cwd: tmp, stdio: 'pipe' });
64
+ } catch (e) {
65
+ allowed = false;
66
+ stderr = e.stderr ? e.stderr.toString() : '';
58
67
  } finally {
59
- fs.rmSync(tmpFile, { force: true });
68
+ fs.rmSync(tmp, { recursive: true, force: true });
60
69
  }
70
+ return { allowed, stderr };
61
71
  }
62
72
 
63
73
  for (const f of FIXTURES) {
64
- const allowed = runTemplateHook(f.message);
74
+ const { allowed } = runTemplateHook(f.message);
65
75
  const expected = checkCommitMessage(f.message).ok;
66
76
  ok(`template hook: ${f.name} -> allowed=${expected}`, allowed === expected);
67
77
  }
@@ -78,6 +88,86 @@ ok('no-strip: #-leading subject blocks with the conventional code',
78
88
  ok('default strip: #-leading line is a comment -> empty subject -> allow',
79
89
  checkCommitMessage(hashSubject).ok === true);
80
90
  ok('template hook: #-leading message is file-semantics (comment) -> allow',
81
- runTemplateHook(hashSubject) === true);
91
+ runTemplateHook(hashSubject).allowed === true);
92
+
93
+ // --- parseSessionTicket unit cases ---
94
+ ok('parseSessionTicket: normal marker', parseSessionTicket('#184 Enforce the Refs trailer\n') === 184);
95
+ ok('parseSessionTicket: number only', parseSessionTicket('#184') === 184);
96
+ ok('parseSessionTicket: empty text', parseSessionTicket('') === null);
97
+ ok('parseSessionTicket: missing hash', parseSessionTicket('184 Title') === null);
98
+ ok('parseSessionTicket: non-numeric', parseSessionTicket('#abc') === null);
99
+ ok('parseSessionTicket: zero', parseSessionTicket('#0 Title') === null);
100
+ ok('parseSessionTicket: trailing junk glued to number', parseSessionTicket('#184x Title') === null);
101
+ ok('parseSessionTicket: BOM stripped', parseSessionTicket('#184 Title') === 184);
102
+ ok('parseSessionTicket: CRLF stripped', parseSessionTicket('#184 Title\r\nsecond line') === 184);
103
+
104
+ // --- Refs / ticket fixtures: [marker, message, expectOk, expectCode?] ---
105
+ const REFS_FIXTURES = [
106
+ { name: 'refs matches marker', marker: '#184 Title\n', message: 'feat: x\n\nRefs: #184', expectOk: true },
107
+ { name: 'refs multi list matches', marker: '#184 Title\n', message: 'feat: x\n\nRefs: #12, #184', expectOk: true },
108
+ { name: 'lowercase refs matches', marker: '#184 Title\n', message: 'feat: x\n\nrefs: #184', expectOk: true },
109
+ { name: 'no refs at all', marker: '#184 Title\n', message: 'feat: x', expectOk: false, code: 'REFS_TRAILER_MISSING' },
110
+ { name: 'refs wrong ticket', marker: '#184 Title\n', message: 'feat: x\n\nRefs: #183', expectOk: false, code: 'REFS_TRAILER_MISSING' },
111
+ { name: 'refs prefix-only mismatch (18 vs 184)', marker: '#184 Title\n', message: 'feat: x\n\nRefs: #18', expectOk: false, code: 'REFS_TRAILER_MISSING' },
112
+ { name: 'marker itself truncated (#18) with refs #184', marker: '#18 Title\n', message: 'feat: x\n\nRefs: #184', expectOk: false, code: 'REFS_TRAILER_MISSING' },
113
+ { name: 'refs suffix junk rejected (#184abc)', marker: '#184 Title\n', message: 'feat: x\n\nRefs: #184abc', expectOk: false, code: 'REFS_TRAILER_MISSING' },
114
+ { name: 'refs only inside a comment line', marker: '#184 Title\n', message: 'feat: x\n\n# Refs: #184', expectOk: false, code: 'REFS_TRAILER_MISSING' },
115
+ {
116
+ name: 'refs only after the scissors line',
117
+ marker: '#184 Title\n',
118
+ message: 'feat: x\n\n# ------------------------ >8 ------------------------\nRefs: #184',
119
+ expectOk: false,
120
+ code: 'REFS_TRAILER_MISSING',
121
+ },
122
+ { name: 'empty message with active marker still requires refs', marker: '#184 Title\n', message: '', expectOk: false, code: 'REFS_TRAILER_MISSING' },
123
+ { name: 'no marker: refs rule off, plain conventional commit ok', marker: undefined, message: 'feat: x', expectOk: true },
124
+ { name: 'malformed marker (no hash): rule off', marker: '184 Title\n', message: 'feat: x', expectOk: true },
125
+ { name: 'malformed marker (non-numeric): rule off', marker: '#abc\n', message: 'feat: x', expectOk: true },
126
+ { name: 'malformed marker (empty file): rule off', marker: '', message: 'feat: x', expectOk: true },
127
+ { name: 'malformed marker (#0): rule off', marker: '#0 Title\n', message: 'feat: x', expectOk: true },
128
+ {
129
+ name: 'precedence: non-conventional subject without refs -> conventional code wins',
130
+ marker: '#184 Title\n',
131
+ message: 'wip stuff',
132
+ expectOk: false,
133
+ code: 'CONVENTIONAL_COMMITS_VIOLATION',
134
+ },
135
+ ];
136
+
137
+ for (const f of REFS_FIXTURES) {
138
+ const ticket = parseSessionTicket(f.marker === undefined ? '' : f.marker);
139
+ const verdict = checkCommitMessage(f.message, { ticket });
140
+ ok(`checkCommitMessage(ticket): ${f.name} -> ok=${f.expectOk}`, verdict.ok === f.expectOk);
141
+ if (!f.expectOk && f.code) {
142
+ ok(`checkCommitMessage(ticket): ${f.name} -> code=${f.code}`, verdict.code === f.code);
143
+ }
144
+
145
+ const { allowed, stderr } = runTemplateHook(f.message, f.marker);
146
+ ok(`template hook(ticket): ${f.name} -> allowed=${f.expectOk}`, allowed === f.expectOk);
147
+ if (!f.expectOk && f.code === 'REFS_TRAILER_MISSING') {
148
+ ok(`template hook(ticket): ${f.name} -> stderr matches lib reason`,
149
+ stderr.trim() === verdict.reason);
150
+ }
151
+ }
152
+
153
+ // --- extra API asserts from the plan ---
154
+ ok('checkCommitMessage: string ticket disables the rule (never blocks)',
155
+ checkCommitMessage('feat: x', { ticket: '184' }).ok === true);
156
+ ok('checkCommitMessage: numeric ticket + stripComments:false still finds Refs',
157
+ checkCommitMessage('feat: x\n\nRefs: #184', { ticket: 184, stripComments: false }).ok === true);
158
+
159
+ // --- template stderr content check for the missing-refs case ---
160
+ {
161
+ const { stderr } = runTemplateHook('feat: x', '#184 Title\n');
162
+ ok('template stderr mentions Refs: #184', stderr.includes('Refs: #184'));
163
+ }
164
+
165
+ // --- hasRefsFor unit checks ---
166
+ ok('hasRefsFor: direct match', hasRefsFor('Refs: #184', 184) === true);
167
+ ok('hasRefsFor: no match', hasRefsFor('Refs: #18', 184) === false);
168
+ ok('hasRefsFor: a "# Refs:" comment line never counts',
169
+ hasRefsFor('# Refs: #184', 184) === false);
170
+ ok('hasRefsFor: a Refs line in a multi-line body counts',
171
+ hasRefsFor('feat: x\n\nbody\n\nRefs: #184', 184) === true);
82
172
 
83
173
  console.log(`✓ commit-message-check: ${passed} assertions passed`);
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-commit-msg-sync.js. Run: node commit-msg-sync.test.js
3
+ // No framework: plain asserts, mirrors the style of commit-message-check.test.js.
4
+ //
5
+ // Covers three layers:
6
+ // 1. isTrailheadHook() / normalize() unit cases.
7
+ // 2. syncCommitMsgHook() unit cases against mkdtemp hook dirs (fs seam left
8
+ // as the real fs; each case gets its own scratch dir).
9
+ // 3. resolveHooksTarget() with a stubbed runGit (no shelling out).
10
+ // 4. End-to-end: execFileSync the script itself against real git repos.
11
+ const assert = require('assert');
12
+ const { execFileSync } = require('child_process');
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+
17
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-commit-msg-sync.js');
18
+ const templatePath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-commit-msg');
19
+ const oldFixturePath = path.join(__dirname, 'fixtures', 'commit-msg-a319156');
20
+
21
+ const {
22
+ resolveHooksTarget,
23
+ isTrailheadHook,
24
+ normalize,
25
+ syncCommitMsgHook,
26
+ } = require(scriptPath);
27
+
28
+ let passed = 0;
29
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
30
+
31
+ const templateText = fs.readFileSync(templatePath, 'utf8');
32
+ const oldHookText = fs.readFileSync(oldFixturePath, 'utf8');
33
+
34
+ function mktmp() {
35
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-commit-msg-sync-test-'));
36
+ }
37
+
38
+ // --- isTrailheadHook() unit cases -------------------------------------------
39
+ ok('isTrailheadHook: current template is recognised', isTrailheadHook(templateText));
40
+ ok('isTrailheadHook: old (a319156) hook is recognised', isTrailheadHook(oldHookText));
41
+ ok('isTrailheadHook: plain foreign script is not', !isTrailheadHook('#!/bin/sh\necho hi\n'));
42
+ ok('isTrailheadHook: mention outside the first 5 lines, not line-start, is not',
43
+ !isTrailheadHook('#!/bin/sh\necho hi\necho more\necho stuff\necho again\n# trailhead commit-msg hook (git). fake\n'));
44
+ ok('isTrailheadHook: mention inside an echo on line 2 is not',
45
+ !isTrailheadHook('#!/bin/sh\necho "trailhead commit-msg hook (git)."\n'));
46
+ ok('isTrailheadHook: empty text is not', !isTrailheadHook(''));
47
+
48
+ // --- normalize() unit cases --------------------------------------------------
49
+ ok('normalize: CRLF becomes LF', normalize('a\r\nb\r\n') === 'a\nb\n');
50
+ ok('normalize: LF-only text is unchanged', normalize('a\nb\n') === 'a\nb\n');
51
+
52
+ // --- syncCommitMsgHook() unit cases ------------------------------------------
53
+ function modeOf(p) { return fs.statSync(p).mode & 0o777; }
54
+
55
+ // absent -> installed, 0755
56
+ {
57
+ const dir = mktmp();
58
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
59
+ const hookPath = path.join(dir, 'commit-msg');
60
+ ok('syncCommitMsgHook: absent -> installed', result.status === 'installed');
61
+ ok('syncCommitMsgHook: absent -> hook path returned', result.hook === hookPath);
62
+ ok('syncCommitMsgHook: absent -> file written', fs.existsSync(hookPath));
63
+ ok('syncCommitMsgHook: absent -> content matches template', fs.readFileSync(hookPath, 'utf8') === templateText);
64
+ ok('syncCommitMsgHook: absent -> mode 0755', modeOf(hookPath) === 0o755);
65
+ fs.rmSync(dir, { recursive: true, force: true });
66
+ }
67
+
68
+ // old -> upgraded, bytes === template, 0755
69
+ {
70
+ const dir = mktmp();
71
+ const hookPath = path.join(dir, 'commit-msg');
72
+ fs.writeFileSync(hookPath, oldHookText);
73
+ fs.chmodSync(hookPath, 0o755);
74
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
75
+ ok('syncCommitMsgHook: old -> upgraded', result.status === 'upgraded');
76
+ ok('syncCommitMsgHook: old -> bytes now match template', fs.readFileSync(hookPath, 'utf8') === templateText);
77
+ ok('syncCommitMsgHook: old -> mode 0755', modeOf(hookPath) === 0o755);
78
+ fs.rmSync(dir, { recursive: true, force: true });
79
+ }
80
+
81
+ // CRLF current -> current, untouched
82
+ {
83
+ const dir = mktmp();
84
+ const hookPath = path.join(dir, 'commit-msg');
85
+ const crlfTemplate = templateText.replace(/\n/g, '\r\n');
86
+ fs.writeFileSync(hookPath, crlfTemplate);
87
+ fs.chmodSync(hookPath, 0o755);
88
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
89
+ ok('syncCommitMsgHook: CRLF current -> current', result.status === 'current');
90
+ ok('syncCommitMsgHook: CRLF current -> untouched bytes', fs.readFileSync(hookPath, 'utf8') === crlfTemplate);
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ }
93
+
94
+ // current but mode 0644 -> current, mode fixed to 0755
95
+ {
96
+ const dir = mktmp();
97
+ const hookPath = path.join(dir, 'commit-msg');
98
+ fs.writeFileSync(hookPath, templateText);
99
+ fs.chmodSync(hookPath, 0o644);
100
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
101
+ ok('syncCommitMsgHook: current 0644 -> status current', result.status === 'current');
102
+ ok('syncCommitMsgHook: current 0644 -> mode fixed to 0755', modeOf(hookPath) === 0o755);
103
+ fs.rmSync(dir, { recursive: true, force: true });
104
+ }
105
+
106
+ // foreign shell script -> foreign, untouched
107
+ {
108
+ const dir = mktmp();
109
+ const hookPath = path.join(dir, 'commit-msg');
110
+ const foreign = '#!/bin/sh\necho hi\n';
111
+ fs.writeFileSync(hookPath, foreign);
112
+ fs.chmodSync(hookPath, 0o755);
113
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
114
+ ok('syncCommitMsgHook: foreign shell -> foreign', result.status === 'foreign');
115
+ ok('syncCommitMsgHook: foreign shell -> bytes untouched', fs.readFileSync(hookPath, 'utf8') === foreign);
116
+ ok('syncCommitMsgHook: foreign shell -> mode untouched', modeOf(hookPath) === 0o755);
117
+ fs.rmSync(dir, { recursive: true, force: true });
118
+ }
119
+
120
+ // foreign wrapper mentioning the phrase in an echo on line 2 -> foreign
121
+ {
122
+ const dir = mktmp();
123
+ const hookPath = path.join(dir, 'commit-msg');
124
+ const foreign = '#!/bin/sh\necho "trailhead commit-msg hook (git). not really"\nexit 0\n';
125
+ fs.writeFileSync(hookPath, foreign);
126
+ fs.chmodSync(hookPath, 0o755);
127
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
128
+ ok('syncCommitMsgHook: foreign wrapper mentioning phrase -> foreign', result.status === 'foreign');
129
+ ok('syncCommitMsgHook: foreign wrapper -> bytes untouched', fs.readFileSync(hookPath, 'utf8') === foreign);
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+
133
+ // symlink -> foreign, target untouched
134
+ {
135
+ const dir = mktmp();
136
+ const hookPath = path.join(dir, 'commit-msg');
137
+ const targetPath = path.join(dir, 'real-hook.sh');
138
+ const targetContent = '#!/bin/sh\necho real\n';
139
+ fs.writeFileSync(targetPath, targetContent);
140
+ fs.symlinkSync(targetPath, hookPath);
141
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: false, fs });
142
+ ok('syncCommitMsgHook: symlink -> foreign', result.status === 'foreign');
143
+ ok('syncCommitMsgHook: symlink -> still a symlink', fs.lstatSync(hookPath).isSymbolicLink());
144
+ ok('syncCommitMsgHook: symlink target untouched', fs.readFileSync(targetPath, 'utf8') === targetContent);
145
+ fs.rmSync(dir, { recursive: true, force: true });
146
+ }
147
+
148
+ // absent + hooksPathSet -> skipped, nothing created
149
+ {
150
+ const dir = mktmp();
151
+ const hookPath = path.join(dir, 'commit-msg');
152
+ const result = syncCommitMsgHook({ hooksDir: dir, templateText, hooksPathSet: true, fs });
153
+ ok('syncCommitMsgHook: absent + hooksPathSet -> skipped', result.status === 'skipped');
154
+ ok('syncCommitMsgHook: absent + hooksPathSet -> nothing created', !fs.existsSync(hookPath));
155
+ fs.rmSync(dir, { recursive: true, force: true });
156
+ }
157
+
158
+ // --- resolveHooksTarget() with a stubbed runGit ------------------------------
159
+ {
160
+ const stubThrow = () => { throw new Error('not a git repo'); };
161
+ const result = resolveHooksTarget({ repoDir: '/nonexistent-repo-dir', runGit: stubThrow });
162
+ ok('resolveHooksTarget: no-repo -> skip', result.skip === 'no-repo');
163
+ }
164
+ {
165
+ let calls = 0;
166
+ const runGit = (args) => {
167
+ calls++;
168
+ if (args[0] === 'rev-parse' && args[1] === '--git-dir') return '.git';
169
+ if (args[0] === 'rev-parse' && args[1] === '--git-path') return '.git/hooks';
170
+ if (args[0] === 'config') throw new Error('not set');
171
+ throw new Error('unexpected args: ' + args.join(' '));
172
+ };
173
+ const result = resolveHooksTarget({ repoDir: '/some/repo', runGit });
174
+ ok('resolveHooksTarget: hooksDir resolved against repoDir', result.hooksDir === path.resolve('/some/repo', '.git/hooks'));
175
+ ok('resolveHooksTarget: hooksPathSet false when core.hooksPath unset', result.hooksPathSet === false);
176
+ ok('resolveHooksTarget: runGit called', calls >= 2);
177
+ }
178
+ {
179
+ const runGit = (args) => {
180
+ if (args[0] === 'rev-parse' && args[1] === '--git-dir') return '.git';
181
+ if (args[0] === 'rev-parse' && args[1] === '--git-path') return '.git/hooks';
182
+ if (args[0] === 'config') return '.githooks';
183
+ throw new Error('unexpected args: ' + args.join(' '));
184
+ };
185
+ const result = resolveHooksTarget({ repoDir: '/some/repo', runGit });
186
+ ok('resolveHooksTarget: hooksPathSet true when core.hooksPath set', result.hooksPathSet === true);
187
+ }
188
+
189
+ console.log(`✓ commit-msg-sync (unit): ${passed} assertions passed`);
190
+
191
+ // --- end-to-end: drive the script itself against real git repos -------------
192
+ function gitEnv(extra) {
193
+ return Object.assign({}, process.env, {
194
+ GIT_CONFIG_GLOBAL: '/dev/null',
195
+ GIT_CONFIG_NOSYSTEM: '1',
196
+ GIT_AUTHOR_NAME: 'Trailhead Test',
197
+ GIT_AUTHOR_EMAIL: 'trailhead-test@example.com',
198
+ GIT_COMMITTER_NAME: 'Trailhead Test',
199
+ GIT_COMMITTER_EMAIL: 'trailhead-test@example.com',
200
+ }, extra || {});
201
+ }
202
+
203
+ function runScript(args, opts) {
204
+ const out = execFileSync(process.execPath, [scriptPath].concat(args), Object.assign({ encoding: 'utf8' }, opts));
205
+ return out;
206
+ }
207
+
208
+ function assertSingleJsonLine(out, label) {
209
+ const lines = out.split('\n').filter((l) => l.length > 0);
210
+ ok(`${label}: stdout is exactly one line`, lines.length === 1);
211
+ let parsed;
212
+ assert.doesNotThrow(() => { parsed = JSON.parse(lines[0]); }, `${label}: stdout line is valid JSON`);
213
+ passed++;
214
+ ok(`${label}: JSON has exactly [status, hook, message] keys`,
215
+ Object.keys(parsed).sort().join(',') === 'hook,message,status');
216
+ return parsed;
217
+ }
218
+
219
+ // fresh repo: installed, then current on a second run
220
+ {
221
+ const dir = mktmp();
222
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
223
+ const out1 = runScript(['--repo', dir], { env: gitEnv() });
224
+ const parsed1 = assertSingleJsonLine(out1, 'e2e fresh repo (1st run)');
225
+ ok('e2e fresh repo: status installed', parsed1.status === 'installed');
226
+ const hookPath = path.join(dir, '.git', 'hooks', 'commit-msg');
227
+ ok('e2e fresh repo: hook file exists', fs.existsSync(hookPath));
228
+ ok('e2e fresh repo: hook is executable', (modeOf(hookPath) & 0o111) !== 0);
229
+
230
+ const out2 = runScript(['--repo', dir], { env: gitEnv() });
231
+ const parsed2 = assertSingleJsonLine(out2, 'e2e fresh repo (2nd run)');
232
+ ok('e2e fresh repo (2nd run): status current', parsed2.status === 'current');
233
+ fs.rmSync(dir, { recursive: true, force: true });
234
+ }
235
+
236
+ // seeded old hook -> upgraded
237
+ {
238
+ const dir = mktmp();
239
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
240
+ const hooksDir = path.join(dir, '.git', 'hooks');
241
+ fs.mkdirSync(hooksDir, { recursive: true });
242
+ const hookPath = path.join(hooksDir, 'commit-msg');
243
+ fs.writeFileSync(hookPath, oldHookText);
244
+ fs.chmodSync(hookPath, 0o755);
245
+ const out = runScript(['--repo', dir], { env: gitEnv() });
246
+ const parsed = assertSingleJsonLine(out, 'e2e seeded old hook');
247
+ ok('e2e seeded old hook: status upgraded', parsed.status === 'upgraded');
248
+ ok('e2e seeded old hook: content now matches template', fs.readFileSync(hookPath, 'utf8') === templateText);
249
+ fs.rmSync(dir, { recursive: true, force: true });
250
+ }
251
+
252
+ // seeded foreign hook -> untouched
253
+ {
254
+ const dir = mktmp();
255
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
256
+ const hooksDir = path.join(dir, '.git', 'hooks');
257
+ fs.mkdirSync(hooksDir, { recursive: true });
258
+ const hookPath = path.join(hooksDir, 'commit-msg');
259
+ const foreign = '#!/bin/sh\necho "custom hook"\n';
260
+ fs.writeFileSync(hookPath, foreign);
261
+ fs.chmodSync(hookPath, 0o755);
262
+ const out = runScript(['--repo', dir], { env: gitEnv() });
263
+ const parsed = assertSingleJsonLine(out, 'e2e seeded foreign hook');
264
+ ok('e2e seeded foreign hook: status foreign', parsed.status === 'foreign');
265
+ ok('e2e seeded foreign hook: bytes untouched', fs.readFileSync(hookPath, 'utf8') === foreign);
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+
269
+ // core.hooksPath = .githooks, absent -> skipped, nothing created anywhere
270
+ {
271
+ const dir = mktmp();
272
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
273
+ execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { cwd: dir, env: gitEnv() });
274
+ const out = runScript(['--repo', dir], { env: gitEnv() });
275
+ const parsed = assertSingleJsonLine(out, 'e2e core.hooksPath absent');
276
+ ok('e2e core.hooksPath absent: status skipped', parsed.status === 'skipped');
277
+ ok('e2e core.hooksPath absent: .githooks/commit-msg not created', !fs.existsSync(path.join(dir, '.githooks', 'commit-msg')));
278
+ ok('e2e core.hooksPath absent: .git/hooks/commit-msg not created', !fs.existsSync(path.join(dir, '.git', 'hooks', 'commit-msg')));
279
+ fs.rmSync(dir, { recursive: true, force: true });
280
+ }
281
+
282
+ // core.hooksPath = .githooks, old hook there -> upgraded there
283
+ {
284
+ const dir = mktmp();
285
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
286
+ execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { cwd: dir, env: gitEnv() });
287
+ const githooksDir = path.join(dir, '.githooks');
288
+ fs.mkdirSync(githooksDir, { recursive: true });
289
+ const hookPath = path.join(githooksDir, 'commit-msg');
290
+ fs.writeFileSync(hookPath, oldHookText);
291
+ fs.chmodSync(hookPath, 0o755);
292
+ const out = runScript(['--repo', dir], { env: gitEnv() });
293
+ const parsed = assertSingleJsonLine(out, 'e2e core.hooksPath old hook');
294
+ ok('e2e core.hooksPath old hook: status upgraded', parsed.status === 'upgraded');
295
+ ok('e2e core.hooksPath old hook: upgraded in .githooks', fs.readFileSync(hookPath, 'utf8') === templateText);
296
+ fs.rmSync(dir, { recursive: true, force: true });
297
+ }
298
+
299
+ // core.hooksPath = .githooks, foreign hook there -> untouched
300
+ {
301
+ const dir = mktmp();
302
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
303
+ execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { cwd: dir, env: gitEnv() });
304
+ const githooksDir = path.join(dir, '.githooks');
305
+ fs.mkdirSync(githooksDir, { recursive: true });
306
+ const hookPath = path.join(githooksDir, 'commit-msg');
307
+ const foreign = '#!/bin/sh\necho "custom"\n';
308
+ fs.writeFileSync(hookPath, foreign);
309
+ fs.chmodSync(hookPath, 0o755);
310
+ const out = runScript(['--repo', dir], { env: gitEnv() });
311
+ const parsed = assertSingleJsonLine(out, 'e2e core.hooksPath foreign hook');
312
+ ok('e2e core.hooksPath foreign hook: status foreign', parsed.status === 'foreign');
313
+ ok('e2e core.hooksPath foreign hook: untouched', fs.readFileSync(hookPath, 'utf8') === foreign);
314
+ fs.rmSync(dir, { recursive: true, force: true });
315
+ }
316
+
317
+ // linked worktree -> hook lands in the main repo's .git/hooks
318
+ {
319
+ const dir = mktmp();
320
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
321
+ execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t',
322
+ 'commit', '--allow-empty', '-m', 'chore: init', '--no-verify'], { cwd: dir, env: gitEnv() });
323
+ const worktreeDir = path.join(dir, '..', path.basename(dir) + '-wt');
324
+ execFileSync('git', ['worktree', 'add', worktreeDir, '-b', 'trailhead-wt'], { cwd: dir, env: gitEnv(), stdio: 'pipe' });
325
+ try {
326
+ const out = runScript(['--repo', worktreeDir], { env: gitEnv() });
327
+ const parsed = assertSingleJsonLine(out, 'e2e linked worktree');
328
+ ok('e2e linked worktree: status installed', parsed.status === 'installed');
329
+ const mainHookPath = path.join(dir, '.git', 'hooks', 'commit-msg');
330
+ ok('e2e linked worktree: hook lands in the main repo .git/hooks', fs.existsSync(mainHookPath));
331
+ ok('e2e linked worktree: reported hook path is the main repo one', parsed.hook === mainHookPath);
332
+ } finally {
333
+ try { execFileSync('git', ['worktree', 'remove', '--force', worktreeDir], { cwd: dir, env: gitEnv(), stdio: 'pipe' }); } catch { /* best effort cleanup */ }
334
+ fs.rmSync(worktreeDir, { recursive: true, force: true });
335
+ fs.rmSync(dir, { recursive: true, force: true });
336
+ }
337
+ }
338
+
339
+ // non-repo tmp dir -> skipped, exit 0
340
+ {
341
+ const dir = mktmp();
342
+ const out = runScript(['--repo', dir], { env: gitEnv() });
343
+ const parsed = assertSingleJsonLine(out, 'e2e non-repo dir');
344
+ ok('e2e non-repo dir: status skipped', parsed.status === 'skipped');
345
+ ok('e2e non-repo dir: hook is null', parsed.hook === null);
346
+ fs.rmSync(dir, { recursive: true, force: true });
347
+ }
348
+
349
+ // --bogus -> exit 2
350
+ {
351
+ const dir = mktmp();
352
+ let exitCode = null;
353
+ try {
354
+ execFileSync(process.execPath, [scriptPath, '--bogus'], { cwd: dir, env: gitEnv(), stdio: 'pipe' });
355
+ } catch (e) {
356
+ exitCode = e.status;
357
+ }
358
+ ok('e2e --bogus: exits 2', exitCode === 2);
359
+ fs.rmSync(dir, { recursive: true, force: true });
360
+ }
361
+
362
+ // --repo without a value -> exit 2
363
+ {
364
+ const dir = mktmp();
365
+ let exitCode = null;
366
+ try {
367
+ execFileSync(process.execPath, [scriptPath, '--repo'], { cwd: dir, env: gitEnv(), stdio: 'pipe' });
368
+ } catch (e) {
369
+ exitCode = e.status;
370
+ }
371
+ ok('e2e --repo without value: exits 2', exitCode === 2);
372
+ fs.rmSync(dir, { recursive: true, force: true });
373
+ }
374
+
375
+ console.log(`✓ commit-msg-sync (e2e): ${passed} assertions passed`);
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead commit-msg hook (git). Host-independent guardrail: installed into
4
+ // .git/hooks/commit-msg at repo first-use so trailhead's commit discipline is
5
+ // enforced on EVERY `git commit`, whatever agent host drives it (Codex has no
6
+ // hook bus; this git hook is how the commit-guard survives there). Decision #16.
7
+ //
8
+ // Self-contained by design: detached from the trailhead install, it inlines the
9
+ // same two rules as plugins/trailhead/hooks/lib/commit-message-check.js. That
10
+ // lib's test drives this hook end-to-end so the two copies cannot drift.
11
+ //
12
+ // git passes the commit-message file path as argv[2]. On a violation: print the
13
+ // reason to stderr and exit 1 (git aborts the commit). Otherwise exit 0.
14
+ // Crash-safe: any unexpected error exits 0 (never wedge a commit).
15
+
16
+ const fs = require('fs');
17
+
18
+ const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
19
+ const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
20
+ const CO_AUTHORED = /co-authored-by\s*:/i;
21
+ const SCISSORS = '# ------------------------ >8';
22
+
23
+ function messageRegion(raw) {
24
+ const text = String(raw == null ? '' : raw);
25
+ const i = text.indexOf(SCISSORS);
26
+ return i === -1 ? text : text.slice(0, i);
27
+ }
28
+ function subjectOf(raw) {
29
+ for (const line of messageRegion(raw).split('\n')) {
30
+ if (line.trim() === '' || line.startsWith('#')) continue;
31
+ return line.trim();
32
+ }
33
+ return '';
34
+ }
35
+
36
+ try {
37
+ const file = process.argv[2];
38
+ const raw = file ? fs.readFileSync(file, 'utf8') : '';
39
+ const region = messageRegion(raw);
40
+
41
+ if (CO_AUTHORED.test(region)) {
42
+ process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
43
+ process.exit(1);
44
+ }
45
+ const subject = subjectOf(raw);
46
+ if (subject && !CONVENTIONAL.test(subject)) {
47
+ process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
48
+ process.exit(1);
49
+ }
50
+ if (subject.length > 72) {
51
+ process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
52
+ process.exit(1);
53
+ }
54
+ } catch {
55
+ process.exit(0); // never wedge a commit on an unexpected error
56
+ }
57
+ process.exit(0);
@@ -44,6 +44,11 @@
44
44
  // are shared with trailhead-search-guard.js via lib/shell-scan.js, so the two
45
45
  // guards can never drift into two divergent tokenizers again.
46
46
  //
47
+ // #180 fix: `docker`/`podman` `--env-file .env` (or `--env-file=.env`) was a
48
+ // false positive: the container RUNTIME reads that file, not the agent. Only
49
+ // that flag's value is exempted, and only for docker/podman verbs; see
50
+ // ENV_FILE_EXEMPT_VERBS below.
51
+ //
47
52
  // Deliberately best-effort on the Bash leg, like the sibling guards: not a
48
53
  // full shell parser. It does not care about `cd` shape at all (that is
49
54
  // trailhead-search-guard.js's job) — it only asks "does any token here
@@ -94,6 +99,18 @@ function isSecretPathArg(t) {
94
99
  // every tool's flag surface, just the common file-value shapes.
95
100
  const VALUE_FLAGS = new Set(['-f', '--file']);
96
101
 
102
+ // #180: `docker run --env-file .env img` / `docker compose --env-file
103
+ // .env.prod up` / `podman run --env-file=.env img` are false positives. The
104
+ // CONTAINER RUNTIME reads that file directly to populate the container's
105
+ // environment; its contents never pass through the agent, so it is not a
106
+ // secret READ by the agent (unlike a bare `cat .env`, which is). Scoped
107
+ // narrowly: only the value of `--env-file` is exempt, and only for these
108
+ // verbs; every other flag/positional in the same statement (image name,
109
+ // `-v`, trailing `cat .env`, etc.) is still scanned exactly as before.
110
+ const ENV_FILE_EXEMPT_VERBS = new Set(['docker', 'podman', 'docker-compose', 'podman-compose']);
111
+ const ENV_FILE_EXEMPT_FLAGS = new Set(['--env-file']);
112
+ const NO_EXEMPT_FLAGS = new Set();
113
+
97
114
  // The pattern-first search verbs (grep/rg/ag/sed/awk), shared with
98
115
  // trailhead-search-guard.js. Their first positional is a PATTERN, not a file
99
116
  // operand, so it must be skipped before scanning for a secret FILE argument
@@ -119,8 +136,11 @@ function stripTrailingPunct(t) {
119
136
  // Scan one already-tokenized statement's argument tokens (verb NOT stripped)
120
137
  // for a secret path used as a plain positional, an option value
121
138
  // (`--file=.env`, `-f .env`, glued `-f.env`), or a `<` (or glued `<file`)
122
- // redirect target. Returns the offending token or null.
123
- function scanTokensForSecretPath(tokens) {
139
+ // redirect target. Returns the offending token or null. `exemptFlags` (#180)
140
+ // names flags whose value must be skipped entirely rather than scanned, in
141
+ // both the `--flag=value` and spaced `--flag value` forms.
142
+ function scanTokensForSecretPath(tokens, exemptFlags) {
143
+ const exempt = exemptFlags || NO_EXEMPT_FLAGS;
124
144
  for (let i = 0; i < tokens.length; i++) {
125
145
  const t = stripTrailingPunct(tokens[i]);
126
146
  if (!t) continue;
@@ -143,10 +163,15 @@ function scanTokensForSecretPath(tokens) {
143
163
  // `--flag=value` form.
144
164
  const eq = t.indexOf('=');
145
165
  if (eq > 0) {
166
+ const flagName = t.slice(0, eq);
167
+ if (exempt.has(flagName)) continue; // #180: value never scanned
146
168
  const val = t.slice(eq + 1);
147
169
  if (isSecretPathArg(val)) return val;
148
170
  continue;
149
171
  }
172
+ // #180: exempt flag's separate-token value is skipped entirely, not
173
+ // scanned as a plain positional on the next loop iteration.
174
+ if (exempt.has(t)) { i++; continue; }
150
175
  // `-f value` / `--file value` (separate-token value).
151
176
  if (VALUE_FLAGS.has(t)) {
152
177
  const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
@@ -214,7 +239,11 @@ function scanStatementForSecretPath(stmt) {
214
239
  if (SEARCH_VERBS.has(bin)) {
215
240
  return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
216
241
  }
217
- return scanTokensForSecretPath(tokens);
242
+ // #180: docker/podman (incl. `docker compose` as a subcommand, and the
243
+ // standalone docker-compose/podman-compose binaries) get --env-file's value
244
+ // exempted; every other verb keeps today's behaviour unchanged.
245
+ const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
246
+ return scanTokensForSecretPath(tokens, exempt);
218
247
  }
219
248
 
220
249
  // Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,