@joshuafolkken/kit 1.1229.0 → 1.1240.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/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
- package/.claude/skills/workflow-commands/followup.md +5 -0
- package/.claude/skills/workflow-commands/kickoff.md +1 -0
- package/.claude/skills/workflow-commands/latest-gate.md +1 -1
- package/.github/workflows/publish.yml +2 -0
- package/README.md +30 -31
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
- package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
- package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
- package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
- package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
- package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
- package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
- package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
- package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
- package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +172 -170
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +3 -17
- package/dist/josh.js +279 -63
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -0
- package/scripts/gate/core-admission.ts +162 -0
- package/scripts/gate/core-budget.ts +121 -67
- package/scripts/gate/gate-plan.ts +35 -2
- package/scripts/gate/gate-report.ts +28 -9
- package/scripts/gate/machine-capacity.ts +175 -0
- package/scripts/gate/verification-gate.ts +7 -2
- package/scripts/init/init.ts +5 -1
- package/scripts/init/package-manager-pin.ts +35 -0
- package/scripts/issue/issue-auto-ok.ts +31 -6
- package/scripts/issue/issue-file-cli.ts +31 -8
- package/scripts/issue/issue-file.ts +5 -0
- package/scripts/issue/issue-labels.ts +13 -0
- package/scripts/issue/issue-release-cli.ts +155 -0
- package/scripts/issue/issue-release.ts +58 -0
- package/scripts/josh/josh-command-types.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +8 -0
- package/scripts/josh/josh-commands-issue.ts +8 -1
- package/scripts/josh/josh-commands-lane.ts +2 -1
- package/scripts/josh/josh-commands-maintenance.ts +2 -2
- package/scripts/josh/josh-logic.ts +4 -3
- package/scripts/lane/lane-cli.ts +9 -1
- package/scripts/lane/lane-leftover.ts +43 -29
- package/scripts/lane/lane-open.ts +5 -5
- package/scripts/lane/lane-paths.ts +13 -0
- package/scripts/lane/lane-registry.ts +11 -0
- package/scripts/lane/lane-stray.ts +140 -0
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release-runs.ts +115 -0
- package/scripts/release/github-release.ts +13 -67
- package/scripts/run/cut/run-cut-cli.ts +16 -3
- package/scripts/run/event/run-event-render.ts +1 -0
- package/scripts/run/event/run-event-stream.ts +4 -0
- package/scripts/run/run-step.ts +5 -2
- package/scripts/run/wake/run-wake-cli.ts +2 -3
- package/scripts/run/wake/run-wake-describe.ts +56 -5
- package/scripts/run/wake/run-wake-loop.ts +5 -13
- package/scripts/run/wake/run-wake.ts +15 -64
- package/scripts/version/latest-corepack.ts +109 -78
- package/templates/workflows/github-release.yml +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DEPTH_0_LABEL, RELEASE_LABEL } from './issue-labels'
|
|
2
|
+
|
|
3
|
+
// The decisions behind `josh issue:release` (joshuafolkken/kit#3360), kept apart from the command
|
|
4
|
+
// that reads the network so each is a pure answer a test can pin. An Issue whose change reaches a
|
|
5
|
+
// consumer only once published used to surface the release only at the end of `followup`'s report;
|
|
6
|
+
// it is now linked to the repository's release Issue, which it blocks until it merges.
|
|
7
|
+
|
|
8
|
+
interface ListingRow {
|
|
9
|
+
number: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function is_listing_row(value: unknown): value is ListingRow {
|
|
13
|
+
if (typeof value !== 'object' || value === null) return false
|
|
14
|
+
|
|
15
|
+
return Number.isSafeInteger(Reflect.get(value, 'number'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The open release Issue a listing of `release`-labelled Issues names, or `undefined` when it names
|
|
19
|
+
// none or is unreadable. One release publishes every merge since the last, so a repository holds one
|
|
20
|
+
// release Issue at a time; were a person to open a second, the oldest is the one the next release
|
|
21
|
+
// closes, so it is the one every new blocker joins.
|
|
22
|
+
function open_release_of(json: string): number | undefined {
|
|
23
|
+
try {
|
|
24
|
+
const parsed: unknown = JSON.parse(json)
|
|
25
|
+
const rows = Array.isArray(parsed) ? parsed.filter((row) => is_listing_row(row)) : []
|
|
26
|
+
|
|
27
|
+
return rows.length === 0 ? undefined : Math.min(...rows.map((row) => row.number))
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function title_of(repo: string): string {
|
|
34
|
+
return `Release ${repo} (next version)`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Plain English with no classification declaration: the Issue is not filed through `issue:file`, and
|
|
38
|
+
// its labels are `RELEASE_LABELS` rather than read off the body.
|
|
39
|
+
function body_of(repo: string): string {
|
|
40
|
+
return [
|
|
41
|
+
`Tracks the next release of ${repo}.`,
|
|
42
|
+
'',
|
|
43
|
+
'Every Issue this one is blocked by changes something a consumer can use only once it is published. Once they have merged, a person runs `pnpm josh release` in the primary checkout.',
|
|
44
|
+
'',
|
|
45
|
+
'Close this Issue once that release is published: the next blocker files a new one, so an open release Issue names only unreleased work.',
|
|
46
|
+
'',
|
|
47
|
+
'`auto-ok` is never applied to this Issue by a run; a person may apply it.',
|
|
48
|
+
'',
|
|
49
|
+
].join('\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The release label and a depth: the subject of a release is what a consumer touches.
|
|
53
|
+
// Never `auto-ok`.
|
|
54
|
+
const RELEASE_LABELS: ReadonlyArray<string> = [RELEASE_LABEL, DEPTH_0_LABEL]
|
|
55
|
+
|
|
56
|
+
const issue_release = { RELEASE_LABELS, open_release_of, title_of, body_of }
|
|
57
|
+
|
|
58
|
+
export { issue_release }
|
|
@@ -71,6 +71,9 @@ interface CommandEntry {
|
|
|
71
71
|
// under a second, so a wait for room on the machine would stall every edit for the length of
|
|
72
72
|
// another lane's check.
|
|
73
73
|
core_weight?: number | (() => number)
|
|
74
|
+
// The memory the command's tools hold, claimed with its cores (joshuafolkken/kit#3371); read like
|
|
75
|
+
// `core_weight`, and none declared is none waited for.
|
|
76
|
+
memory_mb?: number | (() => number)
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// The name `josh gate` registers under. It lives here rather than in `verification-gate.ts` so a
|
|
@@ -28,6 +28,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
28
28
|
category: 'Development',
|
|
29
29
|
reference: ['', 'developer', ['processes']],
|
|
30
30
|
core_weight: core_budget.CORE_WEIGHTS.lint,
|
|
31
|
+
memory_mb: core_budget.MEMORY_MB.lint,
|
|
31
32
|
},
|
|
32
33
|
'lint:related': {
|
|
33
34
|
script: 'scripts/lint/lint-related.ts',
|
|
@@ -35,6 +36,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
35
36
|
category: 'Development',
|
|
36
37
|
reference: [FILE_ARGUMENTS, 'developer', ['processes']],
|
|
37
38
|
core_weight: core_budget.CORE_WEIGHTS.lint,
|
|
39
|
+
memory_mb: core_budget.MEMORY_MB.lint,
|
|
38
40
|
},
|
|
39
41
|
lines: {
|
|
40
42
|
script: 'scripts/lines/lines-command.ts',
|
|
@@ -42,6 +44,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
42
44
|
category: 'Development',
|
|
43
45
|
reference: [REQUIRED_FILE_ARGUMENTS, 'developer', ['none']],
|
|
44
46
|
core_weight: core_budget.CORE_WEIGHTS.eslint_scan,
|
|
47
|
+
memory_mb: core_budget.MEMORY_MB.eslint_scan,
|
|
45
48
|
},
|
|
46
49
|
'refactor:scan': {
|
|
47
50
|
script: 'scripts/refactor/refactor-scan-cli.ts',
|
|
@@ -50,6 +53,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
50
53
|
category: 'Development',
|
|
51
54
|
reference: ['', 'automation', ['processes']],
|
|
52
55
|
core_weight: core_budget.CORE_WEIGHTS.eslint_scan,
|
|
56
|
+
memory_mb: core_budget.MEMORY_MB.eslint_scan,
|
|
53
57
|
},
|
|
54
58
|
bytes: {
|
|
55
59
|
script: 'scripts/lines/bytes-command.ts',
|
|
@@ -137,6 +141,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
137
141
|
category: 'Development',
|
|
138
142
|
reference: ['', 'developer', ['processes']],
|
|
139
143
|
core_weight: core_budget.CORE_WEIGHTS.spell_check,
|
|
144
|
+
memory_mb: core_budget.MEMORY_MB.spell_check,
|
|
140
145
|
},
|
|
141
146
|
behavior: {
|
|
142
147
|
script: 'scripts/behavior/behavior-cli.ts',
|
|
@@ -159,6 +164,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
159
164
|
category: 'Development',
|
|
160
165
|
reference: [FILTER_ARGUMENTS, 'developer', ['processes']],
|
|
161
166
|
core_weight: gate_plan.direct_unit_weight,
|
|
167
|
+
memory_mb: gate_plan.direct_unit_memory,
|
|
162
168
|
},
|
|
163
169
|
'test:related': {
|
|
164
170
|
script: 'scripts/test/test-related.ts',
|
|
@@ -166,6 +172,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
166
172
|
category: 'Development',
|
|
167
173
|
reference: [FILE_ARGUMENTS, 'developer', ['processes']],
|
|
168
174
|
core_weight: gate_plan.direct_unit_weight,
|
|
175
|
+
memory_mb: gate_plan.direct_unit_memory,
|
|
169
176
|
},
|
|
170
177
|
'test:declared': {
|
|
171
178
|
script: 'scripts/test/test-declared.ts',
|
|
@@ -213,6 +220,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
213
220
|
category: 'Development',
|
|
214
221
|
reference: ['[arguments...]', 'developer', ['processes']],
|
|
215
222
|
core_weight: core_budget.CORE_WEIGHTS.type_check,
|
|
223
|
+
memory_mb: core_budget.MEMORY_MB.type_check,
|
|
216
224
|
},
|
|
217
225
|
port: {
|
|
218
226
|
script: 'scripts/ports/port-command.ts',
|
|
@@ -69,11 +69,18 @@ const ISSUE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
69
69
|
'File an Issue with every filing step: lint, Origin, WIP cap, duplicate scout, labels, then epic:bundle',
|
|
70
70
|
category: 'AI tools',
|
|
71
71
|
reference: [
|
|
72
|
-
'<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok]',
|
|
72
|
+
'<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]',
|
|
73
73
|
'automation',
|
|
74
74
|
['network'],
|
|
75
75
|
],
|
|
76
76
|
},
|
|
77
|
+
'issue:release': {
|
|
78
|
+
script: 'scripts/issue/issue-release-cli.ts',
|
|
79
|
+
description:
|
|
80
|
+
"Link an Issue to the repository's open release Issue as its blocker, filing one when none is open",
|
|
81
|
+
category: 'AI tools',
|
|
82
|
+
reference: ['<issue>', 'automation', ['network']],
|
|
83
|
+
},
|
|
77
84
|
}
|
|
78
85
|
/* eslint-enable @typescript-eslint/naming-convention */
|
|
79
86
|
|
|
@@ -39,7 +39,8 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
39
39
|
},
|
|
40
40
|
'lane:prune': {
|
|
41
41
|
...LANE_ARGUMENTS,
|
|
42
|
-
description:
|
|
42
|
+
description:
|
|
43
|
+
'Close every lane an interruption left without its work tree, then sweep unregistered leftovers under the lanes root',
|
|
43
44
|
category: 'AI tools',
|
|
44
45
|
reference: ['', 'automation', ['files', 'git']],
|
|
45
46
|
default_script_arguments: ['prune'],
|
|
@@ -45,9 +45,9 @@ const MAINTENANCE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
45
45
|
shell: [
|
|
46
46
|
'sh',
|
|
47
47
|
'-c',
|
|
48
|
-
'export NODE_AUTH_TOKEN=$(gh auth token) && pnpm josh latest:guard && pnpm josh latest:
|
|
48
|
+
'export NODE_AUTH_TOKEN=$(gh auth token) && pnpm josh latest:guard && pnpm josh latest:update && pnpm josh ranges && pnpm josh audit && pnpm josh latest:corepack && pnpm josh latest:scope --record',
|
|
49
49
|
],
|
|
50
|
-
description: 'Update
|
|
50
|
+
description: 'Update dependencies, run security audit, then update pnpm',
|
|
51
51
|
category: 'Maintenance',
|
|
52
52
|
reference: ['', 'developer', ['files', 'network', 'processes']],
|
|
53
53
|
argument_targets: ['latest:corepack', 'latest:update', 'audit'],
|
|
@@ -279,8 +279,8 @@ async function run_entry(
|
|
|
279
279
|
return await run_script_entry(entry, subcommand_arguments)
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
function
|
|
283
|
-
return typeof
|
|
282
|
+
function declared(value: number | (() => number) | undefined): number | undefined {
|
|
283
|
+
return typeof value === 'function' ? value() : value
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
// **Every command passes through here, so this is where a declared weight is reserved**
|
|
@@ -291,13 +291,14 @@ async function dispatch_entry(
|
|
|
291
291
|
entry: CommandEntry,
|
|
292
292
|
subcommand_arguments: Array<string>,
|
|
293
293
|
): Promise<number> {
|
|
294
|
-
const weight =
|
|
294
|
+
const weight = declared(entry.core_weight)
|
|
295
295
|
|
|
296
296
|
if (weight === undefined) return await run_entry(entry, subcommand_arguments)
|
|
297
297
|
|
|
298
298
|
return await core_budget.with_command_reservation(
|
|
299
299
|
weight,
|
|
300
300
|
async () => await run_entry(entry, subcommand_arguments),
|
|
301
|
+
{ memory_mb: declared(entry.memory_mb) },
|
|
301
302
|
)
|
|
302
303
|
}
|
|
303
304
|
|
package/scripts/lane/lane-cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { lane_output, type ReadOutcome, type RecordOutcome } from './lane-output
|
|
|
16
16
|
import { lane_registry, type LaneInfo } from './lane-registry'
|
|
17
17
|
import { lane_report } from './lane-report'
|
|
18
18
|
import { lane_seed_policy } from './lane-seed'
|
|
19
|
+
import { lane_stray } from './lane-stray'
|
|
19
20
|
|
|
20
21
|
// `josh lane:open` / `lane:close` / `lane:list` / `lane:prune` — the lane's whole lifecycle
|
|
21
22
|
// (joshuafolkken/kit#1490).
|
|
@@ -268,7 +269,14 @@ async function list_command(rest: ReadonlyArray<string>): Promise<number> {
|
|
|
268
269
|
async function prune_command(rest: ReadonlyArray<string>): Promise<number> {
|
|
269
270
|
if (rest.length > 0) return report_usage()
|
|
270
271
|
|
|
271
|
-
|
|
272
|
+
const code = report_sweep(await lane_close.prune_lanes())
|
|
273
|
+
|
|
274
|
+
// After the closes, so a lane whose close left its directory behind is swept in the same run. A
|
|
275
|
+
// kept stray is reported, never a failure: it waits on a person, and a non-zero exit would stop
|
|
276
|
+
// every `backlogrun` preparation until one came (joshuafolkken/kit#3370).
|
|
277
|
+
await lane_stray.sweep_and_report()
|
|
278
|
+
|
|
279
|
+
return code
|
|
272
280
|
}
|
|
273
281
|
|
|
274
282
|
// The recorded path goes to standard output on both arms, so
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { ENV_FILE_NAME } from '#ports'
|
|
4
5
|
import { git_spawn } from '#scripts/git/git-spawn'
|
|
6
|
+
import { IGNORED_CACHE_FILES } from '#scripts/josh/josh-command-types'
|
|
5
7
|
|
|
6
8
|
// A lane directory git no longer registers as a work tree (joshuafolkken/kit#2857).
|
|
7
9
|
//
|
|
@@ -11,31 +13,38 @@ import { git_spawn } from '#scripts/git/git-spawn'
|
|
|
11
13
|
// here before it adds the tree.
|
|
12
14
|
//
|
|
13
15
|
// **The test for removing it is that removing it loses nothing**, not a list of names. Every file in
|
|
14
|
-
// it has to
|
|
15
|
-
// write again; the one exception is the `.git` file, a pointer to the registration that is
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
16
|
+
// it has to hold content the repository's history already stores — a blob some ref reaches — so git
|
|
17
|
+
// can write it again; the one exception is the `.git` file, a pointer to the registration that is
|
|
18
|
+
// gone. **Any commit's content, not only `HEAD`'s at the same path** (joshuafolkken/kit#3370): a
|
|
19
|
+
// leftover is a checkout of whatever commit its lane was on, and measured against `HEAD` the
|
|
20
|
+
// leftovers that issue found differed in 167 to 1,431 files each while every one of them was in the
|
|
21
|
+
// history. A list of generated names would break the next time a file is generated, and would delete
|
|
22
|
+
// whatever shares one of those names.
|
|
23
|
+
//
|
|
24
|
+
// **The one list of names is what `lane:open` and the gate regenerate**, and only at the top level:
|
|
25
|
+
// the install's `node_modules`, the build's `dist`, the `.env` `lane:open` writes from the root's, and
|
|
26
|
+
// the gate's caches. A lane carries them on top of its checkout, none is in the history, and walking
|
|
27
|
+
// `node_modules` alone would hash tens of thousands of files.
|
|
28
|
+
const REGENERATED_PATHS: ReadonlySet<string> = new Set([
|
|
29
|
+
'node_modules',
|
|
30
|
+
'dist',
|
|
31
|
+
ENV_FILE_NAME,
|
|
32
|
+
...IGNORED_CACHE_FILES,
|
|
33
|
+
])
|
|
22
34
|
const GIT_POINTER = '.git'
|
|
23
35
|
const GITDIR_PREFIX = 'gitdir: '
|
|
24
36
|
// The foreign paths a refusal names before it summarizes the rest as a count.
|
|
25
37
|
const NAMED_PATH_LIMIT = 10
|
|
26
38
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (entry_path === undefined || object === undefined) return []
|
|
33
|
-
|
|
34
|
-
return [[entry_path, object]]
|
|
39
|
+
// `git rev-list --objects --all`: one `<object>[ <path>]` line per object any ref reaches — commits,
|
|
40
|
+
// trees and blobs alike, which costs nothing here, because only a blob id can equal a file's.
|
|
41
|
+
function parse_objects(listing: string): Set<string> {
|
|
42
|
+
return new Set(listing.split('\n').map((line) => line.split(' ', 1)[0] ?? ''))
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
/** Every object id the repository's refs reach — the content a leftover can be restored from. */
|
|
46
|
+
async function recoverable_objects(): Promise<Set<string>> {
|
|
47
|
+
return parse_objects(await git_spawn.read(['rev-list', '--objects', '--all']))
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
// The object id git gives this file — a symbolic link's is that of its target string, as git stores it.
|
|
@@ -63,9 +72,14 @@ function walk_files(directory: string, relative: string): Array<string> {
|
|
|
63
72
|
})
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
// Every file under `directory` as a repository path, symbolic links included and never followed
|
|
75
|
+
// Every file under `directory` as a repository path, symbolic links included and never followed —
|
|
76
|
+
// less the regenerated top-level entries, which are neither walked nor listed.
|
|
67
77
|
function list_files(directory: string): Array<string> {
|
|
68
|
-
return
|
|
78
|
+
return readdirSync(directory)
|
|
79
|
+
.filter((name) => !REGENERATED_PATHS.has(name))
|
|
80
|
+
.flatMap((name) =>
|
|
81
|
+
lstatSync(path.join(directory, name)).isDirectory() ? walk_files(directory, name) : name,
|
|
82
|
+
)
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
// The `.git` file is disposable only while the registration it points to is gone: one that still
|
|
@@ -85,25 +99,25 @@ function is_dangling_pointer(directory: string): boolean {
|
|
|
85
99
|
function is_reclaimable_file(
|
|
86
100
|
directory: string,
|
|
87
101
|
relative: string,
|
|
88
|
-
|
|
102
|
+
recoverable: ReadonlySet<string>,
|
|
89
103
|
): boolean {
|
|
90
104
|
if (relative === GIT_POINTER) return is_dangling_pointer(directory)
|
|
91
105
|
|
|
92
|
-
return
|
|
106
|
+
return recoverable.has(blob_id(path.join(directory, relative)))
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
/**
|
|
96
110
|
* The paths under `directory` whose removal would lose something — empty when it holds nothing but
|
|
97
|
-
* the
|
|
98
|
-
* so it is foreign as a whole rather than walked.
|
|
111
|
+
* content the history stores, the regenerated entries and the `.git` pointer. A `.git` directory is a
|
|
112
|
+
* repository of its own, so it is foreign as a whole rather than walked.
|
|
99
113
|
*/
|
|
100
|
-
function foreign_paths(directory: string,
|
|
114
|
+
function foreign_paths(directory: string, recoverable: ReadonlySet<string>): Array<string> {
|
|
101
115
|
const pointer = lstatSync(path.join(directory, GIT_POINTER), { throwIfNoEntry: false })
|
|
102
116
|
|
|
103
117
|
if (pointer?.isDirectory() === true) return [GIT_POINTER]
|
|
104
118
|
|
|
105
119
|
return list_files(directory).filter(
|
|
106
|
-
(relative) => !is_reclaimable_file(directory, relative,
|
|
120
|
+
(relative) => !is_reclaimable_file(directory, relative, recoverable),
|
|
107
121
|
)
|
|
108
122
|
}
|
|
109
123
|
|
|
@@ -125,8 +139,7 @@ function refusal(directory: string, foreign: ReadonlyArray<string>): Error {
|
|
|
125
139
|
async function reclaim(directory: string): Promise<void> {
|
|
126
140
|
if (!existsSync(directory)) return
|
|
127
141
|
|
|
128
|
-
const
|
|
129
|
-
const foreign = foreign_paths(directory, tracked)
|
|
142
|
+
const foreign = foreign_paths(directory, await recoverable_objects())
|
|
130
143
|
|
|
131
144
|
if (foreign.length > 0) throw refusal(directory, foreign)
|
|
132
145
|
|
|
@@ -136,8 +149,9 @@ async function reclaim(directory: string): Promise<void> {
|
|
|
136
149
|
const lane_leftover = {
|
|
137
150
|
blob_id,
|
|
138
151
|
foreign_paths,
|
|
139
|
-
|
|
152
|
+
parse_objects,
|
|
140
153
|
reclaim,
|
|
154
|
+
recoverable_objects,
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
export { lane_leftover }
|
|
@@ -37,14 +37,13 @@ type OpenOutcome =
|
|
|
37
37
|
|
|
38
38
|
const FULL_OUTCOME: OpenOutcome = { kind: 'full' }
|
|
39
39
|
|
|
40
|
-
// The seat a lane will take is claimed by exclusively creating its lock directory
|
|
41
|
-
// lanes root and clear of the lanes themselves (which are numbered, never dot-prefixed). The claim
|
|
40
|
+
// The seat a lane will take is claimed by exclusively creating its lock directory under
|
|
41
|
+
// `lane_paths.SEAT_LOCK_DIR`, in the lanes root and clear of the lanes themselves (which are numbered, never dot-prefixed). The claim
|
|
42
42
|
// is what closes the gap between reading the free seats and writing the lane's `.env`: two
|
|
43
43
|
// `lane:open` runs that both saw seat 1 free cannot both create the same lock, so the loser steps to
|
|
44
44
|
// the next free seat (joshuafolkken/kit#1494). The lock is released once the lane's `.env` is on
|
|
45
45
|
// disk and the seat is discoverable — or on any failure — so only a hard crash mid-open can strand
|
|
46
46
|
// one, which removing the lanes-root `.seat-locks` directory clears.
|
|
47
|
-
const SEAT_LOCK_DIR = '.seat-locks'
|
|
48
47
|
const SEAT_LOCK_PREFIX = 'seat-'
|
|
49
48
|
|
|
50
49
|
// A root with no `.env` is the normal state of a fresh clone and of CI, and it means seed 0 — the
|
|
@@ -79,7 +78,7 @@ function build_lane(
|
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
function seat_lock_path(root: string, seat: number): string {
|
|
82
|
-
return path.join(root, SEAT_LOCK_DIR, `${SEAT_LOCK_PREFIX}${String(seat)}`)
|
|
81
|
+
return path.join(root, lane_paths.SEAT_LOCK_DIR, `${SEAT_LOCK_PREFIX}${String(seat)}`)
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
// Release the seat lock — idempotent (`force`), so calling it after the `.env` write and again in the
|
|
@@ -93,7 +92,7 @@ function release_seat_lock(lock: string): void {
|
|
|
93
92
|
// being created. A missing directory is none held — the normal state when no open is running.
|
|
94
93
|
function open_in_flight_count(root: string): number {
|
|
95
94
|
try {
|
|
96
|
-
return readdirSync(path.join(root, SEAT_LOCK_DIR)).filter((entry) =>
|
|
95
|
+
return readdirSync(path.join(root, lane_paths.SEAT_LOCK_DIR)).filter((entry) =>
|
|
97
96
|
entry.startsWith(SEAT_LOCK_PREFIX),
|
|
98
97
|
).length
|
|
99
98
|
} catch {
|
|
@@ -279,6 +278,7 @@ async function open_lane(issue: string): Promise<OpenOutcome> {
|
|
|
279
278
|
}
|
|
280
279
|
|
|
281
280
|
const lane_open = {
|
|
281
|
+
open_in_flight_count,
|
|
282
282
|
open_lane,
|
|
283
283
|
}
|
|
284
284
|
|
|
@@ -82,10 +82,23 @@ function lane_branch(issue: string): string {
|
|
|
82
82
|
return `${issue}${LANE_BRANCH_SUFFIX}`
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// The lanes root's own bookkeeping, beside the lanes and never one of them: `lane:open` claims a seat
|
|
86
|
+
// by creating a lock directory under it (joshuafolkken/kit#1494).
|
|
87
|
+
const SEAT_LOCK_DIR = '.seat-locks'
|
|
88
|
+
|
|
89
|
+
// A name the lane machinery writes into a lanes root: a lane's issue number, or a lanes root of its
|
|
90
|
+
// own — the nested one a lane opened from inside a lane once put there. Anything else in the root
|
|
91
|
+
// was put there by someone else (joshuafolkken/kit#3370).
|
|
92
|
+
function is_lane_entry(name: string): boolean {
|
|
93
|
+
return LANE_ISSUE_PATTERN.test(name) || DEFAULT_LANE_ROOT_PATTERN.test(name)
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
const lane_paths = {
|
|
86
97
|
LANE_BRANCH_SUFFIX,
|
|
87
98
|
LANE_ROOT_KEY,
|
|
99
|
+
SEAT_LOCK_DIR,
|
|
88
100
|
default_lane_root,
|
|
101
|
+
is_lane_entry,
|
|
89
102
|
lane_branch,
|
|
90
103
|
lane_directory,
|
|
91
104
|
lane_issue_of,
|
|
@@ -217,6 +217,16 @@ async function list_lanes(): Promise<Array<LaneInfo>> {
|
|
|
217
217
|
return lanes.toSorted((left, right) => Number(left.issue) - Number(right.issue))
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Every directory git registers as a work tree, lane or not — what a sweep of the lanes root must
|
|
222
|
+
* never touch, including a work tree at a lane's path whose branch does not read as a lane's.
|
|
223
|
+
*/
|
|
224
|
+
async function registered_directories(): Promise<Array<string>> {
|
|
225
|
+
const blocks = await worktree_blocks()
|
|
226
|
+
|
|
227
|
+
return blocks.flatMap((block) => line_value(block.split('\n'), WORKTREE_PREFIX) ?? [])
|
|
228
|
+
}
|
|
229
|
+
|
|
220
230
|
// The lanes a parent is still waiting on. A stranded lane has no child working in it, so it is not
|
|
221
231
|
// in-flight — the one definition the watcher guard and the headless stop rule both read.
|
|
222
232
|
async function has_lanes_in_flight(): Promise<boolean> {
|
|
@@ -259,6 +269,7 @@ const lane_registry = {
|
|
|
259
269
|
main_repository_root,
|
|
260
270
|
parse_block,
|
|
261
271
|
read_environment,
|
|
272
|
+
registered_directories,
|
|
262
273
|
unreadable_lanes,
|
|
263
274
|
used_seats,
|
|
264
275
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { error_text } from '#scripts/lib/error-message'
|
|
4
|
+
import { lane_leftover } from './lane-leftover'
|
|
5
|
+
import { lane_open } from './lane-open'
|
|
6
|
+
import { lane_paths } from './lane-paths'
|
|
7
|
+
import { lane_registry } from './lane-registry'
|
|
8
|
+
|
|
9
|
+
// What the lanes root holds that no registered work tree accounts for (joshuafolkken/kit#3370).
|
|
10
|
+
//
|
|
11
|
+
// **Every other sweep starts from `git worktree list`**, so a directory whose registration is gone —
|
|
12
|
+
// a lane whose `rmSync` stopped half-way, one whose `.git` points at a registration git pruned, an
|
|
13
|
+
// empty lanes root a lane once nested inside itself — is invisible to all of them, and stays on disk
|
|
14
|
+
// for good. This reads the root itself.
|
|
15
|
+
//
|
|
16
|
+
// **A stray is removed only when removing it loses nothing** — `lane_leftover.foreign_paths`, the one
|
|
17
|
+
// test `lane:open`'s reclaim also applies. One that holds anything else is kept and reported with what
|
|
18
|
+
// it holds; so is every entry whose name the lane machinery never writes, because nothing here knows
|
|
19
|
+
// who put it there. The root's own `.seat-locks` and every registered work tree are not strays.
|
|
20
|
+
|
|
21
|
+
type StrayVerdict = 'foreign' | 'not-a-lane' | 'opening' | 'removed' | 'stuck' | 'unreadable'
|
|
22
|
+
|
|
23
|
+
interface Stray {
|
|
24
|
+
path: string
|
|
25
|
+
verdict: StrayVerdict
|
|
26
|
+
// The paths inside it whose removal would lose something — only on `foreign`.
|
|
27
|
+
foreign: Array<string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stray_names(root: string, registered: ReadonlySet<string>): Array<string> {
|
|
31
|
+
if (!existsSync(root)) return []
|
|
32
|
+
|
|
33
|
+
return readdirSync(root).filter(
|
|
34
|
+
(name) => name !== lane_paths.SEAT_LOCK_DIR && !registered.has(path.join(root, name)),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function is_lane_directory(directory: string): boolean {
|
|
39
|
+
return lane_paths.is_lane_entry(path.basename(directory)) && lstatSync(directory).isDirectory()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A removal that stops part-way is judged by what is left, exactly as `lane:close` judges its own —
|
|
43
|
+
// and what is left is a stray the next sweep reads again.
|
|
44
|
+
function remove(directory: string): StrayVerdict {
|
|
45
|
+
try {
|
|
46
|
+
rmSync(directory, { force: true, recursive: true })
|
|
47
|
+
} catch (error) {
|
|
48
|
+
error_text.trace_swallowed('lane_stray.remove', error)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return existsSync(directory) ? 'stuck' : 'removed'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// **A `lane:open` in flight holds a seat lock from before its `worktree add` until after the
|
|
55
|
+
// registration**, so while any lock is held a numbered directory may be a lane being created, not a
|
|
56
|
+
// leftover — it is kept, and the next sweep reads it again.
|
|
57
|
+
interface SweepContext {
|
|
58
|
+
recoverable: ReadonlySet<string>
|
|
59
|
+
is_opening: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function judge(directory: string, context: SweepContext): Stray {
|
|
63
|
+
if (!is_lane_directory(directory)) return { path: directory, verdict: 'not-a-lane', foreign: [] }
|
|
64
|
+
if (context.is_opening) return { path: directory, verdict: 'opening', foreign: [] }
|
|
65
|
+
|
|
66
|
+
const foreign = lane_leftover.foreign_paths(directory, context.recoverable)
|
|
67
|
+
|
|
68
|
+
if (foreign.length > 0) return { path: directory, verdict: 'foreign', foreign }
|
|
69
|
+
|
|
70
|
+
return { path: directory, verdict: remove(directory), foreign: [] }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One stray that cannot be read — a permission, a file vanishing mid-walk — is kept and reported
|
|
74
|
+
// rather than thrown, so it never stops the sweep of the rest.
|
|
75
|
+
function judge_safely(directory: string, context: SweepContext): Stray {
|
|
76
|
+
try {
|
|
77
|
+
return judge(directory, context)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
error_text.trace_swallowed('lane_stray.judge', error)
|
|
80
|
+
|
|
81
|
+
return { path: directory, verdict: 'unreadable', foreign: [] }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Remove the lanes root's unregistered leftovers that hold nothing worth keeping; report the rest. */
|
|
86
|
+
async function sweep_strays(): Promise<Array<Stray>> {
|
|
87
|
+
const root = lane_paths.lane_root(await lane_registry.main_repository_root())
|
|
88
|
+
const registered = new Set(await lane_registry.registered_directories())
|
|
89
|
+
const names = stray_names(root, registered)
|
|
90
|
+
|
|
91
|
+
if (names.length === 0) return []
|
|
92
|
+
|
|
93
|
+
const context: SweepContext = {
|
|
94
|
+
recoverable: await lane_leftover.recoverable_objects(),
|
|
95
|
+
is_opening: lane_open.open_in_flight_count(root) > 0,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return names.map((name) => judge_safely(path.join(root, name), context))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The foreign paths a report names before it summarizes the rest as a count.
|
|
102
|
+
const NAMED_PATH_LIMIT = 5
|
|
103
|
+
|
|
104
|
+
function named_paths(foreign: ReadonlyArray<string>): string {
|
|
105
|
+
const rest = foreign.length - NAMED_PATH_LIMIT
|
|
106
|
+
const more = rest > 0 ? ` and ${String(rest)} more` : ''
|
|
107
|
+
|
|
108
|
+
return `${foreign.slice(0, NAMED_PATH_LIMIT).join(', ')}${more}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const KEPT_REASONS: Record<Exclude<StrayVerdict, 'foreign' | 'removed'>, string> = {
|
|
112
|
+
'not-a-lane': 'not a name the lane machinery writes, so it was left alone',
|
|
113
|
+
opening:
|
|
114
|
+
'a `lane:open` holds a seat lock and this may be its lane; the next sweep reads it again — if no `lane:open` is running, the lock outlived a crash: remove the lanes root `.seat-locks` directory',
|
|
115
|
+
stuck: 'the removal did not finish; the next `pnpm josh lane:prune` tries again',
|
|
116
|
+
unreadable: 'it could not be read, so it was left in place',
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One line per stray, for standard error. */
|
|
120
|
+
function describe(stray: Stray): string {
|
|
121
|
+
if (stray.verdict === 'removed') return `Removed unregistered lane leftover ${stray.path}.`
|
|
122
|
+
|
|
123
|
+
if (stray.verdict === 'foreign') {
|
|
124
|
+
return `Kept ${stray.path}: it holds files git cannot restore (${named_paths(stray.foreign)}). Move them out and delete the directory.`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return `Kept ${stray.path}: ${KEPT_REASONS[stray.verdict]}.`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Sweep, and say on standard error what was removed and what was kept. */
|
|
131
|
+
async function sweep_and_report(): Promise<void> {
|
|
132
|
+
const strays = await sweep_strays()
|
|
133
|
+
|
|
134
|
+
for (const stray of strays) console.error(describe(stray))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lane_stray = { describe, sweep_and_report, sweep_strays }
|
|
138
|
+
|
|
139
|
+
export type { Stray, StrayVerdict }
|
|
140
|
+
export { lane_stray }
|
|
@@ -38,6 +38,7 @@ function read_release_input(environment: Environment): ReleaseInput {
|
|
|
38
38
|
workflow: optional(environment['RELEASE_WORKFLOW']),
|
|
39
39
|
jobs: job_names(environment['RELEASE_JOBS']),
|
|
40
40
|
await_publish: environment['RELEASE_AWAIT_PUBLISH'] === 'true',
|
|
41
|
+
release_run_workflow: optional(environment['RELEASE_RUN_WORKFLOW']),
|
|
41
42
|
},
|
|
42
43
|
}
|
|
43
44
|
}
|