@brainervirus/workit-core 8.0.1 → 8.1.1
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/package.json +1 -1
- package/src/core/branch-policy.ts +37 -10
- package/src/core/branch.ts +2 -0
- package/src/core/config.ts +48 -29
- package/src/core/workspaces.ts +89 -39
- package/src/forge/exec.ts +72 -3
- package/src/forge/report.ts +4 -3
- package/src/forge/resolve.ts +328 -78
- package/src/git/ops.ts +363 -34
- package/src/git/rev.ts +10 -5
package/package.json
CHANGED
|
@@ -9,17 +9,34 @@ const PREFIXES = {
|
|
|
9
9
|
hotfix: "hotfix/*",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Local branch names plus origin's remote-tracking ones (`origin/develop`
|
|
14
|
+
* counts as `develop`; a fork or mirror remote's branches do not).
|
|
15
|
+
*/
|
|
16
|
+
const branchNames = (workspaceRoot: string): Set<string> => {
|
|
17
|
+
const r = spawnSync(
|
|
18
|
+
"git",
|
|
19
|
+
["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes/origin"],
|
|
20
|
+
{ cwd: workspaceRoot, encoding: "utf8" },
|
|
21
|
+
);
|
|
22
|
+
if (r.status !== 0) return new Set();
|
|
23
|
+
return new Set(
|
|
24
|
+
(r.stdout ?? "")
|
|
25
|
+
.split("\n")
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((ref) =>
|
|
28
|
+
ref.startsWith("refs/heads/")
|
|
29
|
+
? ref.slice("refs/heads/".length)
|
|
30
|
+
: ref.slice("refs/remotes/origin/".length),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
12
35
|
export const detectBranchPolicy = (workspaceRoot: string) => {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
18
|
-
return r.status === 0 && (r.stdout ?? "").trim() !== "";
|
|
19
|
-
};
|
|
20
|
-
const develop = branchExists("develop");
|
|
21
|
-
const main = branchExists("main");
|
|
22
|
-
const master = branchExists("master");
|
|
36
|
+
const names = branchNames(workspaceRoot);
|
|
37
|
+
const develop = names.has("develop");
|
|
38
|
+
const main = names.has("main");
|
|
39
|
+
const master = names.has("master");
|
|
23
40
|
const root = main ? "main" : master ? "master" : null;
|
|
24
41
|
|
|
25
42
|
if (develop) {
|
|
@@ -61,3 +78,13 @@ export const detectBranchPolicy = (workspaceRoot: string) => {
|
|
|
61
78
|
prefixes: PREFIXES,
|
|
62
79
|
};
|
|
63
80
|
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The preset this repository's branches imply (develop -> gitflow, main ->
|
|
84
|
+
* github-flow, master -> trunk-based), or null when they imply none. Used
|
|
85
|
+
* only when no branch policy is configured anywhere (core/config.ts).
|
|
86
|
+
*/
|
|
87
|
+
export const repoBranchPreset = (workspaceRoot: string): BranchPreset | null => {
|
|
88
|
+
const detected = detectBranchPolicy(workspaceRoot);
|
|
89
|
+
return detected.developBranch || detected.allowed.length ? detected.preset : null;
|
|
90
|
+
};
|
package/src/core/branch.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { repoBranchPreset } from "./branch-policy";
|
|
5
6
|
import { matchCommitFlavor } from "./commit-flavors";
|
|
6
7
|
import { gitContext } from "./git";
|
|
7
8
|
import {
|
|
@@ -100,6 +101,7 @@ function effectiveWorkspacePolicy(
|
|
|
100
101
|
resolveConfiguredBranchPolicy(
|
|
101
102
|
config,
|
|
102
103
|
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
104
|
+
() => repoBranchPreset(workspaceRoot),
|
|
103
105
|
),
|
|
104
106
|
),
|
|
105
107
|
provenance: { branchPolicy: selected.source },
|
package/src/core/config.ts
CHANGED
|
@@ -138,6 +138,12 @@ const DEFAULTS: ToolkitConfig = {
|
|
|
138
138
|
commitPolicy: { preset: "conventional" },
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
+
// Configs whose branch policy nobody chose (no config.json, or one without a
|
|
142
|
+
// valid branchPolicy.preset). For them the repository's own branches pick the
|
|
143
|
+
// preset (resolveBranchPolicy's `repoPreset`), so a main-only repository is
|
|
144
|
+
// not held to gitflow's `develop`.
|
|
145
|
+
const unchosenBranchPolicy = new WeakSet<ToolkitConfig>([DEFAULTS]);
|
|
146
|
+
|
|
141
147
|
const readSafe = (p: string): string | null => {
|
|
142
148
|
try {
|
|
143
149
|
return readFileSync(p, "utf8");
|
|
@@ -191,36 +197,35 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
|
|
|
191
197
|
? input.commitPolicy?.preset
|
|
192
198
|
: "conventional"
|
|
193
199
|
) as CommitFlavorPreset;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
protectedNames: Array.isArray(input.branchPolicy?.protected)
|
|
211
|
-
? input.branchPolicy.protected
|
|
212
|
-
: undefined,
|
|
213
|
-
},
|
|
214
|
-
DEFAULTS,
|
|
215
|
-
),
|
|
216
|
-
commitPolicy: {
|
|
217
|
-
preset: commitPreset,
|
|
218
|
-
...(typeof input.commitPolicy?.pattern === "string"
|
|
219
|
-
? { pattern: input.commitPolicy.pattern }
|
|
220
|
-
: {}),
|
|
200
|
+
const config: ToolkitConfig = {
|
|
201
|
+
locale,
|
|
202
|
+
localeOptions: Array.isArray(input.localeOptions)
|
|
203
|
+
? input.localeOptions
|
|
204
|
+
: DEFAULTS.localeOptions,
|
|
205
|
+
// RL-02/CA-23: the persisted preset is authoritative; derived allowed /
|
|
206
|
+
// protected fields always reset from PRESETS via the one shared merge.
|
|
207
|
+
branchPolicy: mergePreset(
|
|
208
|
+
preset,
|
|
209
|
+
{
|
|
210
|
+
allowed: Array.isArray(input.branchPolicy?.allowed)
|
|
211
|
+
? input.branchPolicy.allowed
|
|
212
|
+
: undefined,
|
|
213
|
+
protectedNames: Array.isArray(input.branchPolicy?.protected)
|
|
214
|
+
? input.branchPolicy.protected
|
|
215
|
+
: undefined,
|
|
221
216
|
},
|
|
217
|
+
DEFAULTS,
|
|
218
|
+
),
|
|
219
|
+
commitPolicy: {
|
|
220
|
+
preset: commitPreset,
|
|
221
|
+
...(typeof input.commitPolicy?.pattern === "string"
|
|
222
|
+
? { pattern: input.commitPolicy.pattern }
|
|
223
|
+
: {}),
|
|
222
224
|
},
|
|
223
225
|
};
|
|
226
|
+
if (!Object.hasOwn(PRESETS, input.branchPolicy?.preset as string))
|
|
227
|
+
unchosenBranchPolicy.add(config);
|
|
228
|
+
return { status: "valid", path: file, config };
|
|
224
229
|
};
|
|
225
230
|
|
|
226
231
|
export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
|
|
@@ -298,19 +303,28 @@ export const branchGlobPattern = (glob: string): RegExp =>
|
|
|
298
303
|
export const resolveBranchPolicy = (
|
|
299
304
|
config: ToolkitConfig,
|
|
300
305
|
workspace?: { branchPolicy?: Record<string, any> } | null,
|
|
306
|
+
/** The preset the repository's branches imply (core/branch-policy.ts repoBranchPreset). */
|
|
307
|
+
repoPreset?: () => BranchPreset | null,
|
|
301
308
|
): {
|
|
302
309
|
preset: BranchPreset;
|
|
303
310
|
allowed: RegExp[];
|
|
304
311
|
protected: Set<string>;
|
|
305
312
|
integration: "pr" | "merge";
|
|
306
313
|
defaultTargetBranch: string;
|
|
314
|
+
/** No policy was configured; the repository's branches chose the preset. */
|
|
315
|
+
detected: boolean;
|
|
307
316
|
} => {
|
|
308
317
|
const wp = workspace?.branchPolicy ?? {};
|
|
309
318
|
// An invalid workspace preset (e.g. a typo) falls back to the global preset,
|
|
310
319
|
// preserving resolution order workspace > global > preset, instead of
|
|
311
320
|
// crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
|
|
321
|
+
const workspacePreset = Object.hasOwn(PRESETS, wp.preset);
|
|
322
|
+
// With no policy chosen anywhere, the repository's branches pick the preset
|
|
323
|
+
// (M10); the gitflow protected names stay protected regardless.
|
|
324
|
+
const detected =
|
|
325
|
+
!workspacePreset && unchosenBranchPolicy.has(config) ? (repoPreset?.() ?? null) : null;
|
|
312
326
|
const preset = (
|
|
313
|
-
|
|
327
|
+
workspacePreset ? wp.preset : (detected ?? config.branchPolicy?.preset ?? "gitflow")
|
|
314
328
|
) as BranchPreset;
|
|
315
329
|
// RL-02: the preset is authoritative — allowed/protected re-derive from the
|
|
316
330
|
// workspace's own values or the preset table, never the global config's, and
|
|
@@ -327,11 +341,16 @@ export const resolveBranchPolicy = (
|
|
|
327
341
|
return {
|
|
328
342
|
preset,
|
|
329
343
|
allowed,
|
|
330
|
-
protected: new Set(
|
|
344
|
+
protected: new Set(
|
|
345
|
+
[...merged.protected, ...(detected ? PRESETS.gitflow.protected : [])].map((p) =>
|
|
346
|
+
p.toLowerCase(),
|
|
347
|
+
),
|
|
348
|
+
),
|
|
331
349
|
integration: wp.integration === "merge" ? "merge" : "pr",
|
|
332
350
|
// CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
|
|
333
351
|
defaultTargetBranch:
|
|
334
352
|
preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
|
|
353
|
+
detected: detected !== null,
|
|
335
354
|
};
|
|
336
355
|
};
|
|
337
356
|
|
package/src/core/workspaces.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
type ToolkitConfig,
|
|
15
16
|
} from "./config";
|
|
16
17
|
import type { CommitFlavorPreset } from "./commit-flavors";
|
|
18
|
+
import { repoBranchPreset } from "./branch-policy";
|
|
17
19
|
|
|
18
20
|
export type VcsProvider = "gitlab" | "github";
|
|
19
21
|
|
|
@@ -412,6 +414,70 @@ export const validateWorkspaceGlob = (glob: string): GlobValidation => {
|
|
|
412
414
|
return { ok: true };
|
|
413
415
|
};
|
|
414
416
|
|
|
417
|
+
/** A workspaces.json problem for the user to fix; CLI verbs report it as `blocked`. */
|
|
418
|
+
export class WorkspaceConfigError extends Error {
|
|
419
|
+
override name = "WorkspaceConfigError";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const mainCheckouts = new Map<string, string | null>();
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The main checkout of the linked worktree at `cwd` (null when cwd is not a
|
|
426
|
+
* linked worktree). A worktree outside every glob (~/.codex/worktrees/…,
|
|
427
|
+
* ../x, /tmp) belongs to the workspace of the checkout it was added from (B2).
|
|
428
|
+
*/
|
|
429
|
+
const mainCheckoutOf = (cwd: string): string | null => {
|
|
430
|
+
const cached = mainCheckouts.get(cwd);
|
|
431
|
+
if (cached !== undefined) return cached;
|
|
432
|
+
// The repository at cwd, not one an inherited GIT_DIR/GIT_WORK_TREE names.
|
|
433
|
+
const env = { ...process.env };
|
|
434
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"])
|
|
435
|
+
delete env[name];
|
|
436
|
+
const git = (args: string[]): string | null => {
|
|
437
|
+
const run = spawnSync("git", args, {
|
|
438
|
+
cwd,
|
|
439
|
+
env,
|
|
440
|
+
encoding: "utf8",
|
|
441
|
+
timeout: 5000,
|
|
442
|
+
windowsHide: true,
|
|
443
|
+
});
|
|
444
|
+
return run.status === 0 ? run.stdout : null;
|
|
445
|
+
};
|
|
446
|
+
let main: string | null = null;
|
|
447
|
+
const dirs = git(["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"]);
|
|
448
|
+
const [gitDir, common] = (dirs ?? "").trim().split(/\r?\n/u);
|
|
449
|
+
if (gitDir && common && path.resolve(gitDir) !== path.resolve(common)) {
|
|
450
|
+
// The first `worktree` entry is the main one, also with --separate-git-dir
|
|
451
|
+
// (where the common dir is not <main>/.git); a bare repo lists its own path.
|
|
452
|
+
const listed = /^worktree (.+)$/mu.exec(git(["worktree", "list", "--porcelain"]) ?? "")?.[1];
|
|
453
|
+
main = listed?.trim() || (path.basename(common) === ".git" ? path.dirname(common) : common);
|
|
454
|
+
}
|
|
455
|
+
mainCheckouts.set(cwd, main);
|
|
456
|
+
return main;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The entries whose glob matches cwd (its logical and real path); for a linked
|
|
461
|
+
* worktree that matches none, the entries matching its main checkout.
|
|
462
|
+
*/
|
|
463
|
+
const matchingEntries = <T extends { glob: string }>(cwd: string, entries: readonly T[]): T[] => {
|
|
464
|
+
const match = (where: string): T[] => {
|
|
465
|
+
const targets = [where, realpathOf(where)].map((value) => value.replaceAll("\\", "/"));
|
|
466
|
+
return entries.filter((entry) => {
|
|
467
|
+
const glob = entry.glob.replaceAll("\\", "/");
|
|
468
|
+
const canonical = canonicalGlob(glob);
|
|
469
|
+
return targets.some(
|
|
470
|
+
(target) =>
|
|
471
|
+
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
const direct = match(cwd);
|
|
476
|
+
if (direct.length) return direct;
|
|
477
|
+
const main = mainCheckoutOf(cwd);
|
|
478
|
+
return main ? match(main) : direct;
|
|
479
|
+
};
|
|
480
|
+
|
|
415
481
|
const realpathOf = (p: string): string => {
|
|
416
482
|
// realpathSync.native goes through libuv, which expands Windows 8.3 short
|
|
417
483
|
// names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
|
|
@@ -452,13 +518,17 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
|
452
518
|
const named = matches.filter((entry) => entry.name === workspaceName);
|
|
453
519
|
if (named.length === 1) return named[0];
|
|
454
520
|
if (named.length > 1)
|
|
455
|
-
throw new
|
|
521
|
+
throw new WorkspaceConfigError(
|
|
522
|
+
`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`,
|
|
523
|
+
);
|
|
456
524
|
if (matches.length > 0) {
|
|
457
|
-
throw new
|
|
525
|
+
throw new WorkspaceConfigError(
|
|
458
526
|
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
|
|
459
527
|
);
|
|
460
528
|
}
|
|
461
|
-
throw new
|
|
529
|
+
throw new WorkspaceConfigError(
|
|
530
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`,
|
|
531
|
+
);
|
|
462
532
|
}
|
|
463
533
|
|
|
464
534
|
if (matches.length < 2) return matches[0];
|
|
@@ -467,7 +537,7 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
|
467
537
|
(entry) => globSpecificity(entry.glob) === highestSpecificity,
|
|
468
538
|
);
|
|
469
539
|
if (mostSpecific.length > 1) {
|
|
470
|
-
throw new
|
|
540
|
+
throw new WorkspaceConfigError(
|
|
471
541
|
`ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
|
|
472
542
|
);
|
|
473
543
|
}
|
|
@@ -485,23 +555,7 @@ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: stri
|
|
|
485
555
|
// written with the logical path, so a workspace would silently stop
|
|
486
556
|
// matching on macOS. Match both forms on each side; on Linux both forms
|
|
487
557
|
// are identical so behavior is unchanged.
|
|
488
|
-
const
|
|
489
|
-
const matches: T[] = [];
|
|
490
|
-
for (const ws of entries) {
|
|
491
|
-
const glob = ws.glob.replaceAll("\\", "/");
|
|
492
|
-
const canonical = canonicalGlob(glob);
|
|
493
|
-
let matched = false;
|
|
494
|
-
for (const target of targets) {
|
|
495
|
-
if (
|
|
496
|
-
matchWorkspace(glob, target) ||
|
|
497
|
-
(canonical !== glob && matchWorkspace(canonical, target))
|
|
498
|
-
) {
|
|
499
|
-
matched = true;
|
|
500
|
-
break;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
if (matched) matches.push(ws);
|
|
504
|
-
}
|
|
558
|
+
const matches = matchingEntries(cwd, entries);
|
|
505
559
|
return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
|
|
506
560
|
};
|
|
507
561
|
|
|
@@ -512,7 +566,8 @@ export const resolveWorkspaceFrom = (
|
|
|
512
566
|
workspaceName?: string,
|
|
513
567
|
): WorkspaceConfig | null => {
|
|
514
568
|
const result = readWorkspacesResult(dir);
|
|
515
|
-
if (result.status === "malformed" || result.status === "invalid")
|
|
569
|
+
if (result.status === "malformed" || result.status === "invalid")
|
|
570
|
+
throw new WorkspaceConfigError(result.error);
|
|
516
571
|
return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
|
|
517
572
|
};
|
|
518
573
|
|
|
@@ -585,7 +640,7 @@ const matchRuntimeWorkspace = (
|
|
|
585
640
|
raw = readFileSync(file, "utf8");
|
|
586
641
|
} catch (error) {
|
|
587
642
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
588
|
-
throw new
|
|
643
|
+
throw new WorkspaceConfigError(
|
|
589
644
|
`${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
590
645
|
{ cause: error },
|
|
591
646
|
);
|
|
@@ -594,29 +649,21 @@ const matchRuntimeWorkspace = (
|
|
|
594
649
|
try {
|
|
595
650
|
parsed = JSON.parse(raw);
|
|
596
651
|
} catch {
|
|
597
|
-
throw new
|
|
652
|
+
throw new WorkspaceConfigError(`${file} is not valid JSON`);
|
|
598
653
|
}
|
|
599
654
|
const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
|
|
600
655
|
if (!validated.success) {
|
|
601
656
|
const detail = validated.error.issues
|
|
602
657
|
.map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
|
|
603
658
|
.join("; ");
|
|
604
|
-
throw new
|
|
659
|
+
throw new WorkspaceConfigError(`${file} has invalid workspace matching data: ${detail}`);
|
|
605
660
|
}
|
|
606
661
|
const entries = validated.data.workspaces;
|
|
607
662
|
for (const entry of entries ?? []) {
|
|
608
663
|
const glob = validateWorkspaceGlob(entry.glob);
|
|
609
|
-
if (!glob.ok) throw new
|
|
664
|
+
if (!glob.ok) throw new WorkspaceConfigError(`${file} workspace ${entry.name}: ${glob.error}`);
|
|
610
665
|
}
|
|
611
|
-
const
|
|
612
|
-
const matches = (entries ?? []).filter((entry) => {
|
|
613
|
-
const glob = entry.glob.replaceAll("\\", "/");
|
|
614
|
-
const canonical = canonicalGlob(glob);
|
|
615
|
-
return targets.some(
|
|
616
|
-
(target) =>
|
|
617
|
-
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
618
|
-
);
|
|
619
|
-
});
|
|
666
|
+
const matches = matchingEntries(cwd, entries ?? []);
|
|
620
667
|
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
621
668
|
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
622
669
|
return selected ? { file, selected } : null;
|
|
@@ -648,7 +695,7 @@ const resolveRuntimeWorkspaceCandidate = (
|
|
|
648
695
|
const detail = policy.error.issues
|
|
649
696
|
.map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
|
|
650
697
|
.join("; ");
|
|
651
|
-
throw new
|
|
698
|
+
throw new WorkspaceConfigError(`${file} has invalid ${kind} policy configuration: ${detail}`);
|
|
652
699
|
}
|
|
653
700
|
return policy.data as RuntimeWorkspaceCandidate;
|
|
654
701
|
};
|
|
@@ -669,12 +716,12 @@ export const resolveRuntimeWorkspacePolicy = (
|
|
|
669
716
|
candidate.defaultProfile &&
|
|
670
717
|
!Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
|
|
671
718
|
)
|
|
672
|
-
throw new
|
|
719
|
+
throw new WorkspaceConfigError(
|
|
673
720
|
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
|
|
674
721
|
);
|
|
675
722
|
const profile = profileName ? candidate.profiles?.[profileName] : undefined;
|
|
676
723
|
if (profileName && !profile)
|
|
677
|
-
throw new
|
|
724
|
+
throw new WorkspaceConfigError(
|
|
678
725
|
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
679
726
|
);
|
|
680
727
|
const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
@@ -731,6 +778,8 @@ export const resolveWorkspacePolicy = (
|
|
|
731
778
|
config: ToolkitConfig,
|
|
732
779
|
workspace: WorkspaceConfig | null,
|
|
733
780
|
profileName?: string,
|
|
781
|
+
/** The preset the checkout's branches imply, used when no policy is configured. */
|
|
782
|
+
repoPreset?: () => BranchPreset | null,
|
|
734
783
|
): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
|
|
735
784
|
const selected = workspace
|
|
736
785
|
? resolveWorkspaceProfile(workspace, profileName)
|
|
@@ -745,7 +794,7 @@ export const resolveWorkspacePolicy = (
|
|
|
745
794
|
status: "resolved",
|
|
746
795
|
workspace: selected.workspace,
|
|
747
796
|
profileName: selected.profileName,
|
|
748
|
-
branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
|
|
797
|
+
branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace, repoPreset),
|
|
749
798
|
commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
|
|
750
799
|
provenance: selected.provenance,
|
|
751
800
|
};
|
|
@@ -792,6 +841,7 @@ export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): str
|
|
|
792
841
|
const policyDefault = resolveConfiguredBranchPolicy(
|
|
793
842
|
readConfig(),
|
|
794
843
|
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
844
|
+
() => repoBranchPreset(cwd),
|
|
795
845
|
).defaultTargetBranch;
|
|
796
846
|
return String(
|
|
797
847
|
(ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
|
package/src/forge/exec.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type CliRun = {
|
|
|
19
19
|
timedOut: boolean;
|
|
20
20
|
/** The binary is not on PATH. */
|
|
21
21
|
missing: boolean;
|
|
22
|
+
/** The timeout actually applied (a deadline may have shortened it). */
|
|
23
|
+
timeoutMs?: number;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
/** Runs one gh/glab command. Injected in tests to replay recorded API fixtures. */
|
|
@@ -109,6 +111,47 @@ export const installHint = (bin: CliBin): string =>
|
|
|
109
111
|
export const loginHint = (bin: CliBin, apiHost: string): string =>
|
|
110
112
|
`${bin} auth login --hostname ${apiHost}`;
|
|
111
113
|
|
|
114
|
+
/** Transport trouble (DNS, TCP, TLS, a 5xx from the forge): retryable, exit 5. */
|
|
115
|
+
const NETWORK =
|
|
116
|
+
/error connecting|could not resolve|no such host|connection (refused|reset|timed out)|network is unreachable|i\/o timeout|tls handshake|unexpected eof|\bHTTP 5\d\d\b|bad gateway|service unavailable|gateway time-?out/iu;
|
|
117
|
+
|
|
118
|
+
export const isNetworkError = (text: string): boolean => NETWORK.test(text);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The forge's own reason for a refusal: `gh api`/`glab api` print the error
|
|
122
|
+
* body on stdout (`message`, `errors[].message`), stderr only says "HTTP 422".
|
|
123
|
+
*/
|
|
124
|
+
export function forgeReason(run: Pick<CliRun, "stdout" | "stderr">): string {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
try {
|
|
127
|
+
const body = JSON.parse(run.stdout) as {
|
|
128
|
+
message?: unknown;
|
|
129
|
+
error?: unknown;
|
|
130
|
+
errors?: unknown;
|
|
131
|
+
};
|
|
132
|
+
const add = (value: unknown): void => {
|
|
133
|
+
if (typeof value === "string" && value.trim()) parts.push(value.trim());
|
|
134
|
+
else if (Array.isArray(value)) value.forEach(add);
|
|
135
|
+
else if (value && typeof value === "object") {
|
|
136
|
+
const record = value as Record<string, unknown>;
|
|
137
|
+
if (typeof record.message === "string") add(record.message);
|
|
138
|
+
else if (!("code" in record)) Object.values(record).forEach(add);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
add(body.message);
|
|
142
|
+
add(body.error);
|
|
143
|
+
add(body.errors);
|
|
144
|
+
} catch {
|
|
145
|
+
// not a JSON body
|
|
146
|
+
}
|
|
147
|
+
const stderr = (run.stderr.split(/\r?\n/u).find((value) => value.trim()) ?? "").trim();
|
|
148
|
+
const unique = [...new Set(parts)];
|
|
149
|
+
const text = unique.length
|
|
150
|
+
? `${unique.join("; ")}${stderr ? ` (${stderr.replace(/^gh: |^glab: /u, "")})` : ""}`
|
|
151
|
+
: stderr || (run.stdout.split(/\r?\n/u).find((value) => value.trim()) ?? "");
|
|
152
|
+
return redactText(text).slice(0, 300);
|
|
153
|
+
}
|
|
154
|
+
|
|
112
155
|
/** Map a failed CLI run to an envelope error. */
|
|
113
156
|
export function cliFailure<T>(
|
|
114
157
|
bin: CliBin,
|
|
@@ -119,7 +162,13 @@ export function cliFailure<T>(
|
|
|
119
162
|
): ForgeResult<T> {
|
|
120
163
|
if (run.missing) return failure("unavailable", `${bin} is not installed`, installHint(bin));
|
|
121
164
|
if (run.timedOut)
|
|
122
|
-
return failure(
|
|
165
|
+
return failure(
|
|
166
|
+
"unavailable",
|
|
167
|
+
run.timeoutMs === 0
|
|
168
|
+
? `${bin} api ${what} not attempted: the command's time budget ran out`
|
|
169
|
+
: `${bin} api ${what} timed out after ${run.timeoutMs ?? timeoutMs} ms`,
|
|
170
|
+
`check the network to ${apiHost}, then retry`,
|
|
171
|
+
);
|
|
123
172
|
const stderr = redactText(run.stderr).trim();
|
|
124
173
|
if (
|
|
125
174
|
/auth login|not logged in|no token|authentication required|bad credentials|\b401\b|unauthorized/iu.test(
|
|
@@ -133,6 +182,12 @@ export function cliFailure<T>(
|
|
|
133
182
|
);
|
|
134
183
|
if (/\b404\b|not found/iu.test(stderr)) return failure("not_found", `${what} was not found`);
|
|
135
184
|
const first = stderr.split(/\r?\n/u).find((value) => value.trim()) ?? `exit ${run.status}`;
|
|
185
|
+
if (isNetworkError(stderr))
|
|
186
|
+
return failure(
|
|
187
|
+
"unavailable",
|
|
188
|
+
`${bin} api ${what} failed (network): ${first.slice(0, 200)}`,
|
|
189
|
+
`check the network to ${apiHost}, then retry`,
|
|
190
|
+
);
|
|
136
191
|
return failure("failed", `${bin} api ${what} failed: ${first.slice(0, 200)}`);
|
|
137
192
|
}
|
|
138
193
|
|
|
@@ -197,7 +252,7 @@ export function apiWrite<T>(
|
|
|
197
252
|
const run = runner(bin, args, { timeoutMs });
|
|
198
253
|
if (run.status !== 0) {
|
|
199
254
|
const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
|
|
200
|
-
const reason = (
|
|
255
|
+
const reason = forgeReason(run);
|
|
201
256
|
if (
|
|
202
257
|
!run.missing &&
|
|
203
258
|
!run.timedOut &&
|
|
@@ -210,7 +265,21 @@ export function apiWrite<T>(
|
|
|
210
265
|
);
|
|
211
266
|
if (!run.missing && !run.timedOut && /\b(405|406|422)\b/u.test(text))
|
|
212
267
|
return failure("blocked", `${what} refused by the forge: ${reason}`);
|
|
213
|
-
|
|
268
|
+
const failed = cliFailure<T>(bin, apiHost, run, what, timeoutMs);
|
|
269
|
+
// A timeout, a dropped connection or a 5xx may come after the forge
|
|
270
|
+
// applied the write: look before writing again.
|
|
271
|
+
if (
|
|
272
|
+
!failed.ok &&
|
|
273
|
+
failed.code === "unavailable" &&
|
|
274
|
+
!run.missing &&
|
|
275
|
+
!/ is not authenticated for /u.test(failed.error)
|
|
276
|
+
)
|
|
277
|
+
return failure(
|
|
278
|
+
"unavailable",
|
|
279
|
+
failed.error,
|
|
280
|
+
"workit pr status # the write may have gone through; check it first, then retry",
|
|
281
|
+
);
|
|
282
|
+
return failed;
|
|
214
283
|
}
|
|
215
284
|
if (!run.stdout.trim()) return { ok: true, data: {} as T };
|
|
216
285
|
try {
|
package/src/forge/report.ts
CHANGED
|
@@ -473,14 +473,15 @@ export function waitVerdict(
|
|
|
473
473
|
return { state: "waiting", reason: "head_mismatch" };
|
|
474
474
|
const checks = doc.checks.state;
|
|
475
475
|
if (checks === "failing") return { state: "failed", reason: "checks_failing" };
|
|
476
|
-
|
|
477
|
-
if (checks
|
|
478
|
-
if (doc.conflicts)
|
|
476
|
+
// Before "pending": CI never runs on a conflicting PR, so waiting cannot help (M8).
|
|
477
|
+
if (doc.conflicts && checks !== "passing")
|
|
479
478
|
return {
|
|
480
479
|
state: "blocked",
|
|
481
480
|
reason: "conflicts",
|
|
482
481
|
unblock: `rebase onto ${doc.base} and push; CI does not run on a conflicting PR`,
|
|
483
482
|
};
|
|
483
|
+
if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
|
|
484
|
+
if (checks === "passing") return { state: "ready", reason: "checks_passing" };
|
|
484
485
|
const ciRequired =
|
|
485
486
|
doc.checks.missingRequired === null ||
|
|
486
487
|
doc.mergeState === "ci_must_pass" ||
|