merge-steward 0.5.0 → 0.5.2

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.
@@ -8,6 +8,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
8
8
  private git;
9
9
  fetch(remote?: string): Promise<void>;
10
10
  headSha(branch: string): Promise<string>;
11
+ isAncestor(ancestor: string, descendant: string): Promise<boolean>;
11
12
  rebase(branch: string, onto: string): Promise<RebaseResult>;
12
13
  push(branch: string, force?: boolean): Promise<void>;
13
14
  buildSpeculative(prBranch: string, baseBranch: string, specName: string): Promise<MergeResult>;
@@ -33,8 +33,25 @@ export class ShellGitOperations {
33
33
  const result = await this.git(["rev-parse", branch]);
34
34
  return result.stdout.trim();
35
35
  }
36
+ async isAncestor(ancestor, descendant) {
37
+ const result = await this.git(["merge-base", "--is-ancestor", ancestor, descendant], { allowNonZero: true });
38
+ if (result.exitCode === 0)
39
+ return true;
40
+ if (result.exitCode === 1)
41
+ return false;
42
+ throw new Error(`git merge-base --is-ancestor failed: ${result.stderr || result.stdout}`);
43
+ }
36
44
  async rebase(branch, onto) {
37
- await this.git(["checkout", branch]);
45
+ const remoteBranchRef = `refs/remotes/origin/${branch}`;
46
+ const remoteBranchExists = await this.git(["show-ref", "--verify", remoteBranchRef], { allowNonZero: true });
47
+ if (remoteBranchExists.exitCode === 0) {
48
+ // Always start from the freshly fetched remote branch tip so stale local branches
49
+ // cannot reintroduce old commits into queue processing.
50
+ await this.git(["checkout", "-B", branch, `origin/${branch}`]);
51
+ }
52
+ else {
53
+ await this.git(["checkout", branch]);
54
+ }
38
55
  const result = await this.git(["rebase", onto], { allowNonZero: true });
39
56
  if (result.exitCode !== 0) {
40
57
  await this.git(["rebase", "--abort"], { allowNonZero: true });
@@ -6,6 +6,7 @@ import type { CIStatus, CheckResult, IncidentRecord, MergeResult, PRStatus, Queu
6
6
  export interface GitOperations {
7
7
  fetch(remote?: string): Promise<void>;
8
8
  headSha(branch: string): Promise<string>;
9
+ isAncestor(ancestor: string, descendant: string): Promise<boolean>;
9
10
  rebase(branch: string, onto: string): Promise<RebaseResult>;
10
11
  push(branch: string, force?: boolean): Promise<void>;
11
12
  }
@@ -154,6 +154,26 @@ async function performRebase(ctx, entry, baseSha) {
154
154
  return;
155
155
  }
156
156
  const headSha = result.newHeadSha ?? entry.headSha;
157
+ await ctx.git.fetch();
158
+ const latestRemoteHead = await ctx.git.headSha(ref(ctx, entry.branch));
159
+ const candidateKeepsLatestRemote = await ctx.git.isAncestor(latestRemoteHead, headSha);
160
+ if (!candidateKeepsLatestRemote) {
161
+ const detail = latestRemoteHead === entry.headSha
162
+ ? `candidate diverged from remote head: expected ${entry.headSha.slice(0, 8)}, ` +
163
+ `latest ${latestRemoteHead.slice(0, 8)}, candidate ${headSha.slice(0, 8)}`
164
+ : `remote advanced during rebase: expected ${entry.headSha.slice(0, 8)}, ` +
165
+ `latest ${latestRemoteHead.slice(0, 8)}, candidate ${headSha.slice(0, 8)}`;
166
+ emit(ctx, entry, "branch_mismatch", { detail });
167
+ if (latestRemoteHead !== entry.headSha) {
168
+ ctx.store.updateHead(entry.id, latestRemoteHead);
169
+ }
170
+ else {
171
+ ctx.store.transition(entry.id, "queued", {
172
+ ...CLEAN_CI,
173
+ }, `stale local branch diverged from ${latestRemoteHead.slice(0, 8)}`);
174
+ }
175
+ return;
176
+ }
157
177
  await ctx.git.push(entry.branch, true);
158
178
  emit(ctx, entry, "rebase_succeeded", { baseSha });
159
179
  // Build speculative branch for downstream entries.
package/dist/service.d.ts CHANGED
@@ -23,7 +23,6 @@ export declare class MergeStewardService {
23
23
  private lastTickCompletedAt;
24
24
  private lastTickOutcome;
25
25
  private lastTickError;
26
- private mainBrokenReported;
27
26
  private currentQueueBlock;
28
27
  constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder | null, logger: Logger);
29
28
  /** Expose the GitHub client for webhook handler branch→PR lookups. */
package/dist/service.js CHANGED
@@ -21,7 +21,6 @@ export class MergeStewardService {
21
21
  lastTickCompletedAt = null;
22
22
  lastTickOutcome = "idle";
23
23
  lastTickError = null;
24
- mainBrokenReported = false;
25
24
  currentQueueBlock = null;
26
25
  constructor(config, store, git, ci, github, eviction, specBuilder, logger) {
27
26
  this.config = config;
@@ -264,16 +263,12 @@ export class MergeStewardService {
264
263
  || event.action === "fetch_started";
265
264
  const level = isWarn ? "warn" : isDebug ? "debug" : "info";
266
265
  this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
267
- if (event.action === "main_broken" && !this.mainBrokenReported) {
268
- this.mainBrokenReported = true;
269
- }
270
266
  if (event.action === "main_broken" && tickQueueBlockEvent === null) {
271
267
  tickQueueBlockEvent = event;
272
268
  }
273
269
  },
274
270
  });
275
271
  this.currentQueueBlock = tickQueueBlockEvent ? await this.describeMainBroken(tickQueueBlockEvent) : null;
276
- this.mainBrokenReported = false; // Reset after successful tick.
277
272
  this.lastTickOutcome = "succeeded";
278
273
  }
279
274
  catch (error) {
@@ -309,9 +304,10 @@ export class MergeStewardService {
309
304
  baseSha = event.baseSha ?? null;
310
305
  }
311
306
  }
307
+ const checkRef = event.baseSha ?? baseSha ?? baseRef;
312
308
  let checks = [];
313
309
  try {
314
- checks = await this.github.listChecksForRef(baseRef);
310
+ checks = await this.github.listChecksForRef(checkRef);
315
311
  }
316
312
  catch {
317
313
  checks = [];
@@ -19,5 +19,5 @@ export function QueueListView({ entries, selectedEntryId, recentEvents, headEntr
19
19
  const eventRows = Math.min(8, Math.max(4, rows - entries.length - CHROME_ROWS));
20
20
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
21
21
  const queueBlockLabel = summarizeQueueBlock(queueBlock);
22
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), _jsx(Text, { dimColor: true, children: " s h pos pr status rt ago branch" }), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, branchWidth: branchWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
22
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), _jsx(Text, { dimColor: true, children: " sel head pos pr status retry age branch" }), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, branchWidth: branchWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
23
23
  }
@@ -21,6 +21,12 @@ export async function startWatch(configPath, initialPrNumber) {
21
21
  const config = loadConfig(configPath);
22
22
  const gatewayBase = resolveGatewayBaseUrl();
23
23
  const baseUrl = `${gatewayBase}/repos/${config.repoId}`;
24
- const instance = render(createElement(App, { baseUrl, ...(initialPrNumber !== undefined ? { initialPrNumber } : {}) }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
25
- await instance.waitUntilExit();
24
+ process.stderr.write("\u001b[?1049h\u001b[2J\u001b[H");
25
+ try {
26
+ const instance = render(createElement(App, { baseUrl, ...(initialPrNumber !== undefined ? { initialPrNumber } : {}) }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
27
+ await instance.waitUntilExit();
28
+ }
29
+ finally {
30
+ process.stderr.write("\u001b[?1049l");
31
+ }
26
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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": {