@pmelab/gtd 27.2.0 → 27.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -1
- package/dist/chunks/{reserved-B2QaImnN.mjs → reserved-BPdVZkgv.mjs} +29 -4
- package/dist/chunks/{tail-BnhIxCYv.mjs → tail-iLAMoigl.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +24 -1
- package/dist/gtd.bundle.mjs +174 -23
- package/dist/testkit.mjs +327 -27
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +119 -6
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +146 -8
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +160 -0
- package/src/edge/Edge.ts +90 -8
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flows/helpers.ts +28 -0
- package/src/flows/runtime.ts +7 -0
- package/src/install/Install.ts +2 -1
- package/src/platform/Git.test.ts +25 -1
- package/src/platform/Git.ts +30 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +16 -1
- package/src/replay/Replay.test.ts +91 -0
- package/src/replay/Replay.ts +22 -2
- package/src/replay/Trailers.test.ts +110 -1
- package/src/replay/Trailers.ts +37 -1
- package/src/replay/index.ts +2 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStep.test.ts +32 -0
- package/src/step/planStep.ts +3 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
- package/src/testkit/EmittedScriptRecognizer.ts +33 -0
- package/src/testkit/EmittedScripts.test.ts +9 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/wire/BeatDocument.test.ts +25 -0
- package/src/wire/BeatDocument.ts +4 -2
- package/src/wire/BeatPlain.test.ts +1 -0
- package/src/wire/BeatStatus.test.ts +2 -0
- package/src/wire/BeatStatus.ts +13 -2
- package/src/wire/WireSchema.test.ts +10 -0
- package/src/wire/types.ts +8 -0
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/refactor.test.ts +466 -0
- package/src/workflows/feature/refactor.ts +178 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/access.ts +2 -1
- package/src/workflows/shared/health.test.ts +67 -3
- package/src/workflows/shared/health.ts +5 -3
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +6 -0
- package/src/workflows/shared/skills.ts +5 -1
- package/src/workflows/shared/text.ts +35 -0
- package/src/workflows/shared/vars.ts +1 -0
package/dist/testkit.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
|
|
2
|
-
import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-
|
|
2
|
+
import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-C12dahrH.mjs";
|
|
3
3
|
import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, w as formatFinding } from "./chunks/steering-DVILanVf.mjs";
|
|
4
4
|
import { $ as logWarning, $i as format, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as get$5, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as isNone, Bn as get$3, Br as sync$1, Bt as unsafeMakeLatch, C as all$1, Ci as toArray, Cn as isInterrupted, Cr as exitFail, Ct as sandbox, D as async, Di as GenericTag, Dn as squash, Dr as exitZipRight, Dt as scopedWith$1, E as asVoid$1, Ei as unsafeHead, En as reduceWithContext, Er as exitVoid, Et as scoped$4, F as ensuring$2, Fi as splitAt, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as some, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as map$8, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as filter$1, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as match$2, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as isLeft, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as flatMap$5, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as omit, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as empty$5, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Tag, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as fromIterable, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as NodeInspectSymbol, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as fromNullable, Rn as bindTo, Rr as succeed$4, Rt as uninterruptible$1, S as addFinalizer$1, Si as takeRight, Sn as isDieType, Sr as exitAs, St as runtime, T as as, Ti as unsafeGet, Tn as map$6, Tr as exitSucceed, Tt as scopeWith, U as forEach$2, Ui as match$4, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as isSome, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as none, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as pipeArguments, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as right, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as BaseProto, Zn as failCause$3, Zr as combineAll, Zt as fromEffect$5, _ as decodeSync, _i as of, _n as fail$3, _r as deferredIsDone, _t as raceWith, a as Literal, aa as constUndefined, ai as size$2, an as updateContext$1, ar as zipRight$2, at as matchCause, b as acquireRelease, bi as reverse, bn as interrupt$3, br as deferredUnsafeDone, bt as runPromise, c as String$, ca as identity, ci as append$1, cn as Scope, cr as asyncInterrupt, ct as negate, d as Union, di as empty$4, dn as close, dr as deferredAwait, dt as orElse, ea as toJSON, ei as none$1, en as provideMerge$1, er as mapBoth, et as makeSemaphore, f as between, fi as filter, fn as extend, fr as deferredComplete, ft as orElseSucceed, gi as map$7, gn as die$1, gr as deferredInterruptWith, gt as race, hi as isEmpty$2, hn as NoSuchElementException, hr as deferredInterrupt, ht as provideService, i as Defect, ia as constTrue, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as getOption, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as add, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as pipe, li as appendAll, ln as addFinalizer, lr as catchAll, lt as onError, m as optional$1, mi as isChunk, mn as make$18, mr as deferredFailCause, mt as provide$2, n as Boolean$, na as hasProperty, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constVoid, oi as decode, on as forkIn$1, or as as$1, ot as matchCauseEffect, p as decodeUnknown, pi as fromIterable$1, pn as fork, pr as deferredFail, pt as promise, q as gen, qi as left, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as constFalse, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as dual, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as equals, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ui as drop, un as addFinalizerExit, ur as currentContext$1, ut as orDie, v as encodeSync, vi as prepend, vn as failureOrCause, vr as deferredMake, vt as repeat, w as annotateLogs, wi as unsafeFromArray, wn as isInterruptedOnly, wr as exitFailCause, wt as scope, x as acquireUseRelease$2, xi as take$2, xn as interruptors, xr as deferredUnsafeMake, xt as runPromiseExit$1, y as _void, yi as reduce, yn as flipCauseOption, yr as deferredSucceed, yt as retry, z as fiberIdWith, zi as getOrElse, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-CyImoHTj.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
@@ -5402,6 +5402,13 @@ const makeGitImpl = (executor, root) => {
|
|
|
5402
5402
|
hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
|
|
5403
5403
|
readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
|
|
5404
5404
|
diffPaths: (base, tip) => exec("git", "diff", "--name-only", "-z", "--no-renames", "--no-relative", "--ignore-submodules=none", "--end-of-options", base, tip, "--").pipe(map$4(splitNul)),
|
|
5405
|
+
authorIdent: () => exec("git", "var", "GIT_AUTHOR_IDENT").pipe(map$4((out) => {
|
|
5406
|
+
const match = /^(.*?) <([^<>]*)> \d+ [+-]\d{4}$/.exec(out.trim());
|
|
5407
|
+
return match === null || match[1] === "" || match[2] === "" ? none() : some({
|
|
5408
|
+
name: match[1],
|
|
5409
|
+
email: match[2]
|
|
5410
|
+
});
|
|
5411
|
+
}), catchAll$1(() => succeed$6(none()))),
|
|
5405
5412
|
changedPaths: (base) => gen(function* () {
|
|
5406
5413
|
const ref = base ?? "HEAD";
|
|
5407
5414
|
const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll$1(() => succeed$6("")));
|
|
@@ -5419,6 +5426,7 @@ const makeGitImpl = (executor, root) => {
|
|
|
5419
5426
|
}
|
|
5420
5427
|
return all;
|
|
5421
5428
|
}),
|
|
5429
|
+
stagedPaths: () => exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(catchAll$1(() => succeed$6("")), map$4((out) => parseNameStatus(splitNul(out)))),
|
|
5422
5430
|
resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
|
|
5423
5431
|
readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
|
|
5424
5432
|
isAncestor: (a, b) => run$1(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$1(CommandExecutor, executor)), map$4(() => true), catchAll$1(() => succeed$6(false))),
|
|
@@ -6358,7 +6366,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
6358
6366
|
(\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
|
|
6359
6367
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
6360
6368
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
6361
|
-
\`cost\`, \`costByModel\`.
|
|
6369
|
+
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
6370
|
+
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
6362
6371
|
|
|
6363
6372
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
6364
6373
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|
|
@@ -6879,12 +6888,38 @@ const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm;
|
|
|
6879
6888
|
const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/;
|
|
6880
6889
|
const VAR_RE = /^([^=\s]+)=(.*)$/;
|
|
6881
6890
|
const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/;
|
|
6882
|
-
const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f]/;
|
|
6891
|
+
const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/;
|
|
6883
6892
|
const encodePath = (path) => path.trim() !== path || path.startsWith("\"") || CONTROL_CHARACTER$1.test(path) ? JSON.stringify(path) : path;
|
|
6884
6893
|
const decodePath = (raw) => {
|
|
6885
6894
|
const path = raw.startsWith("\"") ? parseJson(raw) : raw;
|
|
6886
6895
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
|
|
6887
6896
|
};
|
|
6897
|
+
const decodeApproval = (raw) => {
|
|
6898
|
+
const parsed = parseJson(raw);
|
|
6899
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
6900
|
+
const { step, name, email } = parsed;
|
|
6901
|
+
if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string") return void 0;
|
|
6902
|
+
if ([
|
|
6903
|
+
step,
|
|
6904
|
+
name,
|
|
6905
|
+
email
|
|
6906
|
+
].some((field) => CONTROL_CHARACTER$1.test(field))) return void 0;
|
|
6907
|
+
const id = parseStepId(step);
|
|
6908
|
+
return id === void 0 ? void 0 : {
|
|
6909
|
+
step: id,
|
|
6910
|
+
name,
|
|
6911
|
+
email
|
|
6912
|
+
};
|
|
6913
|
+
};
|
|
6914
|
+
const encodeApproval = ({ step, name, email }) => JSON.stringify({
|
|
6915
|
+
step: formatStepId(step),
|
|
6916
|
+
name,
|
|
6917
|
+
email
|
|
6918
|
+
});
|
|
6919
|
+
const isRecordableApproval = (approval) => {
|
|
6920
|
+
const back = decodeApproval(encodeApproval(approval));
|
|
6921
|
+
return back !== void 0 && encodeApproval(back) === encodeApproval(approval);
|
|
6922
|
+
};
|
|
6888
6923
|
const FORMAT_RE = /^[1-9][0-9]*$/;
|
|
6889
6924
|
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
6890
6925
|
const TRAILER_READERS = {
|
|
@@ -6919,6 +6954,9 @@ const TRAILER_READERS = {
|
|
|
6919
6954
|
"Gtd-Kept": (value, into) => {
|
|
6920
6955
|
if (HASH_RE.test(value)) into.kept ??= value;
|
|
6921
6956
|
},
|
|
6957
|
+
"Gtd-Approved": (value, into) => {
|
|
6958
|
+
into.approved ??= decodeApproval(value);
|
|
6959
|
+
},
|
|
6922
6960
|
"Gtd-Format": (value, into) => {
|
|
6923
6961
|
if (FORMAT_RE.test(value)) into.format ??= Number(value);
|
|
6924
6962
|
},
|
|
@@ -6940,6 +6978,7 @@ const parseCommitMessage = (message) => {
|
|
|
6940
6978
|
branch: void 0,
|
|
6941
6979
|
version: void 0,
|
|
6942
6980
|
kept: void 0,
|
|
6981
|
+
approved: void 0,
|
|
6943
6982
|
judge: [],
|
|
6944
6983
|
vars: {},
|
|
6945
6984
|
config: {},
|
|
@@ -6965,6 +7004,7 @@ const formatCommitMessage = (spec) => {
|
|
|
6965
7004
|
for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
|
|
6966
7005
|
if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
|
|
6967
7006
|
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
|
|
7007
|
+
if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
|
|
6968
7008
|
if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
|
|
6969
7009
|
if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
|
|
6970
7010
|
lines.push(`Gtd-Format: 1`);
|
|
@@ -7060,7 +7100,8 @@ const KNOWN_OPTIONS = {
|
|
|
7060
7100
|
human: /* @__PURE__ */ new Set([
|
|
7061
7101
|
...STEERING_OPTIONS,
|
|
7062
7102
|
"message",
|
|
7063
|
-
"acceptClean"
|
|
7103
|
+
"acceptClean",
|
|
7104
|
+
"approve"
|
|
7064
7105
|
]),
|
|
7065
7106
|
run: new Set(STEERING_OPTIONS),
|
|
7066
7107
|
judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
|
|
@@ -7070,6 +7111,13 @@ const unknownOptions = (step) => {
|
|
|
7070
7111
|
if (unknown.length === 0) return void 0;
|
|
7071
7112
|
return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`;
|
|
7072
7113
|
};
|
|
7114
|
+
const approveConflict = (step) => {
|
|
7115
|
+
const request = step.request;
|
|
7116
|
+
if (request.kind !== "human" || request.options.approve !== true) return void 0;
|
|
7117
|
+
const keys = ["mode", "file"].filter((key) => request.options[key] !== void 0);
|
|
7118
|
+
if (keys.length === 0) return void 0;
|
|
7119
|
+
return `gtd: step "${step.name}": approve cannot be combined with ${keys.join(", ")} in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing`;
|
|
7120
|
+
};
|
|
7073
7121
|
const replay = async (input) => {
|
|
7074
7122
|
const commits = input.episode.commits.map((c) => ({
|
|
7075
7123
|
...c,
|
|
@@ -7229,12 +7277,21 @@ const replay = async (input) => {
|
|
|
7229
7277
|
kind: "divergence",
|
|
7230
7278
|
message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`
|
|
7231
7279
|
};
|
|
7232
|
-
const error = unknownOptions(step) ?? checkIdentity(step);
|
|
7280
|
+
const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step);
|
|
7233
7281
|
return error === void 0 ? void 0 : {
|
|
7234
7282
|
kind: "failed",
|
|
7235
7283
|
message: error
|
|
7236
7284
|
};
|
|
7237
7285
|
};
|
|
7286
|
+
const missingApproval = (step, commit, expected) => {
|
|
7287
|
+
if (step.request.kind !== "human" || step.request.options.approve !== true) return void 0;
|
|
7288
|
+
const approved = commit.parsed.approved;
|
|
7289
|
+
if (approved !== void 0 && formatStepId(approved.step) === expected) return void 0;
|
|
7290
|
+
return {
|
|
7291
|
+
kind: "divergence",
|
|
7292
|
+
message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`
|
|
7293
|
+
};
|
|
7294
|
+
};
|
|
7238
7295
|
const consumeCommit = (step, commit) => {
|
|
7239
7296
|
const recorded = commit.parsed.step;
|
|
7240
7297
|
const expected = formatStepId(step.id);
|
|
@@ -7245,6 +7302,8 @@ const replay = async (input) => {
|
|
|
7245
7302
|
message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`
|
|
7246
7303
|
});
|
|
7247
7304
|
}
|
|
7305
|
+
const unapproved = missingApproval(step, commit, expected);
|
|
7306
|
+
if (unapproved !== void 0) return finish(unapproved);
|
|
7248
7307
|
cursor++;
|
|
7249
7308
|
advance(commit);
|
|
7250
7309
|
if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
|
|
@@ -8355,6 +8414,9 @@ const planStep = (snapshot, opts = {}) => {
|
|
|
8355
8414
|
kind: "keepTurn",
|
|
8356
8415
|
ref: landing.keepTurn,
|
|
8357
8416
|
message
|
|
8417
|
+
} : landing.spec.approved !== void 0 ? {
|
|
8418
|
+
kind: "commitClean",
|
|
8419
|
+
message
|
|
8358
8420
|
} : {
|
|
8359
8421
|
kind: "commitAll",
|
|
8360
8422
|
message
|
|
@@ -8556,6 +8618,12 @@ const pinnedConfigOf = (history) => {
|
|
|
8556
8618
|
};
|
|
8557
8619
|
}
|
|
8558
8620
|
};
|
|
8621
|
+
const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
|
|
8622
|
+
step: formatStepId(message.approved.step),
|
|
8623
|
+
name: message.approved.name,
|
|
8624
|
+
email: message.approved.email,
|
|
8625
|
+
commit: hash
|
|
8626
|
+
}];
|
|
8559
8627
|
const buildRun = (history, location, closingHash) => {
|
|
8560
8628
|
const processCommits = history.slice(location.processStart);
|
|
8561
8629
|
const parsed = processCommits.map((c) => parseCommitMessage(c.message));
|
|
@@ -8571,6 +8639,7 @@ const buildRun = (history, location, closingHash) => {
|
|
|
8571
8639
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
|
|
8572
8640
|
costEntries: parsed.flatMap(costEntriesOf),
|
|
8573
8641
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
8642
|
+
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
|
|
8574
8643
|
pinnedVars: { ...first?.vars },
|
|
8575
8644
|
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
8576
8645
|
headTurn: headTurnOf(head),
|
|
@@ -8856,7 +8925,8 @@ const messageDef = (common, request, step) => ({
|
|
|
8856
8925
|
...common,
|
|
8857
8926
|
kind: "message",
|
|
8858
8927
|
content: request.options.message ?? request.options.label ?? step.name,
|
|
8859
|
-
...optional("acceptClean", request.options.acceptClean)
|
|
8928
|
+
...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
|
|
8929
|
+
...optional("approve", request.options.approve)
|
|
8860
8930
|
});
|
|
8861
8931
|
const stepDefOf = (step) => {
|
|
8862
8932
|
const request = step.request;
|
|
@@ -8985,6 +9055,12 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
|
|
|
8985
9055
|
}
|
|
8986
9056
|
};
|
|
8987
9057
|
});
|
|
9058
|
+
const pendingChanges = (git, withIndex) => gen(function* () {
|
|
9059
|
+
const worktree = yield* git.changedPaths();
|
|
9060
|
+
if (!withIndex) return worktree;
|
|
9061
|
+
const seen = new Set(worktree.map((e) => e.path));
|
|
9062
|
+
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
|
|
9063
|
+
});
|
|
8988
9064
|
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
8989
9065
|
const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
8990
9066
|
const git = yield* GitService;
|
|
@@ -9000,7 +9076,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
|
9000
9076
|
yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
|
|
9001
9077
|
const stepDef = stepDefOf(step);
|
|
9002
9078
|
if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$5(/* @__PURE__ */ new Error(`gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
|
|
9003
|
-
const changes = ref === void 0 ? (yield* git.
|
|
9079
|
+
const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
9004
9080
|
status: normalizeStatus(e.status),
|
|
9005
9081
|
path: e.path
|
|
9006
9082
|
})) : [];
|
|
@@ -9086,6 +9162,25 @@ const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state =
|
|
|
9086
9162
|
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
9087
9163
|
const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
|
|
9088
9164
|
const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
|
|
9165
|
+
const approvalRefusal = (rest, approver) => {
|
|
9166
|
+
if (rest.stepDef.approve !== true) return void 0;
|
|
9167
|
+
const id = rest.step.id;
|
|
9168
|
+
if (rest.changes.length > 0) return {
|
|
9169
|
+
kind: "refusal",
|
|
9170
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``
|
|
9171
|
+
};
|
|
9172
|
+
if (isNone(approver)) return {
|
|
9173
|
+
kind: "refusal",
|
|
9174
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
|
|
9175
|
+
};
|
|
9176
|
+
if (!isRecordableApproval({
|
|
9177
|
+
step: id,
|
|
9178
|
+
...approver.value
|
|
9179
|
+
})) return {
|
|
9180
|
+
kind: "refusal",
|
|
9181
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`
|
|
9182
|
+
};
|
|
9183
|
+
};
|
|
9089
9184
|
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
9090
9185
|
const cleanLanding = (rest) => {
|
|
9091
9186
|
if (rest.changes.length > 0) return void 0;
|
|
@@ -9140,7 +9235,11 @@ const closingTrailers = (branch) => ({
|
|
|
9140
9235
|
});
|
|
9141
9236
|
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
9142
9237
|
const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
|
|
9143
|
-
const
|
|
9238
|
+
const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
|
|
9239
|
+
step: rest.step.id,
|
|
9240
|
+
...approver.value
|
|
9241
|
+
} } : {};
|
|
9242
|
+
const landingTo = (rest, to, branch, approver) => {
|
|
9144
9243
|
if (isSettledNoop(rest, to)) return {
|
|
9145
9244
|
kind: "noop",
|
|
9146
9245
|
settled: true
|
|
@@ -9158,6 +9257,7 @@ const landingTo = (rest, to, branch) => {
|
|
|
9158
9257
|
from: rest.state,
|
|
9159
9258
|
to,
|
|
9160
9259
|
step: rest.step.id,
|
|
9260
|
+
...approvedTrailer(rest, approver),
|
|
9161
9261
|
...starts ? {
|
|
9162
9262
|
vars: rest.vars,
|
|
9163
9263
|
config: rest.config
|
|
@@ -9173,7 +9273,9 @@ const landingTo = (rest, to, branch) => {
|
|
|
9173
9273
|
* replay with the working tree as the pending turn. The target step is
|
|
9174
9274
|
* computed by replaying, never matched.
|
|
9175
9275
|
*/
|
|
9176
|
-
const decideLanding = (rest, verdicts, branch) => gen(function* () {
|
|
9276
|
+
const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
|
|
9277
|
+
const unapproved = approvalRefusal(rest, approver);
|
|
9278
|
+
if (unapproved !== void 0) return unapproved;
|
|
9177
9279
|
const early = cleanLanding(rest);
|
|
9178
9280
|
if (early !== void 0) return early;
|
|
9179
9281
|
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
@@ -9189,14 +9291,16 @@ const decideLanding = (rest, verdicts, branch) => gen(function* () {
|
|
|
9189
9291
|
}));
|
|
9190
9292
|
const to = yield* landingTarget(outcome, rest);
|
|
9191
9293
|
if (typeof to !== "string") return to;
|
|
9192
|
-
return landingTo(rest, to, branch);
|
|
9294
|
+
return landingTo(rest, to, branch, approver);
|
|
9193
9295
|
});
|
|
9194
9296
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
9195
|
-
const previewLanding = (rest) => decideLanding(rest, void 0, none()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
|
|
9297
|
+
const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
|
|
9196
9298
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
9197
9299
|
const snapshotFromRest = (rest, verdicts) => gen(function* () {
|
|
9198
|
-
const
|
|
9199
|
-
const
|
|
9300
|
+
const git = yield* GitService;
|
|
9301
|
+
const branch = yield* git.currentBranch();
|
|
9302
|
+
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
|
|
9303
|
+
const landing = yield* decideLanding(rest, verdicts, branch, approver);
|
|
9200
9304
|
return {
|
|
9201
9305
|
stepDef: rest.stepDef,
|
|
9202
9306
|
state: rest.state,
|
|
@@ -9250,7 +9354,8 @@ const historySteps = (run) => {
|
|
|
9250
9354
|
actor: entry.actor,
|
|
9251
9355
|
from: message.parsed?.from,
|
|
9252
9356
|
to: entry.state,
|
|
9253
|
-
verdicts: message.judge
|
|
9357
|
+
verdicts: message.judge,
|
|
9358
|
+
...optional("approval", approvalEntryOf(entry.hash, message)[0])
|
|
9254
9359
|
};
|
|
9255
9360
|
});
|
|
9256
9361
|
};
|
|
@@ -9308,8 +9413,26 @@ const commitAllowEmpty = (message) => {
|
|
|
9308
9413
|
`fi`
|
|
9309
9414
|
].join("\n");
|
|
9310
9415
|
};
|
|
9416
|
+
const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
|
|
9311
9417
|
/** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
|
|
9312
9418
|
const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
|
|
9419
|
+
/**
|
|
9420
|
+
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
9421
|
+
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
9422
|
+
* hook runs and none can add to it. A HEAD that moved fails the guarded
|
|
9423
|
+
* `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
|
|
9424
|
+
*/
|
|
9425
|
+
const commitClean = (message) => [
|
|
9426
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
9427
|
+
`if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
|
|
9428
|
+
` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
|
|
9429
|
+
` exit 1`,
|
|
9430
|
+
`fi`,
|
|
9431
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
9432
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
9433
|
+
`gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
9434
|
+
`git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
|
|
9435
|
+
].join("\n");
|
|
9313
9436
|
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
9314
9437
|
const commitAsIs = (message) => commitAllowEmpty(message);
|
|
9315
9438
|
/** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
|
|
@@ -9347,7 +9470,7 @@ const keepTurn = (refPrefix, message) => {
|
|
|
9347
9470
|
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
9348
9471
|
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
9349
9472
|
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
9350
|
-
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
9473
|
+
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
9351
9474
|
` git reset -q && git update-ref -d ${keptRef}`,
|
|
9352
9475
|
` exit 1`,
|
|
9353
9476
|
`fi`,
|
|
@@ -9385,6 +9508,7 @@ const renderOutcome = (outcome) => {
|
|
|
9385
9508
|
const renderGitWrite = (write) => {
|
|
9386
9509
|
switch (write.kind) {
|
|
9387
9510
|
case "commitAll": return commitAll(write.message);
|
|
9511
|
+
case "commitClean": return commitClean(write.message);
|
|
9388
9512
|
case "updateRef": return updateRef(write.ref, write.target);
|
|
9389
9513
|
case "keepTurn": return keepTurn(write.ref, write.message);
|
|
9390
9514
|
}
|
|
@@ -9858,6 +9982,7 @@ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
|
|
|
9858
9982
|
for (const step of trail.steps) {
|
|
9859
9983
|
lines.push(`${seven(step.hash)} ${step.subject}`);
|
|
9860
9984
|
for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
|
|
9985
|
+
if (step.approval !== void 0) lines.push(` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`);
|
|
9861
9986
|
}
|
|
9862
9987
|
lines.push(`ended (${trail.ended})`);
|
|
9863
9988
|
out.write(`${lines.join("\n")}\n`);
|
|
@@ -10391,7 +10516,8 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
|
|
|
10391
10516
|
changes: computeStatusChanges(rest.changes),
|
|
10392
10517
|
next: yield* computeNextMatch(rest),
|
|
10393
10518
|
cost: rest.cost.total,
|
|
10394
|
-
costByModel: rest.cost.byModel
|
|
10519
|
+
costByModel: rest.cost.byModel,
|
|
10520
|
+
approvals: rest.run.approvals
|
|
10395
10521
|
}));
|
|
10396
10522
|
});
|
|
10397
10523
|
/**
|
|
@@ -11080,11 +11206,11 @@ const COMMAND_ROWS = [
|
|
|
11080
11206
|
arity: { name: "ref" },
|
|
11081
11207
|
details: [
|
|
11082
11208
|
"Replay one kept process under today's config and print its",
|
|
11083
|
-
"steps
|
|
11084
|
-
"(or unique prefix) or a branch name. Refuses
|
|
11085
|
-
"config file differs from its content at the
|
|
11086
|
-
"version mismatch only warns on stderr. --json
|
|
11087
|
-
"trail as one document; writes nothing"
|
|
11209
|
+
"steps, judge verdicts and approvals. <ref> is a full ref, an",
|
|
11210
|
+
"entry hash (or unique prefix) or a branch name. Refuses",
|
|
11211
|
+
"(exit 1) when a config file differs from its content at the",
|
|
11212
|
+
"ref's tip. A version mismatch only warns on stderr. --json",
|
|
11213
|
+
"prints the trail as one document; writes nothing"
|
|
11088
11214
|
]
|
|
11089
11215
|
},
|
|
11090
11216
|
{
|
|
@@ -11697,6 +11823,10 @@ var InMemRepo = class InMemRepo {
|
|
|
11697
11823
|
changedPathsWorktree(base) {
|
|
11698
11824
|
return diffTrees(this.treeAt(base), new Map(this.worktree));
|
|
11699
11825
|
}
|
|
11826
|
+
/** The index vs HEAD — the port's `stagedPaths`. */
|
|
11827
|
+
stagedPaths() {
|
|
11828
|
+
return diffTrees(this.treeAt(void 0), this.index);
|
|
11829
|
+
}
|
|
11700
11830
|
diffPaths(base, tip) {
|
|
11701
11831
|
for (const ref of [base, tip]) if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`);
|
|
11702
11832
|
return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path);
|
|
@@ -11756,6 +11886,7 @@ var InMemRepo = class InMemRepo {
|
|
|
11756
11886
|
/** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
|
|
11757
11887
|
hookRejection = void 0;
|
|
11758
11888
|
signingFails = false;
|
|
11889
|
+
signingEnabled = false;
|
|
11759
11890
|
/** Seeded: a pre-commit hook writing and staging this file. */
|
|
11760
11891
|
hookStages = void 0;
|
|
11761
11892
|
writeCommit(message) {
|
|
@@ -11767,7 +11898,8 @@ var InMemRepo = class InMemRepo {
|
|
|
11767
11898
|
message,
|
|
11768
11899
|
files: new Map(tree),
|
|
11769
11900
|
parent,
|
|
11770
|
-
seq: this.nextSeq
|
|
11901
|
+
seq: this.nextSeq++,
|
|
11902
|
+
signed: this.signingEnabled
|
|
11771
11903
|
};
|
|
11772
11904
|
this.commits.set(hash, commit);
|
|
11773
11905
|
this.head = hash;
|
|
@@ -11788,6 +11920,16 @@ var InMemRepo = class InMemRepo {
|
|
|
11788
11920
|
}
|
|
11789
11921
|
this.writeCommit(message);
|
|
11790
11922
|
}
|
|
11923
|
+
/** Twin of `GitScript.commitClean`: no hook runs, HEAD's tree is recorded; `undefined` on success, else the stderr text. */
|
|
11924
|
+
commitClean(message) {
|
|
11925
|
+
if (this.head === null) return "fatal: Needed a single revision";
|
|
11926
|
+
if (this.statusPorcelain().trim() !== "") return CHANGED_SINCE_LAND;
|
|
11927
|
+
if (this.signingFails) return "error: gpg failed to sign the data";
|
|
11928
|
+
this.writeCommit(message);
|
|
11929
|
+
}
|
|
11930
|
+
headSigned() {
|
|
11931
|
+
return this.headCommit()?.signed === true;
|
|
11932
|
+
}
|
|
11791
11933
|
/** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
|
|
11792
11934
|
seedCommit(message) {
|
|
11793
11935
|
if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
|
|
@@ -11805,7 +11947,8 @@ var InMemRepo = class InMemRepo {
|
|
|
11805
11947
|
message,
|
|
11806
11948
|
files: new Map(tree),
|
|
11807
11949
|
parent,
|
|
11808
|
-
seq: this.nextSeq
|
|
11950
|
+
seq: this.nextSeq++,
|
|
11951
|
+
signed: false
|
|
11809
11952
|
});
|
|
11810
11953
|
const ref = `${refPrefix}/${hash}`;
|
|
11811
11954
|
this.refs.set(ref, hash);
|
|
@@ -12000,7 +12143,12 @@ const makeGitReaderOps = (repo, root) => ({
|
|
|
12000
12143
|
commitHistory: (base, head) => succeed$6(repo.commitHistory(base, head)),
|
|
12001
12144
|
subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
|
|
12002
12145
|
changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base)),
|
|
12003
|
-
|
|
12146
|
+
stagedPaths: () => succeed$6(repo.stagedPaths()),
|
|
12147
|
+
diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip)),
|
|
12148
|
+
authorIdent: () => succeed$6(some({
|
|
12149
|
+
name: "Test",
|
|
12150
|
+
email: "test@test.com"
|
|
12151
|
+
}))
|
|
12004
12152
|
});
|
|
12005
12153
|
const failOn = (output) => {
|
|
12006
12154
|
if (output !== void 0) throw new Error(output);
|
|
@@ -12557,6 +12705,18 @@ const recognizeGitBuilders = (repo, block) => {
|
|
|
12557
12705
|
}
|
|
12558
12706
|
return;
|
|
12559
12707
|
}
|
|
12708
|
+
if (block.startsWith("gtd_head=$(git rev-parse --verify HEAD) || exit 1\nif [ -n ")) {
|
|
12709
|
+
const [message] = extractQuotedTokens(block.slice(block.indexOf("\"$gtd_head\" -m ")));
|
|
12710
|
+
if (message !== void 0 && commitClean(message) === block) {
|
|
12711
|
+
const failure = repo.commitClean(message);
|
|
12712
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12713
|
+
kind: "failed",
|
|
12714
|
+
error: failure,
|
|
12715
|
+
status: 1
|
|
12716
|
+
};
|
|
12717
|
+
}
|
|
12718
|
+
return;
|
|
12719
|
+
}
|
|
12560
12720
|
if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
|
|
12561
12721
|
const [message] = extractQuotedTokens(conditionLine);
|
|
12562
12722
|
if (message !== void 0 && commitAsIs(message) === block) {
|
|
@@ -12961,6 +13121,17 @@ const recognizeDeferNotes = (repo, block) => {
|
|
|
12961
13121
|
repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`);
|
|
12962
13122
|
return { kind: "applied" };
|
|
12963
13123
|
};
|
|
13124
|
+
/** `mkdir -p <dir>` then `printf '%s\n' <quoted lines> > <path>`: a flow's one-file write. Re-rendered from what was extracted, so any other shape stays unrecognized. */
|
|
13125
|
+
const recognizeWriteLines = (repo, block) => {
|
|
13126
|
+
const [mkdir, printf, ...rest] = block.split("\n");
|
|
13127
|
+
if (rest.length > 0 || !mkdir?.startsWith("mkdir -p ") || !printf?.startsWith("printf '%s\\n' ")) return;
|
|
13128
|
+
const tokens = extractQuotedTokens(printf.slice(14));
|
|
13129
|
+
const path = tokens.pop();
|
|
13130
|
+
if (path === void 0 || !path.includes("/")) return void 0;
|
|
13131
|
+
if (`mkdir -p ${path.slice(0, path.lastIndexOf("/"))}\nprintf '%s\\n' ${tokens.map(shellQuote).join(" ")} > ${shellQuote(path)}` !== block) return void 0;
|
|
13132
|
+
repo.writeFile(path, `${tokens.join("\n")}\n`);
|
|
13133
|
+
return { kind: "applied" };
|
|
13134
|
+
};
|
|
12964
13135
|
/** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
|
|
12965
13136
|
const recognizersFor = (repo, commands) => [
|
|
12966
13137
|
(block) => recognizeSetFlags(block),
|
|
@@ -12970,6 +13141,7 @@ const recognizersFor = (repo, commands) => [
|
|
|
12970
13141
|
(block) => recognizeBinaryGuard(commands, block),
|
|
12971
13142
|
(block) => recognizeGitBuilders(repo, block),
|
|
12972
13143
|
(block) => recognizeDeferNotes(repo, block),
|
|
13144
|
+
(block) => recognizeWriteLines(repo, block),
|
|
12973
13145
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
12974
13146
|
(block) => recognizePresentationSubshell(repo, commands, block),
|
|
12975
13147
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -13309,6 +13481,13 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
13309
13481
|
gitExec("config", "commit.gpgsign", "true");
|
|
13310
13482
|
gitExec("config", "gpg.format", "openpgp");
|
|
13311
13483
|
gitExec("config", "gpg.program", "false");
|
|
13484
|
+
},
|
|
13485
|
+
workingSigner: () => {
|
|
13486
|
+
const signer = join(root, ".git/gtd-test-signer.sh");
|
|
13487
|
+
writeFileSync(signer, `#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '-----BEGIN PGP SIGNATURE-----' '' 'ZHVtbXk=' '-----END PGP SIGNATURE-----'\nprintf '\\n[GNUPG:] SIG_CREATED ' >&2\n`, { mode: 493 });
|
|
13488
|
+
gitExec("config", "commit.gpgsign", "true");
|
|
13489
|
+
gitExec("config", "gpg.format", "openpgp");
|
|
13490
|
+
gitExec("config", "gpg.program", signer);
|
|
13312
13491
|
}
|
|
13313
13492
|
},
|
|
13314
13493
|
observe: {
|
|
@@ -13338,7 +13517,8 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
13338
13517
|
});
|
|
13339
13518
|
},
|
|
13340
13519
|
headSubject: () => gitExec("log", "-1", "--pretty=%s"),
|
|
13341
|
-
headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0)
|
|
13520
|
+
headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0),
|
|
13521
|
+
headSigned: () => gitExec("cat-file", "commit", "HEAD").includes("\ngpgsig ")
|
|
13342
13522
|
},
|
|
13343
13523
|
runScript: (script) => {
|
|
13344
13524
|
const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
|
|
@@ -13417,6 +13597,9 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13417
13597
|
},
|
|
13418
13598
|
failingSigner: () => {
|
|
13419
13599
|
repo.signingFails = true;
|
|
13600
|
+
},
|
|
13601
|
+
workingSigner: () => {
|
|
13602
|
+
repo.signingEnabled = true;
|
|
13420
13603
|
}
|
|
13421
13604
|
},
|
|
13422
13605
|
observe: {
|
|
@@ -13437,7 +13620,8 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13437
13620
|
headSubject: () => repo.lastCommitSubject() ?? "",
|
|
13438
13621
|
headTouchedPaths: () => {
|
|
13439
13622
|
return [.../* @__PURE__ */ new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])].filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p)).sort();
|
|
13440
|
-
}
|
|
13623
|
+
},
|
|
13624
|
+
headSigned: () => repo.headSigned()
|
|
13441
13625
|
},
|
|
13442
13626
|
runScript: (script, commands = /* @__PURE__ */ new Map()) => {
|
|
13443
13627
|
const { ok, status, output, error } = applyEmittedScript(repo, commands, script);
|
|
@@ -13514,7 +13698,9 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13514
13698
|
g.readFileAtRef("HEAD", "second.txt"),
|
|
13515
13699
|
g.changedPaths(),
|
|
13516
13700
|
g.changedPaths("HEAD~1"),
|
|
13517
|
-
g.
|
|
13701
|
+
g.stagedPaths(),
|
|
13702
|
+
g.diffPaths("HEAD~1", "HEAD"),
|
|
13703
|
+
g.authorIdent()
|
|
13518
13704
|
]));
|
|
13519
13705
|
expect(await t.observe.snapshot()).toBe(before);
|
|
13520
13706
|
await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
|
|
@@ -14002,6 +14188,13 @@ const runGitServiceContract = (makeTier) => {
|
|
|
14002
14188
|
it("returns [] on a clean tree", async () => {
|
|
14003
14189
|
expect(await runGit(t, (g) => g.changedPaths())).toEqual([]);
|
|
14004
14190
|
});
|
|
14191
|
+
it("ignores an edit staged and then reverted in the worktree", async () => {
|
|
14192
|
+
t.seed.commit("chore: seed", { "a.txt": "1" });
|
|
14193
|
+
t.seed.writeFile("a.txt", "2");
|
|
14194
|
+
t.seed.stageAll();
|
|
14195
|
+
t.seed.writeFile("a.txt", "1");
|
|
14196
|
+
expect(await runGit(t, (g) => g.changedPaths())).toEqual([]);
|
|
14197
|
+
});
|
|
14005
14198
|
for (const { label, path } of PATHOLOGICAL_PATHS) it(`reports an untracked path containing ${label}, verbatim`, async () => {
|
|
14006
14199
|
t.seed.writeFile(path, "content");
|
|
14007
14200
|
expect(await runGit(t, (g) => g.changedPaths())).toEqual([{
|
|
@@ -14086,6 +14279,45 @@ const runGitServiceContract = (makeTier) => {
|
|
|
14086
14279
|
});
|
|
14087
14280
|
});
|
|
14088
14281
|
});
|
|
14282
|
+
describe("stagedPaths", () => {
|
|
14283
|
+
it("returns [] when the index matches HEAD, whatever the worktree holds", async () => {
|
|
14284
|
+
t.seed.commit("chore: seed", { "a.txt": "1" });
|
|
14285
|
+
t.seed.writeFile("a.txt", "2");
|
|
14286
|
+
t.seed.writeFile("u.txt", "u");
|
|
14287
|
+
expect(await runGit(t, (g) => g.stagedPaths())).toEqual([]);
|
|
14288
|
+
});
|
|
14289
|
+
it("reports an edit staged and then reverted in the worktree", async () => {
|
|
14290
|
+
t.seed.commit("chore: seed", { "a.txt": "1" });
|
|
14291
|
+
t.seed.writeFile("a.txt", "2");
|
|
14292
|
+
t.seed.stageAll();
|
|
14293
|
+
t.seed.writeFile("a.txt", "1");
|
|
14294
|
+
expect(await runGit(t, (g) => g.stagedPaths())).toEqual([{
|
|
14295
|
+
path: "a.txt",
|
|
14296
|
+
status: "M"
|
|
14297
|
+
}]);
|
|
14298
|
+
});
|
|
14299
|
+
it("reports staged additions and deletions", async () => {
|
|
14300
|
+
t.seed.commit("chore: seed", { "gone.txt": "g" });
|
|
14301
|
+
t.seed.deleteFile("gone.txt");
|
|
14302
|
+
t.seed.writeFile("new.txt", "n");
|
|
14303
|
+
t.seed.stageAll();
|
|
14304
|
+
expect([...await runGit(t, (g) => g.stagedPaths())].sort((a, b) => a.path.localeCompare(b.path))).toEqual([{
|
|
14305
|
+
path: "gone.txt",
|
|
14306
|
+
status: "D"
|
|
14307
|
+
}, {
|
|
14308
|
+
path: "new.txt",
|
|
14309
|
+
status: "A"
|
|
14310
|
+
}]);
|
|
14311
|
+
});
|
|
14312
|
+
});
|
|
14313
|
+
describe("authorIdent", () => {
|
|
14314
|
+
it("is the configured name and email", async () => {
|
|
14315
|
+
expect(await runGit(t, (g) => g.authorIdent())).toEqual(some({
|
|
14316
|
+
name: "Test",
|
|
14317
|
+
email: "test@test.com"
|
|
14318
|
+
}));
|
|
14319
|
+
});
|
|
14320
|
+
});
|
|
14089
14321
|
describe("topLevel", () => {
|
|
14090
14322
|
it("resolves to this tier's own root", async () => {
|
|
14091
14323
|
expect(await runGit(t, (g) => g.topLevel())).toBe(t.root);
|
|
@@ -14180,6 +14412,74 @@ const runGitScriptContract = (makeTier) => {
|
|
|
14180
14412
|
expect(t.observe.headSubject()).toBe("gtd: grilled");
|
|
14181
14413
|
});
|
|
14182
14414
|
});
|
|
14415
|
+
describe("commitClean", () => {
|
|
14416
|
+
it("on a clean tree commits an empty commit with the message", () => {
|
|
14417
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
14418
|
+
run(t, commitClean("gtd(human): gate → again"));
|
|
14419
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
14420
|
+
expect(t.observe.headSubject()).toBe("gtd(human): gate → again");
|
|
14421
|
+
expect(t.observe.headTouchedPaths()).toEqual([]);
|
|
14422
|
+
});
|
|
14423
|
+
it("a staging hook never runs: nothing touched, nothing staged, file absent", () => {
|
|
14424
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
14425
|
+
t.seed.stagingHook("hooked.txt", "hooked");
|
|
14426
|
+
run(t, commitClean("gtd(human): gate → again"));
|
|
14427
|
+
expect(t.observe.headTouchedPaths()).toEqual([]);
|
|
14428
|
+
expect(t.observe.resolveRef("HEAD^")).toBe(headBefore);
|
|
14429
|
+
expect(t.observe.statusPorcelain().trim()).toBe("");
|
|
14430
|
+
expect(t.observe.existsPath("hooked.txt")).toBe(false);
|
|
14431
|
+
});
|
|
14432
|
+
it("a rejecting hook is never consulted", () => {
|
|
14433
|
+
t.seed.rejectingHook("rejected");
|
|
14434
|
+
run(t, commitClean("gtd(human): gate → again"));
|
|
14435
|
+
expect(t.observe.headSubject()).toBe("gtd(human): gate → again");
|
|
14436
|
+
});
|
|
14437
|
+
it("a working signer signs the approval", () => {
|
|
14438
|
+
t.seed.workingSigner();
|
|
14439
|
+
run(t, commitClean("gtd(human): gate → again"));
|
|
14440
|
+
expect(t.observe.headSigned()).toBe(true);
|
|
14441
|
+
});
|
|
14442
|
+
it("no signing config: the approval does not try to sign", () => {
|
|
14443
|
+
run(t, commitClean("gtd(human): gate → again"));
|
|
14444
|
+
expect(t.observe.headSigned()).toBe(false);
|
|
14445
|
+
});
|
|
14446
|
+
it("a failing signer fails the approval: HEAD and status unchanged", () => {
|
|
14447
|
+
t.seed.failingSigner();
|
|
14448
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
14449
|
+
const result = t.runScript(commitClean("gtd(human): gate → again"));
|
|
14450
|
+
expect(result.ok).toBe(false);
|
|
14451
|
+
expect(result.status).toBe(1);
|
|
14452
|
+
expect(result.output).toContain("failed to sign");
|
|
14453
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
|
|
14454
|
+
expect(t.observe.statusPorcelain().trim()).toBe("");
|
|
14455
|
+
});
|
|
14456
|
+
const refused = (name, dirty, pending) => {
|
|
14457
|
+
it(`${name}: fails, HEAD unchanged, edit still pending`, () => {
|
|
14458
|
+
dirty();
|
|
14459
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
14460
|
+
const result = t.runScript(commitClean("gtd(human): gate → again"));
|
|
14461
|
+
expect(result.ok).toBe(false);
|
|
14462
|
+
expect(result.output).toContain("changed since `gtd land`");
|
|
14463
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
|
|
14464
|
+
expect(t.observe.statusPorcelain()).toContain(pending);
|
|
14465
|
+
});
|
|
14466
|
+
};
|
|
14467
|
+
refused("a modified tracked file", () => {
|
|
14468
|
+
t.seed.commit("feat: base", { "a.txt": "1" });
|
|
14469
|
+
t.seed.writeFile("a.txt", "2");
|
|
14470
|
+
}, "a.txt");
|
|
14471
|
+
refused("a staged change", () => {
|
|
14472
|
+
t.seed.writeFile("s.txt", "s");
|
|
14473
|
+
t.seed.stageAll();
|
|
14474
|
+
}, "s.txt");
|
|
14475
|
+
refused("an untracked file", () => t.seed.writeFile("u.txt", "u"), "u.txt");
|
|
14476
|
+
refused("an edit staged then reverted in the worktree", () => {
|
|
14477
|
+
t.seed.commit("feat: base", { "a.txt": "1" });
|
|
14478
|
+
t.seed.writeFile("a.txt", "2");
|
|
14479
|
+
t.seed.stageAll();
|
|
14480
|
+
t.seed.writeFile("a.txt", "1");
|
|
14481
|
+
}, "a.txt");
|
|
14482
|
+
});
|
|
14183
14483
|
describe("commitAsIs", () => {
|
|
14184
14484
|
it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
|
|
14185
14485
|
t.seed.writeFile("a.txt", "a");
|