newpr 0.6.5 → 1.0.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/package.json +1 -1
- package/src/history/store.ts +25 -0
- package/src/stack/balance.ts +128 -0
- package/src/stack/coupling.test.ts +158 -0
- package/src/stack/coupling.ts +135 -0
- package/src/stack/delta.test.ts +223 -0
- package/src/stack/delta.ts +264 -0
- package/src/stack/execute.test.ts +176 -0
- package/src/stack/execute.ts +194 -0
- package/src/stack/feasibility.test.ts +185 -0
- package/src/stack/feasibility.ts +286 -0
- package/src/stack/integration.test.ts +266 -0
- package/src/stack/merge-groups.test.ts +97 -0
- package/src/stack/merge-groups.ts +87 -0
- package/src/stack/partition.test.ts +233 -0
- package/src/stack/partition.ts +273 -0
- package/src/stack/plan.test.ts +154 -0
- package/src/stack/plan.ts +139 -0
- package/src/stack/pr-title.ts +64 -0
- package/src/stack/publish.ts +96 -0
- package/src/stack/split.ts +173 -0
- package/src/stack/types.ts +202 -0
- package/src/stack/verify.test.ts +137 -0
- package/src/stack/verify.ts +201 -0
- package/src/web/client/components/FeasibilityAlert.tsx +64 -0
- package/src/web/client/components/InputScreen.tsx +100 -89
- package/src/web/client/components/ResultsScreen.tsx +10 -2
- package/src/web/client/components/StackGroupCard.tsx +171 -0
- package/src/web/client/components/StackWarnings.tsx +135 -0
- package/src/web/client/hooks/useStack.ts +301 -0
- package/src/web/client/panels/StackPanel.tsx +289 -0
- package/src/web/server/routes.ts +114 -0
- package/src/web/server/stack-manager.ts +580 -0
- package/src/web/server.ts +15 -0
- package/src/web/styles/built.css +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { DeltaEntry, DeltaFileChange, DeltaStatus, StackGroupStats } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export class DeltaExtractionError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "DeltaExtractionError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function extractDeltas(
|
|
11
|
+
repoPath: string,
|
|
12
|
+
baseSha: string,
|
|
13
|
+
headSha: string,
|
|
14
|
+
): Promise<DeltaEntry[]> {
|
|
15
|
+
const commitList = await getCommitList(repoPath, baseSha, headSha);
|
|
16
|
+
const deltas: DeltaEntry[] = [];
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < commitList.length; i++) {
|
|
19
|
+
const sha = commitList[i];
|
|
20
|
+
if (!sha) continue;
|
|
21
|
+
|
|
22
|
+
const parentSha = i === 0 ? baseSha : commitList[i - 1];
|
|
23
|
+
if (!parentSha) continue;
|
|
24
|
+
|
|
25
|
+
const changes = await extractCommitChanges(repoPath, parentSha, sha);
|
|
26
|
+
const metadata = await getCommitMetadata(repoPath, sha);
|
|
27
|
+
|
|
28
|
+
deltas.push({
|
|
29
|
+
sha,
|
|
30
|
+
parent_sha: parentSha,
|
|
31
|
+
author: metadata.author,
|
|
32
|
+
date: metadata.date,
|
|
33
|
+
message: metadata.message,
|
|
34
|
+
changes,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return deltas;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getCommitList(
|
|
42
|
+
repoPath: string,
|
|
43
|
+
baseSha: string,
|
|
44
|
+
headSha: string,
|
|
45
|
+
): Promise<string[]> {
|
|
46
|
+
const result = await Bun.$`git -C ${repoPath} rev-list --first-parent --reverse ${baseSha}..${headSha}`
|
|
47
|
+
.quiet()
|
|
48
|
+
.nothrow();
|
|
49
|
+
|
|
50
|
+
if (result.exitCode !== 0) {
|
|
51
|
+
throw new DeltaExtractionError(
|
|
52
|
+
`Failed to get commit list: ${result.stderr.toString().trim()}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result.stdout
|
|
57
|
+
.toString()
|
|
58
|
+
.trim()
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter((line) => line.length > 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function getCommitMetadata(
|
|
64
|
+
repoPath: string,
|
|
65
|
+
sha: string,
|
|
66
|
+
): Promise<{ author: string; date: string; message: string }> {
|
|
67
|
+
const result = await Bun.$`git -C ${repoPath} show -s --format=%an%n%aI%n%s ${sha}`
|
|
68
|
+
.quiet()
|
|
69
|
+
.nothrow();
|
|
70
|
+
|
|
71
|
+
if (result.exitCode !== 0) {
|
|
72
|
+
throw new DeltaExtractionError(
|
|
73
|
+
`Failed to get commit metadata: ${result.stderr.toString().trim()}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lines = result.stdout.toString().trim().split("\n");
|
|
78
|
+
return {
|
|
79
|
+
author: lines[0] || "Unknown",
|
|
80
|
+
date: lines[1] || new Date().toISOString(),
|
|
81
|
+
message: lines[2] || "",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function extractCommitChanges(
|
|
86
|
+
repoPath: string,
|
|
87
|
+
parentSha: string,
|
|
88
|
+
commitSha: string,
|
|
89
|
+
): Promise<DeltaFileChange[]> {
|
|
90
|
+
const result = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z -M --no-commit-id --no-textconv --no-ext-diff ${parentSha} ${commitSha}`
|
|
91
|
+
.quiet()
|
|
92
|
+
.nothrow();
|
|
93
|
+
|
|
94
|
+
if (result.exitCode !== 0) {
|
|
95
|
+
throw new DeltaExtractionError(
|
|
96
|
+
`Failed to extract changes: ${result.stderr.toString().trim()}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parseDiffTreeOutput(result.stdout);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseDiffTreeOutput(output: Buffer): DeltaFileChange[] {
|
|
104
|
+
const changes: DeltaFileChange[] = [];
|
|
105
|
+
const entries = output.toString("utf-8").split("\0").filter((s) => s.length > 0);
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < entries.length; i++) {
|
|
108
|
+
const entry = entries[i];
|
|
109
|
+
if (!entry) continue;
|
|
110
|
+
if (!entry.startsWith(":")) continue;
|
|
111
|
+
|
|
112
|
+
const match = entry.match(
|
|
113
|
+
/^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([AMDRC])(\d*)$/,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (!match) continue;
|
|
117
|
+
|
|
118
|
+
const oldMode = match[1];
|
|
119
|
+
const newMode = match[2];
|
|
120
|
+
const oldBlob = match[3];
|
|
121
|
+
const newBlob = match[4];
|
|
122
|
+
const statusChar = match[5];
|
|
123
|
+
const pathInfo = entries[i + 1];
|
|
124
|
+
|
|
125
|
+
if (!oldMode || !newMode || !oldBlob || !newBlob || !statusChar || !pathInfo) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const status = statusChar as DeltaStatus;
|
|
130
|
+
i++;
|
|
131
|
+
|
|
132
|
+
checkForUnsupportedModes(oldMode, newMode, pathInfo);
|
|
133
|
+
|
|
134
|
+
if (status === "R") {
|
|
135
|
+
const oldPath = pathInfo;
|
|
136
|
+
const newPath = entries[i + 1];
|
|
137
|
+
if (!newPath) continue;
|
|
138
|
+
i++;
|
|
139
|
+
|
|
140
|
+
changes.push({
|
|
141
|
+
status,
|
|
142
|
+
path: newPath,
|
|
143
|
+
old_path: oldPath,
|
|
144
|
+
old_blob: oldBlob,
|
|
145
|
+
new_blob: newBlob,
|
|
146
|
+
old_mode: oldMode,
|
|
147
|
+
new_mode: newMode,
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
changes.push({
|
|
151
|
+
status,
|
|
152
|
+
path: pathInfo,
|
|
153
|
+
old_blob: oldBlob,
|
|
154
|
+
new_blob: newBlob,
|
|
155
|
+
old_mode: oldMode,
|
|
156
|
+
new_mode: newMode,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return changes;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function checkForUnsupportedModes(
|
|
165
|
+
oldMode: string,
|
|
166
|
+
newMode: string,
|
|
167
|
+
path: string,
|
|
168
|
+
): void {
|
|
169
|
+
const modes = [oldMode, newMode];
|
|
170
|
+
|
|
171
|
+
for (const mode of modes) {
|
|
172
|
+
if (mode === "160000") {
|
|
173
|
+
throw new DeltaExtractionError(
|
|
174
|
+
`Submodule detected at "${path}". Submodules are not supported in v1.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (mode === "120000") {
|
|
179
|
+
throw new DeltaExtractionError(
|
|
180
|
+
`Symlink detected at "${path}". Symlinks are not supported in v1.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function computeGroupStats(
|
|
187
|
+
repoPath: string,
|
|
188
|
+
baseSha: string,
|
|
189
|
+
orderedGroupIds: string[],
|
|
190
|
+
expectedTrees: Map<string, string>,
|
|
191
|
+
): Promise<Map<string, StackGroupStats>> {
|
|
192
|
+
const stats = new Map<string, StackGroupStats>();
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < orderedGroupIds.length; i++) {
|
|
195
|
+
const gid = orderedGroupIds[i]!;
|
|
196
|
+
const tree = expectedTrees.get(gid);
|
|
197
|
+
if (!tree) continue;
|
|
198
|
+
|
|
199
|
+
const prevTree = i === 0
|
|
200
|
+
? await resolveTree(repoPath, baseSha)
|
|
201
|
+
: expectedTrees.get(orderedGroupIds[i - 1]!);
|
|
202
|
+
if (!prevTree) continue;
|
|
203
|
+
|
|
204
|
+
const numstatResult = await Bun.$`git -C ${repoPath} diff-tree --numstat -r ${prevTree} ${tree}`.quiet().nothrow();
|
|
205
|
+
const rawResult = await Bun.$`git -C ${repoPath} diff-tree --raw --no-commit-id -r -z ${prevTree} ${tree}`.quiet().nothrow();
|
|
206
|
+
|
|
207
|
+
let additions = 0;
|
|
208
|
+
let deletions = 0;
|
|
209
|
+
let filesAdded = 0;
|
|
210
|
+
let filesModified = 0;
|
|
211
|
+
let filesDeleted = 0;
|
|
212
|
+
|
|
213
|
+
if (numstatResult.exitCode === 0) {
|
|
214
|
+
const lines = numstatResult.stdout.toString().trim().split("\n").filter(Boolean);
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
const parts = line.split("\t");
|
|
217
|
+
if (parts.length < 3) continue;
|
|
218
|
+
const [addStr, delStr] = parts;
|
|
219
|
+
if (addStr === "-" || delStr === "-") continue;
|
|
220
|
+
additions += parseInt(addStr!, 10);
|
|
221
|
+
deletions += parseInt(delStr!, 10);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (rawResult.exitCode === 0) {
|
|
226
|
+
const entries = rawResult.stdout.toString("utf-8").split("\0").filter(Boolean);
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (!entry.startsWith(":")) continue;
|
|
229
|
+
const match = entry.match(/^:\d+ \d+ [0-9a-f]+ [0-9a-f]+ ([AMDRC])/);
|
|
230
|
+
if (!match) continue;
|
|
231
|
+
const status = match[1];
|
|
232
|
+
if (status === "A") filesAdded++;
|
|
233
|
+
else if (status === "D") filesDeleted++;
|
|
234
|
+
else filesModified++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
stats.set(gid, { additions, deletions, files_added: filesAdded, files_modified: filesModified, files_deleted: filesDeleted });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return stats;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function resolveTree(repoPath: string, commitSha: string): Promise<string> {
|
|
245
|
+
const result = await Bun.$`git -C ${repoPath} rev-parse ${commitSha}^{tree}`.quiet().nothrow();
|
|
246
|
+
if (result.exitCode !== 0) {
|
|
247
|
+
throw new DeltaExtractionError(`Failed to resolve tree for ${commitSha}: ${result.stderr.toString().trim()}`);
|
|
248
|
+
}
|
|
249
|
+
return result.stdout.toString().trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function buildRenameMap(deltas: DeltaEntry[]): Map<string, string> {
|
|
253
|
+
const renameMap = new Map<string, string>();
|
|
254
|
+
|
|
255
|
+
for (const delta of deltas) {
|
|
256
|
+
for (const change of delta.changes) {
|
|
257
|
+
if (change.status === "R" && change.old_path) {
|
|
258
|
+
renameMap.set(change.old_path, change.path);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return renameMap;
|
|
264
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { createStackPlan } from "./plan.ts";
|
|
6
|
+
import { executeStack } from "./execute.ts";
|
|
7
|
+
import { extractDeltas } from "./delta.ts";
|
|
8
|
+
import type { FileGroup } from "../types/output.ts";
|
|
9
|
+
|
|
10
|
+
let testRepoPath: string;
|
|
11
|
+
let baseSha: string;
|
|
12
|
+
let headSha: string;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
testRepoPath = mkdtempSync(join(tmpdir(), "exec-test-"));
|
|
16
|
+
|
|
17
|
+
await Bun.$`git init ${testRepoPath}`.quiet();
|
|
18
|
+
await Bun.$`git -C ${testRepoPath} config user.name "Test User"`.quiet();
|
|
19
|
+
await Bun.$`git -C ${testRepoPath} config user.email "test@example.com"`.quiet();
|
|
20
|
+
|
|
21
|
+
writeFileSync(join(testRepoPath, "README.md"), "initial\n");
|
|
22
|
+
await Bun.$`git -C ${testRepoPath} add README.md`.quiet();
|
|
23
|
+
await Bun.$`git -C ${testRepoPath} commit -m "Initial commit"`.quiet();
|
|
24
|
+
|
|
25
|
+
baseSha = (await Bun.$`git -C ${testRepoPath} rev-parse HEAD`.quiet()).stdout.toString().trim();
|
|
26
|
+
|
|
27
|
+
mkdirSync(join(testRepoPath, "src"), { recursive: true });
|
|
28
|
+
writeFileSync(join(testRepoPath, "src", "auth.ts"), "export const auth = true;\n");
|
|
29
|
+
await Bun.$`git -C ${testRepoPath} add src/auth.ts`.quiet();
|
|
30
|
+
await Bun.$`git -C ${testRepoPath} commit -m "Add auth module"`.quiet();
|
|
31
|
+
|
|
32
|
+
writeFileSync(join(testRepoPath, "src", "ui.tsx"), "export const UI = () => <div/>;\n");
|
|
33
|
+
await Bun.$`git -C ${testRepoPath} add src/ui.tsx`.quiet();
|
|
34
|
+
await Bun.$`git -C ${testRepoPath} commit -m "Add UI component"`.quiet();
|
|
35
|
+
|
|
36
|
+
headSha = (await Bun.$`git -C ${testRepoPath} rev-parse HEAD`.quiet()).stdout.toString().trim();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
if (testRepoPath) {
|
|
41
|
+
rmSync(testRepoPath, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("executeStack", () => {
|
|
46
|
+
test("creates commit chain with correct parent links", async () => {
|
|
47
|
+
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
48
|
+
const ownership = new Map([
|
|
49
|
+
["src/auth.ts", "Auth"],
|
|
50
|
+
["src/ui.tsx", "UI"],
|
|
51
|
+
]);
|
|
52
|
+
const groups: FileGroup[] = [
|
|
53
|
+
{ name: "Auth", type: "feature", description: "Auth changes", files: ["src/auth.ts"] },
|
|
54
|
+
{ name: "UI", type: "feature", description: "UI changes", files: ["src/ui.tsx"] },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const plan = await createStackPlan({
|
|
58
|
+
repo_path: testRepoPath,
|
|
59
|
+
base_sha: baseSha,
|
|
60
|
+
head_sha: headSha,
|
|
61
|
+
deltas,
|
|
62
|
+
ownership,
|
|
63
|
+
group_order: ["Auth", "UI"],
|
|
64
|
+
groups,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await executeStack({
|
|
68
|
+
repo_path: testRepoPath,
|
|
69
|
+
plan,
|
|
70
|
+
deltas,
|
|
71
|
+
ownership,
|
|
72
|
+
pr_author: { name: "Test Author", email: "author@test.com" },
|
|
73
|
+
pr_number: 42,
|
|
74
|
+
head_branch: "feature-branch",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(result.group_commits.length).toBe(2);
|
|
78
|
+
expect(result.source_copy_branch).toBe("newpr/stack-source/pr-42");
|
|
79
|
+
|
|
80
|
+
const copyRef = await Bun.$`git -C ${testRepoPath} rev-parse refs/heads/newpr/stack-source/pr-42`.quiet().nothrow();
|
|
81
|
+
expect(copyRef.exitCode).toBe(0);
|
|
82
|
+
expect(copyRef.stdout.toString().trim()).toBe(headSha);
|
|
83
|
+
|
|
84
|
+
const commit0 = result.group_commits[0];
|
|
85
|
+
const commit1 = result.group_commits[1];
|
|
86
|
+
|
|
87
|
+
expect(commit0?.group_id).toBe("Auth");
|
|
88
|
+
expect(commit1?.group_id).toBe("UI");
|
|
89
|
+
|
|
90
|
+
expect(commit0?.branch_name).toMatch(/^newpr-stack\/pr-42\/0-[a-z0-9]{6}$/);
|
|
91
|
+
expect(commit1?.branch_name).toMatch(/^newpr-stack\/pr-42\/1-[a-z0-9]{6}$/);
|
|
92
|
+
|
|
93
|
+
if (commit0) {
|
|
94
|
+
const parent0 = (await Bun.$`git -C ${testRepoPath} rev-parse ${commit0.commit_sha}^`.quiet()).stdout.toString().trim();
|
|
95
|
+
expect(parent0).toBe(baseSha);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (commit0 && commit1) {
|
|
99
|
+
const parent1 = (await Bun.$`git -C ${testRepoPath} rev-parse ${commit1.commit_sha}^`.quiet()).stdout.toString().trim();
|
|
100
|
+
expect(parent1).toBe(commit0.commit_sha);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("final tree equals HEAD tree", async () => {
|
|
105
|
+
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
106
|
+
const ownership = new Map([
|
|
107
|
+
["src/auth.ts", "Auth"],
|
|
108
|
+
["src/ui.tsx", "UI"],
|
|
109
|
+
]);
|
|
110
|
+
const groups: FileGroup[] = [
|
|
111
|
+
{ name: "Auth", type: "feature", description: "Auth", files: ["src/auth.ts"] },
|
|
112
|
+
{ name: "UI", type: "feature", description: "UI", files: ["src/ui.tsx"] },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const plan = await createStackPlan({
|
|
116
|
+
repo_path: testRepoPath,
|
|
117
|
+
base_sha: baseSha,
|
|
118
|
+
head_sha: headSha,
|
|
119
|
+
deltas,
|
|
120
|
+
ownership,
|
|
121
|
+
group_order: ["Auth", "UI"],
|
|
122
|
+
groups,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await executeStack({
|
|
126
|
+
repo_path: testRepoPath,
|
|
127
|
+
plan,
|
|
128
|
+
deltas,
|
|
129
|
+
ownership,
|
|
130
|
+
pr_author: { name: "Test", email: "t@t.com" },
|
|
131
|
+
pr_number: 42,
|
|
132
|
+
head_branch: "feature-branch",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const headTree = (await Bun.$`git -C ${testRepoPath} rev-parse ${headSha}^{tree}`.quiet()).stdout.toString().trim();
|
|
136
|
+
expect(result.final_tree_sha).toBe(headTree);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("branches are created in repo", async () => {
|
|
140
|
+
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
141
|
+
const ownership = new Map([
|
|
142
|
+
["src/auth.ts", "Auth"],
|
|
143
|
+
["src/ui.tsx", "UI"],
|
|
144
|
+
]);
|
|
145
|
+
const groups: FileGroup[] = [
|
|
146
|
+
{ name: "Auth", type: "feature", description: "Auth", files: ["src/auth.ts"] },
|
|
147
|
+
{ name: "UI", type: "feature", description: "UI", files: ["src/ui.tsx"] },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
const plan = await createStackPlan({
|
|
151
|
+
repo_path: testRepoPath,
|
|
152
|
+
base_sha: baseSha,
|
|
153
|
+
head_sha: headSha,
|
|
154
|
+
deltas,
|
|
155
|
+
ownership,
|
|
156
|
+
group_order: ["Auth", "UI"],
|
|
157
|
+
groups,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await executeStack({
|
|
161
|
+
repo_path: testRepoPath,
|
|
162
|
+
plan,
|
|
163
|
+
deltas,
|
|
164
|
+
ownership,
|
|
165
|
+
pr_author: { name: "Test", email: "t@t.com" },
|
|
166
|
+
pr_number: 99,
|
|
167
|
+
head_branch: "feature-branch",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const gc of result.group_commits) {
|
|
171
|
+
const ref = await Bun.$`git -C ${testRepoPath} rev-parse refs/heads/${gc.branch_name}`.quiet().nothrow();
|
|
172
|
+
expect(ref.exitCode).toBe(0);
|
|
173
|
+
expect(ref.stdout.toString().trim()).toBe(gc.commit_sha);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeltaEntry,
|
|
3
|
+
StackPlan,
|
|
4
|
+
StackExecResult,
|
|
5
|
+
GroupCommitInfo,
|
|
6
|
+
} from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export class StackExecutionError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "StackExecutionError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ExecuteInput {
|
|
16
|
+
repo_path: string;
|
|
17
|
+
plan: StackPlan;
|
|
18
|
+
deltas: DeltaEntry[];
|
|
19
|
+
ownership: Map<string, string>;
|
|
20
|
+
pr_author: { name: string; email: string };
|
|
21
|
+
pr_number: number;
|
|
22
|
+
head_branch: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function generateAlphanumericId(length = 6): string {
|
|
26
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
27
|
+
let result = "";
|
|
28
|
+
for (let i = 0; i < length; i++) {
|
|
29
|
+
result += chars[Math.floor(Math.random() * chars.length)];
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function executeStack(input: ExecuteInput): Promise<StackExecResult> {
|
|
35
|
+
const { repo_path, plan, deltas, ownership, pr_author, pr_number, head_branch: _head_branch } = input;
|
|
36
|
+
|
|
37
|
+
const runId = `newpr-stack-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
38
|
+
const tmpIndexFiles: string[] = [];
|
|
39
|
+
const createdRefs: string[] = [];
|
|
40
|
+
|
|
41
|
+
const copyBranch = `newpr/stack-source/pr-${pr_number}`;
|
|
42
|
+
const copyRef = `refs/heads/${copyBranch}`;
|
|
43
|
+
const createCopy = await Bun.$`git -C ${repo_path} update-ref ${copyRef} ${plan.head_sha}`.quiet().nothrow();
|
|
44
|
+
if (createCopy.exitCode !== 0) {
|
|
45
|
+
throw new StackExecutionError(
|
|
46
|
+
`Failed to create source copy branch ${copyBranch}: ${createCopy.stderr.toString().trim()}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
createdRefs.push(copyRef);
|
|
50
|
+
|
|
51
|
+
const groupOrder = plan.groups.map((g) => g.id);
|
|
52
|
+
const groupRank = new Map<string, number>();
|
|
53
|
+
groupOrder.forEach((gid, idx) => groupRank.set(gid, idx));
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
for (let i = 0; i < groupOrder.length; i++) {
|
|
57
|
+
const idxFile = `/tmp/newpr-exec-idx-${runId}-${i}`;
|
|
58
|
+
tmpIndexFiles.push(idxFile);
|
|
59
|
+
|
|
60
|
+
const readTree = await Bun.$`GIT_INDEX_FILE=${idxFile} git -C ${repo_path} read-tree ${plan.base_sha}`.quiet().nothrow();
|
|
61
|
+
if (readTree.exitCode !== 0) {
|
|
62
|
+
throw new StackExecutionError(
|
|
63
|
+
`Failed to initialize index ${i}: ${readTree.stderr.toString().trim()}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const delta of deltas) {
|
|
69
|
+
const batchPerIndex = new Map<number, string[]>();
|
|
70
|
+
|
|
71
|
+
for (const change of delta.changes) {
|
|
72
|
+
const fileGroupId = ownership.get(change.path);
|
|
73
|
+
if (!fileGroupId) continue;
|
|
74
|
+
|
|
75
|
+
const fileRank = groupRank.get(fileGroupId);
|
|
76
|
+
if (fileRank === undefined) continue;
|
|
77
|
+
|
|
78
|
+
// Suffix propagation: update index[fileRank] through index[N-1]
|
|
79
|
+
for (let idxNum = fileRank; idxNum < groupOrder.length; idxNum++) {
|
|
80
|
+
let batch = batchPerIndex.get(idxNum);
|
|
81
|
+
if (!batch) {
|
|
82
|
+
batch = [];
|
|
83
|
+
batchPerIndex.set(idxNum, batch);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (change.status === "D") {
|
|
87
|
+
batch.push(`0 ${"0".repeat(40)}\t${change.path}`);
|
|
88
|
+
} else if (change.status === "R") {
|
|
89
|
+
if (change.old_path) {
|
|
90
|
+
batch.push(`0 ${"0".repeat(40)}\t${change.old_path}`);
|
|
91
|
+
}
|
|
92
|
+
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
93
|
+
} else {
|
|
94
|
+
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const [idxNum, lines] of batchPerIndex) {
|
|
100
|
+
const idxFile = tmpIndexFiles[idxNum];
|
|
101
|
+
if (!idxFile || lines.length === 0) continue;
|
|
102
|
+
|
|
103
|
+
const stdinData = lines.join("\n") + "\n";
|
|
104
|
+
const updateIdx = await Bun.$`echo ${stdinData} | GIT_INDEX_FILE=${idxFile} git -C ${repo_path} update-index --index-info`.quiet().nothrow();
|
|
105
|
+
if (updateIdx.exitCode !== 0) {
|
|
106
|
+
throw new StackExecutionError(
|
|
107
|
+
`update-index failed for index ${idxNum}: ${updateIdx.stderr.toString().trim()}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const groupCommits: GroupCommitInfo[] = [];
|
|
114
|
+
let prevCommitSha = plan.base_sha;
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < groupOrder.length; i++) {
|
|
117
|
+
const idxFile = tmpIndexFiles[i];
|
|
118
|
+
const gid = groupOrder[i];
|
|
119
|
+
const group = plan.groups[i];
|
|
120
|
+
if (!idxFile || !gid || !group) continue;
|
|
121
|
+
|
|
122
|
+
const writeTree = await Bun.$`GIT_INDEX_FILE=${idxFile} git -C ${repo_path} write-tree`.quiet().nothrow();
|
|
123
|
+
if (writeTree.exitCode !== 0) {
|
|
124
|
+
throw new StackExecutionError(
|
|
125
|
+
`write-tree failed for group ${gid}: ${writeTree.stderr.toString().trim()}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const treeSha = writeTree.stdout.toString().trim();
|
|
129
|
+
|
|
130
|
+
const expectedTree = plan.expected_trees.get(gid);
|
|
131
|
+
if (expectedTree && treeSha !== expectedTree) {
|
|
132
|
+
throw new StackExecutionError(
|
|
133
|
+
`Tree mismatch for group "${gid}": expected ${expectedTree}, got ${treeSha}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const commitMessage = group.pr_title ?? `${group.type}(${group.name}): ${group.description}`;
|
|
138
|
+
|
|
139
|
+
const commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} -p ${prevCommitSha} -m ${commitMessage}`.env({
|
|
140
|
+
GIT_AUTHOR_NAME: pr_author.name,
|
|
141
|
+
GIT_AUTHOR_EMAIL: pr_author.email,
|
|
142
|
+
GIT_COMMITTER_NAME: pr_author.name,
|
|
143
|
+
GIT_COMMITTER_EMAIL: pr_author.email,
|
|
144
|
+
}).quiet().nothrow();
|
|
145
|
+
|
|
146
|
+
if (commitTree.exitCode !== 0) {
|
|
147
|
+
throw new StackExecutionError(
|
|
148
|
+
`commit-tree failed for group ${gid}: ${commitTree.stderr.toString().trim()}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const commitSha = commitTree.stdout.toString().trim();
|
|
152
|
+
|
|
153
|
+
const branchName = `newpr-stack/pr-${pr_number}/${group.order}-${generateAlphanumericId()}`;
|
|
154
|
+
|
|
155
|
+
const updateRef = await Bun.$`git -C ${repo_path} update-ref refs/heads/${branchName} ${commitSha}`.quiet().nothrow();
|
|
156
|
+
if (updateRef.exitCode !== 0) {
|
|
157
|
+
throw new StackExecutionError(
|
|
158
|
+
`update-ref failed for branch ${branchName}: ${updateRef.stderr.toString().trim()}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
createdRefs.push(`refs/heads/${branchName}`);
|
|
162
|
+
|
|
163
|
+
groupCommits.push({
|
|
164
|
+
group_id: gid,
|
|
165
|
+
commit_sha: commitSha,
|
|
166
|
+
tree_sha: treeSha,
|
|
167
|
+
branch_name: branchName,
|
|
168
|
+
pr_title: group.pr_title,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
prevCommitSha = commitSha;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lastCommit = groupCommits[groupCommits.length - 1];
|
|
175
|
+
const finalTreeSha = lastCommit?.tree_sha ?? "";
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
run_id: runId,
|
|
179
|
+
source_copy_branch: copyBranch,
|
|
180
|
+
group_commits: groupCommits,
|
|
181
|
+
final_tree_sha: finalTreeSha,
|
|
182
|
+
verified: false,
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
for (const ref of createdRefs) {
|
|
186
|
+
await Bun.$`git -C ${repo_path} update-ref -d ${ref}`.quiet().nothrow();
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
} finally {
|
|
190
|
+
for (const idxFile of tmpIndexFiles) {
|
|
191
|
+
await Bun.$`rm -f ${idxFile}`.quiet().nothrow();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|