@joshuafolkken/kit 1.998.0 → 1.1005.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.
@@ -295,7 +295,7 @@ answer=$(pnpm josh run:hold 926) # alias: josh rh ; one token on stdout, prose
295
295
  | --- | --- | --- |
296
296
  | `hold` | Nothing left behind; the tree is claimed | Start the child. This is the ordinary answer. |
297
297
  | `reclaim` | Uncommitted changes, or HEAD off the default branch | Run what stderr printed: `git stash push -u -m "run:hold reclaimed before #<N>"`, then `git switch <default> && git pull`. **`-u` is not optional** (a new `*.test.ts` is untracked). **Then record the stash on `#<N>` as a comment, and ask `run:hold` again** — the check is re-askable. |
298
- | `resume` | A branch for `#<N>`, or an open pull request, is still here | **Reuse it and run the whole verification gate from the start** — never only the part the interrupted run had not reached. The tools are idempotent; what was missing was that **nobody had verified what the dead run already committed**. |
298
+ | `resume` | An open PR, or a `#<N>` branch with commits or a dirty lane | **Reuse it and run the whole verification gate from the start** — never only the part the interrupted run had not reached. The tools are idempotent; what was missing was that **nobody had verified what the dead run already committed**. |
299
299
  | `park` | The pull request for `#<N>` is **merged** or **closed** | **Park the child** — `needs-decision` plus a comment naming what was found — and continue. Carrying on over a merged PR duplicates work; over a closed one revives rejected work. Do not delete the branch, reopen the PR, or commit on top of it. |
300
300
  | `busy` | Another **live** run holds this tree | Stop before filing anything — `run:hold`'s own answer, unchanged. |
301
301
  | `unknown` | The tree could not be read | Stop the session and report. It is not "the tree is clean". |
@@ -314,6 +314,5 @@ stash reference where there was one.
314
314
 
315
315
  **It is not asked in a lane** — `run:hold` claimed inside a lane skips the check, and `lane:open`'s own
316
316
  answer covers an interrupted lane instead. **Asked in the primary checkout while a lane for that child
317
- is open, `run:hold` would read `resume`, which is correct** — the branch read
318
- `git branch --list '<N>-*'` matches `<N>-lane`. The loop never reaches it that way, because `epic:next`
319
- does not offer a child already carrying `in-progress`.
317
+ is open, `run:hold` reads `resume` only if `<N>-lane` has a commit or a dirty tree** — a clean one
318
+ at the default branch reads `hold`. The loop never gets there, because `epic:next` does not offer a child already carrying `in-progress`.
@@ -36,7 +36,7 @@ import {
36
36
  run_watcher_guard,
37
37
  shell_segments,
38
38
  tail_commands
39
- } from "./chunk-7WAOP42A.js";
39
+ } from "./chunk-EZLHXKJ6.js";
40
40
  import {
41
41
  find_local_bin_upwards
42
42
  } from "./chunk-3V37IID6.js";
@@ -3533,7 +3533,8 @@ import { execaSync as execaSync2 } from "execa";
3533
3533
  import { z as z8 } from "zod";
3534
3534
  var CLASSIFICATION_SET = new Set(RELEASE_CLASSIFICATION_NAMES);
3535
3535
  var ISSUE_BUG_LABEL = "bug";
3536
- var DECLARATION = /^- リリース分類: (.+)$/gmu;
3536
+ var DECLARATION_PREFIX = "- \u30EA\u30EA\u30FC\u30B9\u5206\u985E: ";
3537
+ var DECLARATION = new RegExp(`^${DECLARATION_PREFIX}(.+)$`, "gmu");
3537
3538
  var LEGACY_LABELS = /* @__PURE__ */ new Set(["semver-major", "semver-minor"]);
3538
3539
  var LABEL = z8.object({ name: z8.string() });
3539
3540
  var USER = z8.object({ login: z8.string() });
@@ -3552,6 +3553,9 @@ function classification_error(labels, author) {
3552
3553
  if (selected.length === 1 || author.endsWith("[bot]")) return void 0;
3553
3554
  return `Choose one release classification: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}`;
3554
3555
  }
3556
+ function declaration(label) {
3557
+ return `${DECLARATION_PREFIX}${label}`;
3558
+ }
3555
3559
  function is_classification(value) {
3556
3560
  return CLASSIFICATION_SET.has(value);
3557
3561
  }
