@joshuafolkken/kit 1.929.0 → 1.953.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/background-commands.md +6 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
- package/.claude/skills/workflow-commands/observation-filing.md +3 -3
- package/.claude/skills/workflow-commands/prerequisite.md +1 -1
- package/.github/workflows/ci.yml +26 -8
- package/.github/workflows/pr-classification.yml +76 -0
- package/README.md +44 -22
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-GQ3EVWHY.js → chunk-452OKRQX.js} +421 -195
- package/dist/hooks/{chunk-2PGRQ62K.js → chunk-EYFJ74MJ.js} +11 -6
- package/dist/hooks/{chunk-LCJZE42I.js → chunk-IA7WC4NS.js} +1 -1
- package/dist/hooks/{chunk-BNFG2IFK.js → chunk-IBFC25IX.js} +4 -4
- package/dist/hooks/{chunk-I3JP3D4A.js → chunk-M4U4YNRE.js} +3 -3
- package/dist/hooks/{chunk-NJZSO7BK.js → chunk-NKVLBB65.js} +3 -3
- package/dist/hooks/{chunk-SXPTRFPG.js → chunk-O3PEDF3M.js} +1 -1
- package/dist/hooks/{chunk-7XQ6TK3R.js → chunk-OM5VDM6F.js} +2 -2
- package/dist/hooks/{chunk-ROULPSQC.js → chunk-V7RH56HU.js} +2 -2
- package/dist/hooks/{chunk-YO43HEPY.js → chunk-Z5TVZ4AP.js} +237 -235
- 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/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +9 -3
- package/dist/josh.js +11 -6
- package/dist/version/index.js +7 -4
- package/package.json +13 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/scripts/ci/pr-classification.ts +27 -6
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/hook-gate-reuse.ts +1 -1
- package/scripts/gate/project-checks.ts +45 -15
- package/scripts/gate/type-check-command.ts +43 -0
- package/scripts/git/constants.ts +4 -0
- package/scripts/git/git-command.ts +11 -5
- package/scripts/git/git-gh-pr-rollup.ts +56 -16
- package/scripts/git/git-staging.ts +6 -4
- package/scripts/git/git-stash.ts +73 -1
- package/scripts/git/git-worktree.ts +35 -4
- package/scripts/git/repository-lock.ts +138 -0
- package/scripts/git/stash-orphans.ts +24 -2
- package/scripts/git/stash-sweep-lock.ts +30 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/init-actions.ts +3 -2
- package/scripts/init/init-logic-migrate.ts +10 -1
- package/scripts/init/init-logic-templates.ts +27 -7
- package/scripts/init/init-logic-workspace.ts +52 -1
- package/scripts/init/init-logic.ts +6 -1
- package/scripts/init/init.ts +60 -19
- package/scripts/issue/issue-merged.ts +75 -0
- package/scripts/issue/measure-rerun-cli.ts +2 -2
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-command-types.ts +0 -3
- package/scripts/josh/josh-commands-ai.ts +6 -0
- package/scripts/josh/josh-commands-development.ts +5 -5
- package/scripts/lint/lint-parallel.ts +19 -18
- package/scripts/observations/observation-ledger-home.ts +22 -2
- package/scripts/observations/observation-ledger-line.ts +11 -0
- package/scripts/observations/observation-ledger-migrate.ts +146 -0
- package/scripts/observations/observation-ledger.ts +51 -3
- package/scripts/observations/observations-flush.ts +38 -7
- package/scripts/review/review-finding-ledger.ts +1 -1
- package/scripts/review/review-record-cli.ts +1 -7
- package/scripts/rules/delivered-rules.ts +10 -0
- package/scripts/rules/lane-background.ts +111 -0
- package/scripts/rules/stop-rules.ts +15 -2
- package/scripts/run/run-carry-flush.ts +2 -7
- package/scripts/run/run-hold-cli.ts +10 -2
- package/scripts/run/run-tidy-cli.ts +50 -0
- package/scripts/run/run-tidy-lanes.ts +110 -0
- package/scripts/run/run-tidy-stashes.ts +160 -0
- package/scripts/run/run-tidy.ts +101 -0
- package/scripts/safe-chain/ci-installer-pin.ts +90 -0
- package/scripts/safe-chain/preinstall-command.ts +39 -0
- package/scripts/safe-chain/project-config.ts +55 -10
- package/scripts/sync/sync.ts +8 -3
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/preinstall-version-update.ts +2 -0
- package/scripts/version/release-age.ts +11 -6
- package/templates/workflows/ci.yml +21 -7
- package/tsconfig/base.json +4 -1
|
@@ -29,7 +29,9 @@ const check_runs_page_schema = z.looseObject({
|
|
|
29
29
|
// answer an empty rollup — which `git-pr-followup.ts` reports as "this branch has no checks".
|
|
30
30
|
check_runs: z.array(rollup_element_schema),
|
|
31
31
|
})
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
// Both `app` and `check_suite` are objects carrying a numeric `id`, and so is a check run itself.
|
|
34
|
+
const id_holder_schema = z.object({ id: z.number() })
|
|
33
35
|
|
|
34
36
|
const status_page_schema = z.looseObject({
|
|
35
37
|
statuses: z.array(rollup_element_schema),
|
|
@@ -51,37 +53,75 @@ function read_pages<T>(raw_json: string, schema: z.ZodType<T>, message: string):
|
|
|
51
53
|
return pages
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
const NO_SUITE = -1
|
|
57
|
+
|
|
58
|
+
interface RunRank {
|
|
59
|
+
suite: number
|
|
60
|
+
run: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function read_id(value: unknown): number | undefined {
|
|
64
|
+
const parsed = id_holder_schema.safeParse(value)
|
|
65
|
+
|
|
66
|
+
return parsed.success ? parsed.data.id : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
function check_run_group_key(run: RollupElement): string | undefined {
|
|
55
|
-
const { name, app
|
|
70
|
+
const { name, app } = run
|
|
56
71
|
if (typeof name !== 'string') return undefined
|
|
57
|
-
const app = check_run_app_schema.safeParse(raw_app)
|
|
58
72
|
|
|
59
|
-
return JSON.stringify([name, app
|
|
73
|
+
return JSON.stringify([name, read_id(app)])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function read_rank(run: RollupElement): RunRank | undefined {
|
|
77
|
+
const id = read_id(run)
|
|
78
|
+
if (id === undefined) return undefined
|
|
79
|
+
|
|
80
|
+
return { suite: read_id(run['check_suite']) ?? NO_SUITE, run: id }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// **The newest check suite decides a name, not the newest check run** — the rule GitHub's merge box
|
|
84
|
+
// applies. The endpoint's default `filter=latest` already keeps only the newest run of each name
|
|
85
|
+
// *within* a suite (a re-run job lands in the suite it re-runs), so what is left is one run per
|
|
86
|
+
// suite. Across suites GitHub takes the one from the newer suite: PR #2717's cancelled suite was the
|
|
87
|
+
// older one and GitHub read the pull request `CLEAN`, while PR #2698's cancelled suite was the newer
|
|
88
|
+
// one and GitHub held it `BLOCKED`. Ranking by run id alone (joshuafolkken/kit#2644) picked #2698's
|
|
89
|
+
// older, successful suite — its jobs had queued later — so `followup` read green beside a `BLOCKED`
|
|
90
|
+
// pull request and waited out its whole budget (joshuafolkken/kit#2700).
|
|
91
|
+
function is_ranked_above(candidate: RunRank, current: RunRank): boolean {
|
|
92
|
+
if (candidate.suite !== current.suite) return candidate.suite > current.suite
|
|
93
|
+
|
|
94
|
+
return candidate.run > current.run
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function should_replace(previous: RollupElement | undefined, rank: RunRank): boolean {
|
|
98
|
+
const previous_rank = previous === undefined ? undefined : read_rank(previous)
|
|
99
|
+
|
|
100
|
+
return previous_rank === undefined || is_ranked_above(rank, previous_rank)
|
|
60
101
|
}
|
|
61
102
|
|
|
62
|
-
function remember_newest_run(run: RollupElement,
|
|
103
|
+
function remember_newest_run(run: RollupElement, newest: Map<string, RollupElement>): void {
|
|
63
104
|
const key = check_run_group_key(run)
|
|
64
|
-
const
|
|
65
|
-
if (key === undefined ||
|
|
66
|
-
|
|
67
|
-
if (previous_id === undefined || id > previous_id) latest_ids.set(key, id)
|
|
105
|
+
const rank = read_rank(run)
|
|
106
|
+
if (key === undefined || rank === undefined) return
|
|
107
|
+
if (should_replace(newest.get(key), rank)) newest.set(key, run)
|
|
68
108
|
}
|
|
69
109
|
|
|
70
|
-
|
|
110
|
+
// A run with no name or id cannot be ranked, so it is kept rather than guessed to be superseded.
|
|
111
|
+
function is_newest_run(run: RollupElement, newest: Map<string, RollupElement>): boolean {
|
|
71
112
|
const key = check_run_group_key(run)
|
|
72
|
-
|
|
73
|
-
if (key === undefined || typeof id !== 'number') return true
|
|
113
|
+
if (key === undefined || read_rank(run) === undefined) return true
|
|
74
114
|
|
|
75
|
-
return
|
|
115
|
+
return newest.get(key) === run
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
function to_check_run_items(check_runs_json: string): Array<RollupElement> {
|
|
79
119
|
const pages = read_pages(check_runs_json, check_runs_page_schema, NOT_A_CHECK_RUNS_LISTING)
|
|
80
120
|
const runs = pages.flatMap((page) => page.check_runs)
|
|
81
|
-
const
|
|
82
|
-
for (const run of runs) remember_newest_run(run,
|
|
121
|
+
const newest = new Map<string, RollupElement>()
|
|
122
|
+
for (const run of runs) remember_newest_run(run, newest)
|
|
83
123
|
|
|
84
|
-
return runs.filter((run) => is_newest_run(run,
|
|
124
|
+
return runs.filter((run) => is_newest_run(run, newest))
|
|
85
125
|
}
|
|
86
126
|
|
|
87
127
|
function to_status_context_items(status_json: string): Array<RollupElement> {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
observation_ledger,
|
|
3
|
-
|
|
3
|
+
OBSERVATION_LEDGER_PATHS,
|
|
4
4
|
} from '#scripts/observations/observation-ledger'
|
|
5
5
|
import { git_command } from './git-command'
|
|
6
6
|
import { git_prompt } from './git-prompt'
|
|
@@ -73,10 +73,12 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
|
|
|
73
73
|
// entirely — a diff a reviewer has no reason to question. Excluding it here covers every entry
|
|
74
74
|
// point, because this is the one staging step all of them go through;
|
|
75
75
|
// `pnpm josh observations:flush` is what commits it, on a branch of its own.
|
|
76
|
-
|
|
76
|
+
// Both of its paths are excluded (joshuafolkken/kit#2724): a run on the old code still appends at
|
|
77
|
+
// the old one until `observation-ledger-migrate.ts` moves it.
|
|
78
|
+
const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = OBSERVATION_LEDGER_PATHS
|
|
77
79
|
|
|
78
80
|
function is_stageable(file_path: string): boolean {
|
|
79
|
-
return !
|
|
81
|
+
return !observation_ledger.is_ledger_path(file_path)
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
// **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
|
|
@@ -84,7 +86,7 @@ function is_stageable(file_path: string): boolean {
|
|
|
84
86
|
// step dies on git's empty index with `Failed to commit changes` — a message naming nothing that
|
|
85
87
|
// caused it. This line is what turns that into a diagnosis.
|
|
86
88
|
//
|
|
87
|
-
// **The paths are parsed rather than matched as substrings**: `docs/observations.md.bak` contains the
|
|
89
|
+
// **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
|
|
88
90
|
// ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
|
|
89
91
|
// reader to ignore it.
|
|
90
92
|
function report_excluded_paths(status_output: string): void {
|
package/scripts/git/git-stash.ts
CHANGED
|
@@ -14,6 +14,12 @@ const FIELD_SEPARATOR = '\u{0}'
|
|
|
14
14
|
// stash pushed with `-m`); `%x00` emits the NUL the fields are split on.
|
|
15
15
|
const LIST_FORMAT = '--format=%gd%x00%gs'
|
|
16
16
|
const SUBJECT_MESSAGE_SEPARATOR = ': '
|
|
17
|
+
// `%H` is the entry's commit hash; the second format pairs it with the position a drop needs.
|
|
18
|
+
const HASH_FORMAT = '--format=%H%x00%gs'
|
|
19
|
+
const HASH_POSITION_FORMAT = '--format=%H%x00%gd'
|
|
20
|
+
const NAME_ONLY_FLAG = '--name-only'
|
|
21
|
+
const ADDED_PREFIX = '+'
|
|
22
|
+
const FILE_HEADER_PREFIX = '+++'
|
|
17
23
|
|
|
18
24
|
interface StashEntry {
|
|
19
25
|
selector: string
|
|
@@ -84,7 +90,7 @@ async function pop(selector: string, directory?: string): Promise<void> {
|
|
|
84
90
|
// by the unmerged paths it leaves: `--diff-filter=U` lists exactly those, empty on any other failure.
|
|
85
91
|
async function has_conflict(directory?: string): Promise<boolean> {
|
|
86
92
|
const unmerged = await git_spawn.read(
|
|
87
|
-
git_args(directory, ['diff',
|
|
93
|
+
git_args(directory, ['diff', NAME_ONLY_FLAG, '--diff-filter=U']),
|
|
88
94
|
)
|
|
89
95
|
|
|
90
96
|
return unmerged !== ''
|
|
@@ -102,6 +108,68 @@ async function push(message: string, directory?: string): Promise<void> {
|
|
|
102
108
|
await git_spawn.read(git_args(directory, ['stash', 'push', '-u', '-m', message]))
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
// The stack keyed by commit rather than position (joshuafolkken/kit#2701): a sweep that reads the
|
|
112
|
+
// stack, asks GitHub about each entry and only then drops one would drop whatever another lane pushed
|
|
113
|
+
// on top in the meantime if it kept the `stash@{n}` it read first. The `selector` of each entry here is
|
|
114
|
+
// its commit hash, which never moves.
|
|
115
|
+
async function list_by_hash(directory?: string): Promise<Array<StashEntry>> {
|
|
116
|
+
return parse_entries(await git_spawn.read(git_args(directory, ['stash', 'list', HASH_FORMAT])))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The paths an entry carries, untracked files included — `push` stashes with `-u`.
|
|
120
|
+
async function changed_paths(hash: string, directory?: string): Promise<Array<string>> {
|
|
121
|
+
const raw = await git_spawn.read(
|
|
122
|
+
git_args(directory, ['stash', 'show', '--include-untracked', NAME_ONLY_FLAG, hash]),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return raw === '' ? [] : raw.split(ENTRY_SEPARATOR)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A path untracked when the entry was pushed with `-u` lives in its third parent, not in its diff, so
|
|
129
|
+
// every line of it is an added one; a path absent from that parent reads as none.
|
|
130
|
+
async function untracked_lines(
|
|
131
|
+
hash: string,
|
|
132
|
+
file: string,
|
|
133
|
+
directory?: string,
|
|
134
|
+
): Promise<Array<string>> {
|
|
135
|
+
try {
|
|
136
|
+
const raw = await git_spawn.read(git_args(directory, ['show', `${hash}^3:${file}`]))
|
|
137
|
+
|
|
138
|
+
return raw === '' ? [] : raw.split(ENTRY_SEPARATOR)
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The lines an entry adds to one path, as a unified diff against the commit it was pushed on — or, for
|
|
145
|
+
// a path that was untracked then, the whole file.
|
|
146
|
+
async function added_lines(hash: string, file: string, directory?: string): Promise<Array<string>> {
|
|
147
|
+
const diff = await git_spawn.read(git_args(directory, ['diff', `${hash}^1`, hash, '--', file]))
|
|
148
|
+
|
|
149
|
+
if (diff === '') return await untracked_lines(hash, file, directory)
|
|
150
|
+
|
|
151
|
+
return diff
|
|
152
|
+
.split(ENTRY_SEPARATOR)
|
|
153
|
+
.filter((line) => line.startsWith(ADDED_PREFIX) && !line.startsWith(FILE_HEADER_PREFIX))
|
|
154
|
+
.map((line) => line.slice(ADDED_PREFIX.length))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The position is resolved from the hash immediately before the drop, so an entry pushed since the
|
|
158
|
+
// sweep's read cannot be dropped in its place; an entry already gone answers `false` rather than
|
|
159
|
+
// throwing. Sweeps are serialized by `stash-sweep-lock`, but a plain `git stash push` or `pop` from
|
|
160
|
+
// another command landing between the resolve and the drop still shifts the position — that window is
|
|
161
|
+
// the length of two local git calls.
|
|
162
|
+
async function drop_by_hash(hash: string, directory?: string): Promise<boolean> {
|
|
163
|
+
const raw = await git_spawn.read(git_args(directory, ['stash', 'list', HASH_POSITION_FORMAT]))
|
|
164
|
+
const entry = parse_entries(raw).find((candidate) => candidate.selector === hash)
|
|
165
|
+
|
|
166
|
+
if (entry === undefined) return false
|
|
167
|
+
|
|
168
|
+
await git_spawn.read(git_args(directory, ['stash', 'drop', entry.subject]))
|
|
169
|
+
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
105
173
|
// The message uncommitted work is pushed with, keyed on the issue so `josh stash:pop` can target it and
|
|
106
174
|
// on the occasion so two pushes for one issue — a lane close and an `already-done` read — stay apart.
|
|
107
175
|
function work_message(issue: string, occasion: string): string {
|
|
@@ -109,9 +177,13 @@ function work_message(issue: string, occasion: string): string {
|
|
|
109
177
|
}
|
|
110
178
|
|
|
111
179
|
const git_stash = {
|
|
180
|
+
added_lines,
|
|
181
|
+
changed_paths,
|
|
182
|
+
drop_by_hash,
|
|
112
183
|
has_changes,
|
|
113
184
|
has_conflict,
|
|
114
185
|
list,
|
|
186
|
+
list_by_hash,
|
|
115
187
|
matches,
|
|
116
188
|
parse_entries,
|
|
117
189
|
pop,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PORCELAIN_FLAG } from './constants'
|
|
2
2
|
import { ls_remote_branch_arguments } from './git-ls-remote'
|
|
3
3
|
import { git_spawn } from './git-spawn'
|
|
4
|
+
import { repository_lock } from './repository-lock'
|
|
4
5
|
|
|
5
6
|
// The four worktree reads and writes a lane's lifecycle needs, plus the branch deletion that ends it
|
|
6
7
|
// (joshuafolkken/kit#1490; split out of `git-command.ts` under joshuafolkken/kit#1640). They live
|
|
@@ -19,6 +20,36 @@ import { git_spawn } from './git-spawn'
|
|
|
19
20
|
// (joshuafolkken/kit#1641; before it, `lane-start-point.ts` was the only reader and did both).
|
|
20
21
|
const WORKTREE = 'worktree'
|
|
21
22
|
|
|
23
|
+
// **Every call that writes `.git/worktrees/` runs under one repository-wide lock**
|
|
24
|
+
// (joshuafolkken/kit#2736). `git worktree add` is not safe against itself: while one add is still
|
|
25
|
+
// building `.git/worktrees/<id>/` — its `commondir` not yet written — a second add enumerates the
|
|
26
|
+
// existing registrations, reads the half-built one and fails with `fatal: failed to read
|
|
27
|
+
// .git/worktrees/<id>/commondir`. Two lanes opened at once do exactly that, and the per-seat lock in
|
|
28
|
+
// `lane-open.ts` does not serialize them. `remove` and `prune` edit the same directory, so they take
|
|
29
|
+
// the same lock. The lock wraps the git call alone; the slow steps a caller runs afterwards — a lane's
|
|
30
|
+
// dependency install — stay outside it.
|
|
31
|
+
const WORKTREE_LOCK_PREFIX = 'josh-worktree-lock-'
|
|
32
|
+
// A holder runs one local git command, so the wait only bounds a checkout that hung; it is generous
|
|
33
|
+
// because a large repository's checkout is itself several seconds, and several lanes queue behind it.
|
|
34
|
+
const WORKTREE_LOCK_MAX_WAIT_MS = 120_000
|
|
35
|
+
|
|
36
|
+
async function locked(arguments_: Array<string>): Promise<string> {
|
|
37
|
+
const target = repository_lock.lock_path(WORKTREE_LOCK_PREFIX)
|
|
38
|
+
const output = await repository_lock.with_lock(
|
|
39
|
+
async () => await git_spawn.read(arguments_),
|
|
40
|
+
target,
|
|
41
|
+
WORKTREE_LOCK_MAX_WAIT_MS,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (output === undefined) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`git ${arguments_.join(' ')}: timed out waiting for the work-tree lock ${target}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return output
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
// Every registered work tree of this repository, in git's own machine-readable form: one `worktree
|
|
23
54
|
// <path>` / `HEAD <sha>` / `branch <ref>` block per tree, blocks separated by a blank line. Parsing
|
|
24
55
|
// it is the caller's, so this module keeps one shape for every reader.
|
|
@@ -50,14 +81,14 @@ async function worktree_add(
|
|
|
50
81
|
): Promise<string> {
|
|
51
82
|
const flags = start_point === undefined ? ['add'] : ['add', '--no-track', '-b', branch_name]
|
|
52
83
|
|
|
53
|
-
return await
|
|
84
|
+
return await locked([WORKTREE, ...flags, directory, start_point ?? branch_name])
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
// A throwaway tree at one commit, on no branch — `josh test:red` checks the merge-base out here, so
|
|
57
88
|
// the pre-fix tree is built without creating a branch or touching the caller's tree and index
|
|
58
89
|
// (joshuafolkken/kit#2448).
|
|
59
90
|
async function worktree_add_detached(directory: string, commit: string): Promise<string> {
|
|
60
|
-
return await
|
|
91
|
+
return await locked([WORKTREE, 'add', '--detach', directory, commit])
|
|
61
92
|
}
|
|
62
93
|
|
|
63
94
|
// **`--force` is the point, not a convenience.** A lane is closed after a park, a failure or an
|
|
@@ -65,13 +96,13 @@ async function worktree_add_detached(directory: string, commit: string): Promise
|
|
|
65
96
|
// or untracked work. Refusing to remove it there would leave exactly the debris the close exists to
|
|
66
97
|
// prevent.
|
|
67
98
|
async function worktree_remove(directory: string): Promise<string> {
|
|
68
|
-
return await
|
|
99
|
+
return await locked([WORKTREE, 'remove', '--force', directory])
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
// Drops the registrations whose directories are already gone — what makes a lane whose directory was
|
|
72
103
|
// deleted by hand recoverable rather than a permanent `worktree add` refusal on that path.
|
|
73
104
|
async function worktree_prune(): Promise<string> {
|
|
74
|
-
return await
|
|
105
|
+
return await locked([WORKTREE, 'prune'])
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
// **Asked of the remote itself, because nothing prunes the remote-tracking refs**
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { linkSync, renameSync, rmSync } from 'node:fs'
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
+
import { process_identity } from '#scripts/josh/process-identity'
|
|
5
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
6
|
+
import { git_common_directory } from './git-common-directory'
|
|
7
|
+
|
|
8
|
+
// One holder at a time per repository, for work that every work tree of that repository shares
|
|
9
|
+
// (joshuafolkken/kit#2701 for the stash sweep, joshuafolkken/kit#2736 for `git worktree add`). The
|
|
10
|
+
// record is keyed on the git common directory, so every work tree of one repository contends for one
|
|
11
|
+
// record per lock name, and it lives in the platform temp root beside the other stamps rather than
|
|
12
|
+
// inside `.git`. Each caller names its own lock with a prefix, so two unrelated kinds of work never
|
|
13
|
+
// wait on each other.
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 200
|
|
16
|
+
|
|
17
|
+
interface LockOwner {
|
|
18
|
+
pid: number
|
|
19
|
+
process_start?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type IsGone = (owner: LockOwner) => boolean
|
|
23
|
+
|
|
24
|
+
function lock_path(prefix: string, cwd: string = process.cwd()): string {
|
|
25
|
+
return stamp_file.stamp_path(prefix, git_common_directory.repository(cwd) ?? cwd)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parse_owner(raw: string | undefined): LockOwner | undefined {
|
|
29
|
+
if (raw === undefined) return undefined
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw) as LockOwner
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function read_owner(target: string): LockOwner | undefined {
|
|
39
|
+
return parse_owner(stamp_file.read_stamp_text(target))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function is_owner_gone(owner: LockOwner): boolean {
|
|
43
|
+
return process_identity.is_same_process(owner.pid, owner.process_start) === false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Put back a record that turned out not to be the stale one. `linkSync` refuses an existing path, so
|
|
47
|
+
// a lock someone claimed in the meantime is never overwritten.
|
|
48
|
+
function restore(aside: string, target: string): void {
|
|
49
|
+
try {
|
|
50
|
+
linkSync(aside, target)
|
|
51
|
+
} catch {
|
|
52
|
+
// Another claim already holds the path; the record set aside is dropped below.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The record's text when its owner is certainly gone, else `undefined`.
|
|
57
|
+
function stale_record(target: string, is_gone: IsGone): string | undefined {
|
|
58
|
+
const raw = stamp_file.read_stamp_text(target)
|
|
59
|
+
const owner = parse_owner(raw)
|
|
60
|
+
|
|
61
|
+
return owner !== undefined && is_gone(owner) ? raw : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The path the record was moved to, or `undefined` when another claimant moved it first.
|
|
65
|
+
function move_aside(target: string): string | undefined {
|
|
66
|
+
const aside = `${target}.${String(process.pid)}.${randomUUID()}`
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
renameSync(target, aside)
|
|
70
|
+
|
|
71
|
+
return aside
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A record whose process is certainly gone is a crashed holder's, and is cleared before the claim; an
|
|
78
|
+
// unreadable record or an uncertain answer is left in place, since clearing a live holder's lock is the
|
|
79
|
+
// very race the lock exists to close. **The clear is a rename, then a compare**: two claimants can
|
|
80
|
+
// both judge the same record stale, and the slower one's plain remove would delete the lock the faster
|
|
81
|
+
// one had just claimed. Renaming moves exactly one file aside atomically, and only a record
|
|
82
|
+
// byte-identical to the one judged stale is discarded — any other is a fresh claim and goes back.
|
|
83
|
+
function clear_stale(target: string, is_gone: IsGone = is_owner_gone): void {
|
|
84
|
+
const raw = stale_record(target, is_gone)
|
|
85
|
+
|
|
86
|
+
if (raw === undefined) return
|
|
87
|
+
|
|
88
|
+
const aside = move_aside(target)
|
|
89
|
+
|
|
90
|
+
if (aside === undefined) return
|
|
91
|
+
if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target)
|
|
92
|
+
rmSync(aside, { force: true })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function claim(target: string): boolean {
|
|
96
|
+
clear_stale(target)
|
|
97
|
+
|
|
98
|
+
return stamp_file.create_stamp(target, process_identity.own_fields())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function release(target: string): void {
|
|
102
|
+
const owner = read_owner(target)
|
|
103
|
+
|
|
104
|
+
if (owner !== undefined && process_identity.is_own_process(owner.pid, owner.process_start)) {
|
|
105
|
+
stamp_file.remove_stamp(target)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function acquire(target: string, max_wait_ms: number): Promise<boolean> {
|
|
110
|
+
const deadline = Date.now() + max_wait_ms
|
|
111
|
+
|
|
112
|
+
while (!claim(target)) {
|
|
113
|
+
if (Date.now() >= deadline) return false
|
|
114
|
+
await sleep(POLL_INTERVAL_MS)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Runs `work` holding the lock at `target` and answers its result, or `undefined` without running it
|
|
121
|
+
// when another holder kept the lock for the whole wait.
|
|
122
|
+
async function with_lock<T>(
|
|
123
|
+
work: () => Promise<T>,
|
|
124
|
+
target: string,
|
|
125
|
+
max_wait_ms: number,
|
|
126
|
+
): Promise<T | undefined> {
|
|
127
|
+
if (!(await acquire(target, max_wait_ms))) return undefined
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return await work()
|
|
131
|
+
} finally {
|
|
132
|
+
release(target)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const repository_lock = { clear_stale, lock_path, with_lock }
|
|
137
|
+
|
|
138
|
+
export { repository_lock }
|
|
@@ -13,7 +13,11 @@ import type { StashEntry } from './git-stash'
|
|
|
13
13
|
// opens with `N: ` is `git_stash.work_message`'s; and a lane branch (`On N-lane: …`) names it by where
|
|
14
14
|
// the work was pushed from. A subject is `On <branch>: <message>` (or `WIP on <branch>: …` without
|
|
15
15
|
// `-m`), and a branch name carries no `:`, so `(?:^|: )` anchors the message start either way.
|
|
16
|
-
const
|
|
16
|
+
const HASH_PATTERN = /#(?<issue>\d+)\b/u
|
|
17
|
+
const LEADING_PATTERN = /(?:^|: )(?<issue>\d+): /u
|
|
18
|
+
const MESSAGE_PATTERNS: ReadonlyArray<RegExp> = [HASH_PATTERN, LEADING_PATTERN]
|
|
19
|
+
// Every `#N` in the message rather than the first, for `issues_named`.
|
|
20
|
+
const ALL_ISSUE_PATTERN = new RegExp(HASH_PATTERN.source, 'gu')
|
|
17
21
|
const BRANCH_PATTERN = /^(?:WIP on|On) (?<issue>\d+)-lane: /u
|
|
18
22
|
// A stash pushed without `-m` lists as `WIP on <branch>: <sha> <HEAD's commit subject>` — text no run
|
|
19
23
|
// wrote. This repository's commit subjects end in `#N` and merges read `Merge pull request #N`, so a
|
|
@@ -61,6 +65,24 @@ function issues_of(entries: ReadonlyArray<StashEntry>): Array<string> {
|
|
|
61
65
|
return [...new Set(issues.filter((issue): issue is string => issue !== undefined))]
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
function message_issues(subject: string): Array<string | undefined> {
|
|
69
|
+
const hashed = [...subject.matchAll(ALL_ISSUE_PATTERN)].map((match) => match.groups?.['issue'])
|
|
70
|
+
|
|
71
|
+
return [...hashed, LEADING_PATTERN.exec(subject)?.groups?.['issue']]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every issue a subject names, not just its owner (joshuafolkken/kit#2701): `backlogrun: paused #M for
|
|
75
|
+
// prerequisite #N` is tied to both, and the `#N` of a `run:hold reclaimed before #N` entry — which
|
|
76
|
+
// `issue_of` reads as no owner — still names the run the work was found by. A `WIP on` subject is
|
|
77
|
+
// read by its branch alone, for the reason `WIP_PREFIX` records.
|
|
78
|
+
function issues_named(subject: string): Array<string> {
|
|
79
|
+
const branch = BRANCH_PATTERN.exec(subject)?.groups?.['issue']
|
|
80
|
+
const named = subject.startsWith(WIP_PREFIX) ? [] : message_issues(subject)
|
|
81
|
+
const found = [...named, branch].filter((issue): issue is string => issue !== undefined)
|
|
82
|
+
|
|
83
|
+
return [...new Set(found)]
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
// An entry is reported when its issue is in `closed`, or when it names no issue at all — an entry with
|
|
65
87
|
// no owner is exactly the one no run will ever pop. An open issue's entry is still some run's to resume.
|
|
66
88
|
function orphans(
|
|
@@ -85,7 +107,7 @@ function format_report(found: ReadonlyArray<OrphanStash>): string | undefined {
|
|
|
85
107
|
return [HEADER, ...found.map((orphan) => format_line(orphan)), FOOTER].join(LINE_SEPARATOR)
|
|
86
108
|
}
|
|
87
109
|
|
|
88
|
-
const stash_orphans = { format_report, issue_of, issues_of, orphans }
|
|
110
|
+
const stash_orphans = { format_report, issue_of, issues_named, issues_of, orphans }
|
|
89
111
|
|
|
90
112
|
export type { OrphanStash }
|
|
91
113
|
export { stash_orphans }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { repository_lock } from './repository-lock'
|
|
2
|
+
|
|
3
|
+
// One stash sweep at a time per repository (joshuafolkken/kit#2701). The stash is a single stack every
|
|
4
|
+
// work tree shares, and a `backlogrun` runs one sweep per lane seat at once: two sweeps that each
|
|
5
|
+
// resolve the same entry to `stash@{n}` and drop it would have the second drop land on whichever entry
|
|
6
|
+
// moved into that position, and two that carry the same ledger lines would both append them. The
|
|
7
|
+
// locking itself is `repository-lock.ts`, shared with the work-tree writes (joshuafolkken/kit#2736).
|
|
8
|
+
|
|
9
|
+
const LOCK_PREFIX = 'josh-stash-sweep-lock-'
|
|
10
|
+
// The locked phase is local git and one file append per entry, so a holder finishes in well under the
|
|
11
|
+
// wait; the cap only bounds a holder that hung, and a sweep that gives up keeps its entries.
|
|
12
|
+
const MAX_WAIT_MS = 30_000
|
|
13
|
+
|
|
14
|
+
function lock_path(cwd: string = process.cwd()): string {
|
|
15
|
+
return repository_lock.lock_path(LOCK_PREFIX, cwd)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Runs `work` holding the lock and answers its result, or `undefined` without running it when another
|
|
19
|
+
// sweep held the lock for the whole wait.
|
|
20
|
+
async function with_lock<T>(
|
|
21
|
+
work: () => Promise<T>,
|
|
22
|
+
target: string = lock_path(),
|
|
23
|
+
max_wait_ms: number = MAX_WAIT_MS,
|
|
24
|
+
): Promise<T | undefined> {
|
|
25
|
+
return await repository_lock.with_lock(work, target, max_wait_ms)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const stash_sweep_lock = { clear_stale: repository_lock.clear_stale, lock_path, with_lock }
|
|
29
|
+
|
|
30
|
+
export { stash_sweep_lock }
|
|
@@ -8,6 +8,7 @@ import { hook_decision } from '#scripts/josh/hook-decision'
|
|
|
8
8
|
import { session_language } from '#scripts/josh/session-language'
|
|
9
9
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
10
10
|
import { filing_cap } from '#scripts/rules/filing-cap'
|
|
11
|
+
import { lane_background } from '#scripts/rules/lane-background'
|
|
11
12
|
import { lane_park } from '#scripts/rules/lane-park'
|
|
12
13
|
import { stop_rules, type StopContext, type StopOutcome } from '#scripts/rules/stop-rules'
|
|
13
14
|
import { run_cut } from '#scripts/run/run-cut'
|
|
@@ -76,6 +77,7 @@ async function build_context(
|
|
|
76
77
|
headless_waiting: await run_headless.must_keep_waiting(),
|
|
77
78
|
headless_refusals: stop_rules.count_headless_refusals(tail),
|
|
78
79
|
lane_child: lane_child_marker.is_child_of(process.cwd()),
|
|
80
|
+
background_pending: lane_background.pending_background_ids(tail).length > 0,
|
|
79
81
|
session_lang: session_lang(),
|
|
80
82
|
}
|
|
81
83
|
}
|
|
@@ -218,8 +218,9 @@ function build_file_actions(shape?: ProjectShape): ReadonlyArray<FileAction> {
|
|
|
218
218
|
build_eslint_action(),
|
|
219
219
|
build_action(
|
|
220
220
|
PRETTIER_CONFIG_JS,
|
|
221
|
-
() =>
|
|
222
|
-
|
|
221
|
+
() =>
|
|
222
|
+
init_logic.generate_prettier_config(init_logic.detect_tailwind_stylesheet(PROJECT_ROOT)),
|
|
223
|
+
(existing) => init_logic.merge_prettier_config(existing, PROJECT_ROOT),
|
|
223
224
|
),
|
|
224
225
|
build_playwright_action(),
|
|
225
226
|
...build_config_file_actions().filter(
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { safe_chain_preinstall } from '#scripts/safe-chain/preinstall-command'
|
|
2
|
+
|
|
3
|
+
const PREINSTALL_KEY = 'preinstall'
|
|
1
4
|
const JF_PREFIX = 'jf-'
|
|
2
5
|
const JOSH_PREFIX = 'josh '
|
|
3
6
|
|
|
@@ -47,9 +50,15 @@ function migrate_jf_value(value: string): string {
|
|
|
47
50
|
return `${JOSH_PREFIX}${value.slice(JF_PREFIX.length)}`
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
function migrate_script(key: string, value: string): string {
|
|
54
|
+
const migrated = migrate_jf_value(value)
|
|
55
|
+
|
|
56
|
+
return key === PREINSTALL_KEY ? safe_chain_preinstall.migrate_preinstall(migrated) : migrated
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
function apply_jf_migrations(scripts: Record<string, string>): Record<string, string> {
|
|
51
60
|
return Object.fromEntries(
|
|
52
|
-
Object.entries(scripts).map(([key, value]) => [key,
|
|
61
|
+
Object.entries(scripts).map(([key, value]) => [key, migrate_script(key, value)]),
|
|
53
62
|
)
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
import { package_path } from './init-paths'
|
|
3
4
|
|
|
4
5
|
const ESLINT_VANILLA = `import { create_vanilla_config } from '@joshuafolkken/kit/eslint/vanilla'
|
|
@@ -111,20 +112,38 @@ function merge_eslint_config(existing: string): string {
|
|
|
111
112
|
return apply_rules_template(rules_blocks.join(',\n'))
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
// The default is SvelteKit's stylesheet, and prettier-plugin-tailwindcss aborts every format when
|
|
116
|
+
// the named file is missing — so it is written only for a project that has it (joshuafolkken/kit#2710).
|
|
117
|
+
function detect_tailwind_stylesheet(project_root: string): string | undefined {
|
|
118
|
+
return existsSync(path.join(project_root, DEFAULT_TAILWIND_STYLESHEET))
|
|
119
|
+
? DEFAULT_TAILWIND_STYLESHEET
|
|
120
|
+
: undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function generate_prettier_config(stylesheet?: string): string {
|
|
124
|
+
const stylesheet_line = stylesheet === undefined ? '' : `\ttailwindStylesheet: '${stylesheet}',\n`
|
|
125
|
+
|
|
115
126
|
return `import { config } from '@joshuafolkken/kit/prettier'
|
|
116
127
|
|
|
117
128
|
export default {
|
|
118
129
|
\t...config,
|
|
119
|
-
|
|
120
|
-
}
|
|
130
|
+
${stylesheet_line}}
|
|
121
131
|
`
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
// A named stylesheet is kept only while it exists: kit ≤1.929 wrote the SvelteKit default into every
|
|
135
|
+
// node project, and keeping that line would leave `josh sync` unable to repair the aborting format.
|
|
136
|
+
function existing_stylesheet(existing: string, project_root: string): string | undefined {
|
|
137
|
+
const named = TAILWIND_STYLESHEET_PATTERN.exec(existing)?.[1]
|
|
138
|
+
|
|
139
|
+
return named !== undefined && existsSync(path.resolve(project_root, named)) ? named : undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function merge_prettier_config(existing: string, project_root: string): string {
|
|
143
|
+
const stylesheet =
|
|
144
|
+
existing_stylesheet(existing, project_root) ?? detect_tailwind_stylesheet(project_root)
|
|
126
145
|
|
|
127
|
-
return generate_prettier_config(
|
|
146
|
+
return generate_prettier_config(stylesheet)
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
function generate_playwright_config(): string {
|
|
@@ -134,6 +153,7 @@ function generate_playwright_config(): string {
|
|
|
134
153
|
const init_logic_templates = {
|
|
135
154
|
generate_eslint_config,
|
|
136
155
|
merge_eslint_config,
|
|
156
|
+
detect_tailwind_stylesheet,
|
|
137
157
|
generate_prettier_config,
|
|
138
158
|
merge_prettier_config,
|
|
139
159
|
generate_playwright_config,
|