newpr 1.0.21 → 1.0.23
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 +164 -11
- package/src/stack/execute.test.ts +129 -1
- package/src/stack/execute.ts +87 -26
- package/src/stack/feasibility.ts +6 -1
- 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 +69 -5
- package/src/stack/publish.test.ts +83 -0
- package/src/stack/publish.ts +132 -26
- package/src/stack/types.ts +12 -2
- package/src/stack/verify.ts +76 -19
- package/src/web/client/App.tsx +2 -4
- package/src/web/client/components/AnalyticsConsent.tsx +1 -98
- package/src/web/client/components/AppShell.tsx +5 -5
- package/src/web/client/components/StackDagView.tsx +387 -0
- package/src/web/client/components/StackGroupCard.tsx +15 -1
- package/src/web/client/hooks/useStack.ts +43 -0
- package/src/web/client/lib/analytics.ts +6 -4
- package/src/web/client/panels/StackPanel.tsx +7 -15
- package/src/web/server/routes.ts +25 -3
- package/src/web/server/stack-manager.ts +80 -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) {
|
|
@@ -183,22 +183,120 @@ function checkForUnsupportedModes(
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
async function resolveParentTree(
|
|
187
|
+
repoPath: string,
|
|
188
|
+
baseSha: string,
|
|
189
|
+
gid: string,
|
|
190
|
+
expectedTrees: Map<string, string>,
|
|
191
|
+
dagParents: Map<string, string[]>,
|
|
192
|
+
): Promise<string | null> {
|
|
193
|
+
const parentIds = dagParents.get(gid) ?? [];
|
|
194
|
+
|
|
195
|
+
if (parentIds.length === 0) {
|
|
196
|
+
return resolveTree(repoPath, baseSha);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (parentIds.length === 1) {
|
|
200
|
+
return expectedTrees.get(parentIds[0]!) ?? resolveTree(repoPath, baseSha);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const parentTrees = parentIds.map((p) => expectedTrees.get(p)).filter((t): t is string => Boolean(t));
|
|
204
|
+
if (parentTrees.length === 0) return resolveTree(repoPath, baseSha);
|
|
205
|
+
if (parentTrees.length === 1) return parentTrees[0]!;
|
|
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
|
+
|
|
220
|
+
let mergedTree = parentTrees[0]!;
|
|
221
|
+
let mergedCommit = await ensureSyntheticCommit(mergedTree);
|
|
222
|
+
if (!mergedCommit) return null;
|
|
223
|
+
|
|
224
|
+
for (let i = 1; i < parentTrees.length; i++) {
|
|
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;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return mergedTree;
|
|
241
|
+
}
|
|
242
|
+
|
|
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(
|
|
187
282
|
repoPath: string,
|
|
188
283
|
baseSha: string,
|
|
189
284
|
orderedGroupIds: string[],
|
|
190
285
|
expectedTrees: Map<string, string>,
|
|
191
|
-
|
|
192
|
-
|
|
286
|
+
dagParents?: Map<string, string[]>,
|
|
287
|
+
): Promise<Map<string, StackGroupComputedStats>> {
|
|
288
|
+
const stats = new Map<string, StackGroupComputedStats>();
|
|
289
|
+
const linearParents = new Map<string, string[]>(
|
|
290
|
+
orderedGroupIds.map((gid, i) => [gid, i === 0 ? [] : [orderedGroupIds[i - 1]!]]),
|
|
291
|
+
);
|
|
292
|
+
const effectiveDagParents = dagParents ?? linearParents;
|
|
193
293
|
|
|
194
294
|
for (let i = 0; i < orderedGroupIds.length; i++) {
|
|
195
295
|
const gid = orderedGroupIds[i]!;
|
|
196
296
|
const tree = expectedTrees.get(gid);
|
|
197
297
|
if (!tree) continue;
|
|
198
298
|
|
|
199
|
-
const prevTree =
|
|
200
|
-
? await resolveTree(repoPath, baseSha)
|
|
201
|
-
: expectedTrees.get(orderedGroupIds[i - 1]!);
|
|
299
|
+
const prevTree = await resolveParentTree(repoPath, baseSha, gid, expectedTrees, effectiveDagParents);
|
|
202
300
|
if (!prevTree) continue;
|
|
203
301
|
|
|
204
302
|
const numstatResult = await Bun.$`git -C ${repoPath} diff-tree --numstat -r ${prevTree} ${tree}`.quiet().nothrow();
|
|
@@ -209,16 +307,47 @@ export async function computeGroupStats(
|
|
|
209
307
|
let filesAdded = 0;
|
|
210
308
|
let filesModified = 0;
|
|
211
309
|
let filesDeleted = 0;
|
|
310
|
+
const fileStats: Record<string, StackFileStats> = {};
|
|
212
311
|
|
|
213
312
|
if (numstatResult.exitCode === 0) {
|
|
214
313
|
const lines = numstatResult.stdout.toString().trim().split("\n").filter(Boolean);
|
|
215
314
|
for (const line of lines) {
|
|
216
315
|
const parts = line.split("\t");
|
|
217
316
|
if (parts.length < 3) continue;
|
|
218
|
-
const [addStr, delStr] = parts;
|
|
317
|
+
const [addStr, delStr, ...pathParts] = parts;
|
|
219
318
|
if (addStr === "-" || delStr === "-") continue;
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
}
|
|
222
351
|
}
|
|
223
352
|
}
|
|
224
353
|
|
|
@@ -235,12 +364,36 @@ export async function computeGroupStats(
|
|
|
235
364
|
}
|
|
236
365
|
}
|
|
237
366
|
|
|
238
|
-
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
|
+
});
|
|
239
377
|
}
|
|
240
378
|
|
|
241
379
|
return stats;
|
|
242
380
|
}
|
|
243
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
|
+
|
|
244
397
|
async function resolveTree(repoPath: string, commitSha: string): Promise<string> {
|
|
245
398
|
const result = await Bun.$`git -C ${repoPath} rev-parse ${commitSha}^{tree}`.quiet().nothrow();
|
|
246
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
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
StackExecResult,
|
|
5
5
|
GroupCommitInfo,
|
|
6
6
|
} from "./types.ts";
|
|
7
|
+
import { buildAncestorSets } from "./plan.ts";
|
|
7
8
|
|
|
8
9
|
export class StackExecutionError extends Error {
|
|
9
10
|
constructor(message: string) {
|
|
@@ -78,8 +79,33 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
78
79
|
const groupRank = new Map<string, number>();
|
|
79
80
|
groupOrder.forEach((gid, idx) => groupRank.set(gid, idx));
|
|
80
81
|
|
|
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;
|
|
106
|
+
|
|
81
107
|
try {
|
|
82
|
-
for (let i = 0; i <
|
|
108
|
+
for (let i = 0; i < totalIndexSlots; i++) {
|
|
83
109
|
const idxFile = `/tmp/newpr-exec-idx-${runId}-${i}`;
|
|
84
110
|
tmpIndexFiles.push(idxFile);
|
|
85
111
|
|
|
@@ -101,25 +127,35 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
101
127
|
const fileRank = groupRank.get(fileGroupId);
|
|
102
128
|
if (fileRank === undefined) continue;
|
|
103
129
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (change.old_path) {
|
|
116
|
-
batch.push(`0 ${"0".repeat(40)}\t${change.old_path}`);
|
|
117
|
-
}
|
|
118
|
-
batch.push(`${change.new_mode} ${change.new_blob}\t${change.path}`);
|
|
119
|
-
} else {
|
|
120
|
-
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}`);
|
|
121
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}`);
|
|
122
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
|
+
}
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
for (const [idxNum, lines] of batchPerIndex) {
|
|
@@ -137,7 +173,7 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
137
173
|
}
|
|
138
174
|
|
|
139
175
|
const groupCommits: GroupCommitInfo[] = [];
|
|
140
|
-
|
|
176
|
+
const commitBySha = new Map<string, string>();
|
|
141
177
|
|
|
142
178
|
for (let i = 0; i < groupOrder.length; i++) {
|
|
143
179
|
const idxFile = tmpIndexFiles[i];
|
|
@@ -155,14 +191,30 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
155
191
|
|
|
156
192
|
const expectedTree = plan.expected_trees.get(gid);
|
|
157
193
|
if (expectedTree && treeSha !== expectedTree) {
|
|
158
|
-
|
|
159
|
-
`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.`,
|
|
160
196
|
);
|
|
197
|
+
plan.expected_trees.set(gid, treeSha);
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
const commitMessage = group.pr_title ?? `${group.type}(${group.name}): ${group.description}`;
|
|
164
201
|
|
|
165
|
-
const
|
|
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];
|
|
214
|
+
|
|
215
|
+
const parentArgs = directParents.flatMap((p) => ["-p", p]);
|
|
216
|
+
|
|
217
|
+
const commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} ${parentArgs} -m ${commitMessage}`.env({
|
|
166
218
|
GIT_AUTHOR_NAME: pr_author.name,
|
|
167
219
|
GIT_AUTHOR_EMAIL: pr_author.email,
|
|
168
220
|
GIT_COMMITTER_NAME: pr_author.name,
|
|
@@ -175,6 +227,7 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
175
227
|
);
|
|
176
228
|
}
|
|
177
229
|
const commitSha = commitTree.stdout.toString().trim();
|
|
230
|
+
commitBySha.set(gid, commitSha);
|
|
178
231
|
|
|
179
232
|
const branchName = buildStackBranchName(pr_number, head_branch, group);
|
|
180
233
|
|
|
@@ -193,12 +246,20 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
|
|
|
193
246
|
branch_name: branchName,
|
|
194
247
|
pr_title: group.pr_title,
|
|
195
248
|
});
|
|
196
|
-
|
|
197
|
-
prevCommitSha = commitSha;
|
|
198
249
|
}
|
|
199
250
|
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
}
|
|
202
263
|
|
|
203
264
|
return {
|
|
204
265
|
run_id: runId,
|
package/src/stack/feasibility.ts
CHANGED
|
@@ -190,10 +190,11 @@ function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
|
|
|
190
190
|
|
|
191
191
|
function topologicalSort(
|
|
192
192
|
groups: string[],
|
|
193
|
-
|
|
193
|
+
acyclicEdges: ConstraintEdge[],
|
|
194
194
|
deltas: DeltaEntry[],
|
|
195
195
|
ownership?: Map<string, string>,
|
|
196
196
|
): FeasibilityResult {
|
|
197
|
+
const edges = acyclicEdges;
|
|
197
198
|
const inDegree = new Map<string, number>();
|
|
198
199
|
const adjacency = new Map<string, string[]>();
|
|
199
200
|
const edgeMap = new Map<string, ConstraintEdge>();
|
|
@@ -238,9 +239,13 @@ function topologicalSort(
|
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
if (sorted.length === groups.length) {
|
|
242
|
+
const dependencyEdges = acyclicEdges
|
|
243
|
+
.filter((e) => e.kind === "dependency" || e.kind === "path-order")
|
|
244
|
+
.map((e) => ({ from: e.from, to: e.to }));
|
|
241
245
|
return {
|
|
242
246
|
feasible: true,
|
|
243
247
|
ordered_group_ids: sorted,
|
|
248
|
+
dependency_edges: dependencyEdges,
|
|
244
249
|
};
|
|
245
250
|
}
|
|
246
251
|
|