@pmelab/gtd 26.0.0 → 27.0.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 +21 -3
- package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
- package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
- package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +219 -34
- package/dist/testkit.mjs +447 -72
- package/dist/wire.mjs +2 -2
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +2 -2
- package/src/cli/Cli.test.ts +56 -4
- package/src/cli/Cli.ts +48 -6
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Output.ts +3 -1
- package/src/cli/Program.test.ts +11 -12
- package/src/cli/Program.ts +28 -7
- package/src/cli/Strays.test.ts +88 -0
- package/src/cli/Strays.ts +56 -0
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.ts +0 -1
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/Git.test.ts +96 -1
- package/src/platform/Git.ts +24 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/step/strayPaths.test.ts +17 -0
- package/src/step/strayPaths.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +10 -2
- package/src/testkit/GitTiers.ts +170 -25
- package/src/testkit/InMemRepo.ts +64 -12
- package/src/testkit/Layers.test.ts +34 -1
- package/src/testkit/Layers.ts +21 -1
- package/src/testkit/TestRepo.ts +3 -3
- package/src/wire/ExitCodes.test.ts +11 -3
- package/src/wire/ExitCodes.ts +3 -0
- package/src/wire/Glob.test.ts +35 -0
- package/src/wire/Glob.ts +1 -1
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
package/dist/testkit.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-BY1S-obW.mjs";
|
|
3
3
|
import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, w as formatFinding } from "./chunks/steering-DVILanVf.mjs";
|
|
4
|
-
import { $ as
|
|
4
|
+
import { $ as logWarning, $i as format, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as get$5, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as isNone, Bn as get$3, Br as sync$1, Bt as unsafeMakeLatch, C as all$1, Ci as toArray, Cn as isInterrupted, Cr as exitFail, Ct as sandbox, D as async, Di as GenericTag, Dn as squash, Dr as exitZipRight, Dt as scopedWith$1, E as asVoid$1, Ei as unsafeHead, En as reduceWithContext, Er as exitVoid, Et as scoped$4, F as ensuring$2, Fi as splitAt, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as some, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as map$8, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as filter$1, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as match$2, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as isLeft, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as flatMap$5, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as omit, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as empty$5, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Tag, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as fromIterable, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as NodeInspectSymbol, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as fromNullable, Rn as bindTo, Rr as succeed$4, Rt as uninterruptible$1, S as addFinalizer$1, Si as takeRight, Sn as isDieType, Sr as exitAs, St as runtime, T as as, Ti as unsafeGet, Tn as map$6, Tr as exitSucceed, Tt as scopeWith, U as forEach$2, Ui as match$4, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as isSome, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as none, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as pipeArguments, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as right, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as BaseProto, Zn as failCause$3, Zr as combineAll, Zt as fromEffect$5, _ as decodeSync, _i as of, _n as fail$3, _r as deferredIsDone, _t as raceWith, a as Literal, aa as constUndefined, ai as size$2, an as updateContext$1, ar as zipRight$2, at as matchCause, b as acquireRelease, bi as reverse, bn as interrupt$3, br as deferredUnsafeDone, bt as runPromise, c as String$, ca as identity, ci as append$1, cn as Scope, cr as asyncInterrupt, ct as negate, d as Union, di as empty$4, dn as close, dr as deferredAwait, dt as orElse, ea as toJSON, ei as none$1, en as provideMerge$1, er as mapBoth, et as makeSemaphore, f as between, fi as filter, fn as extend, fr as deferredComplete, ft as orElseSucceed, gi as map$7, gn as die$1, gr as deferredInterruptWith, gt as race, hi as isEmpty$2, hn as NoSuchElementException, hr as deferredInterrupt, ht as provideService, i as Defect, ia as constTrue, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as getOption, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as add, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as pipe, li as appendAll, ln as addFinalizer, lr as catchAll, lt as onError, m as optional$1, mi as isChunk, mn as make$18, mr as deferredFailCause, mt as provide$2, n as Boolean$, na as hasProperty, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constVoid, oi as decode, on as forkIn$1, or as as$1, ot as matchCauseEffect, p as decodeUnknown, pi as fromIterable$1, pn as fork, pr as deferredFail, pt as promise, q as gen, qi as left, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as constFalse, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as dual, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as equals, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ui as drop, un as addFinalizerExit, ur as currentContext$1, ut as orDie, v as encodeSync, vi as prepend, vn as failureOrCause, vr as deferredMake, vt as repeat, w as annotateLogs, wi as unsafeFromArray, wn as isInterruptedOnly, wr as exitFailCause, wt as scope, x as acquireUseRelease$2, xi as take$2, xn as interruptors, xr as deferredUnsafeMake, xt as runPromiseExit$1, y as _void, yi as reduce, yn as flipCauseOption, yr as deferredSucceed, yt as retry, z as fiberIdWith, zi as getOrElse, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-CyImoHTj.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import * as Crypto from "node:crypto";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
@@ -5401,6 +5401,7 @@ const makeGitImpl = (executor, root) => {
|
|
|
5401
5401
|
}))),
|
|
5402
5402
|
hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
|
|
5403
5403
|
readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
|
|
5404
|
+
diffPaths: (base, tip) => exec("git", "diff", "--name-only", "-z", "--no-renames", "--no-relative", "--ignore-submodules=none", "--end-of-options", base, tip, "--").pipe(map$4(splitNul)),
|
|
5404
5405
|
changedPaths: (base) => gen(function* () {
|
|
5405
5406
|
const ref = base ?? "HEAD";
|
|
5406
5407
|
const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll$1(() => succeed$6("")));
|
|
@@ -6071,6 +6072,8 @@ const HISTORY_REF = "refs/worktree/gtd/history";
|
|
|
6071
6072
|
/** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
|
|
6072
6073
|
const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
|
|
6073
6074
|
const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
|
|
6075
|
+
/** Directory of a step's turns set aside by `keep: true`, each at `<this>/<kept hash>`; own namespace, so history list/show/fetch never see it. */
|
|
6076
|
+
const keptRef = (entry, step) => `refs/gtd/kept/${entry}/${step}`;
|
|
6074
6077
|
const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
|
|
6075
6078
|
/**
|
|
6076
6079
|
* Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
|
|
@@ -6770,6 +6773,22 @@ const accessShapeFault = (value) => {
|
|
|
6770
6773
|
const sameGlobs = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((g, i) => g === b[i]);
|
|
6771
6774
|
const sameAccess = (a, b) => sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write);
|
|
6772
6775
|
//#endregion
|
|
6776
|
+
//#region src/replay/RefName.ts
|
|
6777
|
+
const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/;
|
|
6778
|
+
/** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
|
|
6779
|
+
const refNameFault = (name) => {
|
|
6780
|
+
if (name === "") return "it is empty";
|
|
6781
|
+
if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\";
|
|
6782
|
+
if (name.includes("..")) return "it contains \"..\"";
|
|
6783
|
+
if (name.includes("@{")) return "it contains \"@{\"";
|
|
6784
|
+
if (name.endsWith(".")) return "it ends with \".\"";
|
|
6785
|
+
for (const part of name.split("/")) {
|
|
6786
|
+
if (part === "") return "it has an empty \"/\" component";
|
|
6787
|
+
if (part.startsWith(".")) return "a component starts with \".\"";
|
|
6788
|
+
if (part.endsWith(".lock")) return "a component ends with \".lock\"";
|
|
6789
|
+
}
|
|
6790
|
+
};
|
|
6791
|
+
//#endregion
|
|
6773
6792
|
//#region src/replay/Tree.ts
|
|
6774
6793
|
const treeFromRecord = (files) => ({
|
|
6775
6794
|
paths: () => Object.keys(files).sort(),
|
|
@@ -6864,6 +6883,7 @@ const decodePath = (raw) => {
|
|
|
6864
6883
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
|
|
6865
6884
|
};
|
|
6866
6885
|
const FORMAT_RE = /^[1-9][0-9]*$/;
|
|
6886
|
+
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
6867
6887
|
const TRAILER_READERS = {
|
|
6868
6888
|
"Gtd-Step": (value, into) => {
|
|
6869
6889
|
into.step ??= parseStepId(value);
|
|
@@ -6893,6 +6913,9 @@ const TRAILER_READERS = {
|
|
|
6893
6913
|
"Gtd-Version": (value, into) => {
|
|
6894
6914
|
if (value !== "") into.version ??= value;
|
|
6895
6915
|
},
|
|
6916
|
+
"Gtd-Kept": (value, into) => {
|
|
6917
|
+
if (HASH_RE.test(value)) into.kept ??= value;
|
|
6918
|
+
},
|
|
6896
6919
|
"Gtd-Format": (value, into) => {
|
|
6897
6920
|
if (FORMAT_RE.test(value)) into.format ??= Number(value);
|
|
6898
6921
|
},
|
|
@@ -6913,6 +6936,7 @@ const parseCommitMessage = (message) => {
|
|
|
6913
6936
|
reviewBase: void 0,
|
|
6914
6937
|
branch: void 0,
|
|
6915
6938
|
version: void 0,
|
|
6939
|
+
kept: void 0,
|
|
6916
6940
|
judge: [],
|
|
6917
6941
|
vars: {},
|
|
6918
6942
|
config: {},
|
|
@@ -7027,7 +7051,8 @@ const KNOWN_OPTIONS = {
|
|
|
7027
7051
|
...STEERING_OPTIONS,
|
|
7028
7052
|
"model",
|
|
7029
7053
|
"system",
|
|
7030
|
-
"allowEmpty"
|
|
7054
|
+
"allowEmpty",
|
|
7055
|
+
"keep"
|
|
7031
7056
|
]),
|
|
7032
7057
|
human: /* @__PURE__ */ new Set([
|
|
7033
7058
|
...STEERING_OPTIONS,
|
|
@@ -7059,6 +7084,7 @@ const replay = async (input) => {
|
|
|
7059
7084
|
const scopeCallIds = [];
|
|
7060
7085
|
let scopeCallCount = 0;
|
|
7061
7086
|
const trace = [];
|
|
7087
|
+
const keptTips = /* @__PURE__ */ new Map();
|
|
7062
7088
|
let landed;
|
|
7063
7089
|
let outcome;
|
|
7064
7090
|
let signal;
|
|
@@ -7218,6 +7244,12 @@ const replay = async (input) => {
|
|
|
7218
7244
|
}
|
|
7219
7245
|
cursor++;
|
|
7220
7246
|
advance(commit);
|
|
7247
|
+
if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
|
|
7248
|
+
else keptTips.set(step.name, {
|
|
7249
|
+
hash: commit.kept.hash,
|
|
7250
|
+
before: commit.tree,
|
|
7251
|
+
after: commit.kept.tree
|
|
7252
|
+
});
|
|
7221
7253
|
const to = commit.parsed.parsed?.to;
|
|
7222
7254
|
if (to !== void 0) expectedNext = {
|
|
7223
7255
|
name: to,
|
|
@@ -7225,6 +7257,21 @@ const replay = async (input) => {
|
|
|
7225
7257
|
};
|
|
7226
7258
|
return replyFor(step.request, commit.parsed.judge);
|
|
7227
7259
|
};
|
|
7260
|
+
const landPending = (step, pending) => {
|
|
7261
|
+
pendingUsed = true;
|
|
7262
|
+
landed = step;
|
|
7263
|
+
const keeping = step.request.kind === "agent" && step.request.options.keep === true;
|
|
7264
|
+
if (keeping) keptTips.set(step.name, {
|
|
7265
|
+
hash: "",
|
|
7266
|
+
before: position.tree,
|
|
7267
|
+
after: pending.tree
|
|
7268
|
+
});
|
|
7269
|
+
advance({
|
|
7270
|
+
hash: "",
|
|
7271
|
+
tree: keeping ? position.tree : pending.tree
|
|
7272
|
+
});
|
|
7273
|
+
return replyFor(step.request, pending.verdicts ?? []);
|
|
7274
|
+
};
|
|
7228
7275
|
const answer = (step) => {
|
|
7229
7276
|
while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
|
|
7230
7277
|
const commit = commits[cursor];
|
|
@@ -7235,17 +7282,18 @@ const replay = async (input) => {
|
|
|
7235
7282
|
trace,
|
|
7236
7283
|
landed
|
|
7237
7284
|
});
|
|
7238
|
-
|
|
7239
|
-
landed = step;
|
|
7240
|
-
advance({
|
|
7241
|
-
hash: "",
|
|
7242
|
-
tree: input.pending.tree
|
|
7243
|
-
});
|
|
7244
|
-
return replyFor(step.request, input.pending.verdicts ?? []);
|
|
7285
|
+
return landPending(step, input.pending);
|
|
7245
7286
|
};
|
|
7246
7287
|
const requestFault = (request) => {
|
|
7247
7288
|
if (request.kind === "run" && typeof request.body !== "string") return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`;
|
|
7248
|
-
|
|
7289
|
+
if (request.kind !== "agent") return void 0;
|
|
7290
|
+
const name = scoped(request.name);
|
|
7291
|
+
if (request.options.keep === true) {
|
|
7292
|
+
if (request.options.allowEmpty === true) return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`;
|
|
7293
|
+
const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
|
|
7294
|
+
if (refFault !== void 0) return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`;
|
|
7295
|
+
}
|
|
7296
|
+
return scopeAccessFault();
|
|
7249
7297
|
};
|
|
7250
7298
|
const handleStep = async (request) => {
|
|
7251
7299
|
if (outcome !== void 0) throw new Stop();
|
|
@@ -7328,6 +7376,13 @@ const replay = async (input) => {
|
|
|
7328
7376
|
faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
|
|
7329
7377
|
}));
|
|
7330
7378
|
}),
|
|
7379
|
+
kept: (name) => {
|
|
7380
|
+
const tip = keptTips.get(scoped(name));
|
|
7381
|
+
return tip === void 0 ? void 0 : {
|
|
7382
|
+
hash: tip.hash,
|
|
7383
|
+
changes: changesBetween(tip.before, tip.after)
|
|
7384
|
+
};
|
|
7385
|
+
},
|
|
7331
7386
|
vars: input.vars,
|
|
7332
7387
|
env: input.env,
|
|
7333
7388
|
start: () => input.start,
|
|
@@ -8293,7 +8348,11 @@ const planStep = (snapshot, opts = {}) => {
|
|
|
8293
8348
|
...uncheckStep(snapshot),
|
|
8294
8349
|
{
|
|
8295
8350
|
kind: "gitWrite",
|
|
8296
|
-
write: {
|
|
8351
|
+
write: landing.keepTurn !== void 0 ? {
|
|
8352
|
+
kind: "keepTurn",
|
|
8353
|
+
ref: landing.keepTurn,
|
|
8354
|
+
message
|
|
8355
|
+
} : {
|
|
8297
8356
|
kind: "commitAll",
|
|
8298
8357
|
message
|
|
8299
8358
|
}
|
|
@@ -8409,6 +8468,10 @@ const resolveBase = (commandLabel, value) => gen(function* () {
|
|
|
8409
8468
|
return mergeBase.value;
|
|
8410
8469
|
});
|
|
8411
8470
|
//#endregion
|
|
8471
|
+
//#region src/step/strayPaths.ts
|
|
8472
|
+
/** Globs use the `access.write` dialect (`globMatches`). */
|
|
8473
|
+
const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
|
|
8474
|
+
//#endregion
|
|
8412
8475
|
//#region src/edge/Edge.ts
|
|
8413
8476
|
const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
8414
8477
|
const ACTORS = /* @__PURE__ */ new Set([
|
|
@@ -8768,6 +8831,7 @@ const promptDef = (common, request, skills, access) => ({
|
|
|
8768
8831
|
...optional("model", request.options.model),
|
|
8769
8832
|
...optional("system", request.options.system),
|
|
8770
8833
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
8834
|
+
...optional("keep", request.options.keep),
|
|
8771
8835
|
...optional("skills", skills),
|
|
8772
8836
|
...optional("access", access)
|
|
8773
8837
|
});
|
|
@@ -8882,11 +8946,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
|
|
|
8882
8946
|
hash: run.episode.base,
|
|
8883
8947
|
tree: commitTree(workspace, run.episode.base)
|
|
8884
8948
|
};
|
|
8885
|
-
const
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8949
|
+
const git = yield* GitService;
|
|
8950
|
+
const episode = [];
|
|
8951
|
+
for (const c of run.episode.commits) {
|
|
8952
|
+
const tip = parseCommitMessage(c.message).kept;
|
|
8953
|
+
const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
|
|
8954
|
+
episode.push({
|
|
8955
|
+
hash: c.hash,
|
|
8956
|
+
message: c.message,
|
|
8957
|
+
tree: commitTree(workspace, c.hash),
|
|
8958
|
+
...exists ? { kept: {
|
|
8959
|
+
hash: tip,
|
|
8960
|
+
tree: commitTree(workspace, tip)
|
|
8961
|
+
} } : {}
|
|
8962
|
+
});
|
|
8963
|
+
}
|
|
8890
8964
|
let pendingMemo;
|
|
8891
8965
|
return {
|
|
8892
8966
|
resolved,
|
|
@@ -9062,6 +9136,7 @@ const closingTrailers = (branch) => ({
|
|
|
9062
9136
|
...isSome(branch) ? { branch: branch.value } : {}
|
|
9063
9137
|
});
|
|
9064
9138
|
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
9139
|
+
const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
|
|
9065
9140
|
const landingTo = (rest, to, branch) => {
|
|
9066
9141
|
if (isSettledNoop(rest, to)) return {
|
|
9067
9142
|
kind: "noop",
|
|
@@ -9086,7 +9161,8 @@ const landingTo = (rest, to, branch) => {
|
|
|
9086
9161
|
} : {},
|
|
9087
9162
|
...finishes ? closingTrailers(branch) : {}
|
|
9088
9163
|
},
|
|
9089
|
-
...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
|
|
9164
|
+
...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
|
|
9165
|
+
...keptTurn(rest)
|
|
9090
9166
|
};
|
|
9091
9167
|
};
|
|
9092
9168
|
/**
|
|
@@ -9223,7 +9299,7 @@ const commitAllowEmpty = (message) => {
|
|
|
9223
9299
|
return [
|
|
9224
9300
|
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
9225
9301
|
` case "$out" in`,
|
|
9226
|
-
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
|
|
9302
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
|
|
9227
9303
|
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
9228
9304
|
` esac`,
|
|
9229
9305
|
`fi`
|
|
@@ -9245,6 +9321,36 @@ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
|
|
|
9245
9321
|
* `GitWriterOperations.discardPending` for why it's kept anyway.
|
|
9246
9322
|
*/
|
|
9247
9323
|
const discardPending = () => `git add -A && git reset --hard HEAD`;
|
|
9324
|
+
/**
|
|
9325
|
+
* POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
|
|
9326
|
+
* tree, so no hook runs on it and its tree is the pre-turn tree by
|
|
9327
|
+
* construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
|
|
9328
|
+
* `-S`, read (and failing closed) before any object or ref is written; the
|
|
9329
|
+
* index is already staged by then, so an early exit leaves the turn staged.
|
|
9330
|
+
* The kept ref is deleted only after `reset -q` succeeds: it is then the
|
|
9331
|
+
* turn's only handle.
|
|
9332
|
+
* The branch moves before the final hard reset, so if that reset fails the
|
|
9333
|
+
* marker has landed and the turn is still in the working tree (kept ref
|
|
9334
|
+
* present); every other failure leaves the branch untouched.
|
|
9335
|
+
*/
|
|
9336
|
+
const keepTurn = (refPrefix, message) => {
|
|
9337
|
+
const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
|
|
9338
|
+
return [
|
|
9339
|
+
`git add -A || exit 1`,
|
|
9340
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
9341
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
9342
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
9343
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
9344
|
+
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
9345
|
+
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
9346
|
+
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
9347
|
+
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
9348
|
+
` git reset -q && git update-ref -d ${keptRef}`,
|
|
9349
|
+
` exit 1`,
|
|
9350
|
+
`fi`,
|
|
9351
|
+
`git reset --hard HEAD`
|
|
9352
|
+
].join("\n");
|
|
9353
|
+
};
|
|
9248
9354
|
const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
|
|
9249
9355
|
/** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
|
|
9250
9356
|
const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
|
|
@@ -9277,6 +9383,7 @@ const renderGitWrite = (write) => {
|
|
|
9277
9383
|
switch (write.kind) {
|
|
9278
9384
|
case "commitAll": return commitAll(write.message);
|
|
9279
9385
|
case "updateRef": return updateRef(write.ref, write.target);
|
|
9386
|
+
case "keepTurn": return keepTurn(write.ref, write.message);
|
|
9280
9387
|
}
|
|
9281
9388
|
};
|
|
9282
9389
|
/**
|
|
@@ -9661,6 +9768,24 @@ const writeSelection = (out, fields, path) => gen(function* () {
|
|
|
9661
9768
|
else if (selection.kind === "unknown") return yield* fail$5(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
|
|
9662
9769
|
});
|
|
9663
9770
|
//#endregion
|
|
9771
|
+
//#region src/cli/Strays.ts
|
|
9772
|
+
const resolveSide = (git, side) => git.resolveRef(`${side}^{commit}`).pipe(mapError(() => new GtdError(`gtd strays: unknown revision '${side}'`)));
|
|
9773
|
+
/** stdout carries the array alone; the summary is ungated stderr so a quiet run still explains its exit code, the per-path lines are narration. */
|
|
9774
|
+
const runStraysCommand = (command, json, out) => gen(function* () {
|
|
9775
|
+
const git = yield* GitService;
|
|
9776
|
+
const narrator = yield* Narrator;
|
|
9777
|
+
const base = yield* resolveSide(git, command.base);
|
|
9778
|
+
const tip = yield* resolveSide(git, command.tip);
|
|
9779
|
+
const touched = yield* git.diffPaths(base, tip);
|
|
9780
|
+
const strays = strayPaths(touched, command.globs);
|
|
9781
|
+
const total = touched.length;
|
|
9782
|
+
if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => _void));
|
|
9783
|
+
else out.write(`${JSON.stringify(strays)}\n`);
|
|
9784
|
+
for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`);
|
|
9785
|
+
yield* narrator.warn(strays.length === 0 ? `gtd strays: no strays — ${total} touched files, all inside the allowlist` : `gtd strays: ${strays.length} of ${total} touched files outside the allowlist`);
|
|
9786
|
+
return strays.length === 0 ? 0 : 10;
|
|
9787
|
+
});
|
|
9788
|
+
//#endregion
|
|
9664
9789
|
//#region src/cli/History.ts
|
|
9665
9790
|
/** Newest tip first. */
|
|
9666
9791
|
const historyEntries = gen(function* () {
|
|
@@ -10313,7 +10438,8 @@ const needsOf = (kind) => {
|
|
|
10313
10438
|
case "lsp":
|
|
10314
10439
|
case "install":
|
|
10315
10440
|
case "loop":
|
|
10316
|
-
case "judgeRun":
|
|
10441
|
+
case "judgeRun":
|
|
10442
|
+
case "strays": return "none";
|
|
10317
10443
|
case "check":
|
|
10318
10444
|
case "uncheck": return "fs";
|
|
10319
10445
|
default: return "state";
|
|
@@ -10322,10 +10448,13 @@ const needsOf = (kind) => {
|
|
|
10322
10448
|
/**
|
|
10323
10449
|
* Dispatches to the named `run*Command` handler for every `Command.kind` —
|
|
10324
10450
|
* the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
|
|
10325
|
-
* every field a handler receives.
|
|
10326
|
-
*
|
|
10327
|
-
* `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
|
|
10451
|
+
* every field a handler receives. Only `strays` chooses its exit code; every
|
|
10452
|
+
* other branch returns `Effect<void>` and resolves to `EXIT_OK`.
|
|
10328
10453
|
*/
|
|
10454
|
+
const dispatchCommand = (command, json, out) => {
|
|
10455
|
+
if (command.kind === "strays") return runStraysCommand(command, json, out);
|
|
10456
|
+
return as(dispatchVoidCommand(command, json, out), 0);
|
|
10457
|
+
};
|
|
10329
10458
|
const dispatchVoidCommand = (command, json, out) => {
|
|
10330
10459
|
switch (command.kind) {
|
|
10331
10460
|
case "lsp": return runLspCommand();
|
|
@@ -10365,7 +10494,7 @@ const dispatchVoidCommand = (command, json, out) => {
|
|
|
10365
10494
|
* so a refusal writes nothing and emits no script by construction.
|
|
10366
10495
|
*/
|
|
10367
10496
|
const runCommand$1 = (command, json, out) => {
|
|
10368
|
-
const dispatch =
|
|
10497
|
+
const dispatch = dispatchCommand(command, json, out);
|
|
10369
10498
|
if (needsOf(command.kind) !== "state") return dispatch;
|
|
10370
10499
|
return gen(function* () {
|
|
10371
10500
|
const git = yield* GitService;
|
|
@@ -10378,7 +10507,7 @@ const runCommand$1 = (command, json, out) => {
|
|
|
10378
10507
|
warn: () => _void
|
|
10379
10508
|
}));
|
|
10380
10509
|
for (const warning of config.warnings) yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`);
|
|
10381
|
-
yield* dispatch;
|
|
10510
|
+
return yield* dispatch;
|
|
10382
10511
|
});
|
|
10383
10512
|
};
|
|
10384
10513
|
//#endregion
|
|
@@ -10393,13 +10522,13 @@ const FLAGS = [
|
|
|
10393
10522
|
name: "--json",
|
|
10394
10523
|
arity: "optional",
|
|
10395
10524
|
repeatable: false,
|
|
10396
|
-
scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
|
|
10525
|
+
scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow" || kind === "strays",
|
|
10397
10526
|
decode: ([raw]) => right(raw ?? ""),
|
|
10398
|
-
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",
|
|
10527
|
+
scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
10399
10528
|
valueHint: "<path>",
|
|
10400
10529
|
help: [
|
|
10401
10530
|
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
|
|
10402
|
-
"list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
|
|
10531
|
+
"list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
|
|
10403
10532
|
"--json=<path> (a dotted key path into that document, e.g.",
|
|
10404
10533
|
"kind, content, session.id) prints just that value: a",
|
|
10405
10534
|
"scalar raw and unquoted, a boolean as true/false, a list",
|
|
@@ -10908,6 +11037,28 @@ const COMMAND_ROWS = [
|
|
|
10908
11037
|
"every question, with nothing on stdout"
|
|
10909
11038
|
]
|
|
10910
11039
|
},
|
|
11040
|
+
{
|
|
11041
|
+
token: "strays",
|
|
11042
|
+
kind: "strays",
|
|
11043
|
+
arity: {
|
|
11044
|
+
name: "range",
|
|
11045
|
+
variadic: "glob"
|
|
11046
|
+
},
|
|
11047
|
+
details: [
|
|
11048
|
+
"List the files <range> (<base>..<tip>, an empty side is HEAD)",
|
|
11049
|
+
"touched in its committed net diff that match none of the",
|
|
11050
|
+
"globs. Anchored at the repo root: * stays within one path",
|
|
11051
|
+
"segment, ** crosses them, everything else is literal — quote",
|
|
11052
|
+
"globs in the shell. stdout: a sorted JSON array, [] when",
|
|
11053
|
+
"clean; stderr: a summary, plus one stray: line per path with",
|
|
11054
|
+
"--verbose. --json=<n> picks one entry, nothing past the end;",
|
|
11055
|
+
"a symlink is a path like any other, never followed; a",
|
|
11056
|
+
"submodule counts as its path, whatever diff.ignoreSubmodules",
|
|
11057
|
+
"or .gitmodules ignore say. Runs in any git repository, no",
|
|
11058
|
+
"process needed. Exits 10 when",
|
|
11059
|
+
"strays were found, 1 on an unknown revision, 2 on a bad range"
|
|
11060
|
+
]
|
|
11061
|
+
},
|
|
10911
11062
|
{
|
|
10912
11063
|
token: "history list",
|
|
10913
11064
|
kind: "historyList",
|
|
@@ -11250,6 +11401,22 @@ const parseArgv = (argv) => {
|
|
|
11250
11401
|
json,
|
|
11251
11402
|
verbose
|
|
11252
11403
|
};
|
|
11404
|
+
if (kind === "strays") {
|
|
11405
|
+
const [range, ...globs] = restPositionals;
|
|
11406
|
+
const at = range.indexOf("..");
|
|
11407
|
+
if (at < 0 || range.includes("...") || range.indexOf("..", at + 1) >= 0) return usagePlan(`gtd strays: <range> must be <base>..<tip> (an empty side is HEAD) — got '${range}'`, jsonSeen);
|
|
11408
|
+
return {
|
|
11409
|
+
kind: "command",
|
|
11410
|
+
command: {
|
|
11411
|
+
kind: "strays",
|
|
11412
|
+
base: range.slice(0, at) || "HEAD",
|
|
11413
|
+
tip: range.slice(at + 2) || "HEAD",
|
|
11414
|
+
globs
|
|
11415
|
+
},
|
|
11416
|
+
json,
|
|
11417
|
+
verbose
|
|
11418
|
+
};
|
|
11419
|
+
}
|
|
11253
11420
|
if (kind === "uncheck") return {
|
|
11254
11421
|
kind: "command",
|
|
11255
11422
|
command: {
|
|
@@ -11328,9 +11495,9 @@ const runCli = (argv, io) => {
|
|
|
11328
11495
|
return flatMap$4(Host, ({ env }) => try_({
|
|
11329
11496
|
try: () => resolveFlags(env, io.flags),
|
|
11330
11497
|
catch: (e) => new GtdError(e.message)
|
|
11331
|
-
})).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4(() => {
|
|
11498
|
+
})).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4((code) => {
|
|
11332
11499
|
out.flush();
|
|
11333
|
-
io.exit(
|
|
11500
|
+
io.exit(code);
|
|
11334
11501
|
}), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
|
|
11335
11502
|
};
|
|
11336
11503
|
//#endregion
|
|
@@ -11406,6 +11573,7 @@ var InMemRepo = class InMemRepo {
|
|
|
11406
11573
|
return this.head !== null;
|
|
11407
11574
|
}
|
|
11408
11575
|
resolveRef(ref) {
|
|
11576
|
+
if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -9));
|
|
11409
11577
|
if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
|
|
11410
11578
|
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
|
|
11411
11579
|
if (tildeMatch) {
|
|
@@ -11526,6 +11694,10 @@ var InMemRepo = class InMemRepo {
|
|
|
11526
11694
|
changedPathsWorktree(base) {
|
|
11527
11695
|
return diffTrees(this.treeAt(base), new Map(this.worktree));
|
|
11528
11696
|
}
|
|
11697
|
+
diffPaths(base, tip) {
|
|
11698
|
+
for (const ref of [base, tip]) if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`);
|
|
11699
|
+
return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path);
|
|
11700
|
+
}
|
|
11529
11701
|
/** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
|
|
11530
11702
|
readFile(path) {
|
|
11531
11703
|
return this.worktree.get(path);
|
|
@@ -11578,8 +11750,12 @@ var InMemRepo = class InMemRepo {
|
|
|
11578
11750
|
stageAll() {
|
|
11579
11751
|
this.index = new Map(this.worktree);
|
|
11580
11752
|
}
|
|
11581
|
-
/**
|
|
11582
|
-
|
|
11753
|
+
/** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
|
|
11754
|
+
hookRejection = void 0;
|
|
11755
|
+
signingFails = false;
|
|
11756
|
+
/** Seeded: a pre-commit hook writing and staging this file. */
|
|
11757
|
+
hookStages = void 0;
|
|
11758
|
+
writeCommit(message) {
|
|
11583
11759
|
const tree = new Map(this.index);
|
|
11584
11760
|
const parent = this.head;
|
|
11585
11761
|
const hash = makeHash(message, parent, tree);
|
|
@@ -11594,27 +11770,59 @@ var InMemRepo = class InMemRepo {
|
|
|
11594
11770
|
this.head = hash;
|
|
11595
11771
|
this.branches.set(this.currentBranch, hash);
|
|
11596
11772
|
}
|
|
11597
|
-
/**
|
|
11598
|
-
|
|
11599
|
-
|
|
11600
|
-
|
|
11773
|
+
/**
|
|
11774
|
+
* Twin of the emitted `git commit --allow-empty` if/case retry: a hook
|
|
11775
|
+
* rejection printing "empty git commit" retries without hooks, any other
|
|
11776
|
+
* rejection fails; a failing signer fails either attempt. Commits whatever is
|
|
11777
|
+
* staged verbatim, like `git commit` after a soft reset.
|
|
11778
|
+
*/
|
|
11779
|
+
commitAsIs(message) {
|
|
11780
|
+
if (this.hookRejection !== void 0 && !this.hookRejection.includes("empty git commit")) return this.hookRejection;
|
|
11781
|
+
if (this.signingFails) return "error: gpg failed to sign the data";
|
|
11782
|
+
if (this.hookStages !== void 0) {
|
|
11783
|
+
this.worktree.set(this.hookStages.path, this.hookStages.content);
|
|
11784
|
+
this.index.set(this.hookStages.path, this.hookStages.content);
|
|
11785
|
+
}
|
|
11786
|
+
this.writeCommit(message);
|
|
11601
11787
|
}
|
|
11602
|
-
|
|
11788
|
+
/** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
|
|
11789
|
+
seedCommit(message) {
|
|
11790
|
+
if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
|
|
11603
11791
|
this.index = new Map(this.worktree);
|
|
11792
|
+
this.writeCommit(message);
|
|
11793
|
+
}
|
|
11794
|
+
/** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
|
|
11795
|
+
keepTurn(refPrefix, message) {
|
|
11796
|
+
this.stageAll();
|
|
11604
11797
|
const tree = new Map(this.index);
|
|
11605
|
-
const message = prefix;
|
|
11606
11798
|
const parent = this.head;
|
|
11607
11799
|
const hash = makeHash(message, parent, tree);
|
|
11608
|
-
|
|
11800
|
+
this.commits.set(hash, {
|
|
11609
11801
|
hash,
|
|
11610
11802
|
message,
|
|
11611
11803
|
files: new Map(tree),
|
|
11612
11804
|
parent,
|
|
11613
11805
|
seq: this.nextSeq++
|
|
11614
|
-
};
|
|
11615
|
-
|
|
11616
|
-
this.
|
|
11617
|
-
this.
|
|
11806
|
+
});
|
|
11807
|
+
const ref = `${refPrefix}/${hash}`;
|
|
11808
|
+
this.refs.set(ref, hash);
|
|
11809
|
+
this.index = new Map(this.headTree());
|
|
11810
|
+
if (this.signingFails) {
|
|
11811
|
+
this.refs.delete(ref);
|
|
11812
|
+
return "error: gpg failed to sign the data";
|
|
11813
|
+
}
|
|
11814
|
+
this.writeCommit(`${message}\nGtd-Kept: ${hash}`);
|
|
11815
|
+
this.index = new Map(tree);
|
|
11816
|
+
this.resetHard();
|
|
11817
|
+
}
|
|
11818
|
+
/** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
|
|
11819
|
+
discardPending() {
|
|
11820
|
+
this.index = new Map(this.worktree);
|
|
11821
|
+
this.resetHard();
|
|
11822
|
+
}
|
|
11823
|
+
commitAllWithPrefix(prefix) {
|
|
11824
|
+
this.index = new Map(this.worktree);
|
|
11825
|
+
return this.commitAsIs(prefix);
|
|
11618
11826
|
}
|
|
11619
11827
|
static EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
11620
11828
|
softResetTo(ref) {
|
|
@@ -11767,6 +11975,7 @@ const makeGitReaderOps = (repo, root) => ({
|
|
|
11767
11975
|
return content !== null ? succeed$6(content) : fail$5(/* @__PURE__ */ new Error(`path not found at ${ref}: ${path}`));
|
|
11768
11976
|
},
|
|
11769
11977
|
readRefOption: (ref) => {
|
|
11978
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return succeed$6(some(ref));
|
|
11770
11979
|
const hash = repo.resolveRef(ref);
|
|
11771
11980
|
return succeed$6(hash !== null ? some(hash) : none());
|
|
11772
11981
|
},
|
|
@@ -11787,12 +11996,16 @@ const makeGitReaderOps = (repo, root) => ({
|
|
|
11787
11996
|
gitCommonDir: () => succeed$6(`${root}/.git`),
|
|
11788
11997
|
commitHistory: (base, head) => succeed$6(repo.commitHistory(base, head)),
|
|
11789
11998
|
subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
|
|
11790
|
-
changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base))
|
|
11999
|
+
changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base)),
|
|
12000
|
+
diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip))
|
|
11791
12001
|
});
|
|
12002
|
+
const failOn = (output) => {
|
|
12003
|
+
if (output !== void 0) throw new Error(output);
|
|
12004
|
+
};
|
|
11792
12005
|
const makeGitWriterOps = (repo) => ({
|
|
11793
|
-
commitAllWithPrefix: (prefix) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
|
|
12006
|
+
commitAllWithPrefix: (prefix) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
|
|
11794
12007
|
softResetTo: (ref) => tryCatch(() => repo.softResetTo(ref)),
|
|
11795
|
-
commitAsIs: (message) => tryCatch(() => repo.commitAsIs(message)),
|
|
12008
|
+
commitAsIs: (message) => tryCatch(() => failOn(repo.commitAsIs(message))),
|
|
11796
12009
|
discardPending: () => tryCatch(() => repo.discardPending()),
|
|
11797
12010
|
updateRef: (ref, hash) => tryCatch(() => repo.updateRef(ref, hash)),
|
|
11798
12011
|
deleteRef: (ref) => tryCatch(() => repo.deleteRef(ref)),
|
|
@@ -11921,6 +12134,13 @@ const missingToolCommands = {
|
|
|
11921
12134
|
};
|
|
11922
12135
|
/** The fine-grained `GitService` layer alone — for unit tests that need only git. */
|
|
11923
12136
|
const gitTestLayer = (repo, root = "/repo") => succeed$1(GitService, fakeGitOperations(repo, root));
|
|
12137
|
+
/**
|
|
12138
|
+
* The one sanctioned leak of the real `process.env` into the in-memory `Host`:
|
|
12139
|
+
* the runner's `GTD_FLAG_*` state, so a flag-on `npm test` state exercises the
|
|
12140
|
+
* flag in memory as it does live. A test asserting one flag side sets the flag
|
|
12141
|
+
* itself; explicit env wins.
|
|
12142
|
+
*/
|
|
12143
|
+
const ambientFlagEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter((e) => e[0].startsWith("GTD_FLAG_") && e[1] !== void 0));
|
|
11924
12144
|
/** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
|
|
11925
12145
|
function testLayers(repo, opts = {}) {
|
|
11926
12146
|
const root = opts.root ?? "/repo";
|
|
@@ -11928,7 +12148,10 @@ function testLayers(repo, opts = {}) {
|
|
|
11928
12148
|
return mergeAll$1(gitTestLayer(repo, root), succeed$1(Workspace, makeInMemoryWorkspaceOps(repo, root)), Host.layer({
|
|
11929
12149
|
root,
|
|
11930
12150
|
home,
|
|
11931
|
-
env:
|
|
12151
|
+
env: {
|
|
12152
|
+
...ambientFlagEnv(),
|
|
12153
|
+
...opts.env
|
|
12154
|
+
},
|
|
11932
12155
|
realPath: () => succeed$6(root)
|
|
11933
12156
|
}), ConfigService.Live, makeInMemoryConfigDiscovery(repo, root), Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true), succeed$1(ToolCommands, {
|
|
11934
12157
|
...missingToolCommands,
|
|
@@ -12295,25 +12518,51 @@ const conditionStatement = (block) => {
|
|
|
12295
12518
|
const end = block.indexOf(CONDITION_TERMINATOR, start);
|
|
12296
12519
|
return end === -1 ? block.slice(start) : block.slice(start, end);
|
|
12297
12520
|
};
|
|
12521
|
+
/** `keepTurn`: an invalid ref name fails before anything moves, as `git update-ref` does. */
|
|
12522
|
+
const recognizeKeepTurn = (repo, block) => {
|
|
12523
|
+
const [message, refDir] = extractQuotedTokens(block);
|
|
12524
|
+
const ref = refDir?.slice(0, -1);
|
|
12525
|
+
if (message === void 0 || ref === void 0 || keepTurn(ref, message) !== block) return;
|
|
12526
|
+
if (refNameFault(ref) !== void 0) return {
|
|
12527
|
+
kind: "failed",
|
|
12528
|
+
error: `fatal: invalid ref format: ${ref}`,
|
|
12529
|
+
status: 128
|
|
12530
|
+
};
|
|
12531
|
+
const failure = repo.keepTurn(ref, message);
|
|
12532
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12533
|
+
kind: "failed",
|
|
12534
|
+
error: failure,
|
|
12535
|
+
status: 1
|
|
12536
|
+
};
|
|
12537
|
+
};
|
|
12298
12538
|
const recognizeGitBuilders = (repo, block) => {
|
|
12299
12539
|
if (block === discardPending()) {
|
|
12300
12540
|
repo.discardPending();
|
|
12301
12541
|
return { kind: "applied" };
|
|
12302
12542
|
}
|
|
12303
12543
|
const conditionLine = conditionStatement(block);
|
|
12544
|
+
if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
|
|
12304
12545
|
if (block.startsWith("git add -A || exit 1\n")) {
|
|
12305
12546
|
const [message] = extractQuotedTokens(conditionLine);
|
|
12306
12547
|
if (message !== void 0 && commitAll(message) === block) {
|
|
12307
|
-
repo.commitAllWithPrefix(message);
|
|
12308
|
-
return { kind: "applied" }
|
|
12548
|
+
const failure = repo.commitAllWithPrefix(message);
|
|
12549
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12550
|
+
kind: "failed",
|
|
12551
|
+
error: failure,
|
|
12552
|
+
status: 1
|
|
12553
|
+
};
|
|
12309
12554
|
}
|
|
12310
12555
|
return;
|
|
12311
12556
|
}
|
|
12312
12557
|
if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
|
|
12313
12558
|
const [message] = extractQuotedTokens(conditionLine);
|
|
12314
12559
|
if (message !== void 0 && commitAsIs(message) === block) {
|
|
12315
|
-
repo.commitAsIs(message);
|
|
12316
|
-
return { kind: "applied" }
|
|
12560
|
+
const failure = repo.commitAsIs(message);
|
|
12561
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12562
|
+
kind: "failed",
|
|
12563
|
+
error: failure,
|
|
12564
|
+
status: 1
|
|
12565
|
+
};
|
|
12317
12566
|
}
|
|
12318
12567
|
return;
|
|
12319
12568
|
}
|
|
@@ -12850,7 +13099,7 @@ const testRepo = (options) => {
|
|
|
12850
13099
|
const id = `module-${++registered}`;
|
|
12851
13100
|
repo.writeFile("gtd.config.ts", typeof workflow === "string" ? workflow : moduleSource(id, workflow));
|
|
12852
13101
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content);
|
|
12853
|
-
repo.
|
|
13102
|
+
repo.seedCommit("chore: initial commit");
|
|
12854
13103
|
const gtd = async (...args) => {
|
|
12855
13104
|
const { io, result } = makeCapturingCliIo(repo, env);
|
|
12856
13105
|
if (typeof workflow !== "string") registry().set(id, workflow);
|
|
@@ -12918,7 +13167,7 @@ const testRepo = (options) => {
|
|
|
12918
13167
|
read: (path) => repo.readFile(path),
|
|
12919
13168
|
write: (path, content) => repo.writeFile(path, content),
|
|
12920
13169
|
remove: (path) => repo.deleteFile(path),
|
|
12921
|
-
commit: (message) => repo.
|
|
13170
|
+
commit: (message) => repo.seedCommit(message),
|
|
12922
13171
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
|
|
12923
13172
|
runScript: (script) => {
|
|
12924
13173
|
const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
|
|
@@ -12958,6 +13207,14 @@ const testRepo = (options) => {
|
|
|
12958
13207
|
};
|
|
12959
13208
|
//#endregion
|
|
12960
13209
|
//#region src/testkit/GitTiers.ts
|
|
13210
|
+
const writeRejectingHook = (hooksDir, output) => {
|
|
13211
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
13212
|
+
writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\ncat >&2 <<'GTD_HOOK_EOF'\n${output}\nGTD_HOOK_EOF\nexit 1\n`, { mode: 493 });
|
|
13213
|
+
};
|
|
13214
|
+
const writeStagingHook = (hooksDir, path, content) => {
|
|
13215
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
13216
|
+
writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\nprintf '%s' ${shellQuote(content)} > ${shellQuote(path)} && git add ${shellQuote(path)}\nexit 0\n`, { mode: 493 });
|
|
13217
|
+
};
|
|
12961
13218
|
/** No-op — these tests assert on git/config behavior, not narration. */
|
|
12962
13219
|
const noopNarratorLayer = Narrator.layer(() => {}, false);
|
|
12963
13220
|
const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
|
|
@@ -13007,7 +13264,6 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
13007
13264
|
root,
|
|
13008
13265
|
capabilities: {
|
|
13009
13266
|
onDiskContent: true,
|
|
13010
|
-
commitHooks: true,
|
|
13011
13267
|
linkedWorktrees: true,
|
|
13012
13268
|
directoryChainConfig: true,
|
|
13013
13269
|
remotes: true
|
|
@@ -13035,7 +13291,22 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
13035
13291
|
},
|
|
13036
13292
|
stageAll: () => gitExec("add", "-A"),
|
|
13037
13293
|
updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
|
|
13038
|
-
mixedReset: (ref) => gitExec("reset", "--mixed", ref)
|
|
13294
|
+
mixedReset: (ref) => gitExec("reset", "--mixed", ref),
|
|
13295
|
+
rejectingHook: (output) => {
|
|
13296
|
+
const hooks = join(root, ".git/hooks");
|
|
13297
|
+
gitExec("config", "core.hooksPath", hooks);
|
|
13298
|
+
writeRejectingHook(hooks, output);
|
|
13299
|
+
},
|
|
13300
|
+
stagingHook: (path, content) => {
|
|
13301
|
+
const hooks = join(root, ".git/hooks");
|
|
13302
|
+
gitExec("config", "core.hooksPath", hooks);
|
|
13303
|
+
writeStagingHook(hooks, path, content);
|
|
13304
|
+
},
|
|
13305
|
+
failingSigner: () => {
|
|
13306
|
+
gitExec("config", "commit.gpgsign", "true");
|
|
13307
|
+
gitExec("config", "gpg.format", "openpgp");
|
|
13308
|
+
gitExec("config", "gpg.program", "false");
|
|
13309
|
+
}
|
|
13039
13310
|
},
|
|
13040
13311
|
observe: {
|
|
13041
13312
|
resolveRef: (ref) => gitExec("rev-parse", ref),
|
|
@@ -13101,7 +13372,7 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13101
13372
|
const repo = new InMemRepo();
|
|
13102
13373
|
if (initialCommit) {
|
|
13103
13374
|
repo.writeFile("readme.txt", "hello");
|
|
13104
|
-
repo.
|
|
13375
|
+
repo.seedCommit("init: first commit");
|
|
13105
13376
|
}
|
|
13106
13377
|
const gitLayer = gitTestLayer(repo, IN_MEM_ROOT);
|
|
13107
13378
|
const provide = (eff) => runPromise(eff.pipe(provide$2(gitLayer), provide$2(noopNarratorLayer)));
|
|
@@ -13111,7 +13382,6 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13111
13382
|
root: IN_MEM_ROOT,
|
|
13112
13383
|
capabilities: {
|
|
13113
13384
|
onDiskContent: false,
|
|
13114
|
-
commitHooks: false,
|
|
13115
13385
|
linkedWorktrees: false,
|
|
13116
13386
|
directoryChainConfig: true,
|
|
13117
13387
|
remotes: false
|
|
@@ -13122,17 +13392,29 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13122
13392
|
seed: {
|
|
13123
13393
|
commit: (message, files = { "file.txt": message }) => {
|
|
13124
13394
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content);
|
|
13125
|
-
repo.
|
|
13395
|
+
repo.seedCommit(message);
|
|
13126
13396
|
},
|
|
13127
13397
|
writeFile: (path, content) => repo.writeFile(path, content),
|
|
13128
13398
|
deleteFile: (path) => repo.deleteFile(path),
|
|
13129
13399
|
commitDeletion: (path, message) => {
|
|
13130
13400
|
repo.deleteFile(path);
|
|
13131
|
-
repo.
|
|
13401
|
+
repo.seedCommit(message);
|
|
13132
13402
|
},
|
|
13133
13403
|
stageAll: () => repo.stageAll(),
|
|
13134
13404
|
updateRef: (ref, hash) => repo.updateRef(ref, hash),
|
|
13135
|
-
mixedReset: (ref) => repo.mixedResetTo(ref)
|
|
13405
|
+
mixedReset: (ref) => repo.mixedResetTo(ref),
|
|
13406
|
+
rejectingHook: (output) => {
|
|
13407
|
+
repo.hookRejection = output;
|
|
13408
|
+
},
|
|
13409
|
+
stagingHook: (path, content) => {
|
|
13410
|
+
repo.hookStages = {
|
|
13411
|
+
path,
|
|
13412
|
+
content
|
|
13413
|
+
};
|
|
13414
|
+
},
|
|
13415
|
+
failingSigner: () => {
|
|
13416
|
+
repo.signingFails = true;
|
|
13417
|
+
}
|
|
13136
13418
|
},
|
|
13137
13419
|
observe: {
|
|
13138
13420
|
resolveRef: (ref) => {
|
|
@@ -13193,7 +13475,7 @@ const PATHOLOGICAL_PATHS = [
|
|
|
13193
13475
|
}
|
|
13194
13476
|
];
|
|
13195
13477
|
/**
|
|
13196
|
-
* Exercise
|
|
13478
|
+
* Exercise every `GitOperations` methods identically against `makeTier()` —
|
|
13197
13479
|
* called once per tier by `src/platform/Git.test.ts`. A capability-gated group
|
|
13198
13480
|
* (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
|
|
13199
13481
|
*/
|
|
@@ -13228,7 +13510,8 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13228
13510
|
g.subjectHistory(10, 0),
|
|
13229
13511
|
g.readFileAtRef("HEAD", "second.txt"),
|
|
13230
13512
|
g.changedPaths(),
|
|
13231
|
-
g.changedPaths("HEAD~1")
|
|
13513
|
+
g.changedPaths("HEAD~1"),
|
|
13514
|
+
g.diffPaths("HEAD~1", "HEAD")
|
|
13232
13515
|
]));
|
|
13233
13516
|
expect(await t.observe.snapshot()).toBe(before);
|
|
13234
13517
|
await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
|
|
@@ -13463,16 +13746,6 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13463
13746
|
const history = await runGit(t, (g) => g.commitHistory());
|
|
13464
13747
|
expect(history[history.length - 1]?.message).toBe("gtd: grilled");
|
|
13465
13748
|
});
|
|
13466
|
-
it.skipIf(!capabilities.commitHooks)("retries with --no-verify when a hook blocks the empty commit", async () => {
|
|
13467
|
-
const hookPath = join(t.root, ".git/hooks/pre-commit");
|
|
13468
|
-
writeFileSync(hookPath, `#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`);
|
|
13469
|
-
execSync(`chmod +x "${hookPath}"`);
|
|
13470
|
-
const headBefore = t.observe.resolveRef("HEAD");
|
|
13471
|
-
await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"));
|
|
13472
|
-
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
13473
|
-
const history = await runGit(t, (g) => g.commitHistory());
|
|
13474
|
-
expect(history[history.length - 1]?.message).toBe("gtd: grilled");
|
|
13475
|
-
});
|
|
13476
13749
|
});
|
|
13477
13750
|
describe("commitAsIs", () => {
|
|
13478
13751
|
it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
|
|
@@ -13491,6 +13764,29 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13491
13764
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
13492
13765
|
});
|
|
13493
13766
|
});
|
|
13767
|
+
describe.each([["commitAllWithPrefix", (g) => g.commitAllWithPrefix("gtd: building")], ["commitAsIs", (g) => g.commitAsIs("gtd: building")]])("%s seeded commit failures", (_name, commit) => {
|
|
13768
|
+
it("retries with --no-verify when a hook blocks the empty commit", async () => {
|
|
13769
|
+
t.seed.rejectingHook("lint-staged prevented an empty git commit.");
|
|
13770
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
13771
|
+
await runGit(t, commit);
|
|
13772
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
13773
|
+
const history = await runGit(t, (g) => g.commitHistory());
|
|
13774
|
+
expect(history[history.length - 1]?.message).toBe("gtd: building");
|
|
13775
|
+
});
|
|
13776
|
+
it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
|
|
13777
|
+
t.seed.rejectingHook("lint error");
|
|
13778
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
13779
|
+
expect(isFailure(await runGitExit(t, commit))).toBe(true);
|
|
13780
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
|
|
13781
|
+
});
|
|
13782
|
+
it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
|
|
13783
|
+
t.seed.rejectingHook("rejected: empty git commit");
|
|
13784
|
+
t.seed.failingSigner();
|
|
13785
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
13786
|
+
expect(isFailure(await runGitExit(t, commit))).toBe(true);
|
|
13787
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
|
|
13788
|
+
});
|
|
13789
|
+
});
|
|
13494
13790
|
describe("softResetTo", () => {
|
|
13495
13791
|
it("moves HEAD back but keeps worktree changes from second commit", async () => {
|
|
13496
13792
|
const firstHash = t.observe.resolveRef("HEAD");
|
|
@@ -13538,6 +13834,20 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13538
13834
|
it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
|
|
13539
13835
|
expect((await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent")))._tag).toBe("None");
|
|
13540
13836
|
});
|
|
13837
|
+
it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
|
|
13838
|
+
const head = t.observe.resolveRef("HEAD");
|
|
13839
|
+
const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`));
|
|
13840
|
+
expect(resolved._tag).toBe("Some");
|
|
13841
|
+
if (resolved._tag === "Some") expect(resolved.value).toBe(head);
|
|
13842
|
+
});
|
|
13843
|
+
it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
|
|
13844
|
+
const missing = "0123456789abcdef0123456789abcdef01234567";
|
|
13845
|
+
expect((await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`)))._tag).toBe("None");
|
|
13846
|
+
});
|
|
13847
|
+
it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
|
|
13848
|
+
const missing = "0123456789abcdef0123456789abcdef01234567";
|
|
13849
|
+
expect((await runGit(t, (g) => g.readRefOption(missing)))._tag).toBe("Some");
|
|
13850
|
+
});
|
|
13541
13851
|
it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
|
|
13542
13852
|
const head = t.observe.resolveRef("HEAD");
|
|
13543
13853
|
await runGit(t, (g) => g.updateRef("refs/gtd/marker", head));
|
|
@@ -13606,6 +13916,71 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13606
13916
|
expect(isFailure(await runGitExit(t, (g) => g.readFileAtRef("HEAD", "nonexistent.txt")))).toBe(true);
|
|
13607
13917
|
});
|
|
13608
13918
|
});
|
|
13919
|
+
describe("diffPaths", () => {
|
|
13920
|
+
const sorted = (paths) => [...paths].sort();
|
|
13921
|
+
it("reports added, modified and deleted paths between two commits", async () => {
|
|
13922
|
+
t.seed.commit("chore: seed", {
|
|
13923
|
+
"mod.txt": "one",
|
|
13924
|
+
"del.txt": "bye"
|
|
13925
|
+
});
|
|
13926
|
+
const base = t.observe.resolveRef("HEAD");
|
|
13927
|
+
t.seed.writeFile("mod.txt", "two");
|
|
13928
|
+
t.seed.stageAll();
|
|
13929
|
+
t.seed.commitDeletion("del.txt", "chore: delete");
|
|
13930
|
+
t.seed.commit("feat: add", {
|
|
13931
|
+
"add.txt": "new",
|
|
13932
|
+
"mod.txt": "two"
|
|
13933
|
+
});
|
|
13934
|
+
const tip = t.observe.resolveRef("HEAD");
|
|
13935
|
+
const paths = await runGit(t, (g) => g.diffPaths(base, tip));
|
|
13936
|
+
expect(sorted(paths)).toEqual([
|
|
13937
|
+
"add.txt",
|
|
13938
|
+
"del.txt",
|
|
13939
|
+
"mod.txt"
|
|
13940
|
+
]);
|
|
13941
|
+
});
|
|
13942
|
+
it("reports both paths of a rename", async () => {
|
|
13943
|
+
t.seed.commit("chore: seed", { "old.txt": "same content unique-marker\n" });
|
|
13944
|
+
const base = t.observe.resolveRef("HEAD");
|
|
13945
|
+
t.seed.deleteFile("old.txt");
|
|
13946
|
+
t.seed.commit("refactor: rename", { "new.txt": "same content unique-marker\n" });
|
|
13947
|
+
const tip = t.observe.resolveRef("HEAD");
|
|
13948
|
+
expect(sorted(await runGit(t, (g) => g.diffPaths(base, tip)))).toEqual(["new.txt", "old.txt"]);
|
|
13949
|
+
});
|
|
13950
|
+
it("omits a file changed then restored between the endpoints", async () => {
|
|
13951
|
+
t.seed.commit("chore: seed", { "flip.txt": "one" });
|
|
13952
|
+
const base = t.observe.resolveRef("HEAD");
|
|
13953
|
+
t.seed.commit("chore: change", { "flip.txt": "two" });
|
|
13954
|
+
t.seed.commit("chore: restore", { "flip.txt": "one" });
|
|
13955
|
+
const tip = t.observe.resolveRef("HEAD");
|
|
13956
|
+
expect(await runGit(t, (g) => g.diffPaths(base, tip))).toEqual([]);
|
|
13957
|
+
});
|
|
13958
|
+
it("never reports staged, unstaged or untracked changes", async () => {
|
|
13959
|
+
t.seed.commit("chore: seed", {
|
|
13960
|
+
"a.txt": "a",
|
|
13961
|
+
"b.txt": "b"
|
|
13962
|
+
});
|
|
13963
|
+
const base = t.observe.resolveRef("HEAD");
|
|
13964
|
+
t.seed.writeFile("a.txt", "staged");
|
|
13965
|
+
t.seed.stageAll();
|
|
13966
|
+
t.seed.writeFile("b.txt", "unstaged");
|
|
13967
|
+
t.seed.writeFile("untracked.txt", "u");
|
|
13968
|
+
expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([]);
|
|
13969
|
+
});
|
|
13970
|
+
it("is [] for diffPaths(x, x)", async () => {
|
|
13971
|
+
const head = t.observe.resolveRef("HEAD");
|
|
13972
|
+
expect(await runGit(t, (g) => g.diffPaths(head, head))).toEqual([]);
|
|
13973
|
+
});
|
|
13974
|
+
for (const { label, path } of PATHOLOGICAL_PATHS) it(`reports a path containing ${label}, verbatim`, async () => {
|
|
13975
|
+
const base = t.observe.resolveRef("HEAD");
|
|
13976
|
+
t.seed.commit("feat: pathological", { [path]: "content" });
|
|
13977
|
+
expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([path]);
|
|
13978
|
+
});
|
|
13979
|
+
it("fails when either side does not resolve", async () => {
|
|
13980
|
+
expect(isFailure(await runGitExit(t, (g) => g.diffPaths("totally-invalid-ref-xyz", "HEAD")))).toBe(true);
|
|
13981
|
+
expect(isFailure(await runGitExit(t, (g) => g.diffPaths("HEAD", "totally-invalid-ref-xyz")))).toBe(true);
|
|
13982
|
+
});
|
|
13983
|
+
});
|
|
13609
13984
|
describe("changedPaths", () => {
|
|
13610
13985
|
it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
|
|
13611
13986
|
t.seed.commit("chore: seed", {
|