@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,51 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { GH_ISSUE_WRITE } from '../hooks/mod/map-pane'
|
|
4
|
+
|
|
5
|
+
// Bash commands the pane refreshes on (a write to the tracker) and ones it must ignore.
|
|
6
|
+
const WRITES = [
|
|
7
|
+
'gh issue close 5',
|
|
8
|
+
'gh --repo o/r issue close 5',
|
|
9
|
+
'gh issue comment 5 -b hi',
|
|
10
|
+
'gh issue edit 5 --add-label x',
|
|
11
|
+
'gh issue create -t t',
|
|
12
|
+
'gh issue reopen 5',
|
|
13
|
+
'gh issue delete 5',
|
|
14
|
+
'gh issue transfer 5 o/other',
|
|
15
|
+
'env GH_HOST=x gh issue close 5',
|
|
16
|
+
'command gh issue close 5',
|
|
17
|
+
'cd x && gh issue close 5',
|
|
18
|
+
'gh api -X POST repos/o/r/issues/198/sub_issues -F sub_issue_id=1',
|
|
19
|
+
'gh api repos/o/r/issues/198/sub_issues -F sub_issue_id=1',
|
|
20
|
+
'gh api repos/{owner}/{repo}/issues/5 -f state=closed',
|
|
21
|
+
'gh api --method=PATCH repos/o/r/issues/5',
|
|
22
|
+
'gh api --method PATCH repos/o/r/issues/5 -f title=x',
|
|
23
|
+
'gh api -XDELETE repos/o/r/issues/5/labels/x',
|
|
24
|
+
'gh api "repos/o/r/issues/5/comments" -f body=hi',
|
|
25
|
+
"gh api -X PUT 'repos/o/r/issues/5/lock'",
|
|
26
|
+
'gh api repos/o/r/issues/5 --input body.json',
|
|
27
|
+
'node /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x',
|
|
28
|
+
]
|
|
29
|
+
const READS = [
|
|
30
|
+
'gh issue list --state open',
|
|
31
|
+
'gh issue view 5',
|
|
32
|
+
'ls -la',
|
|
33
|
+
'gh pr close 4',
|
|
34
|
+
'gh api repos/o/r/issues/5',
|
|
35
|
+
'gh api repos/o/r/issues --jq length',
|
|
36
|
+
'gh api -X GET repos/o/r/issues -f labels=x',
|
|
37
|
+
'gh api --method=GET repos/o/r/issues -F per_page=1',
|
|
38
|
+
'gh api graphql -f query=query{viewer{login}}',
|
|
39
|
+
'gh api graphql -F owner={owner} -F repo={repo} -f query=x',
|
|
40
|
+
'gh api repos/o/r/pulls -f state=open',
|
|
41
|
+
'git log && echo gh issue',
|
|
42
|
+
'gh api repos/o/r/issues/5 | jq . ; echo -f x',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
test('the write matcher takes the tracker writes of a Bash call', () => {
|
|
46
|
+
for (const command of WRITES) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, true])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('the write matcher leaves reads alone', () => {
|
|
50
|
+
for (const command of READS) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, false])
|
|
51
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { On } from 'claude-code'
|
|
2
|
+
import { expect, test } from 'claude-code/testing'
|
|
3
|
+
|
|
4
|
+
// #299: a session started outside any repo, the repo and `origin` created mid-session. The host's
|
|
5
|
+
// `session.repo()` keeps answering null (observed live on Claude Code 2.1.306), while git in the cwd
|
|
6
|
+
// already answers; the pin the pane reads must follow git.
|
|
7
|
+
const CWD = '/work/new-project'
|
|
8
|
+
const REMOTE = 'https://github.com/o/new-project.git'
|
|
9
|
+
const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
|
|
10
|
+
|
|
11
|
+
test('the pin follows a repo created mid-session although session.repo() still answers null', async ($, on) => {
|
|
12
|
+
const pins: unknown[] = []
|
|
13
|
+
let isCreated = false
|
|
14
|
+
on('state.set', { plugin: 'trailhead', key: 'repoPin' }, (_$, e, next) => {
|
|
15
|
+
pins.push(e.value)
|
|
16
|
+
return next(e)
|
|
17
|
+
})
|
|
18
|
+
on('fs.read', () => ({ deny: 'ENOENT: none' }))
|
|
19
|
+
on('fs.write', () => ({ value: undefined }))
|
|
20
|
+
on('ui.status', () => ({ value: undefined }))
|
|
21
|
+
on('ui.log', () => ({ value: undefined }))
|
|
22
|
+
on('ui.panes', () => ({ value: [] }))
|
|
23
|
+
on('session.id', () => ({ value: 's1' }))
|
|
24
|
+
on('session.cwd', () => ({ value: CWD }))
|
|
25
|
+
on('session.root', () => ({ value: CWD }))
|
|
26
|
+
// The host's memoized answer: null from the start, even once the repo exists.
|
|
27
|
+
on('session.repo', () => ({ value: null }))
|
|
28
|
+
on('process.run', (_$, e) => {
|
|
29
|
+
const cmd = e.argv.join(' ')
|
|
30
|
+
if (cmd === 'git rev-parse --show-toplevel') return isCreated ? out(`${CWD}\n`) : out('', 128)
|
|
31
|
+
if (cmd === 'git remote get-url origin') return isCreated ? out(`${REMOTE}\n`) : out('', 2)
|
|
32
|
+
return out('', 1)
|
|
33
|
+
})
|
|
34
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
35
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
36
|
+
on('turn.complete', () => ({ text: '' }))
|
|
37
|
+
|
|
38
|
+
await $.session.start({ cwd: CWD, surface: 'terminal', isInteractive: true })
|
|
39
|
+
isCreated = true
|
|
40
|
+
await $.turn.complete({ turnId: 't1', answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
|
|
41
|
+
expect(pins.at(-1)).toEqual({ root: CWD, host: 'github.com', slug: 'o/new-project' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('the git fallback spawns no more than it needs: nothing past a failed rev-parse, one rev-parse per read', async ($, on) => {
|
|
45
|
+
const spawned: string[] = []
|
|
46
|
+
let isCreated = false
|
|
47
|
+
on('state.set', (_$, e, next) => next(e))
|
|
48
|
+
on('fs.read', () => ({ deny: 'ENOENT: none' }))
|
|
49
|
+
on('fs.write', () => ({ value: undefined }))
|
|
50
|
+
on('ui.status', () => ({ value: undefined }))
|
|
51
|
+
on('ui.log', () => ({ value: undefined }))
|
|
52
|
+
on('ui.panes', () => ({ value: [] }))
|
|
53
|
+
on('session.id', () => ({ value: 's1' }))
|
|
54
|
+
on('session.cwd', () => ({ value: CWD }))
|
|
55
|
+
on('session.root', () => ({ value: CWD }))
|
|
56
|
+
on('session.repo', () => ({ value: null }))
|
|
57
|
+
on('process.run', (_$, e) => {
|
|
58
|
+
const cmd = e.argv.join(' ')
|
|
59
|
+
spawned.push(cmd)
|
|
60
|
+
if (cmd === 'git rev-parse --show-toplevel') return isCreated ? out(`${CWD}\n`) : out('', 128)
|
|
61
|
+
if (cmd === 'git remote get-url origin') return out(`${REMOTE}\n`)
|
|
62
|
+
return out('', 1)
|
|
63
|
+
})
|
|
64
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
65
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
66
|
+
on('turn.complete', () => ({ text: '' }))
|
|
67
|
+
|
|
68
|
+
await $.session.start({ cwd: CWD, surface: 'terminal', isInteractive: true })
|
|
69
|
+
expect(spawned).not.toContain('git remote get-url origin')
|
|
70
|
+
spawned.length = 0
|
|
71
|
+
isCreated = true
|
|
72
|
+
await $.turn.complete({ turnId: 't1', answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
|
|
73
|
+
// The fallback hands its toplevel back: each read is one rev-parse and one remote lookup, no repeat.
|
|
74
|
+
const tops = spawned.filter(c => c === 'git rev-parse --show-toplevel').length
|
|
75
|
+
expect(tops).toBeGreaterThan(0)
|
|
76
|
+
expect(spawned.filter(c => c === 'git remote get-url origin')).toHaveLength(tops)
|
|
77
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { checkoutArg, isPinCheckout, isSamePin, nextPin, pinOf, pinPath, remoteId, repoFromGit } from '../hooks/mod/repo-pin'
|
|
4
|
+
|
|
5
|
+
const repo = (remote: string | null, root = '/repo') => ({ root, remote })
|
|
6
|
+
const GH = { host: 'github.com', slug: 'o/r' }
|
|
7
|
+
|
|
8
|
+
test('remoteId reads host and owner/name from every remote form, with or without .git', () => {
|
|
9
|
+
expect(remoteId('https://github.com/o/r.git')).toEqual(GH)
|
|
10
|
+
expect(remoteId('https://github.com/o/r')).toEqual(GH)
|
|
11
|
+
expect(remoteId('https://github.com/o/r/')).toEqual(GH)
|
|
12
|
+
expect(remoteId('ssh://git@github.com/o/r.git')).toEqual(GH)
|
|
13
|
+
expect(remoteId('git@github.com:o/r.git')).toEqual(GH)
|
|
14
|
+
expect(remoteId('git@github.com:o/r')).toEqual(GH)
|
|
15
|
+
expect(remoteId(' git@github.com:o/r.git\n')).toEqual(GH)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('remoteId accepts a user, a port, any case of the host, and Enterprise hosts', () => {
|
|
19
|
+
expect(remoteId('https://user@github.com/o/r.git')).toEqual(GH)
|
|
20
|
+
expect(remoteId('ssh://git@github.com:22/o/r')).toEqual(GH)
|
|
21
|
+
expect(remoteId('https://GitHub.com/o/r.git')).toEqual(GH)
|
|
22
|
+
expect(remoteId('git@GitHub.COM:o/r.git')).toEqual(GH)
|
|
23
|
+
expect(remoteId('git@ghe.example.com:o/r.git')).toEqual({ host: 'ghe.example.com', slug: 'o/r' })
|
|
24
|
+
expect(remoteId('https://ghe.example.com:8443/o/r')).toEqual({ host: 'ghe.example.com', slug: 'o/r' })
|
|
25
|
+
expect(remoteId('github.com:o/r')).toEqual(GH)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('remoteId is host-aware: any host gives its own, a local path none', () => {
|
|
29
|
+
expect(remoteId('https://gitlab.com/a/b.git')).toEqual({ host: 'gitlab.com', slug: 'a/b' })
|
|
30
|
+
expect(remoteId('ssh://git@github.com/ToRvaLDz/trailhead.git')).toEqual({ host: 'github.com', slug: 'ToRvaLDz/trailhead' })
|
|
31
|
+
expect(remoteId('/srv/git/trailhead.git')).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('remoteId refuses a host or owner/name with characters a link must not carry', () => {
|
|
35
|
+
expect(remoteId('https://github.com/o/r.git')).toBeNull()
|
|
36
|
+
expect(remoteId('git@github.com:o/r.git')).toBeNull()
|
|
37
|
+
expect(remoteId('git@ghe.example.com:o/r.git')).toBeNull()
|
|
38
|
+
expect(remoteId('https://github.com/o/r<x>.git')).toBeNull()
|
|
39
|
+
expect(remoteId('git@github.com:my.org/r_1-x.git')).toEqual({ host: 'github.com', slug: 'my.org/r_1-x' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('remoteId is null for no remote or one that is not host/owner/name', () => {
|
|
43
|
+
expect(remoteId(null)).toBeNull()
|
|
44
|
+
expect(remoteId('')).toBeNull()
|
|
45
|
+
expect(remoteId('/srv/git/r.git')).toBeNull()
|
|
46
|
+
expect(remoteId('../r.git')).toBeNull()
|
|
47
|
+
expect(remoteId('https://github.com/o')).toBeNull()
|
|
48
|
+
expect(remoteId('file:///srv/o/r.git')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('pinOf pins the checkout toplevel when given, else the repo root', () => {
|
|
52
|
+
expect(pinOf(null)).toBeNull()
|
|
53
|
+
expect(pinOf(repo(null))).toBeNull()
|
|
54
|
+
expect(pinOf(repo('git@github.com:o/r.git'))).toEqual({ root: '/repo', ...GH })
|
|
55
|
+
expect(pinOf(repo('git@github.com:o/r.git'), '/repo-t5')).toEqual({ root: '/repo-t5', ...GH })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('nextPin keeps the last pin when the check finds no usable repo, and follows a different one', () => {
|
|
59
|
+
const kept = { root: '/repo', ...GH }
|
|
60
|
+
expect(nextPin(kept, null)).toBe(kept)
|
|
61
|
+
expect(nextPin(kept, repo(null))).toBe(kept)
|
|
62
|
+
expect(nextPin(kept, repo('/srv/git/r.git'))).toBe(kept)
|
|
63
|
+
expect(nextPin(null, null)).toBeNull()
|
|
64
|
+
expect(nextPin(null, repo('git@github.com:o/r.git'))).toEqual(kept)
|
|
65
|
+
expect(nextPin(kept, repo('git@github.com:o/r.git'))).toBe(kept)
|
|
66
|
+
expect(nextPin(kept, repo('git@github.com:o/other.git', '/other'))).toEqual({ root: '/other', host: 'github.com', slug: 'o/other' })
|
|
67
|
+
expect(nextPin(kept, repo('git@github.com:o/r.git'), '/repo-t5')).toEqual({ root: '/repo-t5', ...GH })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('isSamePin compares root and repo identity', () => {
|
|
71
|
+
const a = { root: '/repo', ...GH }
|
|
72
|
+
expect(isSamePin(a, { ...a })).toBe(true)
|
|
73
|
+
expect(isSamePin(a, { ...a, root: '/repo-t5' })).toBe(false)
|
|
74
|
+
expect(isSamePin(a, { ...a, host: 'ghe.example.com' })).toBe(false)
|
|
75
|
+
expect(isSamePin(null, null)).toBe(true)
|
|
76
|
+
expect(isSamePin(a, null)).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('pinPath joins a repo-relative path under the pinned root', () => {
|
|
80
|
+
expect(pinPath({ root: '/repo', ...GH }, '.trailhead/active-map')).toBe('/repo/.trailhead/active-map')
|
|
81
|
+
expect(pinPath({ root: '/repo/', ...GH }, '.trailhead/active-map')).toBe('/repo/.trailhead/active-map')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('isPinCheckout holds only for the pinned repo, checked out at the pinned root', () => {
|
|
85
|
+
const pin = { root: '/repo', ...GH }
|
|
86
|
+
expect(isPinCheckout(pin, repo('git@github.com:o/r.git'), '/repo')).toBe(true)
|
|
87
|
+
expect(isPinCheckout(pin, repo('git@github.com:o/r.git', '/main'), '/repo')).toBe(true)
|
|
88
|
+
expect(isPinCheckout(pin, repo('git@github.com:o/r.git'), '/repo-t5')).toBe(false)
|
|
89
|
+
expect(isPinCheckout(pin, repo('git@github.com:o/other.git'), '/repo')).toBe(false)
|
|
90
|
+
expect(isPinCheckout(pin, repo(null), '/repo')).toBe(false)
|
|
91
|
+
expect(isPinCheckout(pin, null, null)).toBe(false)
|
|
92
|
+
expect(isPinCheckout(pin, repo('git@github.com:o/r.git'), null)).toBe(false)
|
|
93
|
+
expect(isPinCheckout(null, repo('git@github.com:o/r.git'), '/repo')).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('repoFromGit builds the repo from the two git outputs', () => {
|
|
97
|
+
expect(repoFromGit({ exitCode: 0, stdout: '/w/p\n' }, { exitCode: 0, stdout: 'git@github.com:o/p.git\n' })).toEqual({ root: '/w/p', remote: 'git@github.com:o/p.git' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('repoFromGit is null outside a repo, so the last pin stays', () => {
|
|
101
|
+
const current = { root: '/w/p', host: 'github.com', slug: 'o/p' }
|
|
102
|
+
const repo = repoFromGit({ exitCode: 128, stdout: '' }, { exitCode: 128, stdout: '' })
|
|
103
|
+
expect(repo).toBeNull()
|
|
104
|
+
expect(nextPin(current, repo)).toBe(current)
|
|
105
|
+
expect(repoFromGit(null, null)).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('repoFromGit keeps a repo with no origin remote unpinned, so the last pin stays', () => {
|
|
109
|
+
const current = { root: '/w/p', host: 'github.com', slug: 'o/p' }
|
|
110
|
+
const repo = repoFromGit({ exitCode: 0, stdout: '/w/q\n' }, { exitCode: 2, stdout: '' })
|
|
111
|
+
expect(repo).toEqual({ root: '/w/q', remote: null })
|
|
112
|
+
expect(nextPin(current, repo)).toBe(current)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('checkoutArg hands the root over as one shell-safe word the helper decodes back', () => {
|
|
116
|
+
for (const root of ['/repo', '/with space/r', "/it's/r", '/-dash/r', '/uni\u00e8\u4e2d/r', '/a"b$c`d']) {
|
|
117
|
+
const arg = checkoutArg({ ...GH, root })
|
|
118
|
+
expect(arg).toMatch(/^--in=[A-Za-z0-9_-]+$/)
|
|
119
|
+
const token = arg.slice('--in='.length)
|
|
120
|
+
const bytes = Uint8Array.from(atob(token.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(token.length / 4) * 4, '=')), c => c.charCodeAt(0))
|
|
121
|
+
expect(new TextDecoder().decode(bytes)).toBe(root)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { sessionLiveness } from '../hooks/mod/session-registry'
|
|
4
|
+
import type { RegistryDeps } from '../hooks/mod/session-registry'
|
|
5
|
+
|
|
6
|
+
const DIR = '/home/u/.claude/sessions'
|
|
7
|
+
const entry = (sessionId: string, pid: number) => JSON.stringify({ sessionId, pid })
|
|
8
|
+
|
|
9
|
+
// Un registro finto: nome file -> testo; i pid in `alive` sono vivi, `null` = la piattaforma non lo dice.
|
|
10
|
+
const deps = (files: Record<string, string> | Error, alive: number[] | null = []): RegistryDeps => ({
|
|
11
|
+
list: async () => {
|
|
12
|
+
if (files instanceof Error) throw files
|
|
13
|
+
return Object.keys(files)
|
|
14
|
+
},
|
|
15
|
+
read: async path => {
|
|
16
|
+
if (files instanceof Error) throw files
|
|
17
|
+
const text = files[path.slice(DIR.length + 1)]
|
|
18
|
+
if (text === undefined) throw new Error('ENOENT')
|
|
19
|
+
return text
|
|
20
|
+
},
|
|
21
|
+
pidAlive: async pid => (alive === null ? null : alive.includes(pid)),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('a session with a live pid is live', async () => {
|
|
25
|
+
const files = { '10.json': entry('s-A', 10), '11.json': entry('s-B', 11) }
|
|
26
|
+
expect(await sessionLiveness(DIR, 's-A', deps(files, [10, 11]))).toBe('live')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('a session whose entry is missing, or whose pid is dead, is gone', async () => {
|
|
30
|
+
const files = { '10.json': entry('s-A', 10), '11.json': entry('s-B', 11) }
|
|
31
|
+
expect(await sessionLiveness(DIR, 's-OLD', deps(files, [10, 11]))).toBe('gone')
|
|
32
|
+
expect(await sessionLiveness(DIR, 's-A', deps(files, [11]))).toBe('gone')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('with no way to check a pid the entry alone says live', async () => {
|
|
36
|
+
const files = { '10.json': entry('s-A', 10) }
|
|
37
|
+
expect(await sessionLiveness(DIR, 's-A', deps(files, null))).toBe('live')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('an unreadable registry is unknown, never gone', async () => {
|
|
41
|
+
expect(await sessionLiveness(DIR, 's-A', deps(new Error('EACCES')))).toBe('unknown')
|
|
42
|
+
expect(await sessionLiveness(DIR, 's-A', deps({}))).toBe('unknown')
|
|
43
|
+
expect(await sessionLiveness(DIR, 's-A', deps({ 'x.key': 'secret' }))).toBe('unknown')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a file that does not parse keeps a missing session unknown, but not a found live one', async () => {
|
|
47
|
+
const files = { '9.json': '{nope', '10.json': entry('s-A', 10), '11.json': JSON.stringify({ sessionId: 's-C' }) }
|
|
48
|
+
expect(await sessionLiveness(DIR, 's-OLD', deps(files, [10]))).toBe('unknown')
|
|
49
|
+
expect(await sessionLiveness(DIR, 's-A', deps(files, [10]))).toBe('live')
|
|
50
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import type { WorkState } from '../types'
|
|
4
|
+
import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from '../hooks/mod/whiteboard-follow'
|
|
5
|
+
|
|
6
|
+
const state = (over: Partial<WorkState> = {}): WorkState => ({ map: null, ticket: 301, ...over }) as WorkState
|
|
7
|
+
|
|
8
|
+
test('nextFollow follows an own whiteboard ticket once', () => {
|
|
9
|
+
expect(nextFollow(null, state(), 301, false)).toEqual({ follow: true, memo: 301 })
|
|
10
|
+
expect(nextFollow(301, state(), 301, false)).toEqual({ follow: false, memo: 301 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('nextFollow follows a different whiteboard ticket again', () => {
|
|
14
|
+
expect(nextFollow(301, state({ ticket: 302 }), 302, false)).toEqual({ follow: true, memo: 302 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('nextFollow never follows a map ticket, a foreign or orphaned state, or no own ticket', () => {
|
|
18
|
+
expect(nextFollow(null, state({ map: 12 }), 301, false)).toEqual({ follow: false, memo: null })
|
|
19
|
+
expect(nextFollow(null, state(), 999, false)).toEqual({ follow: false, memo: null })
|
|
20
|
+
expect(nextFollow(null, state(), null, false)).toEqual({ follow: false, memo: null })
|
|
21
|
+
expect(nextFollow(null, state(), 301, true)).toEqual({ follow: false, memo: null })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('nextFollow keeps the memo on a null state', () => {
|
|
25
|
+
expect(nextFollow(301, null, null, false)).toEqual({ follow: false, memo: 301 })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('isWhiteboardCreate reads every label form of an issue create', () => {
|
|
29
|
+
const yes = [
|
|
30
|
+
'gh issue create -t t --label trailhead:whiteboard',
|
|
31
|
+
'gh issue create -t t --label a,trailhead:whiteboard',
|
|
32
|
+
'gh issue create -t t --label=trailhead:whiteboard',
|
|
33
|
+
'gh issue create -t t -l trailhead:whiteboard',
|
|
34
|
+
'gh issue create -t t --label bug --label trailhead:whiteboard',
|
|
35
|
+
'gh issue create -t t --label "trailhead:whiteboard"',
|
|
36
|
+
"gh issue create -t t --label 'bug,trailhead:whiteboard' -b x",
|
|
37
|
+
'gh -R o/r issue create -l trailhead:whiteboard',
|
|
38
|
+
]
|
|
39
|
+
for (const c of yes) expect(isWhiteboardCreate(c)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('isWhiteboardCreate refuses other labels, other verbs and other commands', () => {
|
|
43
|
+
const no = [
|
|
44
|
+
'gh issue create -t t --label trailhead:whiteboardx',
|
|
45
|
+
'gh issue create -t t --label trailhead:map',
|
|
46
|
+
'gh issue create -t t',
|
|
47
|
+
'gh issue create -t "trailhead:whiteboard"',
|
|
48
|
+
'gh issue edit 3 --add-label trailhead:whiteboard',
|
|
49
|
+
'gh issue create -t t --label bug; echo --label trailhead:whiteboard',
|
|
50
|
+
'echo --label trailhead:whiteboard',
|
|
51
|
+
]
|
|
52
|
+
for (const c of no) expect(isWhiteboardCreate(c)).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('isWhiteboardCreate reads continued lines, quoted separators and the attached short flag', () => {
|
|
56
|
+
const yes = [
|
|
57
|
+
'gh issue create \\\n --title x \\\n --label trailhead:whiteboard',
|
|
58
|
+
'gh issue create --title "a & b" --label trailhead:whiteboard',
|
|
59
|
+
'gh issue create --title "a; b | c" --label trailhead:whiteboard',
|
|
60
|
+
'gh issue create --body "a\nb" --label trailhead:whiteboard',
|
|
61
|
+
'gh issue create -t t -ltrailhead:whiteboard',
|
|
62
|
+
'cd /x && gh issue create -t t --label trailhead:whiteboard',
|
|
63
|
+
]
|
|
64
|
+
for (const c of yes) expect(isWhiteboardCreate(c)).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('isWhiteboardCreate ignores a label flag written inside a quoted argument', () => {
|
|
68
|
+
expect(isWhiteboardCreate('gh issue create --title "fix -l trailhead:whiteboard"')).toBe(false)
|
|
69
|
+
expect(isWhiteboardCreate("gh issue create -b 'see --label trailhead:whiteboard' -t t")).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('createdIssueNumber reads the last-line issue URL from stdout, a string or the text', () => {
|
|
73
|
+
const url = 'https://github.com/o/r/issues/310'
|
|
74
|
+
expect(createdIssueNumber({ result: { stdout: `${url}\n`, stderr: '' } })).toBe(310)
|
|
75
|
+
expect(createdIssueNumber({ result: `creating\n${url}\n` })).toBe(310)
|
|
76
|
+
expect(createdIssueNumber({ result: {}, text: url })).toBe(310)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('createdIssueNumber gives none on an error, no URL or a bad shape', () => {
|
|
80
|
+
expect(createdIssueNumber({ isError: true, result: 'https://github.com/o/r/issues/310' })).toBeNull()
|
|
81
|
+
expect(createdIssueNumber({ result: { stdout: 'ok' } })).toBeNull()
|
|
82
|
+
expect(createdIssueNumber({ result: { stdout: 'https://github.com/o/r/issues/310/extra' } })).toBeNull()
|
|
83
|
+
expect(createdIssueNumber(undefined)).toBeNull()
|
|
84
|
+
expect(createdIssueNumber('x')).toBeNull()
|
|
85
|
+
expect(createdIssueNumber({ result: 5 })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('whiteboardHint names the ticket in a few words', () => {
|
|
89
|
+
expect(whiteboardHint(310)).toBe('new whiteboard ticket #310: open Whiteboard')
|
|
90
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { candidates, isOwnState, parseWorktrees, pickOwn, siblingClones } from '../hooks/mod/work-state-locate'
|
|
4
|
+
|
|
5
|
+
const state = (session: string | null) =>
|
|
6
|
+
JSON.stringify({ map: null, ticket: 270, title: 'T', step: 'execute', auto: false, autoDone: 0, session, updatedAt: null })
|
|
7
|
+
|
|
8
|
+
test('parseWorktrees keeps the checkout paths in git order, spaces included', () => {
|
|
9
|
+
const out = 'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /my work/repo-t270\nHEAD def\ndetached\n\nworktree /repo-t5/\nbare\n'
|
|
10
|
+
expect(parseWorktrees(out)).toEqual(['/repo', '/my work/repo-t270', '/repo-t5'])
|
|
11
|
+
expect(parseWorktrees('')).toEqual([])
|
|
12
|
+
expect(parseWorktrees('fatal: not a git repository')).toEqual([])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('siblingClones matches <basename>-t<digits> only, sorted, under the main root parent', () => {
|
|
16
|
+
const names = ['repo-t12', 'repo', 'repo-t5', 'repo-t5x', 'repo-tx', 'other-t3', 'repo-t5-old', 'repoXt7']
|
|
17
|
+
expect(siblingClones('/home/me/repo', names)).toEqual(['/home/me/repo-t12', '/home/me/repo-t5'])
|
|
18
|
+
expect(siblingClones('/r.x', ['r.x-t1', 'rax-t2'])).toEqual(['/r.x-t1'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('candidates drops the pinned root and duplicates, worktrees before siblings', () => {
|
|
22
|
+
expect(candidates('/repo', ['/repo', '/repo-t1', '/repo-t2'], ['/repo-t2', '/repo-t3'])).toEqual(['/repo-t1', '/repo-t2', '/repo-t3'])
|
|
23
|
+
expect(candidates('/repo/', ['/repo'], [])).toEqual([])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('only a state naming this session is adopted', () => {
|
|
27
|
+
expect(isOwnState(state('A'), 'A')).toBe(true)
|
|
28
|
+
expect(isOwnState(state('B'), 'A')).toBe(false)
|
|
29
|
+
expect(isOwnState(state(null), 'A')).toBe(false)
|
|
30
|
+
expect(isOwnState('{not json', 'A')).toBe(false)
|
|
31
|
+
expect(pickOwn([null, state(null), state('B'), state('A'), state('A')], 'A')).toBe(3)
|
|
32
|
+
expect(pickOwn([null, state('B')], 'A')).toBe(-1)
|
|
33
|
+
})
|