merge-steward 0.24.0 → 0.25.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/db/schema.js CHANGED
@@ -76,6 +76,9 @@ export function ensureSchema(connection) {
76
76
  ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
77
77
  ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
78
78
  ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
79
+ // Plan §5.3: cached identity for patch-id-aware updateHead.
80
+ ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
81
+ ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
79
82
  connection.exec(`
80
83
  UPDATE queue_entries
81
84
  SET post_merge_status = 'pending'
@@ -12,9 +12,17 @@ export declare class SqliteStore implements QueueStore {
12
12
  listActive(repoId: string): QueueEntry[];
13
13
  listAll(repoId: string): QueueEntry[];
14
14
  insert(entry: QueueEntry): void;
15
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
15
+ transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
16
16
  dequeue(entryId: string): void;
17
17
  updateHead(entryId: string, newHeadSha: string): void;
18
+ rebuildSpecHeadEquivalent(entryId: string, patch: {
19
+ headSha: string;
20
+ specSha: string;
21
+ specBranch: string;
22
+ headPatchId: string;
23
+ specTreeId: string;
24
+ ciRunId: string | null;
25
+ }, detail?: string): void;
18
26
  updatePriority(entryId: string, priority: number, detail?: string): void;
19
27
  insertIncident(incident: IncidentRecord): void;
20
28
  listIncidents(entryId: string): IncidentRecord[];
@@ -30,6 +30,8 @@ function mapEntry(row) {
30
30
  postMergeSummary: row.post_merge_summary === null || row.post_merge_summary === undefined ? null : String(row.post_merge_summary),
31
31
  postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
32
32
  prTitle: row.pr_title === null || row.pr_title === undefined ? null : String(row.pr_title),
33
+ headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
34
+ specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
33
35
  enqueuedAt: String(row.enqueued_at),
34
36
  updatedAt: String(row.updated_at),
35
37
  };
@@ -199,6 +201,14 @@ export class SqliteStore {
199
201
  sets.push("post_merge_checked_at = ?");
200
202
  values.push(patch.postMergeCheckedAt);
201
203
  }
204
+ if (patch?.headPatchId !== undefined) {
205
+ sets.push("head_patch_id = ?");
206
+ values.push(patch.headPatchId);
207
+ }
208
+ if (patch?.specTreeId !== undefined) {
209
+ sets.push("spec_tree_id = ?");
210
+ values.push(patch.specTreeId);
211
+ }
202
212
  this.conn.prepare(`UPDATE queue_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values, entryId);
203
213
  this.writeEvent(entryId, from, to, detail);
204
214
  })();
@@ -222,11 +232,34 @@ export class SqliteStore {
222
232
  spec_branch = NULL, spec_sha = NULL, spec_based_on = NULL,
223
233
  wait_detail = NULL,
224
234
  post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
235
+ head_patch_id = NULL, spec_tree_id = NULL,
225
236
  updated_at = ?
226
237
  WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
227
238
  this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
228
239
  })();
229
240
  }
