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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
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) {
@@ -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 mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedTree} ${parentTrees[i]!}`.quiet().nothrow();
210
- if (mergeResult.exitCode === 0) {
211
- mergedTree = mergeResult.stdout.toString().trim().split("\n")[0]!.trim();
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 async function computeGroupStats(
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, StackGroupStats>> {
225
- const stats = new Map<string, StackGroupStats>();
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
- additions += parseInt(addStr!, 10);
256
- 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
+ }
257
351
  }
258
352
  }
259
353
 
@@ -270,12 +364,36 @@ export async function computeGroupStats(
270
364
  }
271
365
  }
272
366
 
273
- 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
+ });
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 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([
@@ -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 dagParents = new Map<string, string[]>();
83
- for (const g of plan.groups) dagParents.set(g.id, g.deps ?? []);
84
- const ancestorSets = buildAncestorSets(groupOrder, dagParents);
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 < groupOrder.length; 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
- for (let idxNum = 0; idxNum < groupOrder.length; idxNum++) {
110
- const targetGroupId = groupOrder[idxNum]!;
111
- const isOwner = targetGroupId === fileGroupId;
112
- const isAncestorOfOwner = ancestorSets.get(targetGroupId)?.has(fileGroupId) ?? false;
113
- if (!isOwner && !isAncestorOfOwner) continue;
114
-
115
- let batch = batchPerIndex.get(idxNum);
116
- if (!batch) {
117
- batch = [];
118
- batchPerIndex.set(idxNum, batch);
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
- throw new StackExecutionError(
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 directParents = (group.deps ?? []).length > 0
175
- ? group.deps.map((dep) => commitBySha.get(dep) ?? plan.base_sha)
176
- : [groupCommits[i - 1]?.commit_sha ?? plan.base_sha];
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
- const lastCommit = groupCommits[groupCommits.length - 1];
215
- 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
+ }
216
263
 
217
264
  return {
218
265
  run_id: runId,