jackherd 1.9.0 → 1.10.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/CHANGELOG.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
|
|
2
1
|
import { FULL_COMMIT } from '../shared/commit'
|
|
3
|
-
import {
|
|
4
|
-
inlineScript,
|
|
5
|
-
isOption,
|
|
6
|
-
isProgramPosition,
|
|
7
|
-
isShell,
|
|
8
|
-
programName,
|
|
9
|
-
runsInterpreterProgram,
|
|
10
|
-
} from '../shared/program-position'
|
|
2
|
+
import { inlineScript, isOption, isProgramPosition, programName } from '../shared/program-position'
|
|
11
3
|
import {
|
|
12
4
|
expandingHereDocumentAdvice,
|
|
13
5
|
isExpanded,
|
|
@@ -18,15 +10,13 @@ import {
|
|
|
18
10
|
type Word,
|
|
19
11
|
} from '../shared/shell'
|
|
20
12
|
import {
|
|
21
|
-
definesShellAlias,
|
|
22
|
-
mayRunDefinedFunction,
|
|
23
13
|
NO_DEFINITIONS,
|
|
24
14
|
type ShellDefinitionHit,
|
|
25
15
|
type ShellDefinitions,
|
|
26
16
|
shellDefinitionAt,
|
|
27
17
|
shellDefinitionHint,
|
|
28
18
|
} from '../shared/shell-aliases'
|
|
29
|
-
import { resolvePlainXargs } from '
|
|
19
|
+
import { GLOB, resolvePlainXargs, XARGS } from '../shared/xargs-program'
|
|
30
20
|
|
|
31
21
|
export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
|
|
32
22
|
|
|
@@ -43,8 +33,8 @@ export type GuardPolicy = {
|
|
|
43
33
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
44
34
|
commands: readonly string[]
|
|
45
35
|
/**
|
|
46
|
-
* Whether the merge of this pull request may pass without a question: the user approved its branch
|
|
47
|
-
* its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
36
|
+
* Whether the merge of this pull request may pass without a question: the user approved its branch, a standing
|
|
37
|
+
* approval covers the plan of its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
48
38
|
* A throw counts as not approved; a note is not approved either, and the question carries it.
|
|
49
39
|
*/
|
|
50
40
|
isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
|
|
@@ -54,17 +44,13 @@ export type GuardPolicy = {
|
|
|
54
44
|
|
|
55
45
|
/**
|
|
56
46
|
* A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
|
|
57
|
-
* the shell alias or function that may run one (`definition`, whose name is then the `command`)
|
|
58
|
-
* name is unknown (`note` says why it may run one).
|
|
47
|
+
* the shell alias or function that may run one (`definition`, whose name is then the `command`).
|
|
59
48
|
*/
|
|
60
|
-
type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit
|
|
49
|
+
type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit }
|
|
61
50
|
|
|
62
51
|
/** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
|
|
63
52
|
type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
|
|
64
53
|
|
|
65
|
-
/** A whole token of a script's text; `joined` when only blanks, quotes, commas and brackets stand before it. */
|
|
66
|
-
type TextToken = { value: string; end: number; joined: boolean }
|
|
67
|
-
|
|
68
54
|
/** The parts of a `gh api` call that decide whether it writes, and where. */
|
|
69
55
|
type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
|
|
70
56
|
|
|
@@ -81,11 +67,21 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
|
|
|
81
67
|
const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
|
|
82
68
|
const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
|
|
83
69
|
const HEAD_COMMIT_FLAG = '--match-head-commit'
|
|
84
|
-
const GLOB = /[*?[]/
|
|
85
70
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
71
|
+
/** The option that prints a command's help and runs nothing; gh also takes `-h`, another tool may read it as a host. */
|
|
72
|
+
const HELP_OPTION = '--help'
|
|
73
|
+
const GH_SHORT_HELP = '-h'
|
|
74
|
+
/** The gh verbs that only read: `gh <noun> view`, `gh <noun> list`. */
|
|
75
|
+
const READING_VERBS = new Set(['view', 'list'])
|
|
76
|
+
const GH = 'gh'
|
|
77
|
+
/** What `xargs` appends to the program it runs: words from its input, which the guard cannot know. */
|
|
78
|
+
const XARGS_INPUT: Word = { value: '$xargs-input', literal: false }
|
|
79
|
+
/**
|
|
80
|
+
* Where a command may start in text the parse could not read: an operator, a bracket, a quote, a backtick, a line
|
|
81
|
+
* break. Cut there, a word at a program position of a piece may be one.
|
|
82
|
+
*/
|
|
83
|
+
const COMMAND_BOUNDARY = /[;&|()`'"{}\n]/
|
|
84
|
+
const BLANKS = /\s+/
|
|
89
85
|
|
|
90
86
|
/** `gh api` is guarded only for a write that merges, writes a file, starts or reruns a workflow or moves a ref (D43); every other call passes. */
|
|
91
87
|
const API_COMMAND = 'gh api'
|
|
@@ -129,19 +125,6 @@ const DANGEROUS_ENDINGS = [
|
|
|
129
125
|
/(^|\/)(rerun|rerun-failed-jobs)\/?$/,
|
|
130
126
|
/(^|\/)refs(\/|$)/,
|
|
131
127
|
]
|
|
132
|
-
const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
|
|
133
|
-
const EXPANDED_FUNCTION_NOTE =
|
|
134
|
-
'The program is expanded from a variable or a substitution and may name a shell function that runs one of guard.commands in .jackherd/config.json, so the user confirms it.'
|
|
135
|
-
|
|
136
|
-
/** A run of characters that no quote, comma, bracket, backslash, `+`, blank or shell operator ends. */
|
|
137
|
-
const TEXT_TOKEN = /[^\s'"`,()[\]{}\\+;&|<>]+/g
|
|
138
|
-
/** What may stand between two command words in program text: `"gh", "workflow"`, `"gh workflow " + "run"`, `\"gh`. */
|
|
139
|
-
const WORD_GAP = /^[\s'"`,()[\]{}\\+]*$/
|
|
140
|
-
/**
|
|
141
|
-
* Options and their values a command word may be separated by (`workflow -R o/r run`; gh takes flags before the
|
|
142
|
-
* subcommand, too); more ends the search, so a long run of options cannot stall it.
|
|
143
|
-
*/
|
|
144
|
-
const MAX_TOKENS_BETWEEN_WORDS = 16
|
|
145
128
|
|
|
146
129
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
147
130
|
|
|
@@ -285,23 +268,59 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
|
|
|
285
268
|
return at === -1 ? after : after.slice(at + 1)
|
|
286
269
|
}
|
|
287
270
|
|
|
288
|
-
/** Every guarded command
|
|
271
|
+
/** Every guarded command at a program position counts, except a `gh api` call that does not write dangerously. */
|
|
289
272
|
const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
|
|
290
273
|
command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
|
|
291
274
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
275
|
+
const commandWords = (command: string): string[] => command.split(' ').slice(1)
|
|
276
|
+
|
|
277
|
+
/** `gh <noun> view` or `gh <noun> list`, written plainly: it only reads. */
|
|
278
|
+
const isGhRead = (words: SimpleCommand, index: number): boolean => {
|
|
279
|
+
const [noun, verb] = words.slice(index + 1, index + 3)
|
|
280
|
+
const plainNoun = noun?.literal === true && !isOption(noun)
|
|
281
|
+
return programName(words[index]) === GH && plainNoun && verb?.literal === true && READING_VERBS.has(verb.value)
|
|
296
282
|
}
|
|
297
283
|
|
|
284
|
+
const isHelpOption = (program: Word, { value }: Word): boolean =>
|
|
285
|
+
value === HELP_OPTION || (value === GH_SHORT_HELP && programName(program) === GH)
|
|
286
|
+
|
|
287
|
+
/** The command's own words, written plainly, then `--help` (or gh's `-h`): it prints the help and runs nothing. */
|
|
288
|
+
const isHelpOf = (words: SimpleCommand, index: number, command: string): boolean => {
|
|
289
|
+
const expected = commandWords(command)
|
|
290
|
+
const after = words.slice(index + 1, index + 2 + expected.length)
|
|
291
|
+
const help = after[expected.length]
|
|
292
|
+
return (
|
|
293
|
+
after.every((word) => word.literal) &&
|
|
294
|
+
expected.every((value, at) => after[at]?.value === value) &&
|
|
295
|
+
help !== undefined &&
|
|
296
|
+
isHelpOption(words[index], help)
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A call of the program at `index` that only reads: a help text or a gh view or list (D108). */
|
|
301
|
+
const isReading = (words: SimpleCommand, index: number, command: string): boolean =>
|
|
302
|
+
isGhRead(words, index) || isHelpOf(words, index, command)
|
|
303
|
+
|
|
304
|
+
/** An `xargs` before the program hands it words from its input: they may be any of the command words. */
|
|
305
|
+
const isFedByXargs = (words: SimpleCommand, index: number): boolean =>
|
|
306
|
+
words.slice(0, index).some((word, at) => programName(word) === XARGS && isProgramPosition(words, at))
|
|
307
|
+
|
|
308
|
+
/** The words after the program at `index`, with a stand-in for what `xargs` appends. */
|
|
309
|
+
const argumentsOf = (words: SimpleCommand, index: number): Word[] => {
|
|
310
|
+
const after = words.slice(index + 1)
|
|
311
|
+
return isFedByXargs(words, index) ? [...after, XARGS_INPUT] : after
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Whether the words after the program at `index` can be the command's own words. */
|
|
315
|
+
const namesCommandWords = (words: SimpleCommand, index: number, command: string): boolean =>
|
|
316
|
+
operandLists(argumentsOf(words, index)).some((operands) => matchesFrom(operands, commandWords(command)))
|
|
317
|
+
|
|
298
318
|
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
299
319
|
const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
|
|
300
|
-
|
|
301
|
-
if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
|
|
320
|
+
if (!namesCommandWords(words, index, command) || isReading(words, index, command)) return undefined
|
|
302
321
|
if (!isGuardedUse(command, words, index)) return undefined
|
|
303
|
-
const plain = sole && words[index].literal && words[index].value ===
|
|
304
|
-
return { command, merge: plain ? plainMerge(words.slice(index),
|
|
322
|
+
const plain = sole && words[index].literal && words[index].value === GH
|
|
323
|
+
return { command, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
|
|
305
324
|
}
|
|
306
325
|
|
|
307
326
|
const programsOf = (commands: readonly string[]): string[] => [
|
|
@@ -314,124 +333,37 @@ const definitionFinding = (words: SimpleCommand, index: number, { commands, defi
|
|
|
314
333
|
return definition ? [{ command: definition.name, merge: undefined, definition }] : []
|
|
315
334
|
}
|
|
316
335
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
336
|
+
/**
|
|
337
|
+
* A program from a variable, a substitution or a glob may be a guarded one: it counts when the parsed words after it
|
|
338
|
+
* can be that command's words (`"$tool" pr merge 5`). A variable that holds the whole command (`$X`) is not searched.
|
|
339
|
+
*/
|
|
340
|
+
const expandedProgramFindings = (words: SimpleCommand, index: number, { commands }: Search): Finding[] =>
|
|
341
|
+
commands
|
|
342
|
+
.filter((command) => namesCommandWords(words, index, command) && isGuardedUse(command, words, index))
|
|
343
|
+
.map((command) => ({ command, merge: undefined }))
|
|
344
|
+
|
|
345
|
+
/** A program word from an expansion or a glob; a quoted word (`'gh pr merge'`, a script text) is the name it spells. */
|
|
346
|
+
const isExpandedProgram = ({ value, literal }: Word): boolean => isExpanded(value) || (literal && GLOB.test(value))
|
|
321
347
|
|
|
322
348
|
const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
|
|
323
349
|
if (!isProgramPosition(words, index)) return []
|
|
350
|
+
if (isExpandedProgram(words[index])) return expandedProgramFindings(words, index, search)
|
|
324
351
|
const program = programName(words[index])
|
|
325
352
|
const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
|
|
326
353
|
if (guarded.length === 0) return definitionFinding(words, index, search)
|
|
327
354
|
return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
|
|
328
355
|
}
|
|
329
356
|
|
|
330
|
-
/**
|
|
331
|
-
* The script cut into whole tokens: a path (`kit/workflow/skills`) or a dotted name (`subprocess.run`) is one token,
|
|
332
|
-
* while quotes, commas, brackets and shell operators end one, so `["gh", "workflow", "run"]` gives three.
|
|
333
|
-
*/
|
|
334
|
-
const textTokens = (script: string): TextToken[] => {
|
|
335
|
-
const tokens: TextToken[] = []
|
|
336
|
-
let previousEnd = 0
|
|
337
|
-
for (const match of script.matchAll(TEXT_TOKEN)) {
|
|
338
|
-
const end = match.index + match[0].length
|
|
339
|
-
const joined = WORD_GAP.test(script.slice(previousEnd, match.index))
|
|
340
|
-
tokens.push({ value: match[0], end, joined })
|
|
341
|
-
previousEnd = end
|
|
342
|
-
}
|
|
343
|
-
return tokens
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const isOptionToken = (value: string): boolean => value.length > 1 && value.startsWith('-')
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Whether the tokens from `start` on spell the words, each joined to the one before: only quotes, commas, brackets and
|
|
350
|
-
* blanks between them, and options such as `-R o/r` that `gh` takes between its command words. From the first option
|
|
351
|
-
* on, any joined token may follow, since brackets and braces cut a value such as `"${REPO}"` or `REPOS[0]` into
|
|
352
|
-
* several tokens; without an option before it, a word between the command words ends the match. A bounded walk, so a
|
|
353
|
-
* long script cannot stall it.
|
|
354
|
-
*/
|
|
355
|
-
const spellsFrom = (tokens: TextToken[], start: number, words: string[]): boolean => {
|
|
356
|
-
let index = start
|
|
357
|
-
for (const word of words) {
|
|
358
|
-
let skipped = 0
|
|
359
|
-
let optionSeen = false
|
|
360
|
-
while (tokens[index]?.joined && tokens[index].value !== word && skipped < MAX_TOKENS_BETWEEN_WORDS) {
|
|
361
|
-
optionSeen ||= isOptionToken(tokens[index].value)
|
|
362
|
-
if (!optionSeen) return false
|
|
363
|
-
skipped++
|
|
364
|
-
index++
|
|
365
|
-
}
|
|
366
|
-
if (!tokens[index]?.joined || tokens[index].value !== word) return false
|
|
367
|
-
index++
|
|
368
|
-
}
|
|
369
|
-
return true
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** Whether the words stand adjacent somewhere in the tokens, in this order. */
|
|
373
|
-
const spellsWords = (tokens: TextToken[], [first, ...rest]: string[]): boolean =>
|
|
374
|
-
first === undefined || tokens.some(({ value }, index) => value === first && spellsFrom(tokens, index + 1, rest))
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Where the program is hidden or the words are handed over as text, the command words after the program, standing
|
|
378
|
-
* adjacent as whole tokens, are the sign: `$g workflow run` and `["gh", "workflow", "run"]` count, a path segment
|
|
379
|
-
* `workflow` and a `run` elsewhere in the script do not.
|
|
380
|
-
*/
|
|
381
|
-
const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] => {
|
|
382
|
-
const tokens = textTokens(script)
|
|
383
|
-
return commands
|
|
384
|
-
.filter((command) =>
|
|
385
|
-
command === API_COMMAND
|
|
386
|
-
? mentionsDangerousApiCall(script, tokens)
|
|
387
|
-
: spellsWords(tokens, command.split(' ').slice(1)),
|
|
388
|
-
)
|
|
389
|
-
.map((command) => ({ command, merge: undefined }))
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** A program expanded from a variable or a substitution could be the guarded one. */
|
|
393
|
-
const runsExpandedProgram = (words: SimpleCommand): boolean =>
|
|
394
|
-
words.some((word, index) => !word.literal && isProgramPosition(words, index))
|
|
395
|
-
|
|
396
|
-
const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
|
|
400
|
-
* substitution or from a file descriptor; or `source` of such a thing: the text comes from somewhere else.
|
|
401
|
-
*/
|
|
402
|
-
const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
|
|
403
|
-
words.some((word, index) => {
|
|
404
|
-
if (!isProgramPosition(words, index)) return false
|
|
405
|
-
const shell = isShell(word)
|
|
406
|
-
if (!shell && !SOURCING.has(programName(word))) return false
|
|
407
|
-
const rest = words.slice(index + 1)
|
|
408
|
-
const operands = rest.filter((each) => !isOption(each))
|
|
409
|
-
const fromInput = shell && (operands.length === 0 || rest.some(({ value }) => value === '-s'))
|
|
410
|
-
return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
/** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
|
|
414
|
-
const definesGitAlias = (words: SimpleCommand): boolean =>
|
|
415
|
-
words.some(
|
|
416
|
-
(word, index) =>
|
|
417
|
-
isProgramPosition(words, index) &&
|
|
418
|
-
programName(word) === 'git' &&
|
|
419
|
-
words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
/** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
|
|
423
|
-
const runsInlineExpansion = (words: SimpleCommand): boolean =>
|
|
424
|
-
words.some((_word, index) => inlineScript(words, index)?.includes('$') === true)
|
|
425
|
-
|
|
426
357
|
/**
|
|
427
358
|
* Whether the command's words contain the guarded command's words in this order, anything between them: the
|
|
428
359
|
* fallback for a wrapper nobody listed (`mise exec -- gh pr merge 5`), which hides the program from the position test.
|
|
360
|
+
* It reads the parsed words of the command, never text inside them.
|
|
429
361
|
*/
|
|
430
362
|
const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
431
363
|
const [program, ...rest] = command.split(' ')
|
|
432
364
|
const at = words.findIndex((word) => programName(word) === program)
|
|
433
365
|
let from = at + 1
|
|
434
|
-
if (at === -1) return false
|
|
366
|
+
if (at === -1 || isReading(words, at, command)) return false
|
|
435
367
|
for (const expected of rest) {
|
|
436
368
|
const next = words.findIndex((word, index) => index >= from && word.literal && word.value === expected)
|
|
437
369
|
if (next === -1) return false
|
|
@@ -440,45 +372,28 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
|
440
372
|
return isGuardedUse(command, words, at)
|
|
441
373
|
}
|
|
442
374
|
|
|
443
|
-
|
|
444
|
-
const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
|
|
375
|
+
const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean): Finding[] => {
|
|
445
376
|
const direct = words.flatMap((_word, index) => {
|
|
446
377
|
const script = inlineScript(words, index)
|
|
447
|
-
return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false
|
|
378
|
+
return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false)
|
|
448
379
|
})
|
|
449
380
|
const found = new Set(direct.map(({ command }) => command))
|
|
450
381
|
const byWords = search.commands
|
|
451
382
|
.filter((command) => !found.has(command) && containsInOrder(words, command))
|
|
452
383
|
.map((command) => ({ command, merge: undefined }))
|
|
453
|
-
return [...direct, ...byWords
|
|
384
|
+
return [...direct, ...byWords]
|
|
454
385
|
}
|
|
455
386
|
|
|
456
387
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
388
|
+
* The guarded commands in the parsed script: in a program position, in the script of `sh -c`, `eval` or `trap`, in a
|
|
389
|
+
* substitution. Text the script hands to an interpreter, a shell's input, a here-document's data and the words
|
|
390
|
+
* `xargs` reads are not searched (D108).
|
|
459
391
|
*/
|
|
460
|
-
const
|
|
461
|
-
runsExpandedProgram(words) ||
|
|
462
|
-
runsOpaquely(words) ||
|
|
463
|
-
readsScriptFromElsewhere(words) ||
|
|
464
|
-
runsInlineExpansion(words) ||
|
|
465
|
-
runsInterpreterProgram(words) ||
|
|
466
|
-
definesGitAlias(words) ||
|
|
467
|
-
definesShellAlias(words)
|
|
468
|
-
|
|
469
|
-
const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
|
|
470
|
-
parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
|
|
471
|
-
|
|
472
|
-
/** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
|
|
473
|
-
const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
|
|
392
|
+
const callsIn = (script: string, search: Search, top: boolean): Finding[] => {
|
|
474
393
|
const guardedPrograms = programsOf(search.commands)
|
|
475
394
|
const parsed = parseShell(script).map((words) => resolvePlainXargs(words, guardedPrograms))
|
|
476
395
|
const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
|
|
477
|
-
|
|
478
|
-
return [
|
|
479
|
-
...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
|
|
480
|
-
...callsInScriptWide(whole, parsed, search.commands),
|
|
481
|
-
]
|
|
396
|
+
return parsed.flatMap((words) => callsInCommand(words, search, sole))
|
|
482
397
|
}
|
|
483
398
|
|
|
484
399
|
const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
|
|
@@ -498,9 +413,7 @@ const guardedListSentence = (commands: string[]): string[] =>
|
|
|
498
413
|
]
|
|
499
414
|
|
|
500
415
|
const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
|
|
501
|
-
const guarded = findings
|
|
502
|
-
.filter(({ definition, note }) => definition === undefined && note === undefined)
|
|
503
|
-
.map(({ command }) => command)
|
|
416
|
+
const guarded = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
|
|
504
417
|
const definitions = [
|
|
505
418
|
...new Map(
|
|
506
419
|
findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
|
|
@@ -514,7 +427,6 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
|
|
|
514
427
|
(definition) =>
|
|
515
428
|
`${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
|
|
516
429
|
),
|
|
517
|
-
...new Set(findings.flatMap(({ note }) => note ?? [])),
|
|
518
430
|
...notes,
|
|
519
431
|
expandingHereDocumentAdvice(script),
|
|
520
432
|
]
|
|
@@ -523,9 +435,34 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
|
|
|
523
435
|
}
|
|
524
436
|
}
|
|
525
437
|
|
|
438
|
+
/** A word of text the parse could not read: one with an expansion or a backslash may stand for any word. */
|
|
439
|
+
const roughWord = (value: string): Word => ({ value, literal: !/[$\\]/.test(value) })
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Whether text the parse could not read has a guarded program at a program position: the text is cut where a command
|
|
443
|
+
* may start (an operator, a bracket, a quote, a backtick, a line break), and a piece whose word at a program position
|
|
444
|
+
* is a guarded program counts, unless that call only reads. A split program word (`g''h`) is not found, accepted
|
|
445
|
+
* (D108): the guard catches mistakes, not a hidden intent.
|
|
446
|
+
*/
|
|
447
|
+
const namesGuardedProgram = (script: string, commands: readonly string[]): boolean => {
|
|
448
|
+
const programs = programsOf(commands)
|
|
449
|
+
return script.split(COMMAND_BOUNDARY).some((piece) => {
|
|
450
|
+
const words = piece
|
|
451
|
+
.split(BLANKS)
|
|
452
|
+
.filter((value) => value !== '')
|
|
453
|
+
.map(roughWord)
|
|
454
|
+
return words.some(
|
|
455
|
+
(word, index) =>
|
|
456
|
+
isProgramPosition(words, index) &&
|
|
457
|
+
programs.includes(programName(word)) &&
|
|
458
|
+
!commands.some((command) => isReading(words, index, command)),
|
|
459
|
+
)
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
526
463
|
/**
|
|
527
|
-
* Quotes split a word
|
|
528
|
-
*
|
|
464
|
+
* Quotes split a word, an expansion hides a command: in text the parse cannot follow, a guarded program at a program
|
|
465
|
+
* position may run with any words, so the script asks.
|
|
529
466
|
*/
|
|
530
467
|
const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
|
|
531
468
|
kind: 'ask',
|
|
@@ -540,7 +477,7 @@ const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuard
|
|
|
540
477
|
|
|
541
478
|
const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
|
|
542
479
|
try {
|
|
543
|
-
return callsIn(command, search, true
|
|
480
|
+
return callsIn(command, search, true)
|
|
544
481
|
} catch (error) {
|
|
545
482
|
if (error instanceof ShellParseError) return error
|
|
546
483
|
throw error
|
|
@@ -548,20 +485,23 @@ const findingsOrUnreadable = (command: string, search: Search): Finding[] | Shel
|
|
|
548
485
|
}
|
|
549
486
|
|
|
550
487
|
/**
|
|
551
|
-
* Decides on a Bash command before it runs: `ask` when
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
488
|
+
* Decides on a Bash command before it runs: `ask` when a guarded command (`gh pr merge` and `gh workflow run` by
|
|
489
|
+
* default) stands at a program position of the parsed script, also behind options before the verb, a chain, a wrapper,
|
|
490
|
+
* `env`, `sh -c`, `eval`, a substitution or a shell alias or function, or behind a program from a variable whose
|
|
491
|
+
* parsed words are the command's words. Only the parsed script counts (D108): text handed to an interpreter or a
|
|
492
|
+
* shell's input, a here-document's data and what `xargs` reads are not searched, and a call that only reads (`--help`,
|
|
493
|
+
* `-h`, `gh … view`, `gh … list`) passes. A script that is exactly `gh pr merge <number>` with merge-method and
|
|
555
494
|
* delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
|
|
556
|
-
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A
|
|
557
|
-
*
|
|
558
|
-
* unless the list is empty.
|
|
495
|
+
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A script the parse cannot
|
|
496
|
+
* read asks only when a guarded program stands where a command starts.
|
|
559
497
|
*/
|
|
560
498
|
export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
|
|
561
499
|
if (policy.commands.length === 0) return PASS
|
|
562
500
|
const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
|
|
563
501
|
const findings = findingsOrUnreadable(command, search)
|
|
564
|
-
if (findings instanceof ShellParseError)
|
|
502
|
+
if (findings instanceof ShellParseError) {
|
|
503
|
+
return namesGuardedProgram(command, policy.commands) ? askUnreadable(findings, command) : PASS
|
|
504
|
+
}
|
|
565
505
|
if (findings.length === 0) return PASS
|
|
566
506
|
const unapproved = findings
|
|
567
507
|
.map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// decision on stdout when the command runs a guarded one, or with nothing to leave the call to the
|
|
3
3
|
// permission rules. Any failure asks: a guard that crashes, or takes too long, must not wave the command through.
|
|
4
4
|
// The decision runs in a worker that is ended after DEADLINE_MS, because Claude Code lets a call through when a hook
|
|
5
|
-
// runs into its own timeout. argv[2]
|
|
6
|
-
//
|
|
5
|
+
// runs into its own timeout. argv[2] is the exact option label of the pull request question that approves a merge, argv[3]
|
|
6
|
+
// the one of the standing approval and argv[4] the word before its end, all rendered into the hook entry by `sync`.
|
|
7
7
|
import type { MergeGuardDecision } from './decision'
|
|
8
8
|
import { type ApprovalLabels, failureDecision } from './run'
|
|
9
9
|
|
|
@@ -35,7 +35,10 @@ const decide = async (): Promise<MergeGuardDecision> => {
|
|
|
35
35
|
try {
|
|
36
36
|
const text = await Bun.stdin.text()
|
|
37
37
|
const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
38
|
-
const labels = {
|
|
38
|
+
const labels = {
|
|
39
|
+
selfMerge: process.argv[2] ?? '',
|
|
40
|
+
standing: { approve: process.argv[3] ?? '', until: process.argv[4] ?? '' },
|
|
41
|
+
}
|
|
39
42
|
return await decideWithin({ text, labels, projectDirectory }, DEADLINE_MS)
|
|
40
43
|
} catch (error) {
|
|
41
44
|
return failureDecision(error)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan a branch belongs to: the lane whose worktree has the branch checked out, and the one plan that lane's rows
|
|
3
|
+
* link on the boards, as the main checkout holds them (the coordinator keeps its board there, committed or not). The
|
|
4
|
+
* git call runs without a shell and with a timeout; any failure answers nothing, so the guard asks.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { join, posix, relative, resolve } from 'node:path'
|
|
8
|
+
import { type BoardLanguage, lanePlansOf } from '../shared/board-lanes'
|
|
9
|
+
import { laneOf } from '../shared/lane'
|
|
10
|
+
import type { BoardSettings } from './board-settings'
|
|
11
|
+
|
|
12
|
+
/** A board's directory relative to the repository, and its text. */
|
|
13
|
+
export type Board = { directory: string; text: string }
|
|
14
|
+
|
|
15
|
+
const GIT_TIMEOUT_MS = 3000
|
|
16
|
+
|
|
17
|
+
/** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
|
|
18
|
+
export const laneInWorktreeList = (
|
|
19
|
+
worktreeList: string,
|
|
20
|
+
main: string,
|
|
21
|
+
worktreeDirectory: string,
|
|
22
|
+
branch: string,
|
|
23
|
+
): string | undefined => {
|
|
24
|
+
const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
|
|
25
|
+
const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
|
|
26
|
+
const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
|
|
27
|
+
if (path === undefined) return undefined
|
|
28
|
+
const lane = laneOf({ cwd: path, main, worktreeDirectory })
|
|
29
|
+
return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The one plan the lane's open rows link across the boards; none for a cleaned-up lane, no plan or several. */
|
|
33
|
+
export const onlyPlanOf = (boards: readonly Board[], lane: string, language: BoardLanguage): string | undefined => {
|
|
34
|
+
const rows = boards.flatMap(({ directory, text }) => lanePlansOf(text, directory, lane, language) ?? [])
|
|
35
|
+
const plans = new Set(rows.filter(({ closed }) => !closed).flatMap(({ plans }) => plans))
|
|
36
|
+
const [only] = plans
|
|
37
|
+
return plans.size === 1 ? only : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const worktreeListOf = (main: string): string | undefined => {
|
|
41
|
+
const result = Bun.spawnSync(['git', '-C', main, 'worktree', 'list', '--porcelain'], {
|
|
42
|
+
stdin: 'ignore',
|
|
43
|
+
stdout: 'pipe',
|
|
44
|
+
stderr: 'ignore',
|
|
45
|
+
timeout: GIT_TIMEOUT_MS,
|
|
46
|
+
})
|
|
47
|
+
return result.exitCode === 0 ? result.stdout.toString() : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const readTextOrUndefined = (path: string): string | undefined => {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(path, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
|
|
59
|
+
const boardsIn = (main: string, settings: BoardSettings): Board[] => {
|
|
60
|
+
const pattern = new Bun.Glob(settings.board.pattern)
|
|
61
|
+
try {
|
|
62
|
+
return readdirSync(join(main, settings.planDirectory), { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isDirectory() && pattern.match(entry.name))
|
|
64
|
+
.flatMap((entry) => {
|
|
65
|
+
const directory = posix.join(settings.planDirectory, entry.name)
|
|
66
|
+
const text = readTextOrUndefined(join(main, directory, settings.board.file))
|
|
67
|
+
return text === undefined ? [] : [{ directory, text }]
|
|
68
|
+
})
|
|
69
|
+
} catch {
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The one plan the lane of `branch` links on the boards of the main checkout; undefined when it cannot tell. */
|
|
75
|
+
export const planOfBranch = (main: string, settings: BoardSettings, branch: string): string | undefined => {
|
|
76
|
+
const list = worktreeListOf(main)
|
|
77
|
+
const lane = list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
|
|
78
|
+
return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.planLanguage)
|
|
79
|
+
}
|