@marcomigozzi/trailhead 0.17.0 → 0.18.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 +28 -18
- package/README.md +28 -18
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/engine-route-docs.test.js +7 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +8 -8
- package/bin/lib/codex-projection.test.js +19 -3
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +9 -4
- package/bin/trailhead.test.js +18 -6
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-grill.md +2 -2
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +30 -14
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
- package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
- package/plugins/trailhead/skills/_shared/substrate.md +3 -2
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +2 -1
- package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
- package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-repo-target.js (#351). Run: node repo-target.test.js
|
|
3
|
+
// No framework: plain asserts, same style as isolation-answer.test.js.
|
|
4
|
+
// The gh/git runner is always injected: no test calls the real gh or writes to GitHub.
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-repo-target.js');
|
|
12
|
+
const { parseGithubSlug, parentSlug, decide, parseArgs, main, IDLE_WINDOW_MS, SLUG_RE } = require(scriptPath);
|
|
13
|
+
|
|
14
|
+
let passed = 0;
|
|
15
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
16
|
+
|
|
17
|
+
const HOUR = 3600 * 1000;
|
|
18
|
+
const T0 = new Date('2026-10-14T08:00:00.000Z');
|
|
19
|
+
const at = (ms) => new Date(T0.getTime() + ms);
|
|
20
|
+
const iso = (d) => d.toISOString();
|
|
21
|
+
const mktmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-repo-target-test-')));
|
|
22
|
+
|
|
23
|
+
// Fake runner. world: { view, originView, remotes, setDefault } ; records every call.
|
|
24
|
+
function fakeRun(world) {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const run = (cmd, args) => {
|
|
27
|
+
calls.push([cmd, ...args]);
|
|
28
|
+
const fail = { status: 1, stdout: '', stderr: 'boom' };
|
|
29
|
+
if (cmd === 'git') {
|
|
30
|
+
return world.remotes === undefined ? fail
|
|
31
|
+
: { status: 0, stdout: world.remotes.map(([n, u]) => `remote.${n}.url ${u}`).join('\n') + '\n', stderr: '' };
|
|
32
|
+
}
|
|
33
|
+
if (args[0] === 'repo' && args[1] === 'set-default') return world.setDefault || { status: 0, stdout: '', stderr: '' };
|
|
34
|
+
if (args[0] === 'repo' && args[1] === 'view') {
|
|
35
|
+
const v = args.includes('--') ? world.originView : world.view;
|
|
36
|
+
return v ? { status: 0, stdout: JSON.stringify(v), stderr: '' } : fail;
|
|
37
|
+
}
|
|
38
|
+
return fail;
|
|
39
|
+
};
|
|
40
|
+
return { run, calls };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runMain(argv, { cwd, now = T0, world }) {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
const { run, calls } = fakeRun(world || {});
|
|
46
|
+
const realErr = process.stderr.write.bind(process.stderr);
|
|
47
|
+
const errChunks = [];
|
|
48
|
+
process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
|
|
49
|
+
let code;
|
|
50
|
+
try {
|
|
51
|
+
code = main(argv, { cwd, now, run, stdout: { write: (s) => chunks.push(s) } });
|
|
52
|
+
} finally {
|
|
53
|
+
process.stderr.write = realErr;
|
|
54
|
+
}
|
|
55
|
+
return { code, lines: chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l)), calls, err: errChunks.join('') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const recPath = (dir) => path.join(dir, '.trailhead', 'repo-target.json');
|
|
59
|
+
const writeRec = (dir, over = {}) => {
|
|
60
|
+
fs.mkdirSync(path.join(dir, '.trailhead'), { recursive: true });
|
|
61
|
+
fs.writeFileSync(recPath(dir), JSON.stringify({ target: 'cli/cli', recordedAt: iso(T0), usedAt: iso(T0), ...over }));
|
|
62
|
+
};
|
|
63
|
+
const forkWorld = (over = {}) => ({
|
|
64
|
+
view: { nameWithOwner: 'cli/cli', viewerPermission: 'READ' },
|
|
65
|
+
remotes: [['origin', 'https://github.com/fakeuser/cli.git'], ['upstream', 'git@github.com:cli/cli.git']],
|
|
66
|
+
originView: { nameWithOwner: 'fakeuser/cli', isFork: true, parent: { name: 'cli', owner: { login: 'cli' } } },
|
|
67
|
+
...over,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// --- URL parsing ---------------------------------------------------------------
|
|
71
|
+
ok('slug https', parseGithubSlug('https://github.com/o/n.git') === 'o/n');
|
|
72
|
+
ok('slug https no .git', parseGithubSlug('https://github.com/o/n') === 'o/n');
|
|
73
|
+
ok('slug https with token user', parseGithubSlug('https://x-access-token:abc@github.com/o/n.git') === 'o/n');
|
|
74
|
+
ok('slug scp ssh', parseGithubSlug('git@github.com:o/n.git') === 'o/n');
|
|
75
|
+
ok('slug ssh://', parseGithubSlug('ssh://git@github.com/o/n.git') === 'o/n');
|
|
76
|
+
ok('slug ssh:// with port', parseGithubSlug('ssh://git@github.com:22/o/n') === 'o/n');
|
|
77
|
+
ok('slug dotted name', parseGithubSlug('git@github.com:o/n.js.git') === 'o/n.js');
|
|
78
|
+
for (const bad of ['https://gitlab.com/o/n.git', 'git@gitlab.com:o/n.git', '/local/path', '', null, undefined, 'https://github.com/only']) {
|
|
79
|
+
ok(`slug non-GitHub/invalid -> null: ${bad}`, parseGithubSlug(bad) === null);
|
|
80
|
+
}
|
|
81
|
+
ok('parentSlug owner.login', parentSlug({ name: 'cli', owner: { login: 'cli' } }) === 'cli/cli');
|
|
82
|
+
ok('parentSlug nameWithOwner', parentSlug({ nameWithOwner: 'a/b' }) === 'a/b');
|
|
83
|
+
ok('parentSlug null', parentSlug(null) === null);
|
|
84
|
+
|
|
85
|
+
// --- decide (pure) -------------------------------------------------------------
|
|
86
|
+
{
|
|
87
|
+
ok('window is 8 hours', IDLE_WINDOW_MS === 8 * HOUR);
|
|
88
|
+
const f = (o = {}) => ({ target: 'o/n', permission: 'WRITE', originSlug: 'o/n', originInfo: null, remotes: ['o/n'], ...o });
|
|
89
|
+
const rec = (o = {}) => ({ target: 'o/n', recordedAt: iso(T0), usedAt: iso(T0), ...o });
|
|
90
|
+
ok('plain repo -> ok', decide(f(), null, T0).status === 'ok');
|
|
91
|
+
for (const p of ['ADMIN', 'MAINTAIN', 'WRITE']) ok(`permission ${p} -> ok`, decide(f({ permission: p }), null, T0).status === 'ok');
|
|
92
|
+
ok('unknown permission -> ok', decide(f({ permission: undefined }), null, T0).status === 'ok');
|
|
93
|
+
const fork = f({ target: 'cli/cli', originSlug: 'u/cli', originInfo: { isFork: true, parent: { name: 'cli', owner: { login: 'cli' } } }, remotes: ['u/cli', 'cli/cli'] });
|
|
94
|
+
const d = decide(fork, null, T0);
|
|
95
|
+
ok('fork with parent resolved -> ask fork-parent', d.status === 'ask' && d.reason === 'fork-parent');
|
|
96
|
+
ok('fork-parent candidates: fork then parent', JSON.stringify(d.candidates) === '["u/cli","cli/cli"]');
|
|
97
|
+
ok('fork-parent is case-insensitive', decide({ ...fork, target: 'CLI/cli' }, null, T0).reason === 'fork-parent');
|
|
98
|
+
const own = f({ target: 'u/cli', originSlug: 'u/cli', originInfo: null });
|
|
99
|
+
ok('fork whose target is itself -> ok', decide(own, null, T0).status === 'ok');
|
|
100
|
+
ok('origin not a fork of target -> ok', decide(f({ target: 'x/y', originSlug: 'u/cli', originInfo: { isFork: true, parent: { name: 'cli', owner: { login: 'cli' } } } }), null, T0).status === 'ok');
|
|
101
|
+
ok('origin not a fork -> ok', decide(f({ target: 'x/y', originSlug: 'u/cli', originInfo: { isFork: false, parent: null } }), null, T0).status === 'ok');
|
|
102
|
+
// origin that cannot be verified -> skipped, never a silent ok (#351 I1)
|
|
103
|
+
const unv = decide(f({ target: 'x/y', originSlug: 'u/cli', originInfo: null, originExists: true }), null, T0);
|
|
104
|
+
ok('origin differs, lookup failed -> skipped', unv.status === 'skipped' && unv.reason === 'origin fork relation could not be verified');
|
|
105
|
+
const nonGh = decide(f({ target: 'x/y', originSlug: null, originInfo: null, originExists: true }), null, T0);
|
|
106
|
+
ok('origin not a github.com URL -> skipped', nonGh.status === 'skipped' && nonGh.reason === 'origin fork relation could not be verified');
|
|
107
|
+
ok('no origin remote at all -> ok', decide(f({ originSlug: null, originExists: false }), null, T0).status === 'ok');
|
|
108
|
+
ok('origin == target, exists -> ok', decide(f({ originExists: true }), null, T0).status === 'ok');
|
|
109
|
+
ok('no-write still asks before the unverified skip', decide(f({ permission: 'READ', target: 'x/y', originSlug: 'u/cli', originInfo: null, originExists: true, remotes: ['u/cli', 'x/y'] }), null, T0).reason === 'no-write');
|
|
110
|
+
const nw = decide(f({ permission: 'READ', target: 'x/y', originSlug: 'u/cli', remotes: ['u/cli', 'x/y'] }), null, T0);
|
|
111
|
+
ok('no write -> ask no-write', nw.status === 'ask' && nw.reason === 'no-write');
|
|
112
|
+
ok('no-write candidates: origin first, target included', JSON.stringify(nw.candidates) === '["u/cli","x/y"]');
|
|
113
|
+
ok('no-write with only the target remote', JSON.stringify(decide(f({ permission: 'NONE' }), null, T0).candidates) === '["o/n"]');
|
|
114
|
+
ok('fork-parent wins over no-write', decide({ ...fork, permission: 'READ' }, null, T0).reason === 'fork-parent');
|
|
115
|
+
// recorded answer
|
|
116
|
+
ok('record same target in window -> reuse', decide(fork, rec({ target: 'cli/cli' }), T0).status === 'reuse');
|
|
117
|
+
ok('reuse carries recordedAt', decide(fork, rec({ target: 'cli/cli' }), T0).recordedAt === iso(T0));
|
|
118
|
+
ok('reuse is case-insensitive', decide(fork, rec({ target: 'CLI/CLI' }), T0).status === 'reuse');
|
|
119
|
+
ok('exactly 8h reuses', decide(fork, rec({ target: 'cli/cli' }), at(8 * HOUR)).status === 'reuse');
|
|
120
|
+
ok('over 8h -> re-check', decide(fork, rec({ target: 'cli/cli' }), at(8 * HOUR + 1)).status === 'ask');
|
|
121
|
+
ok('target changed -> re-check', decide(fork, rec({ target: 'other/repo' }), T0).status === 'ask');
|
|
122
|
+
for (const [name, r] of [
|
|
123
|
+
['non-object', 'x'], ['array', []], ['bad slug', rec({ target: 'nope' })], ['missing target', { recordedAt: iso(T0), usedAt: iso(T0) }],
|
|
124
|
+
['malformed usedAt', rec({ target: 'cli/cli', usedAt: 'yesterday' })], ['future usedAt', rec({ target: 'cli/cli', usedAt: iso(at(HOUR)) })],
|
|
125
|
+
['future recordedAt', rec({ target: 'cli/cli', recordedAt: iso(at(HOUR)) })], ['non-ISO', rec({ target: 'cli/cli', usedAt: '2026' })],
|
|
126
|
+
]) ok(`invalid record -> re-check: ${name}`, decide(fork, r, T0).status === 'ask');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- parseArgs -----------------------------------------------------------------
|
|
130
|
+
{
|
|
131
|
+
const r = parseArgs(['record', '--repo', '/x', '--target', 'o/n']);
|
|
132
|
+
ok('parseArgs record', r.command === 'record' && r.repo === '/x' && r.target === 'o/n');
|
|
133
|
+
ok('parseArgs check', parseArgs(['check']).command === 'check');
|
|
134
|
+
ok('parseArgs clear', parseArgs(['clear', '--repo', '/x']).repo === '/x');
|
|
135
|
+
for (const bad of ['-x/y', '--exec/y', '.x/y', '_x/y', 'x/', '/y', 'a/b/c', 'x y/z']) ok(`SLUG_RE rejects ${bad}`, !SLUG_RE.test(bad));
|
|
136
|
+
for (const good of ['o/n', 'cli/cli', 'a-b/c.d_e', '0x/y']) ok(`SLUG_RE accepts ${good}`, SLUG_RE.test(good));
|
|
137
|
+
ok('parseArgs rejects a -x/y target', !!parseArgs(['record', '--target', '-x/y']).error);
|
|
138
|
+
for (const argv of [[], ['frob'], ['record'], ['record', '--target', 'nope'], ['record', '--target'], ['check', '--target', 'o/n'], ['clear', '--bogus', '1']]) {
|
|
139
|
+
ok(`parseArgs error: ${argv.join(' ')}`, !!parseArgs(argv).error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- main: check ---------------------------------------------------------------
|
|
144
|
+
{
|
|
145
|
+
const dir = mktmp();
|
|
146
|
+
const plain = runMain(['check', '--repo', dir], { cwd: '/', world: {
|
|
147
|
+
view: { nameWithOwner: 'o/n', viewerPermission: 'ADMIN' }, remotes: [['origin', 'git@github.com:o/n.git']] } });
|
|
148
|
+
ok('plain repo: exit 0, ok, silent', plain.code === 0 && plain.lines[0].status === 'ok' && plain.lines[0].target === 'o/n');
|
|
149
|
+
ok('plain repo: no origin lookup, nothing written', plain.calls.length === 2 && !fs.existsSync(path.join(dir, '.trailhead')));
|
|
150
|
+
|
|
151
|
+
const a = runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld() });
|
|
152
|
+
ok('fork clone, parent resolved: ask fork-parent', a.lines[0].status === 'ask' && a.lines[0].reason === 'fork-parent');
|
|
153
|
+
ok('ask lists both candidates and the path', JSON.stringify(a.lines[0].candidates) === '["fakeuser/cli","cli/cli"]' && a.lines[0].path === recPath(dir));
|
|
154
|
+
ok('ask writes nothing', !fs.existsSync(recPath(dir)));
|
|
155
|
+
ok('ask: only read-only gh calls', a.calls.every((c) => c[0] === 'git' || (c[1] === 'repo' && c[2] !== 'set-default')));
|
|
156
|
+
|
|
157
|
+
const own = runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld({
|
|
158
|
+
view: { nameWithOwner: 'fakeuser/cli', viewerPermission: 'ADMIN' } }) });
|
|
159
|
+
ok('fork resolving to itself: ok', own.lines[0].status === 'ok' && own.lines[0].target === 'fakeuser/cli');
|
|
160
|
+
|
|
161
|
+
const nw = runMain(['check', '--repo', dir], { cwd: '/', world: {
|
|
162
|
+
view: { nameWithOwner: 'o/n', viewerPermission: 'READ' }, remotes: [['origin', 'https://github.com/o/n.git']] } });
|
|
163
|
+
ok('no write permission: ask no-write', nw.lines[0].status === 'ask' && nw.lines[0].reason === 'no-write');
|
|
164
|
+
|
|
165
|
+
// recorded answer
|
|
166
|
+
writeRec(dir, { target: 'cli/cli' });
|
|
167
|
+
const re = runMain(['check', '--repo', dir], { cwd: '/', now: at(2 * HOUR), world: forkWorld() });
|
|
168
|
+
ok('recorded answer: reuse', re.lines[0].status === 'reuse' && re.lines[0].target === 'cli/cli' && re.lines[0].recordedAt === iso(T0));
|
|
169
|
+
ok('reuse slides usedAt, keeps recordedAt', JSON.parse(fs.readFileSync(recPath(dir), 'utf8')).usedAt === iso(at(2 * HOUR))
|
|
170
|
+
&& JSON.parse(fs.readFileSync(recPath(dir), 'utf8')).recordedAt === iso(T0));
|
|
171
|
+
ok('slide keeps a long stretch alive', runMain(['check', '--repo', dir], { cwd: '/', now: at(9 * HOUR), world: forkWorld() }).lines[0].status === 'reuse');
|
|
172
|
+
ok('expired record re-checks', runMain(['check', '--repo', dir], { cwd: '/', now: at(30 * HOUR), world: forkWorld() }).lines[0].status === 'ask');
|
|
173
|
+
writeRec(dir, { target: 'someone/else' });
|
|
174
|
+
ok('target changed re-checks', runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld() }).lines[0].status === 'ask');
|
|
175
|
+
fs.writeFileSync(recPath(dir), '{not json');
|
|
176
|
+
ok('corrupt record re-checks', runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld() }).lines[0].status === 'ask');
|
|
177
|
+
|
|
178
|
+
// failures never block
|
|
179
|
+
const g = runMain(['check', '--repo', dir], { cwd: '/', world: {} });
|
|
180
|
+
ok('gh failure: skipped, exit 0', g.code === 0 && g.lines[0].status === 'skipped' && g.lines[0].reason.length > 0);
|
|
181
|
+
const nogit = runMain(['check', '--repo', dir], { cwd: '/', world: { view: { nameWithOwner: 'o/n', viewerPermission: 'WRITE' } } });
|
|
182
|
+
ok('no remotes readable: target alone, ok', nogit.lines[0].status === 'ok');
|
|
183
|
+
const noOriginInfo = runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld({ originView: undefined, view: { nameWithOwner: 'cli/cli', viewerPermission: 'WRITE' } }) });
|
|
184
|
+
ok('origin lookup fails: skipped, not a silent ok', noOriginInfo.lines[0].status === 'skipped' && /could not be verified/.test(noOriginInfo.lines[0].reason));
|
|
185
|
+
const ghes = runMain(['check', '--repo', dir], { cwd: '/', world: { view: { nameWithOwner: 'cli/cli', viewerPermission: 'WRITE' }, remotes: [['origin', 'git@ghe.example.com:u/cli.git']] } });
|
|
186
|
+
ok('origin on another host: skipped', ghes.lines[0].status === 'skipped' && /could not be verified/.test(ghes.lines[0].reason));
|
|
187
|
+
ok('origin lookup passes -- before the slug', a.calls.some((c) => c.join(' ') === 'gh repo view --json nameWithOwner,isFork,parent -- fakeuser/cli'));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- main: record --------------------------------------------------------------
|
|
191
|
+
{
|
|
192
|
+
const dir = mktmp();
|
|
193
|
+
const w = runMain(['record', '--repo', dir, '--target', 'fakeuser/cli'], { cwd: '/', world: forkWorld() });
|
|
194
|
+
ok('record differing target: written, setDefault true', w.lines[0].status === 'written' && w.lines[0].setDefault === true && w.lines[0].target === 'fakeuser/cli');
|
|
195
|
+
ok('record calls gh repo set-default with the target', w.calls.some((c) => c.join(' ') === 'gh repo set-default -- fakeuser/cli'));
|
|
196
|
+
const s = JSON.parse(fs.readFileSync(recPath(dir), 'utf8'));
|
|
197
|
+
ok('record file shape', JSON.stringify(Object.keys(s)) === '["target","recordedAt","usedAt"]' && s.target === 'fakeuser/cli' && s.recordedAt === iso(T0));
|
|
198
|
+
ok('record in a non-git dir leaves .gitignore alone', !fs.existsSync(path.join(dir, '.gitignore')));
|
|
199
|
+
ok('record leaves no temp', fs.readdirSync(path.join(dir, '.trailhead')).join() === 'repo-target.json');
|
|
200
|
+
|
|
201
|
+
const same = runMain(['record', '--repo', dir, '--target', 'cli/cli'], { cwd: '/', now: at(HOUR), world: forkWorld() });
|
|
202
|
+
ok('record same as current: no set-default', same.lines[0].setDefault === false && !same.calls.some((c) => c.includes('set-default')));
|
|
203
|
+
ok('record replaces the file', JSON.parse(fs.readFileSync(recPath(dir), 'utf8')).target === 'cli/cli');
|
|
204
|
+
|
|
205
|
+
const dir2 = mktmp();
|
|
206
|
+
const bad = runMain(['record', '--repo', dir2, '--target', 'fakeuser/cli'], { cwd: '/', world: forkWorld({ setDefault: { status: 1, stdout: '', stderr: 'no such repo\nmore' } }) });
|
|
207
|
+
ok('set-default failure: error with reason, exit 0', bad.code === 0 && bad.lines[0].status === 'error' && /no such repo/.test(bad.lines[0].reason));
|
|
208
|
+
ok('set-default failure: nothing written', !fs.existsSync(path.join(dir2, '.trailhead')));
|
|
209
|
+
const noView = runMain(['record', '--repo', dir2, '--target', 'fakeuser/cli'], { cwd: '/', world: {} });
|
|
210
|
+
ok('record with unresolvable gh: error, nothing written', noView.lines[0].status === 'error' && !fs.existsSync(path.join(dir2, '.trailhead')));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- main: record keeps the marker gitignored (#351 W1) ---------------------------
|
|
214
|
+
{
|
|
215
|
+
const IGN = '.trailhead/repo-target.json';
|
|
216
|
+
const gitDir = mktmp();
|
|
217
|
+
fs.mkdirSync(path.join(gitDir, '.git'));
|
|
218
|
+
const give = () => runMain(['record', '--repo', gitDir, '--target', 'cli/cli'], { cwd: '/', world: forkWorld() });
|
|
219
|
+
give();
|
|
220
|
+
ok('record creates .gitignore with the marker in a git repo', fs.readFileSync(path.join(gitDir, '.gitignore'), 'utf8') === IGN + '\n');
|
|
221
|
+
give();
|
|
222
|
+
ok('record is idempotent on .gitignore', fs.readFileSync(path.join(gitDir, '.gitignore'), 'utf8') === IGN + '\n');
|
|
223
|
+
const g2 = mktmp();
|
|
224
|
+
fs.mkdirSync(path.join(g2, '.git'));
|
|
225
|
+
fs.writeFileSync(path.join(g2, '.gitignore'), 'node_modules');
|
|
226
|
+
runMain(['record', '--repo', g2, '--target', 'cli/cli'], { cwd: '/', world: forkWorld() });
|
|
227
|
+
ok('record appends after a file without trailing newline', fs.readFileSync(path.join(g2, '.gitignore'), 'utf8') === 'node_modules\n' + IGN + '\n');
|
|
228
|
+
const g3 = mktmp();
|
|
229
|
+
fs.mkdirSync(path.join(g3, '.git'));
|
|
230
|
+
runMain(['record', '--repo', g3, '--target', 'fakeuser/cli'], { cwd: '/', world: forkWorld({ setDefault: { status: 1, stdout: '', stderr: 'x' } }) });
|
|
231
|
+
ok('a failed record does not touch .gitignore', !fs.existsSync(path.join(g3, '.gitignore')));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- main: clear, usage errors -------------------------------------------------
|
|
235
|
+
{
|
|
236
|
+
const dir = mktmp();
|
|
237
|
+
writeRec(dir);
|
|
238
|
+
ok('clear present', runMain(['clear', '--repo', dir], { cwd: '/' }).lines[0].status === 'cleared' && !fs.existsSync(recPath(dir)));
|
|
239
|
+
ok('clear absent', runMain(['clear', '--repo', dir], { cwd: '/' }).lines[0].status === 'absent');
|
|
240
|
+
for (const argv of [['record', '--target', 'nope'], ['record'], ['check', '--bogus', 'x'], ['frob'], []]) {
|
|
241
|
+
const d = mktmp();
|
|
242
|
+
const r = runMain(argv.length ? [argv[0], '--repo', d, ...argv.slice(1)] : [], { cwd: d });
|
|
243
|
+
ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && r.err.length > 0 && !fs.existsSync(path.join(d, '.trailhead')));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- end-to-end (no gh involved: usage error and clear only) -------------------
|
|
248
|
+
{
|
|
249
|
+
const dir = mktmp();
|
|
250
|
+
const bad = spawnSync('node', [scriptPath, 'record', '--repo', dir, '--target', 'nope'], { encoding: 'utf8' });
|
|
251
|
+
ok('e2e: usage error exits 2', bad.status === 2);
|
|
252
|
+
const c = spawnSync('node', [scriptPath, 'clear', '--repo', dir], { encoding: 'utf8' });
|
|
253
|
+
ok('e2e: clear absent prints one JSON line', c.status === 0 && JSON.parse(c.stdout.trim()).status === 'absent');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(`repo-target.test.js: ${passed} checks passed`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// #349: an exported GIT_DIR must not make the statusline describe another repo.
|
|
3
|
+
// Run: node statusline-git-env.test.js
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const script = path.resolve(__dirname, '..', '..', 'templates', 'statusline-trailhead.sh');
|
|
11
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-statusline-git-')));
|
|
12
|
+
const home = path.join(root, 'home');
|
|
13
|
+
fs.mkdirSync(home);
|
|
14
|
+
|
|
15
|
+
const cleanEnv = () => { const e = { ...process.env }; for (const k of Object.keys(e)) if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR)$/.test(k)) delete e[k]; return e; };
|
|
16
|
+
const git = (cwd, ...args) => {
|
|
17
|
+
const r = spawnSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8', env: cleanEnv() });
|
|
18
|
+
assert.strictEqual(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
|
19
|
+
};
|
|
20
|
+
const mk = (name, branch) => {
|
|
21
|
+
const d = path.join(root, name);
|
|
22
|
+
fs.mkdirSync(d);
|
|
23
|
+
git(d, 'init', '-q', '-b', branch);
|
|
24
|
+
git(d, 'commit', '-q', '--allow-empty', '-m', 'init');
|
|
25
|
+
return d;
|
|
26
|
+
};
|
|
27
|
+
const a = mk('alpha', 'branch-a');
|
|
28
|
+
const b = mk('beta', 'branch-b');
|
|
29
|
+
|
|
30
|
+
function render(env) {
|
|
31
|
+
const input = { model: { id: 'claude-opus-5-5', display_name: 'Test' }, workspace: { project_dir: b, current_dir: b } };
|
|
32
|
+
const r = spawnSync('bash', [script], {
|
|
33
|
+
input: JSON.stringify(input),
|
|
34
|
+
env: { ...cleanEnv(), NO_COLOR: '1', TRAILHEAD_STATUSLINE_USAGE: '0', HOME: home, CLAUDE_CONFIG_DIR: path.join(root, 'cfg'), ...env },
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
});
|
|
37
|
+
return r.stdout;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const clean = render({});
|
|
41
|
+
assert.ok(clean.includes('beta') && clean.includes('branch-b'), `baseline: ${clean}`);
|
|
42
|
+
const hostile = render({ GIT_DIR: path.join(a, '.git'), GIT_WORK_TREE: a });
|
|
43
|
+
assert.ok(hostile.includes('beta'), `project follows the cwd, not GIT_DIR: ${hostile}`);
|
|
44
|
+
assert.ok(hostile.includes('branch-b') && !hostile.includes('branch-a'), `branch follows the cwd, not GIT_DIR: ${hostile}`);
|
|
45
|
+
|
|
46
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
47
|
+
console.log('3 passed');
|