@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.
- package/README.it.md +2 -2
- package/README.md +2 -2
- package/bin/lib/install-verify.js +257 -0
- package/bin/lib/install-verify.test.js +379 -0
- package/bin/trailhead.js +80 -1
- package/bin/trailhead.test.js +390 -2
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
|
@@ -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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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; `|`, `;`, `&&`,
|