@tokenoftrust/cli 1.4.0-rc.20 → 1.4.0-rc.22
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/bin/tot.mjs +52 -54
- package/package.json +6 -1
- package/src/activity.mjs +5 -4
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +2 -2
- package/src/commands/accept.mjs +489 -50
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +1 -0
- package/src/commands/cleanup.mjs +2 -1
- package/src/commands/clone.mjs +51 -20
- package/src/commands/dev.mjs +30 -12
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +6 -2
- package/src/commands/grants.mjs +6 -4
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +3 -4
- package/src/commands/pr.mjs +6 -5
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview.mjs +9 -1
- package/src/commands/rollback.mjs +6 -4
- package/src/commands/ship.mjs +19 -1
- package/src/commands/start.mjs +59 -11
- package/src/commands/submit.mjs +526 -68
- package/src/commands/sync.mjs +11 -0
- package/src/commands/validate.mjs +10 -4
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/oauth.mjs +12 -8
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +3 -3
- package/src/sample.mjs +3 -3
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
package/src/commands/submit.mjs
CHANGED
|
@@ -40,12 +40,17 @@
|
|
|
40
40
|
* Dependency-free (global fetch + `git`).
|
|
41
41
|
*/
|
|
42
42
|
import { execFileSync } from "node:child_process";
|
|
43
|
-
import { readFileSync } from "node:fs";
|
|
43
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
44
|
+
import { resolve as resolvePath } from "node:path";
|
|
44
45
|
import { createHash } from "node:crypto";
|
|
45
46
|
import { setTimeout as delay } from "node:timers/promises";
|
|
46
47
|
import { createMcpClient } from "../mcp.mjs";
|
|
47
48
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
48
49
|
import { checkoutTenant } from "./clone.mjs";
|
|
50
|
+
// Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
|
|
51
|
+
// born-rebased submit rebuilds a candidate onto the current base with the SAME
|
|
52
|
+
// engine `tot accept --refresh` uses, so the two read its result identically).
|
|
53
|
+
import { normalizeRefreshResult } from "./accept.mjs";
|
|
49
54
|
import { validateTenant, ERROR } from "../validate.mjs";
|
|
50
55
|
import { openBrowser } from "../open.mjs";
|
|
51
56
|
import { startProgress } from "../progress.mjs";
|
|
@@ -55,6 +60,7 @@ import {
|
|
|
55
60
|
defaultCandidateStatePath,
|
|
56
61
|
readActiveChangeId,
|
|
57
62
|
writeActiveChangeId,
|
|
63
|
+
clearActiveChangeId,
|
|
58
64
|
mintFreshChangeId,
|
|
59
65
|
isTerminalCandidateState,
|
|
60
66
|
isDefaultBranch,
|
|
@@ -90,6 +96,8 @@ export function candidateRefFor(changeId) {
|
|
|
90
96
|
* value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
|
|
91
97
|
* credential, never awaited, never throws, never alters the command. `errorClass` is
|
|
92
98
|
* a low-cardinality class (never a raw git stderr, which can carry a token/path).
|
|
99
|
+
* @param {string} op @param {boolean} ok
|
|
100
|
+
* @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
|
|
93
101
|
*/
|
|
94
102
|
function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
|
|
95
103
|
void emitActivity({
|
|
@@ -103,7 +111,8 @@ export function parseArgs(argv) {
|
|
|
103
111
|
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
104
112
|
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
105
113
|
// never a fixed shared default.
|
|
106
|
-
|
|
114
|
+
/** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, strategy: string|null, json: boolean, forkCandidate: boolean, help: boolean }} */
|
|
115
|
+
const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, strategy: null, json: false, forkCandidate: false, help: false };
|
|
107
116
|
for (let i = 0; i < argv.length; i++) {
|
|
108
117
|
const t = argv[i];
|
|
109
118
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -112,13 +121,15 @@ export function parseArgs(argv) {
|
|
|
112
121
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
113
122
|
else if (t === "--summary") a.summary = argv[++i];
|
|
114
123
|
else if (t === "--summary-file") a.summaryFile = argv[++i];
|
|
124
|
+
else if (t === "--strategy") a.strategy = argv[++i];
|
|
115
125
|
else if (t === "--json") a.json = true;
|
|
116
126
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
127
|
+
else if (t === "--skip-freshness") a.skipFreshness = true;
|
|
117
128
|
else if (t === "--no-commit") a.noCommit = true;
|
|
118
129
|
else if (t === "--no-wait") a.noWait = true;
|
|
119
130
|
else if (t === "--watch") a.watch = true;
|
|
120
131
|
else if (t === "--no-open") a.noOpen = true;
|
|
121
|
-
else if (t === "--
|
|
132
|
+
else if (t === "--fork-candidate") a.forkCandidate = true;
|
|
122
133
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
123
134
|
}
|
|
124
135
|
return a;
|
|
@@ -134,9 +145,20 @@ export function renderUsage(verb = "preview") {
|
|
|
134
145
|
return `tot ${verb} — submit your store for preview
|
|
135
146
|
|
|
136
147
|
tot ${verb} validate → push the preview ref → stream the result
|
|
137
|
-
tot ${verb} --
|
|
148
|
+
tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
|
|
149
|
+
parallel dev path). Rarely needed — a fresh git
|
|
150
|
+
branch already gets its own candidate automatically
|
|
151
|
+
(git checkout is the PR switcher); use this only to
|
|
152
|
+
run two candidates from ONE branch.
|
|
138
153
|
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
139
154
|
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
155
|
+
tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
|
|
156
|
+
(not recommended — may build a candidate rooted in an
|
|
157
|
+
already-superseded base)
|
|
158
|
+
tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
|
|
159
|
+
BOTH sides when the base has moved: "merge" (real 3-way
|
|
160
|
+
merge, surfaces a resolve card on a genuine overlap — the
|
|
161
|
+
default), "ours" (keep yours), "theirs" (keep the store's)
|
|
140
162
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
141
163
|
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
142
164
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
@@ -154,11 +176,15 @@ export function renderUsage(verb = "preview") {
|
|
|
154
176
|
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
155
177
|
|
|
156
178
|
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
157
|
-
to a GitHub PR), rather than opening a new one each time.
|
|
158
|
-
|
|
159
|
-
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
179
|
+
to a GitHub PR), rather than opening a new one each time. Manage your open
|
|
180
|
+
candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
160
181
|
merged or closed, a re-run automatically opens a fresh one.
|
|
161
182
|
|
|
183
|
+
Starting a separate change? \`git checkout -b <branch>\` — a fresh branch gets its
|
|
184
|
+
own candidate automatically, and \`git checkout\` back and forth is how you switch
|
|
185
|
+
between them. --fork-candidate is an escape hatch for the rarer case of wanting a
|
|
186
|
+
SECOND candidate off the SAME branch; reach for a branch first.
|
|
187
|
+
|
|
162
188
|
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
163
189
|
|
|
164
190
|
If you omit -m, a summary is generated from git (commit subject + the diff vs
|
|
@@ -326,6 +352,180 @@ export function readSummaryFileContent(pathOrDash) {
|
|
|
326
352
|
return readFileSync(pathOrDash, "utf8");
|
|
327
353
|
}
|
|
328
354
|
|
|
355
|
+
// ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
|
|
356
|
+
|
|
357
|
+
/** The candidate base branch every submit targets (mirrors tot-mcp's own
|
|
358
|
+
* `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
|
|
359
|
+
* protected branch by three different names in three different modules; kept
|
|
360
|
+
* a local constant here, not imported, since this file already resolves its
|
|
361
|
+
* own defaults independently of sync.mjs and candidate_open's server default). */
|
|
362
|
+
export const FRESHNESS_BASE_BRANCH = "preview";
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Detect a STALE local view of the base branch before minting a candidate —
|
|
366
|
+
* the live-repeat incident this guards against: the checkout's `origin/preview`
|
|
367
|
+
* tracking ref was stale (recorded before a just-merged PR moved it), so a
|
|
368
|
+
* fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
|
|
369
|
+
* instant, entirely avoidable "not mergeable" the moment it was compared
|
|
370
|
+
* against the real, already-advanced `preview`.
|
|
371
|
+
*
|
|
372
|
+
* Compares what the LOCAL checkout believes the base's tip is
|
|
373
|
+
* (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
|
|
374
|
+
* against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
|
|
375
|
+
* single-ref read — no full fetch, no local ref mutated). Returns the live
|
|
376
|
+
* remote sha when local's cached view is behind it, or `null` when: the two
|
|
377
|
+
* already agree, there is no local tracking ref to compare against yet (a
|
|
378
|
+
* checkout that has simply never fetched this branch — never a false block on
|
|
379
|
+
* that), or the remote can't be reached right now (a network hiccup must
|
|
380
|
+
* never block a submit that would otherwise succeed; the push itself is the
|
|
381
|
+
* real connectivity test). Pure git I/O via the injected runner — unit-tested.
|
|
382
|
+
* @param {(cargs:string[])=>string} git
|
|
383
|
+
* @param {string} branch
|
|
384
|
+
* @returns {string|null}
|
|
385
|
+
*/
|
|
386
|
+
export function detectStaleBase(git, branch) {
|
|
387
|
+
let localSha = "";
|
|
388
|
+
try {
|
|
389
|
+
localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
|
|
390
|
+
} catch {
|
|
391
|
+
return null; // never fetched this branch locally — nothing cached to be stale
|
|
392
|
+
}
|
|
393
|
+
if (!localSha) return null;
|
|
394
|
+
let remoteSha = "";
|
|
395
|
+
try {
|
|
396
|
+
remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
|
|
397
|
+
} catch {
|
|
398
|
+
return null; // can't reach the remote right now — don't block on a network hiccup
|
|
399
|
+
}
|
|
400
|
+
if (!remoteSha || remoteSha === localSha) return null;
|
|
401
|
+
return remoteSha;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* How many commits the base branch has advanced since this candidate forked off it
|
|
406
|
+
* (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
|
|
407
|
+
* DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
|
|
408
|
+
* `<base>`, then settles mergeable=false at Accept time. This measures that drift
|
|
409
|
+
* cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
|
|
410
|
+
* remote-tracking tip, then how many commits separate that fork point from the tip.
|
|
411
|
+
*
|
|
412
|
+
* PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
|
|
413
|
+
* never the network; it complements detectStaleBase (which confirms that tracking
|
|
414
|
+
* ref is itself current). Returns 0 — never a false warning — when there's nothing
|
|
415
|
+
* to compare against or the drift can't be positively determined: no base tracking
|
|
416
|
+
* ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
|
|
417
|
+
* any git failure. Non-blocking by contract: the caller warns on a positive count
|
|
418
|
+
* but always proceeds. Pure git I/O via the injected runner — unit-tested.
|
|
419
|
+
* @param {(cargs:string[])=>string} git
|
|
420
|
+
* @param {string} branch
|
|
421
|
+
* @returns {number}
|
|
422
|
+
*/
|
|
423
|
+
export function baseCommitsBehind(git, branch) {
|
|
424
|
+
const baseTip = `refs/remotes/origin/${branch}`;
|
|
425
|
+
let tip = "";
|
|
426
|
+
try {
|
|
427
|
+
tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
|
|
428
|
+
} catch {
|
|
429
|
+
return 0; // no local tracking ref for the base — nothing to compare against
|
|
430
|
+
}
|
|
431
|
+
if (!tip) return 0;
|
|
432
|
+
let mergeBase = "";
|
|
433
|
+
try {
|
|
434
|
+
mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
|
|
435
|
+
} catch {
|
|
436
|
+
return 0; // unrelated histories / no HEAD — nothing meaningful to count
|
|
437
|
+
}
|
|
438
|
+
if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
|
|
439
|
+
try {
|
|
440
|
+
const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
|
|
441
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
442
|
+
} catch {
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
|
|
448
|
+
|
|
449
|
+
/** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
|
|
450
|
+
* --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
|
|
451
|
+
* refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
|
|
452
|
+
* `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
|
|
453
|
+
* same-line overlap, never silently clobber a side. */
|
|
454
|
+
export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
|
|
455
|
+
export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
|
|
459
|
+
* established MCP client — the candidate was just opened by candidate_open (step 2b)
|
|
460
|
+
* and the tenant scope is already bound, so this reuses that session rather than
|
|
461
|
+
* establishing its own (accept.mjs's runRefresh is the operator entry that does the
|
|
462
|
+
* sign-in; here the submit flow already holds the session). Rebuilds the candidate
|
|
463
|
+
* from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
|
|
464
|
+
* the normalized result (status:"committed" is the only success). Best-effort: any
|
|
465
|
+
* throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
|
|
466
|
+
* without the tool, a transient failure — normalizes to a non-ok error result the
|
|
467
|
+
* caller submits-as-is on, NEVER blocking the push that already landed.
|
|
468
|
+
* @param {{callTool:Function}} client
|
|
469
|
+
* @param {{ repo: string, changeId: string, strategy: string }} opts
|
|
470
|
+
* @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
|
|
471
|
+
*/
|
|
472
|
+
export async function runBornRebased(client, { repo, changeId, strategy }) {
|
|
473
|
+
try {
|
|
474
|
+
const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
|
|
475
|
+
return normalizeRefreshResult(raw);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
|
|
483
|
+
* cleanly onto the current base tip, so it enters the queue already mergeable rather
|
|
484
|
+
* than settling not-mergeable at Accept time. `behind` is the base-drift count that
|
|
485
|
+
* triggered the rebuild. Pure — unit-tested.
|
|
486
|
+
* @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
|
|
487
|
+
* @returns {string[]}
|
|
488
|
+
*/
|
|
489
|
+
export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
|
|
490
|
+
const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
|
|
491
|
+
const lines = [
|
|
492
|
+
`\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
|
|
493
|
+
];
|
|
494
|
+
if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
|
|
495
|
+
return lines;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
|
|
500
|
+
* hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
|
|
501
|
+
* shift-left win is surfacing that conflict HERE, at submit, instead of letting it
|
|
502
|
+
* land as a stuck queue row someone discovers at Accept time. Names the diverged files
|
|
503
|
+
* and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
|
|
504
|
+
* the default that just failed, so it isn't re-offered. Matches how the rest of this
|
|
505
|
+
* file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
|
|
506
|
+
* raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
|
|
507
|
+
* the developer typed.
|
|
508
|
+
* @param {{ unresolved?: string[] }} rr
|
|
509
|
+
* @param {string} [verb]
|
|
510
|
+
* @returns {string[]}
|
|
511
|
+
*/
|
|
512
|
+
export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
|
|
513
|
+
const lines = [
|
|
514
|
+
`\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
|
|
515
|
+
];
|
|
516
|
+
if (unresolved.length) {
|
|
517
|
+
lines.push(` These files changed on both sides since you forked and need your call:`);
|
|
518
|
+
for (const f of unresolved) lines.push(` - ${f}`);
|
|
519
|
+
}
|
|
520
|
+
lines.push(
|
|
521
|
+
` Resolve it in one command — choose which side wins on those files:`,
|
|
522
|
+
` tot ${verb} --strategy=ours keep YOURS on any clash`,
|
|
523
|
+
` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
|
|
524
|
+
` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
|
|
525
|
+
);
|
|
526
|
+
return lines;
|
|
527
|
+
}
|
|
528
|
+
|
|
329
529
|
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
330
530
|
|
|
331
531
|
/**
|
|
@@ -410,6 +610,59 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
|
|
|
410
610
|
return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
|
|
411
611
|
}
|
|
412
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
|
|
615
|
+
* `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
|
|
616
|
+
* stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
|
|
617
|
+
* a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
|
|
618
|
+
* otherwise stage + commit the half-resolved content straight into a plain "content
|
|
619
|
+
* update" commit — silently finishing the git operation WRONG and losing whatever
|
|
620
|
+
* edit was still sitting in conflict markers or unresolved hunks (the live incident
|
|
621
|
+
* this guards against: a stalled rebase was never continued, `tot preview` ran
|
|
622
|
+
* anyway, and the developer's own edit was gone). Detection is worktree-safe:
|
|
623
|
+
* MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
|
|
624
|
+
* and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
|
|
625
|
+
* OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
|
|
626
|
+
* Returns which operation is in progress, or null when the tree is clean of one.
|
|
627
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
628
|
+
* @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
|
|
629
|
+
* output may be relative, and Node's `existsSync` resolves relative paths against
|
|
630
|
+
* the CLI process's own cwd, not the checkout, so this pins the resolution base.
|
|
631
|
+
* @returns {"rebase"|"merge"|"cherry-pick"|null}
|
|
632
|
+
*/
|
|
633
|
+
export function detectInProgressGitOperation(git, workspace) {
|
|
634
|
+
const hasRef = (name) => {
|
|
635
|
+
try {
|
|
636
|
+
return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
|
|
637
|
+
} catch {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
if (hasRef("MERGE_HEAD")) return "merge";
|
|
642
|
+
if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
|
|
643
|
+
const hasGitPath = (name) => {
|
|
644
|
+
try {
|
|
645
|
+
const p = git(["rev-parse", "--git-path", name]).trim();
|
|
646
|
+
// An empty result should never happen for a real `--git-path` (it always
|
|
647
|
+
// echoes SOME path, existing or not) — but treat it as "absent" rather than
|
|
648
|
+
// resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
|
|
649
|
+
// itself, which trivially always exists (a false "rebase in progress" on
|
|
650
|
+
// every call, not just an occasional false negative).
|
|
651
|
+
return p.length > 0 && existsSync(resolvePath(workspace, p));
|
|
652
|
+
} catch {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The abort command that recovers from each in-progress git operation, so the
|
|
661
|
+
* refusal below can tell a developer exactly what to run. Pure. */
|
|
662
|
+
export function abortCommandFor(op) {
|
|
663
|
+
return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
|
|
664
|
+
}
|
|
665
|
+
|
|
413
666
|
/**
|
|
414
667
|
* Auto-commit the known content trees before previewing (unit u2). On a DIRTY
|
|
415
668
|
* tree `tot preview` commits your content edits for you, so a preview always
|
|
@@ -420,16 +673,21 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
|
|
|
420
673
|
* (preview whatever's already committed — u1's behavior).
|
|
421
674
|
*
|
|
422
675
|
* Returns exactly one of:
|
|
423
|
-
* { skipped: true }
|
|
424
|
-
* {
|
|
425
|
-
*
|
|
426
|
-
*
|
|
676
|
+
* { skipped: true } — --no-commit.
|
|
677
|
+
* { inProgress: "rebase"|… } — a rebase/merge/cherry-pick is unresolved;
|
|
678
|
+
* caller refuses rather than auto-committing
|
|
679
|
+
* over the developer's own half-resolved tree.
|
|
680
|
+
* { clean: true } — nothing dirty; preview HEAD as-is.
|
|
681
|
+
* { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
|
|
682
|
+
* { committed: true, sha, files } — staged the known dirty paths and committed.
|
|
427
683
|
*
|
|
428
684
|
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
429
|
-
* @param {{ message?: string|null, noCommit?: boolean }} [opts]
|
|
685
|
+
* @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
|
|
430
686
|
*/
|
|
431
|
-
export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
|
|
687
|
+
export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
|
|
432
688
|
if (noCommit) return { skipped: true };
|
|
689
|
+
const inProgress = detectInProgressGitOperation(git, workspace);
|
|
690
|
+
if (inProgress) return { inProgress };
|
|
433
691
|
const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
|
|
434
692
|
const dirty = parsePorcelainPaths(status);
|
|
435
693
|
if (dirty.length === 0) return { clean: true };
|
|
@@ -484,16 +742,17 @@ export function parseNameStatus(text) {
|
|
|
484
742
|
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
485
743
|
*/
|
|
486
744
|
export function buildFilePatch(entries, readBlob) {
|
|
745
|
+
/** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
|
|
487
746
|
const patch = [];
|
|
488
747
|
for (const e of entries) {
|
|
489
|
-
if (e.status === "R") patch.push({ path: e.from, delete: true });
|
|
748
|
+
if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
|
|
490
749
|
if (e.status === "D") {
|
|
491
750
|
patch.push({ path: e.path, delete: true });
|
|
492
751
|
continue;
|
|
493
752
|
}
|
|
494
753
|
const buf = readBlob(e.path);
|
|
495
754
|
const asUtf8 = buf.toString("utf8");
|
|
496
|
-
const isCleanUtf8 = !asUtf8.includes("
|
|
755
|
+
const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
|
|
497
756
|
patch.push(
|
|
498
757
|
isCleanUtf8
|
|
499
758
|
? { path: e.path, content: asUtf8 }
|
|
@@ -523,41 +782,14 @@ export function repoNameFromRemote(remoteUrl) {
|
|
|
523
782
|
|
|
524
783
|
// ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
|
|
525
784
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
* @returns {{ publicUrl: string, username: string, token: string }|null}
|
|
535
|
-
*/
|
|
536
|
-
export function splitAuthedRemote(remoteUrl) {
|
|
537
|
-
try {
|
|
538
|
-
const u = new URL(String(remoteUrl));
|
|
539
|
-
const token = u.password ? decodeURIComponent(u.password) : "";
|
|
540
|
-
if (!token) return null;
|
|
541
|
-
const username = u.username ? decodeURIComponent(u.username) : "";
|
|
542
|
-
return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
|
|
543
|
-
} catch {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* The `http.extraheader` value that hands a basic-auth credential to a SINGLE git
|
|
550
|
-
* invocation (base64 of `user:token`) — so a freshly-minted forge token
|
|
551
|
-
* authenticates one push without ever being written to `.git/config`. Pure —
|
|
552
|
-
* unit-tested.
|
|
553
|
-
* @param {string} username
|
|
554
|
-
* @param {string} token
|
|
555
|
-
* @returns {string}
|
|
556
|
-
*/
|
|
557
|
-
export function basicAuthExtraHeader(username, token) {
|
|
558
|
-
const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
559
|
-
return `Authorization: Basic ${b64}`;
|
|
560
|
-
}
|
|
785
|
+
// splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
|
|
786
|
+
// u10) — a dependency-free module BOTH this file and clone.mjs/commands/
|
|
787
|
+
// git-credential.mjs need, so they moved out of here to avoid a submit.mjs ↔
|
|
788
|
+
// clone.mjs import cycle. Re-exported so every existing import of these two names
|
|
789
|
+
// FROM submit.mjs (this file's own callers below, plus tests) keeps working
|
|
790
|
+
// unchanged.
|
|
791
|
+
export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
|
|
792
|
+
import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
|
|
561
793
|
|
|
562
794
|
/**
|
|
563
795
|
* Recognise a forge auth failure (expired / invalid push token) in a failed git
|
|
@@ -605,7 +837,7 @@ export function tagFromRepoName(repoName, tenant) {
|
|
|
605
837
|
*
|
|
606
838
|
* @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
|
|
607
839
|
* @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
|
|
608
|
-
* @param {{ ref
|
|
840
|
+
* @param {{ ref?: string }} [opts]
|
|
609
841
|
* @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
|
|
610
842
|
*/
|
|
611
843
|
export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
|
|
@@ -691,25 +923,79 @@ export function actorKeyFor(session) {
|
|
|
691
923
|
* Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
|
|
692
924
|
* network call, because it also determines the isolated git ref we push to
|
|
693
925
|
* (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
|
|
694
|
-
* `--
|
|
695
|
-
*
|
|
696
|
-
* otherwise
|
|
697
|
-
*
|
|
926
|
+
* `--fork-candidate` forks a fresh one.
|
|
927
|
+
* forkCandidate → fork a FRESH candidate id;
|
|
928
|
+
* otherwise → the remembered active candidate (from a prior --fork-candidate /
|
|
929
|
+
* terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
|
|
930
|
+
* default.
|
|
698
931
|
* `persist` reports whether the choice diverges from the stable default, so the
|
|
699
932
|
* caller knows whether to remember it as the new active pointer. `mint` is
|
|
700
933
|
* injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
|
|
701
934
|
* Pure — unit-tested.
|
|
702
935
|
* @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
|
|
703
|
-
*
|
|
936
|
+
* forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
|
|
704
937
|
* @returns {{ changeId: string, stableId: string, persist: boolean }}
|
|
705
938
|
*/
|
|
706
|
-
export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
|
|
939
|
+
export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
|
|
707
940
|
const stableId = deriveChangeId(tenant, actorKey, branch);
|
|
708
|
-
const changeId =
|
|
709
|
-
const persist =
|
|
941
|
+
const changeId = forkCandidate ? mint(stableId) : (active || stableId);
|
|
942
|
+
const persist = forkCandidate || (!!active && active !== stableId);
|
|
710
943
|
return { changeId, stableId, persist };
|
|
711
944
|
}
|
|
712
945
|
|
|
946
|
+
/**
|
|
947
|
+
* The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
|
|
948
|
+
* changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
|
|
949
|
+
* the candidate isn't found, carries no state, or the read throws. null ("couldn't
|
|
950
|
+
* tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
|
|
951
|
+
* behaves exactly as if the pointer were still live, so a purely-diagnostic check
|
|
952
|
+
* that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
|
|
953
|
+
* Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
|
|
954
|
+
* plain array. Injectable client for tests.
|
|
955
|
+
* @param {{callTool:Function}} client
|
|
956
|
+
* @param {{ repo: string, changeId: string }} opts
|
|
957
|
+
* @returns {Promise<string|null>}
|
|
958
|
+
*/
|
|
959
|
+
export async function candidateStateFor(client, { repo, changeId }) {
|
|
960
|
+
try {
|
|
961
|
+
const r = await client.callTool("candidate_status", { repo, changeId });
|
|
962
|
+
const c = Array.isArray(r)
|
|
963
|
+
? r.find((x) => x?.changeId === changeId)
|
|
964
|
+
: Array.isArray(r?.candidates)
|
|
965
|
+
? r.candidates.find((x) => x?.changeId === changeId)
|
|
966
|
+
: r;
|
|
967
|
+
return c && typeof c.state === "string" ? c.state : null;
|
|
968
|
+
} catch {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
|
|
975
|
+
* still open. The live incident this guards against: after a candidate PR merged, a
|
|
976
|
+
* plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
|
|
977
|
+
* candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
|
|
978
|
+
* from it — inheriting a guaranteed conflict from the very first commit. When the
|
|
979
|
+
* pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
|
|
980
|
+
* falls back to the stable per-branch id exactly as if no pointer existed.
|
|
981
|
+
*
|
|
982
|
+
* PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
|
|
983
|
+
* repo, or a check that errors / can't positively confirm terminal all resolve to
|
|
984
|
+
* `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
|
|
985
|
+
* state drops the pointer. When it does, `dropped` carries the old changeId + the
|
|
986
|
+
* terminal state so the caller can tell the operator and forget the on-disk pointer.
|
|
987
|
+
* Injectable client for tests.
|
|
988
|
+
* @param {{callTool:Function}} client
|
|
989
|
+
* @param {{ repo: string|null, active: string|null }} opts
|
|
990
|
+
* @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
|
|
991
|
+
*/
|
|
992
|
+
export async function resolveActivePointer(client, { repo, active }) {
|
|
993
|
+
if (!active || !repo) return { active };
|
|
994
|
+
const state = await candidateStateFor(client, { repo, changeId: active });
|
|
995
|
+
if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
|
|
996
|
+
return { active };
|
|
997
|
+
}
|
|
998
|
+
|
|
713
999
|
/**
|
|
714
1000
|
* The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
|
|
715
1001
|
* SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
|
|
@@ -836,7 +1122,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
836
1122
|
shipped: status?.shipped ?? null,
|
|
837
1123
|
dispatched: status?.dispatched ?? null,
|
|
838
1124
|
notDispatched: status?.notDispatched ?? false,
|
|
839
|
-
forwardFailed: status?.forwardFailed ?? false,
|
|
1125
|
+
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
840
1126
|
delivery: status?.delivery ?? null,
|
|
841
1127
|
previewPrUrl,
|
|
842
1128
|
...(error ? { error } : {}),
|
|
@@ -874,6 +1160,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
874
1160
|
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
875
1161
|
return 2;
|
|
876
1162
|
}
|
|
1163
|
+
// --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
|
|
1164
|
+
// "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
|
|
1165
|
+
if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
|
|
1166
|
+
const msg = `unknown --strategy "${args.strategy}"`;
|
|
1167
|
+
console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
|
|
1168
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1169
|
+
return 2;
|
|
1170
|
+
}
|
|
1171
|
+
const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
|
|
877
1172
|
if (args.summaryFile) {
|
|
878
1173
|
let raw;
|
|
879
1174
|
try {
|
|
@@ -899,6 +1194,63 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
899
1194
|
const tenant = ctx.tenant;
|
|
900
1195
|
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
901
1196
|
|
|
1197
|
+
// Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
|
|
1198
|
+
// `origin`'s URL and install the credential helper, so this run (and every one
|
|
1199
|
+
// after) mints fresh creds through `tot` instead of relying on one that quietly
|
|
1200
|
+
// expired. Best-effort — never blocks the actual preview on a migration hiccup.
|
|
1201
|
+
try {
|
|
1202
|
+
ensureTokenlessRemote(git);
|
|
1203
|
+
} catch {
|
|
1204
|
+
/* best-effort — see above */
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// How far the base has drifted since this candidate forked (unit c2), hoisted to the
|
|
1208
|
+
// whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
|
|
1209
|
+
// rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
|
|
1210
|
+
// out of the rebuild too — consistent with opting out of the warning.
|
|
1211
|
+
let baseDrift = 0;
|
|
1212
|
+
|
|
1213
|
+
// Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
|
|
1214
|
+
// cached view of the base branch already behind the store? A candidate built
|
|
1215
|
+
// on a stale base is an instant, avoidable "not mergeable" the moment the
|
|
1216
|
+
// forge compares it against the real (already-advanced) base. --skip-freshness
|
|
1217
|
+
// opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
|
|
1218
|
+
if (!args.skipFreshness) {
|
|
1219
|
+
let staleTip = null;
|
|
1220
|
+
try {
|
|
1221
|
+
staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
|
|
1222
|
+
} catch {
|
|
1223
|
+
staleTip = null; // never block a submit on the preflight's OWN failure
|
|
1224
|
+
}
|
|
1225
|
+
if (staleTip) {
|
|
1226
|
+
const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
|
|
1227
|
+
console.error(
|
|
1228
|
+
fail(msg, `tot sync (fetches the latest ${FRESHNESS_BASE_BRANCH} + merges it into your branch, then re-run \`tot ${verb}\`) — or re-run with --skip-freshness to build anyway`),
|
|
1229
|
+
);
|
|
1230
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1231
|
+
return 1;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
|
|
1235
|
+
// since moved past is what settles mergeable=false at Accept time; catch it early,
|
|
1236
|
+
// right here, with a pure-LOCAL git read (no network — uses the base's
|
|
1237
|
+
// remote-tracking tip, which the stale-base preflight just above confirmed is
|
|
1238
|
+
// current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
|
|
1239
|
+
// is never refused over base drift. Shares --skip-freshness with the preflight.
|
|
1240
|
+
try {
|
|
1241
|
+
baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
|
|
1242
|
+
} catch {
|
|
1243
|
+
baseDrift = 0; // never let the warning's OWN failure disturb the submit
|
|
1244
|
+
}
|
|
1245
|
+
if (baseDrift > 0) {
|
|
1246
|
+
console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
|
|
1247
|
+
console.error(
|
|
1248
|
+
` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
|
|
902
1254
|
// 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
|
|
903
1255
|
// content edits BEFORE previewing so the preview reflects your working changes.
|
|
904
1256
|
// Only content/, public/, theme.json, .tot/ (staged by explicit path, never
|
|
@@ -906,7 +1258,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
906
1258
|
// --no-commit opts out (preview whatever's already committed).
|
|
907
1259
|
let auto;
|
|
908
1260
|
try {
|
|
909
|
-
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
1261
|
+
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
|
|
910
1262
|
} catch (e) {
|
|
911
1263
|
emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
|
|
912
1264
|
const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
|
|
@@ -915,6 +1267,20 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
915
1267
|
return 1;
|
|
916
1268
|
}
|
|
917
1269
|
if (auto.committed) emitGitOp("commit", true, { command: verb });
|
|
1270
|
+
if (auto.inProgress) {
|
|
1271
|
+
const abortCmd = abortCommandFor(auto.inProgress);
|
|
1272
|
+
const msg = `a ${auto.inProgress} is still in progress here`;
|
|
1273
|
+
console.error(
|
|
1274
|
+
fail(
|
|
1275
|
+
msg,
|
|
1276
|
+
`finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
|
|
1277
|
+
+ "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
|
|
1278
|
+
+ "tree into a plain content commit and can lose whatever edit was still unresolved",
|
|
1279
|
+
),
|
|
1280
|
+
);
|
|
1281
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1282
|
+
return 1;
|
|
1283
|
+
}
|
|
918
1284
|
if (auto.refused) {
|
|
919
1285
|
console.error(
|
|
920
1286
|
fail(
|
|
@@ -937,6 +1303,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
937
1303
|
// 1. validate locally — refuse on errors.
|
|
938
1304
|
if (!args.skipValidate) {
|
|
939
1305
|
const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
|
|
1306
|
+
// Advisory but LOUD: git conflict markers must never slip past as "validated"
|
|
1307
|
+
// (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
|
|
1308
|
+
// are otherwise swallowed here — but they never block the submit.
|
|
1309
|
+
const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
|
|
1310
|
+
if (conflicts.length) {
|
|
1311
|
+
console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
|
|
1312
|
+
for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
|
|
1313
|
+
console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
|
|
1314
|
+
}
|
|
940
1315
|
if (!ok) {
|
|
941
1316
|
const errs = findings.filter((f) => f.level === ERROR);
|
|
942
1317
|
console.error(
|
|
@@ -1008,7 +1383,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1008
1383
|
// needed — so they're available even on the no-session fallback path below.
|
|
1009
1384
|
const branch = currentBranch(gitSafe);
|
|
1010
1385
|
const statePath = defaultCandidateStatePath(env);
|
|
1011
|
-
|
|
1386
|
+
let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
1012
1387
|
|
|
1013
1388
|
let session;
|
|
1014
1389
|
try {
|
|
@@ -1020,7 +1395,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1020
1395
|
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
1021
1396
|
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
1022
1397
|
// just not per-developer until sign-in succeeds.
|
|
1023
|
-
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active,
|
|
1398
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
|
|
1024
1399
|
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1025
1400
|
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
1026
1401
|
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
@@ -1059,12 +1434,36 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1059
1434
|
return 0;
|
|
1060
1435
|
}
|
|
1061
1436
|
|
|
1437
|
+
// u17 — before REUSING a remembered active pointer, confirm its PR is still open.
|
|
1438
|
+
// If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
|
|
1439
|
+
// back to the stable id, rather than force-pushing onto a now-dead candidate branch
|
|
1440
|
+
// and opening a NEW PR that inherits a guaranteed conflict (the live incident this
|
|
1441
|
+
// guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
|
|
1442
|
+
// there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1443
|
+
// Needs the tenant scope bound for candidate_status to resolve — idempotent with
|
|
1444
|
+
// the later client_switch / the fresh-mint checkoutTenant.
|
|
1445
|
+
if (active && repo && !args.forkCandidate) {
|
|
1446
|
+
try {
|
|
1447
|
+
await client.callTool("client_switch", { tenant });
|
|
1448
|
+
} catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
|
|
1449
|
+
const resolved = await resolveActivePointer(client, { repo, active });
|
|
1450
|
+
if (resolved.dropped) {
|
|
1451
|
+
console.error(
|
|
1452
|
+
`~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
|
|
1453
|
+
);
|
|
1454
|
+
try {
|
|
1455
|
+
clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
|
|
1456
|
+
} catch { /* best-effort local cleanup — a miss just re-checks next run */ }
|
|
1457
|
+
}
|
|
1458
|
+
active = resolved.active;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1062
1461
|
// Which candidate (and therefore which isolated ref, b03) this submit targets —
|
|
1063
1462
|
// decided now, with a real session, so the SAME id backs both the raw git push
|
|
1064
1463
|
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
1065
|
-
// different branches. See chooseChangeId's doc for the --
|
|
1066
|
-
// rules.
|
|
1067
|
-
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active,
|
|
1464
|
+
// different branches. See chooseChangeId's doc for the --fork-candidate /
|
|
1465
|
+
// active-pointer rules.
|
|
1466
|
+
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
|
|
1068
1467
|
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1069
1468
|
const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
|
|
1070
1469
|
|
|
@@ -1132,6 +1531,61 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1132
1531
|
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1133
1532
|
}
|
|
1134
1533
|
|
|
1534
|
+
// c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
|
|
1535
|
+
// if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
|
|
1536
|
+
// the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
|
|
1537
|
+
// queue already fresh instead of settling not-mergeable at Accept time.
|
|
1538
|
+
// • clean rebuild → report it, hand back the fresh candidate's shareable URL,
|
|
1539
|
+
// and finish (the rebuilt PR has a NEW head; polling the old
|
|
1540
|
+
// local `commit` would read as never-dispatched, so we skip
|
|
1541
|
+
// the reconcile poll and say the fresh preview is building);
|
|
1542
|
+
// • genuine overlap (merge_failed) → surface the resolve card right here, then
|
|
1543
|
+
// fall through — candidate_refresh made NO changes on a real
|
|
1544
|
+
// conflict, so the candidate's head still matches `commit`
|
|
1545
|
+
// and the normal reconcile poll below is still valid;
|
|
1546
|
+
// • anything else (owner-gated denial, older MCP, error) → submit as-is.
|
|
1547
|
+
// Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
|
|
1548
|
+
// invited developer's session may be denied — that degrades to submitting as-is,
|
|
1549
|
+
// never blocking the push that already landed. Gated on --skip-freshness via
|
|
1550
|
+
// baseDrift (0 when skipped).
|
|
1551
|
+
if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
|
|
1552
|
+
const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
|
|
1553
|
+
if (rebased.ok) {
|
|
1554
|
+
for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
|
|
1555
|
+
if (!args.json) console.log(line);
|
|
1556
|
+
}
|
|
1557
|
+
const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
|
|
1558
|
+
const freshPrUrl = typeof freshPr === "number"
|
|
1559
|
+
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
|
|
1560
|
+
: null;
|
|
1561
|
+
if (freshPrUrl && !args.json) {
|
|
1562
|
+
console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
|
|
1563
|
+
}
|
|
1564
|
+
// The rebuild keeps the STABLE changeId, so the persisted active pointer stays
|
|
1565
|
+
// valid — record it (best-effort) exactly as the normal open path does below.
|
|
1566
|
+
if (persist && repo) {
|
|
1567
|
+
try {
|
|
1568
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1569
|
+
} catch { /* best-effort local hint */ }
|
|
1570
|
+
}
|
|
1571
|
+
emitJson(args, buildJsonResult({
|
|
1572
|
+
ok: true, ref, commit, changeId,
|
|
1573
|
+
candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
|
|
1574
|
+
previewPrUrl: freshPrUrl,
|
|
1575
|
+
note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
|
|
1576
|
+
}));
|
|
1577
|
+
return 0;
|
|
1578
|
+
}
|
|
1579
|
+
if (rebased.status === "merge_failed") {
|
|
1580
|
+
for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
|
|
1581
|
+
if (!args.json) console.log(line);
|
|
1582
|
+
}
|
|
1583
|
+
// fall through to the normal poll — the candidate is unchanged.
|
|
1584
|
+
} else if (rebased.message && !args.json) {
|
|
1585
|
+
console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1135
1589
|
// Immediate shareable URL (Vercel-style: "the URL exists before the build
|
|
1136
1590
|
// does"). A non-terminal candidate with a real PR number means a preview
|
|
1137
1591
|
// WILL be built at a deterministic route — so hand the developer that link
|
|
@@ -1372,7 +1826,7 @@ export function shareablePrUrl(base, tenant, prNumber) {
|
|
|
1372
1826
|
* @returns {string}
|
|
1373
1827
|
*/
|
|
1374
1828
|
export function describeReadbackError(e) {
|
|
1375
|
-
const msg = String(e?.message || e || "");
|
|
1829
|
+
const msg = String(/** @type {any} */ (e)?.message || e || "");
|
|
1376
1830
|
const jsonStart = msg.indexOf("{");
|
|
1377
1831
|
if (jsonStart >= 0 && msg.includes("self_repair")) {
|
|
1378
1832
|
try {
|
|
@@ -1428,7 +1882,7 @@ export function formatShareableUrlBlock(s, tenant) {
|
|
|
1428
1882
|
* back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
|
|
1429
1883
|
* what actually happened and what to do, and never recommend another submit. Pure —
|
|
1430
1884
|
* unit-tested. `verb` brands the copy with whatever the developer typed.
|
|
1431
|
-
* @param {{ commit?: string|null, ref?: string|null }} ctx
|
|
1885
|
+
* @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
|
|
1432
1886
|
* @param {string} tenant @param {string} [verb]
|
|
1433
1887
|
* @returns {string[]}
|
|
1434
1888
|
*/
|
|
@@ -1485,6 +1939,10 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
|
|
|
1485
1939
|
* --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
|
|
1486
1940
|
* feed the honest never-dispatched block.
|
|
1487
1941
|
*/
|
|
1942
|
+
/**
|
|
1943
|
+
* @param {any} s @param {string} tenant
|
|
1944
|
+
* @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
|
|
1945
|
+
*/
|
|
1488
1946
|
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
|
|
1489
1947
|
if (!s || s.status === "unknown") {
|
|
1490
1948
|
if (!quiet) {
|