merge-steward 0.8.5 → 0.8.7

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.
@@ -4,13 +4,27 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
4
4
  private readonly clonePath;
5
5
  private readonly repoFullName;
6
6
  private readonly gitBin;
7
+ private readonly worktreeBase;
7
8
  constructor(clonePath: string, repoFullName: string, gitBin?: string);
8
9
  private git;
10
+ /** Run a git command in a specific directory (worktree). */
11
+ private gitIn;
9
12
  fetch(remote?: string): Promise<void>;
10
13
  headSha(branch: string): Promise<string>;
11
14
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
12
- mergeBaseInto(branch: string, base: string): Promise<MergeResult>;
13
15
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
16
+ /**
17
+ * Build a speculative merge branch using an isolated git worktree.
18
+ * Each call gets its own working directory — no shared mutable state.
19
+ * The worktree is removed after the merge; the branch ref persists
20
+ * so the reconciler can push it and reference its SHA.
21
+ */
14
22
  buildSpeculative(prBranch: string, baseBranch: string, specName: string): Promise<MergeResult>;
23
+ /**
24
+ * During a merge conflict, check if the only unmerged files are lockfiles
25
+ * (package-lock.json). If so, resolve by regenerating from the merged
26
+ * package.json via `npm install --package-lock-only`.
27
+ */
28
+ private tryResolveLockfileConflict;
15
29
  deleteSpeculative(specName: string): Promise<void>;
16
30
  }
@@ -1,3 +1,5 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { exec } from "../exec.js";
2
4
  /** Extract conflict file names from git merge stderr. */
