@pmelab/gtd 26.0.0 → 26.1.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 +5 -0
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +134 -18
- package/dist/testkit.mjs +284 -55
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +3 -3
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Program.test.ts +10 -12
- 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/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/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- 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 +8 -2
- package/src/testkit/GitTiers.ts +103 -24
- package/src/testkit/InMemRepo.ts +57 -12
- package/src/testkit/TestRepo.ts +2 -2
- 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
|
@@ -6071,6 +6071,8 @@ const HISTORY_REF = "refs/worktree/gtd/history";
|
|
|
6071
6071
|
/** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
|
|
6072
6072
|
const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
|
|
6073
6073
|
const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
|
|
6074
|
+
/** 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. */
|
|
6075
|
+
const keptRef = (entry, step) => `refs/gtd/kept/${entry}/${step}`;
|
|
6074
6076
|
const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
|
|
6075
6077
|
/**
|
|
6076
6078
|
* Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
|
|
@@ -6770,6 +6772,22 @@ const accessShapeFault = (value) => {
|
|
|
6770
6772
|
const sameGlobs = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((g, i) => g === b[i]);
|
|
6771
6773
|
const sameAccess = (a, b) => sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write);
|
|
6772
6774
|
//#endregion
|
|
6775
|
+
//#region src/replay/RefName.ts
|
|
6776
|
+
const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/;
|
|
6777
|
+
/** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
|
|
6778
|
+
const refNameFault = (name) => {
|
|
6779
|
+
if (name === "") return "it is empty";
|
|
6780
|
+
if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\";
|
|
6781
|
+
if (name.includes("..")) return "it contains \"..\"";
|
|
6782
|
+
if (name.includes("@{")) return "it contains \"@{\"";
|
|
6783
|
+
if (name.endsWith(".")) return "it ends with \".\"";
|
|
6784
|
+
for (const part of name.split("/")) {
|
|
6785
|
+
if (part === "") return "it has an empty \"/\" component";
|
|
6786
|
+
if (part.startsWith(".")) return "a component starts with \".\"";
|
|
6787
|
+
if (part.endsWith(".lock")) return "a component ends with \".lock\"";
|
|
6788
|
+
}
|
|
6789
|
+
};
|
|
6790
|
+
//#endregion
|
|
6773
6791
|
//#region src/replay/Tree.ts
|
|
6774
6792
|
const treeFromRecord = (files) => ({
|
|
6775
6793
|
paths: () => Object.keys(files).sort(),
|
|
@@ -6864,6 +6882,7 @@ const decodePath = (raw) => {
|
|
|
6864
6882
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
|
|
6865
6883
|
};
|
|
6866
6884
|
const FORMAT_RE = /^[1-9][0-9]*$/;
|
|
6885
|
+
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
6867
6886
|
const TRAILER_READERS = {
|
|
6868
6887
|
"Gtd-Step": (value, into) => {
|
|
6869
6888
|
into.step ??= parseStepId(value);
|
|
@@ -6893,6 +6912,9 @@ const TRAILER_READERS = {
|
|
|
6893
6912
|
"Gtd-Version": (value, into) => {
|
|
6894
6913
|
if (value !== "") into.version ??= value;
|
|
6895
6914
|
},
|
|
6915
|
+
"Gtd-Kept": (value, into) => {
|
|
6916
|
+
if (HASH_RE.test(value)) into.kept ??= value;
|
|
6917
|
+
},
|
|
6896
6918
|
"Gtd-Format": (value, into) => {
|
|
6897
6919
|
if (FORMAT_RE.test(value)) into.format ??= Number(value);
|
|
6898
6920
|
},
|
|
@@ -6913,6 +6935,7 @@ const parseCommitMessage = (message) => {
|
|
|
6913
6935
|
reviewBase: void 0,
|
|
6914
6936
|
branch: void 0,
|
|
6915
6937
|
version: void 0,
|
|
6938
|
+
kept: void 0,
|
|
6916
6939
|
judge: [],
|
|
6917
6940
|
vars: {},
|
|
6918
6941
|
config: {},
|
|
@@ -7027,7 +7050,8 @@ const KNOWN_OPTIONS = {
|
|
|
7027
7050
|
...STEERING_OPTIONS,
|
|
7028
7051
|
"model",
|
|
7029
7052
|
"system",
|
|
7030
|
-
"allowEmpty"
|
|
7053
|
+
"allowEmpty",
|
|
7054
|
+
"keep"
|
|
7031
7055
|
]),
|
|
7032
7056
|
human: /* @__PURE__ */ new Set([
|
|
7033
7057
|
...STEERING_OPTIONS,
|
|
@@ -7059,6 +7083,7 @@ const replay = async (input) => {
|
|
|
7059
7083
|
const scopeCallIds = [];
|
|
7060
7084
|
let scopeCallCount = 0;
|
|
7061
7085
|
const trace = [];
|
|
7086
|
+
const keptTips = /* @__PURE__ */ new Map();
|
|
7062
7087
|
let landed;
|
|
7063
7088
|
let outcome;
|
|
7064
7089
|
let signal;
|
|
@@ -7218,6 +7243,12 @@ const replay = async (input) => {
|
|
|
7218
7243
|
}
|
|
7219
7244
|
cursor++;
|
|
7220
7245
|
advance(commit);
|
|
7246
|
+
if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
|
|
7247
|
+
else keptTips.set(step.name, {
|
|
7248
|
+
hash: commit.kept.hash,
|
|
7249
|
+
before: commit.tree,
|
|
7250
|
+
after: commit.kept.tree
|
|
7251
|
+
});
|
|
7221
7252
|
const to = commit.parsed.parsed?.to;
|
|
7222
7253
|
if (to !== void 0) expectedNext = {
|
|
7223
7254
|
name: to,
|
|
@@ -7225,6 +7256,21 @@ const replay = async (input) => {
|
|
|
7225
7256
|
};
|
|
7226
7257
|
return replyFor(step.request, commit.parsed.judge);
|
|
7227
7258
|
};
|
|
7259
|
+
const landPending = (step, pending) => {
|
|
7260
|
+
pendingUsed = true;
|
|
7261
|
+
landed = step;
|
|
7262
|
+
const keeping = step.request.kind === "agent" && step.request.options.keep === true;
|
|
7263
|
+
if (keeping) keptTips.set(step.name, {
|
|
7264
|
+
hash: "",
|
|
7265
|
+
before: position.tree,
|
|
7266
|
+
after: pending.tree
|
|
7267
|
+
});
|
|
7268
|
+
advance({
|
|
7269
|
+
hash: "",
|
|
7270
|
+
tree: keeping ? position.tree : pending.tree
|
|
7271
|
+
});
|
|
7272
|
+
return replyFor(step.request, pending.verdicts ?? []);
|
|
7273
|
+
};
|
|
7228
7274
|
const answer = (step) => {
|
|
7229
7275
|
while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
|
|
7230
7276
|
const commit = commits[cursor];
|
|
@@ -7235,17 +7281,18 @@ const replay = async (input) => {
|
|
|
7235
7281
|
trace,
|
|
7236
7282
|
landed
|
|
7237
7283
|
});
|
|
7238
|
-
|
|
7239
|
-
landed = step;
|
|
7240
|
-
advance({
|
|
7241
|
-
hash: "",
|
|
7242
|
-
tree: input.pending.tree
|
|
7243
|
-
});
|
|
7244
|
-
return replyFor(step.request, input.pending.verdicts ?? []);
|
|
7284
|
+
return landPending(step, input.pending);
|
|
7245
7285
|
};
|
|
7246
7286
|
const requestFault = (request) => {
|
|
7247
7287
|
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
|
-
|
|
7288
|
+
if (request.kind !== "agent") return void 0;
|
|
7289
|
+
const name = scoped(request.name);
|
|
7290
|
+
if (request.options.keep === true) {
|
|
7291
|
+
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`;
|
|
7292
|
+
const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
|
|
7293
|
+
if (refFault !== void 0) return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`;
|
|
7294
|
+
}
|
|
7295
|
+
return scopeAccessFault();
|
|
7249
7296
|
};
|
|
7250
7297
|
const handleStep = async (request) => {
|
|
7251
7298
|
if (outcome !== void 0) throw new Stop();
|
|
@@ -7328,6 +7375,13 @@ const replay = async (input) => {
|
|
|
7328
7375
|
faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
|
|
7329
7376
|
}));
|
|
7330
7377
|
}),
|
|
7378
|
+
kept: (name) => {
|
|
7379
|
+
const tip = keptTips.get(scoped(name));
|
|
7380
|
+
return tip === void 0 ? void 0 : {
|
|
7381
|
+
hash: tip.hash,
|
|
7382
|
+
changes: changesBetween(tip.before, tip.after)
|
|
7383
|
+
};
|
|
7384
|
+
},
|
|
7331
7385
|
vars: input.vars,
|
|
7332
7386
|
env: input.env,
|
|
7333
7387
|
start: () => input.start,
|
|
@@ -8293,7 +8347,11 @@ const planStep = (snapshot, opts = {}) => {
|
|
|
8293
8347
|
...uncheckStep(snapshot),
|
|
8294
8348
|
{
|
|
8295
8349
|
kind: "gitWrite",
|
|
8296
|
-
write: {
|
|
8350
|
+
write: landing.keepTurn !== void 0 ? {
|
|
8351
|
+
kind: "keepTurn",
|
|
8352
|
+
ref: landing.keepTurn,
|
|
8353
|
+
message
|
|
8354
|
+
} : {
|
|
8297
8355
|
kind: "commitAll",
|
|
8298
8356
|
message
|
|
8299
8357
|
}
|
|
@@ -8768,6 +8826,7 @@ const promptDef = (common, request, skills, access) => ({
|
|
|
8768
8826
|
...optional("model", request.options.model),
|
|
8769
8827
|
...optional("system", request.options.system),
|
|
8770
8828
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
8829
|
+
...optional("keep", request.options.keep),
|
|
8771
8830
|
...optional("skills", skills),
|
|
8772
8831
|
...optional("access", access)
|
|
8773
8832
|
});
|
|
@@ -8882,11 +8941,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
|
|
|
8882
8941
|
hash: run.episode.base,
|
|
8883
8942
|
tree: commitTree(workspace, run.episode.base)
|
|
8884
8943
|
};
|
|
8885
|
-
const
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8944
|
+
const git = yield* GitService;
|
|
8945
|
+
const episode = [];
|
|
8946
|
+
for (const c of run.episode.commits) {
|
|
8947
|
+
const tip = parseCommitMessage(c.message).kept;
|
|
8948
|
+
const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
|
|
8949
|
+
episode.push({
|
|
8950
|
+
hash: c.hash,
|
|
8951
|
+
message: c.message,
|
|
8952
|
+
tree: commitTree(workspace, c.hash),
|
|
8953
|
+
...exists ? { kept: {
|
|
8954
|
+
hash: tip,
|
|
8955
|
+
tree: commitTree(workspace, tip)
|
|
8956
|
+
} } : {}
|
|
8957
|
+
});
|
|
8958
|
+
}
|
|
8890
8959
|
let pendingMemo;
|
|
8891
8960
|
return {
|
|
8892
8961
|
resolved,
|
|
@@ -9062,6 +9131,7 @@ const closingTrailers = (branch) => ({
|
|
|
9062
9131
|
...isSome(branch) ? { branch: branch.value } : {}
|
|
9063
9132
|
});
|
|
9064
9133
|
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
9134
|
+
const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
|
|
9065
9135
|
const landingTo = (rest, to, branch) => {
|
|
9066
9136
|
if (isSettledNoop(rest, to)) return {
|
|
9067
9137
|
kind: "noop",
|
|
@@ -9086,7 +9156,8 @@ const landingTo = (rest, to, branch) => {
|
|
|
9086
9156
|
} : {},
|
|
9087
9157
|
...finishes ? closingTrailers(branch) : {}
|
|
9088
9158
|
},
|
|
9089
|
-
...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
|
|
9159
|
+
...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
|
|
9160
|
+
...keptTurn(rest)
|
|
9090
9161
|
};
|
|
9091
9162
|
};
|
|
9092
9163
|
/**
|
|
@@ -9223,7 +9294,7 @@ const commitAllowEmpty = (message) => {
|
|
|
9223
9294
|
return [
|
|
9224
9295
|
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
9225
9296
|
` case "$out" in`,
|
|
9226
|
-
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
|
|
9297
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
|
|
9227
9298
|
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
9228
9299
|
` esac`,
|
|
9229
9300
|
`fi`
|
|
@@ -9245,6 +9316,36 @@ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
|
|
|
9245
9316
|
* `GitWriterOperations.discardPending` for why it's kept anyway.
|
|
9246
9317
|
*/
|
|
9247
9318
|
const discardPending = () => `git add -A && git reset --hard HEAD`;
|
|
9319
|
+
/**
|
|
9320
|
+
* POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
|
|
9321
|
+
* tree, so no hook runs on it and its tree is the pre-turn tree by
|
|
9322
|
+
* construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
|
|
9323
|
+
* `-S`, read (and failing closed) before any object or ref is written; the
|
|
9324
|
+
* index is already staged by then, so an early exit leaves the turn staged.
|
|
9325
|
+
* The kept ref is deleted only after `reset -q` succeeds: it is then the
|
|
9326
|
+
* turn's only handle.
|
|
9327
|
+
* The branch moves before the final hard reset, so if that reset fails the
|
|
9328
|
+
* marker has landed and the turn is still in the working tree (kept ref
|
|
9329
|
+
* present); every other failure leaves the branch untouched.
|
|
9330
|
+
*/
|
|
9331
|
+
const keepTurn = (refPrefix, message) => {
|
|
9332
|
+
const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
|
|
9333
|
+
return [
|
|
9334
|
+
`git add -A || exit 1`,
|
|
9335
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
9336
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
9337
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
9338
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
9339
|
+
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
9340
|
+
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
9341
|
+
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
9342
|
+
`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`,
|
|
9343
|
+
` git reset -q && git update-ref -d ${keptRef}`,
|
|
9344
|
+
` exit 1`,
|
|
9345
|
+
`fi`,
|
|
9346
|
+
`git reset --hard HEAD`
|
|
9347
|
+
].join("\n");
|
|
9348
|
+
};
|
|
9248
9349
|
const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
|
|
9249
9350
|
/** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
|
|
9250
9351
|
const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
|
|
@@ -9277,6 +9378,7 @@ const renderGitWrite = (write) => {
|
|
|
9277
9378
|
switch (write.kind) {
|
|
9278
9379
|
case "commitAll": return commitAll(write.message);
|
|
9279
9380
|
case "updateRef": return updateRef(write.ref, write.target);
|
|
9381
|
+
case "keepTurn": return keepTurn(write.ref, write.message);
|
|
9280
9382
|
}
|
|
9281
9383
|
};
|
|
9282
9384
|
/**
|
|
@@ -11406,6 +11508,7 @@ var InMemRepo = class InMemRepo {
|
|
|
11406
11508
|
return this.head !== null;
|
|
11407
11509
|
}
|
|
11408
11510
|
resolveRef(ref) {
|
|
11511
|
+
if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -9));
|
|
11409
11512
|
if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
|
|
11410
11513
|
const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
|
|
11411
11514
|
if (tildeMatch) {
|
|
@@ -11578,8 +11681,12 @@ var InMemRepo = class InMemRepo {
|
|
|
11578
11681
|
stageAll() {
|
|
11579
11682
|
this.index = new Map(this.worktree);
|
|
11580
11683
|
}
|
|
11581
|
-
/**
|
|
11582
|
-
|
|
11684
|
+
/** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
|
|
11685
|
+
hookRejection = void 0;
|
|
11686
|
+
signingFails = false;
|
|
11687
|
+
/** Seeded: a pre-commit hook writing and staging this file. */
|
|
11688
|
+
hookStages = void 0;
|
|
11689
|
+
writeCommit(message) {
|
|
11583
11690
|
const tree = new Map(this.index);
|
|
11584
11691
|
const parent = this.head;
|
|
11585
11692
|
const hash = makeHash(message, parent, tree);
|
|
@@ -11594,27 +11701,59 @@ var InMemRepo = class InMemRepo {
|
|
|
11594
11701
|
this.head = hash;
|
|
11595
11702
|
this.branches.set(this.currentBranch, hash);
|
|
11596
11703
|
}
|
|
11597
|
-
/**
|
|
11598
|
-
|
|
11599
|
-
|
|
11600
|
-
|
|
11704
|
+
/**
|
|
11705
|
+
* Twin of the emitted `git commit --allow-empty` if/case retry: a hook
|
|
11706
|
+
* rejection printing "empty git commit" retries without hooks, any other
|
|
11707
|
+
* rejection fails; a failing signer fails either attempt. Commits whatever is
|
|
11708
|
+
* staged verbatim, like `git commit` after a soft reset.
|
|
11709
|
+
*/
|
|
11710
|
+
commitAsIs(message) {
|
|
11711
|
+
if (this.hookRejection !== void 0 && !this.hookRejection.includes("empty git commit")) return this.hookRejection;
|
|
11712
|
+
if (this.signingFails) return "error: gpg failed to sign the data";
|
|
11713
|
+
if (this.hookStages !== void 0) {
|
|
11714
|
+
this.worktree.set(this.hookStages.path, this.hookStages.content);
|
|
11715
|
+
this.index.set(this.hookStages.path, this.hookStages.content);
|
|
11716
|
+
}
|
|
11717
|
+
this.writeCommit(message);
|
|
11601
11718
|
}
|
|
11602
|
-
|
|
11719
|
+
/** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
|
|
11720
|
+
seedCommit(message) {
|
|
11721
|
+
if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
|
|
11603
11722
|
this.index = new Map(this.worktree);
|
|
11723
|
+
this.writeCommit(message);
|
|
11724
|
+
}
|
|
11725
|
+
/** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
|
|
11726
|
+
keepTurn(refPrefix, message) {
|
|
11727
|
+
this.stageAll();
|
|
11604
11728
|
const tree = new Map(this.index);
|
|
11605
|
-
const message = prefix;
|
|
11606
11729
|
const parent = this.head;
|
|
11607
11730
|
const hash = makeHash(message, parent, tree);
|
|
11608
|
-
|
|
11731
|
+
this.commits.set(hash, {
|
|
11609
11732
|
hash,
|
|
11610
11733
|
message,
|
|
11611
11734
|
files: new Map(tree),
|
|
11612
11735
|
parent,
|
|
11613
11736
|
seq: this.nextSeq++
|
|
11614
|
-
};
|
|
11615
|
-
|
|
11616
|
-
this.
|
|
11617
|
-
this.
|
|
11737
|
+
});
|
|
11738
|
+
const ref = `${refPrefix}/${hash}`;
|
|
11739
|
+
this.refs.set(ref, hash);
|
|
11740
|
+
this.index = new Map(this.headTree());
|
|
11741
|
+
if (this.signingFails) {
|
|
11742
|
+
this.refs.delete(ref);
|
|
11743
|
+
return "error: gpg failed to sign the data";
|
|
11744
|
+
}
|
|
11745
|
+
this.writeCommit(`${message}\nGtd-Kept: ${hash}`);
|
|
11746
|
+
this.index = new Map(tree);
|
|
11747
|
+
this.resetHard();
|
|
11748
|
+
}
|
|
11749
|
+
/** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
|
|
11750
|
+
discardPending() {
|
|
11751
|
+
this.index = new Map(this.worktree);
|
|
11752
|
+
this.resetHard();
|
|
11753
|
+
}
|
|
11754
|
+
commitAllWithPrefix(prefix) {
|
|
11755
|
+
this.index = new Map(this.worktree);
|
|
11756
|
+
return this.commitAsIs(prefix);
|
|
11618
11757
|
}
|
|
11619
11758
|
static EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
11620
11759
|
softResetTo(ref) {
|
|
@@ -11767,6 +11906,7 @@ const makeGitReaderOps = (repo, root) => ({
|
|
|
11767
11906
|
return content !== null ? succeed$6(content) : fail$5(/* @__PURE__ */ new Error(`path not found at ${ref}: ${path}`));
|
|
11768
11907
|
},
|
|
11769
11908
|
readRefOption: (ref) => {
|
|
11909
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return succeed$6(some(ref));
|
|
11770
11910
|
const hash = repo.resolveRef(ref);
|
|
11771
11911
|
return succeed$6(hash !== null ? some(hash) : none());
|
|
11772
11912
|
},
|
|
@@ -11789,10 +11929,13 @@ const makeGitReaderOps = (repo, root) => ({
|
|
|
11789
11929
|
subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
|
|
11790
11930
|
changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base))
|
|
11791
11931
|
});
|
|
11932
|
+
const failOn = (output) => {
|
|
11933
|
+
if (output !== void 0) throw new Error(output);
|
|
11934
|
+
};
|
|
11792
11935
|
const makeGitWriterOps = (repo) => ({
|
|
11793
|
-
commitAllWithPrefix: (prefix) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
|
|
11936
|
+
commitAllWithPrefix: (prefix) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
|
|
11794
11937
|
softResetTo: (ref) => tryCatch(() => repo.softResetTo(ref)),
|
|
11795
|
-
commitAsIs: (message) => tryCatch(() => repo.commitAsIs(message)),
|
|
11938
|
+
commitAsIs: (message) => tryCatch(() => failOn(repo.commitAsIs(message))),
|
|
11796
11939
|
discardPending: () => tryCatch(() => repo.discardPending()),
|
|
11797
11940
|
updateRef: (ref, hash) => tryCatch(() => repo.updateRef(ref, hash)),
|
|
11798
11941
|
deleteRef: (ref) => tryCatch(() => repo.deleteRef(ref)),
|
|
@@ -12295,25 +12438,51 @@ const conditionStatement = (block) => {
|
|
|
12295
12438
|
const end = block.indexOf(CONDITION_TERMINATOR, start);
|
|
12296
12439
|
return end === -1 ? block.slice(start) : block.slice(start, end);
|
|
12297
12440
|
};
|
|
12441
|
+
/** `keepTurn`: an invalid ref name fails before anything moves, as `git update-ref` does. */
|
|
12442
|
+
const recognizeKeepTurn = (repo, block) => {
|
|
12443
|
+
const [message, refDir] = extractQuotedTokens(block);
|
|
12444
|
+
const ref = refDir?.slice(0, -1);
|
|
12445
|
+
if (message === void 0 || ref === void 0 || keepTurn(ref, message) !== block) return;
|
|
12446
|
+
if (refNameFault(ref) !== void 0) return {
|
|
12447
|
+
kind: "failed",
|
|
12448
|
+
error: `fatal: invalid ref format: ${ref}`,
|
|
12449
|
+
status: 128
|
|
12450
|
+
};
|
|
12451
|
+
const failure = repo.keepTurn(ref, message);
|
|
12452
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12453
|
+
kind: "failed",
|
|
12454
|
+
error: failure,
|
|
12455
|
+
status: 1
|
|
12456
|
+
};
|
|
12457
|
+
};
|
|
12298
12458
|
const recognizeGitBuilders = (repo, block) => {
|
|
12299
12459
|
if (block === discardPending()) {
|
|
12300
12460
|
repo.discardPending();
|
|
12301
12461
|
return { kind: "applied" };
|
|
12302
12462
|
}
|
|
12303
12463
|
const conditionLine = conditionStatement(block);
|
|
12464
|
+
if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
|
|
12304
12465
|
if (block.startsWith("git add -A || exit 1\n")) {
|
|
12305
12466
|
const [message] = extractQuotedTokens(conditionLine);
|
|
12306
12467
|
if (message !== void 0 && commitAll(message) === block) {
|
|
12307
|
-
repo.commitAllWithPrefix(message);
|
|
12308
|
-
return { kind: "applied" }
|
|
12468
|
+
const failure = repo.commitAllWithPrefix(message);
|
|
12469
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12470
|
+
kind: "failed",
|
|
12471
|
+
error: failure,
|
|
12472
|
+
status: 1
|
|
12473
|
+
};
|
|
12309
12474
|
}
|
|
12310
12475
|
return;
|
|
12311
12476
|
}
|
|
12312
12477
|
if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
|
|
12313
12478
|
const [message] = extractQuotedTokens(conditionLine);
|
|
12314
12479
|
if (message !== void 0 && commitAsIs(message) === block) {
|
|
12315
|
-
repo.commitAsIs(message);
|
|
12316
|
-
return { kind: "applied" }
|
|
12480
|
+
const failure = repo.commitAsIs(message);
|
|
12481
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
12482
|
+
kind: "failed",
|
|
12483
|
+
error: failure,
|
|
12484
|
+
status: 1
|
|
12485
|
+
};
|
|
12317
12486
|
}
|
|
12318
12487
|
return;
|
|
12319
12488
|
}
|
|
@@ -12850,7 +13019,7 @@ const testRepo = (options) => {
|
|
|
12850
13019
|
const id = `module-${++registered}`;
|
|
12851
13020
|
repo.writeFile("gtd.config.ts", typeof workflow === "string" ? workflow : moduleSource(id, workflow));
|
|
12852
13021
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content);
|
|
12853
|
-
repo.
|
|
13022
|
+
repo.seedCommit("chore: initial commit");
|
|
12854
13023
|
const gtd = async (...args) => {
|
|
12855
13024
|
const { io, result } = makeCapturingCliIo(repo, env);
|
|
12856
13025
|
if (typeof workflow !== "string") registry().set(id, workflow);
|
|
@@ -12918,7 +13087,7 @@ const testRepo = (options) => {
|
|
|
12918
13087
|
read: (path) => repo.readFile(path),
|
|
12919
13088
|
write: (path, content) => repo.writeFile(path, content),
|
|
12920
13089
|
remove: (path) => repo.deleteFile(path),
|
|
12921
|
-
commit: (message) => repo.
|
|
13090
|
+
commit: (message) => repo.seedCommit(message),
|
|
12922
13091
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
|
|
12923
13092
|
runScript: (script) => {
|
|
12924
13093
|
const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
|
|
@@ -12958,6 +13127,14 @@ const testRepo = (options) => {
|
|
|
12958
13127
|
};
|
|
12959
13128
|
//#endregion
|
|
12960
13129
|
//#region src/testkit/GitTiers.ts
|
|
13130
|
+
const writeRejectingHook = (hooksDir, output) => {
|
|
13131
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
13132
|
+
writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\ncat >&2 <<'GTD_HOOK_EOF'\n${output}\nGTD_HOOK_EOF\nexit 1\n`, { mode: 493 });
|
|
13133
|
+
};
|
|
13134
|
+
const writeStagingHook = (hooksDir, path, content) => {
|
|
13135
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
13136
|
+
writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\nprintf '%s' ${shellQuote(content)} > ${shellQuote(path)} && git add ${shellQuote(path)}\nexit 0\n`, { mode: 493 });
|
|
13137
|
+
};
|
|
12961
13138
|
/** No-op — these tests assert on git/config behavior, not narration. */
|
|
12962
13139
|
const noopNarratorLayer = Narrator.layer(() => {}, false);
|
|
12963
13140
|
const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
|
|
@@ -13007,7 +13184,6 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
13007
13184
|
root,
|
|
13008
13185
|
capabilities: {
|
|
13009
13186
|
onDiskContent: true,
|
|
13010
|
-
commitHooks: true,
|
|
13011
13187
|
linkedWorktrees: true,
|
|
13012
13188
|
directoryChainConfig: true,
|
|
13013
13189
|
remotes: true
|
|
@@ -13035,7 +13211,22 @@ const makeLiveTier = (initialCommit = true) => {
|
|
|
13035
13211
|
},
|
|
13036
13212
|
stageAll: () => gitExec("add", "-A"),
|
|
13037
13213
|
updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
|
|
13038
|
-
mixedReset: (ref) => gitExec("reset", "--mixed", ref)
|
|
13214
|
+
mixedReset: (ref) => gitExec("reset", "--mixed", ref),
|
|
13215
|
+
rejectingHook: (output) => {
|
|
13216
|
+
const hooks = join(root, ".git/hooks");
|
|
13217
|
+
gitExec("config", "core.hooksPath", hooks);
|
|
13218
|
+
writeRejectingHook(hooks, output);
|
|
13219
|
+
},
|
|
13220
|
+
stagingHook: (path, content) => {
|
|
13221
|
+
const hooks = join(root, ".git/hooks");
|
|
13222
|
+
gitExec("config", "core.hooksPath", hooks);
|
|
13223
|
+
writeStagingHook(hooks, path, content);
|
|
13224
|
+
},
|
|
13225
|
+
failingSigner: () => {
|
|
13226
|
+
gitExec("config", "commit.gpgsign", "true");
|
|
13227
|
+
gitExec("config", "gpg.format", "openpgp");
|
|
13228
|
+
gitExec("config", "gpg.program", "false");
|
|
13229
|
+
}
|
|
13039
13230
|
},
|
|
13040
13231
|
observe: {
|
|
13041
13232
|
resolveRef: (ref) => gitExec("rev-parse", ref),
|
|
@@ -13101,7 +13292,7 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13101
13292
|
const repo = new InMemRepo();
|
|
13102
13293
|
if (initialCommit) {
|
|
13103
13294
|
repo.writeFile("readme.txt", "hello");
|
|
13104
|
-
repo.
|
|
13295
|
+
repo.seedCommit("init: first commit");
|
|
13105
13296
|
}
|
|
13106
13297
|
const gitLayer = gitTestLayer(repo, IN_MEM_ROOT);
|
|
13107
13298
|
const provide = (eff) => runPromise(eff.pipe(provide$2(gitLayer), provide$2(noopNarratorLayer)));
|
|
@@ -13111,7 +13302,6 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13111
13302
|
root: IN_MEM_ROOT,
|
|
13112
13303
|
capabilities: {
|
|
13113
13304
|
onDiskContent: false,
|
|
13114
|
-
commitHooks: false,
|
|
13115
13305
|
linkedWorktrees: false,
|
|
13116
13306
|
directoryChainConfig: true,
|
|
13117
13307
|
remotes: false
|
|
@@ -13122,17 +13312,29 @@ const makeInMemTier = (initialCommit = true) => {
|
|
|
13122
13312
|
seed: {
|
|
13123
13313
|
commit: (message, files = { "file.txt": message }) => {
|
|
13124
13314
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content);
|
|
13125
|
-
repo.
|
|
13315
|
+
repo.seedCommit(message);
|
|
13126
13316
|
},
|
|
13127
13317
|
writeFile: (path, content) => repo.writeFile(path, content),
|
|
13128
13318
|
deleteFile: (path) => repo.deleteFile(path),
|
|
13129
13319
|
commitDeletion: (path, message) => {
|
|
13130
13320
|
repo.deleteFile(path);
|
|
13131
|
-
repo.
|
|
13321
|
+
repo.seedCommit(message);
|
|
13132
13322
|
},
|
|
13133
13323
|
stageAll: () => repo.stageAll(),
|
|
13134
13324
|
updateRef: (ref, hash) => repo.updateRef(ref, hash),
|
|
13135
|
-
mixedReset: (ref) => repo.mixedResetTo(ref)
|
|
13325
|
+
mixedReset: (ref) => repo.mixedResetTo(ref),
|
|
13326
|
+
rejectingHook: (output) => {
|
|
13327
|
+
repo.hookRejection = output;
|
|
13328
|
+
},
|
|
13329
|
+
stagingHook: (path, content) => {
|
|
13330
|
+
repo.hookStages = {
|
|
13331
|
+
path,
|
|
13332
|
+
content
|
|
13333
|
+
};
|
|
13334
|
+
},
|
|
13335
|
+
failingSigner: () => {
|
|
13336
|
+
repo.signingFails = true;
|
|
13337
|
+
}
|
|
13136
13338
|
},
|
|
13137
13339
|
observe: {
|
|
13138
13340
|
resolveRef: (ref) => {
|
|
@@ -13463,16 +13665,6 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13463
13665
|
const history = await runGit(t, (g) => g.commitHistory());
|
|
13464
13666
|
expect(history[history.length - 1]?.message).toBe("gtd: grilled");
|
|
13465
13667
|
});
|
|
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
13668
|
});
|
|
13477
13669
|
describe("commitAsIs", () => {
|
|
13478
13670
|
it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
|
|
@@ -13491,6 +13683,29 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13491
13683
|
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
13492
13684
|
});
|
|
13493
13685
|
});
|
|
13686
|
+
describe.each([["commitAllWithPrefix", (g) => g.commitAllWithPrefix("gtd: building")], ["commitAsIs", (g) => g.commitAsIs("gtd: building")]])("%s seeded commit failures", (_name, commit) => {
|
|
13687
|
+
it("retries with --no-verify when a hook blocks the empty commit", async () => {
|
|
13688
|
+
t.seed.rejectingHook("lint-staged prevented an empty git commit.");
|
|
13689
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
13690
|
+
await runGit(t, commit);
|
|
13691
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
|
|
13692
|
+
const history = await runGit(t, (g) => g.commitHistory());
|
|
13693
|
+
expect(history[history.length - 1]?.message).toBe("gtd: building");
|
|
13694
|
+
});
|
|
13695
|
+
it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
|
|
13696
|
+
t.seed.rejectingHook("lint error");
|
|
13697
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
13698
|
+
expect(isFailure(await runGitExit(t, commit))).toBe(true);
|
|
13699
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
|
|
13700
|
+
});
|
|
13701
|
+
it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
|
|
13702
|
+
t.seed.rejectingHook("rejected: empty git commit");
|
|
13703
|
+
t.seed.failingSigner();
|
|
13704
|
+
const headBefore = t.observe.resolveRef("HEAD");
|
|
13705
|
+
expect(isFailure(await runGitExit(t, commit))).toBe(true);
|
|
13706
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
|
|
13707
|
+
});
|
|
13708
|
+
});
|
|
13494
13709
|
describe("softResetTo", () => {
|
|
13495
13710
|
it("moves HEAD back but keeps worktree changes from second commit", async () => {
|
|
13496
13711
|
const firstHash = t.observe.resolveRef("HEAD");
|
|
@@ -13538,6 +13753,20 @@ const runGitServiceContract = (makeTier) => {
|
|
|
13538
13753
|
it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
|
|
13539
13754
|
expect((await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent")))._tag).toBe("None");
|
|
13540
13755
|
});
|
|
13756
|
+
it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
|
|
13757
|
+
const head = t.observe.resolveRef("HEAD");
|
|
13758
|
+
const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`));
|
|
13759
|
+
expect(resolved._tag).toBe("Some");
|
|
13760
|
+
if (resolved._tag === "Some") expect(resolved.value).toBe(head);
|
|
13761
|
+
});
|
|
13762
|
+
it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
|
|
13763
|
+
const missing = "0123456789abcdef0123456789abcdef01234567";
|
|
13764
|
+
expect((await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`)))._tag).toBe("None");
|
|
13765
|
+
});
|
|
13766
|
+
it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
|
|
13767
|
+
const missing = "0123456789abcdef0123456789abcdef01234567";
|
|
13768
|
+
expect((await runGit(t, (g) => g.readRefOption(missing)))._tag).toBe("Some");
|
|
13769
|
+
});
|
|
13541
13770
|
it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
|
|
13542
13771
|
const head = t.observe.resolveRef("HEAD");
|
|
13543
13772
|
await runGit(t, (g) => g.updateRef("refs/gtd/marker", head));
|
package/package.json
CHANGED
package/src/cli/Cli.test.ts
CHANGED
|
@@ -1231,7 +1231,7 @@ describe("runCli — exit codes", () => {
|
|
|
1231
1231
|
const repo = new InMemRepo()
|
|
1232
1232
|
repo.writeFile("NOTE.md", "a note\n")
|
|
1233
1233
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1234
|
-
repo.
|
|
1234
|
+
repo.seedCommit("gtd: init")
|
|
1235
1235
|
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1236
1236
|
await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
|
|
1237
1237
|
const { stdout, stderr, exitCode } = captured()
|
|
@@ -1316,7 +1316,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
|
|
|
1316
1316
|
// produce zero calls, not merely an empty joined string.
|
|
1317
1317
|
const repo = new InMemRepo()
|
|
1318
1318
|
repo.writeFile("README.md", "x\n")
|
|
1319
|
-
repo.
|
|
1319
|
+
repo.seedCommit("chore: init gtd workflow")
|
|
1320
1320
|
repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
|
|
1321
1321
|
const stdoutCalls: string[] = []
|
|
1322
1322
|
const io: CliIo = {
|
|
@@ -1396,7 +1396,7 @@ describe("runCli — flush normalizes the artifact's trailing newline", () => {
|
|
|
1396
1396
|
const repo = new InMemRepo()
|
|
1397
1397
|
repo.writeFile(".gitignore", "node_modules\n")
|
|
1398
1398
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1399
|
-
repo.
|
|
1399
|
+
repo.seedCommit("chore: initial commit")
|
|
1400
1400
|
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1401
1401
|
await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
|
|
1402
1402
|
const { stdout } = captured()
|
package/src/cli/History.test.ts
CHANGED
|
@@ -36,7 +36,7 @@ const finishedProcess = async (repo: InMemRepo, file: string) => {
|
|
|
36
36
|
const repoWithConfig = (): InMemRepo => {
|
|
37
37
|
const repo = new InMemRepo()
|
|
38
38
|
repo.writeFile("gtd.config.ts", CONFIG)
|
|
39
|
-
repo.
|
|
39
|
+
repo.seedCommit("chore: add workflow")
|
|
40
40
|
return repo
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -90,7 +90,7 @@ describe("historyEntries", () => {
|
|
|
90
90
|
const repo = repoWithConfig()
|
|
91
91
|
const { entry: hash } = await finishedProcess(repo, "a.txt")
|
|
92
92
|
repo.writeFile("x.txt", "x\n")
|
|
93
|
-
repo.
|
|
93
|
+
repo.seedCommit("chore: bare tip")
|
|
94
94
|
repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
|
|
95
95
|
expect((await entries(repo))[0]?.branch).toBeNull()
|
|
96
96
|
})
|