merge-steward 0.8.6 → 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.
- package/dist/github/shell-git.d.ts +15 -1
- package/dist/github/shell-git.js +72 -23
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/github/shell-git.js
CHANGED
|
@@ -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(["
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
await this.
|
|
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.
|
|
81
|
-
|
|
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
|
}
|