@intentius/chant 0.1.6 → 0.1.8

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/build.test.ts +58 -5
  3. package/src/cli/commands/build.ts +24 -3
  4. package/src/cli/handlers/graph.test.ts +91 -0
  5. package/src/cli/handlers/graph.ts +23 -0
  6. package/src/cli/handlers/run-client.ts +134 -0
  7. package/src/cli/handlers/run-report.ts +160 -0
  8. package/src/cli/handlers/run.test.ts +448 -0
  9. package/src/cli/handlers/run.ts +453 -0
  10. package/src/cli/handlers/state.test.ts +409 -0
  11. package/src/cli/handlers/state.ts +232 -10
  12. package/src/cli/main.test.ts +65 -0
  13. package/src/cli/main.ts +32 -18
  14. package/src/cli/mcp/op-tools.ts +204 -0
  15. package/src/cli/mcp/resource-handlers.ts +69 -50
  16. package/src/cli/mcp/resources/context.ts +27 -0
  17. package/src/cli/mcp/server.test.ts +176 -3
  18. package/src/cli/mcp/server.ts +7 -3
  19. package/src/cli/mcp/state-tools.ts +0 -51
  20. package/src/cli/mcp/tools/search.ts +6 -1
  21. package/src/cli/registry.ts +3 -0
  22. package/src/composite.ts +10 -5
  23. package/src/index.ts +1 -2
  24. package/src/lexicon-plugin-helpers.ts +13 -5
  25. package/src/lexicon.ts +57 -1
  26. package/src/lint/config.test.ts +21 -0
  27. package/src/lint/config.ts +19 -3
  28. package/src/op/discover.test.ts +43 -0
  29. package/src/op/discover.ts +89 -0
  30. package/src/op/index.ts +3 -1
  31. package/src/op/types.ts +13 -6
  32. package/src/state/digest.test.ts +117 -0
  33. package/src/state/git.test.ts +191 -0
  34. package/src/state/git.ts +63 -11
  35. package/src/state/live-diff.test.ts +184 -0
  36. package/src/state/live-diff.ts +215 -0
  37. package/src/state/snapshot.test.ts +171 -0
  38. package/src/state/snapshot.ts +39 -19
  39. package/src/state/types.ts +4 -2
  40. package/src/cli/handlers/spell.ts +0 -396
  41. package/src/spell/discovery.ts +0 -183
  42. package/src/spell/index.ts +0 -3
  43. package/src/spell/prompt.ts +0 -133
  44. package/src/spell/types.ts +0 -89
@@ -0,0 +1,89 @@
1
+ import { getRuntime } from "../runtime-adapter";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { OpConfig } from "./types";
5
+
6
+ export interface DiscoveredOp {
7
+ config: OpConfig;
8
+ filePath: string;
9
+ }
10
+
11
+ export interface OpDiscoveryResult {
12
+ ops: Map<string, DiscoveredOp>;
13
+ errors: string[];
14
+ }
15
+
16
+ async function findGitRoot(cwd?: string): Promise<string> {
17
+ const rt = getRuntime();
18
+ const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"], { cwd });
19
+ if (result.exitCode !== 0) throw new Error("Not in a git repository");
20
+ return result.stdout.trim();
21
+ }
22
+
23
+ async function collectOpFiles(dir: string): Promise<string[]> {
24
+ const files: string[] = [];
25
+ let entries;
26
+ try {
27
+ entries = await readdir(dir, { withFileTypes: true });
28
+ } catch {
29
+ return files;
30
+ }
31
+ for (const entry of entries) {
32
+ const fullPath = join(dir, entry.name);
33
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "dist") {
34
+ files.push(...await collectOpFiles(fullPath));
35
+ } else if (
36
+ entry.isFile() &&
37
+ entry.name.endsWith(".op.ts") &&
38
+ !entry.name.endsWith(".test.ts") &&
39
+ !entry.name.endsWith(".spec.ts")
40
+ ) {
41
+ files.push(fullPath);
42
+ }
43
+ }
44
+ return files;
45
+ }
46
+
47
+ /**
48
+ * Discover all Op definitions from *.op.ts files under the git root.
49
+ */
50
+ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryResult> {
51
+ const errors: string[] = [];
52
+ const ops = new Map<string, DiscoveredOp>();
53
+
54
+ const gitRoot = await findGitRoot(opts?.cwd);
55
+ const files = await collectOpFiles(gitRoot);
56
+
57
+ const nameToFile = new Map<string, string>();
58
+
59
+ for (const filePath of files) {
60
+ try {
61
+ const mod = await import(filePath);
62
+ const entity = mod.default;
63
+
64
+ if (!entity || typeof entity !== "object") {
65
+ errors.push(`${filePath}: default export is not an object`);
66
+ continue;
67
+ }
68
+
69
+ const config = entity.props as OpConfig | undefined;
70
+
71
+ if (!config || typeof config.name !== "string" || !Array.isArray(config.phases)) {
72
+ errors.push(`${filePath}: default export is not a valid Op (missing name or phases)`);
73
+ continue;
74
+ }
75
+
76
+ if (nameToFile.has(config.name)) {
77
+ errors.push(`Duplicate Op name "${config.name}" in ${filePath} and ${nameToFile.get(config.name)}`);
78
+ continue;
79
+ }
80
+
81
+ nameToFile.set(config.name, filePath);
82
+ ops.set(config.name, { config, filePath });
83
+ } catch (err) {
84
+ errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
85
+ }
86
+ }
87
+
88
+ return { ops, errors };
89
+ }
package/src/op/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack,
2
2
  gitlabPipeline, stateSnapshot, shell, teardown } from "./builders";