241
+ rebuildSpecHeadEquivalent(entryId, patch, detail) {
242
+ this.conn.transaction(() => {
243
+ const current = this.conn.prepare("SELECT status, generation FROM queue_entries WHERE id = ?").get(entryId);
244
+ if (!current)
245
+ return;
246
+ const from = String(current.status);
247
+ if (TERMINAL_STATUSES.includes(from))
248
+ return;
249
+ const newGen = Number(current.generation) + 1;
250
+ this.conn.prepare(`UPDATE queue_entries SET
251
+ head_sha = ?, status = 'validating', generation = ?,
252
+ ci_run_id = ?, ci_retries = 0, retry_attempts = 0,
253
+ last_failed_base_sha = NULL,
254
+ spec_branch = ?, spec_sha = ?,
255
+ head_patch_id = ?, spec_tree_id = ?,
256
+ wait_detail = NULL,
257
+ post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
258
+ updated_at = ?
259
+ WHERE id = ?`).run(patch.headSha, newGen, patch.ciRunId, patch.specBranch, patch.specSha, patch.headPatchId, patch.specTreeId, isoNow(), entryId);
260
+ this.writeEvent(entryId, from, "validating", detail ?? `patch-id-equivalent rebuild: generation ${newGen}`);
261
+ })();
262
+ }
230
263
  updatePriority(entryId, priority, detail) {
231
264
  this.conn.transaction(() => {
232
265
  const current = this.conn.prepare("SELECT status, priority FROM queue_entries WHERE id = ?").get(entryId);
@@ -25,6 +25,11 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
25
25
  headSha(branch: string): Promise<string>;
26
26
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
27
27
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
28
+ patchIdAgainst(base: string, headSha: string): Promise<string | undefined>;
29
+ integrationTreeId(base: string, headSha: string): Promise<string | undefined>;
30
+ treeId(commitSha: string): Promise<string | undefined>;
31
+ commitTree(tree: string, parents: string[], message: string): Promise<string | undefined>;
32
+ pushCommit(commitSha: string, branch: string): Promise<void>;
28
33
  /**
29
34
  * Build a speculative merge branch using an isolated git worktree.
30
35
  * Each call gets its own working directory — no shared mutable state.
@@ -1,6 +1,10 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { exec } from "../exec.js";
4
+ /** Shell-quote a string for sh -c invocation (single-quote, escape inner quotes). */
5
+ function shellQuote(value) {
6
+ return `'${value.replaceAll("'", "'\\''")}'`;
7
+ }
4
8
  /** Extract conflict file names from git merge stderr. */
5
9
  function parseConflicts(stderr) {
6
10
  const files = stderr
@@ -90,6 +94,64 @@ export class ShellGitOperations {
90
94
  args.push("origin", refspec);
91
95
  await this.git(args, { timeoutMs: 60_000 });
92
96
  }
97
+ // Plan §5.3: identity primitives for patch-id-aware updateHead.
98
+ async patchIdAgainst(base, headSha) {
99
+ // `git diff base..head | git patch-id --stable` — invoke as one
100
+ // pipeline through a shell so we don't have to plumb stdin/stdout.
101
+ const result = await exec("sh", ["-c", `${this.gitBin} -C ${shellQuote(this.clonePath)} diff ${shellQuote(base)}..${shellQuote(headSha)} | ${this.gitBin} patch-id --stable`], {
102
+ timeoutMs: 30_000,
103
+ allowNonZero: true,
104
+ githubRepoFullName: this.repoFullName,
105
+ });
106
+ if (result.exitCode !== 0)
107
+ return undefined;
108
+ const first = result.stdout.split(/\s+/, 1)[0]?.trim();
109
+ return first ? first : undefined;
110
+ }
111
+ async integrationTreeId(base, headSha) {
112
+ const result = await this.git(["merge-tree", "--write-tree", "--no-messages", base, headSha], { allowNonZero: true, timeoutMs: 60_000 });
113
+ if (result.exitCode !== 0)
114
+ return undefined;
115
+ const tree = result.stdout.trim().split(/\s+/, 1)[0];
116
+ return tree || undefined;
117
+ }
118
+ async treeId(commitSha) {
119
+ const result = await this.git(["rev-parse", `${commitSha}^{tree}`], { allowNonZero: true });
120
+ if (result.exitCode !== 0)
121
+ return undefined;
122
+ const tree = result.stdout.trim();
123
+ return tree || undefined;
124
+ }
125
+ async commitTree(tree, parents, message) {
126
+ const args = ["commit-tree", tree];
127
+ for (const parent of parents) {
128
+ args.push("-p", parent);
129
+ }
130
+ args.push("-m", message);
131
+ const env = {};
132
+ if (this.botIdentity) {
133
+ env.GIT_AUTHOR_NAME = this.botIdentity.name;
134
+ env.GIT_AUTHOR_EMAIL = this.botIdentity.email;
135
+ env.GIT_COMMITTER_NAME = this.botIdentity.name;
136
+ env.GIT_COMMITTER_EMAIL = this.botIdentity.email;
137
+ }
138
+ const result = await exec(this.gitBin, ["-C", this.clonePath, ...args], {
139
+ timeoutMs: 30_000,
140
+ allowNonZero: true,
141
+ githubRepoFullName: this.repoFullName,
142
+ ...(Object.keys(env).length > 0 ? { env } : {}),
143
+ });
144
+ if (result.exitCode !== 0)
145
+ return undefined;
146
+ const sha = result.stdout.trim();
147
+ return sha || undefined;
148
+ }
149
+ async pushCommit(commitSha, branch) {
150
+ // Force-update the named branch on origin to a known commit
151
+ // SHA. Used by the patch-id-aware rebuild path which writes a
152
+ // synthetic commit-tree result without checking it out.
153
+ await this.git(["push", "--force", "origin", `${commitSha}:refs/heads/${branch}`], { timeoutMs: 60_000 });
154
+ }
93
155
  // ─── SpeculativeBranchBuilder ───────────────────────────────
94
156
  /**
95
157
  * Build a speculative merge branch using an isolated git worktree.
@@ -8,6 +8,16 @@ export interface GitOperations {
8
8
  headSha(branch: string): Promise<string>;
9
9
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
10
10
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
11
+ /** Stable patch-id of head's diff against base. Returns undefined on git error. */
12
+ patchIdAgainst?(base: string, headSha: string): Promise<string | undefined>;
13
+ /** `git merge-tree --write-tree base headSha` — returns tree-id, or undefined on conflict/error. */
14
+ integrationTreeId?(base: string, headSha: string): Promise<string | undefined>;
15
+ /** Tree id of a commit (`commit^{tree}`). */
16
+ treeId?(commitSha: string): Promise<string | undefined>;
17
+ /** `git commit-tree tree -p p1 -p p2 -m message`. Returns the new commit SHA. */
18
+ commitTree?(tree: string, parents: string[], message: string): Promise<string | undefined>;
19
+ /** Force-push a known commit SHA to a branch (overrides the target ref directly). */
20
+ pushCommit?(commitSha: string, branch: string): Promise<void>;
11
21
  }
12
22
  /**
13
23
  * Builds and manages speculative cumulative branches.
@@ -41,6 +41,18 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
41
41
  const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
42
42
  if (currentRef !== entry.headSha) {
43
43
  emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
44
+ // Plan §5.3: patch-id-aware short-circuit. If the new head is
45
+ // patch-id-equivalent to the prior head AND the freshly merged
46
+ // tree matches the cached spec tree, rebuild the spec commit
47
+ // with the new head as its second parent so GitHub still marks
48
+ // the PR merged after the lander's fast-forward — but skip the
49
+ // prepare cycle. CI must re-run on the new spec SHA (check_runs
50
+ // are SHA-anchored). Falls back to the standard `updateHead`
51
+ // path if the git plumbing is unavailable or if the identities
52
+ // don't match.
53
+ const shortCircuited = await tryPatchIdEquivalentRebuild(ctx, entry, base, currentRef);
54
+ if (shortCircuited)
55
+ return;
44
56
  ctx.store.updateHead(entry.id, currentRef);
45
57
  return;
46
58
  }
@@ -170,6 +182,16 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
170
182
  }
171
183
  const runId = await ctx.ci.triggerRun(specName, specSha);
172
184
  emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
185
+ // Plan §5.3: cache identity for the patch-id-aware short-circuit
186
+ // on subsequent updateHead detections. Best-effort — if the git
187
+ // primitives are missing the cached identity stays null and the
188
+ // short-circuit never fires (existing behavior).
189
+ const headPatchId = ctx.git.patchIdAgainst
190
+ ? await ctx.git.patchIdAgainst(base, entry.headSha).catch(() => undefined)
191
+ : undefined;
192
+ const specTreeId = ctx.git.treeId
193
+ ? await ctx.git.treeId(specSha).catch(() => undefined)
194
+ : undefined;
173
195
  ctx.store.transition(entry.id, "validating", {
174
196
  baseSha,
175
197
  ciRunId: runId,
@@ -177,5 +199,84 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
177
199
  specBranch: specName,
178
200
  specSha,
179
201
  specBasedOn: isHead ? null : prevEntry.id,
202
+ ...(headPatchId !== undefined ? { headPatchId } : {}),
203
+ ...(specTreeId !== undefined ? { specTreeId } : {}),
180
204
  }, `spec ready, CI ${runId.slice(0, 12)}`);
181
205
  }
206
+ async function tryPatchIdEquivalentRebuild(ctx, entry, base, newHead) {
207
+ // Need the cached identity *and* the git primitives required to
208
+ // verify the equivalence and rebuild the spec commit. If any
209
+ // piece is missing, fall back.
210
+ if (!entry.headPatchId || !entry.specTreeId || !entry.specBranch || !entry.specSha) {
211
+ return false;
212
+ }
213
+ const git = ctx.git;
214
+ if (!git.patchIdAgainst || !git.integrationTreeId || !git.commitTree || !git.pushCommit) {
215
+ return false;
216
+ }
217
+ let newPatchId;
218
+ let newTreeId;
219
+ let baseSha;
220
+ try {
221
+ [newPatchId, newTreeId, baseSha] = await Promise.all([
222
+ git.patchIdAgainst(base, newHead),
223
+ git.integrationTreeId(base, newHead),
224
+ git.headSha(base),
225
+ ]);
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ if (!newPatchId || !newTreeId || !baseSha)
231
+ return false;
232
+ if (newPatchId !== entry.headPatchId)
233
+ return false;
234
+ if (newTreeId !== entry.specTreeId)
235
+ return false;
236
+ // Tree-equivalent. Rebuild spec commit with the new head as the
237
+ // second parent so post-merge PR detection still works.
238
+ const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
239
+ const message = `Merge PR #${entry.prNumber}: ${branchSuffix} (rebased)`;
240
+ let newSpecSha;
241
+ try {
242
+ newSpecSha = await git.commitTree(entry.specTreeId, [baseSha, newHead], message);
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ if (!newSpecSha)
248
+ return false;
249
+ try {
250
+ await git.pushCommit(newSpecSha, entry.specBranch);
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ emit(ctx, entry, "spec_build_succeeded", {
256
+ specBranch: entry.specBranch,
257
+ detail: "patch-id-equivalent rebuild",
258
+ });
259
+ // Plan §5.2: re-announce spec availability on the new head.
260
+ if (ctx.eviction.reportSpecReady) {
261
+ await ctx.eviction.reportSpecReady(entry, entry.specBranch, newSpecSha).catch(() => undefined);
262
+ }
263
+ let runId = null;
264
+ try {
265
+ runId = await ctx.ci.triggerRun(entry.specBranch, newSpecSha);
266
+ emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: entry.specBranch });
267
+ }
268
+ catch {
269
+ runId = null;
270
+ }
271
+ ctx.store.rebuildSpecHeadEquivalent(entry.id, {
272
+ headSha: newHead,
273
+ specSha: newSpecSha,
274
+ specBranch: entry.specBranch,
275
+ headPatchId: newPatchId,
276
+ specTreeId: newTreeId,
277
+ ciRunId: runId,
278
+ }, runId
279
+ ? `patch-id-equivalent rebuild, CI ${runId.slice(0, 12)}`
280
+ : "patch-id-equivalent rebuild (CI trigger pending)");
281
+ return true;
282
+ }
@@ -63,6 +63,8 @@ export class MergeStewardQueueCommands {
63
63
  postMergeSha: null,
64
64
  postMergeSummary: null,
65
65
  postMergeCheckedAt: null,
66
+ headPatchId: null,
67
+ specTreeId: null,
66
68
  prTitle: params.prTitle ?? null,
67
69
  enqueuedAt: new Date().toISOString(),
68
70
  updatedAt: new Date().toISOString(),
package/dist/store.d.ts CHANGED
@@ -11,9 +11,27 @@ export interface QueueStore {
11
11
  listActive(repoId: string): QueueEntry[];
12
12
  listAll(repoId: string): QueueEntry[];
13
13
  insert(entry: QueueEntry): void;
14
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
14
+ transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
15
15
  dequeue(entryId: string): void;
16
16
  updateHead(entryId: string, newHeadSha: string): void;
17
+ /**
18
+ * Plan §5.3: patch-id-aware short-circuit. The new head is
19
+ * patch-id-equivalent to the cached identity AND the freshly
20
+ * computed merge tree matches the cached spec tree. We rebuild the
21
+ * spec commit with the new head as its second parent (so GitHub
22
+ * marks the PR merged after fast-forward) but do not blow away the
23
+ * spec branch or restart the prepare cycle. CI must re-run on the
24
+ * new spec SHA — check_runs are SHA-anchored and there is no API
25
+ * to reuse a passing verdict.
26
+ */
27
+ rebuildSpecHeadEquivalent(entryId: string, patch: {
28
+ headSha: string;
29
+ specSha: string;
30
+ specBranch: string;
31
+ headPatchId: string;
32
+ specTreeId: string;
33
+ ciRunId: string | null;
34
+ }, detail?: string): void;
17
35
  updatePriority(entryId: string, priority: number, detail?: string): void;
18
36
  insertIncident(incident: IncidentRecord): void;
19
37
  listIncidents(entryId: string): IncidentRecord[];
package/dist/types.d.ts CHANGED
@@ -50,6 +50,21 @@ export interface QueueEntry {
50
50
  postMergeSummary: string | null;
51
51
  /** Timestamp of the most recent post-merge verification attempt. */
52
52
  postMergeCheckedAt: string | null;
53
+ /**
54
+ * Plan §5.3: stable patch-id (`git patch-id --stable`) of the
55
+ * current `headSha` against the entry's `baseSha`. Cached at spec
56
+ * build time so the prepare path can short-circuit on
57
+ * patch-id-equivalent updateHeads without re-running the merge.
58
+ * Null until the spec is first built.
59
+ */
60
+ headPatchId: string | null;
61
+ /**
62
+ * Plan §5.3: tree id of `specSha` (i.e. `git rev-parse specSha^{tree}`).
63
+ * Cached at spec build time so we can compare against a freshly
64
+ * computed `merge-tree --write-tree` for the new head and decide
65
+ * whether the merged tree is unchanged.
66
+ */
67
+ specTreeId: string | null;
53
68
  enqueuedAt: string;
54
69
  updatedAt: string;
55
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {