merge-steward 0.5.1 → 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.
@@ -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.1",
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": {