newpr 1.0.22 → 1.0.24
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/stack/delta.test.ts +79 -1
- package/src/stack/delta.ts +130 -12
- package/src/stack/execute.test.ts +129 -1
- package/src/stack/execute.ts +79 -32
- package/src/stack/import-deps.test.ts +254 -0
- package/src/stack/import-deps.ts +70 -17
- package/src/stack/integration.test.ts +1 -0
- package/src/stack/merge-groups.test.ts +23 -0
- package/src/stack/merge-groups.ts +17 -1
- package/src/stack/plan.test.ts +30 -0
- package/src/stack/plan.ts +20 -14
- package/src/stack/publish.test.ts +83 -0
- package/src/stack/publish.ts +34 -18
- package/src/stack/types.ts +10 -2
- package/src/stack/verify.ts +76 -19
- package/src/web/client/components/Markdown.tsx +9 -8
- package/src/web/client/components/StackDagView.tsx +264 -194
- package/src/web/client/hooks/useStack.ts +43 -0
- package/src/web/client/panels/StackPanel.tsx +1 -0
- package/src/web/server/routes.ts +5 -1
- package/src/web/server/stack-manager.ts +77 -3
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
package/src/stack/delta.test.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { extractDeltas, buildRenameMap } from "./delta.ts";
|
|
5
|
+
import { extractDeltas, buildRenameMap, computeGroupStats, computeGroupStatsWithFiles } from "./delta.ts";
|
|
6
|
+
import { createStackPlan } from "./plan.ts";
|
|
7
|
+
import type { FileGroup } from "../types/output.ts";
|
|
6
8
|
|
|
7
9
|
let testRepoPath: string;
|
|
8
10
|
|
|
@@ -116,6 +118,82 @@ describe("extractDeltas", () => {
|
|
|
116
118
|
});
|
|
117
119
|
});
|
|
118
120
|
|
|
121
|
+
describe("computeGroupStats", () => {
|
|
122
|
+
test("uses merged dependency baseline for multi-parent DAG groups", async () => {
|
|
123
|
+
const repoPath = mkdtempSync(join(tmpdir(), "group-stats-dag-test-"));
|
|
124
|
+
try {
|
|
125
|
+
await Bun.$`git init ${repoPath}`.quiet();
|
|
126
|
+
await Bun.$`git -C ${repoPath} config user.name "Test User"`.quiet();
|
|
127
|
+
await Bun.$`git -C ${repoPath} config user.email "test@example.com"`.quiet();
|
|
128
|
+
|
|
129
|
+
await Bun.$`echo "a0" > ${join(repoPath, "a.txt")}`.quiet();
|
|
130
|
+
await Bun.$`echo "b0" > ${join(repoPath, "b.txt")}`.quiet();
|
|
131
|
+
await Bun.$`echo "c0" > ${join(repoPath, "c.txt")}`.quiet();
|
|
132
|
+
await Bun.$`git -C ${repoPath} add -A`.quiet();
|
|
133
|
+
await Bun.$`git -C ${repoPath} commit -m "base"`.quiet();
|
|
134
|
+
const baseSha = await getCurrentSha(repoPath);
|
|
135
|
+
|
|
136
|
+
await Bun.$`echo "a1" > ${join(repoPath, "a.txt")}`.quiet();
|
|
137
|
+
await Bun.$`git -C ${repoPath} add a.txt`.quiet();
|
|
138
|
+
await Bun.$`git -C ${repoPath} commit -m "A change"`.quiet();
|
|
139
|
+
|
|
140
|
+
await Bun.$`echo "b1" > ${join(repoPath, "b.txt")}`.quiet();
|
|
141
|
+
await Bun.$`git -C ${repoPath} add b.txt`.quiet();
|
|
142
|
+
await Bun.$`git -C ${repoPath} commit -m "B change"`.quiet();
|
|
143
|
+
|
|
144
|
+
await Bun.$`echo "c1" > ${join(repoPath, "c.txt")}`.quiet();
|
|
145
|
+
await Bun.$`git -C ${repoPath} add c.txt`.quiet();
|
|
146
|
+
await Bun.$`git -C ${repoPath} commit -m "C change"`.quiet();
|
|
147
|
+
const headSha = await getCurrentSha(repoPath);
|
|
148
|
+
|
|
149
|
+
const deltas = await extractDeltas(repoPath, baseSha, headSha);
|
|
150
|
+
const ownership = new Map([
|
|
151
|
+
["a.txt", "A"],
|
|
152
|
+
["b.txt", "B"],
|
|
153
|
+
["c.txt", "C"],
|
|
154
|
+
]);
|
|
155
|
+
const groups: FileGroup[] = [
|
|
156
|
+
{ name: "A", type: "feature", description: "A", files: ["a.txt"] },
|
|
157
|
+
{ name: "B", type: "feature", description: "B", files: ["b.txt"] },
|
|
158
|
+
{ name: "C", type: "feature", description: "C", files: ["c.txt"] },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const plan = await createStackPlan({
|
|
162
|
+
repo_path: repoPath,
|
|
163
|
+
base_sha: baseSha,
|
|
164
|
+
head_sha: headSha,
|
|
165
|
+
deltas,
|
|
166
|
+
ownership,
|
|
167
|
+
group_order: ["A", "B", "C"],
|
|
168
|
+
groups,
|
|
169
|
+
dependency_edges: [
|
|
170
|
+
{ from: "A", to: "C" },
|
|
171
|
+
{ from: "B", to: "C" },
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const dagParents = new Map(plan.groups.map((g) => [g.id, g.deps ?? []]));
|
|
176
|
+
const stats = await computeGroupStats(repoPath, baseSha, ["A", "B", "C"], plan.expected_trees, dagParents);
|
|
177
|
+
const detailed = await computeGroupStatsWithFiles(repoPath, baseSha, ["A", "B", "C"], plan.expected_trees, dagParents);
|
|
178
|
+
|
|
179
|
+
expect(stats.get("A")?.additions).toBe(1);
|
|
180
|
+
expect(stats.get("A")?.deletions).toBe(1);
|
|
181
|
+
expect(stats.get("B")?.additions).toBe(1);
|
|
182
|
+
expect(stats.get("B")?.deletions).toBe(1);
|
|
183
|
+
expect(stats.get("C")?.additions).toBe(1);
|
|
184
|
+
expect(stats.get("C")?.deletions).toBe(1);
|
|
185
|
+
expect(detailed.get("A")?.file_stats["a.txt"]?.additions).toBe(1);
|
|
186
|
+
expect(detailed.get("A")?.file_stats["a.txt"]?.deletions).toBe(1);
|
|
187
|
+
expect(detailed.get("B")?.file_stats["b.txt"]?.additions).toBe(1);
|
|
188
|
+
expect(detailed.get("B")?.file_stats["b.txt"]?.deletions).toBe(1);
|
|
189
|
+
expect(detailed.get("C")?.file_stats["c.txt"]?.additions).toBe(1);
|
|
190
|
+
expect(detailed.get("C")?.file_stats["c.txt"]?.deletions).toBe(1);
|
|
191
|
+
} finally {
|
|
192
|
+
rmSync(repoPath, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
119
197
|
describe("buildRenameMap", () => {
|
|
120
198
|
test("builds rename map from deltas", () => {
|
|
121
199
|
const deltas = [
|
package/src/stack/delta.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeltaEntry, DeltaFileChange, DeltaStatus, StackGroupStats } from "./types.ts";
|
|
1
|
+
import type { DeltaEntry, DeltaFileChange, DeltaStatus, StackFileStats, StackGroupStats } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
export class DeltaExtractionError extends Error {
|
|
4
4
|
constructor(message: string) {
|
|
@@ -204,25 +204,88 @@ async function resolveParentTree(
|
|
|
204
204
|
if (parentTrees.length === 0) return resolveTree(repoPath, baseSha);
|
|
205
205
|
if (parentTrees.length === 1) return parentTrees[0]!;
|
|
206
206
|
|
|
207
|
+
const syntheticCommitByTree = new Map<string, string>();
|
|
208
|
+
const ensureSyntheticCommit = async (treeSha: string): Promise<string | null> => {
|
|
209
|
+
const existing = syntheticCommitByTree.get(treeSha);
|
|
210
|
+
if (existing) return existing;
|
|
211
|
+
|
|
212
|
+
const result = await Bun.$`git -C ${repoPath} commit-tree ${treeSha} -p ${baseSha} -m "newpr synthetic parent"`.quiet().nothrow();
|
|
213
|
+
if (result.exitCode !== 0) return null;
|
|
214
|
+
const commitSha = result.stdout.toString().trim();
|
|
215
|
+
if (!commitSha) return null;
|
|
216
|
+
syntheticCommitByTree.set(treeSha, commitSha);
|
|
217
|
+
return commitSha;
|
|
218
|
+
};
|
|
219
|
+
|
|
207
220
|
let mergedTree = parentTrees[0]!;
|
|
221
|
+
let mergedCommit = await ensureSyntheticCommit(mergedTree);
|
|
222
|
+
if (!mergedCommit) return null;
|
|
223
|
+
|
|
208
224
|
for (let i = 1; i < parentTrees.length; i++) {
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
}
|
|
225
|
+
const nextCommit = await ensureSyntheticCommit(parentTrees[i]!);
|
|
226
|
+
if (!nextCommit) return null;
|
|
227
|
+
|
|
228
|
+
const mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedCommit} ${nextCommit}`.quiet().nothrow();
|
|
229
|
+
if (mergeResult.exitCode !== 0) return null;
|
|
230
|
+
const nextTree = mergeResult.stdout.toString().trim().split("\n")[0]?.trim();
|
|
231
|
+
if (!nextTree) return null;
|
|
232
|
+
mergedTree = nextTree;
|
|
233
|
+
|
|
234
|
+
const mergedCommitResult = await Bun.$`git -C ${repoPath} commit-tree ${mergedTree} -p ${mergedCommit} -p ${nextCommit} -m "newpr synthetic merged parent"`.quiet().nothrow();
|
|
235
|
+
if (mergedCommitResult.exitCode !== 0) return null;
|
|
236
|
+
mergedCommit = mergedCommitResult.stdout.toString().trim();
|
|
237
|
+
if (!mergedCommit) return null;
|
|
213
238
|
}
|
|
214
239
|
|
|
215
240
|
return mergedTree;
|
|
216
241
|
}
|
|
217
242
|
|
|
218
|
-
export
|
|
243
|
+
export interface StackGroupComputedStats {
|
|
244
|
+
stats: StackGroupStats;
|
|
245
|
+
file_stats: Record<string, StackFileStats>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function materializeRenamedPath(path: string): string {
|
|
249
|
+
const bracePattern = /(.*)\{([^{}]*) => ([^{}]*)\}(.*)/;
|
|
250
|
+
const braceMatch = path.match(bracePattern);
|
|
251
|
+
if (braceMatch) {
|
|
252
|
+
const [, prefix = "", _from = "", to = "", suffix = ""] = braceMatch;
|
|
253
|
+
return `${prefix}${to}${suffix}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (path.includes(" => ")) {
|
|
257
|
+
const parts = path.split(" => ");
|
|
258
|
+
const candidate = parts[parts.length - 1]?.trim();
|
|
259
|
+
if (candidate) return candidate;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return path;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function accumulateFileStats(
|
|
266
|
+
fileStats: Record<string, StackFileStats>,
|
|
267
|
+
path: string,
|
|
268
|
+
additions: number,
|
|
269
|
+
deletions: number,
|
|
270
|
+
): void {
|
|
271
|
+
const existing = fileStats[path];
|
|
272
|
+
if (existing) {
|
|
273
|
+
existing.additions += additions;
|
|
274
|
+
existing.deletions += deletions;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fileStats[path] = { additions, deletions };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function computeGroupStatsWithFiles(
|
|
219
282
|
repoPath: string,
|
|
220
283
|
baseSha: string,
|
|
221
284
|
orderedGroupIds: string[],
|
|
222
285
|
expectedTrees: Map<string, string>,
|
|
223
286
|
dagParents?: Map<string, string[]>,
|
|
224
|
-
): Promise<Map<string,
|
|
225
|
-
const stats = new Map<string,
|
|
287
|
+
): Promise<Map<string, StackGroupComputedStats>> {
|
|
288
|
+
const stats = new Map<string, StackGroupComputedStats>();
|
|
226
289
|
const linearParents = new Map<string, string[]>(
|
|
227
290
|
orderedGroupIds.map((gid, i) => [gid, i === 0 ? [] : [orderedGroupIds[i - 1]!]]),
|
|
228
291
|
);
|
|
@@ -244,16 +307,47 @@ export async function computeGroupStats(
|
|
|
244
307
|
let filesAdded = 0;
|
|
245
308
|
let filesModified = 0;
|
|
246
309
|
let filesDeleted = 0;
|
|
310
|
+
const fileStats: Record<string, StackFileStats> = {};
|
|
247
311
|
|
|
248
312
|
if (numstatResult.exitCode === 0) {
|
|
249
313
|
const lines = numstatResult.stdout.toString().trim().split("\n").filter(Boolean);
|
|
250
314
|
for (const line of lines) {
|
|
251
315
|
const parts = line.split("\t");
|
|
252
316
|
if (parts.length < 3) continue;
|
|
253
|
-
const [addStr, delStr] = parts;
|
|
317
|
+
const [addStr, delStr, ...pathParts] = parts;
|
|
254
318
|
if (addStr === "-" || delStr === "-") continue;
|
|
255
|
-
|
|
256
|
-
|
|
319
|
+
|
|
320
|
+
const addCount = parseInt(addStr!, 10);
|
|
321
|
+
const delCount = parseInt(delStr!, 10);
|
|
322
|
+
if (Number.isNaN(addCount) || Number.isNaN(delCount)) continue;
|
|
323
|
+
|
|
324
|
+
additions += addCount;
|
|
325
|
+
deletions += delCount;
|
|
326
|
+
|
|
327
|
+
const normalizedParts = pathParts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
328
|
+
if (normalizedParts.length === 0) continue;
|
|
329
|
+
|
|
330
|
+
if (normalizedParts.length === 1) {
|
|
331
|
+
const rawPath = normalizedParts[0]!;
|
|
332
|
+
accumulateFileStats(fileStats, rawPath, addCount, delCount);
|
|
333
|
+
const materialized = materializeRenamedPath(rawPath);
|
|
334
|
+
if (materialized !== rawPath) {
|
|
335
|
+
accumulateFileStats(fileStats, materialized, addCount, delCount);
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const oldPath = normalizedParts[0]!;
|
|
341
|
+
const newPath = normalizedParts[normalizedParts.length - 1]!;
|
|
342
|
+
const candidates = new Set<string>([
|
|
343
|
+
oldPath,
|
|
344
|
+
newPath,
|
|
345
|
+
materializeRenamedPath(oldPath),
|
|
346
|
+
materializeRenamedPath(newPath),
|
|
347
|
+
]);
|
|
348
|
+
for (const candidate of candidates) {
|
|
349
|
+
accumulateFileStats(fileStats, candidate, addCount, delCount);
|
|
350
|
+
}
|
|
257
351
|
}
|
|
258
352
|
}
|
|
259
353
|
|
|
@@ -270,12 +364,36 @@ export async function computeGroupStats(
|
|
|
270
364
|
}
|
|
271
365
|
}
|
|
272
366
|
|
|
273
|
-
stats.set(gid, {
|
|
367
|
+
stats.set(gid, {
|
|
368
|
+
stats: {
|
|
369
|
+
additions,
|
|
370
|
+
deletions,
|
|
371
|
+
files_added: filesAdded,
|
|
372
|
+
files_modified: filesModified,
|
|
373
|
+
files_deleted: filesDeleted,
|
|
374
|
+
},
|
|
375
|
+
file_stats: fileStats,
|
|
376
|
+
});
|
|
274
377
|
}
|
|
275
378
|
|
|
276
379
|
return stats;
|
|
277
380
|
}
|
|
278
381
|
|
|
382
|
+
export async function computeGroupStats(
|
|
383
|
+
repoPath: string,
|
|
384
|
+
baseSha: string,
|
|
385
|
+
orderedGroupIds: string[],
|
|
386
|
+
expectedTrees: Map<string, string>,
|
|
387
|
+
dagParents?: Map<string, string[]>,
|
|
388
|
+
): Promise<Map<string, StackGroupStats>> {
|
|
389
|
+
const detailed = await computeGroupStatsWithFiles(repoPath, baseSha, orderedGroupIds, expectedTrees, dagParents);
|
|
390
|
+
const stats = new Map<string, StackGroupStats>();
|
|
391
|
+
for (const [gid, value] of detailed) {
|
|
392
|
+
stats.set(gid, value.stats);
|
|
393
|
+
}
|
|
394
|
+
return stats;
|
|
395
|
+
}
|
|
396
|
+
|
|
279
397
|
async function resolveTree(repoPath: string, commitSha: string): Promise<string> {
|
|
280
398
|
const result = await Bun.$`git -C ${repoPath} rev-parse ${commitSha}^{tree}`.quiet().nothrow();
|
|
281
399
|
if (result.exitCode !== 0) {
|
|
@@ -43,7 +43,7 @@ afterAll(() => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
describe("executeStack", () => {
|
|
46
|
-
test("creates commit chain with
|
|
46
|
+
test("creates commit chain with explicit dependency links", async () => {
|
|
47
47
|
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
48
48
|
const ownership = new Map([
|
|
49
49
|
["src/auth.ts", "Auth"],
|
|
@@ -62,6 +62,7 @@ describe("executeStack", () => {
|
|
|
62
62
|
ownership,
|
|
63
63
|
group_order: ["Auth", "UI"],
|
|
64
64
|
groups,
|
|
65
|
+
dependency_edges: [{ from: "Auth", to: "UI" }],
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
const result = await executeStack({
|
|
@@ -101,6 +102,133 @@ describe("executeStack", () => {
|
|
|
101
102
|
}
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
test("uses base as parent for independent groups", async () => {
|
|
106
|
+
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
107
|
+
const ownership = new Map([
|
|
108
|
+
["src/auth.ts", "Auth"],
|
|
109
|
+
["src/ui.tsx", "UI"],
|
|
110
|
+
]);
|
|
111
|
+
const groups: FileGroup[] = [
|
|
112
|
+
{ name: "Auth", type: "feature", description: "Auth changes", files: ["src/auth.ts"] },
|
|
113
|
+
{ name: "UI", type: "feature", description: "UI changes", files: ["src/ui.tsx"] },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const plan = await createStackPlan({
|
|
117
|
+
repo_path: testRepoPath,
|
|
118
|
+
base_sha: baseSha,
|
|
119
|
+
head_sha: headSha,
|
|
120
|
+
deltas,
|
|
121
|
+
ownership,
|
|
122
|
+
group_order: ["Auth", "UI"],
|
|
123
|
+
groups,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = await executeStack({
|
|
127
|
+
repo_path: testRepoPath,
|
|
128
|
+
plan,
|
|
129
|
+
deltas,
|
|
130
|
+
ownership,
|
|
131
|
+
pr_author: { name: "Test Author", email: "author@test.com" },
|
|
132
|
+
pr_number: 43,
|
|
133
|
+
head_branch: "feature-branch",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const commit0 = result.group_commits[0];
|
|
137
|
+
const commit1 = result.group_commits[1];
|
|
138
|
+
|
|
139
|
+
if (commit0) {
|
|
140
|
+
const parent0 = (await Bun.$`git -C ${testRepoPath} rev-parse ${commit0.commit_sha}^`.quiet()).stdout.toString().trim();
|
|
141
|
+
expect(parent0).toBe(baseSha);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (commit1) {
|
|
145
|
+
const parent1 = (await Bun.$`git -C ${testRepoPath} rev-parse ${commit1.commit_sha}^`.quiet()).stdout.toString().trim();
|
|
146
|
+
expect(parent1).toBe(baseSha);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("continues when planned expected tree is stale", async () => {
|
|
151
|
+
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
152
|
+
const ownership = new Map([
|
|
153
|
+
["src/auth.ts", "Auth"],
|
|
154
|
+
["src/ui.tsx", "UI"],
|
|
155
|
+
]);
|
|
156
|
+
const groups: FileGroup[] = [
|
|
157
|
+
{ name: "Auth", type: "feature", description: "Auth changes", files: ["src/auth.ts"] },
|
|
158
|
+
{ name: "UI", type: "feature", description: "UI changes", files: ["src/ui.tsx"] },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const plan = await createStackPlan({
|
|
162
|
+
repo_path: testRepoPath,
|
|
163
|
+
base_sha: baseSha,
|
|
164
|
+
head_sha: headSha,
|
|
165
|
+
deltas,
|
|
166
|
+
ownership,
|
|
167
|
+
group_order: ["Auth", "UI"],
|
|
168
|
+
groups,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
plan.expected_trees.set("Auth", "0".repeat(40));
|
|
172
|
+
|
|
173
|
+
const result = await executeStack({
|
|
174
|
+
repo_path: testRepoPath,
|
|
175
|
+
plan,
|
|
176
|
+
deltas,
|
|
177
|
+
ownership,
|
|
178
|
+
pr_author: { name: "Test", email: "t@t.com" },
|
|
179
|
+
pr_number: 44,
|
|
180
|
+
head_branch: "feature-branch",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result.group_commits.length).toBe(2);
|
|
184
|
+
const headTree = (await Bun.$`git -C ${testRepoPath} rev-parse ${headSha}^{tree}`.quiet()).stdout.toString().trim();
|
|
185
|
+
expect(result.final_tree_sha).toBe(headTree);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("ignores self dependency on group while creating parents", async () => {
|
|
189
|
+
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
190
|
+
const ownership = new Map([
|
|
191
|
+
["src/auth.ts", "Auth"],
|
|
192
|
+
["src/ui.tsx", "UI"],
|
|
193
|
+
]);
|
|
194
|
+
const groups: FileGroup[] = [
|
|
195
|
+
{ name: "Auth", type: "feature", description: "Auth", files: ["src/auth.ts"] },
|
|
196
|
+
{ name: "UI", type: "feature", description: "UI", files: ["src/ui.tsx"] },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const plan = await createStackPlan({
|
|
200
|
+
repo_path: testRepoPath,
|
|
201
|
+
base_sha: baseSha,
|
|
202
|
+
head_sha: headSha,
|
|
203
|
+
deltas,
|
|
204
|
+
ownership,
|
|
205
|
+
group_order: ["Auth", "UI"],
|
|
206
|
+
groups,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (plan.groups[1]) {
|
|
210
|
+
plan.groups[1].deps = [plan.groups[1].id];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const result = await executeStack({
|
|
214
|
+
repo_path: testRepoPath,
|
|
215
|
+
plan,
|
|
216
|
+
deltas,
|
|
217
|
+
ownership,
|
|
218
|
+
pr_author: { name: "Test", email: "t@t.com" },
|
|
219
|
+
pr_number: 45,
|
|
220
|
+
head_branch: "feature-branch",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(result.group_commits.length).toBe(2);
|
|
224
|
+
const uiCommit = result.group_commits.find((gc) => gc.group_id === "UI");
|
|
225
|
+
expect(uiCommit).toBeDefined();
|
|
226
|
+
if (uiCommit) {
|
|
227
|
+
const parent = (await Bun.$`git -C ${testRepoPath} rev-parse ${uiCommit.commit_sha}^`.quiet()).stdout.toString().trim();
|
|
228
|
+
expect(parent).toBe(baseSha);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
104
232
|
test("final tree equals HEAD tree", async () => {
|
|
105
233
|
const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
|
|
106
234
|
const ownership = new Map([
|
package/src/stack/execute.ts
CHANGED
|
@@ -79,12 +79,33 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
79
79
|
const groupRank = new Map<string, number>();
|
|
80
80
|
groupOrder.forEach((gid, idx) => groupRank.set(gid, idx));
|
|
81
81
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
const ancestorSets = new Map<string, Set<string>>();
|
|
83
|
+
if (plan.ancestor_sets && plan.ancestor_sets.size > 0) {
|
|
84
|
+
for (const [gid, ancestors] of plan.ancestor_sets) {
|
|
85
|
+
ancestorSets.set(gid, new Set(ancestors));
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const groupIdSet = new Set(groupOrder);
|
|
89
|
+
const dagParents = new Map<string, string[]>();
|
|
90
|
+
for (const g of plan.groups) {
|
|
91
|
+
dagParents.set(g.id, (g.deps ?? []).filter((dep) => groupIdSet.has(dep)));
|
|
92
|
+
}
|
|
93
|
+
for (const [gid, set] of buildAncestorSets(groupOrder, dagParents)) {
|
|
94
|
+
ancestorSets.set(gid, set);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const allDeps = new Set<string>();
|
|
99
|
+
for (const g of plan.groups) {
|
|
100
|
+
for (const dep of g.deps ?? []) allDeps.add(dep);
|
|
101
|
+
}
|
|
102
|
+
const leafGroups = groupOrder.filter((gid) => !allDeps.has(gid));
|
|
103
|
+
const hasMultipleLeaves = leafGroups.length > 1;
|
|
104
|
+
const allChangesIdxSlot = hasMultipleLeaves ? groupOrder.length : -1;
|
|
105
|
+
const totalIndexSlots = hasMultipleLeaves ? groupOrder.length + 1 : groupOrder.length;
|
|
85
106
|
|
|
86
107
|
try {
|
|
87
|
-
for (let i = 0; i <
|
|
108
|
+
for (let i = 0; i < totalIndexSlots; i++) {
|
|
88
109
|
const idxFile = `/tmp/newpr-exec-idx-${runId}-${i}`;
|
|
89
110
|
tmpIndexFiles.push(idxFile);
|
|
90
111
|
|
|
@@ -106,29 +127,35 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
106
127
|
const fileRank = groupRank.get(fileGroupId);
|
|
107
128
|
if (fileRank === undefined) continue;
|
|
108
129
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (change.status === "D") {
|
|
122
|
-
batch.push(`0 ${"0".repeat(40)}\t${change.path}`);
|
|
123
|
-
} else if (change.status === "R") {
|
|
124
|
-
if (change.old_path) {
|
|
125
|
-
batch.push(`0 ${"0".repeat(40)}\t${change.old_path}`);
|
|
126
|
-
}
|
|
127
|
-
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
128
|
-
} else {
|
|
129
|
-
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
130
|
+
const addToBatch = (idxNum: number) => {
|
|
131
|
+
let batch = batchPerIndex.get(idxNum);
|
|
132
|
+
if (!batch) {
|
|
133
|
+
batch = [];
|
|
134
|
+
batchPerIndex.set(idxNum, batch);
|
|
135
|
+
}
|
|
136
|
+
if (change.status === "D") {
|
|
137
|
+
batch.push(`0 ${"0".repeat(40)}\t${change.path}`);
|
|
138
|
+
} else if (change.status === "R") {
|
|
139
|
+
if (change.old_path) {
|
|
140
|
+
batch.push(`0 ${"0".repeat(40)}\t${change.old_path}`);
|
|
130
141
|
}
|
|
142
|
+
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
143
|
+
} else {
|
|
144
|
+
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
131
145
|
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (let idxNum = 0; idxNum < groupOrder.length; idxNum++) {
|
|
149
|
+
const targetGroupId = groupOrder[idxNum]!;
|
|
150
|
+
const isOwner = targetGroupId === fileGroupId;
|
|
151
|
+
const isAncestorOfOwner = ancestorSets.get(targetGroupId)?.has(fileGroupId) ?? false;
|
|
152
|
+
if (!isOwner && !isAncestorOfOwner) continue;
|
|
153
|
+
addToBatch(idxNum);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (allChangesIdxSlot >= 0) {
|
|
157
|
+
addToBatch(allChangesIdxSlot);
|
|
158
|
+
}
|
|
132
159
|
}
|
|
133
160
|
|
|
134
161
|
for (const [idxNum, lines] of batchPerIndex) {
|
|
@@ -164,16 +191,26 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
164
191
|
|
|
165
192
|
const expectedTree = plan.expected_trees.get(gid);
|
|
166
193
|
if (expectedTree && treeSha !== expectedTree) {
|
|
167
|
-
|
|
168
|
-
`Tree mismatch for group "${gid}": expected ${expectedTree}, got ${treeSha}
|
|
194
|
+
console.warn(
|
|
195
|
+
`[stack] Tree mismatch for group "${gid}": expected ${expectedTree}, got ${treeSha}. Continuing with computed tree.`,
|
|
169
196
|
);
|
|
197
|
+
plan.expected_trees.set(gid, treeSha);
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
const commitMessage = group.pr_title ?? `${group.type}(${group.name}): ${group.description}`;
|
|
173
201
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
202
|
+
const deps = Array.from(new Set((group.deps ?? []).filter((dep) => dep !== gid)));
|
|
203
|
+
const directParents = deps.length > 0
|
|
204
|
+
? deps.map((dep) => {
|
|
205
|
+
const parentCommit = commitBySha.get(dep);
|
|
206
|
+
if (!parentCommit) {
|
|
207
|
+
throw new StackExecutionError(
|
|
208
|
+
`Missing parent commit for dependency "${dep}" of group "${gid}"`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return parentCommit;
|
|
212
|
+
})
|
|
213
|
+
: [plan.base_sha];
|
|
177
214
|
|
|
178
215
|
const parentArgs = directParents.flatMap((p) => ["-p", p]);
|
|
179
216
|
|
|
@@ -211,8 +248,18 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
211
248
|
});
|
|
212
249
|
}
|
|
213
250
|
|
|
214
|
-
|
|
215
|
-
|
|
251
|
+
let finalTreeSha: string;
|
|
252
|
+
if (allChangesIdxSlot >= 0) {
|
|
253
|
+
const allChangesIdxFile = tmpIndexFiles[allChangesIdxSlot];
|
|
254
|
+
if (!allChangesIdxFile) throw new StackExecutionError("Missing all-changes index file");
|
|
255
|
+
const writeAllTree = await Bun.$`GIT_INDEX_FILE=${allChangesIdxFile} git -C ${repo_path} write-tree`.quiet().nothrow();
|
|
256
|
+
if (writeAllTree.exitCode !== 0) {
|
|
257
|
+
throw new StackExecutionError(`write-tree failed for all-changes index: ${writeAllTree.stderr.toString().trim()}`);
|
|
258
|
+
}
|
|
259
|
+
finalTreeSha = writeAllTree.stdout.toString().trim();
|
|
260
|
+
} else {
|
|
261
|
+
finalTreeSha = groupCommits[groupCommits.length - 1]?.tree_sha ?? "";
|
|
262
|
+
}
|
|
216
263
|
|
|
217
264
|
return {
|
|
218
265
|
run_id: runId,
|