@marcomigozzi/trailhead 0.14.0 → 0.16.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 +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead base preflight (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
|
|
5
|
+
// and Codex). Measures how far the working base is behind the trunk, optionally
|
|
6
|
+
// syncs it, and runs the project's own preflight checks. #243.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-base-preflight.js --repo <working-root> [--config-root <project-root>]
|
|
10
|
+
// [--trunk <name>] [--sync] [--no-fetch]
|
|
11
|
+
// --config-root is the original checkout the effective config was loaded from
|
|
12
|
+
// (defaults to --repo); a worktree/clone workspace never re-resolves config.
|
|
13
|
+
//
|
|
14
|
+
// Trunk: --trunk, else the remote default branch (refs/remotes/origin/HEAD), else
|
|
15
|
+
// the first existing of main / master; none found -> status skipped.
|
|
16
|
+
// Fetch: an explicit destination refspec so origin/<trunk> really advances. A
|
|
17
|
+
// failed fetch is never tolerated silently: status unverified (distance on local refs).
|
|
18
|
+
// Base (one ref, deterministic): no remote ref -> local <trunk>; HEAD is the trunk ->
|
|
19
|
+
// origin/<trunk>; HEAD on a ticket branch -> origin/<trunk> unless the local trunk is
|
|
20
|
+
// strictly ahead (then the local trunk); both moved -> status trunk-diverged.
|
|
21
|
+
// Sync (--sync, only when behind): refuses a dirty tracked tree (dirty); on the trunk
|
|
22
|
+
// ff-only (rebase --rebase-merges when it has diverged, merge commits are kept); on a ticket branch rebase when unpublished,
|
|
23
|
+
// merge when it has an upstream. Any failed sync is aborted and HEAD verified back at
|
|
24
|
+
// its pre-sync commit: conflict (paths listed) or sync-failed (git's message).
|
|
25
|
+
// Checks: `preflight.checks` (array of shell commands), nested ({"preflight":
|
|
26
|
+
// {"checks":[...]}}) or flat ({"preflight.checks":[...]}); nested wins when a file
|
|
27
|
+
// carries both. The project file (read from --config-root) wins over the global one
|
|
28
|
+
// (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) as a whole value. Each
|
|
29
|
+
// command runs with `sh -c` at --repo after any sync. Not run on unverified,
|
|
30
|
+
// trunk-diverged, error, conflict, dirty, sync-failed.
|
|
31
|
+
// Prints one JSON line:
|
|
32
|
+
// {"status","trunk","base","behind","ahead","method"?,"pulled"?,"testsRequired",
|
|
33
|
+
// "conflicts":[],"checks":[{command,ok,code,output}],"checksOk","checksError"?,"warnings":[],"message"?}
|
|
34
|
+
// status: current | behind | synced | unverified | trunk-diverged | dirty | conflict |
|
|
35
|
+
// sync-failed | error | skipped
|
|
36
|
+
// Fail closed: only current and synced (with checksOk) let the caller go on.
|
|
37
|
+
// Exit 0 whenever a JSON line is printed; exit 2 only on a usage error.
|
|
38
|
+
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const os = require('os');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const { spawnSync } = require('child_process');
|
|
43
|
+
|
|
44
|
+
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
45
|
+
const CHECK_TIMEOUT_MS = 300000;
|
|
46
|
+
const OUTPUT_TAIL = 2000;
|
|
47
|
+
const FLAGS = ['sync', 'no-fetch'];
|
|
48
|
+
const VALUED = ['repo', 'config-root', 'trunk'];
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
const name = a.startsWith('--') ? a.slice(2) : null;
|
|
55
|
+
if (name && VALUED.includes(name)) {
|
|
56
|
+
const v = argv[++i];
|
|
57
|
+
if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
|
|
58
|
+
out[name] = v;
|
|
59
|
+
} else if (name && FLAGS.includes(name)) {
|
|
60
|
+
out[name] = true;
|
|
61
|
+
} else {
|
|
62
|
+
return { error: `usage: unknown argument ${a}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!out.repo) return { error: 'usage: missing --repo <working-root>' };
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Reads preflight.checks out of one config file.
|
|
70
|
+
// { state: 'unset' } | { state: 'set', list, warnings } | { state: 'invalid', message }
|
|
71
|
+
function readChecks(file, fs_) {
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err && err.code === 'ENOENT') return { state: 'unset' };
|
|
77
|
+
return { state: 'invalid', message: `cannot read ${file}: ${err && err.message ? err.message : err}` };
|
|
78
|
+
}
|
|
79
|
+
let cfg;
|
|
80
|
+
try {
|
|
81
|
+
cfg = JSON.parse(text);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { state: 'invalid', message: `${file} is not valid JSON` };
|
|
84
|
+
}
|
|
85
|
+
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
86
|
+
return { state: 'invalid', message: `${file} is not a JSON object` };
|
|
87
|
+
}
|
|
88
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
89
|
+
const nested = cfg.preflight !== null && typeof cfg.preflight === 'object' && !Array.isArray(cfg.preflight) && has(cfg.preflight, 'checks');
|
|
90
|
+
const flat = has(cfg, 'preflight.checks');
|
|
91
|
+
if (!nested && !flat) return { state: 'unset' };
|
|
92
|
+
const warnings = [];
|
|
93
|
+
if (nested && flat) warnings.push(`${file} sets preflight.checks in both nested and flat form; the nested form wins`);
|
|
94
|
+
const value = nested ? cfg.preflight.checks : cfg['preflight.checks'];
|
|
95
|
+
if (!Array.isArray(value)) return { state: 'invalid', message: `preflight.checks in ${file} must be an array of shell commands` };
|
|
96
|
+
if (value.some((e) => typeof e !== 'string' || e.trim() === '')) {
|
|
97
|
+
return { state: 'invalid', message: `preflight.checks in ${file} must hold only non-empty strings` };
|
|
98
|
+
}
|
|
99
|
+
return { state: 'set', list: value, warnings };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function globalConfigPath(env) {
|
|
103
|
+
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
104
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tail(text) {
|
|
108
|
+
const t = String(text || '');
|
|
109
|
+
return t.length > OUTPUT_TAIL ? '...' + t.slice(t.length - OUTPUT_TAIL) : t;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Runs git at `repo`; never throws on a non-zero exit.
|
|
113
|
+
function makeGit(repo, env) {
|
|
114
|
+
const gitEnv = { ...process.env, ...env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
|
|
115
|
+
const G = (args) => {
|
|
116
|
+
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: gitEnv, maxBuffer: 64 * 1024 * 1024 });
|
|
117
|
+
if (r.error) throw r.error;
|
|
118
|
+
const res = { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
|
|
119
|
+
G.last = res;
|
|
120
|
+
return res;
|
|
121
|
+
};
|
|
122
|
+
return G;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function detectTrunk(requested, G) {
|
|
126
|
+
if (requested) return requested;
|
|
127
|
+
const head = G(['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
|
|
128
|
+
if (head.ok && head.out.startsWith('origin/')) return head.out.slice('origin/'.length);
|
|
129
|
+
for (const name of ['main', 'master']) {
|
|
130
|
+
if (G(['show-ref', '--verify', '-q', `refs/heads/${name}`]).ok || G(['show-ref', '--verify', '-q', `refs/remotes/origin/${name}`]).ok) return name;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Commit count of a range; null when git fails (the caller reports the error, never a 0).
|
|
136
|
+
const count = (G, range) => {
|
|
137
|
+
const r = G(['rev-list', '--count', range]);
|
|
138
|
+
const n = Number(r.out);
|
|
139
|
+
return r.ok && r.out !== '' && Number.isInteger(n) ? n : null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const gitFailure = (G, what) => `${what}: ${(G.last && (G.last.err || G.last.out)) || 'git failed'}`;
|
|
143
|
+
|
|
144
|
+
// Picks the one base ref. { base, diverged } or { error } when a comparison fails.
|
|
145
|
+
function pickBase(G, trunk, head, hasLocal, hasRemote) {
|
|
146
|
+
const remote = `origin/${trunk}`;
|
|
147
|
+
if (!hasRemote) return { base: trunk, diverged: false };
|
|
148
|
+
if (!hasLocal || head === trunk) return { base: remote, diverged: false };
|
|
149
|
+
const what = `cannot compare ${trunk} with ${remote}`;
|
|
150
|
+
const localBehind = count(G, `${trunk}..${remote}`);
|
|
151
|
+
if (localBehind === null) return { error: gitFailure(G, what) };
|
|
152
|
+
const localAhead = count(G, `${remote}..${trunk}`);
|
|
153
|
+
if (localAhead === null) return { error: gitFailure(G, what) };
|
|
154
|
+
if (localBehind > 0 && localAhead > 0) return { base: remote, diverged: true };
|
|
155
|
+
return { base: localAhead > 0 ? trunk : remote, diverged: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Puts HEAD and the tree back at `pre` after a failed sync.
|
|
159
|
+
function restore(G, pre, warnings) {
|
|
160
|
+
G(['rebase', '--abort']);
|
|
161
|
+
G(['merge', '--abort']);
|
|
162
|
+
if (G(['rev-parse', 'HEAD']).out !== pre) {
|
|
163
|
+
G(['reset', '--hard', pre]);
|
|
164
|
+
warnings.push('the failed sync left HEAD moved; it was reset to the pre-sync commit');
|
|
165
|
+
}
|
|
166
|
+
const back = G(['rev-parse', 'HEAD']).out === pre && G(['status', '--porcelain', '--untracked-files=no']).out === '';
|
|
167
|
+
if (!back) warnings.push('could not verify the tree is back at its pre-sync state; inspect it before going on');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Syncs the working base onto `base`. Returns fields for the result.
|
|
171
|
+
function sync(G, { trunk, head, base, ahead, behind, warnings }) {
|
|
172
|
+
if (G(['status', '--porcelain', '--untracked-files=no']).out !== '') {
|
|
173
|
+
return { status: 'dirty', message: 'tracked changes in the working tree; commit or stash them before syncing' };
|
|
174
|
+
}
|
|
175
|
+
if (!head) return { status: 'sync-failed', message: 'detached HEAD: nothing to sync' };
|
|
176
|
+
const pre = G(['rev-parse', 'HEAD']).out;
|
|
177
|
+
let method;
|
|
178
|
+
let r;
|
|
179
|
+
if (head === trunk) {
|
|
180
|
+
method = ahead === 0 ? 'ff' : 'rebase';
|
|
181
|
+
r = method === 'ff' ? G(['merge', '--ff-only', '-q', base]) : G(['rebase', '-q', '--rebase-merges', base]);
|
|
182
|
+
} else if (G(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).ok) {
|
|
183
|
+
method = 'merge';
|
|
184
|
+
r = G(['merge', '--no-edit', '-q', base]);
|
|
185
|
+
} else {
|
|
186
|
+
method = 'rebase';
|
|
187
|
+
r = G(['rebase', '-q', '--rebase-merges', base]);
|
|
188
|
+
}
|
|
189
|
+
if (r.ok) return { status: 'synced', method, pulled: behind, testsRequired: true };
|
|
190
|
+
const conflicts = G(['diff', '--name-only', '--diff-filter=U']).out.split('\n').filter(Boolean);
|
|
191
|
+
restore(G, pre, warnings);
|
|
192
|
+
if (conflicts.length > 0) return { status: 'conflict', method, conflicts, message: `${method} onto ${base} conflicts; aborted, the tree is unchanged` };
|
|
193
|
+
return { status: 'sync-failed', method, message: r.err || r.out || `${method} failed` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function runChecks(list, repo, env) {
|
|
197
|
+
const results = list.map((command) => {
|
|
198
|
+
const r = spawnSync('sh', ['-c', command], { cwd: repo, encoding: 'utf8', env: { ...process.env, ...env }, timeout: CHECK_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
|
|
199
|
+
const timedOut = r.error && r.error.code === 'ETIMEDOUT';
|
|
200
|
+
const output = (r.stdout || '') + (r.stderr || '') + (timedOut ? `\n(timed out after ${CHECK_TIMEOUT_MS / 1000}s)` : r.error && !timedOut ? `\n${r.error.message}` : '');
|
|
201
|
+
return { command, ok: r.status === 0, code: r.status, output: tail(output) };
|
|
202
|
+
});
|
|
203
|
+
return results;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const NO_CHECKS = new Set(['unverified', 'trunk-diverged', 'error', 'skipped', 'conflict', 'dirty', 'sync-failed']);
|
|
207
|
+
|
|
208
|
+
function preflight(parsed, cwd, env, fs_) {
|
|
209
|
+
const repo = path.resolve(cwd, parsed.repo);
|
|
210
|
+
const configRoot = path.resolve(cwd, parsed['config-root'] || parsed.repo);
|
|
211
|
+
const G = makeGit(repo, env);
|
|
212
|
+
const warnings = [];
|
|
213
|
+
const result = { status: 'error', trunk: null, base: null, behind: 0, ahead: 0, testsRequired: false, conflicts: [], checks: [], checksOk: false, warnings };
|
|
214
|
+
|
|
215
|
+
if (!G(['rev-parse', '--git-dir']).ok) return { ...result, message: `${repo} is not a git repository` };
|
|
216
|
+
const trunk = detectTrunk(parsed.trunk, G);
|
|
217
|
+
if (!trunk) return { ...result, status: 'skipped', message: 'no trunk found (no remote default branch, main or master)' };
|
|
218
|
+
result.trunk = trunk;
|
|
219
|
+
|
|
220
|
+
let fetchFailed = false;
|
|
221
|
+
const hasOrigin = G(['remote', 'get-url', 'origin']).ok;
|
|
222
|
+
if (!hasOrigin) {
|
|
223
|
+
const remotes = G(['remote']).out.split('\n').filter(Boolean);
|
|
224
|
+
if (remotes.length > 0) warnings.push(`no remote named origin (found: ${remotes.join(', ')}); distance measured on local refs`);
|
|
225
|
+
}
|
|
226
|
+
if (hasOrigin && !parsed['no-fetch']) {
|
|
227
|
+
const f = G(['fetch', '--quiet', 'origin', `+refs/heads/${trunk}:refs/remotes/origin/${trunk}`]);
|
|
228
|
+
if (!f.ok) {
|
|
229
|
+
fetchFailed = true;
|
|
230
|
+
warnings.push(`fetch of origin/${trunk} failed, distance measured on local refs: ${f.err || 'unknown error'}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (parsed['no-fetch']) warnings.push('--no-fetch: distance measured on local refs');
|
|
234
|
+
|
|
235
|
+
const hasLocal = G(['show-ref', '--verify', '-q', `refs/heads/${trunk}`]).ok;
|
|
236
|
+
const hasRemote = G(['show-ref', '--verify', '-q', `refs/remotes/origin/${trunk}`]).ok;
|
|
237
|
+
if (!hasLocal && !hasRemote) return { ...result, status: 'skipped', message: `trunk ${trunk} has no local or remote ref` };
|
|
238
|
+
const headName = G(['symbolic-ref', '-q', '--short', 'HEAD']);
|
|
239
|
+
const head = headName.ok ? headName.out : null;
|
|
240
|
+
const picked = pickBase(G, trunk, head, hasLocal, hasRemote);
|
|
241
|
+
if (picked.error) return { ...result, message: picked.error };
|
|
242
|
+
result.base = picked.base;
|
|
243
|
+
const behind = count(G, `HEAD..${picked.base}`);
|
|
244
|
+
const ahead = behind === null ? null : count(G, `${picked.base}..HEAD`);
|
|
245
|
+
if (behind === null || ahead === null) return { ...result, message: gitFailure(G, `cannot measure HEAD against ${picked.base}`) };
|
|
246
|
+
result.behind = behind;
|
|
247
|
+
result.ahead = ahead;
|
|
248
|
+
result.status = fetchFailed ? 'unverified' : picked.diverged ? 'trunk-diverged' : result.behind > 0 ? 'behind' : 'current';
|
|
249
|
+
if (fetchFailed) result.message = `could not fetch origin/${trunk}; the distance is not verified`;
|
|
250
|
+
if (picked.diverged) result.message = `local ${trunk} and origin/${trunk} have diverged; reconcile the trunk first`;
|
|
251
|
+
|
|
252
|
+
if (parsed.sync && result.status === 'behind') {
|
|
253
|
+
const s = sync(G, { trunk, head, base: picked.base, ahead: result.ahead, behind: result.behind, warnings });
|
|
254
|
+
Object.assign(result, s);
|
|
255
|
+
if (s.status === 'synced') {
|
|
256
|
+
// The sync succeeded; a failed recount is reported, never turned into a 0.
|
|
257
|
+
result.behind = count(G, `HEAD..${picked.base}`);
|
|
258
|
+
result.ahead = count(G, `${picked.base}..HEAD`);
|
|
259
|
+
if (result.behind === null || result.ahead === null) warnings.push(gitFailure(G, `cannot recount HEAD against ${picked.base} after the sync`));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!NO_CHECKS.has(result.status)) {
|
|
264
|
+
let cfg = readChecks(path.join(configRoot, CONFIG_REL), fs_);
|
|
265
|
+
if (cfg.state === 'unset') cfg = readChecks(globalConfigPath(env), fs_);
|
|
266
|
+
if (cfg.state === 'invalid') {
|
|
267
|
+
result.checksError = cfg.message;
|
|
268
|
+
} else {
|
|
269
|
+
if (cfg.state === 'set') {
|
|
270
|
+
warnings.push(...cfg.warnings);
|
|
271
|
+
result.checks = runChecks(cfg.list, repo, env);
|
|
272
|
+
}
|
|
273
|
+
result.checksOk = result.checks.every((c) => c.ok);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
|
|
280
|
+
const cwd_ = cwd || process.cwd();
|
|
281
|
+
const out = stdout || process.stdout;
|
|
282
|
+
const env_ = env || process.env;
|
|
283
|
+
const fs_ = fsMod || fs;
|
|
284
|
+
|
|
285
|
+
const parsed = parseArgs(argv || []);
|
|
286
|
+
if (parsed.error) {
|
|
287
|
+
process.stderr.write(parsed.error + '\n');
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
let result;
|
|
291
|
+
try {
|
|
292
|
+
result = preflight(parsed, cwd_, env_, fs_);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
result = { status: 'error', trunk: null, base: null, behind: 0, ahead: 0, testsRequired: false, conflicts: [], checks: [], checksOk: false, warnings: [], message: 'error: ' + (err && err.message ? err.message : String(err)) };
|
|
295
|
+
}
|
|
296
|
+
out.write(JSON.stringify(result) + '\n');
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = { parseArgs, readChecks, main };
|
|
301
|
+
|
|
302
|
+
if (require.main === module) {
|
|
303
|
+
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
|
|
304
|
+
process.exit(code);
|
|
305
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead checkout-in (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). The panel cannot move the session's directory, so it hands the
|
|
5
|
+
// pinned checkout to `work` / `resume` as a trailing `--in=<token>` word and the
|
|
6
|
+
// verb moves there first. This helper decodes the token, validates it and says
|
|
7
|
+
// whether the session is already there. It fails closed: it never searches for
|
|
8
|
+
// another checkout. #312.
|
|
9
|
+
//
|
|
10
|
+
// The token is base64url(path) (alphabet [A-Za-z0-9_-], no padding), so a path
|
|
11
|
+
// with spaces, quotes or a leading `-` travels as one shell-safe word.
|
|
12
|
+
//
|
|
13
|
+
// CLI:
|
|
14
|
+
// node trailhead-checkout-in.js --in <b64url> --cwd <dir>
|
|
15
|
+
//
|
|
16
|
+
// Prints exactly one JSON line on stdout:
|
|
17
|
+
// {"status":"here"|"move"|"invalid","path":"...","reason":"...","cd":"..."}
|
|
18
|
+
// `cd` is present on `move` only: a ready-to-run, single-quote-escaped command.
|
|
19
|
+
// Exit 0 for the three statuses, 2 on a usage error (stderr).
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { spawnSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const PREFIX = '--in=';
|
|
26
|
+
|
|
27
|
+
function encodeIn(p) {
|
|
28
|
+
return PREFIX + Buffer.from(String(p), 'utf8').toString('base64url');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function decodeIn(token) {
|
|
32
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token)) return null;
|
|
33
|
+
const p = Buffer.from(token, 'base64url').toString('utf8');
|
|
34
|
+
// Round-trip guard: a token that does not re-encode to itself is not ours.
|
|
35
|
+
return p !== '' && Buffer.from(p, 'utf8').toString('base64url') === token ? p : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function real(p) {
|
|
39
|
+
try {
|
|
40
|
+
return fs.realpathSync(p);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toplevelOf(dir) {
|
|
47
|
+
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
48
|
+
if (r.status !== 0) return null;
|
|
49
|
+
const top = r.stdout.replace(/\n$/, '');
|
|
50
|
+
return top === '' ? null : real(top);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// POSIX single-quote escaping: ' becomes '\''.
|
|
54
|
+
function cdCommand(p) {
|
|
55
|
+
return `cd -- '${p.replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function check(token, cwd) {
|
|
59
|
+
const p = decodeIn(token);
|
|
60
|
+
if (p === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
|
|
61
|
+
if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
|
|
62
|
+
const canon = real(p);
|
|
63
|
+
if (canon === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
|
|
64
|
+
// The path must be a checkout root itself, never a folder inside one.
|
|
65
|
+
if (toplevelOf(p) !== canon) return { status: 'invalid', path: p, reason: 'the path is not the root of a git checkout' };
|
|
66
|
+
const here = real(cwd) !== null ? toplevelOf(cwd) : null;
|
|
67
|
+
if (here === canon) return { status: 'here', path: p };
|
|
68
|
+
return { status: 'move', path: p, cd: cdCommand(p) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main(argv) {
|
|
72
|
+
const get = (flag) => {
|
|
73
|
+
const i = argv.indexOf(flag);
|
|
74
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
75
|
+
};
|
|
76
|
+
const token = get('--in');
|
|
77
|
+
const cwd = get('--cwd');
|
|
78
|
+
if (token === null || cwd === null) {
|
|
79
|
+
process.stderr.write('usage: trailhead-checkout-in.js --in <b64url> --cwd <dir>\n');
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write(JSON.stringify(check(token, cwd)) + '\n');
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { encodeIn, decodeIn, check, cdCommand };
|
|
87
|
+
|
|
88
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
@@ -1,104 +1,40 @@
|
|
|
1
|
-
#!/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Crash-safe: any unexpected error exits 0 (never wedge a commit).
|
|
15
|
-
//
|
|
16
|
-
// Note: a merge/revert/fixup message already fails the conventional-subject
|
|
17
|
-
// rule below, so it needs no special case; `git commit --amend` keeps whatever
|
|
18
|
-
// trailer the original message carried; `git commit --no-verify` bypasses this
|
|
19
|
-
// hook entirely, as before.
|
|
20
|
-
|
|
21
|
-
const fs = require('fs');
|
|
22
|
-
const path = require('path');
|
|
23
|
-
|
|
24
|
-
const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
|
|
25
|
-
const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
|
|
26
|
-
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
27
|
-
const SCISSORS = '# ------------------------ >8';
|
|
28
|
-
const REFS_LINE = /^\s*refs\s*:(.*)$/i;
|
|
29
|
-
|
|
30
|
-
function messageRegion(raw) {
|
|
31
|
-
const text = String(raw == null ? '' : raw);
|
|
32
|
-
const i = text.indexOf(SCISSORS);
|
|
33
|
-
return i === -1 ? text : text.slice(0, i);
|
|
34
|
-
}
|
|
35
|
-
function subjectOf(raw) {
|
|
36
|
-
for (const line of messageRegion(raw).split('\n')) {
|
|
37
|
-
if (line.trim() === '' || line.startsWith('#')) continue;
|
|
38
|
-
return line.trim();
|
|
39
|
-
}
|
|
40
|
-
return '';
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# trailhead commit-msg hook (git). Thin sh wrapper around the CommonJS logic in
|
|
3
|
+
# trailhead-commit-msg.cjs beside it (#257: an extensionless node script would
|
|
4
|
+
# inherit the "type" of the user repo's package.json, so "type": "module" broke it).
|
|
5
|
+
#
|
|
6
|
+
# Never blocks a commit on a startup failure: node missing, the .cjs missing or
|
|
7
|
+
# unreadable, or node crashing at load all exit 0, but print a one-line "inactive"
|
|
8
|
+
# notice on stderr so the failure is not silent. Only the .cjs's dedicated
|
|
9
|
+
# rule-violation code (3) becomes exit 1, which makes git abort the commit.
|
|
10
|
+
|
|
11
|
+
# Avviso unico su stderr, stesso formato per tutti i casi.
|
|
12
|
+
inactive() {
|
|
13
|
+
echo "trailhead: commit-msg hook inactive ($1); commit rules not enforced." >&2
|
|
41
14
|
}
|
|
42
15
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
function readTicket() {
|
|
69
|
-
try {
|
|
70
|
-
const markerPath = path.join(process.cwd(), '.trailhead', 'session-ticket');
|
|
71
|
-
return parseSessionTicket(fs.readFileSync(markerPath, 'utf8'));
|
|
72
|
-
} catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const file = process.argv[2];
|
|
79
|
-
const raw = file ? fs.readFileSync(file, 'utf8') : '';
|
|
80
|
-
const region = messageRegion(raw);
|
|
81
|
-
|
|
82
|
-
if (CO_AUTHORED.test(region)) {
|
|
83
|
-
process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
86
|
-
const subject = subjectOf(raw);
|
|
87
|
-
if (subject && !CONVENTIONAL.test(subject)) {
|
|
88
|
-
process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
|
|
89
|
-
process.exit(1);
|
|
90
|
-
}
|
|
91
|
-
if (subject.length > 72) {
|
|
92
|
-
process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const ticket = readTicket();
|
|
97
|
-
if (Number.isInteger(ticket) && ticket > 0 && !hasRefsFor(raw, ticket)) {
|
|
98
|
-
process.stderr.write(`trailhead: this commit is on ticket #${ticket} (per .trailhead/session-ticket) but has no "Refs: #${ticket}" trailer. Add "Refs: #${ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${ticket}, remove the stale .trailhead/session-ticket marker first.\n`);
|
|
99
|
-
process.exit(1);
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
process.exit(0); // never wedge a commit on an unexpected error
|
|
103
|
-
}
|
|
104
|
-
process.exit(0);
|
|
16
|
+
case $0 in
|
|
17
|
+
*/*) dir=${0%/*} ;;
|
|
18
|
+
*) dir=. ;;
|
|
19
|
+
esac
|
|
20
|
+
cjs="$dir/trailhead-commit-msg.cjs"
|
|
21
|
+
|
|
22
|
+
if [ ! -r "$cjs" ]; then
|
|
23
|
+
inactive "trailhead-commit-msg.cjs missing or unreadable"
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
27
|
+
inactive "node not found on PATH"
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
node "$cjs" "$@"
|
|
32
|
+
code=$?
|
|
33
|
+
case $code in
|
|
34
|
+
0) exit 0 ;;
|
|
35
|
+
3) exit 1 ;;
|
|
36
|
+
*)
|
|
37
|
+
inactive "trailhead-commit-msg.cjs failed to run, exit $code"
|
|
38
|
+
exit 0
|
|
39
|
+
;;
|
|
40
|
+
esac
|