3
3
  export { OpResource } from "./resource";
4
- export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, RetryPolicy } from "./types";
4
+ export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
5
+ export { discoverOps } from "./discover";
6
+ export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
package/src/op/types.ts CHANGED
@@ -46,6 +46,19 @@ export interface ActivityStep {
46
46
  * Default: "fastIdempotent"
47
47
  */
48
48
  profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate";
49
+ /**
50
+ * Surface this activity's return value as a workflow search attribute.
51
+ *
52
+ * The serializer captures the awaited result into a temporary, then emits
53
+ * `upsertSearchAttributes({ <name>: [String(<from-path>)] })` immediately
54
+ * after. Useful for filtering runs by outcome (e.g. `Drift: "true"/"false"`
55
+ * from a stateDiff activity).
56
+ *
57
+ * `from` is a dot-path into the return value (e.g. `"drifted"` for
58
+ * `{ drifted: boolean }`); when omitted, the whole return value is
59
+ * stringified.
60
+ */
61
+ outcomeAttribute?: { name: string; from?: string };
49
62
  }
50
63
 
51
64
  export interface GateStep {
@@ -58,9 +71,3 @@ export interface GateStep {
58
71
  description?: string;
59
72
  }
60
73
 
61
- export interface RetryPolicy {
62
- initialInterval?: string;
63
- backoffCoefficient?: number;
64
- maximumAttempts?: number;
65
- maximumInterval?: string;
66
- }
@@ -0,0 +1,117 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { computeBuildDigest, diffDigests, hashProps } from "./digest";
3
+ import type { BuildResult } from "../build";
4
+ import type { BuildDigest } from "./types";
5
+
6
+ function makeBuildResult(entitiesByLexicon: Record<string, Array<{ name: string; type: string; props: unknown }>>): BuildResult {
7
+ const entities = new Map();
8
+ for (const [lexicon, list] of Object.entries(entitiesByLexicon)) {
9
+ for (const item of list) {
10
+ entities.set(item.name, { lexicon, entityType: item.type, props: item.props });
11
+ }
12
+ }
13
+ return {
14
+ outputs: new Map(Object.keys(entitiesByLexicon).map((l) => [l, "{}"])),
15
+ entities,
16
+ dependencies: new Map(),
17
+ errors: [],
18
+ warnings: [],
19
+ manifest: {
20
+ lexicons: Object.keys(entitiesByLexicon),
21
+ outputs: {},
22
+ deployOrder: Object.keys(entitiesByLexicon),
23
+ },
24
+ sourceFileCount: 1,
25
+ } as unknown as BuildResult;
26
+ }
27
+
28
+ describe("hashProps", () => {
29
+ test("produces the same hash for identical props", () => {
30
+ expect(hashProps({ a: 1, b: 2 })).toBe(hashProps({ a: 1, b: 2 }));
31
+ });
32
+
33
+ test("is order-independent (sorted JSON serialization)", () => {
34
+ expect(hashProps({ a: 1, b: 2 })).toBe(hashProps({ b: 2, a: 1 }));
35
+ });
36
+
37
+ test("produces different hashes for different props", () => {
38
+ expect(hashProps({ a: 1 })).not.toBe(hashProps({ a: 2 }));
39
+ });
40
+ });
41
+
42
+ describe("computeBuildDigest", () => {
43
+ test("emits one entry per entity with type, lexicon, and propsHash", () => {
44
+ const buildResult = makeBuildResult({
45
+ aws: [{ name: "bucket", type: "AWS::S3::Bucket", props: { name: "data" } }],
46
+ });
47
+ const digest = computeBuildDigest(buildResult);
48
+ expect(digest.resources["bucket"]).toMatchObject({
49
+ type: "AWS::S3::Bucket",
50
+ lexicon: "aws",
51
+ propsHash: expect.any(String),
52
+ });
53
+ });
54
+
55
+ test("missing props default to empty object", () => {
56
+ const buildResult = makeBuildResult({ aws: [{ name: "x", type: "T", props: undefined }] });
57
+ const digest = computeBuildDigest(buildResult);
58
+ expect(digest.resources["x"].propsHash).toBe(hashProps({}));
59
+ });
60
+
61
+ test("mirrors the build manifest's deployOrder and outputs", () => {
62
+ const buildResult = makeBuildResult({
63
+ aws: [{ name: "b", type: "T", props: {} }],
64
+ gcp: [{ name: "g", type: "T", props: {} }],
65
+ });
66
+ const digest = computeBuildDigest(buildResult);
67
+ expect(digest.deployOrder).toEqual(["aws", "gcp"]);
68
+ expect(digest.outputs).toEqual({});
69
+ });
70
+ });
71
+
72
+ describe("diffDigests", () => {
73
+ function makeDigest(resources: Record<string, string>): BuildDigest {
74
+ const out: BuildDigest["resources"] = {};
75
+ for (const [name, propsHash] of Object.entries(resources)) {
76
+ out[name] = { type: "T", lexicon: "aws", propsHash };
77
+ }
78
+ return { resources: out, dependencies: {}, outputs: {}, deployOrder: [] };
79
+ }
80
+
81
+ test("no previous digest → everything is added", () => {
82
+ const result = diffDigests(makeDigest({ a: "x", b: "y" }), undefined);
83
+ expect(result.added).toEqual(["a", "b"]);
84
+ expect(result.removed).toEqual([]);
85
+ expect(result.changed).toEqual([]);
86
+ expect(result.unchanged).toEqual([]);
87
+ });
88
+
89
+ test("identical digests → all unchanged", () => {
90
+ const d = makeDigest({ a: "x" });
91
+ const result = diffDigests(d, d);
92
+ expect(result.unchanged).toEqual(["a"]);
93
+ expect(result.added).toEqual([]);
94
+ expect(result.changed).toEqual([]);
95
+ expect(result.removed).toEqual([]);
96
+ });
97
+
98
+ test("different propsHash → changed", () => {
99
+ const result = diffDigests(makeDigest({ a: "x2" }), makeDigest({ a: "x1" }));
100
+ expect(result.changed).toEqual(["a"]);
101
+ });
102
+
103
+ test("resource gone from current → removed", () => {
104
+ const result = diffDigests(makeDigest({}), makeDigest({ a: "x" }));
105
+ expect(result.removed).toEqual(["a"]);
106
+ });
107
+
108
+ test("mixed: added + removed + changed + unchanged", () => {
109
+ const previous = makeDigest({ a: "x1", b: "y", c: "z" });
110
+ const current = makeDigest({ a: "x2", b: "y", d: "w" });
111
+ const result = diffDigests(current, previous);
112
+ expect(result.added.sort()).toEqual(["d"]);
113
+ expect(result.changed.sort()).toEqual(["a"]);
114
+ expect(result.unchanged.sort()).toEqual(["b"]);
115
+ expect(result.removed.sort()).toEqual(["c"]);
116
+ });
117
+ });
@@ -0,0 +1,191 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { withTestDir } from "@intentius/chant-test-utils";
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import {
7
+ writeSnapshot,
8
+ readSnapshot,
9
+ readEnvironmentSnapshots,
10
+ listSnapshots,
11
+ getHeadCommit,
12
+ pushState,
13
+ StaleStateBranchError,
14
+ } from "./git";
15
+
16
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
17
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
18
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
19
+ }
20
+
21
+ async function initRepo(dir: string): Promise<void> {
22
+ git(["init", "-q", "-b", "main"], dir);
23
+ git(["config", "user.email", "test@chant.dev"], dir);
24
+ git(["config", "user.name", "Test"], dir);
25
+ // Need at least one commit so HEAD exists.
26
+ writeFileSync(join(dir, "README.md"), "fixture\n");
27
+ git(["add", "README.md"], dir);
28
+ git(["commit", "-q", "-m", "init"], dir);
29
+ }
30
+
31
+ describe("state/git", () => {
32
+ test("writeSnapshot creates the orphan branch and writes JSON addressable by readSnapshot", async () => {
33
+ await withTestDir(async (dir) => {
34
+ await initRepo(dir);
35
+ const json = JSON.stringify({ resources: { bucket: { type: "T", status: "OK" } } });
36
+ const sha = await writeSnapshot("prod", "aws", json, { cwd: dir });
37
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
38
+
39
+ const out = await readSnapshot("prod", "aws", { cwd: dir });
40
+ expect(out).not.toBeNull();
41
+ expect(JSON.parse(out!)).toEqual(JSON.parse(json));
42
+ });
43
+ });
44
+
45
+ test("readSnapshot returns null for missing env/lexicon", async () => {
46
+ await withTestDir(async (dir) => {
47
+ await initRepo(dir);
48
+ const out = await readSnapshot("prod", "aws", { cwd: dir });
49
+ expect(out).toBeNull();
50
+ });
51
+ });
52
+
53
+ test("subsequent writes preserve other env+lexicon entries", async () => {
54
+ await withTestDir(async (dir) => {
55
+ await initRepo(dir);
56
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
57
+ await writeSnapshot("prod", "gcp", JSON.stringify({ b: 2 }), { cwd: dir });
58
+ await writeSnapshot("staging", "aws", JSON.stringify({ c: 3 }), { cwd: dir });
59
+
60
+ expect(await readSnapshot("prod", "aws", { cwd: dir })).toBeTruthy();
61
+ expect(await readSnapshot("prod", "gcp", { cwd: dir })).toBeTruthy();
62
+ expect(await readSnapshot("staging", "aws", { cwd: dir })).toBeTruthy();
63
+ });
64
+ });
65
+
66
+ test("re-writing the same env+lexicon updates the entry rather than duplicating", async () => {
67
+ await withTestDir(async (dir) => {
68
+ await initRepo(dir);
69
+ await writeSnapshot("prod", "aws", JSON.stringify({ v: 1 }), { cwd: dir });
70
+ await writeSnapshot("prod", "aws", JSON.stringify({ v: 2 }), { cwd: dir });
71
+ const out = await readSnapshot("prod", "aws", { cwd: dir });
72
+ expect(JSON.parse(out!)).toEqual({ v: 2 });
73
+ });
74
+ });
75
+
76
+ test("readEnvironmentSnapshots returns all lexicons for an env", async () => {
77
+ await withTestDir(async (dir) => {
78
+ await initRepo(dir);
79
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
80
+ await writeSnapshot("prod", "gcp", JSON.stringify({ b: 2 }), { cwd: dir });
81
+ const all = await readEnvironmentSnapshots("prod", { cwd: dir });
82
+ expect([...all.keys()].sort()).toEqual(["aws", "gcp"]);
83
+ });
84
+ });
85
+
86
+ test("listSnapshots returns commit history of the orphan branch", async () => {
87
+ await withTestDir(async (dir) => {
88
+ await initRepo(dir);
89
+ await writeSnapshot("prod", "aws", JSON.stringify({ v: 1 }), { cwd: dir });
90
+ await writeSnapshot("prod", "aws", JSON.stringify({ v: 2 }), { cwd: dir });
91
+ const log = await listSnapshots({ cwd: dir });
92
+ expect(log.length).toBe(2);
93
+ expect(log[0].commit).toMatch(/^[0-9a-f]{40}$/);
94
+ });
95
+ });
96
+
97
+ test("getHeadCommit returns the working-branch HEAD sha", async () => {
98
+ await withTestDir(async (dir) => {
99
+ await initRepo(dir);
100
+ const head = await getHeadCommit({ cwd: dir });
101
+ expect(head).toMatch(/^[0-9a-f]{40}$/);
102
+ });
103
+ });
104
+
105
+ // ── Concurrent push rejection (#30) ─────────────────────────────────────────
106
+
107
+ /**
108
+ * Build a "remote ↔ clone" pair where `clone` has `remote` configured as
109
+ * `origin`. Returns the clone path; the caller writes snapshots there.
110
+ */
111
+ async function setupClonePair(): Promise<{ clonePath: string; remotePath: string; cleanup: () => Promise<void> }> {
112
+ const remotePath = join(import.meta.dirname ?? "/tmp", `chant-state-remote-${Date.now()}-${Math.random()}`);
113
+ const clonePath = join(import.meta.dirname ?? "/tmp", `chant-state-clone-${Date.now()}-${Math.random()}`);
114
+ const { mkdir, rm } = await import("node:fs/promises");
115
+ await mkdir(remotePath, { recursive: true });
116
+ git(["init", "-q", "--bare", "-b", "main"], remotePath);
117
+ git(["clone", "-q", remotePath, clonePath], import.meta.dirname ?? "/tmp");
118
+ git(["config", "user.email", "test@chant.dev"], clonePath);
119
+ git(["config", "user.name", "Test"], clonePath);
120
+ writeFileSync(join(clonePath, "README.md"), "fixture\n");
121
+ git(["add", "README.md"], clonePath);
122
+ git(["commit", "-q", "-m", "init"], clonePath);
123
+ git(["push", "-q", "origin", "main"], clonePath);
124
+ return {
125
+ clonePath,
126
+ remotePath,
127
+ cleanup: async () => {
128
+ await rm(remotePath, { recursive: true, force: true });
129
+ await rm(clonePath, { recursive: true, force: true });
130
+ },
131
+ };
132
+ }
133
+
134
+ test("first push to remote succeeds (no remote ref yet)", async () => {
135
+ const { clonePath, cleanup } = await setupClonePair();
136
+ try {
137
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
138
+ const ok = await pushState({ cwd: clonePath });
139
+ expect(ok).toBe(true);
140
+ } finally {
141
+ await cleanup();
142
+ }
143
+ });
144
+
145
+ test("subsequent push from same clone (after fetch) succeeds via lease", async () => {
146
+ const { clonePath, cleanup } = await setupClonePair();
147
+ try {
148
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
149
+ expect(await pushState({ cwd: clonePath })).toBe(true);
150
+
151
+ // Pull the remote ref into local remote-tracking, then commit + push again
152
+ git(["fetch", "-q", "origin", "+refs/heads/chant/state:refs/remotes/origin/chant/state"], clonePath);
153
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: clonePath });
154
+ expect(await pushState({ cwd: clonePath })).toBe(true);
155
+ } finally {
156
+ await cleanup();
157
+ }
158
+ });
159
+
160
+ test("concurrent write rejected: second push throws StaleStateBranchError", async () => {
161
+ // Simulate two concurrent operators by setting up two clones of the same remote.
162
+ const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
163
+ const cloneB = join(import.meta.dirname ?? "/tmp", `chant-state-clone-b-${Date.now()}-${Math.random()}`);
164
+ try {
165
+ git(["clone", "-q", remotePath, cloneB], import.meta.dirname ?? "/tmp");
166
+ git(["config", "user.email", "test@chant.dev"], cloneB);
167
+ git(["config", "user.name", "Test"], cloneB);
168
+
169
+ // Operator A writes + pushes first.
170
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
171
+ expect(await pushState({ cwd: cloneA })).toBe(true);
172
+
173
+ // Operator B writes from the same baseline (chant/state doesn't exist
174
+ // on cloneB's remote-tracking yet) and tries to push — should fail
175
+ // with StaleStateBranchError because A's push moved the remote ref.
176
+ await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
177
+ await expect(pushState({ cwd: cloneB })).rejects.toBeInstanceOf(StaleStateBranchError);
178
+ } finally {
179
+ await cleanup();
180
+ const { rm } = await import("node:fs/promises");
181
+ await rm(cloneB, { recursive: true, force: true });
182
+ }
183
+ });
184
+
185
+ test("StaleStateBranchError carries the expected SHA used as the lease", async () => {
186
+ const err = new StaleStateBranchError(null, "stale info: ...");
187
+ expect(err.name).toBe("StaleStateBranchError");
188
+ expect(err.expected).toBeNull();
189
+ expect(err.message).toContain("moved");
190
+ });
191
+ });
package/src/state/git.ts CHANGED
@@ -22,13 +22,8 @@ export async function writeSnapshot(
22
22
  const rt = getRuntime();
23
23
  const cwd = opts?.cwd;
24
24
 
25
- // 1. Write blob
26
- const hashResult = await rt.spawn(
27
- ["git", "hash-object", "-w", "--stdin"],
28
- { cwd },
29
- );
30
- // hash-object reads from stdin, but spawn doesn't support piping directly.
31
- // Use a shell pipeline instead.
25
+ // 1. Write blob — hash-object reads from stdin, but spawn() doesn't expose
26
+ // a stdin handle, so we run via a shell pipeline (`echo … | git hash-object`).
32
27
  const blobResult = await rt.spawn(
33
28
  ["sh", "-c", `echo '${json.replace(/'/g, "'\\''")}' | git hash-object -w --stdin`],
34
29
  { cwd },
@@ -179,20 +174,77 @@ export async function listSnapshots(
179
174
  }
180
175
 
181
176
  /**
182
- * Push the state branch to remote.
177
+ * Thrown by pushState when the remote chant/state branch has moved since
178
+ * the local snapshot was prepared — i.e. another snapshot for this or a
179
+ * different env was pushed concurrently. The caller should fetch and retry.
180
+ */
181
+ export class StaleStateBranchError extends Error {
182
+ readonly expected: string | null;
183
+ constructor(expected: string | null, stderr: string) {
184
+ super(
185
+ "chant/state remote branch has moved since this run started — " +
186
+ "another snapshot was pushed concurrently. " +
187
+ `git stderr: ${stderr.trim()}`,
188
+ );
189
+ this.name = "StaleStateBranchError";
190
+ this.expected = expected;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Look up the remote-tracking SHA for chant/state, if any. Returns null when
196
+ * the remote ref doesn't exist locally yet (e.g. first-ever snapshot).
197
+ */
198
+ export async function getRemoteStateBranchSha(
199
+ remote: string,
200
+ opts?: { cwd?: string },
201
+ ): Promise<string | null> {
202
+ const rt = getRuntime();
203
+ const ref = `refs/remotes/${remote}/${STATE_BRANCH}`;
204
+ const result = await rt.spawn(["git", "rev-parse", "--verify", ref], { cwd: opts?.cwd });
205
+ if (result.exitCode !== 0) return null;
206
+ return result.stdout.trim() || null;
207
+ }
208
+
209
+ /**
210
+ * Push the state branch to remote with --force-with-lease.
211
+ *
212
+ * If the remote chant/state ref has advanced past the local remote-tracking
213
+ * SHA captured at the start of this push, the push is rejected and we throw
214
+ * StaleStateBranchError so the caller can surface a recovery hint.
215
+ *
216
+ * Returns false (without throwing) only when no remote is configured at all.
183
217
  */
184
218
  export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
185
219
  const rt = getRuntime();
186
- // Check if remote exists
187
220
  const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
188
221
  if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
189
222
 
190
223
  const remote = remoteResult.stdout.trim().split("\n")[0];
224
+
225
+ // Capture the lease SHA — if null, the remote ref doesn't exist yet
226
+ // (first-time push) and we send `--force-with-lease=ref:` (empty SHA),
227
+ // which git interprets as "ref does not exist on remote".
228
+ const expected = await getRemoteStateBranchSha(remote, opts);
229
+ const lease = `refs/heads/${STATE_BRANCH}:${expected ?? ""}`;
230
+
191
231
  const pushResult = await rt.spawn(
192
- ["git", "push", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
232
+ ["git", "push", `--force-with-lease=${lease}`, remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
193
233
  { cwd: opts?.cwd },
194
234
  );
195
- return pushResult.exitCode === 0;
235
+
236
+ if (pushResult.exitCode !== 0) {
237
+ const stderr = pushResult.stderr ?? "";
238
+ if (
239
+ stderr.includes("stale info") ||
240
+ stderr.includes("rejected") ||
241
+ stderr.includes("non-fast-forward")
242
+ ) {
243
+ throw new StaleStateBranchError(expected, stderr);
244
+ }
245
+ return false;
246
+ }
247
+ return true;
196
248
  }
197
249
 
198
250
  /**