@pmelab/gtd 24.2.0 → 24.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 +41 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- package/src/workflows/shared/vars.ts +1 -0
package/dist/testkit.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { configProblems, configure, installContext, isFactoryWorkflow, protocolMismatch, t as flows_exports } from "./flows.mjs";
|
|
1
|
+
import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
|
|
2
2
|
import { O as renderFormat, T as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-DA7q2ikx.mjs";
|
|
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 } from "./chunks/steering-
|
|
4
|
-
import { $ as makeSemaphore, $i as
|
|
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
|
+
import { $ as makeSemaphore, $i as toJSON, $n as mapBoth, $r as none$1, $t as provideMerge$1, A as catchTag, Ai as getOption, An as scopeTag, Ar as interrupt$2, At as sync$2, B as flatten$4, Bi as isSome, Bn as make$16, Br as tap$1, Bt as unsafeMakeSemaphore$1, C as all$1, Ci as unsafeFromArray, Cn as isInterruptedOnly, Cr as exitFailCause, Ct as scope, D as async, Di as Tag, Dn as ensuring$3, Dr as fiberId, Dt as serviceOption, E as asVoid$1, Ei as GenericTag, En as squash, Er as exitZipRight, Et as scopedWith$1, F as exit, Fi as filter$1, Fn as sequential, Fr as onInterrupt, Ft as tryPromise, G as forkScoped, Gi as isLeft, Gn as Class, Gr as withFiberRuntime, Gt as zipRight$3, H as forEach$2, Hi as match$4, Hn as set$1, Hr as uninterruptibleMask$1, Ht as withFiberRuntime$1, I as fail$5, Ii as flatMap$5, In as bind, Ir as scopeClose, It as try_, J as interrupt$4, Ji as right, Jn as die, Jr as empty$6, Jt as TaggedError$1, K as gen, Ki as left, Kn as spanTag, Kr as zipRight$1, Kt as zipWith, L as failCause$4, Li as fromNullable, Ln as bindTo, Lr as succeed$4, Lt as uninterruptible$1, M as dieMessage, Mi as empty$5, Mn as inheritAll$1, Mr as interruptFiber, Mt as tapErrorCause, N as either, Ni as fromIterable, Nn as interruptAllAs$1, Nr as interruptible$1, Nt as timeout, O as catchAll$1, Oi as add, On as forEachConcurrentDiscard, Or as fiberRefLocally, Ot as succeed$6, P as ensuring$2, Pi as splitAt, Pn as join$3, Pr as map$3, Pt as tryMap, Q as logWarning, Qi as format, Qn as map$5, Qr as make$19, Qt as provide$1, R as fiberIdWith, Ri as getOrElse, Rn as sleep, Rr as suspend$4, Rt as uninterruptibleMask, S as addFinalizer$1, Si as toArray, Sn as isInterrupted, Sr as exitFail, St as sandbox, T as as, Ti as unsafeHead, Tn as reduceWithContext, Tr as exitVoid, Tt as scoped$4, U as forkDaemon, Ui as none, Un as update$1, Ur as void_$2, Ut as zip$1, V as fnUntraced, Vi as map$8, Vn as modify$1, Vr as uninterruptible, Vt as when, W as forkIn, Wi as some, Wn as TypeId$10, Wr as whenEffect, Wt as zipLeft, X as intoDeferred, Xi as BaseProto, Xn as failCause$3, Xr as combineAll, Xt as fromEffect$5, Y as interruptible, Yi as pipeArguments, Yn as fail$4, Yr as combine, Yt as tagged, Z as isEffect, Zi as NodeInspectSymbol, Zn as isFailure, Zr as ids, Zt as mergeAll$2, _ as decodeSync, _i as prepend, _n as failureOrCause, _r as deferredMake, _t as repeat, a as Literal, aa as constVoid, ai as decode, an as forkIn$1, ar as as$1, at as matchCauseEffect, b as acquireRelease, bi as take$2, bn as interruptors, br as deferredUnsafeMake, bt as runPromiseExit$1, c as String$, ca as pipe, ci as appendAll, cn as addFinalizer, cr as catchAll, ct as onError, d as Union, di as filter, dn as extend, dr as deferredComplete, dt as orElseSucceed, ea as equals, ei as get$4, en as scoped$3, er as match$3, et as map$4, f as between, fi as fromIterable$1, fn as fork, fr as deferredFail, ft as promise, gi as of, gn as fail$3, gr as deferredIsDone, gt as raceWith, hi as map$7, hn as die$1, hr as deferredInterruptWith, ht as race, i as Defect, ia as constUndefined, ii as size$2, in as updateContext$1, ir as zipRight$2, it as matchCause, j as die$2, ji as omit, jn as _await$2, jr as interruptAsFiber, jt as tap, k as catchAllCause, ki as get$5, kn as scopeMake, kr as flatMap$3, kt as suspend$5, l as Struct, li as drop, ln as addFinalizerExit, lr as currentContext$1, lt as orDie, m as optional$1, mi as isEmpty$2, mn as NoSuchElementException, mr as deferredInterrupt, mt as provideService, n as Boolean$, na as constFalse, ni as set$2, nn as unsafeFork, nr as void_$3, nt as mapError, o as Never, oa as dual, oi as isFinite, on as unsafeMakeSemaphore, or as asVoid, ot as matchEffect, p as decodeUnknown, pi as isChunk, pn as make$18, pr as deferredFailCause, pt as provide$2, q as ignore, qi as match$2, qn as all, qr as DeferredTypeId$1, qt as spaced$1, r as Cause, ra as constTrue, ri as has, rn as unsafeRunPromiseExit, rr as zip$2, rt as match$5, s as Number$, sa as identity, si as append$1, sn as Scope, sr as asyncInterrupt, st as negate, t as Array$, ta as hasProperty, ti as make$17, tn as succeed$3, tr as succeed$5, tt as mapBoth$1, u as TaggedError, ui as empty$4, un as close, ur as deferredAwait, ut as orElse, v as encodeSync, vi as reduce, vn as flipCauseOption, vr as deferredSucceed, vt as retry, w as annotateLogs, wi as unsafeGet, wn as map$6, wr as exitSucceed, wt as scopeWith, x as acquireUseRelease$2, xi as takeRight, xn as isDieType, xr as exitAs, xt as runtime, y as _void, yi as reverse, yn as interrupt$3, yr as deferredUnsafeDone, yt as runPromise, z as flatMap$4, zi as isNone, zn as get$3, zr as sync$1, zt as unsafeMakeLatch } from "./chunks/Schema-B5yK7QrT.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import * as Crypto from "node:crypto";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import * as Path from "node:path";
|
|
9
|
-
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
9
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { PassThrough } from "node:stream";
|
|
11
11
|
import * as NFS from "node:fs";
|
|
12
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
13
|
import * as OS from "node:os";
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import * as ChildProcess from "node:child_process";
|
|
16
|
-
import { execFileSync, execSync } from "node:child_process";
|
|
16
|
+
import { execFileSync, execSync, spawnSync } from "node:child_process";
|
|
17
17
|
import * as NodeUrl from "node:url";
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
19
|
import * as readline from "node:readline";
|
|
@@ -2438,9 +2438,9 @@ const pipeToOrFail = /*#__PURE__*/ dual(2, (self, that) => suspend$3(() => {
|
|
|
2438
2438
|
return pipeTo$1(pipeTo$1(pipeTo$1(self, reader), that), writer);
|
|
2439
2439
|
}));
|
|
2440
2440
|
/** @internal */
|
|
2441
|
-
const run$
|
|
2441
|
+
const run$4 = (self) => scopedWith$1((scope) => runIn(self, scope));
|
|
2442
2442
|
/** @internal */
|
|
2443
|
-
const runDrain = (self) => run$
|
|
2443
|
+
const runDrain = (self) => run$4(drain$2(self));
|
|
2444
2444
|
/** @internal */
|
|
2445
2445
|
const runScoped = (self) => scopeWith((scope) => runIn(self, scope));
|
|
2446
2446
|
/** @internal */
|
|
@@ -3013,7 +3013,7 @@ const map = /*#__PURE__*/ dual(2, (self, f) => new StreamImpl(pipe(toChannel$2(s
|
|
|
3013
3013
|
/** @internal */
|
|
3014
3014
|
const mapChunks$1 = /*#__PURE__*/ dual(2, (self, f) => new StreamImpl(pipe(toChannel$2(self), mapOut(f))));
|
|
3015
3015
|
/** @internal */
|
|
3016
|
-
const mkString$1 = (self) => run$
|
|
3016
|
+
const mkString$1 = (self) => run$3(self, mkString$2);
|
|
3017
3017
|
/** @internal */
|
|
3018
3018
|
const pipeThroughChannel = /*#__PURE__*/ dual(2, (self, channel) => new StreamImpl(pipeTo$1(toChannel$2(self), channel)));
|
|
3019
3019
|
/** @internal */
|
|
@@ -3022,9 +3022,9 @@ const repeatEffectChunkOption = (effect) => unfoldChunkEffect(effect, (effect) =
|
|
|
3022
3022
|
onSome: fail$5
|
|
3023
3023
|
}))));
|
|
3024
3024
|
/** @internal */
|
|
3025
|
-
const run$
|
|
3025
|
+
const run$3 = /*#__PURE__*/ dual(2, (self, sink) => toChannel$2(self).pipe(pipeToOrFail(toChannel$3(sink)), runDrain));
|
|
3026
3026
|
/** @internal */
|
|
3027
|
-
const runCollect$1 = (self) => run$
|
|
3027
|
+
const runCollect$1 = (self) => run$3(self, collectAll());
|
|
3028
3028
|
/** @internal */
|
|
3029
3029
|
const runIntoQueueScoped = /*#__PURE__*/ dual(2, (self, queue) => {
|
|
3030
3030
|
const writer = readWithCause({
|
|
@@ -3764,7 +3764,7 @@ const mkString = mkString$1;
|
|
|
3764
3764
|
* @since 2.0.0
|
|
3765
3765
|
* @category destructors
|
|
3766
3766
|
*/
|
|
3767
|
-
const run$
|
|
3767
|
+
const run$2 = run$3;
|
|
3768
3768
|
/**
|
|
3769
3769
|
* Runs the stream and collects all of its elements to a chunk.
|
|
3770
3770
|
*
|
|
@@ -4726,7 +4726,7 @@ const makeExecutor$1 = (start) => {
|
|
|
4726
4726
|
stream,
|
|
4727
4727
|
string: (command, encoding = "utf-8") => {
|
|
4728
4728
|
const decoder = new TextDecoder(encoding);
|
|
4729
|
-
return pipe(start(command), flatMap$4((process) => run$
|
|
4729
|
+
return pipe(start(command), flatMap$4((process) => run$2(process.stdout, collectUint8Array)), map$4((bytes) => decoder.decode(bytes)), scoped$4);
|
|
4730
4730
|
},
|
|
4731
4731
|
lines: (command, encoding = "utf-8") => {
|
|
4732
4732
|
return pipe(streamLines(command, encoding), runCollect, map$4(toArray));
|
|
@@ -4959,7 +4959,7 @@ const ProcessProto = {
|
|
|
4959
4959
|
};
|
|
4960
4960
|
}
|
|
4961
4961
|
};
|
|
4962
|
-
const runCommand$
|
|
4962
|
+
const runCommand$2 = (fileSystem) => (command) => {
|
|
4963
4963
|
switch (command._tag) {
|
|
4964
4964
|
case "StandardCommand": {
|
|
4965
4965
|
const spawn = flatMap$4(make$15(), (exitCode) => async((resume) => {
|
|
@@ -5031,17 +5031,17 @@ const runCommand$1 = (fileSystem) => (command) => {
|
|
|
5031
5031
|
stderr,
|
|
5032
5032
|
stdout
|
|
5033
5033
|
});
|
|
5034
|
-
}), typeof command.stdin === "string" ? identity : tap((process) => forkDaemon(run$
|
|
5034
|
+
}), typeof command.stdin === "string" ? identity : tap((process) => forkDaemon(run$2(command.stdin, process.stdin))));
|
|
5035
5035
|
}
|
|
5036
5036
|
case "PipedCommand": {
|
|
5037
5037
|
const flattened = flatten(command);
|
|
5038
|
-
if (flattened.length === 1) return pipe(flattened[0], runCommand$
|
|
5038
|
+
if (flattened.length === 1) return pipe(flattened[0], runCommand$2(fileSystem));
|
|
5039
5039
|
const head = flattened[0];
|
|
5040
5040
|
const tail = flattened.slice(1);
|
|
5041
5041
|
const initial = tail.slice(0, tail.length - 1);
|
|
5042
5042
|
const last = tail[tail.length - 1];
|
|
5043
|
-
const stream = initial.reduce((stdin$2, command) => pipe(stdin(command, stdin$2), runCommand$
|
|
5044
|
-
return pipe(stdin(last, stream), runCommand$
|
|
5043
|
+
const stream = initial.reduce((stdin$2, command) => pipe(stdin(command, stdin$2), runCommand$2(fileSystem), map$4((process) => process.stdout), unwrapScoped), pipe(runCommand$2(fileSystem)(head), map$4((process) => process.stdout), unwrapScoped));
|
|
5044
|
+
return pipe(stdin(last, stream), runCommand$2(fileSystem));
|
|
5045
5045
|
}
|
|
5046
5046
|
}
|
|
5047
5047
|
};
|
|
@@ -5051,7 +5051,7 @@ const runCommand$1 = (fileSystem) => (command) => {
|
|
|
5051
5051
|
* @since 1.0.0
|
|
5052
5052
|
* @category layer
|
|
5053
5053
|
*/
|
|
5054
|
-
const layer$5 = /* @__PURE__ */ effect$1(CommandExecutor, /*#__PURE__*/ pipe(FileSystem, /*#__PURE__*/ map$4((fileSystem) => makeExecutor(runCommand$
|
|
5054
|
+
const layer$5 = /* @__PURE__ */ effect$1(CommandExecutor, /*#__PURE__*/ pipe(FileSystem, /*#__PURE__*/ map$4((fileSystem) => makeExecutor(runCommand$2(fileSystem)))));
|
|
5055
5055
|
//#endregion
|
|
5056
5056
|
//#region node_modules/@effect/platform-node-shared/dist/esm/internal/path.js
|
|
5057
5057
|
const fromFileUrl = (url) => try_({
|
|
@@ -5352,7 +5352,7 @@ const classifyUntracked = (exec, ref, untrackedPaths) => gen(function* () {
|
|
|
5352
5352
|
* `git add`. Callers that expect a probe to fail handle it with an explicit
|
|
5353
5353
|
* `catchAll`.
|
|
5354
5354
|
*/
|
|
5355
|
-
const run = (root, ...args) => scoped$4(gen(function* () {
|
|
5355
|
+
const run$1 = (root, ...args) => scoped$4(gen(function* () {
|
|
5356
5356
|
const process = yield* (yield* CommandExecutor).start(make$1(...args).pipe(workingDirectory(root), stdout("pipe"), stderr("pipe")));
|
|
5357
5357
|
const collect = (stream) => stream.pipe(decodeText(), mkString);
|
|
5358
5358
|
const [stdout$3, stderr$3, exitCode] = yield* all$1([
|
|
@@ -5386,10 +5386,19 @@ var InvalidRefError = class extends Error {
|
|
|
5386
5386
|
*/
|
|
5387
5387
|
const resolvedRefOrCorrupted = (ref, hash) => /^[0-9a-f]{40}$/.test(hash) ? succeed$6(hash) : fail$5(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]));
|
|
5388
5388
|
const makeGitImpl = (executor, root) => {
|
|
5389
|
-
const exec = (...args) => run(root, ...args).pipe(provide$2(succeed$1(CommandExecutor, executor)));
|
|
5389
|
+
const exec = (...args) => run$1(root, ...args).pipe(provide$2(succeed$1(CommandExecutor, executor)));
|
|
5390
5390
|
return {
|
|
5391
|
-
lastCommitSubject: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%s", ref).pipe(map$4((s) => s.trim())),
|
|
5392
|
-
lastCommitMessage: () => exec("git", "log", "-1", "--pretty=%B").pipe(map$4((s) => s.trim())),
|
|
5391
|
+
lastCommitSubject: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%s", "--end-of-options", ref).pipe(map$4((s) => s.trim())),
|
|
5392
|
+
lastCommitMessage: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%B", "--end-of-options", ref).pipe(map$4((s) => s.trim())),
|
|
5393
|
+
currentBranch: () => exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(map$4((s) => s.trim()), map$4((name) => some(name)), catchAll$1(() => succeed$6(none()))),
|
|
5394
|
+
listRefs: (prefix) => exec("git", "for-each-ref", "--sort=-committerdate", "--format=%(refname)%09%(objectname)%09%(committerdate:iso-strict)", prefix).pipe(map$4((out) => out.split("\n").filter((line) => line.length > 0).map((line) => {
|
|
5395
|
+
const [ref = "", hash = "", date = ""] = line.split(" ");
|
|
5396
|
+
return {
|
|
5397
|
+
ref,
|
|
5398
|
+
hash,
|
|
5399
|
+
date
|
|
5400
|
+
};
|
|
5401
|
+
}))),
|
|
5393
5402
|
hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
|
|
5394
5403
|
readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
|
|
5395
5404
|
changedPaths: (base) => gen(function* () {
|
|
@@ -5409,9 +5418,9 @@ const makeGitImpl = (executor, root) => {
|
|
|
5409
5418
|
}
|
|
5410
5419
|
return all;
|
|
5411
5420
|
}),
|
|
5412
|
-
resolveRef: (ref) => exec("git", "rev-parse", "--verify", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
|
|
5421
|
+
resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
|
|
5413
5422
|
readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
|
|
5414
|
-
isAncestor: (a, b) => run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$1(CommandExecutor, executor)), map$4(() => true), catchAll$1(() => succeed$6(false))),
|
|
5423
|
+
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))),
|
|
5415
5424
|
mergeBase: (a, b) => exec("git", "merge-base", a, b).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
|
|
5416
5425
|
defaultBranch: () => exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(map$4((s) => s.trim()), catchAll$1(() => succeed$6("")), map$4((name) => name === "" ? "main" : name)),
|
|
5417
5426
|
topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(map$4((s) => s.trim())),
|
|
@@ -6059,6 +6068,9 @@ const loopLogPath = gen(function* () {
|
|
|
6059
6068
|
//#region src/platform/RetainedHistory.ts
|
|
6060
6069
|
/** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
|
|
6061
6070
|
const HISTORY_REF = "refs/worktree/gtd/history";
|
|
6071
|
+
/** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
|
|
6072
|
+
const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
|
|
6073
|
+
const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
|
|
6062
6074
|
const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
|
|
6063
6075
|
/**
|
|
6064
6076
|
* Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
|
|
@@ -6855,6 +6867,12 @@ const TRAILER_READERS = {
|
|
|
6855
6867
|
"Gtd-Review-Base": (value, into) => {
|
|
6856
6868
|
into.reviewBase ??= value.split(/\s/)[0];
|
|
6857
6869
|
},
|
|
6870
|
+
"Gtd-Branch": (value, into) => {
|
|
6871
|
+
if (value !== "") into.branch ??= value;
|
|
6872
|
+
},
|
|
6873
|
+
"Gtd-Version": (value, into) => {
|
|
6874
|
+
if (value !== "") into.version ??= value;
|
|
6875
|
+
},
|
|
6858
6876
|
"Gtd-Format": (value, into) => {
|
|
6859
6877
|
if (FORMAT_RE.test(value)) into.format ??= Number(value);
|
|
6860
6878
|
},
|
|
@@ -6873,6 +6891,8 @@ const parseCommitMessage = (message) => {
|
|
|
6873
6891
|
step: void 0,
|
|
6874
6892
|
workflow: void 0,
|
|
6875
6893
|
reviewBase: void 0,
|
|
6894
|
+
branch: void 0,
|
|
6895
|
+
version: void 0,
|
|
6876
6896
|
judge: [],
|
|
6877
6897
|
vars: {},
|
|
6878
6898
|
config: {},
|
|
@@ -6898,6 +6918,8 @@ const formatCommitMessage = (spec) => {
|
|
|
6898
6918
|
for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
|
|
6899
6919
|
if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
|
|
6900
6920
|
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
|
|
6921
|
+
if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
|
|
6922
|
+
if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
|
|
6901
6923
|
lines.push(`Gtd-Format: 1`);
|
|
6902
6924
|
return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
|
|
6903
6925
|
};
|
|
@@ -7481,6 +7503,30 @@ var ConfigDiscovery = class ConfigDiscovery extends Tag("ConfigDiscovery")() {
|
|
|
7481
7503
|
});
|
|
7482
7504
|
};
|
|
7483
7505
|
//#endregion
|
|
7506
|
+
//#region src/workflow/fingerprint.ts
|
|
7507
|
+
const HASH_LENGTH = 16;
|
|
7508
|
+
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/;
|
|
7509
|
+
const contentHash = (content) => createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
|
|
7510
|
+
const realOr = (path) => {
|
|
7511
|
+
try {
|
|
7512
|
+
return realpathSync(path);
|
|
7513
|
+
} catch {
|
|
7514
|
+
return path;
|
|
7515
|
+
}
|
|
7516
|
+
};
|
|
7517
|
+
const configFingerprint = (files, root, read) => {
|
|
7518
|
+
const fingerprint = {};
|
|
7519
|
+
const realRoot = realOr(root);
|
|
7520
|
+
for (const file of files) {
|
|
7521
|
+
const path = relative(realRoot, realOr(file)).split(sep).join("/");
|
|
7522
|
+
if (CONTROL_CHARACTER.test(path)) continue;
|
|
7523
|
+
const content = read(file);
|
|
7524
|
+
if (content !== void 0) fingerprint[path] = contentHash(content);
|
|
7525
|
+
}
|
|
7526
|
+
return fingerprint;
|
|
7527
|
+
};
|
|
7528
|
+
const changedConfigFiles = (recorded, current) => [.../* @__PURE__ */ new Set([...Object.keys(recorded), ...Object.keys(current)])].filter((path) => recorded[path] !== current[path]).sort();
|
|
7529
|
+
//#endregion
|
|
7484
7530
|
//#region src/workflow/Diagnostic.ts
|
|
7485
7531
|
const compareSegment = (a, b) => {
|
|
7486
7532
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
@@ -7615,9 +7661,16 @@ const load = gen(function* () {
|
|
|
7615
7661
|
},
|
|
7616
7662
|
...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
|
|
7617
7663
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
7618
|
-
|
|
7664
|
+
fingerprint: configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk)
|
|
7619
7665
|
};
|
|
7620
7666
|
});
|
|
7667
|
+
const readFromDisk = (path) => {
|
|
7668
|
+
try {
|
|
7669
|
+
return readFileSync(path, "utf8");
|
|
7670
|
+
} catch {
|
|
7671
|
+
return;
|
|
7672
|
+
}
|
|
7673
|
+
};
|
|
7621
7674
|
const TRANSFORM_MEMO_SIZE = 512;
|
|
7622
7675
|
const memoized = (transform) => {
|
|
7623
7676
|
const memo = /* @__PURE__ */ new Map();
|
|
@@ -7634,12 +7687,18 @@ const memoized = (transform) => {
|
|
|
7634
7687
|
};
|
|
7635
7688
|
let jitiModule;
|
|
7636
7689
|
let transform;
|
|
7637
|
-
const jiti = (shipped) => {
|
|
7690
|
+
const jiti = (shipped, local) => {
|
|
7638
7691
|
const require = createRequire(import.meta.url);
|
|
7639
7692
|
jitiModule ??= require("jiti");
|
|
7640
7693
|
transform ??= memoized(require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")));
|
|
7641
7694
|
return jitiModule.createJiti(import.meta.url, {
|
|
7642
|
-
virtualModules: shipped.modules,
|
|
7695
|
+
virtualModules: new Proxy(shipped.modules, {
|
|
7696
|
+
has: (modules, id) => id in modules || typeof id === "string" && local?.resolve(id) !== void 0,
|
|
7697
|
+
get: (modules, id) => {
|
|
7698
|
+
const path = typeof id === "string" ? local?.resolve(id) : void 0;
|
|
7699
|
+
return path === void 0 ? modules[id] : local.load(path);
|
|
7700
|
+
}
|
|
7701
|
+
}),
|
|
7643
7702
|
alias: shipped.missing,
|
|
7644
7703
|
fsCache: false,
|
|
7645
7704
|
moduleCache: false,
|
|
@@ -7836,12 +7895,9 @@ const namespaceOf = (exported) => typeof exported === "object" && exported !== n
|
|
|
7836
7895
|
* pulled in, transitively.
|
|
7837
7896
|
*/
|
|
7838
7897
|
const loadWorkflows = (module, shipped) => {
|
|
7839
|
-
const
|
|
7840
|
-
const exports = namespaceOf(
|
|
7841
|
-
|
|
7842
|
-
cache
|
|
7843
|
-
}));
|
|
7844
|
-
const file = fileFrom(exports, module.filepath, Object.keys(cache));
|
|
7898
|
+
const { exports: evaluated, files } = module.read === void 0 ? evaluateFromDisk(module, shipped) : evaluateRead(module, module.read, shipped);
|
|
7899
|
+
const exports = namespaceOf(evaluated);
|
|
7900
|
+
const file = fileFrom(exports, module.filepath, files);
|
|
7845
7901
|
const exported = Object.entries(exports).filter(([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value));
|
|
7846
7902
|
const value = defaultExport(exports);
|
|
7847
7903
|
const defaultName = exported.find(([, other]) => other === value)?.[0] ?? "default";
|
|
@@ -7865,6 +7921,49 @@ const loadWorkflows = (module, shipped) => {
|
|
|
7865
7921
|
diagnostics: nameRejections(rejections)
|
|
7866
7922
|
};
|
|
7867
7923
|
};
|
|
7924
|
+
const evaluateFromDisk = (module, shipped) => {
|
|
7925
|
+
const cache = {};
|
|
7926
|
+
return {
|
|
7927
|
+
exports: jiti(shipped).evalModule(module.source, {
|
|
7928
|
+
filename: module.filepath,
|
|
7929
|
+
cache
|
|
7930
|
+
}),
|
|
7931
|
+
files: Object.keys(cache)
|
|
7932
|
+
};
|
|
7933
|
+
};
|
|
7934
|
+
const moduleCandidates = (path) => [
|
|
7935
|
+
path,
|
|
7936
|
+
path.replace(/\.js$/, ".ts"),
|
|
7937
|
+
path.replace(/\.mjs$/, ".mts"),
|
|
7938
|
+
`${path}.ts`,
|
|
7939
|
+
join(path, "index.ts")
|
|
7940
|
+
];
|
|
7941
|
+
/**
|
|
7942
|
+
* Evaluates the config with every local module it imports taken from `read`
|
|
7943
|
+
* instead of the disk. jiti matches a virtual module on the specifier as
|
|
7944
|
+
* written, so each module gets its own instance resolving against its own
|
|
7945
|
+
* directory. A specifier `read` cannot serve falls through to the disk, and
|
|
7946
|
+
* fails the way a missing file does.
|
|
7947
|
+
*/
|
|
7948
|
+
const evaluateRead = (module, read, shipped) => {
|
|
7949
|
+
const files = [module.filepath];
|
|
7950
|
+
const evaluated = /* @__PURE__ */ new Map();
|
|
7951
|
+
const evaluate = (filepath, source) => jiti(shipped, {
|
|
7952
|
+
resolve: (specifier) => /^\.\.?\//.test(specifier) ? moduleCandidates(join(dirname(filepath), specifier)).find((candidate) => read(candidate) !== void 0) : void 0,
|
|
7953
|
+
load: (path) => {
|
|
7954
|
+
if (!evaluated.has(path)) {
|
|
7955
|
+
evaluated.set(path, {});
|
|
7956
|
+
files.push(path);
|
|
7957
|
+
evaluated.set(path, evaluate(path, read(path)));
|
|
7958
|
+
}
|
|
7959
|
+
return evaluated.get(path);
|
|
7960
|
+
}
|
|
7961
|
+
}).evalModule(source, { filename: filepath });
|
|
7962
|
+
return {
|
|
7963
|
+
exports: evaluate(module.filepath, module.source),
|
|
7964
|
+
files
|
|
7965
|
+
};
|
|
7966
|
+
};
|
|
7868
7967
|
const isWorkflow = (value) => typeof value === "function" || isFactoryWorkflow(value);
|
|
7869
7968
|
const defaultExport = (exports) => {
|
|
7870
7969
|
const value = exports.default;
|
|
@@ -8092,35 +8191,6 @@ const resolveScopeAccess = (def, skills, vars) => gen(function* () {
|
|
|
8092
8191
|
//#region src/workflow/Workflow.ts
|
|
8093
8192
|
const knownModes = (def) => Object.keys(def.modes);
|
|
8094
8193
|
//#endregion
|
|
8095
|
-
//#region src/workflow/fingerprint.ts
|
|
8096
|
-
const HASH_LENGTH = 16;
|
|
8097
|
-
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/;
|
|
8098
|
-
const contentHash = (content) => createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
|
|
8099
|
-
const realOr = (path) => {
|
|
8100
|
-
try {
|
|
8101
|
-
return realpathSync(path);
|
|
8102
|
-
} catch {
|
|
8103
|
-
return path;
|
|
8104
|
-
}
|
|
8105
|
-
};
|
|
8106
|
-
const configFingerprint = (files, root) => {
|
|
8107
|
-
const fingerprint = {};
|
|
8108
|
-
const realRoot = realOr(root);
|
|
8109
|
-
for (const file of files) {
|
|
8110
|
-
const path = relative(realRoot, realOr(file)).split(sep).join("/");
|
|
8111
|
-
if (CONTROL_CHARACTER.test(path)) continue;
|
|
8112
|
-
let content;
|
|
8113
|
-
try {
|
|
8114
|
-
content = readFileSync(file);
|
|
8115
|
-
} catch {
|
|
8116
|
-
continue;
|
|
8117
|
-
}
|
|
8118
|
-
fingerprint[path] = contentHash(content);
|
|
8119
|
-
}
|
|
8120
|
-
return fingerprint;
|
|
8121
|
-
};
|
|
8122
|
-
const changedConfigFiles = (recorded, current) => [.../* @__PURE__ */ new Set([...Object.keys(recorded), ...Object.keys(current)])].filter((path) => recorded[path] !== current[path]).sort();
|
|
8123
|
-
//#endregion
|
|
8124
8194
|
//#region src/step/planStep.ts
|
|
8125
8195
|
/**
|
|
8126
8196
|
* At the human review gate an unconditional `gtd uncheck '<file>'` runs
|
|
@@ -8206,6 +8276,14 @@ const planStep = (snapshot, opts = {}) => {
|
|
|
8206
8276
|
message
|
|
8207
8277
|
}
|
|
8208
8278
|
},
|
|
8279
|
+
...landing.keepHistory !== void 0 ? [{
|
|
8280
|
+
kind: "gitWrite",
|
|
8281
|
+
write: {
|
|
8282
|
+
kind: "updateRef",
|
|
8283
|
+
ref: landing.keepHistory,
|
|
8284
|
+
target: "HEAD"
|
|
8285
|
+
}
|
|
8286
|
+
}] : [],
|
|
8209
8287
|
outcome
|
|
8210
8288
|
]
|
|
8211
8289
|
};
|
|
@@ -8513,25 +8591,38 @@ const currentRun = gen(function* () {
|
|
|
8513
8591
|
const config = yield* (yield* ConfigService).load;
|
|
8514
8592
|
return yield* computeProcessRun(git, config.workflow);
|
|
8515
8593
|
});
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
const git = yield* GitService;
|
|
8519
|
-
const def = (yield* (yield* ConfigService).load).workflow;
|
|
8520
|
-
const headEntry = (yield* git.subjectHistory(1, 0))[0];
|
|
8521
|
-
if (headEntry === void 0) {
|
|
8594
|
+
const runAtTip = (git, def, tip) => gen(function* () {
|
|
8595
|
+
if (tip === void 0) {
|
|
8522
8596
|
const history = yield* historyUpTo(git, def, void 0);
|
|
8523
8597
|
return yield* runOf(history, locateEpisode(def, history));
|
|
8524
8598
|
}
|
|
8525
|
-
if (!(parseCommitMessage(
|
|
8526
|
-
const history = yield* historyUpTo(git, def,
|
|
8599
|
+
if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
|
|
8600
|
+
const history = yield* historyUpTo(git, def, tip.hash);
|
|
8527
8601
|
return yield* runOf(history, locateEpisode(def, history));
|
|
8528
8602
|
}
|
|
8529
|
-
const parentRef = `${
|
|
8603
|
+
const parentRef = `${tip.hash}~1`;
|
|
8530
8604
|
const before = yield* historyUpTo(git, def, parentRef);
|
|
8531
8605
|
const location = locateEpisode(def, before);
|
|
8532
|
-
let closing = yield* git.commitHistory(parentRef,
|
|
8533
|
-
if (closing.length === 0) closing = yield* git.commitHistory(void 0,
|
|
8534
|
-
return yield* runOf([...before, ...closing], { ...location },
|
|
8606
|
+
let closing = yield* git.commitHistory(parentRef, tip.hash);
|
|
8607
|
+
if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
|
|
8608
|
+
return yield* runOf([...before, ...closing], { ...location }, tip.hash);
|
|
8609
|
+
});
|
|
8610
|
+
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
8611
|
+
const summaryRun = gen(function* () {
|
|
8612
|
+
const git = yield* GitService;
|
|
8613
|
+
const def = (yield* (yield* ConfigService).load).workflow;
|
|
8614
|
+
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
|
|
8615
|
+
});
|
|
8616
|
+
/** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
|
|
8617
|
+
const closedRunAt = (tip) => gen(function* () {
|
|
8618
|
+
const git = yield* GitService;
|
|
8619
|
+
const def = (yield* (yield* ConfigService).load).workflow;
|
|
8620
|
+
const hash = yield* git.resolveRef(tip);
|
|
8621
|
+
const subject = yield* git.lastCommitSubject(hash);
|
|
8622
|
+
return yield* runAtTip(git, def, {
|
|
8623
|
+
hash,
|
|
8624
|
+
subject
|
|
8625
|
+
});
|
|
8535
8626
|
});
|
|
8536
8627
|
const commitTree = (workspace, hash) => {
|
|
8537
8628
|
let entries;
|
|
@@ -8751,13 +8842,7 @@ const costByModel = (entries) => {
|
|
|
8751
8842
|
cost
|
|
8752
8843
|
})).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
|
|
8753
8844
|
};
|
|
8754
|
-
|
|
8755
|
-
const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
8756
|
-
const git = yield* GitService;
|
|
8757
|
-
const config = yield* (yield* ConfigService).load;
|
|
8758
|
-
const workspace = yield* Workspace;
|
|
8759
|
-
const host = yield* Host;
|
|
8760
|
-
const run = yield* computeProcessRun(git, config.workflow, ref);
|
|
8845
|
+
const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
|
|
8761
8846
|
const resolved = yield* workflowOf(config, run);
|
|
8762
8847
|
const def = resolved.def;
|
|
8763
8848
|
const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides);
|
|
@@ -8781,19 +8866,34 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
|
8781
8866
|
tree: commitTree(workspace, c.hash)
|
|
8782
8867
|
}));
|
|
8783
8868
|
let pendingMemo;
|
|
8784
|
-
|
|
8869
|
+
return {
|
|
8870
|
+
resolved,
|
|
8785
8871
|
def,
|
|
8786
|
-
run,
|
|
8787
8872
|
vars,
|
|
8788
8873
|
env,
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
|
|
8794
|
-
|
|
8795
|
-
|
|
8874
|
+
setup: {
|
|
8875
|
+
def,
|
|
8876
|
+
run,
|
|
8877
|
+
vars,
|
|
8878
|
+
env,
|
|
8879
|
+
budget,
|
|
8880
|
+
skills,
|
|
8881
|
+
access,
|
|
8882
|
+
workspace,
|
|
8883
|
+
base,
|
|
8884
|
+
episode,
|
|
8885
|
+
pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
|
|
8886
|
+
}
|
|
8796
8887
|
};
|
|
8888
|
+
});
|
|
8889
|
+
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
8890
|
+
const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
8891
|
+
const git = yield* GitService;
|
|
8892
|
+
const config = yield* (yield* ConfigService).load;
|
|
8893
|
+
const workspace = yield* Workspace;
|
|
8894
|
+
const host = yield* Host;
|
|
8895
|
+
const run = yield* computeProcessRun(git, config.workflow, ref);
|
|
8896
|
+
const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
|
|
8797
8897
|
const outcome = yield* promise(() => replayFor(setup));
|
|
8798
8898
|
const error = replayError(outcome);
|
|
8799
8899
|
if (error !== void 0 || outcome.kind !== "rest") return yield* fail$5(error ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
|
|
@@ -8806,7 +8906,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
|
8806
8906
|
path: e.path
|
|
8807
8907
|
})) : [];
|
|
8808
8908
|
const memory = memoryOf(outcome.trace, run);
|
|
8809
|
-
const configNow =
|
|
8909
|
+
const configNow = config.fingerprint;
|
|
8810
8910
|
if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
|
|
8811
8911
|
return {
|
|
8812
8912
|
def,
|
|
@@ -8935,14 +9035,21 @@ const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && s
|
|
|
8935
9035
|
kind: "refusal",
|
|
8936
9036
|
message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
|
|
8937
9037
|
} : void 0;
|
|
8938
|
-
const
|
|
8939
|
-
|
|
9038
|
+
const closingTrailers = (branch) => ({
|
|
9039
|
+
version: GTD_VERSION,
|
|
9040
|
+
...isSome(branch) ? { branch: branch.value } : {}
|
|
9041
|
+
});
|
|
9042
|
+
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
9043
|
+
const landingTo = (rest, to, branch) => {
|
|
9044
|
+
if (isSettledNoop(rest, to)) return {
|
|
8940
9045
|
kind: "noop",
|
|
8941
9046
|
settled: true
|
|
8942
9047
|
};
|
|
8943
|
-
const
|
|
9048
|
+
const underway = !noProcessUnderway(rest);
|
|
9049
|
+
const starts = !underway && to !== rest.def.initial;
|
|
8944
9050
|
const refused = starts ? multilineRefusal(rest.vars) : void 0;
|
|
8945
9051
|
if (refused !== void 0) return refused;
|
|
9052
|
+
const finishes = underway && to === rest.def.initial;
|
|
8946
9053
|
return {
|
|
8947
9054
|
kind: "commit",
|
|
8948
9055
|
to,
|
|
@@ -8954,8 +9061,10 @@ const landingTo = (rest, to) => {
|
|
|
8954
9061
|
...starts ? {
|
|
8955
9062
|
vars: rest.vars,
|
|
8956
9063
|
config: rest.config
|
|
8957
|
-
} : {}
|
|
8958
|
-
|
|
9064
|
+
} : {},
|
|
9065
|
+
...finishes ? closingTrailers(branch) : {}
|
|
9066
|
+
},
|
|
9067
|
+
...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
|
|
8959
9068
|
};
|
|
8960
9069
|
};
|
|
8961
9070
|
/**
|
|
@@ -8963,7 +9072,7 @@ const landingTo = (rest, to) => {
|
|
|
8963
9072
|
* replay with the working tree as the pending turn. The target step is
|
|
8964
9073
|
* computed by replaying, never matched.
|
|
8965
9074
|
*/
|
|
8966
|
-
const decideLanding = (rest, verdicts) => gen(function* () {
|
|
9075
|
+
const decideLanding = (rest, verdicts, branch) => gen(function* () {
|
|
8967
9076
|
const early = cleanLanding(rest);
|
|
8968
9077
|
if (early !== void 0) return early;
|
|
8969
9078
|
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
@@ -8979,13 +9088,14 @@ const decideLanding = (rest, verdicts) => gen(function* () {
|
|
|
8979
9088
|
}));
|
|
8980
9089
|
const to = yield* landingTarget(outcome, rest);
|
|
8981
9090
|
if (typeof to !== "string") return to;
|
|
8982
|
-
return landingTo(rest, to);
|
|
9091
|
+
return landingTo(rest, to, branch);
|
|
8983
9092
|
});
|
|
8984
9093
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
8985
|
-
const previewLanding = (rest) => decideLanding(rest, void 0).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
|
|
9094
|
+
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)));
|
|
8986
9095
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
8987
9096
|
const snapshotFromRest = (rest, verdicts) => gen(function* () {
|
|
8988
|
-
const
|
|
9097
|
+
const branch = yield* (yield* GitService).currentBranch();
|
|
9098
|
+
const landing = yield* decideLanding(rest, verdicts, branch);
|
|
8989
9099
|
return {
|
|
8990
9100
|
stepDef: rest.stepDef,
|
|
8991
9101
|
state: rest.state,
|
|
@@ -9016,6 +9126,66 @@ const summaryFor = (run) => gen(function* () {
|
|
|
9016
9126
|
...settingsFor(resolved, run.pinnedVars, host.env)
|
|
9017
9127
|
});
|
|
9018
9128
|
});
|
|
9129
|
+
/** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
|
|
9130
|
+
const inRepoConfigPaths = (root, files, recorded) => {
|
|
9131
|
+
const paths = new Set(Object.keys(recorded));
|
|
9132
|
+
for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
|
|
9133
|
+
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
|
|
9134
|
+
};
|
|
9135
|
+
const configRefusal = (tip, paths) => /* @__PURE__ */ new Error(`gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. A history ref replays under today's config, so it is unreadable after the config changes`);
|
|
9136
|
+
const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
|
|
9137
|
+
const differing = [];
|
|
9138
|
+
for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint), run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
|
|
9139
|
+
return differing;
|
|
9140
|
+
});
|
|
9141
|
+
const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
|
|
9142
|
+
const historySteps = (run) => {
|
|
9143
|
+
const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
|
|
9144
|
+
return run.trace.map((entry) => {
|
|
9145
|
+
const message = parseCommitMessage(messages.get(entry.hash) ?? "");
|
|
9146
|
+
return {
|
|
9147
|
+
hash: entry.hash,
|
|
9148
|
+
subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
|
|
9149
|
+
actor: entry.actor,
|
|
9150
|
+
from: message.parsed?.from,
|
|
9151
|
+
to: entry.state,
|
|
9152
|
+
verdicts: message.judge
|
|
9153
|
+
};
|
|
9154
|
+
});
|
|
9155
|
+
};
|
|
9156
|
+
const versionWarningFor = (recorded) => recorded === GTD_VERSION ? void 0 : `gtd: warning: this history was recorded by ${recorded === void 0 ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — a workflow imported from @pmelab/gtd/workflows may have changed since`;
|
|
9157
|
+
/**
|
|
9158
|
+
* Replays the closed run at `tip` under today's config. The workflow is not
|
|
9159
|
+
* fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
|
|
9160
|
+
* moves with every upgrade, and the version warning is the only signal.
|
|
9161
|
+
*/
|
|
9162
|
+
const historyTrail = (tip) => gen(function* () {
|
|
9163
|
+
const git = yield* GitService;
|
|
9164
|
+
const config = yield* (yield* ConfigService).load;
|
|
9165
|
+
const workspace = yield* Workspace;
|
|
9166
|
+
const host = yield* Host;
|
|
9167
|
+
const hash = yield* git.resolveRef(tip);
|
|
9168
|
+
const run = yield* closedRunAt(hash);
|
|
9169
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
|
|
9170
|
+
if (differing.length > 0) return yield* fail$5(configRefusal(hash, differing));
|
|
9171
|
+
const { setup } = yield* setupFor(config, run, workspace, host);
|
|
9172
|
+
yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
|
|
9173
|
+
const outcome = yield* promise(() => replayFor(setup));
|
|
9174
|
+
if (outcome.kind !== "ended") return yield* fail$5(notClosedError(hash, outcome));
|
|
9175
|
+
const steps = historySteps(run);
|
|
9176
|
+
const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version;
|
|
9177
|
+
const versionWarning = versionWarningFor(recorded);
|
|
9178
|
+
return {
|
|
9179
|
+
tip: hash,
|
|
9180
|
+
steps,
|
|
9181
|
+
ended: outcome.via,
|
|
9182
|
+
version: {
|
|
9183
|
+
recorded,
|
|
9184
|
+
running: GTD_VERSION
|
|
9185
|
+
},
|
|
9186
|
+
...versionWarning !== void 0 ? { versionWarning } : {}
|
|
9187
|
+
};
|
|
9188
|
+
});
|
|
9019
9189
|
//#endregion
|
|
9020
9190
|
//#region src/emit/GitScript.ts
|
|
9021
9191
|
const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
@@ -9037,8 +9207,8 @@ const commitAllowEmpty = (message) => {
|
|
|
9037
9207
|
`fi`
|
|
9038
9208
|
].join("\n");
|
|
9039
9209
|
};
|
|
9040
|
-
/** `git add -A
|
|
9041
|
-
const commitAll = (message) => `git add -A
|
|
9210
|
+
/** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
|
|
9211
|
+
const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
|
|
9042
9212
|
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
9043
9213
|
const commitAsIs = (message) => commitAllowEmpty(message);
|
|
9044
9214
|
/** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
|
|
@@ -9056,6 +9226,17 @@ const discardPending = () => `git add -A && git reset --hard HEAD`;
|
|
|
9056
9226
|
const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
|
|
9057
9227
|
/** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
|
|
9058
9228
|
const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
|
|
9229
|
+
/**
|
|
9230
|
+
* No `+` on the refspec: a local ref the remote's tip does not descend from
|
|
9231
|
+
* fails the fetch instead of being overwritten (a fast-forward still applies).
|
|
9232
|
+
*/
|
|
9233
|
+
const fetchHistoryRefs = (remote) => [
|
|
9234
|
+
`set -eu`,
|
|
9235
|
+
`before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
|
|
9236
|
+
`git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
|
|
9237
|
+
`after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
|
|
9238
|
+
`printf '%s history refs arrived\\n' "$((after - before))"`
|
|
9239
|
+
].join("\n");
|
|
9059
9240
|
/** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/testkit/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
|
|
9060
9241
|
const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
|
|
9061
9242
|
const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
|
|
@@ -9073,6 +9254,7 @@ const renderOutcome = (outcome) => {
|
|
|
9073
9254
|
const renderGitWrite = (write) => {
|
|
9074
9255
|
switch (write.kind) {
|
|
9075
9256
|
case "commitAll": return commitAll(write.message);
|
|
9257
|
+
case "updateRef": return updateRef(write.ref, write.target);
|
|
9076
9258
|
}
|
|
9077
9259
|
};
|
|
9078
9260
|
/**
|
|
@@ -9440,7 +9622,7 @@ const validateScriptFor = (resolved, file) => gen(function* () {
|
|
|
9440
9622
|
*/
|
|
9441
9623
|
var ToolCommands = class extends Tag("ToolCommands")() {};
|
|
9442
9624
|
//#endregion
|
|
9443
|
-
//#region src/cli/
|
|
9625
|
+
//#region src/cli/Output.ts
|
|
9444
9626
|
/**
|
|
9445
9627
|
* A command-level failure that should exit like a CLI usage error (2), not a
|
|
9446
9628
|
* generic runtime failure (1): an unknown `--json=<path>` selector, or a
|
|
@@ -9450,12 +9632,92 @@ var ToolCommands = class extends Tag("ToolCommands")() {};
|
|
|
9450
9632
|
* about the resolved rest's own state.
|
|
9451
9633
|
*/
|
|
9452
9634
|
var SelectorUsageError = class extends Error {};
|
|
9453
|
-
/** The `--json=<path>` select branch, shared by
|
|
9635
|
+
/** The `--json=<path>` select branch, shared by the emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
|
|
9454
9636
|
const writeSelection = (out, fields, path) => gen(function* () {
|
|
9455
9637
|
const selection = selectPath(fields, path);
|
|
9456
9638
|
if (selection.kind === "value") out.write(`${selection.text}\n`);
|
|
9457
9639
|
else if (selection.kind === "unknown") return yield* fail$5(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
|
|
9458
9640
|
});
|
|
9641
|
+
//#endregion
|
|
9642
|
+
//#region src/cli/History.ts
|
|
9643
|
+
/** Newest tip first. */
|
|
9644
|
+
const historyEntries = gen(function* () {
|
|
9645
|
+
const git = yield* GitService;
|
|
9646
|
+
return yield* forEach$2((yield* git.listRefs(PROCESS_HISTORY_PREFIX)).filter(({ ref }) => ENTRY_HASH.test(ref.slice(17))), ({ ref, hash, date }) => gen(function* () {
|
|
9647
|
+
const entry = ref.slice(17);
|
|
9648
|
+
return {
|
|
9649
|
+
ref,
|
|
9650
|
+
entry,
|
|
9651
|
+
branch: parseCommitMessage(yield* git.lastCommitMessage(hash)).branch ?? null,
|
|
9652
|
+
tip: hash,
|
|
9653
|
+
date,
|
|
9654
|
+
subject: yield* git.lastCommitSubject(entry)
|
|
9655
|
+
};
|
|
9656
|
+
}));
|
|
9657
|
+
});
|
|
9658
|
+
const ENTRY_HASH = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
9659
|
+
const HEX = /^[0-9a-f]+$/i;
|
|
9660
|
+
/**
|
|
9661
|
+
* Lookup order: full ref; then entry hash or hex prefix (unique → it, several
|
|
9662
|
+
* → fail listing them); then branch name (newest tip first, as `entries` is
|
|
9663
|
+
* ordered). A hex-looking name that matches no entry still resolves as a
|
|
9664
|
+
* branch. No match → fail.
|
|
9665
|
+
*/
|
|
9666
|
+
const resolveHistoryRef = (arg, entries) => {
|
|
9667
|
+
const byRef = entries.find((e) => e.ref === arg);
|
|
9668
|
+
if (byRef !== void 0) return succeed$6(byRef);
|
|
9669
|
+
if (HEX.test(arg)) {
|
|
9670
|
+
const lower = arg.toLowerCase();
|
|
9671
|
+
const matches = entries.filter((e) => e.entry.toLowerCase().startsWith(lower));
|
|
9672
|
+
if (matches.length === 1) return succeed$6(matches[0]);
|
|
9673
|
+
if (matches.length > 1) return fail$5(/* @__PURE__ */ new Error(`gtd history: "${arg}" is ambiguous — it matches:\n${matches.map((e) => ` ${e.entry} ${e.branch ?? "-"}`).join("\n")}`));
|
|
9674
|
+
}
|
|
9675
|
+
const byBranch = entries.find((e) => e.branch === arg);
|
|
9676
|
+
if (byBranch !== void 0) return succeed$6(byBranch);
|
|
9677
|
+
return fail$5(/* @__PURE__ */ new Error(`gtd history: no history ref matches "${arg}"`));
|
|
9678
|
+
};
|
|
9679
|
+
const seven = (hash) => hash.slice(0, 7);
|
|
9680
|
+
/** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
|
|
9681
|
+
const writeJson = (json, out, document) => gen(function* () {
|
|
9682
|
+
if (json.kind === "off") return false;
|
|
9683
|
+
if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`);
|
|
9684
|
+
else yield* writeSelection(out, document, json.path);
|
|
9685
|
+
return true;
|
|
9686
|
+
});
|
|
9687
|
+
const runHistoryListCommand = (json, out) => gen(function* () {
|
|
9688
|
+
const entries = yield* historyEntries;
|
|
9689
|
+
if (yield* writeJson(json, out, entries)) return;
|
|
9690
|
+
if (entries.length === 0) {
|
|
9691
|
+
out.write("no history refs; run gtd history fetch | sh to pull them\n");
|
|
9692
|
+
return;
|
|
9693
|
+
}
|
|
9694
|
+
out.write(entries.map((e) => `${seven(e.entry)} ${e.branch ?? "-"} ${seven(e.tip)} ${e.date} ${e.subject}\n`).join(""));
|
|
9695
|
+
});
|
|
9696
|
+
const runHistoryShowCommand = (ref, json, out) => gen(function* () {
|
|
9697
|
+
const entry = yield* resolveHistoryRef(ref, yield* historyEntries);
|
|
9698
|
+
const trail = yield* historyTrail(entry.tip);
|
|
9699
|
+
const document = {
|
|
9700
|
+
ref: entry.ref,
|
|
9701
|
+
entry: entry.entry,
|
|
9702
|
+
branch: entry.branch,
|
|
9703
|
+
...trail
|
|
9704
|
+
};
|
|
9705
|
+
if (yield* writeJson(json, out, document)) return;
|
|
9706
|
+
if (trail.versionWarning !== void 0) yield* (yield* Narrator).warn(trail.versionWarning);
|
|
9707
|
+
const lines = [`${entry.ref} (branch ${entry.branch ?? "-"}, tip ${seven(entry.tip)})`];
|
|
9708
|
+
for (const step of trail.steps) {
|
|
9709
|
+
lines.push(`${seven(step.hash)} ${step.subject}`);
|
|
9710
|
+
for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
|
|
9711
|
+
}
|
|
9712
|
+
lines.push(`ended (${trail.ended})`);
|
|
9713
|
+
out.write(`${lines.join("\n")}\n`);
|
|
9714
|
+
});
|
|
9715
|
+
/** Prints the script and never runs git: gtd's promise that it only decides and prints. */
|
|
9716
|
+
const runHistoryFetchCommand = (remote, out) => sync$2(() => {
|
|
9717
|
+
out.write(`${combinedScript(fetchHistoryRefs(remote), "")}\n`);
|
|
9718
|
+
});
|
|
9719
|
+
//#endregion
|
|
9720
|
+
//#region src/cli/Program.ts
|
|
9459
9721
|
const selfGtd = () => {
|
|
9460
9722
|
const script = process.argv[1];
|
|
9461
9723
|
return script === void 0 ? ["gtd"] : [process.execPath, script];
|
|
@@ -9865,12 +10127,6 @@ const runValidateCommand = (out) => gen(function* () {
|
|
|
9865
10127
|
const resolved = yield* resolveValidateScript(rest);
|
|
9866
10128
|
out.write(resolved !== void 0 && resolved.script.length > 0 ? `${resolved.script}\n` : `nothing to validate at "${rest.state}"\n`);
|
|
9867
10129
|
});
|
|
9868
|
-
/** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
|
|
9869
|
-
const formatFinding = (file, finding) => {
|
|
9870
|
-
if (finding.line === void 0) return finding.message;
|
|
9871
|
-
const col = finding.range !== void 0 ? `:${finding.range.start.character + 1}` : "";
|
|
9872
|
-
return `${file}:${finding.line + 1}${col}: ${finding.message}`;
|
|
9873
|
-
};
|
|
9874
10130
|
/**
|
|
9875
10131
|
* Runs `<mode>`'s parser over `<file>`, exiting non-zero on any finding.
|
|
9876
10132
|
* Resolves no workflow state and reads no config — this is what a seeded
|
|
@@ -10074,6 +10330,9 @@ const dispatchVoidCommand = (command, json, out) => {
|
|
|
10074
10330
|
case "judge": return runJudgeCommand(json, out);
|
|
10075
10331
|
case "judgeAnswer": return runJudgeAnswerCommand(json, out);
|
|
10076
10332
|
case "judgeRun": return runJudgeRunCommand(command, out);
|
|
10333
|
+
case "historyList": return runHistoryListCommand(json, out);
|
|
10334
|
+
case "historyShow": return runHistoryShowCommand(command.ref, json, out);
|
|
10335
|
+
case "historyFetch": return runHistoryFetchCommand(command.remote, out);
|
|
10077
10336
|
}
|
|
10078
10337
|
};
|
|
10079
10338
|
/**
|
|
@@ -10083,7 +10342,7 @@ const dispatchVoidCommand = (command, json, out) => {
|
|
|
10083
10342
|
* (the `standaloneKinds` run bare). Both guard checks run before `dispatch`,
|
|
10084
10343
|
* so a refusal writes nothing and emits no script by construction.
|
|
10085
10344
|
*/
|
|
10086
|
-
const runCommand = (command, json, out) => {
|
|
10345
|
+
const runCommand$1 = (command, json, out) => {
|
|
10087
10346
|
const dispatch = dispatchVoidCommand(command, json, out);
|
|
10088
10347
|
if (needsOf(command.kind) !== "state") return dispatch;
|
|
10089
10348
|
return gen(function* () {
|
|
@@ -10112,13 +10371,13 @@ const FLAGS = [
|
|
|
10112
10371
|
name: "--json",
|
|
10113
10372
|
arity: "optional",
|
|
10114
10373
|
repeatable: false,
|
|
10115
|
-
scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors",
|
|
10374
|
+
scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
|
|
10116
10375
|
decode: ([raw]) => right(raw ?? ""),
|
|
10117
|
-
scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
10376
|
+
scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
10118
10377
|
valueHint: "<path>",
|
|
10119
10378
|
help: [
|
|
10120
|
-
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors
|
|
10121
|
-
"structured JSON. Bare --json prints the whole document;",
|
|
10379
|
+
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
|
|
10380
|
+
"list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
|
|
10122
10381
|
"--json=<path> (a dotted key path into that document, e.g.",
|
|
10123
10382
|
"kind, content, session.id) prints just that value: a",
|
|
10124
10383
|
"scalar raw and unquoted, a boolean as true/false, a list",
|
|
@@ -10626,6 +10885,44 @@ const COMMAND_ROWS = [
|
|
|
10626
10885
|
"repository. jev and llm exit 1 when they cannot answer",
|
|
10627
10886
|
"every question, with nothing on stdout"
|
|
10628
10887
|
]
|
|
10888
|
+
},
|
|
10889
|
+
{
|
|
10890
|
+
token: "history list",
|
|
10891
|
+
kind: "historyList",
|
|
10892
|
+
arity: "none",
|
|
10893
|
+
details: [
|
|
10894
|
+
"List the finished processes kept behind refs/gtd/history/*,",
|
|
10895
|
+
"newest first: entry, branch, tip, date and subject per line.",
|
|
10896
|
+
"--json prints one array of { ref, entry, branch, tip, date,",
|
|
10897
|
+
"subject }. Writes nothing and never replays; with no refs it",
|
|
10898
|
+
"hints at `gtd history fetch | sh` and still exits 0"
|
|
10899
|
+
]
|
|
10900
|
+
},
|
|
10901
|
+
{
|
|
10902
|
+
token: "history show",
|
|
10903
|
+
kind: "historyShow",
|
|
10904
|
+
arity: { name: "ref" },
|
|
10905
|
+
details: [
|
|
10906
|
+
"Replay one kept process under today's config and print its",
|
|
10907
|
+
"steps and judge verdicts. <ref> is a full ref, an entry hash",
|
|
10908
|
+
"(or unique prefix) or a branch name. Refuses (exit 1) when a",
|
|
10909
|
+
"config file differs from its content at the ref's tip. A",
|
|
10910
|
+
"version mismatch only warns on stderr. --json prints the",
|
|
10911
|
+
"trail as one document; writes nothing"
|
|
10912
|
+
]
|
|
10913
|
+
},
|
|
10914
|
+
{
|
|
10915
|
+
token: "history fetch",
|
|
10916
|
+
kind: "historyFetch",
|
|
10917
|
+
arity: {
|
|
10918
|
+
name: "remote",
|
|
10919
|
+
optional: true
|
|
10920
|
+
},
|
|
10921
|
+
details: [
|
|
10922
|
+
"Print a script that fetches every refs/gtd/history/* from",
|
|
10923
|
+
"<remote> (default origin) and reports how many arrived — run",
|
|
10924
|
+
"it with `gtd history fetch | sh`. Never runs git itself"
|
|
10925
|
+
]
|
|
10629
10926
|
}
|
|
10630
10927
|
];
|
|
10631
10928
|
const commandByToken = (token) => COMMAND_ROWS.find((r) => r.token === token);
|
|
@@ -10645,7 +10942,7 @@ const flagHeader = (row) => {
|
|
|
10645
10942
|
};
|
|
10646
10943
|
const renderHelp = () => {
|
|
10647
10944
|
const commandBlocks = COMMAND_ROWS.map((row) => {
|
|
10648
|
-
const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : "variadic" in row.arity ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]` : `${row.token} <${row.arity.name}>`;
|
|
10945
|
+
const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : "variadic" in row.arity ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]` : "optional" in row.arity ? `${row.token} [${row.arity.name}]` : `${row.token} <${row.arity.name}>`;
|
|
10649
10946
|
return renderBlock(header, row.details);
|
|
10650
10947
|
});
|
|
10651
10948
|
return `Usage: gtd [command] [options]\n\nCommands:\n${[
|
|
@@ -10756,6 +11053,7 @@ const conflictViolation = (present) => {
|
|
|
10756
11053
|
const arityError = (cmd, rest, arity) => {
|
|
10757
11054
|
if (arity === "none") return rest.length > 0 ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}` : void 0;
|
|
10758
11055
|
if ("names" in arity) return pairArityError(cmd, rest, arity.names);
|
|
11056
|
+
if ("optional" in arity) return rest.length > 1 ? `gtd ${cmd}: too many arguments — expected at most one ${arity.name}, got: ${rest.join(", ")}` : void 0;
|
|
10759
11057
|
if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`;
|
|
10760
11058
|
if (!("variadic" in arity) && rest.length > 1) return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`;
|
|
10761
11059
|
};
|
|
@@ -10806,7 +11104,9 @@ const parseArgv = (argv) => {
|
|
|
10806
11104
|
kind: "output",
|
|
10807
11105
|
stdout: renderHelp()
|
|
10808
11106
|
};
|
|
10809
|
-
const
|
|
11107
|
+
const isHistory = first === "history";
|
|
11108
|
+
const row = first === void 0 ? void 0 : commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first);
|
|
11109
|
+
if (isHistory && row === void 0) return usagePlan("gtd history: expected a subcommand — list, show <ref> or fetch [remote]", jsonSeen);
|
|
10810
11110
|
const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer";
|
|
10811
11111
|
const selectsStart = workflowPresent && first === void 0;
|
|
10812
11112
|
const judgeRun = row?.kind === "judge" && positionals[1] === "run";
|
|
@@ -10817,11 +11117,11 @@ const parseArgv = (argv) => {
|
|
|
10817
11117
|
if (first === void 0) return usagePlan("gtd: missing command — gtd decides and prints, a driver executes; run `gtd install`, or copy one from https://github.com/pmelab/gtd/blob/main/docs/driver.md's \"A complete minimal driver\" section and run that, or see usage above (`gtd --help`)", jsonSeen, renderHelp());
|
|
10818
11118
|
return usagePlan(`unknown command '${first}'`, jsonSeen);
|
|
10819
11119
|
}
|
|
10820
|
-
const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1);
|
|
11120
|
+
const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1);
|
|
10821
11121
|
if (kind === "start" && first === void 0) {
|
|
10822
11122
|
if (restPositionals.length > 0) return usagePlan(`gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`, jsonSeen);
|
|
10823
11123
|
} else {
|
|
10824
|
-
const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(first, restPositionals, row.arity);
|
|
11124
|
+
const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(isHistory ? row.token : first, restPositionals, row.arity);
|
|
10825
11125
|
if (arityMsg !== void 0) return usagePlan(arityMsg, jsonSeen);
|
|
10826
11126
|
}
|
|
10827
11127
|
const violation = scopeViolation(kind, present);
|
|
@@ -10910,6 +11210,24 @@ const parseArgv = (argv) => {
|
|
|
10910
11210
|
verbose
|
|
10911
11211
|
};
|
|
10912
11212
|
}
|
|
11213
|
+
if (kind === "historyShow") return {
|
|
11214
|
+
kind: "command",
|
|
11215
|
+
command: {
|
|
11216
|
+
kind: "historyShow",
|
|
11217
|
+
ref: restPositionals[0]
|
|
11218
|
+
},
|
|
11219
|
+
json,
|
|
11220
|
+
verbose
|
|
11221
|
+
};
|
|
11222
|
+
if (kind === "historyFetch") return {
|
|
11223
|
+
kind: "command",
|
|
11224
|
+
command: {
|
|
11225
|
+
kind: "historyFetch",
|
|
11226
|
+
remote: restPositionals[0] ?? "origin"
|
|
11227
|
+
},
|
|
11228
|
+
json,
|
|
11229
|
+
verbose
|
|
11230
|
+
};
|
|
10913
11231
|
if (kind === "uncheck") return {
|
|
10914
11232
|
kind: "command",
|
|
10915
11233
|
command: {
|
|
@@ -10985,7 +11303,7 @@ const runCli = (argv, io) => {
|
|
|
10985
11303
|
io.exit(2);
|
|
10986
11304
|
});
|
|
10987
11305
|
const out = bufferedArtifactOut(io);
|
|
10988
|
-
return runCommand(plan.command, plan.json, out).pipe(map$4(() => {
|
|
11306
|
+
return runCommand$1(plan.command, plan.json, out).pipe(map$4(() => {
|
|
10989
11307
|
out.flush();
|
|
10990
11308
|
io.exit(0);
|
|
10991
11309
|
}), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
|
|
@@ -11010,6 +11328,7 @@ var InMemRepo = class InMemRepo {
|
|
|
11010
11328
|
currentBranch = "main";
|
|
11011
11329
|
worktree = /* @__PURE__ */ new Map();
|
|
11012
11330
|
index = /* @__PURE__ */ new Map();
|
|
11331
|
+
nextSeq = 1;
|
|
11013
11332
|
getCommit(hash) {
|
|
11014
11333
|
return this.commits.get(hash) ?? null;
|
|
11015
11334
|
}
|
|
@@ -11063,10 +11382,10 @@ var InMemRepo = class InMemRepo {
|
|
|
11063
11382
|
}
|
|
11064
11383
|
resolveRef(ref) {
|
|
11065
11384
|
if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
|
|
11066
|
-
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref);
|
|
11385
|
+
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
|
|
11067
11386
|
if (tildeMatch) {
|
|
11068
11387
|
const base = tildeMatch[1];
|
|
11069
|
-
const steps = tildeMatch[3] !== void 0 ? parseInt(tildeMatch[3], 10) : 0;
|
|
11388
|
+
const steps = tildeMatch[3] !== void 0 ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0;
|
|
11070
11389
|
let cur = base === "HEAD" ? this.head : this.commits.has(base) ? base : null;
|
|
11071
11390
|
for (let i = 0; i < steps; i++) {
|
|
11072
11391
|
if (cur === null) return null;
|
|
@@ -11084,10 +11403,25 @@ var InMemRepo = class InMemRepo {
|
|
|
11084
11403
|
if (!c) return null;
|
|
11085
11404
|
return c.message.split("\n")[0] ?? null;
|
|
11086
11405
|
}
|
|
11087
|
-
lastCommitMessage() {
|
|
11088
|
-
const
|
|
11089
|
-
|
|
11090
|
-
return c.message;
|
|
11406
|
+
lastCommitMessage(ref) {
|
|
11407
|
+
const hash = ref === void 0 ? this.head : this.resolveRef(ref);
|
|
11408
|
+
const c = hash === null ? null : this.getCommit(hash);
|
|
11409
|
+
return c === null ? null : c.message;
|
|
11410
|
+
}
|
|
11411
|
+
branchName() {
|
|
11412
|
+
return this.currentBranch;
|
|
11413
|
+
}
|
|
11414
|
+
/** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
|
|
11415
|
+
listRefs(prefix) {
|
|
11416
|
+
return [...this.refs.entries()].filter(([ref]) => ref.startsWith(prefix)).map(([ref, hash]) => ({
|
|
11417
|
+
ref,
|
|
11418
|
+
hash,
|
|
11419
|
+
seq: this.getCommit(hash)?.seq ?? 0
|
|
11420
|
+
})).sort((a, b) => b.seq - a.seq).map(({ ref, hash, seq }) => ({
|
|
11421
|
+
ref,
|
|
11422
|
+
hash,
|
|
11423
|
+
date: (/* @__PURE__ */ new Date(seq * 1e3)).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
11424
|
+
}));
|
|
11091
11425
|
}
|
|
11092
11426
|
/**
|
|
11093
11427
|
* `head` (default `this.head`, mirroring production's `"HEAD"` default) is
|
|
@@ -11188,6 +11522,33 @@ var InMemRepo = class InMemRepo {
|
|
|
11188
11522
|
const dirPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
11189
11523
|
return [...this.worktree.keys()].filter((key) => key === prefix || key.startsWith(dirPrefix)).sort();
|
|
11190
11524
|
}
|
|
11525
|
+
/** Everything git would keep on disk, serialized — two snapshots compare equal exactly when nothing was written. */
|
|
11526
|
+
snapshot() {
|
|
11527
|
+
const sorted = (m) => [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
11528
|
+
return JSON.stringify({
|
|
11529
|
+
head: this.head,
|
|
11530
|
+
branch: this.currentBranch,
|
|
11531
|
+
commits: [...this.commits.keys()].sort(),
|
|
11532
|
+
branches: sorted(this.branches),
|
|
11533
|
+
refs: sorted(this.refs),
|
|
11534
|
+
index: sorted(this.index),
|
|
11535
|
+
worktree: sorted(this.worktree)
|
|
11536
|
+
});
|
|
11537
|
+
}
|
|
11538
|
+
/** Every ref name under `prefix` (`refs/…`), sorted — `git for-each-ref <prefix>`. */
|
|
11539
|
+
refNamesUnder(prefix) {
|
|
11540
|
+
return [...this.refs.keys()].filter((ref) => ref.startsWith(prefix)).sort();
|
|
11541
|
+
}
|
|
11542
|
+
/** Whether `path` is in the index — what `git diff <commit> -- <path>` compares against the worktree. */
|
|
11543
|
+
isTracked(path) {
|
|
11544
|
+
return this.index.has(path);
|
|
11545
|
+
}
|
|
11546
|
+
/** `git add <path>` — the index takes `path` as the worktree has it, or drops it. */
|
|
11547
|
+
stage(path) {
|
|
11548
|
+
const content = this.worktree.get(path);
|
|
11549
|
+
if (content === void 0) this.index.delete(path);
|
|
11550
|
+
else this.index.set(path, content);
|
|
11551
|
+
}
|
|
11191
11552
|
/** `git add -A` — the index becomes exactly the current worktree. */
|
|
11192
11553
|
stageAll() {
|
|
11193
11554
|
this.index = new Map(this.worktree);
|
|
@@ -11201,7 +11562,8 @@ var InMemRepo = class InMemRepo {
|
|
|
11201
11562
|
hash,
|
|
11202
11563
|
message,
|
|
11203
11564
|
files: new Map(tree),
|
|
11204
|
-
parent
|
|
11565
|
+
parent,
|
|
11566
|
+
seq: this.nextSeq++
|
|
11205
11567
|
};
|
|
11206
11568
|
this.commits.set(hash, commit);
|
|
11207
11569
|
this.head = hash;
|
|
@@ -11222,7 +11584,8 @@ var InMemRepo = class InMemRepo {
|
|
|
11222
11584
|
hash,
|
|
11223
11585
|
message,
|
|
11224
11586
|
files: new Map(tree),
|
|
11225
|
-
parent
|
|
11587
|
+
parent,
|
|
11588
|
+
seq: this.nextSeq++
|
|
11226
11589
|
};
|
|
11227
11590
|
this.commits.set(hash, commit);
|
|
11228
11591
|
this.head = hash;
|
|
@@ -11364,10 +11727,12 @@ const makeGitReaderOps = (repo, root) => ({
|
|
|
11364
11727
|
const subject = repo.lastCommitSubject(ref);
|
|
11365
11728
|
return subject !== null ? succeed$6(subject) : fail$5(/* @__PURE__ */ new Error("No commits"));
|
|
11366
11729
|
},
|
|
11367
|
-
lastCommitMessage: () => {
|
|
11368
|
-
const message = repo.lastCommitMessage();
|
|
11730
|
+
lastCommitMessage: (ref) => {
|
|
11731
|
+
const message = repo.lastCommitMessage(ref);
|
|
11369
11732
|
return message !== null ? succeed$6(message) : fail$5(/* @__PURE__ */ new Error("No commits"));
|
|
11370
11733
|
},
|
|
11734
|
+
currentBranch: () => succeed$6(some(repo.branchName())),
|
|
11735
|
+
listRefs: (prefix) => succeed$6(repo.listRefs(prefix)),
|
|
11371
11736
|
resolveRef: (ref) => {
|
|
11372
11737
|
const hash = repo.resolveRef(ref);
|
|
11373
11738
|
return hash !== null ? succeed$6(hash) : fail$5(/* @__PURE__ */ new Error(`Cannot resolve ref: ${ref}`));
|
|
@@ -11513,7 +11878,8 @@ const makeInMemoryConfigDiscovery = (repo, root) => {
|
|
|
11513
11878
|
const source = workspace.atPath(filepath);
|
|
11514
11879
|
if (source !== void 0) return {
|
|
11515
11880
|
filepath,
|
|
11516
|
-
source
|
|
11881
|
+
source,
|
|
11882
|
+
read: workspace.atPath
|
|
11517
11883
|
};
|
|
11518
11884
|
}
|
|
11519
11885
|
},
|
|
@@ -11576,6 +11942,269 @@ const makeCapturingCliIo = (repo, env = {}, tools = {}) => {
|
|
|
11576
11942
|
};
|
|
11577
11943
|
};
|
|
11578
11944
|
//#endregion
|
|
11945
|
+
//#region src/testkit/FlowScripts.ts
|
|
11946
|
+
const SHEBANG = "#!/usr/bin/env sh";
|
|
11947
|
+
const words$1 = (text) => [...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1].replaceAll("'\\''", "'"));
|
|
11948
|
+
const ok = (output = "") => ({
|
|
11949
|
+
ok: true,
|
|
11950
|
+
output
|
|
11951
|
+
});
|
|
11952
|
+
/** `rm -rf -- <paths>`. `InMemRepo.pathsUnder("")` is the whole tree, where a real `rm -- ''` removes nothing. */
|
|
11953
|
+
const removeAll = (repo, paths) => {
|
|
11954
|
+
if (paths.includes("")) throw new Error("testkit: rm: refusing an empty path");
|
|
11955
|
+
for (const path of paths) for (const file of repo.pathsUnder(path)) repo.deleteFile(file);
|
|
11956
|
+
};
|
|
11957
|
+
const deleteIfPresent = (repo, path) => {
|
|
11958
|
+
if (repo.readFile(path) !== void 0) repo.deleteFile(path);
|
|
11959
|
+
};
|
|
11960
|
+
const recognizeRemove = (repo, script) => {
|
|
11961
|
+
const line = script.split("\n").find((l) => l.startsWith("rm -rf -- ")) ?? "";
|
|
11962
|
+
const paths = words$1(line);
|
|
11963
|
+
if (removeScript(paths) !== script) return void 0;
|
|
11964
|
+
removeAll(repo, paths);
|
|
11965
|
+
return ok();
|
|
11966
|
+
};
|
|
11967
|
+
const recognizeMove = (repo, script) => {
|
|
11968
|
+
const line = script.split("\n").find((l) => l.startsWith("mv -- ")) ?? "";
|
|
11969
|
+
const [from, to] = words$1(line);
|
|
11970
|
+
if (from === void 0 || to === void 0 || moveScript(from, to) !== script) return void 0;
|
|
11971
|
+
const content = repo.readFile(from);
|
|
11972
|
+
if (content === void 0) return {
|
|
11973
|
+
ok: false,
|
|
11974
|
+
error: `mv: ${from}: No such file`,
|
|
11975
|
+
output: ""
|
|
11976
|
+
};
|
|
11977
|
+
repo.deleteFile(from);
|
|
11978
|
+
repo.writeFile(to, content);
|
|
11979
|
+
return ok();
|
|
11980
|
+
};
|
|
11981
|
+
const recognizeShow = (repo, script) => {
|
|
11982
|
+
const line = script.split("\n").find((l) => l.startsWith("git show ")) ?? "";
|
|
11983
|
+
const [source, to] = words$1(line);
|
|
11984
|
+
const at = source?.indexOf(":") ?? -1;
|
|
11985
|
+
if (source === void 0 || to === void 0 || at === -1) return void 0;
|
|
11986
|
+
const ref = source.slice(0, at);
|
|
11987
|
+
const path = source.slice(at + 1);
|
|
11988
|
+
if (showScript(ref, path, to) !== script) return void 0;
|
|
11989
|
+
const content = repo.fileAtRef(ref, path);
|
|
11990
|
+
if (content === null) return {
|
|
11991
|
+
ok: false,
|
|
11992
|
+
error: `git show: ${source} does not exist`,
|
|
11993
|
+
output: ""
|
|
11994
|
+
};
|
|
11995
|
+
repo.writeFile(to, content);
|
|
11996
|
+
return ok();
|
|
11997
|
+
};
|
|
11998
|
+
/** `git diff --quiet <commit> -- <path>`: the worktree copy of a tracked path, or nothing for an untracked one, against `commit`'s. */
|
|
11999
|
+
const unchangedSince = (repo, commit, path) => {
|
|
12000
|
+
return (repo.isTracked(path) ? repo.readFile(path) ?? null : null) === repo.fileAtRef(commit, path);
|
|
12001
|
+
};
|
|
12002
|
+
const RESTORE_LINE = / && git restore --source=/;
|
|
12003
|
+
const REMOVE_LINE = / && rm -f -- /;
|
|
12004
|
+
const parseRestoreLine = (line) => {
|
|
12005
|
+
const [commit, path, source] = words$1(line);
|
|
12006
|
+
if (commit === void 0 || path === void 0) return void 0;
|
|
12007
|
+
if (RESTORE_LINE.test(line) && source !== void 0) return {
|
|
12008
|
+
commit,
|
|
12009
|
+
path,
|
|
12010
|
+
source
|
|
12011
|
+
};
|
|
12012
|
+
return REMOVE_LINE.test(line) ? {
|
|
12013
|
+
commit,
|
|
12014
|
+
path
|
|
12015
|
+
} : void 0;
|
|
12016
|
+
};
|
|
12017
|
+
/** Applies one line, returning its exit status as `sh` would see it. */
|
|
12018
|
+
const applyRestoreLine = (repo, { commit, path, source }) => {
|
|
12019
|
+
if (!unchangedSince(repo, commit, path)) return false;
|
|
12020
|
+
if (source !== void 0 && repo.resolveRef(source) === null) return false;
|
|
12021
|
+
const content = source === void 0 ? null : repo.fileAtRef(source, path);
|
|
12022
|
+
if (content !== null) repo.writeFile(path, content);
|
|
12023
|
+
else if (source === void 0 || repo.isTracked(path)) deleteIfPresent(repo, path);
|
|
12024
|
+
return true;
|
|
12025
|
+
};
|
|
12026
|
+
const recognizeRestore = (repo, script) => {
|
|
12027
|
+
const parsed = script.split("\n").slice(2, -1).map(parseRestoreLine);
|
|
12028
|
+
if (parsed.includes(void 0)) return void 0;
|
|
12029
|
+
const lines = parsed;
|
|
12030
|
+
const restoring = lines.filter((l) => l.source !== void 0);
|
|
12031
|
+
const paths = {
|
|
12032
|
+
restore: restoring.map((l) => l.path),
|
|
12033
|
+
remove: lines.filter((l) => l.source === void 0).map((l) => l.path)
|
|
12034
|
+
};
|
|
12035
|
+
if (restoreScript(lines[0]?.commit ?? "", paths, restoring[0]?.source) !== script) return void 0;
|
|
12036
|
+
if (lines.map((line) => applyRestoreLine(repo, line)).at(-1) === false) return {
|
|
12037
|
+
ok: false,
|
|
12038
|
+
error: "the last path changed since the commit",
|
|
12039
|
+
output: ""
|
|
12040
|
+
};
|
|
12041
|
+
return ok();
|
|
12042
|
+
};
|
|
12043
|
+
const PATCH_FAILED = "error: patch does not apply";
|
|
12044
|
+
/**
|
|
12045
|
+
* `git diff C^ C | git apply -R`, which is all or nothing. A file still
|
|
12046
|
+
* exactly as `C` left it reverts; a file gone, or one `C` added and that
|
|
12047
|
+
* changed since, fails the whole patch. A file `C` modified and that changed
|
|
12048
|
+
* since may still apply through its context — that needs real git, so it
|
|
12049
|
+
* throws rather than guess.
|
|
12050
|
+
*/
|
|
12051
|
+
const reverseApply = (repo, commit) => {
|
|
12052
|
+
const parent = `${commit}^`;
|
|
12053
|
+
const changed = [.../* @__PURE__ */ new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])].filter((p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p));
|
|
12054
|
+
for (const path of changed) {
|
|
12055
|
+
const after = repo.fileAtRef(commit, path);
|
|
12056
|
+
const current = repo.readFile(path) ?? null;
|
|
12057
|
+
if (current === after) continue;
|
|
12058
|
+
if (current === null || after === null || repo.fileAtRef(parent, path) === null) return `${PATCH_FAILED}: ${path}`;
|
|
12059
|
+
throw new Error(`testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`);
|
|
12060
|
+
}
|
|
12061
|
+
for (const path of changed) {
|
|
12062
|
+
const before = repo.fileAtRef(parent, path);
|
|
12063
|
+
if (before === null) repo.deleteFile(path);
|
|
12064
|
+
else repo.writeFile(path, before);
|
|
12065
|
+
}
|
|
12066
|
+
};
|
|
12067
|
+
const recognizeRevert = (repo, script) => {
|
|
12068
|
+
const lines = script.split("\n");
|
|
12069
|
+
const [, commit] = words$1(lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "");
|
|
12070
|
+
const [, failure] = words$1(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "");
|
|
12071
|
+
const [report] = words$1(lines.find((l) => l.startsWith(" } > ")) ?? "");
|
|
12072
|
+
if (commit === void 0 || failure === void 0 || report === void 0) return void 0;
|
|
12073
|
+
if (revertScript(commit, report, failure) !== script) return void 0;
|
|
12074
|
+
const error = reverseApply(repo, commit);
|
|
12075
|
+
if (error !== void 0) repo.writeFile(report, `${failure}\n\n${error}\n`);
|
|
12076
|
+
return ok();
|
|
12077
|
+
};
|
|
12078
|
+
/** One preamble line — the closed vocabulary the bundled workflows write there. */
|
|
12079
|
+
const runPreambleLine = (repo, line) => {
|
|
12080
|
+
const quoted = words$1(line);
|
|
12081
|
+
if (line === "exit 0") return {
|
|
12082
|
+
exit: true,
|
|
12083
|
+
output: ""
|
|
12084
|
+
};
|
|
12085
|
+
if (line.startsWith("mkdir -p ")) return {
|
|
12086
|
+
exit: false,
|
|
12087
|
+
output: ""
|
|
12088
|
+
};
|
|
12089
|
+
if (line.startsWith("rm -f ")) {
|
|
12090
|
+
for (const path of quoted) deleteIfPresent(repo, path);
|
|
12091
|
+
return {
|
|
12092
|
+
exit: false,
|
|
12093
|
+
output: ""
|
|
12094
|
+
};
|
|
12095
|
+
}
|
|
12096
|
+
if (line.startsWith("rm -rf -- ")) {
|
|
12097
|
+
removeAll(repo, quoted);
|
|
12098
|
+
return {
|
|
12099
|
+
exit: false,
|
|
12100
|
+
output: ""
|
|
12101
|
+
};
|
|
12102
|
+
}
|
|
12103
|
+
const printf = /^printf '%s\\n' '(?:[^']|'\\'')*' (>&2|> '(?:[^']|'\\'')*')$/.exec(line);
|
|
12104
|
+
if (printf !== null) {
|
|
12105
|
+
const [, text, file] = quoted;
|
|
12106
|
+
if (printf[1] === ">&2") return {
|
|
12107
|
+
exit: false,
|
|
12108
|
+
output: `${text}\n`
|
|
12109
|
+
};
|
|
12110
|
+
repo.writeFile(file, `${text}\n`);
|
|
12111
|
+
return {
|
|
12112
|
+
exit: false,
|
|
12113
|
+
output: ""
|
|
12114
|
+
};
|
|
12115
|
+
}
|
|
12116
|
+
throw new Error(`testkit: cannot run the preamble line \`${line}\` in memory`);
|
|
12117
|
+
};
|
|
12118
|
+
/** Rejoins lines a single-quoted word spans, so a preamble entry carrying a newline stays one line. */
|
|
12119
|
+
const shellLines = (lines) => {
|
|
12120
|
+
const joined = [];
|
|
12121
|
+
let open = false;
|
|
12122
|
+
for (const line of lines) {
|
|
12123
|
+
if (open) joined[joined.length - 1] += `\n${line}`;
|
|
12124
|
+
else joined.push(line);
|
|
12125
|
+
for (let i = 0; i < line.length; i++) if (line[i] === "'" && (open || line[i - 1] !== "\\")) open = !open;
|
|
12126
|
+
}
|
|
12127
|
+
return joined;
|
|
12128
|
+
};
|
|
12129
|
+
const outsideCommand = (lines, mkdir) => {
|
|
12130
|
+
const before = shellLines(lines.slice(2, mkdir));
|
|
12131
|
+
const swept = before.at(-1)?.startsWith("rm -rf -- ") === true;
|
|
12132
|
+
const green = lines.find((l) => l.startsWith(" rm -rf -- "));
|
|
12133
|
+
return {
|
|
12134
|
+
sweep: swept ? words$1(before.at(-1)) : [],
|
|
12135
|
+
sweepOnGreen: green === void 0 ? [] : words$1(green),
|
|
12136
|
+
preamble: swept ? before.slice(0, -1) : before
|
|
12137
|
+
};
|
|
12138
|
+
};
|
|
12139
|
+
const parseCheck = (script) => {
|
|
12140
|
+
const lines = script.split("\n");
|
|
12141
|
+
const mkdir = lines.findIndex((l) => l.startsWith("mkdir -p \"$(dirname "));
|
|
12142
|
+
const close = lines.findIndex((l, i) => i > mkdir && /^\) > '.*' 2>&1$/.test(l));
|
|
12143
|
+
if (mkdir === -1 || lines[mkdir + 1] !== "(" || close === -1) return void 0;
|
|
12144
|
+
const [report] = words$1(lines[mkdir]);
|
|
12145
|
+
const [, stamp] = words$1(lines.find((l) => l.includes("<!-- gtd check %s -->")) ?? "");
|
|
12146
|
+
if (report === void 0 || stamp === void 0) return void 0;
|
|
12147
|
+
return {
|
|
12148
|
+
command: lines.slice(mkdir + 2, close).join("\n"),
|
|
12149
|
+
report,
|
|
12150
|
+
stamp,
|
|
12151
|
+
...outsideCommand(lines, mkdir)
|
|
12152
|
+
};
|
|
12153
|
+
};
|
|
12154
|
+
const runCommand = (repo, commands, command) => {
|
|
12155
|
+
const scripted = commands.get(command);
|
|
12156
|
+
if (scripted === void 0) throw new Error(`testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`);
|
|
12157
|
+
if (scripted.kind === "exit") return scripted;
|
|
12158
|
+
if (scripted.kind === "missing") return {
|
|
12159
|
+
status: 127,
|
|
12160
|
+
output: `sh: ${command}: not found\n`
|
|
12161
|
+
};
|
|
12162
|
+
repo.writeFile(scripted.file, rewritten(repo, scripted));
|
|
12163
|
+
return {
|
|
12164
|
+
status: 0,
|
|
12165
|
+
output: ""
|
|
12166
|
+
};
|
|
12167
|
+
};
|
|
12168
|
+
/** What a scripted rewrite leaves in its file. */
|
|
12169
|
+
const rewritten = (repo, command) => command.append === true ? `${repo.readFile(command.file) ?? ""}${command.content}` : command.content;
|
|
12170
|
+
const recognizeCheck = (repo, commands, script) => {
|
|
12171
|
+
const parsed = parseCheck(script);
|
|
12172
|
+
if (parsed === void 0) return void 0;
|
|
12173
|
+
const { command, report, stamp, sweep, sweepOnGreen, preamble } = parsed;
|
|
12174
|
+
if (checkScript(command, {
|
|
12175
|
+
report,
|
|
12176
|
+
stamp,
|
|
12177
|
+
sweep,
|
|
12178
|
+
sweepOnGreen,
|
|
12179
|
+
preamble
|
|
12180
|
+
}) !== script) return;
|
|
12181
|
+
let printed = "";
|
|
12182
|
+
for (const line of preamble) {
|
|
12183
|
+
const outcome = runPreambleLine(repo, line);
|
|
12184
|
+
printed += outcome.output;
|
|
12185
|
+
if (outcome.exit) return ok(printed);
|
|
12186
|
+
}
|
|
12187
|
+
removeAll(repo, sweep);
|
|
12188
|
+
const { status, output } = runCommand(repo, commands, command);
|
|
12189
|
+
if (status === 0) {
|
|
12190
|
+
deleteIfPresent(repo, report);
|
|
12191
|
+
removeAll(repo, sweepOnGreen);
|
|
12192
|
+
return ok(printed);
|
|
12193
|
+
}
|
|
12194
|
+
const body = output.length > 0 ? output : `the test command failed with exit code ${status} and produced no output.`;
|
|
12195
|
+
repo.writeFile(report, `${body}\n<!-- gtd check ${stamp} -->\n`);
|
|
12196
|
+
return ok(printed);
|
|
12197
|
+
};
|
|
12198
|
+
/**
|
|
12199
|
+
* Applies a `src/flows/scripts.ts` script to `repo`, or `undefined` when
|
|
12200
|
+
* `script` is none of them. `commands` stands in for the real programs a
|
|
12201
|
+
* check runs, keyed by the exact command string.
|
|
12202
|
+
*/
|
|
12203
|
+
const applyFlowScript = (repo, commands, script) => {
|
|
12204
|
+
if (!script.startsWith(`${SHEBANG}\n`)) return void 0;
|
|
12205
|
+
return recognizeRemove(repo, script) ?? recognizeMove(repo, script) ?? recognizeShow(repo, script) ?? recognizeRevert(repo, script) ?? recognizeRestore(repo, script) ?? recognizeCheck(repo, commands, script);
|
|
12206
|
+
};
|
|
12207
|
+
//#endregion
|
|
11579
12208
|
//#region src/testkit/EmittedScriptRecognizer.ts
|
|
11580
12209
|
/**
|
|
11581
12210
|
* Reverses `GitScript.ts`'s `shellQuote`: extracts every `'...'` token from
|
|
@@ -11612,6 +12241,8 @@ const extractQuotedTokens = (text) => {
|
|
|
11612
12241
|
}
|
|
11613
12242
|
return tokens;
|
|
11614
12243
|
};
|
|
12244
|
+
/** What the in-memory tier prints for `$PATH` — there is none to name. */
|
|
12245
|
+
const IN_MEMORY_PATH = "(in-memory: no PATH)";
|
|
11615
12246
|
const SET_FLAGS_RE = /^set\s+-\S+(\s+\S+)*$/;
|
|
11616
12247
|
const recognizeSetFlags = (block) => SET_FLAGS_RE.test(block) ? { kind: "noop" } : void 0;
|
|
11617
12248
|
const PRECONDITION_RE = /^\[ "\$\(git rev-parse HEAD\)" = '([0-9a-f]{40})' \] \|\| \{ echo "precondition failed: HEAD moved" >&2; exit 1; \}$/;
|
|
@@ -11619,11 +12250,9 @@ const recognizePrecondition = (repo, block) => {
|
|
|
11619
12250
|
const match = PRECONDITION_RE.exec(block);
|
|
11620
12251
|
if (!match) return void 0;
|
|
11621
12252
|
const expected = match[1];
|
|
11622
|
-
|
|
11623
|
-
if (actual === expected) return { kind: "noop" };
|
|
11624
|
-
return {
|
|
12253
|
+
return repo.resolveRef("HEAD") === expected ? { kind: "noop" } : {
|
|
11625
12254
|
kind: "failed",
|
|
11626
|
-
error:
|
|
12255
|
+
error: "precondition failed: HEAD moved"
|
|
11627
12256
|
};
|
|
11628
12257
|
};
|
|
11629
12258
|
/**
|
|
@@ -11647,7 +12276,7 @@ const recognizeGitBuilders = (repo, block) => {
|
|
|
11647
12276
|
return { kind: "applied" };
|
|
11648
12277
|
}
|
|
11649
12278
|
const conditionLine = conditionStatement(block);
|
|
11650
|
-
if (block.startsWith("git add -A
|
|
12279
|
+
if (block.startsWith("git add -A || exit 1\n")) {
|
|
11651
12280
|
const [message] = extractQuotedTokens(conditionLine);
|
|
11652
12281
|
if (message !== void 0 && commitAll(message) === block) {
|
|
11653
12282
|
repo.commitAllWithPrefix(message);
|
|
@@ -11710,9 +12339,9 @@ const recognizeFileExistsGuard = (repo, block) => {
|
|
|
11710
12339
|
if (file === void 0 || fileExistsGuard(file) !== block) return void 0;
|
|
11711
12340
|
return repo.readFile(file) !== void 0 ? { kind: "noop" } : { kind: "stopped" };
|
|
11712
12341
|
};
|
|
11713
|
-
/** `binaryGuard`. The in-memory tier has no
|
|
12342
|
+
/** `binaryGuard`. The in-memory tier has no `$PATH`: a binary is present unless declared `missing`. */
|
|
11714
12343
|
const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/;
|
|
11715
|
-
const recognizeBinaryGuard = (block) => {
|
|
12344
|
+
const recognizeBinaryGuard = (commands, block) => {
|
|
11716
12345
|
const [binary, , message] = extractQuotedTokens(block);
|
|
11717
12346
|
if (binary === void 0 || message === void 0) return void 0;
|
|
11718
12347
|
const match = BINARY_GUARD_MESSAGE_RE.exec(message);
|
|
@@ -11720,10 +12349,29 @@ const recognizeBinaryGuard = (block) => {
|
|
|
11720
12349
|
const [, mode, key, messageBinary] = match;
|
|
11721
12350
|
if (messageBinary !== binary) return void 0;
|
|
11722
12351
|
if (binaryGuard(binary, mode, key) !== block) return void 0;
|
|
11723
|
-
return { kind: "noop" };
|
|
12352
|
+
if (commands.get(binary)?.kind !== "missing") return { kind: "noop" };
|
|
12353
|
+
return {
|
|
12354
|
+
kind: "failed",
|
|
12355
|
+
error: `${message}\n$PATH: ${IN_MEMORY_PATH}`,
|
|
12356
|
+
status: 127
|
|
12357
|
+
};
|
|
11724
12358
|
};
|
|
11725
12359
|
/** Recognized by `OUTCOME_MARKER` rather than re-derived and string-compared: an outcome only prints, so no git effect a loose match could miss — unlike `recognizeGitBuilders`, where a near-miss would silently skip a real mutation. */
|
|
11726
|
-
const recognizeOutcome = (block) =>
|
|
12360
|
+
const recognizeOutcome = (repo, block) => {
|
|
12361
|
+
if (!block.startsWith(`# gtd: outcome (print-only)\nprintf `)) return void 0;
|
|
12362
|
+
const line = block.slice(28);
|
|
12363
|
+
const [format, ...args] = shellWords(repo, line.slice(7));
|
|
12364
|
+
return {
|
|
12365
|
+
kind: "noop",
|
|
12366
|
+
output: renderFormat(format.replace(/\\n/g, "\n"), ...args)
|
|
12367
|
+
};
|
|
12368
|
+
};
|
|
12369
|
+
/** The word list of `printf`'s arguments: quoted literals, and the `"$(git …)"` lookups outcome lines carry. */
|
|
12370
|
+
const shellWords = (repo, text) => [...text.matchAll(/'((?:[^']|'\\'')*)'|"\$\(git (rev-parse --short|log -1 --format=%s) '([^']*)'\)"/g)].map((m) => {
|
|
12371
|
+
if (m[1] !== void 0) return m[1].replaceAll("'\\''", "'");
|
|
12372
|
+
const hash = repo.resolveRef(m[3]) ?? "";
|
|
12373
|
+
return m[2] === "rev-parse --short" ? hash.slice(0, 7) : repo.lastCommitSubject(hash) ?? "";
|
|
12374
|
+
});
|
|
11727
12375
|
/**
|
|
11728
12376
|
* `failurePromptWrapper`. The constants below bracket the template's one
|
|
11729
12377
|
* variable part; the extracted pair is confirmed by RE-RUNNING the real
|
|
@@ -11740,8 +12388,27 @@ const expandFileVar = (text) => {
|
|
|
11740
12388
|
if (file === void 0) return void 0;
|
|
11741
12389
|
return text.slice(match[0].length).replace(/"\$GTD_FILE"|"\$\{GTD_FILE\}"/g, shellQuote(file)).replace(/\$\{GTD_FILE\}|\$GTD_FILE\b/g, file);
|
|
11742
12390
|
};
|
|
11743
|
-
/**
|
|
11744
|
-
|
|
12391
|
+
/**
|
|
12392
|
+
* A mode's `format:`/`validate:` command, under `withFileVar` or not:
|
|
12393
|
+
* `gtd check`, or a scripted command — keyed by the command as expanded, or
|
|
12394
|
+
* as written in the config (`fmt $GTD_FILE`), which is the only key a command
|
|
12395
|
+
* run on a scratch path can have.
|
|
12396
|
+
*/
|
|
12397
|
+
const recognizeModeCommand = (repo, commands, block) => {
|
|
12398
|
+
const command = expandFileVar(block) ?? block;
|
|
12399
|
+
const exact = recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command);
|
|
12400
|
+
if (exact !== void 0) return exact;
|
|
12401
|
+
const match = FILE_VAR_PREFIX_RE.exec(block);
|
|
12402
|
+
if (match === null) return void 0;
|
|
12403
|
+
const scripted = commands.get(block.slice(match[0].length));
|
|
12404
|
+
const [file] = extractQuotedTokens(match[1]);
|
|
12405
|
+
if (scripted === void 0 || file === void 0) return void 0;
|
|
12406
|
+
const bound = scripted.kind === "rewrite" && scripted.file === "$GTD_FILE" ? {
|
|
12407
|
+
...scripted,
|
|
12408
|
+
file
|
|
12409
|
+
} : scripted;
|
|
12410
|
+
return recognizeScriptedCommand(repo, /* @__PURE__ */ new Map([[command, bound]]), command);
|
|
12411
|
+
};
|
|
11745
12412
|
const FAILURE_PROMPT_HEADER = "gtd_validate_status=0\ngtd_validate_out=\"$( {\n";
|
|
11746
12413
|
const FAILURE_PROMPT_MIDDLE = "\n} 2>&1 )\" || gtd_validate_status=$?\nif [ \"$gtd_validate_status\" -ne 0 ]; then\n printf '%s\\n\\n%s\\n' ";
|
|
11747
12414
|
const FAILURE_PROMPT_FOOTER = " \"$gtd_validate_out\"\n exit \"$gtd_validate_status\"\nfi";
|
|
@@ -11753,11 +12420,12 @@ const recognizeFailurePromptWrapper = (repo, commands, block) => {
|
|
|
11753
12420
|
const promptQuoted = block.slice(middleIndex + 101, block.length - 53);
|
|
11754
12421
|
const [prompt] = extractQuotedTokens(promptQuoted);
|
|
11755
12422
|
if (prompt === void 0 || failurePromptWrapper(inner, prompt) !== block) return void 0;
|
|
11756
|
-
const innerOutcome = recognizeModeCommand(repo, commands,
|
|
12423
|
+
const innerOutcome = recognizeModeCommand(repo, commands, inner);
|
|
11757
12424
|
if (innerOutcome === void 0) return void 0;
|
|
11758
12425
|
if (innerOutcome.kind === "failed") return {
|
|
11759
12426
|
kind: "failed",
|
|
11760
|
-
error: `${prompt}\n\n${innerOutcome.error}
|
|
12427
|
+
error: `${prompt}\n\n${innerOutcome.error.replace(/\n+$/, "")}`,
|
|
12428
|
+
status: innerOutcome.status ?? 1
|
|
11761
12429
|
};
|
|
11762
12430
|
return innerOutcome;
|
|
11763
12431
|
};
|
|
@@ -11770,7 +12438,10 @@ const recognizeModeContradictionSkipNotice = (block) => {
|
|
|
11770
12438
|
if (!match) return void 0;
|
|
11771
12439
|
const mode = match[1];
|
|
11772
12440
|
if (modeContradictionSkipNotice(mode) !== block) return void 0;
|
|
11773
|
-
return {
|
|
12441
|
+
return {
|
|
12442
|
+
kind: "noop",
|
|
12443
|
+
output: `${message}\n`
|
|
12444
|
+
};
|
|
11774
12445
|
};
|
|
11775
12446
|
/** The printf line's own pieces — the sample bytes, the scratch path, and the literal PREFIX text (up to and including that path) — or `undefined` when `block` doesn't open with `printf '%s' ...`. */
|
|
11776
12447
|
const parsePrintfPrefix = (block) => {
|
|
@@ -11833,7 +12504,7 @@ const simulateModeContradictionCheck = (repo, commands, parsed) => {
|
|
|
11833
12504
|
const { mode, samplePath, sample, formatCommand, format } = parsed;
|
|
11834
12505
|
repo.writeFile(samplePath, sample);
|
|
11835
12506
|
const command = expandFileVar(formatCommand) ?? formatCommand;
|
|
11836
|
-
const formatOutcome =
|
|
12507
|
+
const formatOutcome = recognizeModeCommand(repo, commands, formatCommand);
|
|
11837
12508
|
if (formatOutcome === void 0) {
|
|
11838
12509
|
repo.deleteFile(samplePath);
|
|
11839
12510
|
return {
|
|
@@ -11875,16 +12546,16 @@ const recognizeGtdCheck = (repo, block) => {
|
|
|
11875
12546
|
const format = steeringFormatFor(mode);
|
|
11876
12547
|
if (format === void 0 || file === void 0) return {
|
|
11877
12548
|
kind: "failed",
|
|
11878
|
-
error: `gtd check:
|
|
12549
|
+
error: `gtd check: unknown mode "${mode}" — known modes: ${BUILT_IN_MODE_NAMES.join(", ")}`
|
|
11879
12550
|
};
|
|
11880
12551
|
const content = repo.readFile(file);
|
|
11881
12552
|
if (content === void 0) return { kind: "noop" };
|
|
11882
|
-
const findings = format.validate(content);
|
|
11883
|
-
if (findings.length
|
|
12553
|
+
const findings = format.validate(content).map((finding) => formatFinding(file, finding));
|
|
12554
|
+
if (findings.length === 0) return { kind: "noop" };
|
|
12555
|
+
return {
|
|
11884
12556
|
kind: "failed",
|
|
11885
|
-
error: `gtd check ${
|
|
12557
|
+
error: `gtd check: ${file} is not valid under mode "${mode}" (${findings.length} finding(s)):\n` + findings.join("\n")
|
|
11886
12558
|
};
|
|
11887
|
-
return { kind: "noop" };
|
|
11888
12559
|
};
|
|
11889
12560
|
const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/;
|
|
11890
12561
|
/** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
|
|
@@ -11908,27 +12579,44 @@ const recognizeGtdUncheck = (repo, block) => {
|
|
|
11908
12579
|
*/
|
|
11909
12580
|
const recognizeDidNotRunComment = (block) => block === "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn" ? { kind: "noop" } : void 0;
|
|
11910
12581
|
/** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
|
|
12582
|
+
const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0].replace(/\\n$/, "")}\n`;
|
|
11911
12583
|
const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`;
|
|
11912
12584
|
const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`;
|
|
11913
12585
|
const recognizePresentationSubshell = (repo, commands, block) => {
|
|
11914
12586
|
if (!block.startsWith(PRESENTATION_SUBSHELL_PREFIX) || !block.endsWith(PRESENTATION_SUBSHELL_SUFFIX)) return;
|
|
11915
12587
|
const inner = block.slice(PRESENTATION_SUBSHELL_PREFIX.length, block.length - PRESENTATION_SUBSHELL_SUFFIX.length);
|
|
11916
|
-
applyEmittedScript(repo, commands, inner);
|
|
11917
|
-
return {
|
|
12588
|
+
const result = applyEmittedScript(repo, commands, inner);
|
|
12589
|
+
if (result.ok) return {
|
|
12590
|
+
kind: "noop",
|
|
12591
|
+
output: result.output
|
|
12592
|
+
};
|
|
12593
|
+
return {
|
|
12594
|
+
kind: "noop",
|
|
12595
|
+
output: `${result.output}${PRESENTATION_FAILURE_TEXT}`
|
|
12596
|
+
};
|
|
11918
12597
|
};
|
|
11919
12598
|
/** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
|
|
11920
12599
|
const recognizeScriptedCommand = (repo, commands, block) => {
|
|
11921
12600
|
const scripted = commands.get(block);
|
|
11922
12601
|
if (scripted === void 0) return void 0;
|
|
11923
12602
|
if (scripted.kind === "rewrite") {
|
|
11924
|
-
repo.writeFile(scripted.file, scripted
|
|
12603
|
+
repo.writeFile(scripted.file, rewritten(repo, scripted));
|
|
11925
12604
|
return { kind: "applied" };
|
|
11926
12605
|
}
|
|
12606
|
+
if (scripted.kind === "missing") return {
|
|
12607
|
+
kind: "failed",
|
|
12608
|
+
error: `sh: ${block.split(" ")[0]}: not found`,
|
|
12609
|
+
status: 127
|
|
12610
|
+
};
|
|
11927
12611
|
if (scripted.status !== 0) return {
|
|
11928
12612
|
kind: "failed",
|
|
11929
|
-
error:
|
|
12613
|
+
error: scripted.output,
|
|
12614
|
+
status: scripted.status
|
|
12615
|
+
};
|
|
12616
|
+
return {
|
|
12617
|
+
kind: "noop",
|
|
12618
|
+
output: scripted.output
|
|
11930
12619
|
};
|
|
11931
|
-
return { kind: "noop" };
|
|
11932
12620
|
};
|
|
11933
12621
|
/**
|
|
11934
12622
|
* Splits on blank lines, IGNORING those inside a single-quoted string: every
|
|
@@ -11984,14 +12672,27 @@ const splitBlocks = (script) => {
|
|
|
11984
12672
|
if (current.trim().length > 0) blocks.push(current.trim());
|
|
11985
12673
|
return blocks;
|
|
11986
12674
|
};
|
|
12675
|
+
/** `deferNotesScript`: append its one section to `.gtd/TODO.md`, reading the section's lines off the final `printf`. */
|
|
12676
|
+
const DEFER_HEAD = "mkdir -p .gtd\nif [ -s .gtd/TODO.md ]; then";
|
|
12677
|
+
const recognizeDeferNotes = (repo, block) => {
|
|
12678
|
+
if (!block.startsWith(DEFER_HEAD)) return void 0;
|
|
12679
|
+
const at = block.lastIndexOf("\nprintf '%s\\n' ");
|
|
12680
|
+
if (at === -1) return void 0;
|
|
12681
|
+
const lines = extractQuotedTokens(block.slice(at + 15));
|
|
12682
|
+
const old = repo.readFile(".gtd/TODO.md") ?? "";
|
|
12683
|
+
const sep = old === "" ? "" : `${old.endsWith("\n") ? "" : "\n"}\n`;
|
|
12684
|
+
repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`);
|
|
12685
|
+
return { kind: "applied" };
|
|
12686
|
+
};
|
|
11987
12687
|
/** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
|
|
11988
12688
|
const recognizersFor = (repo, commands) => [
|
|
11989
12689
|
(block) => recognizeSetFlags(block),
|
|
11990
12690
|
(block) => recognizeDidNotRunComment(block),
|
|
11991
12691
|
(block) => recognizePrecondition(repo, block),
|
|
11992
12692
|
(block) => recognizeFileExistsGuard(repo, block),
|
|
11993
|
-
(block) => recognizeBinaryGuard(block),
|
|
12693
|
+
(block) => recognizeBinaryGuard(commands, block),
|
|
11994
12694
|
(block) => recognizeGitBuilders(repo, block),
|
|
12695
|
+
(block) => recognizeDeferNotes(repo, block),
|
|
11995
12696
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
11996
12697
|
(block) => recognizePresentationSubshell(repo, commands, block),
|
|
11997
12698
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -11999,10 +12700,9 @@ const recognizersFor = (repo, commands) => [
|
|
|
11999
12700
|
(block) => recognizeGtdCheck(repo, block),
|
|
12000
12701
|
(block) => recognizeGtdUncheck(repo, block),
|
|
12001
12702
|
(block) => {
|
|
12002
|
-
|
|
12003
|
-
return command === void 0 ? void 0 : recognizeModeCommand(repo, commands, command);
|
|
12703
|
+
return FILE_VAR_PREFIX_RE.test(block) ? recognizeModeCommand(repo, commands, block) : void 0;
|
|
12004
12704
|
},
|
|
12005
|
-
(block) => recognizeOutcome(block),
|
|
12705
|
+
(block) => recognizeOutcome(repo, block),
|
|
12006
12706
|
(block) => recognizeScriptedCommand(repo, commands, block)
|
|
12007
12707
|
];
|
|
12008
12708
|
const recognizeBlock = (recognizers, block) => {
|
|
@@ -12018,21 +12718,50 @@ const recognizeBlock = (recognizers, block) => {
|
|
|
12018
12718
|
* real script. No block after the failing one is applied.
|
|
12019
12719
|
*/
|
|
12020
12720
|
const applyEmittedScript = (repo, commands, script) => {
|
|
12721
|
+
const flow = applyFlowScript(repo, commands, script);
|
|
12722
|
+
if (flow !== void 0) return flow.ok ? {
|
|
12723
|
+
ok: true,
|
|
12724
|
+
status: 0,
|
|
12725
|
+
output: flow.output
|
|
12726
|
+
} : {
|
|
12727
|
+
ok: false,
|
|
12728
|
+
status: 1,
|
|
12729
|
+
output: flow.output,
|
|
12730
|
+
error: flow.error ?? ""
|
|
12731
|
+
};
|
|
12732
|
+
return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script);
|
|
12733
|
+
};
|
|
12734
|
+
const applyBlocks = (repo, commands, script) => {
|
|
12021
12735
|
const recognizers = recognizersFor(repo, commands);
|
|
12736
|
+
let output = "";
|
|
12022
12737
|
for (const block of splitBlocks(script)) {
|
|
12023
12738
|
const outcome = recognizeBlock(recognizers, block);
|
|
12024
|
-
if (outcome === void 0) return
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
if (outcome.kind === "stopped") return { ok: true };
|
|
12029
|
-
if (outcome.kind === "failed") return {
|
|
12030
|
-
ok: false,
|
|
12031
|
-
error: outcome.error
|
|
12032
|
-
};
|
|
12739
|
+
if (outcome === void 0) return unrecognized(block, output);
|
|
12740
|
+
if (outcome.kind === "noop") output += outcome.output ?? "";
|
|
12741
|
+
if (outcome.kind === "stopped") break;
|
|
12742
|
+
if (outcome.kind === "failed") return failedAt(outcome, output);
|
|
12033
12743
|
}
|
|
12034
|
-
return {
|
|
12744
|
+
return {
|
|
12745
|
+
ok: true,
|
|
12746
|
+
status: 0,
|
|
12747
|
+
output
|
|
12748
|
+
};
|
|
12035
12749
|
};
|
|
12750
|
+
const failedAt = (outcome, output) => {
|
|
12751
|
+
const printed = outcome.error.length === 0 || outcome.error.endsWith("\n") ? "" : "\n";
|
|
12752
|
+
return {
|
|
12753
|
+
ok: false,
|
|
12754
|
+
status: outcome.status ?? 1,
|
|
12755
|
+
output: `${output}${outcome.error}${printed}`,
|
|
12756
|
+
error: outcome.error
|
|
12757
|
+
};
|
|
12758
|
+
};
|
|
12759
|
+
const unrecognized = (block, output = "") => ({
|
|
12760
|
+
ok: false,
|
|
12761
|
+
status: 1,
|
|
12762
|
+
output,
|
|
12763
|
+
error: `unrecognized script block: ${block.split("\n")[0]}`
|
|
12764
|
+
});
|
|
12036
12765
|
//#endregion
|
|
12037
12766
|
//#region src/testkit/TestRepo.ts
|
|
12038
12767
|
const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules");
|
|
@@ -12167,7 +12896,9 @@ const testRepo = (options) => {
|
|
|
12167
12896
|
commit: (message) => repo.commitAllWithPrefix(message),
|
|
12168
12897
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
|
|
12169
12898
|
runScript: (script) => {
|
|
12170
|
-
|
|
12899
|
+
const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
|
|
12900
|
+
if (flow === void 0) for (const line of script.split("\n")) runLine(repo, line.trim());
|
|
12901
|
+
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`);
|
|
12171
12902
|
},
|
|
12172
12903
|
gtd,
|
|
12173
12904
|
start: (workflow, ...args) => begin([
|
|
@@ -12212,6 +12943,21 @@ const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
|
|
|
12212
12943
|
const makeLiveTier = (initialCommit = true) => {
|
|
12213
12944
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")));
|
|
12214
12945
|
const gitExec = (...args) => gitExecIn(root, ...args);
|
|
12946
|
+
let tick = 0;
|
|
12947
|
+
const commitEnv = () => ({
|
|
12948
|
+
...process.env,
|
|
12949
|
+
GIT_COMMITTER_DATE: new Date(Date.UTC(2024, 0, 1, 0, 0, ++tick)).toISOString()
|
|
12950
|
+
});
|
|
12951
|
+
const gitCommit = (message, allowEmpty = false) => execFileSync("git", [
|
|
12952
|
+
"commit",
|
|
12953
|
+
...allowEmpty ? ["--allow-empty"] : [],
|
|
12954
|
+
"-m",
|
|
12955
|
+
message
|
|
12956
|
+
], {
|
|
12957
|
+
cwd: root,
|
|
12958
|
+
env: commitEnv(),
|
|
12959
|
+
stdio: "pipe"
|
|
12960
|
+
});
|
|
12215
12961
|
gitExec("init");
|
|
12216
12962
|
gitExec(`config user.email "test@test.com"`);
|
|
12217
12963
|
gitExec(`config user.name "Test"`);
|
|
@@ -12219,7 +12965,7 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
12219
12965
|
if (initialCommit) {
|
|
12220
12966
|
writeFileSync(join(root, "readme.txt"), "hello");
|
|
12221
12967
|
gitExec("add", "-A");
|
|
12222
|
-
|
|
12968
|
+
gitCommit("init: first commit");
|
|
12223
12969
|
}
|
|
12224
12970
|
const provide = (eff) => runPromise(eff.pipe(provide$2(GitService.Live), provide$2(Host.layer({
|
|
12225
12971
|
root,
|
|
@@ -12238,7 +12984,8 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
12238
12984
|
onDiskContent: true,
|
|
12239
12985
|
commitHooks: true,
|
|
12240
12986
|
linkedWorktrees: true,
|
|
12241
|
-
directoryChainConfig: true
|
|
12987
|
+
directoryChainConfig: true,
|
|
12988
|
+
remotes: true
|
|
12242
12989
|
},
|
|
12243
12990
|
provide,
|
|
12244
12991
|
provideExit,
|
|
@@ -12250,7 +12997,7 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
12250
12997
|
writeFileSync(join(root, path), content);
|
|
12251
12998
|
}
|
|
12252
12999
|
gitExec("add", "-A");
|
|
12253
|
-
|
|
13000
|
+
gitCommit(message, true);
|
|
12254
13001
|
},
|
|
12255
13002
|
writeFile: (path, content) => {
|
|
12256
13003
|
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
@@ -12277,7 +13024,37 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
12277
13024
|
}
|
|
12278
13025
|
},
|
|
12279
13026
|
readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
|
|
12280
|
-
existsPath: (path) => existsSync(join(root, path))
|
|
13027
|
+
existsPath: (path) => existsSync(join(root, path)),
|
|
13028
|
+
snapshot: async () => {
|
|
13029
|
+
gitExec("status", "--porcelain");
|
|
13030
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
13031
|
+
gitExec("status", "--porcelain");
|
|
13032
|
+
const files = readdirSync(join(root, ".git"), {
|
|
13033
|
+
recursive: true,
|
|
13034
|
+
withFileTypes: true
|
|
13035
|
+
}).filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)).filter((path) => !path.includes(`${sep}gtd-cache${sep}`)).sort().map((path) => `${path}:${statSync(path).size}:${statSync(path).mtimeMs}`);
|
|
13036
|
+
return JSON.stringify({
|
|
13037
|
+
files,
|
|
13038
|
+
status: gitExec("status", "--porcelain", "-uall")
|
|
13039
|
+
});
|
|
13040
|
+
},
|
|
13041
|
+
headSubject: () => gitExec("log", "-1", "--pretty=%s"),
|
|
13042
|
+
headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0)
|
|
13043
|
+
},
|
|
13044
|
+
runScript: (script) => {
|
|
13045
|
+
const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
|
|
13046
|
+
cwd: root,
|
|
13047
|
+
encoding: "utf8",
|
|
13048
|
+
env: {
|
|
13049
|
+
...process.env,
|
|
13050
|
+
PATH: `${gtdShimDir()}:${process.env["PATH"] ?? ""}`
|
|
13051
|
+
}
|
|
13052
|
+
});
|
|
13053
|
+
return {
|
|
13054
|
+
ok: result.status === 0,
|
|
13055
|
+
status: result.status ?? 1,
|
|
13056
|
+
output: result.stdout
|
|
13057
|
+
};
|
|
12281
13058
|
},
|
|
12282
13059
|
dispose: () => rmSync(root, {
|
|
12283
13060
|
recursive: true,
|
|
@@ -12285,6 +13062,15 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
12285
13062
|
})
|
|
12286
13063
|
};
|
|
12287
13064
|
};
|
|
13065
|
+
/** A dir whose `gtd` runs this build's bundle, so a script's bare `gtd` is the code under test. */
|
|
13066
|
+
let shimDir;
|
|
13067
|
+
const gtdShimDir = () => {
|
|
13068
|
+
if (shimDir !== void 0) return shimDir;
|
|
13069
|
+
shimDir = mkdtempSync(join(tmpdir(), "gtd-tier-shim-"));
|
|
13070
|
+
const bundle = join(import.meta.dirname, "../../dist/gtd.bundle.mjs");
|
|
13071
|
+
writeFileSync(join(shimDir, "gtd"), `#!/bin/sh\nexec node "${bundle}" "$@"\n`, { mode: 493 });
|
|
13072
|
+
return shimDir;
|
|
13073
|
+
};
|
|
12288
13074
|
const IN_MEM_ROOT = "/repo";
|
|
12289
13075
|
const makeInMemTier = (initialCommit = true) => {
|
|
12290
13076
|
const repo = new InMemRepo();
|
|
@@ -12302,7 +13088,8 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
12302
13088
|
onDiskContent: false,
|
|
12303
13089
|
commitHooks: false,
|
|
12304
13090
|
linkedWorktrees: false,
|
|
12305
|
-
directoryChainConfig: true
|
|
13091
|
+
directoryChainConfig: true,
|
|
13092
|
+
remotes: false
|
|
12306
13093
|
},
|
|
12307
13094
|
provide,
|
|
12308
13095
|
provideExit,
|
|
@@ -12335,7 +13122,21 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
12335
13122
|
if (content === void 0) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
12336
13123
|
return content;
|
|
12337
13124
|
},
|
|
12338
|
-
existsPath: (path) => repo.hasPath(path)
|
|
13125
|
+
existsPath: (path) => repo.hasPath(path),
|
|
13126
|
+
snapshot: async () => repo.snapshot(),
|
|
13127
|
+
headSubject: () => repo.lastCommitSubject() ?? "",
|
|
13128
|
+
headTouchedPaths: () => {
|
|
13129
|
+
return [.../* @__PURE__ */ new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])].filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p)).sort();
|
|
13130
|
+
}
|
|
13131
|
+
},
|
|
13132
|
+
runScript: (script, commands = /* @__PURE__ */ new Map()) => {
|
|
13133
|
+
const { ok, status, output, error } = applyEmittedScript(repo, commands, script);
|
|
13134
|
+
if (error?.startsWith("unrecognized script block:") === true) throw new Error(error);
|
|
13135
|
+
return {
|
|
13136
|
+
ok,
|
|
13137
|
+
status,
|
|
13138
|
+
output
|
|
13139
|
+
};
|
|
12339
13140
|
},
|
|
12340
13141
|
dispose: () => {}
|
|
12341
13142
|
};
|
|
@@ -12382,6 +13183,32 @@ const runGitServiceContract = (makeTier) => {
|
|
|
12382
13183
|
afterEach(() => {
|
|
12383
13184
|
t.dispose();
|
|
12384
13185
|
});
|
|
13186
|
+
it("no reader writes to the repository", async () => {
|
|
13187
|
+
t.seed.commit("feat: second", { "second.txt": "two" });
|
|
13188
|
+
t.seed.writeFile("dirty.txt", "dirty");
|
|
13189
|
+
const before = await t.observe.snapshot();
|
|
13190
|
+
await runGit(t, (g) => all$1([
|
|
13191
|
+
g.lastCommitSubject(),
|
|
13192
|
+
g.lastCommitMessage(),
|
|
13193
|
+
g.hasCommits(),
|
|
13194
|
+
g.resolveRef("HEAD"),
|
|
13195
|
+
g.readRefOption("refs/gtd/none"),
|
|
13196
|
+
g.isAncestor("HEAD~1", "HEAD"),
|
|
13197
|
+
g.mergeBase("HEAD~1", "HEAD"),
|
|
13198
|
+
g.defaultBranch(),
|
|
13199
|
+
g.topLevel(),
|
|
13200
|
+
g.gitDir(),
|
|
13201
|
+
g.gitCommonDir(),
|
|
13202
|
+
g.commitHistory(),
|
|
13203
|
+
g.subjectHistory(10, 0),
|
|
13204
|
+
g.readFileAtRef("HEAD", "second.txt"),
|
|
13205
|
+
g.changedPaths(),
|
|
13206
|
+
g.changedPaths("HEAD~1")
|
|
13207
|
+
]));
|
|
13208
|
+
expect(await t.observe.snapshot()).toBe(before);
|
|
13209
|
+
await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
|
|
13210
|
+
expect(await t.observe.snapshot()).not.toBe(before);
|
|
13211
|
+
});
|
|
12385
13212
|
describe("resolveRef", () => {
|
|
12386
13213
|
it("resolves HEAD to a 40-char hash", async () => {
|
|
12387
13214
|
expect(await runGit(t, (g) => g.resolveRef("HEAD"))).toMatch(/^[0-9a-f]{40}$/);
|
|
@@ -12414,6 +13241,54 @@ const runGitServiceContract = (makeTier) => {
|
|
|
12414
13241
|
expect(await runGit(t, (g) => g.lastCommitMessage())).toBe("feat: subject only");
|
|
12415
13242
|
});
|
|
12416
13243
|
});
|
|
13244
|
+
describe("lastCommitMessage with a ref", () => {
|
|
13245
|
+
it("reads the given ref's full message, not HEAD's", async () => {
|
|
13246
|
+
t.seed.commit("feat: first\n\nGtd-Branch: one");
|
|
13247
|
+
t.seed.commit("feat: second", { "extra.txt": "extra" });
|
|
13248
|
+
expect(await runGit(t, (g) => g.lastCommitMessage("HEAD~1"))).toBe("feat: first\n\nGtd-Branch: one");
|
|
13249
|
+
});
|
|
13250
|
+
});
|
|
13251
|
+
describe("currentBranch", () => {
|
|
13252
|
+
it("is the checked-out branch's name", async () => {
|
|
13253
|
+
expect(isSome(await runGit(t, (g) => g.currentBranch()))).toBe(true);
|
|
13254
|
+
});
|
|
13255
|
+
it.skipIf(!capabilities.onDiskContent)("is none on a detached HEAD, never a failure", async () => {
|
|
13256
|
+
gitExecIn(t.root, "checkout", "--detach", "HEAD");
|
|
13257
|
+
expect(isNone(await runGit(t, (g) => g.currentBranch()))).toBe(true);
|
|
13258
|
+
});
|
|
13259
|
+
});
|
|
13260
|
+
describe("listRefs", () => {
|
|
13261
|
+
it("is [] when no ref lives under the prefix", async () => {
|
|
13262
|
+
expect(await runGit(t, (g) => g.listRefs("refs/gtd/history/"))).toEqual([]);
|
|
13263
|
+
});
|
|
13264
|
+
it("lists {ref, hash, date} for refs under the prefix only, newest tip first", async () => {
|
|
13265
|
+
const first = t.observe.resolveRef("HEAD");
|
|
13266
|
+
t.seed.commit("feat: second", { "second.txt": "2" });
|
|
13267
|
+
const second = t.observe.resolveRef("HEAD");
|
|
13268
|
+
t.seed.updateRef("refs/gtd/history/old", first);
|
|
13269
|
+
t.seed.updateRef("refs/gtd/history/new", second);
|
|
13270
|
+
t.seed.updateRef("refs/gtd/other/elsewhere", second);
|
|
13271
|
+
const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"));
|
|
13272
|
+
expect(refs.map((r) => [r.ref, r.hash])).toEqual([["refs/gtd/history/new", second], ["refs/gtd/history/old", first]]);
|
|
13273
|
+
for (const r of refs) expect(r.date.length).toBeGreaterThan(0);
|
|
13274
|
+
});
|
|
13275
|
+
it("reports the date as ISO 8601 strict", async () => {
|
|
13276
|
+
t.seed.updateRef("refs/gtd/history/x", t.observe.resolveRef("HEAD"));
|
|
13277
|
+
const [entry] = await runGit(t, (g) => g.listRefs("refs/gtd/history/"));
|
|
13278
|
+
expect(entry?.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/);
|
|
13279
|
+
});
|
|
13280
|
+
});
|
|
13281
|
+
describe("history reachable only from a retained ref", () => {
|
|
13282
|
+
it("commitHistory and readFileAtRef read it after HEAD moved off those commits", async () => {
|
|
13283
|
+
t.seed.commit("feat: second", { "kept.txt": "kept" });
|
|
13284
|
+
const tip = t.observe.resolveRef("HEAD");
|
|
13285
|
+
t.seed.updateRef("refs/gtd/history/tip", tip);
|
|
13286
|
+
t.seed.mixedReset("HEAD~1");
|
|
13287
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(tip);
|
|
13288
|
+
expect((await runGit(t, (g) => g.commitHistory(void 0, "refs/gtd/history/tip"))).map((c) => c.message)).toEqual(["init: first commit", "feat: second"]);
|
|
13289
|
+
expect(await runGit(t, (g) => g.readFileAtRef("refs/gtd/history/tip", "kept.txt"))).toBe("kept");
|
|
13290
|
+
});
|
|
13291
|
+
});
|
|
12417
13292
|
describe("hasCommits", () => {
|
|
12418
13293
|
it("returns false in an empty repo", async () => {
|
|
12419
13294
|
const empty = t.emptyRepo();
|
|
@@ -12869,31 +13744,18 @@ const runGitServiceContract = (makeTier) => {
|
|
|
12869
13744
|
});
|
|
12870
13745
|
};
|
|
12871
13746
|
/**
|
|
12872
|
-
*
|
|
12873
|
-
*
|
|
12874
|
-
*
|
|
12875
|
-
* `&&`, a misquoted path) fails here exactly as it would in production.
|
|
13747
|
+
* The SAME writer postconditions `runGitServiceContract` asserts against
|
|
13748
|
+
* `GitOperations`, asserted here against `GitScript.ts`'s builders as a driver
|
|
13749
|
+
* runs them: real `sh` on Live, the in-memory twin on InMemory.
|
|
12876
13750
|
*/
|
|
12877
|
-
const
|
|
12878
|
-
|
|
12879
|
-
|
|
12880
|
-
stdio: "pipe"
|
|
12881
|
-
});
|
|
13751
|
+
const run = (t, script) => {
|
|
13752
|
+
const result = t.runScript(script);
|
|
13753
|
+
if (!result.ok) throw new Error(`script failed on ${t.name}: ${result.output}`);
|
|
12882
13754
|
};
|
|
12883
|
-
|
|
12884
|
-
const headTouchedPaths = (t) => gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0);
|
|
12885
|
-
const headSubject = (t) => gitExecIn(t.root, "log", "-1", "--pretty=%s");
|
|
12886
|
-
/**
|
|
12887
|
-
* The SAME 9 writer postconditions `runGitServiceContract` asserts against
|
|
12888
|
-
* `GitOperations`, asserted here against `GitScript.ts`'s bash builders
|
|
12889
|
-
* instead — driven through real `bash` against a real repo (`makeLiveTier`),
|
|
12890
|
-
* never the in-memory fake, since a fake root (`/repo`) has no shell to run
|
|
12891
|
-
* against.
|
|
12892
|
-
*/
|
|
12893
|
-
const runGitScriptContract = () => {
|
|
13755
|
+
const runGitScriptContract = (makeTier) => {
|
|
12894
13756
|
let t;
|
|
12895
13757
|
beforeEach(() => {
|
|
12896
|
-
t =
|
|
13758
|
+
t = makeTier();
|
|
12897
13759
|
});
|
|
12898
13760
|
afterEach(() => {
|
|
12899
13761
|
t.dispose();
|
|
@@ -12902,17 +13764,17 @@ const runGitScriptContract = () => {
|
|
|
12902
13764
|
it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
|
|
12903
13765
|
t.seed.writeFile("new.ts", "export const x = 1");
|
|
12904
13766
|
const headBefore = t.observe.resolveRef("HEAD");
|
|
12905
|
-
|
|
13767
|
+
run(t, commitAll("gtd: building"));
|
|
12906
13768
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
12907
|
-
expect(headSubject(
|
|
12908
|
-
expect(headTouchedPaths(
|
|
13769
|
+
expect(t.observe.headSubject()).toBe("gtd: building");
|
|
13770
|
+
expect(t.observe.headTouchedPaths()).toEqual(["new.ts"]);
|
|
12909
13771
|
expect(t.observe.statusPorcelain().trim()).toBe("");
|
|
12910
13772
|
});
|
|
12911
13773
|
it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
|
|
12912
13774
|
const headBefore = t.observe.resolveRef("HEAD");
|
|
12913
|
-
|
|
13775
|
+
run(t, commitAll("gtd: grilled"));
|
|
12914
13776
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
12915
|
-
expect(headSubject(
|
|
13777
|
+
expect(t.observe.headSubject()).toBe("gtd: grilled");
|
|
12916
13778
|
});
|
|
12917
13779
|
});
|
|
12918
13780
|
describe("commitAsIs", () => {
|
|
@@ -12920,22 +13782,22 @@ const runGitScriptContract = () => {
|
|
|
12920
13782
|
t.seed.writeFile("a.txt", "a");
|
|
12921
13783
|
t.seed.stageAll();
|
|
12922
13784
|
t.seed.writeFile("b.txt", "b");
|
|
12923
|
-
|
|
12924
|
-
expect(headSubject(
|
|
12925
|
-
expect(headTouchedPaths(
|
|
13785
|
+
run(t, commitAsIs("feat: squash"));
|
|
13786
|
+
expect(t.observe.headSubject()).toBe("feat: squash");
|
|
13787
|
+
expect(t.observe.headTouchedPaths()).toEqual(["a.txt"]);
|
|
12926
13788
|
});
|
|
12927
13789
|
it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
|
|
12928
13790
|
const headBefore = t.observe.resolveRef("HEAD");
|
|
12929
|
-
|
|
13791
|
+
run(t, commitAsIs("gtd: empty squash"));
|
|
12930
13792
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
12931
|
-
expect(headSubject(
|
|
13793
|
+
expect(t.observe.headSubject()).toBe("gtd: empty squash");
|
|
12932
13794
|
});
|
|
12933
13795
|
});
|
|
12934
13796
|
describe("softResetTo", () => {
|
|
12935
13797
|
it("moves HEAD back but keeps worktree changes from the second commit", () => {
|
|
12936
13798
|
const firstHash = t.observe.resolveRef("HEAD");
|
|
12937
13799
|
t.seed.commit("feat: second", { "second.txt": "second content" });
|
|
12938
|
-
|
|
13800
|
+
run(t, softResetTo(firstHash));
|
|
12939
13801
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
|
|
12940
13802
|
expect(t.observe.statusPorcelain()).toContain("second.txt");
|
|
12941
13803
|
});
|
|
@@ -12944,7 +13806,7 @@ const runGitScriptContract = () => {
|
|
|
12944
13806
|
it("moves HEAD, index, and working tree content back to the target ref", () => {
|
|
12945
13807
|
const firstHash = t.observe.resolveRef("HEAD");
|
|
12946
13808
|
t.seed.commit("feat: second", { "readme.txt": "modified content" });
|
|
12947
|
-
|
|
13809
|
+
run(t, hardResetTo(firstHash));
|
|
12948
13810
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
|
|
12949
13811
|
expect(t.observe.statusPorcelain()).toBe("");
|
|
12950
13812
|
expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello");
|
|
@@ -12954,7 +13816,7 @@ const runGitScriptContract = () => {
|
|
|
12954
13816
|
it("moves HEAD and index, leaving the working tree untouched", () => {
|
|
12955
13817
|
const firstHash = t.observe.resolveRef("HEAD");
|
|
12956
13818
|
t.seed.commit("feat: second", { "second.txt": "second content" });
|
|
12957
|
-
|
|
13819
|
+
run(t, mixedResetTo(firstHash));
|
|
12958
13820
|
expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
|
|
12959
13821
|
expect(t.observe.statusPorcelain()).toContain("second.txt");
|
|
12960
13822
|
});
|
|
@@ -12963,29 +13825,84 @@ const runGitScriptContract = () => {
|
|
|
12963
13825
|
it("drops both tracked modifications and untracked files", () => {
|
|
12964
13826
|
t.seed.writeFile("readme.txt", "modified");
|
|
12965
13827
|
t.seed.writeFile("untracked.txt", "new");
|
|
12966
|
-
|
|
13828
|
+
run(t, discardPending());
|
|
12967
13829
|
expect(t.observe.statusPorcelain()).toBe("");
|
|
12968
13830
|
});
|
|
12969
13831
|
});
|
|
12970
13832
|
describe("updateRef / deleteRef", () => {
|
|
12971
13833
|
it("updateRef points a ref at a commit", () => {
|
|
12972
13834
|
const head = t.observe.resolveRef("HEAD");
|
|
12973
|
-
|
|
13835
|
+
run(t, updateRef("refs/gtd/marker", head));
|
|
12974
13836
|
expect(t.observe.refExists("refs/gtd/marker")).toBe(true);
|
|
12975
13837
|
expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head);
|
|
12976
13838
|
});
|
|
13839
|
+
it("updateRef with HEAD points the ref at the commit just made", () => {
|
|
13840
|
+
run(t, commitAll("feat: just made"));
|
|
13841
|
+
run(t, updateRef("refs/gtd/history/x", "HEAD"));
|
|
13842
|
+
expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"));
|
|
13843
|
+
});
|
|
12977
13844
|
it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
|
|
12978
13845
|
const head = t.observe.resolveRef("HEAD");
|
|
12979
|
-
|
|
12980
|
-
|
|
12981
|
-
|
|
13846
|
+
run(t, updateRef("refs/gtd/marker", head));
|
|
13847
|
+
run(t, deleteRef("refs/gtd/marker"));
|
|
13848
|
+
run(t, deleteRef("refs/gtd/marker"));
|
|
12982
13849
|
expect(t.observe.refExists("refs/gtd/marker")).toBe(false);
|
|
12983
13850
|
});
|
|
12984
13851
|
it("deleteRef tolerates a ref that was never created", () => {
|
|
12985
|
-
expect(() =>
|
|
13852
|
+
expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow();
|
|
12986
13853
|
expect(t.observe.refExists("refs/gtd/never-created")).toBe(false);
|
|
12987
13854
|
});
|
|
12988
13855
|
});
|
|
13856
|
+
describe("fetchHistoryRefs", () => {
|
|
13857
|
+
it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
|
|
13858
|
+
if (!t.capabilities.remotes) ctx.skip();
|
|
13859
|
+
const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
|
|
13860
|
+
try {
|
|
13861
|
+
gitExecIn(bare, "init", "-q", "--bare");
|
|
13862
|
+
gitExecIn(t.root, "remote", "add", "origin", bare);
|
|
13863
|
+
const head = t.observe.resolveRef("HEAD");
|
|
13864
|
+
gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/one`);
|
|
13865
|
+
gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/two`);
|
|
13866
|
+
gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/other/skip`);
|
|
13867
|
+
const out = execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
|
|
13868
|
+
cwd: t.root,
|
|
13869
|
+
encoding: "utf8"
|
|
13870
|
+
});
|
|
13871
|
+
expect(t.observe.refExists("refs/gtd/history/one")).toBe(true);
|
|
13872
|
+
expect(t.observe.refExists("refs/gtd/history/two")).toBe(true);
|
|
13873
|
+
expect(t.observe.refExists("refs/gtd/other/skip")).toBe(false);
|
|
13874
|
+
expect(out.trim()).toBe("2 history refs arrived");
|
|
13875
|
+
} finally {
|
|
13876
|
+
rmSync(bare, {
|
|
13877
|
+
recursive: true,
|
|
13878
|
+
force: true
|
|
13879
|
+
});
|
|
13880
|
+
}
|
|
13881
|
+
});
|
|
13882
|
+
it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
|
|
13883
|
+
if (!t.capabilities.remotes) ctx.skip();
|
|
13884
|
+
const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
|
|
13885
|
+
try {
|
|
13886
|
+
gitExecIn(bare, "init", "-q", "--bare");
|
|
13887
|
+
gitExecIn(t.root, "remote", "add", "origin", bare);
|
|
13888
|
+
const first = t.observe.resolveRef("HEAD");
|
|
13889
|
+
t.seed.commit("feat: second", { "second.txt": "x" });
|
|
13890
|
+
const second = t.observe.resolveRef("HEAD");
|
|
13891
|
+
gitExecIn(t.root, "push", "-q", "origin", `${first}:refs/gtd/history/one`);
|
|
13892
|
+
t.seed.updateRef("refs/gtd/history/one", second);
|
|
13893
|
+
expect(() => execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
|
|
13894
|
+
cwd: t.root,
|
|
13895
|
+
stdio: "pipe"
|
|
13896
|
+
})).toThrow();
|
|
13897
|
+
expect(t.observe.resolveRef("refs/gtd/history/one")).toBe(second);
|
|
13898
|
+
} finally {
|
|
13899
|
+
rmSync(bare, {
|
|
13900
|
+
recursive: true,
|
|
13901
|
+
force: true
|
|
13902
|
+
});
|
|
13903
|
+
}
|
|
13904
|
+
});
|
|
13905
|
+
});
|
|
12989
13906
|
};
|
|
12990
13907
|
//#endregion
|
|
12991
13908
|
//#region src/testkit/StrictGitOperations.ts
|