@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.
Files changed (41) hide show
  1. package/README.md +4 -5
  2. package/dist/hooks/batch-guard.js +4 -4
  3. package/dist/hooks/{chunk-V7RH56HU.js → chunk-244WYOEO.js} +2 -2
  4. package/dist/hooks/{chunk-IA7WC4NS.js → chunk-3ON7HOYB.js} +1 -1
  5. package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-BELI5XSS.js} +6 -2
  6. package/dist/hooks/{chunk-IBFC25IX.js → chunk-GNOITLRR.js} +4 -4
  7. package/dist/hooks/{chunk-M4U4YNRE.js → chunk-HJXQZ7V4.js} +3 -3
  8. package/dist/hooks/{chunk-3FWV6CFC.js → chunk-IJPBTDTB.js} +371 -178
  9. package/dist/hooks/{chunk-NKVLBB65.js → chunk-KOCK5FXQ.js} +3 -3
  10. package/dist/hooks/{chunk-O3PEDF3M.js → chunk-LDQDZYHX.js} +1 -1
  11. package/dist/hooks/{chunk-GIX5SAAB.js → chunk-M6K7EQDO.js} +8 -8
  12. package/dist/hooks/{chunk-OM5VDM6F.js → chunk-XSL4SRMA.js} +2 -2
  13. package/dist/hooks/codex-hook-adapter.js +10 -10
  14. package/dist/hooks/duplicate-read-guard.js +5 -5
  15. package/dist/hooks/format-edited.js +2 -2
  16. package/dist/hooks/investigation-guard.js +5 -5
  17. package/dist/hooks/pretool-guard.js +9 -9
  18. package/dist/hooks/stop-guard.js +3 -3
  19. package/dist/josh.js +6 -2
  20. package/dist/version/index.js +7 -4
  21. package/package.json +1 -1
  22. package/scripts/backlog/backlog-drive-cli.ts +5 -1
  23. package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
  24. package/scripts/backlog/backlog-drive.ts +7 -3
  25. package/scripts/git/git-worktree.ts +35 -4
  26. package/scripts/git/issue-labels.ts +19 -0
  27. package/scripts/git/repository-lock.ts +138 -0
  28. package/scripts/git/stash-sweep-lock.ts +6 -117
  29. package/scripts/init/project-profile.ts +9 -5
  30. package/scripts/init/start-plan.ts +145 -0
  31. package/scripts/init/start-prompt.ts +55 -0
  32. package/scripts/init/start-steps.ts +167 -0
  33. package/scripts/init/start.ts +92 -16
  34. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  35. package/scripts/josh/josh-commands-project.ts +6 -2
  36. package/scripts/run/run-retrospective.ts +34 -0
  37. package/scripts/run/run-step-cli.ts +3 -5
  38. package/scripts/run/run-step.ts +3 -15
  39. package/scripts/safe-chain/project-config.ts +55 -10
  40. package/scripts/sync/sync.ts +3 -1
  41. package/scripts/version/release-age.ts +11 -6
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  lane_child_marker
3
- } from "./chunk-O3PEDF3M.js";
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-EYFJ74MJ.js";
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-GIX5SAAB.js";
5
- import "./chunk-3FWV6CFC.js";
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-IA7WC4NS.js";
10
+ } from "./chunk-3ON7HOYB.js";
11
11
  import "./chunk-3V37IID6.js";
12
- import "./chunk-V7RH56HU.js";
13
- import "./chunk-NKVLBB65.js";
14
- import "./chunk-IBFC25IX.js";
15
- import "./chunk-M4U4YNRE.js";
16
- import "./chunk-OM5VDM6F.js";
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-O3PEDF3M.js";
19
+ } from "./chunk-LDQDZYHX.js";
20
20
  import {
21
21
  time_density_hook
22
- } from "./chunk-EYFJ74MJ.js";
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-IBFC25IX.js";
13
- import "./chunk-M4U4YNRE.js";
14
- import "./chunk-OM5VDM6F.js";
15
- import "./chunk-O3PEDF3M.js";
16
- import "./chunk-EYFJ74MJ.js";
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-IA7WC4NS.js";
18
+ } from "./chunk-3ON7HOYB.js";
19
19
  import "./chunk-3V37IID6.js";
20
- import "./chunk-EYFJ74MJ.js";
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-NKVLBB65.js";
14
- import "./chunk-M4U4YNRE.js";
15
- import "./chunk-OM5VDM6F.js";
16
- import "./chunk-O3PEDF3M.js";
17
- import "./chunk-EYFJ74MJ.js";
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-GIX5SAAB.js";
7
- import "./chunk-3FWV6CFC.js";
6
+ } from "./chunk-M6K7EQDO.js";
7
+ import "./chunk-IJPBTDTB.js";
8
8
  import "./chunk-3V37IID6.js";
9
- import "./chunk-V7RH56HU.js";
10
- import "./chunk-NKVLBB65.js";
11
- import "./chunk-IBFC25IX.js";
12
- import "./chunk-M4U4YNRE.js";
13
- import "./chunk-OM5VDM6F.js";
14
- import "./chunk-O3PEDF3M.js";
15
- import "./chunk-EYFJ74MJ.js";
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 {
@@ -16,20 +16,20 @@ import {
16
16
  run_hold,
17
17
  run_invocation,
18
18
  run_watcher_guard
19
- } from "./chunk-3FWV6CFC.js";
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-O3PEDF3M.js";
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-EYFJ74MJ.js";
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: "Initialize a project for the GitHub Issue workflow",
1176
+ description: "Create a new project for the GitHub Issue workflow, from git init to labels",
1177
1177
  category: "Project",
1178
- reference: [PROFILE_ARGUMENTS, "developer", ["files", "git", "network"]]
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",
@@ -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 parse_minimum_release_age(npmrc_content) {
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 ? NO_QUARANTINE_MINUTES : Number(raw);
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
- const raw = MINIMUM_RELEASE_AGE_RE.exec(readFileSync2(npmrc_path, "utf8"))?.[1];
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.947.0",
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, is_retrospective_done: carry.retrospective === true }
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
- is_retrospective_done?: boolean
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 && state.in_flight.length === 0 && !read.is_retrospective_done
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
- !read.is_retrospective_done
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 git_spawn.read([WORKTREE, ...flags, directory, start_point ?? branch_name])
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 git_spawn.read([WORKTREE, 'add', '--detach', directory, commit])
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 git_spawn.read([WORKTREE, 'remove', '--force', directory])
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 git_spawn.read([WORKTREE, 'prune'])
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 }