@intentius/chant 0.49.0 → 0.50.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/dist/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +93 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.ts +12 -4
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +14 -2
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +103 -2
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +4 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +45 -9
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate resolution ledger (#1485, epic #1487) — the durable counterpart to a
|
|
3
|
+
* converge tick's gate-as-fact outcome (`./converge-ledger.ts`'s
|
|
4
|
+
* `ConvergeRuleOutcome.action === "gated"`). Same append-only, content-
|
|
5
|
+
* addressed shape as the converge/release ledgers, reusing
|
|
6
|
+
* `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly — one line per
|
|
7
|
+
* resolution at `_gates/<op>.jsonl` on the `chant/lifecycle` orphan branch.
|
|
8
|
+
*
|
|
9
|
+
* Keyed by op name rather than environment (`_gates`, not `<env>/gates...`)
|
|
10
|
+
* because a gate belongs to the *dispatched* op — the thing a converge
|
|
11
|
+
* rule's `run()` action names — and that op's own env, if it declares one at
|
|
12
|
+
* all, isn't always the calling `ConvergeOp`'s env. `writeBlobToPath`'s own
|
|
13
|
+
* doc already establishes this generic-namespace pattern
|
|
14
|
+
* (`./build-ledger-store.ts`'s `_builds`); this is the same move for a
|
|
15
|
+
* second non-env top-level directory.
|
|
16
|
+
*
|
|
17
|
+
* `chant approve <op> <gate>` (`../cli/handlers/operator.ts`) is what
|
|
18
|
+
* appends here — issue #1485's "resolution is an out-of-band act that
|
|
19
|
+
* writes the counterpart fact". Per the issue's own leaning on open
|
|
20
|
+
* question 3 ("local trust in v1, signature as an additive follow-up"),
|
|
21
|
+
* this record is *not* itself an authorization check — anyone who can run
|
|
22
|
+
* `chant approve` locally can write one, the same trust boundary a local
|
|
23
|
+
* `git commit` already has. What it changes: `chant operator status` (and
|
|
24
|
+
* any future gate-aware dispatch retry) can tell a resolved gate from a
|
|
25
|
+
* still-pending one by finding a resolution newer than the tick that
|
|
26
|
+
* recorded it. It does **not** (v1) retroactively make a gated op's local
|
|
27
|
+
* dispatch succeed — the local executor still refuses any op containing a
|
|
28
|
+
* gate outright (`../op/local-executor.ts`'s `LocalGateUnsupportedError`),
|
|
29
|
+
* unconditionally, gate resolution or not. Wiring an approved gate back
|
|
30
|
+
* into the local executor's dispatch path is the GateStep semantic change
|
|
31
|
+
* issue #1485 itself flags as open question 1 ("suspension → fact... does
|
|
32
|
+
* it land as its own issue first? Leaning: yes, split it out") — deferred
|
|
33
|
+
* here for the same reason the issue defers it: it touches both the local
|
|
34
|
+
* executor and the generated Temporal workflow, and has migration impact on
|
|
35
|
+
* shipped ops. `chant approve` in v1 is the durable, queryable record of
|
|
36
|
+
* "this gate is cleared" that a human (or a future auto-resume path) reads;
|
|
37
|
+
* it is not itself the unblock.
|
|
38
|
+
*/
|
|
39
|
+
import { sortedJsonReplacer } from "../utils";
|
|
40
|
+
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
41
|
+
|
|
42
|
+
const DIR = "_gates";
|
|
43
|
+
const APPEND_RETRY_ATTEMPTS = 5;
|
|
44
|
+
|
|
45
|
+
/** One immutable gate-resolution record. */
|
|
46
|
+
export interface GateResolutionRecord {
|
|
47
|
+
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
48
|
+
version: 1;
|
|
49
|
+
/** The dispatched op the gate belongs to. */
|
|
50
|
+
op: string;
|
|
51
|
+
/** The gate's signal name (matches `ConvergeRuleOutcome.gateName`). */
|
|
52
|
+
gate: string;
|
|
53
|
+
/** Who resolved it — an actor name, the same convention `components release --actor` and `run signal --approver` use. */
|
|
54
|
+
resolvedBy: string;
|
|
55
|
+
/** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
|
|
56
|
+
timestamp: string;
|
|
57
|
+
/** Optional free-text context (e.g. a PR URL — "or a merged PR" is the issue's other resolution path; recording its link here keeps both paths visible from one ledger). */
|
|
58
|
+
note?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type GateResolutionInput = Omit<GateResolutionRecord, "version">;
|
|
62
|
+
|
|
63
|
+
function filename(op: string): string {
|
|
64
|
+
return `${op}.jsonl`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Append one immutable gate-resolution record. Does not push to the remote — call `pushLifecycle` (./git.ts) afterward, same two-step shape every other ledger write here uses. Retries on `RefCASConflictError` the same way `appendConvergeRecord` does (./converge-ledger.ts) — a concurrent writer to a different op's/env's file on the same orphan branch is the ordinary case, not an edge case. The baseline read must be `readPathSha` + `readBlobBySha` rather than `readBlobFromPath`, so the exact sha `existing` came from can be passed as `expectPriorPathSha` — see `writeBlobToPath` (./git.ts) for the race that closes. */
|
|
68
|
+
export async function appendGateResolution(
|
|
69
|
+
input: GateResolutionInput,
|
|
70
|
+
opts?: { cwd?: string },
|
|
71
|
+
): Promise<{ commit: string; record: GateResolutionRecord }> {
|
|
72
|
+
const record: GateResolutionRecord = { version: 1, ...input };
|
|
73
|
+
const json = JSON.stringify(record, sortedJsonReplacer);
|
|
74
|
+
|
|
75
|
+
let lastErr: unknown;
|
|
76
|
+
for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
const priorSha = await readPathSha(DIR, filename(record.op), opts);
|
|
79
|
+
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
80
|
+
const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
|
|
81
|
+
const commit = await writeBlobToPath(DIR, filename(record.op), content, "Gate resolution record", {
|
|
82
|
+
...opts,
|
|
83
|
+
expectPriorPathSha: priorSha,
|
|
84
|
+
});
|
|
85
|
+
return { commit, record };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
88
|
+
lastErr = err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw lastErr;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read every gate-resolution record for `op`, oldest first. Malformed lines are skipped, not thrown on, the same graceful-degradation stance `readConvergeLedger` takes. Returns `[]` (never throws) when `op` has no resolutions recorded yet. */
|
|
95
|
+
export async function readGateResolutions(
|
|
96
|
+
op: string,
|
|
97
|
+
opts?: { cwd?: string },
|
|
98
|
+
): Promise<{ records: GateResolutionRecord[]; malformed: number }> {
|
|
99
|
+
const content = await readBlobFromPath(DIR, filename(op), opts);
|
|
100
|
+
if (!content) return { records: [], malformed: 0 };
|
|
101
|
+
|
|
102
|
+
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
103
|
+
const records: GateResolutionRecord[] = [];
|
|
104
|
+
let malformed = 0;
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(line) as Partial<GateResolutionRecord>;
|
|
108
|
+
if (
|
|
109
|
+
parsed.version !== 1 ||
|
|
110
|
+
typeof parsed.op !== "string" ||
|
|
111
|
+
typeof parsed.gate !== "string" ||
|
|
112
|
+
typeof parsed.resolvedBy !== "string" ||
|
|
113
|
+
typeof parsed.timestamp !== "string"
|
|
114
|
+
) {
|
|
115
|
+
malformed++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
records.push(parsed as GateResolutionRecord);
|
|
119
|
+
} catch {
|
|
120
|
+
malformed++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { records, malformed };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The most recent resolution for `gate` recorded after `sinceIso` (a gated tick's own timestamp) — what `chant operator status` uses to tell a resolved gate from a still-pending one. `undefined` when no such resolution exists. */
|
|
127
|
+
export function latestResolutionSince(
|
|
128
|
+
records: GateResolutionRecord[],
|
|
129
|
+
gate: string,
|
|
130
|
+
sinceIso: string,
|
|
131
|
+
): GateResolutionRecord | undefined {
|
|
132
|
+
const since = new Date(sinceIso).getTime();
|
|
133
|
+
let latest: GateResolutionRecord | undefined;
|
|
134
|
+
for (const r of records) {
|
|
135
|
+
if (r.gate !== gate) continue;
|
|
136
|
+
if (new Date(r.timestamp).getTime() < since) continue;
|
|
137
|
+
if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
|
|
138
|
+
}
|
|
139
|
+
return latest;
|
|
140
|
+
}
|
|
@@ -19,6 +19,16 @@ import {
|
|
|
19
19
|
writeBlobToPath,
|
|
20
20
|
readBlobFromPath,
|
|
21
21
|
listFilesInDir,
|
|
22
|
+
readRefSha,
|
|
23
|
+
updateRefCAS,
|
|
24
|
+
deleteRefCAS,
|
|
25
|
+
writeBlob,
|
|
26
|
+
readBlobBySha,
|
|
27
|
+
pushRef,
|
|
28
|
+
fetchRef,
|
|
29
|
+
fetchRefInto,
|
|
30
|
+
RefCASConflictError,
|
|
31
|
+
StaleLockError,
|
|
22
32
|
} from "./git";
|
|
23
33
|
|
|
24
34
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
@@ -157,6 +167,181 @@ describe("lifecycle/git", () => {
|
|
|
157
167
|
});
|
|
158
168
|
});
|
|
159
169
|
|
|
170
|
+
// ── Concurrent writers (#1959 finding 1) ────────────────────────────────
|
|
171
|
+
//
|
|
172
|
+
// writeBlobToPath's own CAS-guarded ref update (#1485) closed the silent
|
|
173
|
+
// clobber two concurrent local writers used to hit, but replaced it with
|
|
174
|
+
// an outright throw on ANY conflict — breaking every pre-existing caller
|
|
175
|
+
// that never learned to retry the moment a live `chant operator` (or any
|
|
176
|
+
// other concurrent writer) touched the orphan branch in between. These
|
|
177
|
+
// tests pin the fix: a conflict caused by a DIFFERENT path is absorbed
|
|
178
|
+
// internally (every caller — even ones with no retry logic of their own —
|
|
179
|
+
// is safe); a conflict on the SAME path a read-modify-write caller is
|
|
180
|
+
// writing is NOT blindly retried (that would silently drop data), so it
|
|
181
|
+
// still surfaces for a content-aware caller's own retry to handle.
|
|
182
|
+
describe("writeBlobToPath — concurrent writers on the same branch tip (#1959 finding 1)", () => {
|
|
183
|
+
test("two interleaved writers to DIFFERENT paths both survive with no caller-level retry needed", async () => {
|
|
184
|
+
await withTestDir(async (dir) => {
|
|
185
|
+
await initRepo(dir);
|
|
186
|
+
// Seed the branch so both writers race against a real, non-null parent tip.
|
|
187
|
+
await writeBlobToPath("_seed", "seed.json", "{}", "seed", { cwd: dir });
|
|
188
|
+
|
|
189
|
+
// Two "callers" that — like writeObservationBaseline/writeSnapshot/
|
|
190
|
+
// persistBuildManifest — pass a self-contained `content`, computed
|
|
191
|
+
// before either write starts, and have NO retry loop of their own.
|
|
192
|
+
const [shaA, shaB] = await Promise.all([
|
|
193
|
+
writeBlobToPath("prod", "baseline.json", '{"owner":"A"}', "A's write", { cwd: dir }),
|
|
194
|
+
writeBlobToPath("staging", "baseline.json", '{"owner":"B"}', "B's write", { cwd: dir }),
|
|
195
|
+
]);
|
|
196
|
+
expect(shaA).toMatch(/^[0-9a-f]{40}$/);
|
|
197
|
+
expect(shaB).toMatch(/^[0-9a-f]{40}$/);
|
|
198
|
+
|
|
199
|
+
// Both landed — neither writer's tree update was lost to the other's race.
|
|
200
|
+
expect(await readBlobFromPath("prod", "baseline.json", { cwd: dir })).toBe('{"owner":"A"}');
|
|
201
|
+
expect(await readBlobFromPath("staging", "baseline.json", { cwd: dir })).toBe('{"owner":"B"}');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a caller with no retry loop still throws RefCASConflictError (not a silent clobber) when TWO writers race the exact same path", async () => {
|
|
206
|
+
await withTestDir(async (dir) => {
|
|
207
|
+
await initRepo(dir);
|
|
208
|
+
await writeBlobToPath("prod", "solo.json", '{"v":0}', "seed", { cwd: dir });
|
|
209
|
+
|
|
210
|
+
// Both writers read the SAME starting content and race to replace it —
|
|
211
|
+
// exactly the scenario a bare (non-retrying) caller cannot safely
|
|
212
|
+
// resolve on its own, since neither knows about the other's write.
|
|
213
|
+
const results = await Promise.allSettled([
|
|
214
|
+
writeBlobToPath("prod", "solo.json", '{"v":"A"}', "A", { cwd: dir }),
|
|
215
|
+
writeBlobToPath("prod", "solo.json", '{"v":"B"}', "B", { cwd: dir }),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
|
219
|
+
const rejected = results.filter((r) => r.status === "rejected");
|
|
220
|
+
// At least one must land; if both raced hard enough that one lost,
|
|
221
|
+
// it must fail loudly as a RefCASConflictError, never silently.
|
|
222
|
+
expect(fulfilled.length).toBeGreaterThanOrEqual(1);
|
|
223
|
+
for (const r of rejected) {
|
|
224
|
+
expect((r as PromiseRejectedResult).reason).toBeInstanceOf(RefCASConflictError);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("appendReleaseRecordLine: two interleaved writers appending to the SAME env's ledger both survive (#1959 finding 1)", async () => {
|
|
230
|
+
await withTestDir(async (dir) => {
|
|
231
|
+
await initRepo(dir);
|
|
232
|
+
const recordA = JSON.stringify({ version: 1, component: "svc-a", digest: "sha256:a" });
|
|
233
|
+
const recordB = JSON.stringify({ version: 1, component: "svc-b", digest: "sha256:b" });
|
|
234
|
+
|
|
235
|
+
await Promise.all([
|
|
236
|
+
appendReleaseRecordLine("prod", recordA, { cwd: dir }),
|
|
237
|
+
appendReleaseRecordLine("prod", recordB, { cwd: dir }),
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const lines = await readReleaseLedgerLines("prod", { cwd: dir });
|
|
241
|
+
expect(lines).toHaveLength(2);
|
|
242
|
+
expect(lines).toContain(recordA);
|
|
243
|
+
expect(lines).toContain(recordB);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Needs more writers than a single retry cycle serializes. A two-writer
|
|
248
|
+
// race is absorbed by `expectPriorPathSha` alone (the test above); it
|
|
249
|
+
// takes a third to expose a commit landing between the tree read and a
|
|
250
|
+
// re-resolved branch tip, where the CAS matches but the committed tree
|
|
251
|
+
// is stale.
|
|
252
|
+
test("appendReleaseRecordLine: five interleaved writers appending to the SAME env's ledger all survive (#1973)", async () => {
|
|
253
|
+
await withTestDir(async (dir) => {
|
|
254
|
+
await initRepo(dir);
|
|
255
|
+
const records = Array.from({ length: 5 }, (_, i) =>
|
|
256
|
+
JSON.stringify({ version: 1, component: `svc-${i}`, digest: `sha256:${i}` }),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await Promise.all(records.map((r) => appendReleaseRecordLine("prod", r, { cwd: dir })));
|
|
260
|
+
|
|
261
|
+
const lines = await readReleaseLedgerLines("prod", { cwd: dir });
|
|
262
|
+
expect(lines).toHaveLength(5);
|
|
263
|
+
for (const r of records) expect(lines).toContain(r);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── Backslash-escape content round-trips byte-identical (#1936) ────────────
|
|
269
|
+
//
|
|
270
|
+
// writeBlobToPath used to shell out to `sh -c "echo '<content>' | git
|
|
271
|
+
// hash-object -w --stdin"`. sh's `echo` reinterprets backslash-escape
|
|
272
|
+
// sequences (`\n`, `\t`, `\\`, ...) in single-quoted content, so any content
|
|
273
|
+
// embedding a literal two-character `\n` (as opposed to an actual newline
|
|
274
|
+
// byte) — e.g. a serialized `kubectl.kubernetes.io/last-applied-configuration`
|
|
275
|
+
// annotation, which is itself JSON whose string values are JSON-escaped —
|
|
276
|
+
// got silently corrupted in the stored blob. Content is now written
|
|
277
|
+
// directly to spawn's stdin, with no shell in the loop.
|
|
278
|
+
describe("writeBlobToPath preserves literal backslash-escape sequences (#1936)", () => {
|
|
279
|
+
test("literal \\n, \\t, \\\\ two-character sequences survive the write/read round trip", async () => {
|
|
280
|
+
await withTestDir(async (dir) => {
|
|
281
|
+
await initRepo(dir);
|
|
282
|
+
const content = String.raw`before\nmiddle\ttab\\backslash\nafter`;
|
|
283
|
+
await writeBlobToPath("prod", "raw.txt", content, "raw content", { cwd: dir });
|
|
284
|
+
const out = await readBlobFromPath("prod", "raw.txt", { cwd: dir });
|
|
285
|
+
expect(out).toBe(content);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("single quotes combined with backslash sequences survive the round trip", async () => {
|
|
290
|
+
await withTestDir(async (dir) => {
|
|
291
|
+
await initRepo(dir);
|
|
292
|
+
const content = String.raw`it's a \path\to\thing with 'nested' quotes and \n \t escapes`;
|
|
293
|
+
await writeBlobToPath("prod", "mixed.txt", content, "mixed content", { cwd: dir });
|
|
294
|
+
const out = await readBlobFromPath("prod", "mixed.txt", { cwd: dir });
|
|
295
|
+
expect(out).toBe(content);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a realistic last-applied-configuration-style JSON payload survives byte-identical", async () => {
|
|
300
|
+
await withTestDir(async (dir) => {
|
|
301
|
+
await initRepo(dir);
|
|
302
|
+
// The annotation value is itself JSON-serialized, so a multi-line
|
|
303
|
+
// shell script embedded in the manifest shows up as literal `\n`
|
|
304
|
+
// and `\t` two-character sequences in the annotation string — the
|
|
305
|
+
// exact shape that `sh`'s `echo` used to mangle.
|
|
306
|
+
const innerManifest = {
|
|
307
|
+
apiVersion: "v1",
|
|
308
|
+
kind: "ConfigMap",
|
|
309
|
+
metadata: { name: "test-cm", namespace: "default" },
|
|
310
|
+
data: { "init.sh": "#!/bin/sh\necho 'hello world'\ncd /tmp\n\techo done" },
|
|
311
|
+
};
|
|
312
|
+
const manifest = {
|
|
313
|
+
apiVersion: "v1",
|
|
314
|
+
kind: "ConfigMap",
|
|
315
|
+
metadata: {
|
|
316
|
+
name: "test-cm",
|
|
317
|
+
namespace: "default",
|
|
318
|
+
annotations: {
|
|
319
|
+
"kubectl.kubernetes.io/last-applied-configuration": JSON.stringify(innerManifest),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
const content = JSON.stringify(manifest);
|
|
324
|
+
// Sanity-check the fixture actually contains literal backslash-n /
|
|
325
|
+
// backslash-t sequences (the corruption trigger), not real newlines.
|
|
326
|
+
expect(content).toContain("\\n");
|
|
327
|
+
expect(content).toContain("\\t");
|
|
328
|
+
|
|
329
|
+
const sha = await writeBlobToPath("prod", "annotation.json", content, "annotated manifest", { cwd: dir });
|
|
330
|
+
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
|
331
|
+
|
|
332
|
+
const out = await readBlobFromPath("prod", "annotation.json", { cwd: dir });
|
|
333
|
+
expect(out).toBe(content);
|
|
334
|
+
expect(JSON.parse(out!)).toEqual(manifest);
|
|
335
|
+
|
|
336
|
+
// Cross-check via git plumbing directly (not just the readBlobFromPath
|
|
337
|
+
// helper), to rule out corruption in the stored blob itself.
|
|
338
|
+
const catFile = git(["cat-file", "-p", `chant/lifecycle:prod/annotation.json`], dir);
|
|
339
|
+
expect(catFile.exitCode).toBe(0);
|
|
340
|
+
expect(catFile.stdout).toBe(content);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
160
345
|
test("listSnapshots returns commit history of the orphan branch", async () => {
|
|
161
346
|
await withTestDir(async (dir) => {
|
|
162
347
|
await initRepo(dir);
|
|
@@ -327,4 +512,249 @@ describe("lifecycle/git", () => {
|
|
|
327
512
|
});
|
|
328
513
|
});
|
|
329
514
|
});
|
|
515
|
+
|
|
516
|
+
// ── Generic ref CAS (#1485) ────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
describe("readRefSha / updateRefCAS / deleteRefCAS / writeBlob / readBlobBySha", () => {
|
|
519
|
+
const REF = "refs/chant/lease/test-op";
|
|
520
|
+
|
|
521
|
+
test("readRefSha returns null for a ref that doesn't exist", async () => {
|
|
522
|
+
await withTestDir(async (dir) => {
|
|
523
|
+
await initRepo(dir);
|
|
524
|
+
expect(await readRefSha(REF, { cwd: dir })).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("writeBlob + updateRefCAS(old=null) creates a ref pointing at a bare blob (no tree, no commit)", async () => {
|
|
529
|
+
await withTestDir(async (dir) => {
|
|
530
|
+
await initRepo(dir);
|
|
531
|
+
const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
532
|
+
await updateRefCAS(REF, blobSha, null, { cwd: dir });
|
|
533
|
+
|
|
534
|
+
expect(await readRefSha(REF, { cwd: dir })).toBe(blobSha);
|
|
535
|
+
expect(await readBlobBySha(blobSha, { cwd: dir })).toBe('{"holder":"a"}');
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("updateRefCAS(old=null) fails if the ref already exists — RefCASConflictError, not a silent overwrite", async () => {
|
|
540
|
+
await withTestDir(async (dir) => {
|
|
541
|
+
await initRepo(dir);
|
|
542
|
+
const first = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
543
|
+
await updateRefCAS(REF, first, null, { cwd: dir });
|
|
544
|
+
|
|
545
|
+
const second = await writeBlob('{"holder":"b"}', { cwd: dir });
|
|
546
|
+
await expect(updateRefCAS(REF, second, null, { cwd: dir })).rejects.toBeInstanceOf(RefCASConflictError);
|
|
547
|
+
// The first writer's value is untouched.
|
|
548
|
+
expect(await readRefSha(REF, { cwd: dir })).toBe(first);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("updateRefCAS succeeds when oldValue matches the ref's current value (a correct renewal)", async () => {
|
|
553
|
+
await withTestDir(async (dir) => {
|
|
554
|
+
await initRepo(dir);
|
|
555
|
+
const first = await writeBlob('{"holder":"a","expiresAt":"t1"}', { cwd: dir });
|
|
556
|
+
await updateRefCAS(REF, first, null, { cwd: dir });
|
|
557
|
+
|
|
558
|
+
const renewed = await writeBlob('{"holder":"a","expiresAt":"t2"}', { cwd: dir });
|
|
559
|
+
await updateRefCAS(REF, renewed, first, { cwd: dir });
|
|
560
|
+
|
|
561
|
+
expect(await readRefSha(REF, { cwd: dir })).toBe(renewed);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("updateRefCAS fails when oldValue is stale — the exact race writeBlobToPath's own final ref update now guards against", async () => {
|
|
566
|
+
await withTestDir(async (dir) => {
|
|
567
|
+
await initRepo(dir);
|
|
568
|
+
const first = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
569
|
+
await updateRefCAS(REF, first, null, { cwd: dir });
|
|
570
|
+
|
|
571
|
+
// Simulate a second writer who moved the ref concurrently.
|
|
572
|
+
const interloper = await writeBlob('{"holder":"b"}', { cwd: dir });
|
|
573
|
+
await updateRefCAS(REF, interloper, first, { cwd: dir });
|
|
574
|
+
|
|
575
|
+
// The first writer, still holding its stale `first` as `oldValue`,
|
|
576
|
+
// tries to write again — must fail loudly, not clobber `interloper`.
|
|
577
|
+
const staleWrite = await writeBlob('{"holder":"a","stale":true}', { cwd: dir });
|
|
578
|
+
await expect(updateRefCAS(REF, staleWrite, first, { cwd: dir })).rejects.toBeInstanceOf(RefCASConflictError);
|
|
579
|
+
expect(await readRefSha(REF, { cwd: dir })).toBe(interloper);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("deleteRefCAS removes the ref only when oldValue matches; a mismatch is a conflict, not a silent no-op", async () => {
|
|
584
|
+
await withTestDir(async (dir) => {
|
|
585
|
+
await initRepo(dir);
|
|
586
|
+
const sha = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
587
|
+
await updateRefCAS(REF, sha, null, { cwd: dir });
|
|
588
|
+
|
|
589
|
+
const wrongSha = await writeBlob("garbage", { cwd: dir });
|
|
590
|
+
await expect(deleteRefCAS(REF, wrongSha, { cwd: dir })).rejects.toBeInstanceOf(RefCASConflictError);
|
|
591
|
+
expect(await readRefSha(REF, { cwd: dir })).toBe(sha); // untouched
|
|
592
|
+
|
|
593
|
+
await deleteRefCAS(REF, sha, { cwd: dir });
|
|
594
|
+
expect(await readRefSha(REF, { cwd: dir })).toBeNull();
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("readBlobBySha returns null for a sha that was never written", async () => {
|
|
599
|
+
await withTestDir(async (dir) => {
|
|
600
|
+
await initRepo(dir);
|
|
601
|
+
expect(await readBlobBySha("0".repeat(40), { cwd: dir })).toBeNull();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ── Failure classification (#1959 finding 2) ────────────────────────────
|
|
607
|
+
//
|
|
608
|
+
// Before this fix, updateRefCAS/deleteRefCAS turned ANY nonzero git exit
|
|
609
|
+
// into RefCASConflictError. That's wrong for at least two other real
|
|
610
|
+
// failure modes git itself distinguishes: a stale `.lock` file (what a
|
|
611
|
+
// killed process leaves behind — the exact crash this feature must
|
|
612
|
+
// recover from) and an outright bad ref name. These tests pin the
|
|
613
|
+
// corrected classification.
|
|
614
|
+
describe("updateRefCAS / deleteRefCAS — classifying real git failures, not just any nonzero exit (#1959 finding 2)", () => {
|
|
615
|
+
test("a stale .lock file is reported as StaleLockError, never as RefCASConflictError", async () => {
|
|
616
|
+
await withTestDir(async (dir) => {
|
|
617
|
+
await initRepo(dir);
|
|
618
|
+
const ref = "refs/chant/lease/stale-lock-op";
|
|
619
|
+
const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
620
|
+
await updateRefCAS(ref, blobSha, null, { cwd: dir });
|
|
621
|
+
|
|
622
|
+
// Simulate a `chant operator` killed mid-write: git's own
|
|
623
|
+
// lockfile-then-rename never completed, so the `.lock` file it
|
|
624
|
+
// created is still sitting there.
|
|
625
|
+
const { mkdirSync, writeFileSync: write } = await import("node:fs");
|
|
626
|
+
mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
|
|
627
|
+
write(join(dir, ".git", "refs", "chant", "lease", "stale-lock-op.lock"), "");
|
|
628
|
+
|
|
629
|
+
const renewed = await writeBlob('{"holder":"a","renewed":true}', { cwd: dir });
|
|
630
|
+
const err = await updateRefCAS(ref, renewed, blobSha, { cwd: dir }).catch((e) => e);
|
|
631
|
+
expect(err).toBeInstanceOf(StaleLockError);
|
|
632
|
+
expect(err).not.toBeInstanceOf(RefCASConflictError);
|
|
633
|
+
expect((err as StaleLockError).lockPath).toContain("stale-lock-op.lock");
|
|
634
|
+
expect((err as StaleLockError).message).toContain("stale-lock-op.lock");
|
|
635
|
+
|
|
636
|
+
// The ref itself is untouched — the write never happened.
|
|
637
|
+
expect(await readRefSha(ref, { cwd: dir })).toBe(blobSha);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("deleteRefCAS also reports a stale .lock file as StaleLockError", async () => {
|
|
642
|
+
await withTestDir(async (dir) => {
|
|
643
|
+
await initRepo(dir);
|
|
644
|
+
const ref = "refs/chant/lease/stale-lock-delete-op";
|
|
645
|
+
const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
646
|
+
await updateRefCAS(ref, blobSha, null, { cwd: dir });
|
|
647
|
+
|
|
648
|
+
const { mkdirSync, writeFileSync: write } = await import("node:fs");
|
|
649
|
+
mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
|
|
650
|
+
write(join(dir, ".git", "refs", "chant", "lease", "stale-lock-delete-op.lock"), "");
|
|
651
|
+
|
|
652
|
+
const err = await deleteRefCAS(ref, blobSha, { cwd: dir }).catch((e) => e);
|
|
653
|
+
expect(err).toBeInstanceOf(StaleLockError);
|
|
654
|
+
expect(err).not.toBeInstanceOf(RefCASConflictError);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("a genuine value mismatch is still RefCASConflictError (no lock file involved)", async () => {
|
|
659
|
+
await withTestDir(async (dir) => {
|
|
660
|
+
await initRepo(dir);
|
|
661
|
+
const ref = "refs/chant/lease/mismatch-op";
|
|
662
|
+
const first = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
663
|
+
await updateRefCAS(ref, first, null, { cwd: dir });
|
|
664
|
+
const interloper = await writeBlob('{"holder":"b"}', { cwd: dir });
|
|
665
|
+
await updateRefCAS(ref, interloper, first, { cwd: dir });
|
|
666
|
+
|
|
667
|
+
const staleWrite = await writeBlob('{"holder":"a","stale":true}', { cwd: dir });
|
|
668
|
+
const err = await updateRefCAS(ref, staleWrite, first, { cwd: dir }).catch((e) => e);
|
|
669
|
+
expect(err).toBeInstanceOf(RefCASConflictError);
|
|
670
|
+
expect(err).not.toBeInstanceOf(StaleLockError);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("a bad ref name is a plain Error, not RefCASConflictError or StaleLockError — the ref's value never actually diverged from oldValue", async () => {
|
|
675
|
+
await withTestDir(async (dir) => {
|
|
676
|
+
await initRepo(dir);
|
|
677
|
+
const badRef = "refs/chant/lease/bad..name";
|
|
678
|
+
const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
679
|
+
|
|
680
|
+
const err = await updateRefCAS(badRef, blobSha, null, { cwd: dir }).catch((e) => e);
|
|
681
|
+
expect(err).toBeInstanceOf(Error);
|
|
682
|
+
expect(err).not.toBeInstanceOf(RefCASConflictError);
|
|
683
|
+
expect(err).not.toBeInstanceOf(StaleLockError);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
describe("pushRef / fetchRef — a lease ref survives a round trip through a shared remote", () => {
|
|
689
|
+
test("pushRef pushes a non-branch ref; a second clone sees it via fetchRef", async () => {
|
|
690
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
691
|
+
const cloneB = join(tmpdir(), `chant-lease-clone-b-${Date.now()}-${Math.random()}`);
|
|
692
|
+
try {
|
|
693
|
+
git(["clone", "-q", remotePath, cloneB], tmpdir());
|
|
694
|
+
|
|
695
|
+
const ref = "refs/chant/lease/fountain-converge";
|
|
696
|
+
const blobSha = await writeBlob('{"holder":"a","token":"t1"}', { cwd: cloneA });
|
|
697
|
+
await updateRefCAS(ref, blobSha, null, { cwd: cloneA });
|
|
698
|
+
expect(await pushRef(ref, { cwd: cloneA })).toBe(true);
|
|
699
|
+
|
|
700
|
+
expect(await fetchRef(ref, { cwd: cloneB })).toBe(true);
|
|
701
|
+
expect(await readRefSha(ref, { cwd: cloneB })).toBe(blobSha);
|
|
702
|
+
expect(await readBlobBySha(blobSha, { cwd: cloneB })).toBe('{"holder":"a","token":"t1"}');
|
|
703
|
+
} finally {
|
|
704
|
+
await cleanup();
|
|
705
|
+
const { rm } = await import("node:fs/promises");
|
|
706
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("pushRef / fetchRef return false (never throw) when no remote is configured", async () => {
|
|
711
|
+
await withTestDir(async (dir) => {
|
|
712
|
+
await initRepo(dir);
|
|
713
|
+
const ref = "refs/chant/lease/local-only";
|
|
714
|
+
const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
|
|
715
|
+
await updateRefCAS(ref, blobSha, null, { cwd: dir });
|
|
716
|
+
expect(await pushRef(ref, { cwd: dir })).toBe(false);
|
|
717
|
+
expect(await fetchRef(ref, { cwd: dir })).toBe(false);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("fetchRefInto lands the remote's value under a DIFFERENT local ref name, leaving the same-named local ref (if any) untouched (#1959 finding 3)", async () => {
|
|
722
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
723
|
+
const cloneB = join(tmpdir(), `chant-lease-clone-b-${Date.now()}-${Math.random()}`);
|
|
724
|
+
try {
|
|
725
|
+
git(["clone", "-q", remotePath, cloneB], tmpdir());
|
|
726
|
+
|
|
727
|
+
const ref = "refs/chant/lease/fountain-converge";
|
|
728
|
+
const remoteBlobSha = await writeBlob('{"holder":"a","token":"t1"}', { cwd: cloneA });
|
|
729
|
+
await updateRefCAS(ref, remoteBlobSha, null, { cwd: cloneA });
|
|
730
|
+
expect(await pushRef(ref, { cwd: cloneA })).toBe(true);
|
|
731
|
+
|
|
732
|
+
// cloneB already has its OWN local value at `ref` — simulating a
|
|
733
|
+
// just-acquired, not-yet-pushed local lease. fetchRefInto must not
|
|
734
|
+
// touch it.
|
|
735
|
+
const localBlobSha = await writeBlob('{"holder":"b","token":"t2"}', { cwd: cloneB });
|
|
736
|
+
await updateRefCAS(ref, localBlobSha, null, { cwd: cloneB });
|
|
737
|
+
|
|
738
|
+
const trackingRef = "refs/chant/lease-remote/fountain-converge";
|
|
739
|
+
expect(await fetchRefInto(ref, trackingRef, { cwd: cloneB })).toBe(true);
|
|
740
|
+
|
|
741
|
+
// The tracking ref reflects the remote's value...
|
|
742
|
+
expect(await readRefSha(trackingRef, { cwd: cloneB })).toBe(remoteBlobSha);
|
|
743
|
+
// ...but cloneB's own local `ref` is completely untouched.
|
|
744
|
+
expect(await readRefSha(ref, { cwd: cloneB })).toBe(localBlobSha);
|
|
745
|
+
} finally {
|
|
746
|
+
await cleanup();
|
|
747
|
+
const { rm } = await import("node:fs/promises");
|
|
748
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("RefCASConflictError carries the ref and expected value", () => {
|
|
754
|
+
const err = new RefCASConflictError("refs/chant/lease/x", "abc123", "stale info");
|
|
755
|
+
expect(err.name).toBe("RefCASConflictError");
|
|
756
|
+
expect(err.ref).toBe("refs/chant/lease/x");
|
|
757
|
+
expect(err.expected).toBe("abc123");
|
|
758
|
+
expect(err.message).toContain("moved concurrently");
|
|
759
|
+
});
|
|
330
760
|
});
|