jackherd 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- 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 +57 -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/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -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 +5 -1
- package/schemas/jackherd.schema.json +139 -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 +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- 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/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -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/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- 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 +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- 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/planned.ts +22 -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/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- 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 +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- 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 +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -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 +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- 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/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- 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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** The lane's merged pull request: the newest one on its branch, with the commit the merge left on the base. */
|
|
2
|
+
export type MergedPullRequest = { number: number; headCommit: string; mergeCommit: string }
|
|
3
|
+
|
|
4
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
5
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
6
|
+
|
|
7
|
+
/** How the board's lanes table names the lane: with open rows, only with rows marked cleaned up, or not at all. */
|
|
8
|
+
export const BOARD_LANE = { open: 'open', closed: 'closed', missing: 'missing' } as const
|
|
9
|
+
|
|
10
|
+
export type BoardLane = (typeof BOARD_LANE)[keyof typeof BOARD_LANE]
|
|
11
|
+
|
|
12
|
+
/** The lane's branch, the head the check compared, and its merged pull request once nothing stands against closing it. */
|
|
13
|
+
export type ClosableLane = { branch: string; head: string; pullRequest: MergedPullRequest }
|
|
14
|
+
|
|
15
|
+
/** Every reason against closing, or the lane ready to close; the second only when the first is empty. */
|
|
16
|
+
export type CloseVerdict = { kind: 'refused'; refusals: string[] } | ({ kind: 'closable' } & ClosableLane)
|
|
17
|
+
|
|
18
|
+
/** One entry of `git status --porcelain`: its two status letters (`!!` ignored, `??` untracked) and its path. */
|
|
19
|
+
export type StatusEntry = { code: string; path: string }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What git, GitHub and the terminal answered about one lane, gathered before anything is removed. `treeDiff` lists the
|
|
23
|
+
* paths in which the branch head differs from the merge commit; `status` holds the worktree's status with ignored
|
|
24
|
+
* files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `handoverEntries` every
|
|
25
|
+
* entry of the worktree's handover directory, which git shows only as the directory.
|
|
26
|
+
*/
|
|
27
|
+
export type CloseFacts = {
|
|
28
|
+
lane: string
|
|
29
|
+
boardLane: BoardLane
|
|
30
|
+
worktreeExists: boolean
|
|
31
|
+
branch: string | null
|
|
32
|
+
/** The commit the worktree's branch points to, as `git worktree list` reports it; null without a worktree. */
|
|
33
|
+
head: string | null
|
|
34
|
+
pullRequest: MergedPullRequest | null
|
|
35
|
+
treeDiff: readonly string[]
|
|
36
|
+
status: readonly StatusEntry[]
|
|
37
|
+
setupFiles: readonly string[]
|
|
38
|
+
handoverEntries: readonly string[]
|
|
39
|
+
/** The agent named after the lane whose directory lies in the worktree, with its pane. */
|
|
40
|
+
agent: { pane: string; status: string } | null
|
|
41
|
+
/** The panes of other agents whose directory lies in the worktree: a helper, or a session without the lane's name. */
|
|
42
|
+
otherAgents: readonly string[]
|
|
43
|
+
/** Whether the command itself runs in the worktree it would remove. */
|
|
44
|
+
runsInside: boolean
|
|
45
|
+
/** The reason `git worktree lock` recorded, empty for a lock without one; null when the worktree is not locked. */
|
|
46
|
+
locked: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const RESTING_AGENT = new Set<string>([AGENT_STATUS.idle, AGENT_STATUS.done])
|
|
50
|
+
const IGNORED_CODE = '!!'
|
|
51
|
+
const RENAME_OR_COPY = new Set(['R', 'C'])
|
|
52
|
+
/** A status field is two letters, a space, then the path. */
|
|
53
|
+
const STATUS_PATH_START = 3
|
|
54
|
+
const DEPENDENCY_DIRECTORY = 'node_modules'
|
|
55
|
+
const BUILD_OUTPUT_DIRECTORIES = ['dist', '.turbo', 'test-results', '__pycache__']
|
|
56
|
+
const BUILD_OUTPUT = [
|
|
57
|
+
...BUILD_OUTPUT_DIRECTORIES.flatMap((directory) => [`**/${directory}`, `**/${directory}/**`]),
|
|
58
|
+
'**/*.tsbuildinfo',
|
|
59
|
+
'packages/*/bin/*.{js,mjs,cjs}',
|
|
60
|
+
'.husky/_',
|
|
61
|
+
'.husky/_/**',
|
|
62
|
+
].map((glob) => new Bun.Glob(glob))
|
|
63
|
+
/** Build output only as a directory entry: a package's `bin/` that an ignore rule hides as a whole. */
|
|
64
|
+
const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
|
|
65
|
+
const HANDOVER = claudePath(HANDOVER_DIRECTORY)
|
|
66
|
+
|
|
67
|
+
const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
|
|
68
|
+
|
|
69
|
+
const isHandoverDirectory = (path: string): boolean => withoutSlashes(path) === HANDOVER
|
|
70
|
+
|
|
71
|
+
/** The patterns of a `.gitignore`-style list that name files; comments, blanks and negations name none. */
|
|
72
|
+
const patternsOf = (lines: readonly string[]): string[] =>
|
|
73
|
+
lines.map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether `path` (relative to the worktree, a directory with its trailing slash) is one the lane setup copies in: a
|
|
77
|
+
* pattern with an inner slash is anchored at the root, one without matches the name at any depth, as in `.gitignore`.
|
|
78
|
+
*/
|
|
79
|
+
export const isSetupFile = (path: string, setupFiles: readonly string[]): boolean => {
|
|
80
|
+
const target = withoutSlashes(path)
|
|
81
|
+
return patternsOf(setupFiles).some((pattern) => {
|
|
82
|
+
const bare = withoutSlashes(pattern)
|
|
83
|
+
const glob = bare.includes('/') ? bare : `**/${bare}`
|
|
84
|
+
return new Bun.Glob(glob).match(target) || new Bun.Glob(bare).match(target)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isDependencyPath = (path: string): boolean => withoutSlashes(path).split('/').includes(DEPENDENCY_DIRECTORY)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The entries of `git status --porcelain=v1 -z --ignored=matching`: NUL-separated, never quoted, a rename or copy followed by
|
|
92
|
+
* its source as a field of its own, which is skipped. Names are read as git wrote them, so no file name can pose as
|
|
93
|
+
* another path.
|
|
94
|
+
*/
|
|
95
|
+
export const statusEntriesOf = (output: string): StatusEntry[] => {
|
|
96
|
+
const fields = output.split('\0')
|
|
97
|
+
const entries: StatusEntry[] = []
|
|
98
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
99
|
+
const field = fields[i]
|
|
100
|
+
if (field.length < STATUS_PATH_START) continue
|
|
101
|
+
const code = field.slice(0, 2)
|
|
102
|
+
entries.push({ code, path: field.slice(STATUS_PATH_START) })
|
|
103
|
+
if (RENAME_OR_COPY.has(code[0])) i += 1
|
|
104
|
+
}
|
|
105
|
+
return entries
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether `path` (relative to the worktree, a directory with its trailing slash) is output a build makes again, from a
|
|
110
|
+
* fixed list: `dist`, `.turbo`, `test-results` and `__pycache__` at any depth, a `*.tsbuildinfo`, a
|
|
111
|
+
* `packages/*\/bin/*.{js,mjs,cjs}` or a whole `packages/*\/bin/`, and husky's helpers in `.husky/_`. Everything else an
|
|
112
|
+
* ignore rule hides may hold work and is not.
|
|
113
|
+
*/
|
|
114
|
+
export const isBuildOutput = (path: string): boolean => {
|
|
115
|
+
const target = withoutSlashes(path)
|
|
116
|
+
const directory = path.endsWith('/') && BUILD_OUTPUT_DIRECTORY_ENTRIES.some((glob) => glob.match(target))
|
|
117
|
+
return directory || BUILD_OUTPUT.some((glob) => glob.match(target))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
|
|
122
|
+
* path except dependencies (`node_modules` at any depth), the setup files, build output and the handover directory.
|
|
123
|
+
*/
|
|
124
|
+
export const lostPathsOf = (status: readonly StatusEntry[], setupFiles: readonly string[]): string[] => {
|
|
125
|
+
const kept = (path: string) =>
|
|
126
|
+
isDependencyPath(path) || isSetupFile(path, setupFiles) || isBuildOutput(path) || isHandoverDirectory(path)
|
|
127
|
+
return status.filter(({ code, path }) => code !== IGNORED_CODE || !kept(path)).map(({ path }) => path)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The lost paths and the entries of the handover directory, each once: the directory stands for all of them. */
|
|
131
|
+
const deletedPathsOf = ({ status, setupFiles, handoverEntries }: CloseFacts): string[] => [
|
|
132
|
+
...new Set([...lostPathsOf(status, setupFiles), ...handoverEntries]),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
/** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
|
|
136
|
+
const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
|
|
137
|
+
boardLane === BOARD_LANE.missing ? [`the board has no row for lane ${lane}`] : []
|
|
138
|
+
|
|
139
|
+
const mergeRefusals = ({ lane, branch, pullRequest, treeDiff }: CloseFacts): string[] => {
|
|
140
|
+
if (branch === null) return [`the worktree of ${lane} is on no branch`]
|
|
141
|
+
if (pullRequest === null) return [`branch ${branch} has no merged pull request`]
|
|
142
|
+
return treeDiff.map(
|
|
143
|
+
(path) => `branch ${branch} differs from the merge of #${pullRequest.number} (${pullRequest.mergeCommit}): ${path}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const agentRefusals = ({ lane, agent, otherAgents }: CloseFacts): string[] => [
|
|
148
|
+
...(agent === null || RESTING_AGENT.has(agent.status) ? [] : [`the agent of ${lane} is ${agent.status}`]),
|
|
149
|
+
...otherAgents.map((pane) => `pane ${pane} runs another agent in the worktree of ${lane}`),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
|
|
153
|
+
locked === null
|
|
154
|
+
? []
|
|
155
|
+
: [`the worktree of ${lane} is locked${locked ? ` (${locked})` : ''}; unlock it once its session has ended`]
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every reason not to close the lane, all at once; empty when nothing would be lost. Closing needs an open board row,
|
|
159
|
+
* the worktree on a branch whose newest merged pull request left the same tree on the base (a squash merge leaves the
|
|
160
|
+
* branch unmerged in git's eyes, so the trees are compared), nothing in the worktree beyond dependencies, the setup
|
|
161
|
+
* files and build output (`isBuildOutput`), an empty handover directory, an agent that is gone or rests, and no lock
|
|
162
|
+
* on the worktree (a Claude Code session may still hold it).
|
|
163
|
+
*/
|
|
164
|
+
export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
165
|
+
if (!facts.worktreeExists) return [...boardRefusals(facts), `lane ${facts.lane} has no worktree`]
|
|
166
|
+
if (facts.runsInside) return [`run it outside the worktree of ${facts.lane}, which it would remove`]
|
|
167
|
+
return [
|
|
168
|
+
...boardRefusals(facts),
|
|
169
|
+
...mergeRefusals(facts),
|
|
170
|
+
...deletedPathsOf(facts).map((path) => `removing the worktree deletes ${path}`),
|
|
171
|
+
...agentRefusals(facts),
|
|
172
|
+
...lockRefusals(facts),
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `closeRefusals` as a verdict that carries the branch and pull request it proved, so a caller needs no second check. */
|
|
177
|
+
export const closeVerdict = (facts: CloseFacts): CloseVerdict => {
|
|
178
|
+
const refusals = closeRefusals(facts)
|
|
179
|
+
const { branch, head, pullRequest } = facts
|
|
180
|
+
if (refusals.length > 0 || branch === null || head === null || pullRequest === null)
|
|
181
|
+
return { kind: 'refused', refusals }
|
|
182
|
+
return { kind: 'closable', branch, head, pullRequest }
|
|
183
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
3
|
+
|
|
4
|
+
/** The command as the usage, the board's history and the watcher's status line name it. */
|
|
5
|
+
export const LANE_CLOSE_COMMAND = `${CLI_NAME} lane close`
|
|
6
|
+
|
|
7
|
+
export const DRY_RUN_OPTION = '--dry-run'
|
|
8
|
+
|
|
9
|
+
/** The dry run the watcher suggests for a lane; null for a name no command line should carry. */
|
|
10
|
+
export const closeDryRunOf = (lane: string): string | null =>
|
|
11
|
+
SAFE_LANE_NAME.test(lane) ? `${LANE_CLOSE_COMMAND} ${lane} ${DRY_RUN_OPTION}` : null
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { TERMINAL_ADAPTER } from '../config'
|
|
5
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
6
|
+
import { boardFor } from '../plans/board-pick'
|
|
7
|
+
import { commitOf, mainCheckout, worktreeRoot } from '../plans/git'
|
|
8
|
+
import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
|
|
9
|
+
import { asksForHelp } from '../preview/options'
|
|
10
|
+
import { laneStatusSourceFor } from '../terminal'
|
|
11
|
+
import { runHerdr } from '../terminal/herdr'
|
|
12
|
+
import { runGh } from '../watch/gh'
|
|
13
|
+
import { parseWatchedPullRequests, WATCHED_PULL_REQUEST_FIELDS } from '../watch/pull-requests'
|
|
14
|
+
import { type ClosePorts, type CloseRequest, type CloseSurvey, closeLane } from './close'
|
|
15
|
+
import { boardLaneOf } from './close-board'
|
|
16
|
+
import { type CloseFacts, type MergedPullRequest, statusEntriesOf } from './close-check'
|
|
17
|
+
import { type CloseOptions, LANE_CLOSE_USAGE, parseCloseOptions } from './close-options'
|
|
18
|
+
import { agentFactsOf, mergedPullRequestOf, openWorkspaceOf, type WorktreeEntry, worktreeEntryOf } from './close-survey'
|
|
19
|
+
|
|
20
|
+
/** Where the command runs and what it acts on, located once. */
|
|
21
|
+
type CloseContext = { root: string; cwd: string; settings: PlansSettings; worktree: string; boardFile: string }
|
|
22
|
+
|
|
23
|
+
/** The facts that only a worktree git lists has. */
|
|
24
|
+
type WorktreeFacts = Pick<
|
|
25
|
+
CloseFacts,
|
|
26
|
+
'worktreeExists' | 'branch' | 'head' | 'pullRequest' | 'treeDiff' | 'status' | 'handoverEntries' | 'locked'
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
/** The file the lane setup copies into every new worktree, in `.gitignore` syntax. */
|
|
30
|
+
const SETUP_FILES = '.worktreeinclude'
|
|
31
|
+
const GH_TIMEOUT_MS = 30_000
|
|
32
|
+
const PULL_REQUEST_LIMIT = 20
|
|
33
|
+
const EXIT_WAIT_MS = 60_000
|
|
34
|
+
const EXIT_POLL_MS = 1_000
|
|
35
|
+
const EXIT_COMMAND = '/exit'
|
|
36
|
+
const BRANCH_REF = 'refs/heads/'
|
|
37
|
+
/**
|
|
38
|
+
* Ignored paths as an ignore pattern names them: a directory whose files are all ignored by a file pattern
|
|
39
|
+
* (`packages/*\/bin/*.js`) shows as those files, not folded into a directory no pattern names, so the build output
|
|
40
|
+
* list can judge each one.
|
|
41
|
+
*/
|
|
42
|
+
const IGNORED_MATCHING = '--ignored=matching'
|
|
43
|
+
/** Untracked files are listed whatever `status.showUntrackedFiles` says, so a project setting cannot hide one. */
|
|
44
|
+
const ALL_UNTRACKED = '--untracked-files=all'
|
|
45
|
+
|
|
46
|
+
const ABSENT_WORKTREE: WorktreeFacts = {
|
|
47
|
+
worktreeExists: false,
|
|
48
|
+
branch: null,
|
|
49
|
+
head: null,
|
|
50
|
+
pullRequest: null,
|
|
51
|
+
treeDiff: [],
|
|
52
|
+
status: [],
|
|
53
|
+
handoverEntries: [],
|
|
54
|
+
locked: null,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Runs git and returns its stdout; throws with git's own error text. */
|
|
58
|
+
const runGit = (cwd: string, args: string[]): string => {
|
|
59
|
+
const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
60
|
+
if (result.exitCode !== 0)
|
|
61
|
+
throw new Error(`git ${args[0]}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`)
|
|
62
|
+
return result.stdout.toString()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const realPath = (path: string): string => (existsSync(path) ? realpathSync(path) : path)
|
|
66
|
+
|
|
67
|
+
const linesOf = (text: string): string[] => text.split('\n').filter((line) => line.trim() !== '')
|
|
68
|
+
|
|
69
|
+
const setupFilesOf = (root: string): string[] => {
|
|
70
|
+
const file = join(root, SETUP_FILES)
|
|
71
|
+
return existsSync(file) ? readFileSync(file, 'utf8').split('\n') : []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The paths in which the branch head differs from the merge commit; fetches the base once when the commit is missing. */
|
|
75
|
+
const treeDiffOf = (context: CloseContext, head: string, mergeCommit: string): string[] => {
|
|
76
|
+
if (commitOf(context.root, mergeCommit) === null)
|
|
77
|
+
runGit(context.root, ['fetch', '--quiet', 'origin', context.settings.baseBranch])
|
|
78
|
+
return linesOf(runGit(context.root, ['diff', '--name-only', '--end-of-options', head, mergeCommit]))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every entry of the worktree's handover directory, a folder with its trailing slash, as git names paths. */
|
|
82
|
+
const handoverEntriesOf = (worktree: string): string[] => {
|
|
83
|
+
const directory = claudePath(HANDOVER_DIRECTORY)
|
|
84
|
+
const absolute = join(worktree, directory)
|
|
85
|
+
if (!existsSync(absolute)) return []
|
|
86
|
+
return readdirSync(absolute, { withFileTypes: true })
|
|
87
|
+
.map((entry) => `${directory}/${entry.name}${entry.isDirectory() ? '/' : ''}`)
|
|
88
|
+
.sort()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mergedPullRequestOn = async (context: CloseContext, branch: string): Promise<MergedPullRequest | null> => {
|
|
92
|
+
const fields = ['--json', WATCHED_PULL_REQUEST_FIELDS, '--limit', String(PULL_REQUEST_LIMIT)]
|
|
93
|
+
const answer = await runGh(['pr', 'list', '--head', branch, '--state', 'merged', ...fields], context.root, {
|
|
94
|
+
timeoutMs: GH_TIMEOUT_MS,
|
|
95
|
+
})
|
|
96
|
+
if (!answer.ok) throw new Error(answer.error)
|
|
97
|
+
return mergedPullRequestOf(parseWatchedPullRequests(answer.stdout), branch)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Branch, merged pull request, tree difference, status, handover files and lock of the worktree git lists for the lane. */
|
|
101
|
+
const worktreeFactsOf = async (context: CloseContext, entry: WorktreeEntry): Promise<WorktreeFacts> => {
|
|
102
|
+
const pullRequest = entry.branch === null ? null : await mergedPullRequestOn(context, entry.branch)
|
|
103
|
+
return {
|
|
104
|
+
worktreeExists: true,
|
|
105
|
+
branch: entry.branch,
|
|
106
|
+
head: entry.head,
|
|
107
|
+
pullRequest,
|
|
108
|
+
treeDiff: pullRequest === null ? [] : treeDiffOf(context, entry.head, pullRequest.mergeCommit),
|
|
109
|
+
status: statusEntriesOf(
|
|
110
|
+
runGit(context.worktree, ['status', '--porcelain=v1', '-z', IGNORED_MATCHING, ALL_UNTRACKED]),
|
|
111
|
+
),
|
|
112
|
+
handoverEntries: handoverEntriesOf(context.worktree),
|
|
113
|
+
locked: entry.locked,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const workspaceOf = (context: CloseContext): string | null =>
|
|
118
|
+
context.settings.adapter === TERMINAL_ADAPTER.herdr
|
|
119
|
+
? openWorkspaceOf(runHerdr(['worktree', 'list', '--cwd', context.root]), context.worktree)
|
|
120
|
+
: null
|
|
121
|
+
|
|
122
|
+
/** What git, GitHub and the terminal say about the lane now; throws when one of them cannot be asked. */
|
|
123
|
+
const surveyOf = async (lane: string, context: CloseContext): Promise<CloseSurvey> => {
|
|
124
|
+
const entry = worktreeEntryOf(runGit(context.root, ['worktree', 'list', '--porcelain']), context.worktree)
|
|
125
|
+
const present = entry !== null && existsSync(context.worktree)
|
|
126
|
+
const facts: CloseFacts = {
|
|
127
|
+
lane,
|
|
128
|
+
boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.planLanguage),
|
|
129
|
+
setupFiles: setupFilesOf(context.root),
|
|
130
|
+
...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
|
|
131
|
+
runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
|
|
132
|
+
...(present ? await worktreeFactsOf(context, entry) : ABSENT_WORKTREE),
|
|
133
|
+
}
|
|
134
|
+
return { facts, workspace: present ? workspaceOf(context) : null }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms))
|
|
138
|
+
|
|
139
|
+
/** Sends `/exit` to the agent in `pane` and waits until the terminal lists no session in that pane. */
|
|
140
|
+
const exitAgentIn =
|
|
141
|
+
(context: CloseContext) =>
|
|
142
|
+
async (pane: string): Promise<void> => {
|
|
143
|
+
if (context.settings.adapter !== TERMINAL_ADAPTER.herdr) throw new Error('only herdr can end an agent')
|
|
144
|
+
runHerdr(['agent', 'prompt', pane, EXIT_COMMAND])
|
|
145
|
+
const source = laneStatusSourceFor(context.settings.adapter)
|
|
146
|
+
for (let waited = 0; waited < EXIT_WAIT_MS; waited += EXIT_POLL_MS) {
|
|
147
|
+
await sleep(EXIT_POLL_MS)
|
|
148
|
+
const agent = source.listAgents().find((listed) => listed.pane === pane)
|
|
149
|
+
if (agent === undefined || agent.session === null) return
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`the agent in pane ${pane} still runs after ${EXIT_WAIT_MS / 1000} s`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Git removes the worktree and refuses on any change itself; herdr then only closes the emptied workspace. */
|
|
155
|
+
const removeWorktreeIn =
|
|
156
|
+
(context: CloseContext) =>
|
|
157
|
+
(worktree: string, workspace: string | null): void => {
|
|
158
|
+
runGit(context.root, ['worktree', 'remove', worktree])
|
|
159
|
+
if (workspace !== null) runHerdr(['workspace', 'close', workspace])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Deletes the branch only while it points to `head`; git refuses when it moved since the check. */
|
|
163
|
+
const deleteBranchIn =
|
|
164
|
+
(context: CloseContext) =>
|
|
165
|
+
(branch: string, head: string): void => {
|
|
166
|
+
runGit(context.root, ['update-ref', '-d', `${BRANCH_REF}${branch}`, head])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const portsFor = (lane: string, context: CloseContext): ClosePorts => ({
|
|
170
|
+
survey: () => surveyOf(lane, context),
|
|
171
|
+
exitAgent: exitAgentIn(context),
|
|
172
|
+
removeWorktree: removeWorktreeIn(context),
|
|
173
|
+
deleteBranch: deleteBranchIn(context),
|
|
174
|
+
readBoard: () => readFileSync(context.boardFile, 'utf8'),
|
|
175
|
+
writeBoard: (text) => writeFileSync(context.boardFile, text),
|
|
176
|
+
print: (line) => console.log(terminalSafe(line)),
|
|
177
|
+
printError: (line) => console.error(terminalSafe(line)),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/** The main checkout, its settings, the lane's worktree from those settings, and the board, with the pick's notice. */
|
|
181
|
+
const contextOf = (options: CloseOptions, cwd: string): { context: CloseContext; notice: string | null } => {
|
|
182
|
+
const root = mainCheckout(cwd)
|
|
183
|
+
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
184
|
+
const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
|
|
185
|
+
const board = boardFor(boardPath, [root], settings, () => [options.lane])
|
|
186
|
+
const worktree = worktreeOf(root, settings, options.lane)
|
|
187
|
+
return { context: { root, cwd, settings, worktree, boardFile: board.file }, notice: board.notice }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `jh lane close <lane> [--dry-run] [--board <board.md>]`: closes a lane in one run once nothing would be lost, see
|
|
192
|
+
* `closeLane`. The worktree's path comes from the settings and the lane's name, never from a reply. Exit 0 closed or
|
|
193
|
+
* dry run, 1 refused or failed.
|
|
194
|
+
*/
|
|
195
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
196
|
+
if (asksForHelp(args)) {
|
|
197
|
+
console.log(LANE_CLOSE_USAGE)
|
|
198
|
+
return 0
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const options = parseCloseOptions(args)
|
|
202
|
+
const { context, notice } = contextOf(options, cwd)
|
|
203
|
+
if (notice) console.error(terminalSafe(notice))
|
|
204
|
+
const request: CloseRequest = {
|
|
205
|
+
lane: options.lane,
|
|
206
|
+
worktree: context.worktree,
|
|
207
|
+
boardPath: relative(context.root, context.boardFile),
|
|
208
|
+
dryRun: options.dryRun,
|
|
209
|
+
now: new Date(),
|
|
210
|
+
language: context.settings.planLanguage,
|
|
211
|
+
}
|
|
212
|
+
return await closeLane(request, portsFor(options.lane, context))
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
215
|
+
return 1
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
3
|
+
import { DRY_RUN_OPTION, LANE_CLOSE_COMMAND } from './close-command-line'
|
|
4
|
+
|
|
5
|
+
/** What `jh lane close` was asked for; `boardPath` is null where `--board` was left out. */
|
|
6
|
+
export type CloseOptions = { lane: string; dryRun: boolean; boardPath: string | null }
|
|
7
|
+
|
|
8
|
+
const BOARD = '--board'
|
|
9
|
+
|
|
10
|
+
export const LANE_CLOSE_USAGE = `usage: ${LANE_CLOSE_COMMAND} <lane> [${DRY_RUN_OPTION}] [--board <board.md>]
|
|
11
|
+
|
|
12
|
+
Closes a lane whose pull request is merged, once nothing in it would be lost: exits its agent, removes its workspace,
|
|
13
|
+
worktree and local branch, and marks its rows in the board with a history row. Refuses with every reason otherwise.
|
|
14
|
+
--dry-run check and print the steps, change nothing
|
|
15
|
+
--board <board.md> the board to mark; without it the board is picked as for ${CLI_NAME} herd`
|
|
16
|
+
|
|
17
|
+
const usageError = (message: string): Error => new Error(`${message}\n${LANE_CLOSE_USAGE}`)
|
|
18
|
+
|
|
19
|
+
const laneOf = (words: readonly string[]): string => {
|
|
20
|
+
if (words.length === 0) throw usageError('the lane is required')
|
|
21
|
+
if (words.length > 1) throw usageError(`one lane at most, got ${words.join(' ')}`)
|
|
22
|
+
if (!SAFE_LANE_NAME.test(words[0])) throw usageError(`${words[0]} is no lane name (letters, digits, . _ -)`)
|
|
23
|
+
return words[0]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reads `<lane> [--dry-run] [--board <file>]` in any order, the arguments after `close`; throws on a wrong line. */
|
|
27
|
+
export const parseCloseOptions = (args: readonly string[]): CloseOptions => {
|
|
28
|
+
const words: string[] = []
|
|
29
|
+
let dryRun = false
|
|
30
|
+
let boardPath: string | null = null
|
|
31
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
32
|
+
const arg = args[i]
|
|
33
|
+
if (arg === DRY_RUN_OPTION) dryRun = true
|
|
34
|
+
else if (arg === BOARD) {
|
|
35
|
+
const value = args[i + 1]
|
|
36
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`${BOARD} needs a value`)
|
|
37
|
+
boardPath = value
|
|
38
|
+
i += 1
|
|
39
|
+
} else if (arg.startsWith('--')) throw usageError(`unknown option ${arg}`)
|
|
40
|
+
else words.push(arg)
|
|
41
|
+
}
|
|
42
|
+
return { lane: laneOf(words), dryRun, boardPath }
|
|
43
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { sep } from 'node:path'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
import type { LaneAgent } from '../terminal'
|
|
4
|
+
import { PULL_REQUEST_STATE, type WatchedPullRequest } from '../watch/pull-requests'
|
|
5
|
+
import type { CloseFacts, MergedPullRequest } from './close-check'
|
|
6
|
+
|
|
7
|
+
/** A worktree as `git worktree list --porcelain` lists it; `branch` null when detached, `locked` as in `CloseFacts`. */
|
|
8
|
+
export type WorktreeEntry = { head: string; branch: string | null; locked: string | null }
|
|
9
|
+
|
|
10
|
+
const BRANCH_REF = 'refs/heads/'
|
|
11
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
12
|
+
|
|
13
|
+
const fieldOf = (lines: readonly string[], key: string): string | null => {
|
|
14
|
+
const line = lines.find((entry) => entry === key || entry.startsWith(`${key} `))
|
|
15
|
+
return line === undefined ? null : line.slice(key.length + 1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The entry of the worktree at `path` in `git worktree list --porcelain`; null when git lists none there. */
|
|
19
|
+
export const worktreeEntryOf = (porcelain: string, path: string): WorktreeEntry | null => {
|
|
20
|
+
const entry = porcelain
|
|
21
|
+
.split('\n\n')
|
|
22
|
+
.map((block) => block.split('\n').filter(Boolean))
|
|
23
|
+
.find((lines) => fieldOf(lines, 'worktree') === path)
|
|
24
|
+
if (entry === undefined) return null
|
|
25
|
+
const branch = fieldOf(entry, 'branch')
|
|
26
|
+
return {
|
|
27
|
+
head: fieldOf(entry, 'HEAD') ?? '',
|
|
28
|
+
branch: branch?.startsWith(BRANCH_REF) ? branch.slice(BRANCH_REF.length) : null,
|
|
29
|
+
locked: fieldOf(entry, 'locked'),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type HerdrWorktree = { path: string; open_workspace_id?: string }
|
|
34
|
+
|
|
35
|
+
const isHerdrWorktree = (value: unknown): value is HerdrWorktree =>
|
|
36
|
+
isRecord(value) &&
|
|
37
|
+
typeof value.path === 'string' &&
|
|
38
|
+
(value.open_workspace_id === undefined || typeof value.open_workspace_id === 'string')
|
|
39
|
+
|
|
40
|
+
/** The herdr workspace the worktree at `path` is open in, from `herdr worktree list`; null when it is in none. */
|
|
41
|
+
export const openWorkspaceOf = (json: string, path: string): string | null => {
|
|
42
|
+
const result = (JSON.parse(json) as { result?: { worktrees?: unknown } }).result
|
|
43
|
+
const worktrees = result?.worktrees
|
|
44
|
+
if (!Array.isArray(worktrees) || !worktrees.every(isHerdrWorktree))
|
|
45
|
+
throw new Error('herdr worktree list: unexpected answer')
|
|
46
|
+
return worktrees.find((worktree) => worktree.path === path)?.open_workspace_id ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A full git object id, SHA-1 or SHA-256; anything else from GitHub never reaches a git command line. */
|
|
50
|
+
export const isObjectId = (value: string): boolean => OBJECT_ID.test(value)
|
|
51
|
+
|
|
52
|
+
/** The newest merged pull request on `branch`, by number; null when none was merged with a well-formed merge commit. */
|
|
53
|
+
export const mergedPullRequestOf = (
|
|
54
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
55
|
+
branch: string,
|
|
56
|
+
): MergedPullRequest | null => {
|
|
57
|
+
const merged = pullRequests
|
|
58
|
+
.filter((pullRequest) => pullRequest.branch === branch && pullRequest.state === PULL_REQUEST_STATE.merged)
|
|
59
|
+
.flatMap(({ number, headCommit, mergeCommit }) =>
|
|
60
|
+
mergeCommit === null || !isObjectId(mergeCommit) ? [] : [{ number, headCommit, mergeCommit }],
|
|
61
|
+
)
|
|
62
|
+
return merged.sort((left, right) => right.number - left.number)[0] ?? null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isInside = (cwd: string | null, worktree: string): boolean =>
|
|
66
|
+
cwd !== null && (cwd === worktree || cwd.startsWith(`${worktree}${sep}`))
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The lane's agent, by its name and a directory in the worktree, so an agent of that name elsewhere is never ended;
|
|
70
|
+
* and the panes of every other agent in the worktree.
|
|
71
|
+
*/
|
|
72
|
+
export const agentFactsOf = (
|
|
73
|
+
agents: readonly LaneAgent[],
|
|
74
|
+
lane: string,
|
|
75
|
+
worktree: string,
|
|
76
|
+
): Pick<CloseFacts, 'agent' | 'otherAgents'> => {
|
|
77
|
+
const inWorktree = agents.filter(({ cwd }) => isInside(cwd, worktree))
|
|
78
|
+
const own = inWorktree.find(({ name }) => name === lane)
|
|
79
|
+
return {
|
|
80
|
+
agent: own === undefined ? null : { pane: own.pane, status: own.status },
|
|
81
|
+
otherAgents: inWorktree.filter((agent) => agent !== own).map(({ pane }) => pane),
|
|
82
|
+
}
|
|
83
|
+
}
|