@marcomigozzi/trailhead 0.9.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.
Files changed (37) hide show
  1. package/README.it.md +11 -11
  2. package/README.md +11 -11
  3. package/bin/lib/codex-projection.js +13 -6
  4. package/bin/lib/codex-projection.test.js +45 -3
  5. package/bin/lib/install-verify.js +257 -0
  6. package/bin/lib/install-verify.test.js +379 -0
  7. package/bin/trailhead.js +91 -5
  8. package/bin/trailhead.test.js +588 -4
  9. package/package.json +1 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  11. package/plugins/trailhead/commands/quick.md +1 -1
  12. package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
  13. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
  14. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
  15. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
  16. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
  17. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
  18. package/plugins/trailhead/skills/_shared/choices.md +3 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +27 -20
  20. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  21. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  22. package/plugins/trailhead/skills/_shared/session-handoff.md +8 -6
  23. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  24. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  25. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +13 -4
  26. package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
  27. package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
  28. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  29. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -2
  30. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  31. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  32. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -10
  34. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  35. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +12 -9
  36. package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
  37. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
5
5
  "bin": {
6
6
  "trailhead": "bin/trailhead.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trailhead",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
5
5
  "author": {
6
6
  "name": "Marco Migozzi",
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Work one ticket whole, off the map (whiteboard); grills only if needed, never splits
2
+ description: Work one ticket whole, never splits: a new whiteboard ticket from text, or an existing one; grills only if needed
3
3
  argument-hint: "[ticket | \"text\"]"
4
4
  ---
5
5
 
@@ -5,13 +5,18 @@
5
5
  // - templates/trailhead-commit-msg (host-independent git commit-msg hook,
6
6
  // installed into .git/hooks at repo first-use so it runs on ANY host)
7
7
  // Two rules: NEVER a Co-Authored-By trailer; a Conventional Commits subject
8
- // (<type>(scope)?: subject) of at most 72 chars.
8
+ // (<type>(scope)?: subject) of at most 72 chars. Plus a conditional third rule:
9
+ // while a ticket is active (a caller passes opts.ticket, resolved from the
10
+ // .trailhead/session-ticket marker), the message must carry a matching
11
+ // `Refs: #<n>` trailer, so every commit made while resolving a ticket threads
12
+ // into that ticket's timeline.
9
13
  // Pure and dependency-free: takes a commit message string, returns a verdict.
10
14
 
11
15
  const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
12
16
  const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
13
17
  const CO_AUTHORED = /co-authored-by\s*:/i;
14
18
  const SCISSORS = '# ------------------------ >8';
19
+ const REFS_LINE = /^\s*refs\s*:(.*)$/i;
15
20
 
16
21
  // The message region: everything before git's --verbose scissors line (the
17
22
  // block after it is the diff, not the message). Everything else is kept, so a
@@ -39,11 +44,46 @@ function subjectOf(raw, opts = {}) {
39
44
  return '';
40
45
  }
41
46
 
47
+ // parseSessionTicket(text) -> ticket number | null
48
+ // Parses the first line of a .trailhead/session-ticket marker (`#<n> <title>`).
49
+ // Strips a leading BOM and any trailing \r before matching, so a marker read
50
+ // with either line-ending convention parses the same way. Anything else
51
+ // (empty text, no leading '#', a non-digit, a leading zero, or trailing
52
+ // characters glued to the digits) is not a valid marker and yields null.
53
+ function parseSessionTicket(text) {
54
+ const raw = String(text == null ? '' : text).replace(/^/, '');
55
+ const firstLine = raw.split('\n')[0].replace(/\r$/, '');
56
+ const m = firstLine.match(/^#([1-9]\d*)(?:\s|$)/);
57
+ return m ? Number(m[1]) : null;
58
+ }
59
+
60
+ // hasRefsFor(message, n) -> boolean
61
+ // Scans the message region (see messageRegion) for a `Refs:` trailer line
62
+ // whose value contains `#<n>` not immediately followed by another word
63
+ // character (so `#184` matches but `#18` and `#184abc` do not). A `#`-leading
64
+ // line never counts: it is a git comment in a commit-msg file, and a
65
+ // `# Refs: ...` line is not a trailer in a literal `-m` string either.
66
+ function hasRefsFor(message, n) {
67
+ const target = new RegExp('#' + n + '(?![\\w])');
68
+ for (const line of messageRegion(message).split('\n')) {
69
+ if (line.trim().startsWith('#')) continue;
70
+ const m = line.match(REFS_LINE);
71
+ if (m && target.test(m[1])) return true;
72
+ }
73
+ return false;
74
+ }
75
+
42
76
  // checkCommitMessage(message, opts?) -> { ok: true } | { ok: false, code, reason }
43
77
  // opts.stripComments is forwarded to subjectOf (see there): the git commit-msg
44
78
  // hook uses the default (file semantics); the PreToolUse `-m` guard passes false.
79
+ // opts.ticket activates the Refs rule when it is a positive integer (the
80
+ // ticket resolved from a live .trailhead/session-ticket marker); any other
81
+ // value (undefined, a non-integer, a string, 0, negative) leaves the rule off,
82
+ // it never blocks by mistake. When active it applies even to an empty
83
+ // message, closing a `--allow-empty-message` bypass.
45
84
  function checkCommitMessage(message, opts = {}) {
46
85
  const region = messageRegion(message);
86
+ const ticketActive = Number.isInteger(opts.ticket) && opts.ticket > 0;
47
87
 
48
88
  if (CO_AUTHORED.test(region)) {
49
89
  return {
@@ -68,7 +108,23 @@ function checkCommitMessage(message, opts = {}) {
68
108
  reason: 'trailhead: commit subject must be 72 characters or less.',
69
109
  };
70
110
  }
111
+ if (ticketActive && !hasRefsFor(message, opts.ticket)) {
112
+ return {
113
+ ok: false,
114
+ code: 'REFS_TRAILER_MISSING',
115
+ reason: `trailhead: this commit is on ticket #${opts.ticket} (per .trailhead/session-ticket) but has no "Refs: #${opts.ticket}" trailer. Add "Refs: #${opts.ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${opts.ticket}, remove the stale .trailhead/session-ticket marker first.`,
116
+ };
117
+ }
71
118
  return { ok: true };
72
119
  }
73
120
 
74
- module.exports = { checkCommitMessage, subjectOf, messageRegion, TYPES, CONVENTIONAL, CO_AUTHORED };
121
+ module.exports = {
122
+ checkCommitMessage,
123
+ subjectOf,
124
+ messageRegion,
125
+ parseSessionTicket,
126
+ hasRefsFor,
127
+ TYPES,
128
+ CONVENTIONAL,
129
+ CO_AUTHORED,
130
+ };
@@ -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`);