@@ -3577,7 +3581,7 @@ function select_issue_classification(issue_json) {
3577
3581
  const [label] = selected;
3578
3582
  if (label === void 0 || selected.length > 1) {
3579
3583
  throw new Error(
3580
- `Choose one release classification before opening the PR: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}; found: ${selected.join(", ") || "none"}. Add "- \u30EA\u30EA\u30FC\u30B9\u5206\u985E: <label>" to the Issue body.`
3584
+ `Choose one release classification before opening the PR: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}; found: ${selected.join(", ") || "none"}. Add "${DECLARATION_PREFIX}<label>" to the Issue body.`
3581
3585
  );
3582
3586
  }
3583
3587
  return label;
@@ -3610,6 +3614,7 @@ if (process.argv[1] === fileURLToPath2(import.meta.url)) {
3610
3614
  var pr_classification = {
3611
3615
  classification_error,
3612
3616
  check_event,
3617
+ declaration,
3613
3618
  is_classification,
3614
3619
  select_issue_classification
3615
3620
  };
@@ -5113,7 +5118,7 @@ var GH_PR_STATES = /* @__PURE__ */ new Map([
5113
5118
  ["MERGED", MERGED_PR],
5114
5119
  ["OPEN", OPEN_PR]
5115
5120
  ]);
5116
- var CLEAN_REASON = "Nothing was left behind: the tree is clean, HEAD is on the default branch, and this issue has no branch or pull request.";
5121
+ var CLEAN_REASON = "Nothing was left behind: the tree is clean, HEAD is on the default branch, and this issue has no pull request and no branch holding work.";
5117
5122
  var CLEAN_ADVICE = "Start the child.";
5118
5123
  var RESUME_ADVICE = "Reuse the branch and run the whole verification gate from the start \u2014 refactor, `pnpm josh gate`, `/code-review` \u2014 never only the part the interrupted run had not reached. Nobody verified what it already committed.";
5119
5124
  var PARK_ADVICE = "Park the child: add `needs-decision` and comment what was found. Do not delete the branch, do not reopen the pull request, and do not commit on top of it.";
@@ -5228,19 +5233,51 @@ function to_head_branch(name) {
5228
5233
  const separator_index = name.indexOf(REMOTE_SEPARATOR);
5229
5234
  return separator_index === NOT_FOUND_INDEX ? name : name.slice(separator_index + 1);
5230
5235
  }
5236
+ function group_by_head(pairs) {
5237
+ const groups = /* @__PURE__ */ new Map();
5238
+ for (const [name, reference] of pairs) groups.set(name, [...groups.get(name) ?? [], reference]);
5239
+ return groups;
5240
+ }
5231
5241
  async function read_branch_candidates(issue) {
5232
5242
  const pattern = `${issue}${ISSUE_BRANCH_SUFFIX}`;
5233
5243
  const [local, remote] = await Promise.all([
5234
5244
  git_command.branch_names(pattern),
5235
5245
  git_command.branch_names_remote(`${ANY_REMOTE_PREFIX}${pattern}`)
5236
5246
  ]);
5237
- return [.../* @__PURE__ */ new Set([...local, ...remote.map((name) => to_head_branch(name))])];
5247
+ return group_by_head([
5248
+ ...local.map((name) => [name, name]),
5249
+ ...remote.map((reference) => [to_head_branch(reference), reference])
5250
+ ]);
5251
+ }
5252
+ async function has_commits_beyond(base, references) {
5253
+ const counts = await Promise.all(
5254
+ references.map(async (reference) => await git_command.commit_count_beyond(base, reference))
5255
+ );
5256
+ return counts.some((count) => count > 0);
5257
+ }
5258
+ async function is_held_dirty(name, lane) {
5259
+ if (lane?.branch !== name || lane.is_stranded) return false;
5260
+ return await run_hold.is_tree_dirty(lane.directory);
5261
+ }
5262
+ async function read_candidate(name, references, context) {
5263
+ const state = await read_pr_of_branch(name);
5264
+ if (state.pr_state !== NO_PR) return state;
5265
+ if (await has_commits_beyond(context.base, references)) return state;
5266
+ return await is_held_dirty(name, context.lane) ? state : void 0;
5238
5267
  }
5239
5268
  async function read_child_state(issue) {
5240
5269
  const candidates = await read_branch_candidates(issue);
5241
- return pick_child_state(
5242
- await Promise.all(candidates.map(async (name) => await read_pr_of_branch(name)))
5270
+ if (candidates.size === 0) return NO_CHILD_WORK;
5271
+ const [base, lane] = await Promise.all([
5272
+ git_command.default_branch_reference(),
5273
+ lane_registry.find_open_lane(issue)
5274
+ ]);
5275
+ const reads = await Promise.all(
5276
+ [...candidates].map(
5277
+ async ([name, references]) => await read_candidate(name, references, { base, lane })
5278
+ )
5243
5279
  );
5280
+ return pick_child_state(reads.filter((read2) => read2 !== void 0));
5244
5281
  }
5245
5282
  async function check2(issue) {
5246
5283
  require_issue_number(issue);
@@ -264,8 +264,9 @@ var git_spawn = {
264
264
  async function branch() {
265
265
  return await git_spawn.read(["rev-parse", "--abbrev-ref", "HEAD"]);
266
266
  }
267
- async function status() {
268
- return await git_spawn.read(["status", PORCELAIN_FLAG, UNTRACKED_FILES_FLAG]);
267
+ async function status(directory) {
268
+ const location = directory === void 0 ? [] : ["-C", directory];
269
+ return await git_spawn.read([...location, "status", PORCELAIN_FLAG, UNTRACKED_FILES_FLAG]);
269
270
  }
270
271
  async function repository_root() {
271
272
  return await git_spawn.read(["rev-parse", "--show-toplevel"]);
@@ -607,9 +608,9 @@ function create_hold(target, issue, now = /* @__PURE__ */ new Date(), is_fullrun
607
608
  function create_halfrun_stop_hold(target, issue, now = /* @__PURE__ */ new Date()) {
608
609
  return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true });
609
610
  }
610
- async function is_tree_dirty() {
611
+ async function is_tree_dirty(directory) {
611
612
  try {
612
- const status2 = await git_command.status();
613
+ const status2 = await git_command.status(directory);
613
614
  return status2.trim() !== "";
614
615
  } catch {
615
616
  return true;
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env tsx
2
2
  import {
3
3
  pretool_guard
4
- } from "./chunk-762BDXDU.js";
5
- import "./chunk-7WAOP42A.js";
4
+ } from "./chunk-6PSTVOLH.js";
5
+ import "./chunk-EZLHXKJ6.js";
6
6
  import {
7
7
  build_envelope,
8
8
  compose_context,
@@ -3,8 +3,8 @@ import {
3
3
  pretool_guard,
4
4
  pretool_outcome,
5
5
  pretool_outcome_async
6
- } from "./chunk-762BDXDU.js";
7
- import "./chunk-7WAOP42A.js";
6
+ } from "./chunk-6PSTVOLH.js";
7
+ import "./chunk-EZLHXKJ6.js";
8
8
  import "./chunk-3V37IID6.js";
9
9
  import "./chunk-A2H5I2VD.js";
10
10
  import "./chunk-KPZN42RT.js";
@@ -19,7 +19,7 @@ import {
19
19
  run_hold,
20
20
  run_invocation,
21
21
  run_watcher_guard
22
- } from "./chunk-7WAOP42A.js";
22
+ } from "./chunk-EZLHXKJ6.js";
23
23
  import {
24
24
  session_language
25
25
  } from "./chunk-6V5OPIUU.js";
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.998.0",
2
+ "version": "1.1005.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": [
@@ -12,7 +12,11 @@ import { z } from 'zod'
12
12
 
13
13
  const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
14
14
  const ISSUE_BUG_LABEL = 'bug'
15
- const DECLARATION = /^- リリース分類: (.+)$/gmu
15
+ // The line an Issue body declares its release classification with. A body written by a script
16
+ // (`josh propagate`'s upgrade issue) builds it with `declaration`, so the writer and this parser share
17
+ // one format (joshuafolkken/kit#2858).
18
+ const DECLARATION_PREFIX = '- リリース分類: '
19
+ const DECLARATION = new RegExp(`^${DECLARATION_PREFIX}(.+)$`, 'gmu')
16
20
  const LEGACY_LABELS: ReadonlySet<string> = new Set(['semver-major', 'semver-minor'])
17
21
 
18
22
  const LABEL = z.object({ name: z.string() })
@@ -38,6 +42,10 @@ function classification_error(labels: ReadonlyArray<string>, author: string): st
38
42
  return `Choose one release classification: ${CLASSIFICATION_LABELS.join(', ')}`
39
43
  }
40
44
 
45
+ function declaration(label: ReleaseClassification): string {
46
+ return `${DECLARATION_PREFIX}${label}`
47
+ }
48
+
41
49
  function is_classification(value: string): value is ReleaseClassification {
42
50
  return CLASSIFICATION_SET.has(value)
43
51
  }
@@ -75,7 +83,7 @@ function select_issue_classification(issue_json: string): ReleaseClassification
75
83
 
76
84
  if (label === undefined || selected.length > 1) {
77
85
  throw new Error(
78
- `Choose one release classification before opening the PR: ${CLASSIFICATION_LABELS.join(', ')}; found: ${selected.join(', ') || 'none'}. Add "- リリース分類: <label>" to the Issue body.`,
86
+ `Choose one release classification before opening the PR: ${CLASSIFICATION_LABELS.join(', ')}; found: ${selected.join(', ') || 'none'}. Add "${DECLARATION_PREFIX}<label>" to the Issue body.`,
79
87
  )
80
88
  }
81
89
 
@@ -125,6 +133,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
125
133
  const pr_classification = {
126
134
  classification_error,
127
135
  check_event,
136
+ declaration,
128
137
  is_classification,
129
138
  select_issue_classification,
130
139
  }
@@ -19,8 +19,11 @@ async function branch(): Promise<string> {
19
19
  // recognizes the observation ledger by its path then misses it: `git-staging.ts` stages the directory
20
20
  // — ledger included — into an unrelated pull request, and the flush refuses the directory as "other
21
21
  // changes".
22
- async function status(): Promise<string> {
23
- return await git_spawn.read(['status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
22
+ // `directory` reads another work tree of the repository — a lane — instead of this one.
23
+ async function status(directory?: string): Promise<string> {
24
+ const location = directory === undefined ? [] : ['-C', directory]
25
+
26
+ return await git_spawn.read([...location, 'status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
24
27
  }
25
28
 
26
29
  // The absolute path every other git command's output is relative to. Asking git rather than reading
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
1
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { z } from 'zod'
4
+ import { init_basic } from './init-basic'
4
5
  import { init_install, type InstallStep } from './init-install'
5
6
  import { init_logic_workspace } from './init-logic-workspace'
6
7
  import { project_profile } from './project-profile'
@@ -73,6 +74,14 @@ function handoff_step(args: ReadonlyArray<string>): InstallStep {
73
74
  }
74
75
  }
75
76
 
77
+ // pnpm walks up to the nearest `package.json`, so without one here it installs kit into whatever
78
+ // project an ancestor directory holds and rewrites that project's files (joshuafolkken/kit#2866).
79
+ function ensure_project_manifest(project_root: string): void {
80
+ const manifest_path = path.join(project_root, PACKAGE_JSON)
81
+
82
+ if (!existsSync(manifest_path)) writeFileSync(manifest_path, init_basic.initial_manifest())
83
+ }
84
+
76
85
  // Returns the failure to report, or undefined once the project-installed kit finished the setup.
77
86
  function hand_off(
78
87
  package_directory: string,
@@ -83,6 +92,8 @@ function hand_off(
83
92
  console.info('\n🚀 Installing @joshuafolkken/kit into the project, then running its josh init')
84
93
  const template = readFileSync(path.join(package_directory, KIT_BUILDS_TEMPLATE), 'utf8')
85
94
  const install = kit_install_step(has_kit_dependency(project_root), template)
95
+
96
+ ensure_project_manifest(project_root)
86
97
  if (!init_install.did_step_succeed(install, project_root)) return `${install.label} failed`
87
98
  if (!init_install.did_step_succeed(handoff_step(args), project_root)) return HANDOFF_FAILURE
88
99
 
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { git_spawn } from '#scripts/git/git-spawn'
5
+
6
+ // A lane directory git no longer registers as a work tree (joshuafolkken/kit#2857).
7
+ //
8
+ // **Nothing else can see it.** `lane:list` and `lane:prune` read `git worktree list`, so a directory
9
+ // whose registration is gone is invisible to both, and `git worktree add` refuses its path for good —
10
+ // the lane for that issue cannot be opened again until someone deletes it by hand. `lane:open` asks
11
+ // here before it adds the tree.
12
+ //
13
+ // **The test for removing it is that removing it loses nothing**, not a list of names. Every file in
14
+ // it has to be one the repository already holds with the same content, so it is a checkout git would
15
+ // write again; the one exception is the `.git` file, a pointer to the registration that is gone. A
16
+ // list of generated names would break the next time a file is generated, and would delete whatever
17
+ // shares one of those names.
18
+
19
+ // `git ls-tree -r -z`: `<mode> <type> <object>\t<path>`, entries separated by NUL — the `-z` form
20
+ // leaves a path with an unusual character unquoted, so it can be compared to the path on disk.
21
+ const TREE_ENTRY_PATTERN = /^\d+ blob (?<object>[\da-f]+)\t(?<path>.+)$/su
22
+ const GIT_POINTER = '.git'
23
+ const GITDIR_PREFIX = 'gitdir: '
24
+ // The foreign paths a refusal names before it summarizes the rest as a count.
25
+ const NAMED_PATH_LIMIT = 10
26
+
27
+ // One blob entry as `[path, object id]`; a tree, a commit (a submodule) or the trailing empty entry is
28
+ // none.
29
+ function tree_entry(entry: string): Array<[string, string]> {
30
+ const { path: entry_path, object } = TREE_ENTRY_PATTERN.exec(entry)?.groups ?? {}
31
+
32
+ if (entry_path === undefined || object === undefined) return []
33
+
34
+ return [[entry_path, object]]
35
+ }
36
+
37
+ function parse_tree(listing: string): Map<string, string> {
38
+ return new Map(listing.split('\0').flatMap((entry) => tree_entry(entry)))
39
+ }
40
+
41
+ // The object id git gives this file — a symbolic link's is that of its target string, as git stores it.
42
+ function blob_id(file: string): string {
43
+ const content = lstatSync(file).isSymbolicLink()
44
+ ? Buffer.from(readlinkSync(file))
45
+ : readFileSync(file)
46
+
47
+ // SHA-1 because it is git's object id, compared rather than trusted — a repository on the SHA-256
48
+ // object format matches nothing here, so its leftovers are refused rather than removed.
49
+ // eslint-disable-next-line sonarjs/hashing -- git's object id, not a security digest
50
+ return createHash('sha1')
51
+ .update(`blob ${String(content.length)}\0`)
52
+ .update(content)
53
+ .digest('hex')
54
+ }
55
+
56
+ // Walked one level at a time because `readdirSync`'s `recursive` option descends into a symbolic link
57
+ // to a directory, which would list the repository's own files under the link's path as foreign.
58
+ function walk_files(directory: string, relative: string): Array<string> {
59
+ return readdirSync(path.join(directory, relative), { withFileTypes: true }).flatMap((entry) => {
60
+ const child = relative === '' ? entry.name : `${relative}/${entry.name}`
61
+
62
+ return entry.isDirectory() ? walk_files(directory, child) : [child]
63
+ })
64
+ }
65
+
66
+ // Every file under `directory` as a repository path, symbolic links included and never followed.
67
+ function list_files(directory: string): Array<string> {
68
+ return walk_files(directory, '')
69
+ }
70
+
71
+ // The `.git` file is disposable only while the registration it points to is gone: one that still
72
+ // resolves is a live work tree — a `lane:open` of the same issue may be checking it out right now.
73
+ function is_dangling_pointer(directory: string): boolean {
74
+ const file = path.join(directory, GIT_POINTER)
75
+
76
+ if (!lstatSync(file).isFile()) return false
77
+
78
+ const pointer = readFileSync(file, 'utf8').trim()
79
+
80
+ if (!pointer.startsWith(GITDIR_PREFIX)) return false
81
+
82
+ return !existsSync(path.resolve(directory, pointer.slice(GITDIR_PREFIX.length)))
83
+ }
84
+
85
+ function is_reclaimable_file(
86
+ directory: string,
87
+ relative: string,
88
+ tracked: ReadonlyMap<string, string>,
89
+ ): boolean {
90
+ if (relative === GIT_POINTER) return is_dangling_pointer(directory)
91
+
92
+ return tracked.get(relative) === blob_id(path.join(directory, relative))
93
+ }
94
+
95
+ /**
96
+ * The paths under `directory` whose removal would lose something — empty when it holds nothing but
97
+ * the repository's own files and the `.git` pointer. A `.git` directory is a repository of its own,
98
+ * so it is foreign as a whole rather than walked.
99
+ */
100
+ function foreign_paths(directory: string, tracked: ReadonlyMap<string, string>): Array<string> {
101
+ const pointer = lstatSync(path.join(directory, GIT_POINTER), { throwIfNoEntry: false })
102
+
103
+ if (pointer?.isDirectory() === true) return [GIT_POINTER]
104
+
105
+ return list_files(directory).filter(
106
+ (relative) => !is_reclaimable_file(directory, relative, tracked),
107
+ )
108
+ }
109
+
110
+ function refusal(directory: string, foreign: ReadonlyArray<string>): Error {
111
+ const named = foreign.slice(0, NAMED_PATH_LIMIT).join(', ')
112
+ const rest = foreign.length - NAMED_PATH_LIMIT
113
+ const more = rest > 0 ? ` and ${String(rest)} more` : ''
114
+
115
+ return new Error(
116
+ `${directory} already exists but git does not register it as a work tree, and it holds files the repository does not: ${named}${more}. It was left in place so nothing is lost — move those files out, delete the directory, and open the lane again.`,
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Clear the way for `git worktree add` at `directory`, which the caller has found unregistered.
122
+ * Absent is nothing to do; holding only the repository's own files, it is removed; anything else is
123
+ * refused with the paths that would have been lost.
124
+ */
125
+ async function reclaim(directory: string): Promise<void> {
126
+ if (!existsSync(directory)) return
127
+
128
+ const tracked = parse_tree(await git_spawn.read(['ls-tree', '-r', '-z', '--full-tree', 'HEAD']))
129
+ const foreign = foreign_paths(directory, tracked)
130
+
131
+ if (foreign.length > 0) throw refusal(directory, foreign)
132
+
133
+ rmSync(directory, { force: true, recursive: true })
134
+ }
135
+
136
+ const lane_leftover = {
137
+ blob_id,
138
+ foreign_paths,
139
+ parse_tree,
140
+ reclaim,
141
+ }
142
+
143
+ export { lane_leftover }
@@ -5,6 +5,7 @@ import { git_worktree } from '#scripts/git/git-worktree'
5
5
  import { lane_cache } from './lane-cache'
6
6
  import { lane_environment } from './lane-environment'
7
7
  import { lane_install, type InstallResult } from './lane-install'
8
+ import { lane_leftover } from './lane-leftover'
8
9
  import { lane_paths } from './lane-paths'
9
10
  import { lane_registry, type LaneInfo } from './lane-registry'
10
11
  import { lane_seed_policy } from './lane-seed'
@@ -234,6 +235,18 @@ async function materialize(plan: LanePlan, source_root: string): Promise<void> {
234
235
  }
235
236
  }
236
237
 
238
+ // Both refusals run before the seat is claimed, so neither leaves a lock behind. A directory at the
239
+ // lane's path that git does not register is `lane_leftover`'s to remove or refuse
240
+ // (joshuafolkken/kit#2857).
241
+ async function clear_the_way(
242
+ root: string,
243
+ issue: string,
244
+ lanes: ReadonlyArray<LaneInfo>,
245
+ ): Promise<void> {
246
+ guard_unreadable(root, lanes)
247
+ await lane_leftover.reclaim(lane_paths.lane_directory(root, issue))
248
+ }
249
+
237
250
  /**
238
251
  * Open the lane for `issue`, or say why it was not opened.
239
252
  *
@@ -253,7 +266,7 @@ async function open_lane(issue: string): Promise<OpenOutcome> {
253
266
 
254
267
  const root = lane_paths.lane_root(repository_root)
255
268
 
256
- guard_unreadable(root, lanes)
269
+ await clear_the_way(root, issue, lanes)
257
270
 
258
271
  const plan = build_plan(repository_root, root, issue, lanes)
259
272
 
@@ -1,5 +1,7 @@
1
+ import { pr_classification } from '#scripts/ci/pr-classification'
1
2
  import { git_gh_exec } from '#scripts/git/git-gh-exec'
2
3
  import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
4
+ import { OTHER_CHANGE_LABEL } from '#scripts/git/issue-labels'
3
5
  import { GATE_COMMAND } from '#scripts/josh/josh-command-types'
4
6
  import { build_upgrade_shell_command } from '#scripts/version/upgrade-shell-command'
5
7
  import { create_version_command_config } from '#scripts/version/version-command-config'
@@ -62,6 +64,10 @@ const PROPAGATE_ORIGIN =
62
64
  'Opened by `josh propagate` from the supplier repository after the release was published.'
63
65
  const RUN_NOTE =
64
66
  'The upgrade, the managed-file sync and the verification gate have already run here.'
67
+ // `josh git` refuses to open a pull request for an issue that declares no release classification
68
+ // (joshuafolkken/kit#2666), so the upgrade issue declares one. A dependency upgrade changes no code of
69
+ // the consumer's own, which is what `other-change` covers (joshuafolkken/kit#2858).
70
+ const UPGRADE_CLASSIFICATION = OTHER_CHANGE_LABEL
65
71
  const EMPTY_PLAN_TITLE = 'Upgrade the installed toolkits'
66
72
  const LAST_SEPARATOR = ' and '
67
73
  const NAME_SEPARATOR = ', '
@@ -95,7 +101,14 @@ function plan_body(plan: ReleasePlan): string {
95
101
  .map((release) => `\`${release.package_name}@${release.version}\``)
96
102
  .join(NAME_SEPARATOR)
97
103
 
98
- return [`Carry ${specs} into this repository.`, '', plan.origin, RUN_NOTE].join('\n')
104
+ return [
105
+ `Carry ${specs} into this repository.`,
106
+ '',
107
+ plan.origin,
108
+ RUN_NOTE,
109
+ '',
110
+ pr_classification.declaration(UPGRADE_CLASSIFICATION),
111
+ ].join('\n')
99
112
  }
100
113
 
101
114
  function issue_body(package_name: string, version: string): string {
@@ -170,10 +170,10 @@ function create_halfrun_stop_hold(target: string, issue: string, now: Date = new
170
170
  // be chosen that covers a `halfrun` stop or a `needs-human-review` stop: those are held across a
171
171
  // person's latency rather than a run's, and the thing that must not be trampled is sitting in the
172
172
  // tree where anyone can see it. An unreadable status is dirty, for the reason every other unreadable
173
- // state here blocks.
174
- async function is_tree_dirty(): Promise<boolean> {
173
+ // state here blocks. `directory` asks the same of another work tree — a lane — instead of this one.
174
+ async function is_tree_dirty(directory?: string): Promise<boolean> {
175
175
  try {
176
- const status = await git_command.status()
176
+ const status = await git_command.status(directory)
177
177
 
178
178
  return status.trim() !== ''
179
179
  } catch {
@@ -1,5 +1,6 @@
1
1
  import { git_command } from '#scripts/git/git-command'
2
2
  import { git_gh_pr_read } from '#scripts/git/git-gh-pr-read'
3
+ import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
3
4
  import { z } from 'zod'
4
5
  import { run_hold } from './run-hold'
5
6
  import { run_issue_number } from './run-issue-number'
@@ -72,7 +73,7 @@ const GH_PR_STATES: ReadonlyMap<string, PrState> = new Map([
72
73
  ])
73
74
 
74
75
  const CLEAN_REASON =
75
- 'Nothing was left behind: the tree is clean, HEAD is on the default branch, and this issue has no branch or pull request.'
76
+ 'Nothing was left behind: the tree is clean, HEAD is on the default branch, and this issue has no pull request and no branch holding work.'
76
77
 
77
78
  const CLEAN_ADVICE = 'Start the child.'
78
79
 
@@ -283,22 +284,91 @@ function to_head_branch(name: string): string {
283
284
  return separator_index === NOT_FOUND_INDEX ? name : name.slice(separator_index + 1)
284
285
  }
285
286
 
286
- async function read_branch_candidates(issue: string): Promise<Array<string>> {
287
+ function group_by_head(pairs: ReadonlyArray<[string, string]>): Map<string, Array<string>> {
288
+ const groups = new Map<string, Array<string>>()
289
+
290
+ for (const [name, reference] of pairs) groups.set(name, [...(groups.get(name) ?? []), reference])
291
+
292
+ return groups
293
+ }
294
+
295
+ // Each candidate keeps every ref it was found as — the local branch and each remote-tracking ref —
296
+ // because either may be the one holding the commits: a local branch left at the default branch can
297
+ // sit beside a remote one another machine pushed work to, and the stripped head name may not resolve.
298
+ async function read_branch_candidates(issue: string): Promise<Map<string, Array<string>>> {
287
299
  const pattern = `${issue}${ISSUE_BRANCH_SUFFIX}`
288
300
  const [local, remote] = await Promise.all([
289
301
  git_command.branch_names(pattern),
290
302
  git_command.branch_names_remote(`${ANY_REMOTE_PREFIX}${pattern}`),
291
303
  ])
292
304
 
293
- return [...new Set([...local, ...remote.map((name) => to_head_branch(name))])]
305
+ return group_by_head([
306
+ ...local.map((name): [string, string] => [name, name]),
307
+ ...remote.map((reference): [string, string] => [to_head_branch(reference), reference]),
308
+ ])
309
+ }
310
+
311
+ async function has_commits_beyond(
312
+ base: string,
313
+ references: ReadonlyArray<string>,
314
+ ): Promise<boolean> {
315
+ const counts = await Promise.all(
316
+ references.map(async (reference) => await git_command.commit_count_beyond(base, reference)),
317
+ )
318
+
319
+ return counts.some((count) => count > 0)
320
+ }
321
+
322
+ // **A lane stopped mid-implementation holds its work uncommitted**, on a branch with no commit yet —
323
+ // so the lane checked out on the candidate is asked whether its tree is dirty, read the way
324
+ // `run_hold` reads this tree (an unreadable status is dirty). A lane on another branch, or whose
325
+ // directory is gone, holds nothing for this candidate.
326
+ async function is_held_dirty(name: string, lane: LaneInfo | undefined): Promise<boolean> {
327
+ if (lane?.branch !== name || lane.is_stranded) return false
328
+
329
+ return await run_hold.is_tree_dirty(lane.directory)
330
+ }
331
+
332
+ interface CandidateContext {
333
+ base: string
334
+ lane: LaneInfo | undefined
335
+ }
336
+
337
+ // **A branch with no pull request, no commit beyond the default branch and no uncommitted change in
338
+ // its lane holds no work** (joshuafolkken/kit#2855). A lane that ended before implementing — an
339
+ // operational issue with no code change — leaves exactly that behind, and reading its mere existence
340
+ // as a partial implementation sent the next `fullrun #N` to `resume` over nothing. A branch with a
341
+ // pull request is never counted: whatever its commits, the pull request is the state the verdict is
342
+ // decided on.
343
+ async function read_candidate(
344
+ name: string,
345
+ references: ReadonlyArray<string>,
346
+ context: CandidateContext,
347
+ ): Promise<ChildState | undefined> {
348
+ const state = await read_pr_of_branch(name)
349
+
350
+ if (state.pr_state !== NO_PR) return state
351
+ if (await has_commits_beyond(context.base, references)) return state
352
+
353
+ return (await is_held_dirty(name, context.lane)) ? state : undefined
294
354
  }
295
355
 
296
356
  async function read_child_state(issue: string): Promise<ChildState> {
297
357
  const candidates = await read_branch_candidates(issue)
298
358
 
299
- return pick_child_state(
300
- await Promise.all(candidates.map(async (name) => await read_pr_of_branch(name))),
359
+ if (candidates.size === 0) return NO_CHILD_WORK
360
+
361
+ const [base, lane] = await Promise.all([
362
+ git_command.default_branch_reference(),
363
+ lane_registry.find_open_lane(issue),
364
+ ])
365
+ const reads = await Promise.all(
366
+ [...candidates].map(
367
+ async ([name, references]) => await read_candidate(name, references, { base, lane }),
368
+ ),
301
369
  )
370
+
371
+ return pick_child_state(reads.filter((read): read is ChildState => read !== undefined))
302
372
  }
303
373
 
304
374
  // The child read is skipped where the tree already answers `reclaim`: a `gh` round trip buys nothing
@@ -77,7 +77,12 @@ const WAIT_EXPIRED_NOTICE = `The watch bound (\`--hours\`, ${String(DEFAULT_MAX_
77
77
  // The repository name is only ever printed, never written against, so the bounded lookup is the right
78
78
  // one: a `gh` call that hangs would otherwise block the synchronous read at startup and leave the
79
79
  // watcher neither running nor saying so.
80
- const REPO_LOOKUP_TIMEOUT_MS = 10_000
80
+ const REPO_LOOKUP_TIMEOUT_SECONDS = 10
81
+ const REPO_LOOKUP_TIMEOUT_MS = REPO_LOOKUP_TIMEOUT_SECONDS * run_progress.MS_PER_SECOND
82
+ // A lookup that timed out or failed is not an argument the caller got wrong, so it is told apart from
83
+ // the usage error — read as one, it sent a person to fix arguments that were correct
84
+ // (joshuafolkken/kit#2859).
85
+ const REPO_UNRESOLVED_NOTICE = `The repository could not be resolved (\`gh\` did not answer within ${String(REPO_LOOKUP_TIMEOUT_SECONDS)}s or failed), so the progress watcher did not start. Pass \`--repo <owner/repo>\` and start it again.`
81
86
 
82
87
  const DECLINE_NOTICES: Readonly<Record<'idle' | 'unreadable', string>> = {
83
88
  idle: IDLE_NOTICE,
@@ -141,6 +146,12 @@ function report_usage(): number {
141
146
  return FAILURE_EXIT_CODE
142
147
  }
143
148
 
149
+ function report_repo_unresolved(): number {
150
+ console.error(REPO_UNRESOLVED_NOTICE)
151
+
152
+ return FAILURE_EXIT_CODE
153
+ }
154
+
144
155
  function report_disabled(): number {
145
156
  console.error(DISABLED_NOTICE)
146
157
 
@@ -443,7 +454,7 @@ function to_options(values: ParsedValues): WatchOptions | undefined {
443
454
  async function run_watch(values: ParsedValues): Promise<number> {
444
455
  const options = to_options(values)
445
456
 
446
- if (options === undefined) return report_usage()
457
+ if (options === undefined) return report_repo_unresolved()
447
458
  if (values.once === true) return await once(options)
448
459
  if (values.wait === true) return await wait_once(options)
449
460
 
@@ -487,6 +498,7 @@ const run_progress_cli = {
487
498
  IDLE_NOTICE,
488
499
  LANE_CHILD_NOTICE,
489
500
  MARKED_NOTICE,
501
+ REPO_UNRESOLVED_NOTICE,
490
502
  TICK_SECONDS,
491
503
  UNREADABLE_NOTICE,
492
504
  USAGE,