@brainervirus/workit-core 8.34.0 → 8.35.2
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/workspaces.ts +58 -13
- package/src/forge/github.ts +38 -0
- package/src/forge/gitlab.ts +48 -0
- package/src/forge/pr-ops.ts +79 -1
- package/src/forge/types.ts +12 -0
- package/src/hooks/context.ts +12 -4
- package/src/ledger.ts +118 -31
- package/src/stack.ts +135 -25
package/package.json
CHANGED
package/src/core/workspaces.ts
CHANGED
|
@@ -103,7 +103,11 @@ const releaseTrackSchema = z
|
|
|
103
103
|
["requiredChecks", track.requiredChecks ?? []],
|
|
104
104
|
] as const) {
|
|
105
105
|
if (new Set(values).size !== values.length) {
|
|
106
|
-
context.addIssue({
|
|
106
|
+
context.addIssue({
|
|
107
|
+
code: "custom",
|
|
108
|
+
path: [field],
|
|
109
|
+
message: "entries must be unique",
|
|
110
|
+
});
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
if (
|
|
@@ -192,7 +196,10 @@ const workspaceConfigSchema = z
|
|
|
192
196
|
.passthrough()
|
|
193
197
|
.optional(),
|
|
194
198
|
youtrack: z
|
|
195
|
-
.object({
|
|
199
|
+
.object({
|
|
200
|
+
baseUrl: nonBlank.optional(),
|
|
201
|
+
link_issues: z.boolean().optional(),
|
|
202
|
+
})
|
|
196
203
|
.passthrough()
|
|
197
204
|
.optional(),
|
|
198
205
|
/**
|
|
@@ -302,9 +309,16 @@ export const workspacesRevision = (contents: string | null): string =>
|
|
|
302
309
|
|
|
303
310
|
export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
|
|
304
311
|
if (!isConfigObject(parsed)) {
|
|
305
|
-
return {
|
|
312
|
+
return {
|
|
313
|
+
status: "malformed",
|
|
314
|
+
path: file,
|
|
315
|
+
entries: [],
|
|
316
|
+
error: `${file} is not a JSON object`,
|
|
317
|
+
};
|
|
306
318
|
}
|
|
307
|
-
const validated = workspacesFileSchema.safeParse(parsed, {
|
|
319
|
+
const validated = workspacesFileSchema.safeParse(parsed, {
|
|
320
|
+
reportInput: true,
|
|
321
|
+
});
|
|
308
322
|
if (!validated.success) {
|
|
309
323
|
const detail = validated.error.issues
|
|
310
324
|
.map((issue) => {
|
|
@@ -353,7 +367,12 @@ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResul
|
|
|
353
367
|
raw = readFileSync(file, "utf8");
|
|
354
368
|
} catch (error) {
|
|
355
369
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
356
|
-
return {
|
|
370
|
+
return {
|
|
371
|
+
status: "missing",
|
|
372
|
+
path: file,
|
|
373
|
+
entries: [],
|
|
374
|
+
revision: workspacesRevision(null),
|
|
375
|
+
};
|
|
357
376
|
return {
|
|
358
377
|
status: "malformed",
|
|
359
378
|
path: file,
|
|
@@ -373,7 +392,10 @@ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResul
|
|
|
373
392
|
error: `${file} is not valid JSON`,
|
|
374
393
|
};
|
|
375
394
|
}
|
|
376
|
-
return {
|
|
395
|
+
return {
|
|
396
|
+
...validateWorkspacesDocument(parsed, file),
|
|
397
|
+
revision: workspacesRevision(raw),
|
|
398
|
+
};
|
|
377
399
|
};
|
|
378
400
|
|
|
379
401
|
/** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
|
|
@@ -615,7 +637,7 @@ export const workspaceOverlaps = (
|
|
|
615
637
|
for (const b of entries.slice(i + 1)) {
|
|
616
638
|
const aInB = matchWorkspace(b.glob, globRoot(a.glob));
|
|
617
639
|
const bInA = matchWorkspace(a.glob, globRoot(b.glob));
|
|
618
|
-
if (!aInB && !bInA) continue;
|
|
640
|
+
if (!aInB && !bInA && !globsMayOverlap(a.glob, b.glob)) continue;
|
|
619
641
|
const [inner, outer] =
|
|
620
642
|
aInB && !bInA
|
|
621
643
|
? [a, b]
|
|
@@ -818,7 +840,10 @@ export const resolveRuntimeReleaseTracks = (
|
|
|
818
840
|
): { workspace: string; releaseTracks: unknown } | null => {
|
|
819
841
|
const matched = matchRuntimeWorkspace(cwd);
|
|
820
842
|
return matched
|
|
821
|
-
? {
|
|
843
|
+
? {
|
|
844
|
+
workspace: matched.selected.name,
|
|
845
|
+
releaseTracks: matched.selected.releaseTracks,
|
|
846
|
+
}
|
|
822
847
|
: null;
|
|
823
848
|
};
|
|
824
849
|
|
|
@@ -846,7 +871,10 @@ const inheritedSetting = (
|
|
|
846
871
|
const enclosing = matches
|
|
847
872
|
.filter((entry) => entry !== selected)
|
|
848
873
|
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
849
|
-
return {
|
|
874
|
+
return {
|
|
875
|
+
workspace: selected.name,
|
|
876
|
+
source: [selected, ...enclosing].find(has) ?? null,
|
|
877
|
+
};
|
|
850
878
|
};
|
|
851
879
|
|
|
852
880
|
/** Whether `pr merge` deletes the head branch by default: true unless a covering entry sets `deleteBranch: false`. */
|
|
@@ -906,8 +934,18 @@ export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode =
|
|
|
906
934
|
export const modelSources = (
|
|
907
935
|
cwd: string,
|
|
908
936
|
dir?: string,
|
|
909
|
-
): Array<{
|
|
910
|
-
|
|
937
|
+
): Array<{
|
|
938
|
+
name: string;
|
|
939
|
+
global: boolean;
|
|
940
|
+
models: unknown;
|
|
941
|
+
tierModels: unknown;
|
|
942
|
+
}> => {
|
|
943
|
+
const out: Array<{
|
|
944
|
+
name: string;
|
|
945
|
+
global: boolean;
|
|
946
|
+
models: unknown;
|
|
947
|
+
tierModels: unknown;
|
|
948
|
+
}> = [];
|
|
911
949
|
try {
|
|
912
950
|
const matched = matchRuntimeWorkspace(cwd, dir);
|
|
913
951
|
if (matched) {
|
|
@@ -1099,7 +1137,10 @@ export const resolveWorkspacePolicy = (
|
|
|
1099
1137
|
status: "resolved" as const,
|
|
1100
1138
|
profileName: null,
|
|
1101
1139
|
workspace: null,
|
|
1102
|
-
provenance: {
|
|
1140
|
+
provenance: {
|
|
1141
|
+
branchPolicy: "user-default",
|
|
1142
|
+
commitPolicy: "user-default",
|
|
1143
|
+
},
|
|
1103
1144
|
};
|
|
1104
1145
|
if (selected.status === "invalid") return selected;
|
|
1105
1146
|
return {
|
|
@@ -1130,7 +1171,11 @@ export const selectReleaseTrack = (
|
|
|
1130
1171
|
error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
|
|
1131
1172
|
};
|
|
1132
1173
|
}
|
|
1133
|
-
return {
|
|
1174
|
+
return {
|
|
1175
|
+
status: "selected",
|
|
1176
|
+
name: requestedTrack,
|
|
1177
|
+
track: tracks[requestedTrack],
|
|
1178
|
+
};
|
|
1134
1179
|
};
|
|
1135
1180
|
|
|
1136
1181
|
/**
|
package/src/forge/github.ts
CHANGED
|
@@ -29,6 +29,8 @@ export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number:
|
|
|
29
29
|
|
|
30
30
|
export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
|
|
31
31
|
|
|
32
|
+
export const BRANCH_CHECKS_QUERY = `query($owner: String!, $name: String!, $ref: String!, $contexts: String) { repository(owner: $owner, name: $name) { object(expression: $ref) { ... on Commit { oid statusCheckRollup { contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl } } } } } } } }`;
|
|
33
|
+
|
|
32
34
|
const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
33
35
|
const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
34
36
|
const REPLY_MUTATION = `mutation($thread: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body }) { comment { url } } }`;
|
|
@@ -113,6 +115,12 @@ type GqlPr = {
|
|
|
113
115
|
};
|
|
114
116
|
};
|
|
115
117
|
|
|
118
|
+
type BranchChecksData = {
|
|
119
|
+
repository: {
|
|
120
|
+
object: { oid?: string; statusCheckRollup: { contexts: Page<GqlContext> } | null } | null;
|
|
121
|
+
} | null;
|
|
122
|
+
};
|
|
123
|
+
|
|
116
124
|
type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
|
|
117
125
|
|
|
118
126
|
const prState = (value: string): PrState =>
|
|
@@ -451,6 +459,36 @@ export function createGitHubForge(options: {
|
|
|
451
459
|
return success(status);
|
|
452
460
|
},
|
|
453
461
|
|
|
462
|
+
branchChecks(branch) {
|
|
463
|
+
const contexts: GqlContext[] = [];
|
|
464
|
+
let sha: string | null = null;
|
|
465
|
+
let cursor: string | null = null;
|
|
466
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
467
|
+
const data: ForgeResult<BranchChecksData> = graphql<BranchChecksData>(
|
|
468
|
+
BRANCH_CHECKS_QUERY,
|
|
469
|
+
{ owner, name, ref: `refs/heads/${branch}`, contexts: cursor },
|
|
470
|
+
`checks on ${branch}`,
|
|
471
|
+
);
|
|
472
|
+
if (!data.ok) return data;
|
|
473
|
+
const commit: NonNullable<BranchChecksData["repository"]>["object"] =
|
|
474
|
+
data.data.repository?.object ?? null;
|
|
475
|
+
if (!commit) return failure("not_found", `branch ${branch} was not found`);
|
|
476
|
+
sha = commit.oid ?? sha;
|
|
477
|
+
const rollup: { contexts: Page<GqlContext> } | null = commit.statusCheckRollup;
|
|
478
|
+
if (!rollup) break;
|
|
479
|
+
contexts.push(...rollup.contexts.nodes);
|
|
480
|
+
if (!rollup.contexts.pageInfo.hasNextPage) break;
|
|
481
|
+
cursor = rollup.contexts.pageInfo.endCursor;
|
|
482
|
+
}
|
|
483
|
+
const required = requiredContexts(branch);
|
|
484
|
+
const checks = latestPerName(
|
|
485
|
+
contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
|
|
486
|
+
).map((check) =>
|
|
487
|
+
required ? { ...check, required: required.includes(check.context) } : check,
|
|
488
|
+
);
|
|
489
|
+
return success({ sha, checks, requiredKnown: required !== null });
|
|
490
|
+
},
|
|
491
|
+
|
|
454
492
|
jobLogTail(job, lines) {
|
|
455
493
|
const text = apiText(
|
|
456
494
|
runner,
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -465,6 +465,54 @@ export function createGitLabForge(options: {
|
|
|
465
465
|
return success(status);
|
|
466
466
|
},
|
|
467
467
|
|
|
468
|
+
branchChecks(branch) {
|
|
469
|
+
const tip = baseTip(branch);
|
|
470
|
+
if (!tip) return failure("not_found", `branch ${branch} was not found`);
|
|
471
|
+
const latest = apiJson<
|
|
472
|
+
Array<{ id: number; project_id?: number; status: string; web_url?: string }>
|
|
473
|
+
>(
|
|
474
|
+
runner,
|
|
475
|
+
"glab",
|
|
476
|
+
apiHost,
|
|
477
|
+
api(
|
|
478
|
+
`${project}/pipelines?ref=${encodeURIComponent(branch)}&sha=${tip}&order_by=id&sort=desc&per_page=1`,
|
|
479
|
+
),
|
|
480
|
+
`pipelines on ${branch}`,
|
|
481
|
+
);
|
|
482
|
+
if (!latest.ok) return latest;
|
|
483
|
+
const pipeline = Array.isArray(latest.data) ? latest.data[0] : undefined;
|
|
484
|
+
if (!pipeline) return success({ sha: tip, checks: [], requiredKnown: true });
|
|
485
|
+
const scope = String(pipeline.project_id ?? encodeURIComponent(repo));
|
|
486
|
+
const jobs = pages(
|
|
487
|
+
`projects/${scope}/pipelines/${pipeline.id}/jobs`,
|
|
488
|
+
`pipeline ${pipeline.id} jobs`,
|
|
489
|
+
);
|
|
490
|
+
if (!jobs.ok) return jobs;
|
|
491
|
+
const bridges = pages(
|
|
492
|
+
`projects/${scope}/pipelines/${pipeline.id}/bridges`,
|
|
493
|
+
`pipeline ${pipeline.id} bridges`,
|
|
494
|
+
);
|
|
495
|
+
if (!bridges.ok) return bridges;
|
|
496
|
+
const checks = [
|
|
497
|
+
...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
|
|
498
|
+
...(bridges.data.items as GlBridge[]).map((bridge) =>
|
|
499
|
+
checkFromBridge(bridge, pipeline.id, scope),
|
|
500
|
+
),
|
|
501
|
+
];
|
|
502
|
+
if (
|
|
503
|
+
stateOf(pipeline.status, false) === "failing" &&
|
|
504
|
+
!checks.some((check) => check.state === "failing")
|
|
505
|
+
)
|
|
506
|
+
checks.push(
|
|
507
|
+
pipelineCheck("failing", pipeline.status, {
|
|
508
|
+
id: pipeline.id,
|
|
509
|
+
project_id: pipeline.project_id ?? 0,
|
|
510
|
+
web_url: pipeline.web_url,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
return success({ sha: tip, checks, requiredKnown: true });
|
|
514
|
+
},
|
|
515
|
+
|
|
468
516
|
jobLogTail(job, lines) {
|
|
469
517
|
const scope = job.scope ?? encodeURIComponent(repo);
|
|
470
518
|
const text = apiText(
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -46,7 +46,14 @@ import type { LaneNeed, ReviewLane } from "../core/policy/lane";
|
|
|
46
46
|
import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
|
|
47
47
|
import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
|
|
48
48
|
import type { ResolvedForge } from "./resolve";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
failure,
|
|
51
|
+
success,
|
|
52
|
+
type ForgeCheck,
|
|
53
|
+
type ForgeResult,
|
|
54
|
+
type MergeMethod,
|
|
55
|
+
type PrRef,
|
|
56
|
+
} from "./types";
|
|
50
57
|
|
|
51
58
|
export type Sleep = (ms: number) => Promise<void>;
|
|
52
59
|
|
|
@@ -358,6 +365,8 @@ export type MergeInput = {
|
|
|
358
365
|
expect?: { base: string; branch: string };
|
|
359
366
|
/** Merge without a verdict (`merge: true` only); the reason is recorded. */
|
|
360
367
|
unverified?: { reason: string };
|
|
368
|
+
/** Merge onto a base whose latest CI is red (`--base-red-ok`); the reason is recorded. */
|
|
369
|
+
baseRedOk?: { reason: string };
|
|
361
370
|
/** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
|
|
362
371
|
subject?: string;
|
|
363
372
|
body?: string;
|
|
@@ -393,6 +402,8 @@ export type MergeOutcome = {
|
|
|
393
402
|
};
|
|
394
403
|
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
395
404
|
unverified: { reason: string; recorded: string } | null;
|
|
405
|
+
/** The base branch's CI at its head: a one-line note when it was not green, and the override used. */
|
|
406
|
+
baseCi: { note: string | null; override: { reason: string } | null };
|
|
396
407
|
grant: { source: AutonomySource };
|
|
397
408
|
/** `skipped`: the implicit default found a reason to keep the branch; the merge is unaffected. */
|
|
398
409
|
deletedBranch: boolean | { error: string } | { skipped: string };
|
|
@@ -454,6 +465,7 @@ export type MergeRefusal = {
|
|
|
454
465
|
| "closed"
|
|
455
466
|
| "protected_branch"
|
|
456
467
|
| "base_mismatch"
|
|
468
|
+
| "base_red"
|
|
457
469
|
| "ledger_tampered"
|
|
458
470
|
| "intent_unrecorded";
|
|
459
471
|
next?: string;
|
|
@@ -632,6 +644,41 @@ function implicitDeleteSkip(
|
|
|
632
644
|
return null;
|
|
633
645
|
}
|
|
634
646
|
|
|
647
|
+
type BaseCi =
|
|
648
|
+
| { ok: true; note: string | null }
|
|
649
|
+
| { ok: false; failing: ForgeCheck[]; sha: string | null; requiredKnown: boolean };
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The base branch head's CI before a merge lands on it: red (a failing check)
|
|
653
|
+
* refuses, pending or unreadable proceeds with a note, green or no checks
|
|
654
|
+
* proceeds quietly. Only a protected target is guarded: a stack's lower
|
|
655
|
+
* branch is its own PR's business.
|
|
656
|
+
*/
|
|
657
|
+
function baseCiState(cwd: string, resolved: ResolvedForge, base: string): BaseCi {
|
|
658
|
+
if (!isProtectedTarget(cwd, base)) return { ok: true, note: null };
|
|
659
|
+
const read = resolved.forge.branchChecks(base);
|
|
660
|
+
if (!read.ok)
|
|
661
|
+
return { ok: true, note: `base ${base} CI not read (${read.error}); merging anyway` };
|
|
662
|
+
// Only checks branch protection requires gate; with the required set
|
|
663
|
+
// unreadable, count hard failures not known to be optional.
|
|
664
|
+
const { requiredKnown } = read.data;
|
|
665
|
+
const failing = read.data.checks.filter(
|
|
666
|
+
(check) =>
|
|
667
|
+
check.state === "failing" &&
|
|
668
|
+
(requiredKnown
|
|
669
|
+
? check.required === true
|
|
670
|
+
: check.required !== false && check.conclusion === "failure"),
|
|
671
|
+
);
|
|
672
|
+
if (failing.length) return { ok: false, failing, sha: read.data.sha, requiredKnown };
|
|
673
|
+
const pending = read.data.checks.filter((check) => check.state === "pending");
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
note: pending.length
|
|
677
|
+
? `base ${base} CI is still running (${pending.map((check) => check.name).join(", ")}); merging without waiting`
|
|
678
|
+
: null,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
635
682
|
export async function mergePullRequest(
|
|
636
683
|
cwd: string,
|
|
637
684
|
resolved: ResolvedForge,
|
|
@@ -650,6 +697,13 @@ export async function mergePullRequest(
|
|
|
650
697
|
{ reason: "unverified_refused" },
|
|
651
698
|
);
|
|
652
699
|
|
|
700
|
+
const baseRed = input.baseRedOk ?? null;
|
|
701
|
+
if (baseRed && !baseRed.reason.trim())
|
|
702
|
+
return failure(
|
|
703
|
+
"invalid_input",
|
|
704
|
+
"--base-red-ok needs --reason <why merging onto a red base is fine>",
|
|
705
|
+
);
|
|
706
|
+
|
|
653
707
|
const number = selectPr(cwd, resolved, { pr: input.pr });
|
|
654
708
|
if (!number.ok) return number;
|
|
655
709
|
const status = resolved.forge.prStatus(number.data);
|
|
@@ -789,6 +843,20 @@ export async function mergePullRequest(
|
|
|
789
843
|
);
|
|
790
844
|
}
|
|
791
845
|
|
|
846
|
+
const baseCi = baseCiState(cwd, resolved, doc.base);
|
|
847
|
+
let baseRedOverride: { reason: string } | null = null;
|
|
848
|
+
if (!baseCi.ok) {
|
|
849
|
+
const names = baseCi.failing.map((failed) => failed.name).join(", ");
|
|
850
|
+
const url = baseCi.failing.find((failed) => failed.url)?.url ?? null;
|
|
851
|
+
if (!baseRed)
|
|
852
|
+
return refuse(
|
|
853
|
+
`base_red: ${doc.base} at ${baseCi.sha?.slice(0, 12) ?? "its head"} has failing CI (${names}${baseCi.requiredKnown ? "" : "; the required checks could not be read, so every failed check counts"})${url ? `: ${url}` : ""}; merging ${label} onto it would land on a red base`,
|
|
854
|
+
`fix ${doc.base} first, or, if the user accepts it: workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --base-red-ok --reason "<why>"`,
|
|
855
|
+
{ reason: "base_red", blockers: baseCi.failing.map((failed) => failed.name) },
|
|
856
|
+
);
|
|
857
|
+
baseRedOverride = baseRed;
|
|
858
|
+
}
|
|
859
|
+
|
|
792
860
|
const squashMessage = squashMessageFor(cwd, resolved, input, doc);
|
|
793
861
|
if (squashMessage && !squashMessage.ok) return squashMessage;
|
|
794
862
|
// The intent goes in before the forge write: a merge call that errors after
|
|
@@ -806,6 +874,7 @@ export async function mergePullRequest(
|
|
|
806
874
|
review: verdict.review,
|
|
807
875
|
lane: verdict.lane,
|
|
808
876
|
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
877
|
+
...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
|
|
809
878
|
grant: grant.source,
|
|
810
879
|
});
|
|
811
880
|
if (!intent.ok)
|
|
@@ -841,6 +910,7 @@ export async function mergePullRequest(
|
|
|
841
910
|
review: verdict.review,
|
|
842
911
|
lane: verdict.lane,
|
|
843
912
|
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
913
|
+
...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
|
|
844
914
|
grant: grant.source,
|
|
845
915
|
intent: intentId,
|
|
846
916
|
verified: false,
|
|
@@ -909,6 +979,14 @@ export async function mergePullRequest(
|
|
|
909
979
|
mergeSha: merged.data.mergeSha,
|
|
910
980
|
verdict,
|
|
911
981
|
unverified,
|
|
982
|
+
baseCi: {
|
|
983
|
+
note: baseRedOverride
|
|
984
|
+
? `base ${doc.base} CI is red; merged anyway (--base-red-ok: ${baseRedOverride.reason})`
|
|
985
|
+
: baseCi.ok
|
|
986
|
+
? baseCi.note
|
|
987
|
+
: null,
|
|
988
|
+
override: baseRedOverride,
|
|
989
|
+
},
|
|
912
990
|
grant: { source: grant.source },
|
|
913
991
|
deletedBranch,
|
|
914
992
|
recorded,
|
package/src/forge/types.ts
CHANGED
|
@@ -110,6 +110,16 @@ export type ForgePrStatus = {
|
|
|
110
110
|
truncated: boolean;
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
/** The checks on the latest commit of a branch (the merge guard reads the base's). */
|
|
114
|
+
export type RefChecks = {
|
|
115
|
+
/** The commit the checks belong to; null when the forge did not say. */
|
|
116
|
+
sha: string | null;
|
|
117
|
+
/** `required` is set from branch protection when `requiredKnown`; else it is null on GitHub. */
|
|
118
|
+
checks: ForgeCheck[];
|
|
119
|
+
/** True when the branch's required checks could be read (GitLab gates on the whole pipeline). */
|
|
120
|
+
requiredKnown: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
113
123
|
export type JobRef = { jobId: number; scope: string | null };
|
|
114
124
|
|
|
115
125
|
export type RerunTarget =
|
|
@@ -196,6 +206,8 @@ export interface Forge {
|
|
|
196
206
|
repoInfo(repo: string): ForgeResult<RepoInfo>;
|
|
197
207
|
findPr(head: string, match: PrMatch): ForgeResult<PrRef | null>;
|
|
198
208
|
prStatus(n: number): ForgeResult<ForgePrStatus>;
|
|
209
|
+
/** The checks on the head commit of branch `branch`; empty when CI has not reported for it. */
|
|
210
|
+
branchChecks(branch: string): ForgeResult<RefChecks>;
|
|
199
211
|
jobLogTail(job: JobRef, lines: number): ForgeResult<string[]>;
|
|
200
212
|
rerun(target: RerunTarget): ForgeResult<void>;
|
|
201
213
|
/** Open a PR/MR; `headSha` is the head the forge reports right after creation. */
|
package/src/hooks/context.ts
CHANGED
|
@@ -223,7 +223,10 @@ function entryCompactContext(
|
|
|
223
223
|
return hit.value;
|
|
224
224
|
const task = entry ? store.readTask(entry.id) : null;
|
|
225
225
|
if (task && !task.ok) return null;
|
|
226
|
-
const freshness: Freshness = {
|
|
226
|
+
const freshness: Freshness = {
|
|
227
|
+
mode: "signal",
|
|
228
|
+
current: () => recentSignal(store.root),
|
|
229
|
+
};
|
|
227
230
|
const observed = task?.data.evidence.some(isObservedCheck) ?? false;
|
|
228
231
|
const files = (task?.data.decisions ?? []).flatMap((decision) =>
|
|
229
232
|
decision.data.binding.contentRefs.flatMap((ref) =>
|
|
@@ -298,7 +301,6 @@ export const sessionContextText = (
|
|
|
298
301
|
return `<workit-contract>\n${invariantBootstrap()}${compact}${offer ? `\n${offer}` : ""}${options.addendum ? `\n${options.addendum}` : ""}\n</workit-contract>`;
|
|
299
302
|
};
|
|
300
303
|
|
|
301
|
-
/** Per-turn task context only (no bootstrap or offer), or null when none applies. */
|
|
302
304
|
/** The checkout holding `cwd` and its branch, read from the git dir (no spawn). */
|
|
303
305
|
const checkoutAt = (cwd: string): { root: string; branch: string | null } | null => {
|
|
304
306
|
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
@@ -311,7 +313,10 @@ const checkoutAt = (cwd: string): { root: string; branch: string | null } | null
|
|
|
311
313
|
)
|
|
312
314
|
: dotGit;
|
|
313
315
|
const head = fs.readFileSync(path.join(admin, "HEAD"), "utf8").trim();
|
|
314
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
root: dir,
|
|
318
|
+
branch: /^ref: refs\/heads\/(.+)$/u.exec(head)?.[1] ?? null,
|
|
319
|
+
};
|
|
315
320
|
}
|
|
316
321
|
if (path.dirname(dir) === dir) return null;
|
|
317
322
|
}
|
|
@@ -333,6 +338,7 @@ export const whereLine = (cwd: string): string | null => {
|
|
|
333
338
|
}
|
|
334
339
|
};
|
|
335
340
|
|
|
341
|
+
/** Per-turn task context only (no bootstrap or offer), or null when none applies. */
|
|
336
342
|
export const turnContextText = (input: HookInput, descriptor: HostDescriptor): string | null => {
|
|
337
343
|
try {
|
|
338
344
|
const text = sessionCompactContext(
|
|
@@ -422,7 +428,9 @@ export const changedTurnContext = (input: HookInput, text: string | null): strin
|
|
|
422
428
|
if (!file) return text;
|
|
423
429
|
const digest = digestOf(text);
|
|
424
430
|
try {
|
|
425
|
-
const previous = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
431
|
+
const previous = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
432
|
+
digest?: unknown;
|
|
433
|
+
};
|
|
426
434
|
if (previous.digest === digest) return null;
|
|
427
435
|
} catch {
|
|
428
436
|
// Missing or unreadable digest: treat as changed.
|
package/src/ledger.ts
CHANGED
|
@@ -110,7 +110,11 @@ export const PR_ROW_TYPES: ReadonlySet<string> = new Set([
|
|
|
110
110
|
]);
|
|
111
111
|
|
|
112
112
|
/** Who wrote a row. Unverified: the environment says so (D18). */
|
|
113
|
-
export type LedgerActor = {
|
|
113
|
+
export type LedgerActor = {
|
|
114
|
+
host: string;
|
|
115
|
+
session: string | null;
|
|
116
|
+
agentId: string | null;
|
|
117
|
+
};
|
|
114
118
|
|
|
115
119
|
/** The code state a row was recorded against (§2.2). */
|
|
116
120
|
export type CodeKey = {
|
|
@@ -180,7 +184,11 @@ export type VerdictRow = RowCommon & {
|
|
|
180
184
|
untracked?: string[];
|
|
181
185
|
untrackedCount?: number;
|
|
182
186
|
};
|
|
183
|
-
export type HandoffRow = RowCommon & {
|
|
187
|
+
export type HandoffRow = RowCommon & {
|
|
188
|
+
type: "handoff";
|
|
189
|
+
note: string | null;
|
|
190
|
+
next: string;
|
|
191
|
+
};
|
|
184
192
|
/**
|
|
185
193
|
* A lead's standing order for every worker of a fanout ("no new dependencies").
|
|
186
194
|
* `stop` marks the stop line: running workers stop and report.
|
|
@@ -801,7 +809,11 @@ export type UnverifiedRow = {
|
|
|
801
809
|
reason: UnverifiedReason;
|
|
802
810
|
};
|
|
803
811
|
/** The hash chain over the file: rows the CLI wrote, rows from before the chain, and the rest. */
|
|
804
|
-
export type LedgerIntegrity = {
|
|
812
|
+
export type LedgerIntegrity = {
|
|
813
|
+
chained: number;
|
|
814
|
+
legacy: number;
|
|
815
|
+
unverified: UnverifiedRow[];
|
|
816
|
+
};
|
|
805
817
|
export type LedgerRead = {
|
|
806
818
|
path: string;
|
|
807
819
|
rows: ReadRow[];
|
|
@@ -950,7 +962,12 @@ export function readLedger(
|
|
|
950
962
|
});
|
|
951
963
|
return {
|
|
952
964
|
ok: true,
|
|
953
|
-
value: {
|
|
965
|
+
value: {
|
|
966
|
+
path: file.value,
|
|
967
|
+
rows,
|
|
968
|
+
skipped,
|
|
969
|
+
integrity: options.integrity ? integrity : null,
|
|
970
|
+
},
|
|
954
971
|
};
|
|
955
972
|
}
|
|
956
973
|
|
|
@@ -997,7 +1014,12 @@ const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string
|
|
|
997
1014
|
", ",
|
|
998
1015
|
)}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
|
|
999
1016
|
|
|
1000
|
-
export type RowFilter = {
|
|
1017
|
+
export type RowFilter = {
|
|
1018
|
+
branch?: string;
|
|
1019
|
+
pr?: number;
|
|
1020
|
+
type?: string;
|
|
1021
|
+
last?: number;
|
|
1022
|
+
};
|
|
1001
1023
|
|
|
1002
1024
|
export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
|
|
1003
1025
|
const matched = rows.filter(
|
|
@@ -1088,7 +1110,11 @@ export function codeKey(
|
|
|
1088
1110
|
|
|
1089
1111
|
export type VerdictBasis = "fresh" | "carried" | "stale" | "none";
|
|
1090
1112
|
|
|
1091
|
-
type Keyed = {
|
|
1113
|
+
type Keyed = {
|
|
1114
|
+
head: string | null;
|
|
1115
|
+
patchId: string | null;
|
|
1116
|
+
diffHash: string | null;
|
|
1117
|
+
};
|
|
1092
1118
|
|
|
1093
1119
|
/** fresh: same head · carried: same non-null patch-id AND exact diff · stale: otherwise. */
|
|
1094
1120
|
export function verdictBasis(verdict: Keyed, current: Keyed): Exclude<VerdictBasis, "none"> {
|
|
@@ -1170,7 +1196,11 @@ export type VerdictCheck = {
|
|
|
1170
1196
|
* What merge gates read: an accepted strong (`verified`/`tests-verified`)
|
|
1171
1197
|
* verdict and no current independent failure. `type-check-only` never counts.
|
|
1172
1198
|
*/
|
|
1173
|
-
accepted: {
|
|
1199
|
+
accepted: {
|
|
1200
|
+
accepted: boolean;
|
|
1201
|
+
verdict: ReadRow | null;
|
|
1202
|
+
reasons: RejectReason[];
|
|
1203
|
+
};
|
|
1174
1204
|
review: ReviewLabel;
|
|
1175
1205
|
/** The newest current, strong, self verdict when `review` is self-reviewed. */
|
|
1176
1206
|
selfVerdict: ReadRow | null;
|
|
@@ -1269,7 +1299,10 @@ export function authorSessions(
|
|
|
1269
1299
|
cwd: string,
|
|
1270
1300
|
rows: readonly ReadRow[],
|
|
1271
1301
|
branch: string,
|
|
1272
|
-
range: { base: string | null; head: string | null } = {
|
|
1302
|
+
range: { base: string | null; head: string | null } = {
|
|
1303
|
+
base: null,
|
|
1304
|
+
head: null,
|
|
1305
|
+
},
|
|
1273
1306
|
scope: BranchScope = branchScope(cwd, branch),
|
|
1274
1307
|
): Set<string> {
|
|
1275
1308
|
const sessions = new Set<string>();
|
|
@@ -1437,7 +1470,7 @@ function effectiveOf(
|
|
|
1437
1470
|
* head to the current head. With `exactIn` (the checkout), each link is
|
|
1438
1471
|
* re-read with exact blobs (sameOwnChange, #334), so a row recorded before
|
|
1439
1472
|
* that check carries a pass only when the branch's own files are identical
|
|
1440
|
-
* across the restack. Null keeps every observed link: a failure keeps
|
|
1473
|
+
* across the restack or the head is the judged head cleanly rebased (#468). Null keeps every observed link: a failure keeps
|
|
1441
1474
|
* applying through them (failing closed the other way).
|
|
1442
1475
|
*/
|
|
1443
1476
|
const restackExact = new WeakMap<ReadRow, boolean>();
|
|
@@ -1454,15 +1487,18 @@ function restackCarries(
|
|
|
1454
1487
|
const cwd = exactIn;
|
|
1455
1488
|
let known = restackExact.get(row);
|
|
1456
1489
|
if (known === undefined) {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1490
|
+
// Identical own blobs, or the head is exactly the judged head cleanly
|
|
1491
|
+
// rebased onto the moved base (#468): a base edit elsewhere in a shared file.
|
|
1492
|
+
const range =
|
|
1493
|
+
typeof row.fromBase === "string" && typeof row.toBase === "string"
|
|
1494
|
+
? {
|
|
1495
|
+
from: fromHead,
|
|
1496
|
+
to: head,
|
|
1497
|
+
fromBase: row.fromBase,
|
|
1498
|
+
toBase: row.toBase,
|
|
1499
|
+
}
|
|
1500
|
+
: null;
|
|
1501
|
+
known = range !== null && (sameOwnChange(cwd, range) || cleanRebase(cwd, range));
|
|
1466
1502
|
restackExact.set(row, known);
|
|
1467
1503
|
}
|
|
1468
1504
|
return known;
|
|
@@ -1510,7 +1546,10 @@ export function checkVerdicts(
|
|
|
1510
1546
|
const cacheKey = `${base}\0${at}`;
|
|
1511
1547
|
let key = keyCache.get(cacheKey);
|
|
1512
1548
|
if (!key) {
|
|
1513
|
-
key = {
|
|
1549
|
+
key = {
|
|
1550
|
+
patchId: patchId(cwd, base, at),
|
|
1551
|
+
diffHash: diffHash(cwd, base, at),
|
|
1552
|
+
};
|
|
1514
1553
|
keyCache.set(cacheKey, key);
|
|
1515
1554
|
}
|
|
1516
1555
|
return key;
|
|
@@ -1575,7 +1614,10 @@ export function checkVerdicts(
|
|
|
1575
1614
|
const exactCarry = (row: ReadRow): boolean =>
|
|
1576
1615
|
FAILING.has(String(row.result)) || exactMatch(row) !== null;
|
|
1577
1616
|
const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1578
|
-
const basis = verdictBasis(row, {
|
|
1617
|
+
const basis = verdictBasis(row, {
|
|
1618
|
+
head,
|
|
1619
|
+
...keyFor(row.base ?? fallbackBase),
|
|
1620
|
+
});
|
|
1579
1621
|
return basis === "carried" && !exactCarry(row) ? "stale" : basis;
|
|
1580
1622
|
};
|
|
1581
1623
|
/** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
|
|
@@ -1736,7 +1778,11 @@ export function checkVerdicts(
|
|
|
1736
1778
|
(entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
1737
1779
|
) ?? null;
|
|
1738
1780
|
const accepted = tampered.length
|
|
1739
|
-
? {
|
|
1781
|
+
? {
|
|
1782
|
+
accepted: false,
|
|
1783
|
+
verdict: null,
|
|
1784
|
+
reasons: ["ledger_tampered"] as RejectReason[],
|
|
1785
|
+
}
|
|
1740
1786
|
: newestAccepted && !failing
|
|
1741
1787
|
? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
|
|
1742
1788
|
: {
|
|
@@ -1789,7 +1835,10 @@ export function checkVerdicts(
|
|
|
1789
1835
|
selfVerdict: accepted.accepted ? null : (selfEntry?.verdict ?? null),
|
|
1790
1836
|
base: newestCurrent?.verdict.base ?? fallbackBase,
|
|
1791
1837
|
patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
|
|
1792
|
-
current: {
|
|
1838
|
+
current: {
|
|
1839
|
+
basis: newestCurrent?.basis ?? "none",
|
|
1840
|
+
verdict: newestCurrent?.verdict ?? null,
|
|
1841
|
+
},
|
|
1793
1842
|
carry: carryEntry?.carry ?? null,
|
|
1794
1843
|
reverify,
|
|
1795
1844
|
accepted,
|
|
@@ -1868,7 +1917,13 @@ function checkSupersede(
|
|
|
1868
1917
|
|
|
1869
1918
|
export function recordDecision(
|
|
1870
1919
|
context: RecordContext,
|
|
1871
|
-
input: {
|
|
1920
|
+
input: {
|
|
1921
|
+
what?: string;
|
|
1922
|
+
why?: string;
|
|
1923
|
+
refs?: string[];
|
|
1924
|
+
by?: string;
|
|
1925
|
+
views?: string[];
|
|
1926
|
+
},
|
|
1872
1927
|
): LedgerResult<DecisionRow> {
|
|
1873
1928
|
if (input.by !== undefined && input.by !== "decider")
|
|
1874
1929
|
return err("invalid_input", `--by takes "decider", got "${input.by}"`);
|
|
@@ -2081,7 +2136,11 @@ export function clearStanding(
|
|
|
2081
2136
|
/** Lift the stop line: clear each stop order in force and keep the others; lead only. */
|
|
2082
2137
|
export function liftStop(
|
|
2083
2138
|
context: RecordContext,
|
|
2084
|
-
input: {
|
|
2139
|
+
input: {
|
|
2140
|
+
fanout: string;
|
|
2141
|
+
lead: string | null;
|
|
2142
|
+
leads?: readonly string[] | null;
|
|
2143
|
+
},
|
|
2085
2144
|
): LedgerResult<StandingClearedRow[]> {
|
|
2086
2145
|
const lead = leadOnly(context, input.lead);
|
|
2087
2146
|
if (!lead.ok) return lead;
|
|
@@ -2242,10 +2301,16 @@ export function recordVerdict(
|
|
|
2242
2301
|
);
|
|
2243
2302
|
}
|
|
2244
2303
|
|
|
2245
|
-
function untrackedField(cwd: string): {
|
|
2304
|
+
function untrackedField(cwd: string): {
|
|
2305
|
+
untracked?: string[];
|
|
2306
|
+
untrackedCount?: number;
|
|
2307
|
+
} {
|
|
2246
2308
|
const files = untrackedFiles(cwd);
|
|
2247
2309
|
return files.length
|
|
2248
|
-
? {
|
|
2310
|
+
? {
|
|
2311
|
+
untracked: files.slice(0, MAX_UNTRACKED_LISTED),
|
|
2312
|
+
untrackedCount: files.length,
|
|
2313
|
+
}
|
|
2249
2314
|
: {};
|
|
2250
2315
|
}
|
|
2251
2316
|
|
|
@@ -2255,7 +2320,12 @@ export function recordHandoff(
|
|
|
2255
2320
|
): LedgerResult<HandoffRow> {
|
|
2256
2321
|
return appendRow<HandoffRow>(
|
|
2257
2322
|
context.cwd,
|
|
2258
|
-
{
|
|
2323
|
+
{
|
|
2324
|
+
...common(context, noteKey(context)),
|
|
2325
|
+
type: "handoff",
|
|
2326
|
+
note: input.note,
|
|
2327
|
+
next: input.next,
|
|
2328
|
+
},
|
|
2259
2329
|
{ now: context.now },
|
|
2260
2330
|
);
|
|
2261
2331
|
}
|
|
@@ -2404,7 +2474,12 @@ export function stackPosition(root: string, branch: string): StackPosition | nul
|
|
|
2404
2474
|
return null;
|
|
2405
2475
|
}
|
|
2406
2476
|
|
|
2407
|
-
export type CheckFreshness = {
|
|
2477
|
+
export type CheckFreshness = {
|
|
2478
|
+
name: string;
|
|
2479
|
+
result: string | null;
|
|
2480
|
+
fresh: boolean;
|
|
2481
|
+
at: string;
|
|
2482
|
+
};
|
|
2408
2483
|
|
|
2409
2484
|
type VerdictSummary = Omit<VerdictEntry, "verdict"> & { summary: string };
|
|
2410
2485
|
|
|
@@ -2453,7 +2528,11 @@ const dirtyState = (cwd: string): HandoffBrief["dirty"] => {
|
|
|
2453
2528
|
// Renames and copies carry the source path as the next entry.
|
|
2454
2529
|
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
2455
2530
|
}
|
|
2456
|
-
return {
|
|
2531
|
+
return {
|
|
2532
|
+
dirty: paths.length > 0,
|
|
2533
|
+
files: paths.length,
|
|
2534
|
+
paths: paths.slice(0, MAX_DIRTY_PATHS),
|
|
2535
|
+
};
|
|
2457
2536
|
};
|
|
2458
2537
|
|
|
2459
2538
|
const nextCommandFor = (brief: Omit<HandoffBrief, "nextCommand" | "next">): string => {
|
|
@@ -2521,7 +2600,11 @@ export function buildHandoff(
|
|
|
2521
2600
|
}));
|
|
2522
2601
|
const lastHandoffRow = mine.findLast((row) => row.type === "handoff");
|
|
2523
2602
|
const partial: Omit<HandoffBrief, "nextCommand" | "next"> = {
|
|
2524
|
-
store: {
|
|
2603
|
+
store: {
|
|
2604
|
+
root: store.value.root,
|
|
2605
|
+
shared: store.value.shared,
|
|
2606
|
+
ledger: ledger.path,
|
|
2607
|
+
},
|
|
2525
2608
|
branch,
|
|
2526
2609
|
head,
|
|
2527
2610
|
subject: head ? git(cwd, ["log", "-1", "--format=%s", head]) : null,
|
|
@@ -2559,6 +2642,10 @@ export function buildHandoff(
|
|
|
2559
2642
|
const nextCommand = nextCommandFor(partial);
|
|
2560
2643
|
return {
|
|
2561
2644
|
ok: true,
|
|
2562
|
-
value: {
|
|
2645
|
+
value: {
|
|
2646
|
+
...partial,
|
|
2647
|
+
nextCommand,
|
|
2648
|
+
next: options.next?.trim() || nextCommand,
|
|
2649
|
+
},
|
|
2563
2650
|
};
|
|
2564
2651
|
}
|
package/src/stack.ts
CHANGED
|
@@ -160,7 +160,12 @@ export const stackDeps: {
|
|
|
160
160
|
// ---------------------------------------------------------------------------
|
|
161
161
|
// git plumbing
|
|
162
162
|
|
|
163
|
-
type GitRun = {
|
|
163
|
+
type GitRun = {
|
|
164
|
+
ok: boolean;
|
|
165
|
+
stdout: string;
|
|
166
|
+
stderr: string;
|
|
167
|
+
timedOut: boolean;
|
|
168
|
+
};
|
|
164
169
|
|
|
165
170
|
const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
|
|
166
171
|
const run = spawnSync("git", args, {
|
|
@@ -245,7 +250,13 @@ const trackedDirt = (dir: string): boolean => {
|
|
|
245
250
|
|
|
246
251
|
export type RestackOutcome =
|
|
247
252
|
| { ok: true; head: string }
|
|
248
|
-
| {
|
|
253
|
+
| {
|
|
254
|
+
ok: false;
|
|
255
|
+
conflict: boolean;
|
|
256
|
+
timedOut?: boolean;
|
|
257
|
+
worktree: string;
|
|
258
|
+
error: string;
|
|
259
|
+
};
|
|
249
260
|
|
|
250
261
|
/** Where sync checks out branches nobody has checked out (never the user's checkout). */
|
|
251
262
|
const scratchDir = (cwd: string): string | null => {
|
|
@@ -269,14 +280,26 @@ function gitRestack(
|
|
|
269
280
|
let dir = owner;
|
|
270
281
|
if (!dir) {
|
|
271
282
|
const scratch = scratchDir(cwd);
|
|
272
|
-
if (!scratch)
|
|
283
|
+
if (!scratch)
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
conflict: false,
|
|
287
|
+
worktree: cwd,
|
|
288
|
+
error: "no store root",
|
|
289
|
+
};
|
|
273
290
|
fs.mkdirSync(scratch, { recursive: true });
|
|
274
291
|
dir = path.join(
|
|
275
292
|
scratch,
|
|
276
293
|
`${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes(4).toString("hex")}`,
|
|
277
294
|
);
|
|
278
295
|
const added = gitRun(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
|
|
279
|
-
if (!added.ok)
|
|
296
|
+
if (!added.ok)
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
conflict: false,
|
|
300
|
+
worktree: cwd,
|
|
301
|
+
error: firstLine(added),
|
|
302
|
+
};
|
|
280
303
|
}
|
|
281
304
|
const run = gitRun(
|
|
282
305
|
dir,
|
|
@@ -304,7 +327,12 @@ function gitRestack(
|
|
|
304
327
|
error: `git rebase did not finish within ${stackDeps.rebaseTimeoutMs} ms`,
|
|
305
328
|
};
|
|
306
329
|
if (rebaseInProgress(dir))
|
|
307
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
conflict: true,
|
|
333
|
+
worktree: dir,
|
|
334
|
+
error: firstLine(run),
|
|
335
|
+
};
|
|
308
336
|
if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
309
337
|
return { ok: false, conflict: false, worktree: dir, error: firstLine(run) };
|
|
310
338
|
}
|
|
@@ -312,7 +340,12 @@ function gitRestack(
|
|
|
312
340
|
const head = branchTip(cwd, input.branch);
|
|
313
341
|
return head
|
|
314
342
|
? { ok: true, head }
|
|
315
|
-
: {
|
|
343
|
+
: {
|
|
344
|
+
ok: false,
|
|
345
|
+
conflict: false,
|
|
346
|
+
worktree: dir,
|
|
347
|
+
error: `${input.branch} vanished`,
|
|
348
|
+
};
|
|
316
349
|
}
|
|
317
350
|
|
|
318
351
|
/** Remove temporary sync worktrees that are clean and not mid-rebase. */
|
|
@@ -489,7 +522,10 @@ export function listStacks(cwd: string): StackResult<StackFile[]> {
|
|
|
489
522
|
// Only files at the name's own path count (a renamed copy is ignored).
|
|
490
523
|
if (parsed && fileFor(parsed.name) === name) out.push(parsed);
|
|
491
524
|
}
|
|
492
|
-
return {
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
data: out.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
528
|
+
};
|
|
493
529
|
}
|
|
494
530
|
|
|
495
531
|
/** The stack `name`, else the one holding the current branch, else the only one. */
|
|
@@ -539,7 +575,12 @@ const OWNERLESS_STALE_MS = 30_000;
|
|
|
539
575
|
|
|
540
576
|
const OWNER = "owner";
|
|
541
577
|
|
|
542
|
-
type LockOwner = {
|
|
578
|
+
type LockOwner = {
|
|
579
|
+
pid: number;
|
|
580
|
+
processStart: string | null;
|
|
581
|
+
host: string;
|
|
582
|
+
nonce: string;
|
|
583
|
+
};
|
|
543
584
|
|
|
544
585
|
const readOwner = (lock: string): { raw: string | null; age: number | null } => {
|
|
545
586
|
let raw: string | null = null;
|
|
@@ -1039,7 +1080,13 @@ export type BranchStatus = {
|
|
|
1039
1080
|
|
|
1040
1081
|
export type Qualification =
|
|
1041
1082
|
| { ok: true }
|
|
1042
|
-
| {
|
|
1083
|
+
| {
|
|
1084
|
+
ok: false;
|
|
1085
|
+
reason: StopReason;
|
|
1086
|
+
detail: string;
|
|
1087
|
+
unblock: string;
|
|
1088
|
+
ready: boolean;
|
|
1089
|
+
};
|
|
1043
1090
|
|
|
1044
1091
|
export type StatusOutcome = {
|
|
1045
1092
|
name: string;
|
|
@@ -1063,7 +1110,10 @@ const noun = (resolved: ResolvedForge, pr: number): string =>
|
|
|
1063
1110
|
function statusDoc(ctx: Ctx, pr: number): ForgeResult<PrStatusDoc> {
|
|
1064
1111
|
const status = ctx.resolved.forge.prStatus(pr);
|
|
1065
1112
|
if (!status.ok) return status;
|
|
1066
|
-
return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
|
|
1113
|
+
return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
|
|
1114
|
+
logLines: 0,
|
|
1115
|
+
behind: false,
|
|
1116
|
+
});
|
|
1067
1117
|
}
|
|
1068
1118
|
|
|
1069
1119
|
/**
|
|
@@ -1094,7 +1144,10 @@ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): Stac
|
|
|
1094
1144
|
"blocked",
|
|
1095
1145
|
`repo_mismatch: stack ${stack.name} was planned on ${stack.forge ?? "?"} ${stack.repo ?? "?"}, but this checkout resolves to ${kind} ${repo}`,
|
|
1096
1146
|
`workit stack plan --name ${stack.name} <branch…> # re-plan it here, or run from the right checkout`,
|
|
1097
|
-
{
|
|
1147
|
+
{
|
|
1148
|
+
planned: { forge: stack.forge, repo: stack.repo },
|
|
1149
|
+
resolved: { forge: kind, repo },
|
|
1150
|
+
},
|
|
1098
1151
|
);
|
|
1099
1152
|
return null;
|
|
1100
1153
|
}
|
|
@@ -1332,7 +1385,12 @@ export type SyncStep = {
|
|
|
1332
1385
|
pr: number | null;
|
|
1333
1386
|
parent: string;
|
|
1334
1387
|
/** null: already on its parent. */
|
|
1335
|
-
restack: {
|
|
1388
|
+
restack: {
|
|
1389
|
+
from: string;
|
|
1390
|
+
onto: string;
|
|
1391
|
+
head: string | null;
|
|
1392
|
+
carried: boolean | null;
|
|
1393
|
+
} | null;
|
|
1336
1394
|
push: { pushed: boolean; forced: boolean; sha: string } | null;
|
|
1337
1395
|
retarget: { from: string; to: string } | null;
|
|
1338
1396
|
};
|
|
@@ -1383,7 +1441,11 @@ function detectMerged(
|
|
|
1383
1441
|
isAncestor(cwd, tip, trunkSha);
|
|
1384
1442
|
}
|
|
1385
1443
|
if (merged) {
|
|
1386
|
-
entry.merged = {
|
|
1444
|
+
entry.merged = {
|
|
1445
|
+
pr: entry.pr,
|
|
1446
|
+
mergeSha: null,
|
|
1447
|
+
at: new Date().toISOString(),
|
|
1448
|
+
};
|
|
1387
1449
|
newly.push({ branch: entry.branch, pr: entry.pr });
|
|
1388
1450
|
}
|
|
1389
1451
|
}
|
|
@@ -1407,21 +1469,34 @@ function pushBranch(
|
|
|
1407
1469
|
const remote = remoteRefTip(cwd, plan.data.rawUrl, `refs/heads/${branch}`);
|
|
1408
1470
|
if (!remote.ok) return stackFail(remote.code, remote.error);
|
|
1409
1471
|
if (remote.sha === plan.data.sha)
|
|
1410
|
-
return {
|
|
1472
|
+
return {
|
|
1473
|
+
ok: true,
|
|
1474
|
+
data: { pushed: false, forced: false, sha: plan.data.sha },
|
|
1475
|
+
};
|
|
1411
1476
|
const grant = requireGrant(cwd, "push", { forge: !plan.data.local });
|
|
1412
1477
|
if (!grant.allowed)
|
|
1413
|
-
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1478
|
+
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1479
|
+
reason: grant.reason,
|
|
1480
|
+
});
|
|
1414
1481
|
const force = remote.sha !== null && !isAncestor(cwd, remote.sha, plan.data.sha);
|
|
1415
1482
|
const expect = !force
|
|
1416
1483
|
? null
|
|
1417
1484
|
: remote.sha === priorTip
|
|
1418
1485
|
? priorTip
|
|
1419
1486
|
: (recordedRemoteTip(cwd, plan.data) ?? null);
|
|
1420
|
-
const pushed = executePush(cwd, plan.data, {
|
|
1487
|
+
const pushed = executePush(cwd, plan.data, {
|
|
1488
|
+
forceWithLease: force,
|
|
1489
|
+
expect,
|
|
1490
|
+
actor,
|
|
1491
|
+
});
|
|
1421
1492
|
if (!pushed.ok) return { ...fromForge(pushed), data: { branch } };
|
|
1422
1493
|
return {
|
|
1423
1494
|
ok: true,
|
|
1424
|
-
data: {
|
|
1495
|
+
data: {
|
|
1496
|
+
pushed: pushed.data.pushed,
|
|
1497
|
+
forced: pushed.data.forced,
|
|
1498
|
+
sha: plan.data.sha,
|
|
1499
|
+
},
|
|
1425
1500
|
};
|
|
1426
1501
|
}
|
|
1427
1502
|
|
|
@@ -1584,7 +1659,11 @@ export function syncStack(
|
|
|
1584
1659
|
"blocked",
|
|
1585
1660
|
`conflict restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
|
|
1586
1661
|
`resolve the conflicts in ${rebased.worktree}, git rebase --continue, then workit stack sync # or git rebase --abort`,
|
|
1587
|
-
{
|
|
1662
|
+
{
|
|
1663
|
+
conflict: entry.branch,
|
|
1664
|
+
worktree: rebased.worktree,
|
|
1665
|
+
progress: outcome,
|
|
1666
|
+
},
|
|
1588
1667
|
)
|
|
1589
1668
|
: stackFail(
|
|
1590
1669
|
"failed",
|
|
@@ -1670,13 +1749,21 @@ export function syncStack(
|
|
|
1670
1749
|
p: patchId(cwd, fromBase, fromHead),
|
|
1671
1750
|
d: diffHash(cwd, fromBase, fromHead),
|
|
1672
1751
|
};
|
|
1673
|
-
const after = {
|
|
1752
|
+
const after = {
|
|
1753
|
+
p: patchId(cwd, parentTip, newTip),
|
|
1754
|
+
d: diffHash(cwd, parentTip, newTip),
|
|
1755
|
+
};
|
|
1674
1756
|
const equal =
|
|
1675
1757
|
before.d !== null &&
|
|
1676
1758
|
before.d === after.d &&
|
|
1677
1759
|
before.p !== null &&
|
|
1678
1760
|
before.p === after.p &&
|
|
1679
|
-
sameOwnChange(cwd, {
|
|
1761
|
+
sameOwnChange(cwd, {
|
|
1762
|
+
from: fromHead,
|
|
1763
|
+
to: newTip,
|
|
1764
|
+
fromBase,
|
|
1765
|
+
toBase: parentTip,
|
|
1766
|
+
});
|
|
1680
1767
|
appendObserved(cwd, {
|
|
1681
1768
|
type: "stack.restacked",
|
|
1682
1769
|
actor,
|
|
@@ -1692,7 +1779,13 @@ export function syncStack(
|
|
|
1692
1779
|
forced,
|
|
1693
1780
|
});
|
|
1694
1781
|
if (step.restack) step.restack.carried = equal;
|
|
1695
|
-
else
|
|
1782
|
+
else
|
|
1783
|
+
step.restack = {
|
|
1784
|
+
from: fromBase,
|
|
1785
|
+
onto: parentTip,
|
|
1786
|
+
head: newTip,
|
|
1787
|
+
carried: equal,
|
|
1788
|
+
};
|
|
1696
1789
|
}
|
|
1697
1790
|
if (options.publish) {
|
|
1698
1791
|
touchStackLock();
|
|
@@ -1721,7 +1814,10 @@ export function syncStack(
|
|
|
1721
1814
|
});
|
|
1722
1815
|
const updated = ctx.resolved.forge.updateBase(entry.pr, parent.name);
|
|
1723
1816
|
if (!updated.ok)
|
|
1724
|
-
return {
|
|
1817
|
+
return {
|
|
1818
|
+
...fromForge(updated),
|
|
1819
|
+
data: { branch: entry.branch, progress: outcome },
|
|
1820
|
+
};
|
|
1725
1821
|
appendObserved(cwd, {
|
|
1726
1822
|
type: "pr.retargeted",
|
|
1727
1823
|
actor,
|
|
@@ -1766,6 +1862,8 @@ export type LandOptions = {
|
|
|
1766
1862
|
method: MergeMethod;
|
|
1767
1863
|
/** Land without verdicts (`merge: true` only); each bypass is recorded. */
|
|
1768
1864
|
unverified?: { reason: string } | null;
|
|
1865
|
+
/** Merge onto a red base (`--base-red-ok`); each merge records the reason. */
|
|
1866
|
+
baseRedOk?: { reason: string } | null;
|
|
1769
1867
|
/** How long to wait for CI on a PR that was just restacked. */
|
|
1770
1868
|
timeoutMs: number;
|
|
1771
1869
|
intervalMs: number;
|
|
@@ -1775,7 +1873,12 @@ export type LandOutcome = {
|
|
|
1775
1873
|
name: string;
|
|
1776
1874
|
dryRun: boolean;
|
|
1777
1875
|
/** `unverified`: the ledger row of an `--unverified` bypass, else null. */
|
|
1778
|
-
landed: Array<{
|
|
1876
|
+
landed: Array<{
|
|
1877
|
+
pr: number;
|
|
1878
|
+
branch: string;
|
|
1879
|
+
mergeSha: string | null;
|
|
1880
|
+
unverified: string | null;
|
|
1881
|
+
}>;
|
|
1779
1882
|
/** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
|
|
1780
1883
|
wouldLand: Array<{ pr: number; branch: string }>;
|
|
1781
1884
|
stoppedAt: {
|
|
@@ -1849,7 +1952,9 @@ export async function landStack(
|
|
|
1849
1952
|
const grant = requireGrant(ctx.cwd, "merge");
|
|
1850
1953
|
if (!grant.allowed)
|
|
1851
1954
|
return failWith(
|
|
1852
|
-
stackFail("blocked", grant.error, grant.unblock, {
|
|
1955
|
+
stackFail("blocked", grant.error, grant.unblock, {
|
|
1956
|
+
reason: "grant_required",
|
|
1957
|
+
}),
|
|
1853
1958
|
);
|
|
1854
1959
|
if (!grant.allowUnverified)
|
|
1855
1960
|
return failWith(
|
|
@@ -1959,6 +2064,7 @@ export async function landStack(
|
|
|
1959
2064
|
actor: ctx.actor,
|
|
1960
2065
|
expect: { base: stack.trunk, branch: entry.branch },
|
|
1961
2066
|
...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
|
|
2067
|
+
...(options.baseRedOk ? { baseRedOk: options.baseRedOk } : {}),
|
|
1962
2068
|
},
|
|
1963
2069
|
ctx.sleep,
|
|
1964
2070
|
);
|
|
@@ -1982,7 +2088,11 @@ export async function landStack(
|
|
|
1982
2088
|
mergeSha: merged.data.mergeSha,
|
|
1983
2089
|
unverified: merged.data.unverified?.recorded ?? null,
|
|
1984
2090
|
});
|
|
1985
|
-
entry.merged = {
|
|
2091
|
+
entry.merged = {
|
|
2092
|
+
pr,
|
|
2093
|
+
mergeSha: merged.data.mergeSha,
|
|
2094
|
+
at: new Date().toISOString(),
|
|
2095
|
+
};
|
|
1986
2096
|
const written = writeStack(ctx.cwd, stack);
|
|
1987
2097
|
if (!written.ok) return failWith(written);
|
|
1988
2098
|
// Move the next PR onto the trunk: restack, lease push, retarget. The
|