@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.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/analysis/change-classifier.mjs +16 -30
- package/src/analysis/diff-analyzer.mjs +38 -15
- package/src/claude/asset-generation.mjs +36 -1
- package/src/config/config.mjs +143 -81
- package/src/config/extension-defaults.yaml +18 -6
- package/src/github/copilot-helpers.mjs +22 -8
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +9 -9
- package/src/loop/gate-fanin.mjs +21 -0
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +87 -22
- package/src/loop/pr-gate-coordination.mjs +270 -60
- package/src/loop/spec-authority.mjs +21 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -264,7 +264,7 @@ export function sanitizeCopilotSummonTokens(text) {
|
|
|
264
264
|
// line) from `text`, leaving only bare-text markdown to scan. Unlike
|
|
265
265
|
// transformNonFencedLines, fenced content here must be REMOVED, not kept:
|
|
266
266
|
// leaving it would let bare text inside a fence still match the summon scan.
|
|
267
|
-
function stripMarkdownCodeForScan(text) {
|
|
267
|
+
export function stripMarkdownCodeForScan(text) {
|
|
268
268
|
const lines = String(text).split(/\r?\n/);
|
|
269
269
|
let inFencedBlock = false;
|
|
270
270
|
let fencedDelimiter = "";
|
|
@@ -310,7 +310,8 @@ export function normalizeTimestamp(value) {
|
|
|
310
310
|
export function extractReviewCommitSha(review) {
|
|
311
311
|
const graphqlSha = typeof review?.commit?.oid === "string" ? review.commit.oid.trim() : "";
|
|
312
312
|
const restSha = typeof review?.commit_id === "string" ? review.commit_id.trim() : "";
|
|
313
|
-
const
|
|
313
|
+
const camelCaseSha = typeof review?.commitId === "string" ? review.commitId.trim() : "";
|
|
314
|
+
const sha = graphqlSha || restSha || camelCaseSha;
|
|
314
315
|
return sha.length > 0 ? sha : null;
|
|
315
316
|
}
|
|
316
317
|
|
|
@@ -768,6 +769,11 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
768
769
|
let hasSubmittedReviewOnCurrentHead = false;
|
|
769
770
|
let latestSubmittedReviewOnCurrentHeadAt = null;
|
|
770
771
|
let hasBodyFindingOnCurrentHead = false;
|
|
772
|
+
// The id of the review whose body set hasBodyFindingOnCurrentHead: the
|
|
773
|
+
// review a copilot-body-disposition record must name to clear the finding.
|
|
774
|
+
// Updated on every branch that updates hasBodyFindingOnCurrentHead so the two
|
|
775
|
+
// never drift.
|
|
776
|
+
let bodyFindingReviewId = null;
|
|
771
777
|
let completedCopilotReviewRounds = 0;
|
|
772
778
|
|
|
773
779
|
for (const review of effectiveReviews) {
|
|
@@ -793,21 +799,28 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
793
799
|
const submittedAt = typeof review?.submittedAt === "string"
|
|
794
800
|
? review.submittedAt
|
|
795
801
|
: (typeof review?.submitted_at === "string" ? review.submitted_at : null);
|
|
802
|
+
const reviewId = review?.id !== null && review?.id !== undefined ? String(review.id) : null;
|
|
796
803
|
if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
|
|
797
804
|
latestSubmittedReviewOnCurrentHeadAt = submittedAt;
|
|
798
805
|
hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
806
|
+
bodyFindingReviewId = hasBodyFindingOnCurrentHead ? reviewId : null;
|
|
807
|
+
} else if (submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
|
|
808
|
+
// Equal-timestamp (or both-null) tie on the same head: fail toward
|
|
809
|
+
// surfacing so array order never silently drops a finding. When two
|
|
810
|
+
// tied reviews both signal, no single review owns the finding, so
|
|
811
|
+
// bodyFindingReviewId is null and no disposition record can clear it.
|
|
812
|
+
const tieSignals = copilotReviewBodySignalsChanges(state, review?.body);
|
|
813
|
+
if (tieSignals) {
|
|
814
|
+
bodyFindingReviewId = hasBodyFindingOnCurrentHead ? null : reviewId;
|
|
815
|
+
}
|
|
816
|
+
hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || tieSignals;
|
|
805
817
|
}
|
|
806
818
|
}
|
|
807
819
|
}
|
|
808
820
|
|
|
809
821
|
return {
|
|
810
822
|
copilotReviews,
|
|
823
|
+
effectiveCopilotReviews: effectiveReviews,
|
|
811
824
|
copilotReviewIds: copilotReviews
|
|
812
825
|
.map((review) => review?.id)
|
|
813
826
|
.filter((id) => id !== null && id !== undefined)
|
|
@@ -818,5 +831,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
818
831
|
hasSubmittedReviewOnCurrentHead,
|
|
819
832
|
latestSubmittedReviewOnCurrentHeadAt,
|
|
820
833
|
hasBodyFindingOnCurrentHead,
|
|
834
|
+
bodyFindingReviewId,
|
|
821
835
|
};
|
|
822
836
|
}
|
package/src/github/gh.mjs
CHANGED
|
@@ -67,7 +67,8 @@ export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRu
|
|
|
67
67
|
* @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
|
|
68
68
|
* @param {object} [opts]
|
|
69
69
|
* @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
|
|
70
|
-
* in the response is returned instead of thrown.
|
|
70
|
+
* in the response is returned instead of thrown. This includes a non-zero
|
|
71
|
+
* `gh` exit whose stdout is JSON with an `errors` array.
|
|
71
72
|
*/
|
|
72
73
|
export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
|
|
73
74
|
const fieldArgs = [];
|
|
@@ -80,6 +81,18 @@ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, {
|
|
|
80
81
|
env,
|
|
81
82
|
);
|
|
82
83
|
if (result.code !== 0) {
|
|
84
|
+
// `gh api graphql` exits non-zero when the response carries GraphQL
|
|
85
|
+
// `errors` (e.g. NOT_FOUND) but still prints the JSON on stdout. With
|
|
86
|
+
// allowErrors the caller wants that errors array, so return it.
|
|
87
|
+
if (allowErrors) {
|
|
88
|
+
let errorPayload = null;
|
|
89
|
+
try {
|
|
90
|
+
errorPayload = JSON.parse(result.stdout);
|
|
91
|
+
} catch {
|
|
92
|
+
// not JSON: fall through to GH_API_ERROR
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(errorPayload?.errors)) return errorPayload;
|
|
95
|
+
}
|
|
83
96
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
84
97
|
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
85
98
|
}
|
|
@@ -121,6 +121,11 @@ export function parseReviewThreads(payload) {
|
|
|
121
121
|
const threads = rawThreads.map((thread, threadIndex) => {
|
|
122
122
|
const threadId = normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`);
|
|
123
123
|
const isResolved = Boolean(thread?.isResolved);
|
|
124
|
+
// The review that opened the thread: the root (first) comment's
|
|
125
|
+
// pullRequestReview id. null when the payload does not carry it, so a
|
|
126
|
+
// consumer that needs attribution treats the thread as unattributed.
|
|
127
|
+
const rootReviewId = extractRawComments(thread)[0]?.pullRequestReview?.id;
|
|
128
|
+
const reviewId = typeof rootReviewId === "string" && rootReviewId.length > 0 ? rootReviewId : null;
|
|
124
129
|
const normalizedComments = extractRawComments(thread)
|
|
125
130
|
.map((comment, commentIndex) => normalizeComment(comment, threadId, commentIndex, { isResolved }))
|
|
126
131
|
.sort((left, right) => compareIds(left.id, right.id));
|
|
@@ -141,6 +146,7 @@ export function parseReviewThreads(payload) {
|
|
|
141
146
|
return {
|
|
142
147
|
id: threadId,
|
|
143
148
|
isResolved,
|
|
149
|
+
reviewId,
|
|
144
150
|
isActionable: actionableCommentIds.length > 0,
|
|
145
151
|
commentIds,
|
|
146
152
|
commentDatabaseIds,
|
|
@@ -198,6 +198,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
198
198
|
failureDetails = [],
|
|
199
199
|
excludedFailureDetails,
|
|
200
200
|
copilotBodyFeedbackUnresolved = false,
|
|
201
|
+
copilotPriorHeadBodyFeedbackUnresolved = false,
|
|
201
202
|
}) {
|
|
202
203
|
const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
|
|
203
204
|
const prMerged = prState === "MERGED";
|
|
@@ -228,6 +229,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
228
229
|
failureDetails,
|
|
229
230
|
excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
|
|
230
231
|
copilotBodyFeedbackUnresolved,
|
|
232
|
+
copilotPriorHeadBodyFeedbackUnresolved,
|
|
231
233
|
});
|
|
232
234
|
}
|
|
233
235
|
|
|
@@ -298,6 +300,10 @@ export function normalizeSnapshot(raw) {
|
|
|
298
300
|
failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
|
|
299
301
|
excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
|
|
300
302
|
copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
|
|
303
|
+
// A body-only changes-recommended/unrecognized latest Copilot review on an
|
|
304
|
+
// earlier head with no trusted disposition record. Consumed only at the
|
|
305
|
+
// round cap, where no fresh Copilot review can supersede it.
|
|
306
|
+
copilotPriorHeadBodyFeedbackUnresolved: Boolean(raw.copilotPriorHeadBodyFeedbackUnresolved),
|
|
301
307
|
};
|
|
302
308
|
}
|
|
303
309
|
|
|
@@ -357,7 +363,8 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
|
|
|
357
363
|
* nextAction: string,
|
|
358
364
|
* autoRerequestEligible: boolean,
|
|
359
365
|
* sameHeadCleanConverged: boolean,
|
|
360
|
-
* roundCapCleanEligible: boolean
|
|
366
|
+
* roundCapCleanEligible: boolean,
|
|
367
|
+
* roundCapReopenEligible: boolean
|
|
361
368
|
* }}
|
|
362
369
|
*/
|
|
363
370
|
export function interpretLoopState(snapshot, refinementConfig) {
|
|
@@ -402,12 +409,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
402
409
|
const maxRounds = refinementConfig?.maxCopilotRounds;
|
|
403
410
|
const reviewInFlight = s.copilotReviewRequestStatus === "requested"
|
|
404
411
|
|| s.copilotReviewRequestStatus === "already-requested";
|
|
412
|
+
// Clean at the cap except for an earlier-head body-only finding. That finding
|
|
413
|
+
// blocks the clean fallback, but a significant post-convergence change still
|
|
414
|
+
// opens a new Copilot cycle, whose review supersedes it.
|
|
415
|
+
let roundCapBlockedOnlyByPriorHeadBody = false;
|
|
405
416
|
if (isCopilotRoundCapReached({ copilotReviewRoundCount: s.copilotReviewRoundCount, maxCopilotRounds: maxRounds })
|
|
406
417
|
&& state !== STATE.NO_PR && state !== STATE.DONE
|
|
407
418
|
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
408
419
|
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|
|
409
420
|
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
|
|
410
|
-
const
|
|
421
|
+
const cleanCurrentHead = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
|
|
422
|
+
const cleanThreads = cleanCurrentHead && !s.copilotPriorHeadBodyFeedbackUnresolved;
|
|
423
|
+
roundCapBlockedOnlyByPriorHeadBody = cleanCurrentHead && ciClean && !cleanThreads && !reviewInFlight;
|
|
411
424
|
if (cleanThreads && ciClean) {
|
|
412
425
|
state = STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
413
426
|
} else if (!reviewInFlight) {
|
|
@@ -483,6 +496,9 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
483
496
|
}
|
|
484
497
|
|
|
485
498
|
const roundCapCleanEligible = state === STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
499
|
+
// Cap states where a significant post-convergence change reopens a Copilot cycle.
|
|
500
|
+
const roundCapReopenEligible = roundCapCleanEligible
|
|
501
|
+
|| (state === STATE.ROUND_CAP_REACHED && roundCapBlockedOnlyByPriorHeadBody);
|
|
486
502
|
|
|
487
503
|
return {
|
|
488
504
|
state,
|
|
@@ -491,6 +507,26 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
491
507
|
autoRerequestEligible,
|
|
492
508
|
sameHeadCleanConverged,
|
|
493
509
|
roundCapCleanEligible,
|
|
510
|
+
roundCapReopenEligible,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Reopen a Copilot cycle at the round cap after a significant post-convergence
|
|
516
|
+
* change landed on a newer head (see `roundCapReopenEligible`).
|
|
517
|
+
*
|
|
518
|
+
* @param {object} interpretation - interpretLoopState() output
|
|
519
|
+
* @returns {object} the interpretation routed to READY_TO_REREQUEST_REVIEW
|
|
520
|
+
*/
|
|
521
|
+
export function reopenRoundCapCycle(interpretation) {
|
|
522
|
+
return {
|
|
523
|
+
...interpretation,
|
|
524
|
+
state: STATE.READY_TO_REREQUEST_REVIEW,
|
|
525
|
+
nextAction: NEXT_ACTIONS[STATE.READY_TO_REREQUEST_REVIEW],
|
|
526
|
+
allowedTransitions: [...TRANSITIONS[STATE.READY_TO_REREQUEST_REVIEW]],
|
|
527
|
+
autoRerequestEligible: true,
|
|
528
|
+
roundCapCleanEligible: false,
|
|
529
|
+
roundCapReopenEligible: false,
|
|
494
530
|
};
|
|
495
531
|
}
|
|
496
532
|
|
|
@@ -257,13 +257,13 @@ export function dedupeActListByCluster(actFindings, clusters, allFindings) {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
* A "clean" verdict means no finding at a BLOCKING severity
|
|
261
|
-
* invalid only when a finding at a blocking severity was
|
|
262
|
-
* unresolved blocking work, so the round cannot be clean.
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
260
|
+
* A "clean" ledger severity verdict means no finding at a BLOCKING severity
|
|
261
|
+
* remains open. It is invalid only when a finding at a blocking severity was
|
|
262
|
+
* acted on — that is unresolved blocking work, so the round cannot be clean.
|
|
263
|
+
* The ledger's severity verdict may stay clean with NON-BLOCKING act findings
|
|
264
|
+
* (a medium in the fix window, a low the fixer triages). The posted review
|
|
265
|
+
* verdict is composed with the act list (ADR 0089), so such a round posts
|
|
266
|
+
* findings_present until its act items are fixed.
|
|
267
267
|
*
|
|
268
268
|
* Throws a clear Error on `overallVerdict === "clean"` with any act finding at a
|
|
269
269
|
* blocking severity; returns `overallVerdict` unchanged otherwise.
|
|
@@ -288,8 +288,8 @@ export function assertCleanImpliesNoBlockingAct(overallVerdict, actFindings, blo
|
|
|
288
288
|
const severities = [...new Set(offending.map((f) => normalizeSeverity(f?.severity)))].join(", ");
|
|
289
289
|
throw new Error(
|
|
290
290
|
`clean verdict is invalid with ${offending.length} acted finding(s) at a blocking severity (${severities}): ` +
|
|
291
|
-
`a blocking-severity finding acted on this round cannot be clean.
|
|
292
|
-
`
|
|
291
|
+
`a blocking-severity finding acted on this round cannot be clean. The ledger's severity verdict may be clean with ` +
|
|
292
|
+
`non-blocking act findings; the posted review verdict is composed with the act list (ADR 0089).`,
|
|
293
293
|
);
|
|
294
294
|
}
|
|
295
295
|
return overallVerdict;
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -1046,6 +1046,27 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
1046
1046
|
return { findings: enriched, scopeDrift: validated.scopeDrift };
|
|
1047
1047
|
}
|
|
1048
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* The judge's act list: the findings this PR must still fix. Pure.
|
|
1051
|
+
* @param {unknown} findings
|
|
1052
|
+
* @returns {Array<object>}
|
|
1053
|
+
*/
|
|
1054
|
+
export function listOpenActItems(findings) {
|
|
1055
|
+
return (Array.isArray(findings) ? findings : []).filter((f) => f && f.judgeDisposition === "act");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Compose a round's review verdict with the judge act list (ADR 0089): a
|
|
1060
|
+
* non-empty act list keeps a `clean` severity verdict from `clean`. Never
|
|
1061
|
+
* lowers a verdict; the blockCleanOnFindingSeverities floor already applied.
|
|
1062
|
+
* @param {"clean"|"findings_present"|"blocked"} overallVerdict
|
|
1063
|
+
* @param {unknown} findings
|
|
1064
|
+
* @returns {"clean"|"findings_present"|"blocked"}
|
|
1065
|
+
*/
|
|
1066
|
+
export function composeReviewVerdict(overallVerdict, findings) {
|
|
1067
|
+
return overallVerdict === "clean" && listOpenActItems(findings).length > 0 ? "findings_present" : overallVerdict;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1049
1070
|
/**
|
|
1050
1071
|
* Map consolidated findings into the `--findings` JSON shape consumed by
|
|
1051
1072
|
* scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Main-checkout fast-forward
|
|
2
|
+
* Main-checkout fast-forward flow.
|
|
3
3
|
*
|
|
4
4
|
* The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
|
|
5
5
|
* procedure nor the post-merge hooks fast-forwarded the main checkout's local
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
|
|
9
9
|
* until the main checkout caught up).
|
|
10
10
|
*
|
|
11
|
-
* This module owns the shared, dependency-free
|
|
11
|
+
* This module owns the shared, dependency-free step-wise flow both harness hooks
|
|
12
12
|
* (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
|
|
13
13
|
* merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
|
|
14
14
|
* without rewriting history, so a diverged checkout fails the merge step cleanly and
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
|
|
17
17
|
* spaces or shell metacharacters cannot break or inject into the shell string.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* `syncMainCheckout` inspects the current ref BEFORE submitting any merge command, so
|
|
20
|
+
* a non-`main` checkout (detached HEAD, or another branch checked out) is classified
|
|
21
|
+
* and reported as an action-required diagnostic instead of fast-forwarding the wrong
|
|
22
|
+
* branch. No `git switch`/`checkout`/`reset` command is ever submitted for a non-main
|
|
23
|
+
* checkout — only read-only inspection commands run.
|
|
23
24
|
*
|
|
24
25
|
* No imports so this file vendors into the `.claude/hooks/` bundle unchanged
|
|
25
26
|
* (vendored modules may only import `node:` builtins or relative paths).
|
|
@@ -27,15 +28,18 @@
|
|
|
27
28
|
import path from "node:path";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Timeout (ms) for the `git worktree list` resolution step (the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* Timeout (ms) for the `git worktree list` resolution step (the harness hook's own
|
|
32
|
+
* budget; `syncMainCheckout`'s step-wise `fetch`/`rev-parse`/`merge` commands each
|
|
33
|
+
* run under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS` instead).
|
|
33
34
|
*/
|
|
34
35
|
export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
|
|
35
36
|
|
|
36
|
-
/** Timeout (ms) for
|
|
37
|
+
/** Timeout (ms) for each step of `syncMainCheckout`'s fetch/rev-parse/merge flow. */
|
|
37
38
|
export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
|
|
38
39
|
|
|
40
|
+
/** Stable diagnostic kind for a main checkout proven not to be on `main`. */
|
|
41
|
+
export const MAIN_CHECKOUT_NOT_ON_MAIN_KIND = "main_checkout_not_on_main";
|
|
42
|
+
|
|
39
43
|
/**
|
|
40
44
|
* POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
|
|
41
45
|
* path cannot break or inject into the shell string.
|
|
@@ -45,17 +49,165 @@ function shellQuotePath(value) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
|
-
*
|
|
52
|
+
* Classify a main checkout's current full symbolic ref (`git rev-parse
|
|
53
|
+
* --symbolic-full-name HEAD` output) into one of the four states `syncMainCheckout`
|
|
54
|
+
* acts on. `--symbolic-full-name` (unlike `--abbrev-ref`) is never subject to
|
|
55
|
+
* `core.warnAmbiguousRefs` renaming a branch to `heads/<name>` when a tag or other ref
|
|
56
|
+
* shares its short name.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} symbolicFullName - Raw (already-trimmed or not) `--symbolic-full-name
|
|
59
|
+
* HEAD` output.
|
|
60
|
+
* @returns {"main" | "other_branch" | "detached" | "unreadable"}
|
|
61
|
+
*/
|
|
62
|
+
export function classifyMainCheckoutRef(symbolicFullName) {
|
|
63
|
+
if (typeof symbolicFullName !== "string") {
|
|
64
|
+
return "unreadable";
|
|
65
|
+
}
|
|
66
|
+
const trimmed = symbolicFullName.trim();
|
|
67
|
+
if (!trimmed) {
|
|
68
|
+
return "unreadable";
|
|
69
|
+
}
|
|
70
|
+
if (trimmed === "HEAD") {
|
|
71
|
+
return "detached";
|
|
72
|
+
}
|
|
73
|
+
if (trimmed === "refs/heads/main") {
|
|
74
|
+
return "main";
|
|
75
|
+
}
|
|
76
|
+
if (trimmed.startsWith("refs/heads/")) {
|
|
77
|
+
return "other_branch";
|
|
78
|
+
}
|
|
79
|
+
return "unreadable";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the `main_checkout_not_on_main` diagnostic, or `null` when any required field
|
|
84
|
+
* is missing or invalid. The one shared rendered `message` names the kind, the
|
|
85
|
+
* absolute checkout path, the current ref, the behind count, that no fast-forward
|
|
86
|
+
* happened, that the action is non-fatal, and the manual recovery — never a
|
|
87
|
+
* reset/force suggestion.
|
|
88
|
+
*
|
|
89
|
+
* @param {{ mainCheckout: unknown, ref: unknown, behindCount: unknown }} fields
|
|
90
|
+
* @returns {{ kind: string, severity: "error", mainCheckout: string, ref: string, behindCount: number, message: string } | null}
|
|
91
|
+
*/
|
|
92
|
+
export function buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount } = {}) {
|
|
93
|
+
if (typeof mainCheckout !== "string" || !path.isAbsolute(mainCheckout)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (typeof ref !== "string" || !ref.trim()) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isInteger(behindCount) || behindCount < 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const message =
|
|
103
|
+
`[dev-loops] post-merge: ${MAIN_CHECKOUT_NOT_ON_MAIN_KIND} — the main checkout at '${mainCheckout}' ` +
|
|
104
|
+
`is on ${ref}, ${behindCount} commit(s) behind origin/main after fetch. No fast-forward was performed; ` +
|
|
105
|
+
`this is non-fatal. Reconcile manually: preserve any local-only commits, then check out main and ` +
|
|
106
|
+
`fast-forward it to origin/main.`;
|
|
107
|
+
return { kind: MAIN_CHECKOUT_NOT_ON_MAIN_KIND, severity: "error", mainCheckout, ref, behindCount, message };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run one shell-command step through the harness-supplied `run` adapter, normalizing
|
|
112
|
+
* a thrown/rejected `run` into the same `{ ok: false, reason }` shape as an adapter
|
|
113
|
+
* that resolves a failure — callers never need a second failure path.
|
|
114
|
+
*
|
|
115
|
+
* @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
|
|
116
|
+
* @param {string} command
|
|
117
|
+
* @returns {Promise<{ ok: true, stdout: string } | { ok: false, reason: string }>}
|
|
118
|
+
*/
|
|
119
|
+
async function runStep(run, command) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await run(command);
|
|
122
|
+
if (result?.ok) {
|
|
123
|
+
return { ok: true, stdout: typeof result.stdout === "string" ? result.stdout : "" };
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, reason: result?.reason || "command failed" };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, reason: error?.message || String(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Best-effort, step-wise main-checkout sync.
|
|
133
|
+
*
|
|
134
|
+
* After a successful `fetch origin main`, inspects the checkout's current ref BEFORE
|
|
135
|
+
* submitting any merge command:
|
|
136
|
+
* - `main` → `git merge --ff-only origin/main`; ok → `fast_forwarded`; a diverged
|
|
137
|
+
* main fails the merge step cleanly → `skipped`.
|
|
138
|
+
* - detached HEAD or another named branch → never merged/switched/reset; instead
|
|
139
|
+
* the post-fetch `HEAD..origin/main` behind count is measured and reported as a
|
|
140
|
+
* `not_on_main` diagnostic (see `buildMainCheckoutNotOnMainDiagnostic`).
|
|
141
|
+
* Any step that cannot prove its outcome (fetch failure, unreadable ref, a failed or
|
|
142
|
+
* empty short-SHA resolution, a failed or non-numeric behind-count read) is reported
|
|
143
|
+
* as `skipped` instead of a partial diagnostic.
|
|
49
144
|
*
|
|
50
145
|
* @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
|
|
51
|
-
* @
|
|
146
|
+
* @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
|
|
147
|
+
* Harness adapter that executes one shell command and resolves its outcome (may also
|
|
148
|
+
* throw — treated as a failed step).
|
|
149
|
+
* @returns {Promise<
|
|
150
|
+
* | { status: "fast_forwarded" }
|
|
151
|
+
* | { status: "skipped", reason: string }
|
|
152
|
+
* | { status: "not_on_main", diagnostic: ReturnType<typeof buildMainCheckoutNotOnMainDiagnostic> }
|
|
153
|
+
* >}
|
|
52
154
|
*/
|
|
53
|
-
export function
|
|
155
|
+
export async function syncMainCheckout(mainCheckout, run) {
|
|
54
156
|
const quoted = shellQuotePath(mainCheckout);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
157
|
+
|
|
158
|
+
const fetchResult = await runStep(run, `git -C ${quoted} fetch origin main`);
|
|
159
|
+
if (!fetchResult.ok) {
|
|
160
|
+
return { status: "skipped", reason: fetchResult.reason };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const refResult = await runStep(run, `git -C ${quoted} rev-parse --symbolic-full-name HEAD`);
|
|
164
|
+
if (!refResult.ok) {
|
|
165
|
+
return { status: "skipped", reason: refResult.reason };
|
|
166
|
+
}
|
|
167
|
+
const symbolicRef = refResult.stdout.trim();
|
|
168
|
+
const classification = classifyMainCheckoutRef(symbolicRef);
|
|
169
|
+
if (classification === "unreadable") {
|
|
170
|
+
return { status: "skipped", reason: `could not determine the current branch (got: ${JSON.stringify(symbolicRef)})` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (classification === "main") {
|
|
174
|
+
const mergeResult = await runStep(run, `git -C ${quoted} merge --ff-only origin/main`);
|
|
175
|
+
if (!mergeResult.ok) {
|
|
176
|
+
return { status: "skipped", reason: mergeResult.reason };
|
|
177
|
+
}
|
|
178
|
+
return { status: "fast_forwarded" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let ref;
|
|
182
|
+
if (classification === "detached") {
|
|
183
|
+
const shortShaResult = await runStep(run, `git -C ${quoted} rev-parse --short HEAD`);
|
|
184
|
+
if (!shortShaResult.ok) {
|
|
185
|
+
return { status: "skipped", reason: shortShaResult.reason };
|
|
186
|
+
}
|
|
187
|
+
const shortSha = shortShaResult.stdout.trim();
|
|
188
|
+
if (!shortSha) {
|
|
189
|
+
return { status: "skipped", reason: "could not resolve a short SHA for the detached HEAD" };
|
|
190
|
+
}
|
|
191
|
+
ref = `detached@${shortSha}`;
|
|
192
|
+
} else {
|
|
193
|
+
ref = symbolicRef.slice("refs/heads/".length);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const behindResult = await runStep(run, `git -C ${quoted} rev-list --count HEAD..origin/main`);
|
|
197
|
+
if (!behindResult.ok) {
|
|
198
|
+
return { status: "skipped", reason: behindResult.reason };
|
|
199
|
+
}
|
|
200
|
+
const behindStdout = behindResult.stdout.trim();
|
|
201
|
+
if (!/^\d+$/.test(behindStdout)) {
|
|
202
|
+
return { status: "skipped", reason: `could not read the behind count (got: ${JSON.stringify(behindStdout)})` };
|
|
203
|
+
}
|
|
204
|
+
const behindCount = Number.parseInt(behindStdout, 10);
|
|
205
|
+
|
|
206
|
+
const diagnostic = buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount });
|
|
207
|
+
if (!diagnostic) {
|
|
208
|
+
return { status: "skipped", reason: "incomplete main_checkout_not_on_main diagnostic" };
|
|
209
|
+
}
|
|
210
|
+
return { status: "not_on_main", diagnostic };
|
|
59
211
|
}
|
|
60
212
|
|
|
61
213
|
/**
|