3
5
  function parseConflicts(stderr) {
@@ -14,10 +16,12 @@ export class ShellGitOperations {
14
16
  clonePath;
15
17
  repoFullName;
16
18
  gitBin;
19
+ worktreeBase;
17
20
  constructor(clonePath, repoFullName, gitBin = "git") {
18
21
  this.clonePath = clonePath;
19
22
  this.repoFullName = repoFullName;
20
23
  this.gitBin = gitBin;
24
+ this.worktreeBase = `${clonePath}-worktrees`;
21
25
  }
22
26
  git(args, opts) {
23
27
  return exec(this.gitBin, ["-C", this.clonePath, ...args], {
@@ -26,6 +30,14 @@ export class ShellGitOperations {
26
30
  githubRepoFullName: this.repoFullName,
27
31
  });
28
32
  }
33
+ /** Run a git command in a specific directory (worktree). */
34
+ gitIn(cwd, args, opts) {
35
+ return exec(this.gitBin, ["-C", cwd, ...args], {
36
+ timeoutMs: opts?.timeoutMs ?? 120_000,
37
+ allowNonZero: opts?.allowNonZero,
38
+ githubRepoFullName: this.repoFullName,
39
+ });
40
+ }
29
41
  async fetch(remote = "origin") {
30
42
  await this.git(["fetch", remote], { timeoutMs: 60_000 });
31
43
  }
@@ -41,23 +53,6 @@ export class ShellGitOperations {
41
53
  return false;
42
54
  throw new Error(`git merge-base --is-ancestor failed: ${result.stderr || result.stdout}`);
43
55
  }
44
- async mergeBaseInto(branch, base) {
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
- await this.git(["checkout", "-B", branch, `origin/${branch}`]);
49
- }
50
- else {
51
- await this.git(["checkout", branch]);
52
- }
53
- const result = await this.git(["merge", "--no-ff", "--no-edit", base], { allowNonZero: true });
54
- if (result.exitCode !== 0) {
55
- await this.git(["merge", "--abort"], { allowNonZero: true });
56
- return { success: false, conflictFiles: parseConflicts(result.stderr) };
57
- }
58
- const newSha = await this.headSha("HEAD");
59
- return { success: true, sha: newSha };
60
- }
61
56
  async push(branch, force = false, targetBranch) {
62
57
  const args = ["push"];
63
58
  if (force)
@@ -67,21 +62,75 @@ export class ShellGitOperations {
67
62
  await this.git(args, { timeoutMs: 60_000 });
68
63
  }
69
64
  // ─── SpeculativeBranchBuilder ───────────────────────────────
65
+ /**
66
+ * Build a speculative merge branch using an isolated git worktree.
67
+ * Each call gets its own working directory — no shared mutable state.
68
+ * The worktree is removed after the merge; the branch ref persists
69
+ * so the reconciler can push it and reference its SHA.
70
+ */
70
71
  async buildSpeculative(prBranch, baseBranch, specName) {
72
+ const wtPath = join(this.worktreeBase, specName);
73
+ // Clean up any leftover worktree/branch from a previous run.
74
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
71
75
  await this.git(["branch", "-D", specName], { allowNonZero: true });
72
- await this.git(["checkout", "-B", specName, baseBranch]);
73
- const result = await this.git(["merge", "--no-ff", prBranch], { allowNonZero: true });
76
+ await this.git(["worktree", "prune"], { allowNonZero: true });
77
+ // Create isolated worktree with spec branch starting at baseBranch.
78
+ mkdirSync(this.worktreeBase, { recursive: true });
79
+ await this.git(["worktree", "add", "-B", specName, wtPath, baseBranch]);
80
+ // Merge PR into spec branch inside the worktree.
81
+ const result = await this.gitIn(wtPath, ["merge", "--no-ff", prBranch], { allowNonZero: true });
74
82
  if (result.exitCode !== 0) {
75
83
  const conflictFiles = parseConflicts(result.stderr);
76
- await this.git(["merge", "--abort"], { allowNonZero: true });
77
- await this.git(["checkout", "-"], { allowNonZero: true });
84
+ // Lockfile-only conflicts can be auto-resolved by regenerating.
85
+ if (await this.tryResolveLockfileConflict(wtPath)) {
86
+ const sha = (await this.gitIn(wtPath, ["rev-parse", "HEAD"])).stdout.trim();
87
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
88
+ return { success: true, sha };
89
+ }
90
+ await this.gitIn(wtPath, ["merge", "--abort"], { allowNonZero: true });
91
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
92
+ await this.git(["branch", "-D", specName], { allowNonZero: true });
78
93
  return { success: false, conflictFiles };
79
94
  }
80
- const sha = (await this.git(["rev-parse", "HEAD"])).stdout.trim();
81
- await this.git(["checkout", "-"], { allowNonZero: true });
95
+ const sha = (await this.gitIn(wtPath, ["rev-parse", "HEAD"])).stdout.trim();
96
+ // Remove worktree but keep the branch — reconciler needs it for push/CI.
97
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
82
98
  return { success: true, sha };
83
99
  }
100
+ /**
101
+ * During a merge conflict, check if the only unmerged files are lockfiles
102
+ * (package-lock.json). If so, resolve by regenerating from the merged
103
+ * package.json via `npm install --package-lock-only`.
104
+ */
105
+ async tryResolveLockfileConflict(wtPath) {
106
+ try {
107
+ const unmerged = await this.gitIn(wtPath, ["diff", "--name-only", "--diff-filter=U"]);
108
+ const files = unmerged.stdout.trim().split("\n").filter(Boolean);
109
+ if (files.length === 0 || !files.every((f) => f.endsWith("package-lock.json"))) {
110
+ return false;
111
+ }
112
+ for (const file of files) {
113
+ await this.gitIn(wtPath, ["checkout", "--ours", "--", file]);
114
+ }
115
+ const npmResult = await exec("npm", ["install", "--package-lock-only"], {
116
+ cwd: wtPath,
117
+ timeoutMs: 60_000,
118
+ });
119
+ if (npmResult.exitCode !== 0)
120
+ return false;
121
+ for (const file of files) {
122
+ await this.gitIn(wtPath, ["add", file]);
123
+ }
124
+ const commitResult = await this.gitIn(wtPath, ["commit", "--no-edit"], { allowNonZero: true });
125
+ return commitResult.exitCode === 0;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
84
131
  async deleteSpeculative(specName) {
132
+ const wtPath = join(this.worktreeBase, specName);
133
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
85
134
  await this.git(["branch", "-D", specName], { allowNonZero: true });
86
135
  }
87
136
  }
@@ -159,7 +159,6 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
159
159
  }
160
160
  // Gate: non-spinning — skip if base hasn't changed since last conflict.
161
161
  if (isRetryGated(entry, baseSha)) {
162
- emit(ctx, entry, "retry_gated", { baseSha, detail: "base unchanged since last conflict" });
163
162
  try {
164
163
  const prStatus = await ctx.github.getStatus(entry.prNumber);
165
164
  if (prStatus.mergeStateStatus === "DIRTY") {
@@ -168,10 +167,18 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
168
167
  detail: "retry gated and GitHub still reports merge conflict",
169
168
  });
170
169
  await evictEntry(ctx, entry, "integration_conflict");
170
+ return;
171
171
  }
172
+ // GitHub says CLEAN but local clone conflicted — the conflict was
173
+ // likely transient (stale clone state). Clear the gate and retry.
174
+ emit(ctx, entry, "retry_gated", { baseSha, detail: "local conflict but GitHub reports CLEAN, retrying" });
175
+ ctx.store.transition(entry.id, "preparing_head", {
176
+ lastFailedBaseSha: null, ...CLEAN_CI, ...CLEAN_SPEC,
177
+ }, "GitHub reports CLEAN, clearing retry gate");
172
178
  }
173
179
  catch {
174
- // Best-effort check.
180
+ // GitHub probe failed — hold the gate.
181
+ emit(ctx, entry, "retry_gated", { baseSha, detail: "base unchanged since last conflict" });
175
182
  }
176
183
  return;
177
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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": {