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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -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 = [
@@ -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
- export async function computeGroupStats(
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
- ): Promise<Map<string, StackGroupStats>> {
192
- const stats = new Map<string, StackGroupStats>();
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 = i === 0
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
- additions += parseInt(addStr!, 10);
221
- deletions += parseInt(delStr!, 10);
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, { additions, deletions, files_added: filesAdded, files_modified: filesModified, files_deleted: filesDeleted });
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 correct parent links", async () => {
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([
@@ -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 < groupOrder.length; 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
- // Suffix propagation: update index[fileRank] through index[N-1]
105
- for (let idxNum = fileRank; idxNum < groupOrder.length; idxNum++) {
106
- let batch = batchPerIndex.get(idxNum);
107
- if (!batch) {
108
- batch = [];
109
- batchPerIndex.set(idxNum, batch);
110
- }
111
-
112
- if (change.status === "D") {
113
- batch.push(`0 ${"0".repeat(40)}\t${change.path}`);
114
- } else if (change.status === "R") {
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
- let prevCommitSha = plan.base_sha;
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
- throw new StackExecutionError(
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 commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} -p ${prevCommitSha} -m ${commitMessage}`.env({
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
- const lastCommit = groupCommits[groupCommits.length - 1];
201
- const finalTreeSha = lastCommit?.tree_sha ?? "";
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,
@@ -190,10 +190,11 @@ function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
190
190
 
191
191
  function topologicalSort(
192
192
  groups: string[],
193
- edges: ConstraintEdge[],
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
 
@@ -63,6 +63,7 @@ async function runFullPipeline(
63
63
  ownership: coupled.ownership,
64
64
  group_order: feasibility.ordered_group_ids,
65
65
  groups,
66
+ dependency_edges: feasibility.dependency_edges,
66
67
  });
67
68
 
68
69
  const execResult = await executeStack({