@joshuafolkken/kit 1.947.0 → 1.957.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-V7RH56HU.js → chunk-244WYOEO.js} +2 -2
- package/dist/hooks/{chunk-IA7WC4NS.js → chunk-3ON7HOYB.js} +1 -1
- package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-BELI5XSS.js} +6 -2
- package/dist/hooks/{chunk-IBFC25IX.js → chunk-GNOITLRR.js} +4 -4
- package/dist/hooks/{chunk-M4U4YNRE.js → chunk-HJXQZ7V4.js} +3 -3
- package/dist/hooks/{chunk-3FWV6CFC.js → chunk-IJPBTDTB.js} +371 -178
- package/dist/hooks/{chunk-NKVLBB65.js → chunk-KOCK5FXQ.js} +3 -3
- package/dist/hooks/{chunk-O3PEDF3M.js → chunk-LDQDZYHX.js} +1 -1
- package/dist/hooks/{chunk-GIX5SAAB.js → chunk-M6K7EQDO.js} +8 -8
- package/dist/hooks/{chunk-OM5VDM6F.js → chunk-XSL4SRMA.js} +2 -2
- 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 +3 -3
- package/dist/josh.js +6 -2
- package/dist/version/index.js +7 -4
- package/package.json +1 -1
- package/scripts/backlog/backlog-drive-cli.ts +5 -1
- package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
- package/scripts/backlog/backlog-drive.ts +7 -3
- package/scripts/git/git-worktree.ts +35 -4
- package/scripts/git/issue-labels.ts +19 -0
- package/scripts/git/repository-lock.ts +138 -0
- package/scripts/git/stash-sweep-lock.ts +6 -117
- package/scripts/init/project-profile.ts +9 -5
- package/scripts/init/start-plan.ts +145 -0
- package/scripts/init/start-prompt.ts +55 -0
- package/scripts/init/start-steps.ts +167 -0
- package/scripts/init/start.ts +92 -16
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
- package/scripts/josh/josh-commands-project.ts +6 -2
- package/scripts/run/run-retrospective.ts +34 -0
- package/scripts/run/run-step-cli.ts +3 -5
- package/scripts/run/run-step.ts +3 -15
- package/scripts/safe-chain/project-config.ts +55 -10
- package/scripts/sync/sync.ts +3 -1
- package/scripts/version/release-age.ts +11 -6
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
lane_child_marker
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-LDQDZYHX.js";
|
|
4
4
|
import {
|
|
5
5
|
time_bundle_call,
|
|
6
6
|
time_format,
|
|
7
7
|
time_round_trips,
|
|
8
8
|
time_spans,
|
|
9
9
|
time_transcript_line
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-BELI5XSS.js";
|
|
11
11
|
|
|
12
12
|
// scripts/lane/lane-guard-policy.ts
|
|
13
13
|
var DEFAULT_MODE = "refuse";
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import {
|
|
3
3
|
pretool_guard
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-M6K7EQDO.js";
|
|
5
|
+
import "./chunk-IJPBTDTB.js";
|
|
6
6
|
import {
|
|
7
7
|
build_envelope,
|
|
8
8
|
compose_context,
|
|
9
9
|
format_edited_payload
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-3ON7HOYB.js";
|
|
11
11
|
import "./chunk-3V37IID6.js";
|
|
12
|
-
import "./chunk-
|
|
13
|
-
import "./chunk-
|
|
14
|
-
import "./chunk-
|
|
15
|
-
import "./chunk-
|
|
16
|
-
import "./chunk-
|
|
12
|
+
import "./chunk-244WYOEO.js";
|
|
13
|
+
import "./chunk-KOCK5FXQ.js";
|
|
14
|
+
import "./chunk-GNOITLRR.js";
|
|
15
|
+
import "./chunk-HJXQZ7V4.js";
|
|
16
|
+
import "./chunk-XSL4SRMA.js";
|
|
17
17
|
import {
|
|
18
18
|
hook_decision
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-LDQDZYHX.js";
|
|
20
20
|
import {
|
|
21
21
|
time_density_hook
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-BELI5XSS.js";
|
|
23
23
|
import "./chunk-WPALEIRX.js";
|
|
24
24
|
import "./chunk-RKUAD3I6.js";
|
|
25
25
|
|
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
is_enabled,
|
|
10
10
|
notice_refusal_path,
|
|
11
11
|
refusal_path
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
14
|
-
import "./chunk-
|
|
15
|
-
import "./chunk-
|
|
16
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-GNOITLRR.js";
|
|
13
|
+
import "./chunk-HJXQZ7V4.js";
|
|
14
|
+
import "./chunk-XSL4SRMA.js";
|
|
15
|
+
import "./chunk-LDQDZYHX.js";
|
|
16
|
+
import "./chunk-BELI5XSS.js";
|
|
17
17
|
import "./chunk-WPALEIRX.js";
|
|
18
18
|
import "./chunk-RKUAD3I6.js";
|
|
19
19
|
export {
|
|
@@ -15,9 +15,9 @@ import {
|
|
|
15
15
|
relative_to_root,
|
|
16
16
|
resolve_invocations,
|
|
17
17
|
select_invocation
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-3ON7HOYB.js";
|
|
19
19
|
import "./chunk-3V37IID6.js";
|
|
20
|
-
import "./chunk-
|
|
20
|
+
import "./chunk-BELI5XSS.js";
|
|
21
21
|
import "./chunk-RKUAD3I6.js";
|
|
22
22
|
export {
|
|
23
23
|
ESLINT_DAEMON,
|
|
@@ -10,11 +10,11 @@ import {
|
|
|
10
10
|
is_enabled,
|
|
11
11
|
load_environment_file,
|
|
12
12
|
refusal_path
|
|
13
|
-
} from "./chunk-
|
|
14
|
-
import "./chunk-
|
|
15
|
-
import "./chunk-
|
|
16
|
-
import "./chunk-
|
|
17
|
-
import "./chunk-
|
|
13
|
+
} from "./chunk-KOCK5FXQ.js";
|
|
14
|
+
import "./chunk-HJXQZ7V4.js";
|
|
15
|
+
import "./chunk-XSL4SRMA.js";
|
|
16
|
+
import "./chunk-LDQDZYHX.js";
|
|
17
|
+
import "./chunk-BELI5XSS.js";
|
|
18
18
|
import "./chunk-WPALEIRX.js";
|
|
19
19
|
import "./chunk-RKUAD3I6.js";
|
|
20
20
|
export {
|
|
@@ -3,16 +3,16 @@ import {
|
|
|
3
3
|
pretool_guard,
|
|
4
4
|
pretool_outcome,
|
|
5
5
|
pretool_outcome_async
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-M6K7EQDO.js";
|
|
7
|
+
import "./chunk-IJPBTDTB.js";
|
|
8
8
|
import "./chunk-3V37IID6.js";
|
|
9
|
-
import "./chunk-
|
|
10
|
-
import "./chunk-
|
|
11
|
-
import "./chunk-
|
|
12
|
-
import "./chunk-
|
|
13
|
-
import "./chunk-
|
|
14
|
-
import "./chunk-
|
|
15
|
-
import "./chunk-
|
|
9
|
+
import "./chunk-244WYOEO.js";
|
|
10
|
+
import "./chunk-KOCK5FXQ.js";
|
|
11
|
+
import "./chunk-GNOITLRR.js";
|
|
12
|
+
import "./chunk-HJXQZ7V4.js";
|
|
13
|
+
import "./chunk-XSL4SRMA.js";
|
|
14
|
+
import "./chunk-LDQDZYHX.js";
|
|
15
|
+
import "./chunk-BELI5XSS.js";
|
|
16
16
|
import "./chunk-WPALEIRX.js";
|
|
17
17
|
import "./chunk-RKUAD3I6.js";
|
|
18
18
|
export {
|
package/dist/hooks/stop-guard.js
CHANGED
|
@@ -16,20 +16,20 @@ import {
|
|
|
16
16
|
run_hold,
|
|
17
17
|
run_invocation,
|
|
18
18
|
run_watcher_guard
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-IJPBTDTB.js";
|
|
20
20
|
import {
|
|
21
21
|
session_language
|
|
22
22
|
} from "./chunk-6V5OPIUU.js";
|
|
23
23
|
import {
|
|
24
24
|
hook_decision,
|
|
25
25
|
lane_child_marker
|
|
26
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-LDQDZYHX.js";
|
|
27
27
|
import {
|
|
28
28
|
PROJECT_ROOT,
|
|
29
29
|
stamp_file,
|
|
30
30
|
time_density_hook,
|
|
31
31
|
time_hook_transcript
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-BELI5XSS.js";
|
|
33
33
|
import "./chunk-WPALEIRX.js";
|
|
34
34
|
import "./chunk-RKUAD3I6.js";
|
|
35
35
|
|
package/dist/josh.js
CHANGED
|
@@ -1173,9 +1173,13 @@ var PROJECT_COMMANDS = {
|
|
|
1173
1173
|
},
|
|
1174
1174
|
start: {
|
|
1175
1175
|
script: "scripts/init/start.ts",
|
|
1176
|
-
description: "
|
|
1176
|
+
description: "Create a new project for the GitHub Issue workflow, from git init to labels",
|
|
1177
1177
|
category: "Project",
|
|
1178
|
-
reference: [
|
|
1178
|
+
reference: [
|
|
1179
|
+
`${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
|
|
1180
|
+
"developer",
|
|
1181
|
+
["files", "git", "network", "processes"]
|
|
1182
|
+
]
|
|
1179
1183
|
},
|
|
1180
1184
|
init: {
|
|
1181
1185
|
script: "scripts/init/init.ts",
|
package/dist/version/index.js
CHANGED
|
@@ -163,9 +163,12 @@ var NO_QUARANTINE_MINUTES = 0;
|
|
|
163
163
|
var NPMRC_PATH = ".npmrc";
|
|
164
164
|
var release_times_schema = z2.record(z2.string(), z2.string());
|
|
165
165
|
var MS_PER_MINUTE = 6e4;
|
|
166
|
-
function
|
|
166
|
+
function parse_declared_minimum_release_age(npmrc_content) {
|
|
167
167
|
const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1];
|
|
168
|
-
return raw === void 0 ?
|
|
168
|
+
return raw === void 0 ? void 0 : Number(raw);
|
|
169
|
+
}
|
|
170
|
+
function parse_minimum_release_age(npmrc_content) {
|
|
171
|
+
return parse_declared_minimum_release_age(npmrc_content) ?? NO_QUARANTINE_MINUTES;
|
|
169
172
|
}
|
|
170
173
|
function is_on_major(version, major) {
|
|
171
174
|
return major === void 0 || String(semver.major(version)) === major;
|
|
@@ -184,8 +187,7 @@ function select_aged_version(times, major, minimum_age_minutes, now_ms) {
|
|
|
184
187
|
}
|
|
185
188
|
function read_declared_minimum_release_age(npmrc_path) {
|
|
186
189
|
try {
|
|
187
|
-
|
|
188
|
-
return raw === void 0 ? void 0 : Number(raw);
|
|
190
|
+
return parse_declared_minimum_release_age(readFileSync2(npmrc_path, "utf8"));
|
|
189
191
|
} catch {
|
|
190
192
|
return void 0;
|
|
191
193
|
}
|
|
@@ -206,6 +208,7 @@ function read_nearest_minimum_release_age(start, boundary) {
|
|
|
206
208
|
var release_age = {
|
|
207
209
|
release_times_schema,
|
|
208
210
|
parse_minimum_release_age,
|
|
211
|
+
parse_declared_minimum_release_age,
|
|
209
212
|
read_minimum_release_age,
|
|
210
213
|
read_nearest_minimum_release_age,
|
|
211
214
|
select_aged_version
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.957.0",
|
|
3
3
|
"name": "@joshuafolkken/kit",
|
|
4
4
|
"description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
|
|
5
5
|
"keywords": [
|
|
@@ -3,6 +3,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { parseArgs } from 'node:util'
|
|
5
5
|
import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
|
|
6
|
+
import { hook_decision } from '#scripts/josh/hook-decision'
|
|
6
7
|
import { josh_command } from '#scripts/josh/josh-run'
|
|
7
8
|
import { lane_await, type AwaitState } from '#scripts/lane/lane-await'
|
|
8
9
|
import { lane_launch_cli } from '#scripts/lane/lane-launch-cli'
|
|
@@ -24,6 +25,7 @@ import { backlog_drive_named_offer } from './backlog-drive-named-offer'
|
|
|
24
25
|
import { backlog_drive_offer_argv } from './backlog-drive-offer-argv'
|
|
25
26
|
import { backlog_drive_owner } from './backlog-drive-owner'
|
|
26
27
|
import { backlog_drive_restore } from './backlog-drive-restore'
|
|
28
|
+
import { backlog_drive_retrospective } from './backlog-drive-retrospective'
|
|
27
29
|
import { backlog_offer_cli } from './backlog-offer-cli'
|
|
28
30
|
|
|
29
31
|
// `josh backlog:drive` — the backlogrun parent's loop as one resident wait (joshuafolkken/kit#2499).
|
|
@@ -193,7 +195,7 @@ async function read_offer(
|
|
|
193
195
|
|
|
194
196
|
return offer === undefined
|
|
195
197
|
? undefined
|
|
196
|
-
: { ...offer,
|
|
198
|
+
: { ...offer, is_retrospective_owed: backlog_drive_retrospective.is_owed(carry) }
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
// A non-zero `run:merge` prints its token first only for a hand-off (`busy`, `stop`, `retry`); any
|
|
@@ -357,6 +359,8 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
|
357
359
|
}
|
|
358
360
|
|
|
359
361
|
async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
362
|
+
// Load `.env` on the real command path only, so `JOSH_RETROSPECTIVE` set there reaches the offer read.
|
|
363
|
+
hook_decision.load_environment_file()
|
|
360
364
|
process.exitCode = await run(argv)
|
|
361
365
|
}
|
|
362
366
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
|
|
2
|
+
import { find_package_directory } from '#scripts/josh/josh-logic'
|
|
3
|
+
import type { RunCarry } from '#scripts/run/run-carry'
|
|
4
|
+
import { run_retrospective } from '#scripts/run/run-retrospective'
|
|
5
|
+
|
|
6
|
+
// Whether a drain owes the retrospective, read through the owed rule `run:step` reads, so a drain it
|
|
7
|
+
// owes nothing at keeps the idle watch rather than waking a session with nothing to run
|
|
8
|
+
// (joshuafolkken/kit#2750). The drive is the parent's loop, never a lane child's.
|
|
9
|
+
function is_owed(carry: RunCarry): boolean {
|
|
10
|
+
const root = find_package_directory(process.cwd())
|
|
11
|
+
|
|
12
|
+
return run_retrospective.is_owed({
|
|
13
|
+
is_retrospective_enabled: run_retrospective.is_enabled(),
|
|
14
|
+
is_lane_child: false,
|
|
15
|
+
is_retrospective_done: carry.retrospective === true,
|
|
16
|
+
is_consumer: doctor_consumer.is_kit_consumer(root),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const backlog_drive_retrospective = { is_owed }
|
|
@@ -45,7 +45,9 @@ interface OfferRead {
|
|
|
45
45
|
retries: number
|
|
46
46
|
answer?: string | undefined
|
|
47
47
|
reason?: string | undefined
|
|
48
|
-
|
|
48
|
+
// Whether a drain here owes the retrospective: the opt-in switch is on and it has not run yet. Absent
|
|
49
|
+
// reads as not owed, so a drain with the switch off keeps the idle watch (joshuafolkken/kit#2750).
|
|
50
|
+
is_retrospective_owed?: boolean
|
|
49
51
|
is_finish?: boolean | undefined
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -106,7 +108,9 @@ function ended(reason: string, state: DriveState, token?: string, issue?: string
|
|
|
106
108
|
|
|
107
109
|
function is_drain(read: OfferRead, state: DriveState): boolean {
|
|
108
110
|
return (
|
|
109
|
-
read.verdict === WATCH_VERDICT &&
|
|
111
|
+
read.verdict === WATCH_VERDICT &&
|
|
112
|
+
state.in_flight.length === 0 &&
|
|
113
|
+
read.is_retrospective_owed === true
|
|
110
114
|
)
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -115,7 +119,7 @@ function needs_retrospective(read: OfferRead, state: DriveState): boolean {
|
|
|
115
119
|
read.verdict === STOP_VERDICT &&
|
|
116
120
|
read.answer === 'exhausted' &&
|
|
117
121
|
state.in_flight.length === 0 &&
|
|
118
|
-
|
|
122
|
+
read.is_retrospective_owed === true
|
|
119
123
|
)
|
|
120
124
|
}
|
|
121
125
|
|
|
@@ -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**
|
|
@@ -177,6 +177,24 @@ const DEPTH_LABELS: ReadonlyArray<{
|
|
|
177
177
|
},
|
|
178
178
|
]
|
|
179
179
|
|
|
180
|
+
// What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
|
|
181
|
+
// applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
|
|
182
|
+
// the one the workflow documents create them with; the labels only a person applies are left out.
|
|
183
|
+
const WORKFLOW_LABELS: ReadonlyArray<{
|
|
184
|
+
name: string
|
|
185
|
+
color: string
|
|
186
|
+
description: string
|
|
187
|
+
}> = [
|
|
188
|
+
{
|
|
189
|
+
name: EPIC_LABEL,
|
|
190
|
+
color: '5319e7',
|
|
191
|
+
description: 'Tracks a batch of child issues from one split',
|
|
192
|
+
},
|
|
193
|
+
{ name: IN_PROGRESS_LABEL, color: '0075ca', description: 'Work is actively in progress' },
|
|
194
|
+
...FILING_ROUTE_LABELS,
|
|
195
|
+
...DEPTH_LABELS,
|
|
196
|
+
]
|
|
197
|
+
|
|
180
198
|
// The shape `gh issue list --json labels` returns; narrowed here so the predicate below takes any
|
|
181
199
|
// listing row without importing a schema.
|
|
182
200
|
interface LabelReference {
|
|
@@ -285,4 +303,5 @@ export {
|
|
|
285
303
|
REVIEW_ROUND2_SKIPPED_LABEL,
|
|
286
304
|
SPLIT_ROUTE_LABEL,
|
|
287
305
|
TIER_A_ROUTE_LABEL,
|
|
306
|
+
WORKFLOW_LABELS,
|
|
288
307
|
}
|
|
@@ -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 }
|