@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.
- package/.claude/skills/workflow-commands/backlogrun-child.md +3 -4
- package/dist/hooks/{chunk-762BDXDU.js → chunk-6PSTVOLH.js} +44 -7
- package/dist/hooks/{chunk-7WAOP42A.js → chunk-EZLHXKJ6.js} +5 -4
- package/dist/hooks/codex-hook-adapter.js +2 -2
- package/dist/hooks/pretool-guard.js +2 -2
- package/dist/hooks/stop-guard.js +1 -1
- package/package.json +1 -1
- package/scripts/ci/pr-classification.ts +11 -2
- package/scripts/git/git-command.ts +5 -2
- package/scripts/init/init-bootstrap.ts +12 -1
- package/scripts/lane/lane-leftover.ts +143 -0
- package/scripts/lane/lane-open.ts +14 -1
- package/scripts/propagate/propagate-steps.ts +14 -1
- package/scripts/run/run-hold.ts +3 -3
- package/scripts/run/run-preflight.ts +75 -5
- package/scripts/run/run-progress-cli.ts +14 -2
|
@@ -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` |
|
|
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`
|
|
318
|
-
|
|
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-
|
|
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
|
|
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 "
|
|
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
|
|
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
|
|
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
|
|
5242
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
pretool_guard,
|
|
4
4
|
pretool_outcome,
|
|
5
5
|
pretool_outcome_async
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
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";
|
package/dist/hooks/stop-guard.js
CHANGED
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 [
|
|
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 {
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
300
|
-
|
|
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
|
|
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
|
|
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,
|