newpr 1.0.22 → 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.
@@ -80,6 +80,35 @@ describe("createStackPlan", () => {
80
80
  expect(plan.groups[1]?.files).toContain("src/ui.tsx");
81
81
  });
82
82
 
83
+ test("ignores self dependency edges when building group deps", async () => {
84
+ const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
85
+
86
+ const ownership = new Map([
87
+ ["src/auth.ts", "Auth"],
88
+ ["src/ui.tsx", "UI"],
89
+ ]);
90
+
91
+ const groups: FileGroup[] = [
92
+ { name: "Auth", type: "feature", description: "Auth", files: ["src/auth.ts"] },
93
+ { name: "UI", type: "feature", description: "UI", files: ["src/ui.tsx"] },
94
+ ];
95
+
96
+ const plan = await createStackPlan({
97
+ repo_path: testRepoPath,
98
+ base_sha: baseSha,
99
+ head_sha: headSha,
100
+ deltas,
101
+ ownership,
102
+ group_order: ["Auth", "UI"],
103
+ groups,
104
+ dependency_edges: [{ from: "UI", to: "UI" }],
105
+ });
106
+
107
+ const uiGroup = plan.groups.find((g) => g.id === "UI");
108
+ expect(uiGroup).toBeDefined();
109
+ expect(uiGroup?.deps).toEqual([]);
110
+ });
111
+
83
112
  test("final tree matches HEAD tree (suffix propagation)", async () => {
84
113
  const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
85
114
 
@@ -101,6 +130,7 @@ describe("createStackPlan", () => {
101
130
  ownership,
102
131
  group_order: ["Auth", "UI"],
103
132
  groups,
133
+ dependency_edges: [{ from: "Auth", to: "UI" }],
104
134
  });
105
135
 
106
136
  const headTreeResult = await Bun.$`git -C ${testRepoPath} rev-parse ${headSha}^{tree}`.quiet();
package/src/stack/plan.ts CHANGED
@@ -22,10 +22,12 @@ export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
22
22
  const groupRank = new Map<string, number>();
23
23
  group_order.forEach((gid, idx) => groupRank.set(gid, idx));
24
24
 
25
- const dagParents = buildDagParents(group_order, dependency_edges ?? []);
25
+ const edges = dependency_edges ?? [];
26
+ const dagParents = buildDagParents(group_order, edges);
27
+ const explicitDagParents = buildExplicitDagParents(group_order, edges);
26
28
  const ancestorSets = buildAncestorSets(group_order, dagParents);
27
29
 
28
- const stackGroups = buildStackGroups(groups, group_order, ownership, dagParents);
30
+ const stackGroups = buildStackGroups(groups, group_order, ownership, dagParents, explicitDagParents);
29
31
 
30
32
  const tmpIndexFiles: string[] = [];
31
33
  const expectedTrees = new Map<string, string>();
@@ -108,39 +110,41 @@ export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
108
110
  }
109
111
  }
110
112
 
113
+ const ancestorSetsRecord = new Map<string, string[]>();
114
+ for (const [gid, set] of ancestorSets) {
115
+ ancestorSetsRecord.set(gid, Array.from(set));
116
+ }
117
+
111
118
  return {
112
119
  base_sha,
113
120
  head_sha,
114
121
  groups: stackGroups,
115
122
  expected_trees: expectedTrees,
123
+ ancestor_sets: ancestorSetsRecord,
116
124
  };
117
125
  }
118
126
 
119
127
  export function buildDagParents(
120
128
  groupOrder: string[],
121
129
  dependencyEdges: Array<{ from: string; to: string }>,
130
+ ): Map<string, string[]> {
131
+ return buildExplicitDagParents(groupOrder, dependencyEdges);
132
+ }
133
+
134
+ export function buildExplicitDagParents(
135
+ groupOrder: string[],
136
+ dependencyEdges: Array<{ from: string; to: string }>,
122
137
  ): Map<string, string[]> {
123
138
  const parents = new Map<string, string[]>();
124
139
  for (const gid of groupOrder) parents.set(gid, []);
125
140
 
126
141
  for (const edge of dependencyEdges) {
127
142
  if (!parents.has(edge.to)) continue;
143
+ if (edge.from === edge.to) continue;
128
144
  const arr = parents.get(edge.to)!;
129
145
  if (!arr.includes(edge.from)) arr.push(edge.from);
130
146
  }
131
147
 
132
- for (const gid of groupOrder) {
133
- if ((parents.get(gid) ?? []).length === 0) {
134
- const rank = groupOrder.indexOf(gid);
135
- if (rank > 0) {
136
- const prev = groupOrder[rank - 1]!;
137
- if (!dependencyEdges.some((e) => e.to === gid)) {
138
- parents.set(gid, [prev]);
139
- }
140
- }
141
- }
142
- }
143
-
144
148
  return parents;
145
149
  }
146
150
 
@@ -170,6 +174,7 @@ function buildStackGroups(
170
174
  groupOrder: string[],
171
175
  ownership: Map<string, string>,
172
176
  dagParents: Map<string, string[]>,
177
+ explicitDagParents: Map<string, string[]>,
173
178
  ): StackGroup[] {
174
179
  const groupNameMap = new Map<string, FileGroup>();
175
180
  for (const g of groups) {
@@ -191,6 +196,7 @@ function buildStackGroups(
191
196
  description: original?.description ?? "",
192
197
  files: files.sort(),
193
198
  deps: dagParents.get(gid) ?? [],
199
+ explicit_deps: explicitDagParents.get(gid) ?? [],
194
200
  order: idx,
195
201
  };
196
202
  });
@@ -0,0 +1,83 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { PrMeta } from "../types/output.ts";
6
+ import { buildStackPublishPreview } from "./publish.ts";
7
+ import type { StackExecResult } from "./types.ts";
8
+
9
+ let repoPath = "";
10
+ let headSha = "";
11
+
12
+ beforeAll(async () => {
13
+ repoPath = mkdtempSync(join(tmpdir(), "publish-preview-test-"));
14
+
15
+ await Bun.$`git init ${repoPath}`.quiet();
16
+ await Bun.$`git -C ${repoPath} config user.name "Test User"`.quiet();
17
+ await Bun.$`git -C ${repoPath} config user.email "test@example.com"`.quiet();
18
+
19
+ writeFileSync(join(repoPath, "README.md"), "initial\n");
20
+ await Bun.$`git -C ${repoPath} add README.md`.quiet();
21
+ await Bun.$`git -C ${repoPath} commit -m "Initial commit"`.quiet();
22
+
23
+ headSha = (await Bun.$`git -C ${repoPath} rev-parse HEAD`.quiet()).stdout.toString().trim();
24
+ });
25
+
26
+ afterAll(() => {
27
+ if (repoPath) rmSync(repoPath, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("buildStackPublishPreview", () => {
31
+ test("uses DAG dependency bases instead of linear previous branch", async () => {
32
+ const execResult: StackExecResult = {
33
+ run_id: "run-1",
34
+ source_copy_branch: "newpr/stack-source/pr-42",
35
+ group_commits: [
36
+ { group_id: "A", commit_sha: headSha, tree_sha: headSha, branch_name: "stack/a", pr_title: "A title" },
37
+ { group_id: "B", commit_sha: headSha, tree_sha: headSha, branch_name: "stack/b", pr_title: "B title" },
38
+ { group_id: "C", commit_sha: headSha, tree_sha: headSha, branch_name: "stack/c", pr_title: "C title" },
39
+ ],
40
+ final_tree_sha: headSha,
41
+ verified: true,
42
+ };
43
+
44
+ const prMeta: PrMeta = {
45
+ pr_number: 42,
46
+ pr_title: "Source PR",
47
+ pr_url: "https://github.com/acme/repo/pull/42",
48
+ base_branch: "main",
49
+ head_branch: "feature/source",
50
+ author: "tester",
51
+ total_files_changed: 3,
52
+ total_additions: 10,
53
+ total_deletions: 2,
54
+ analyzed_at: new Date().toISOString(),
55
+ model_used: "test-model",
56
+ };
57
+
58
+ const preview = await buildStackPublishPreview({
59
+ repo_path: repoPath,
60
+ exec_result: execResult,
61
+ pr_meta: prMeta,
62
+ base_branch: "main",
63
+ owner: "acme",
64
+ repo: "repo",
65
+ plan_groups: [
66
+ { id: "A", name: "A", description: "A desc", files: ["a.ts"], order: 0, deps: [] },
67
+ { id: "B", name: "B", description: "B desc", files: ["b.ts"], order: 1, deps: ["A"] },
68
+ { id: "C", name: "C", description: "C desc", files: ["c.ts"], order: 2, deps: [] },
69
+ ],
70
+ });
71
+
72
+ const itemById = new Map(preview.items.map((item) => [item.group_id, item]));
73
+
74
+ expect(itemById.get("A")?.base_branch).toBe("main");
75
+ expect(itemById.get("B")?.base_branch).toBe("stack/a");
76
+ expect(itemById.get("C")?.base_branch).toBe("main");
77
+
78
+ expect(itemById.get("B")?.title).toContain("L1");
79
+ expect(itemById.get("C")?.title).toContain("L0");
80
+ expect(itemById.get("B")?.body).toContain("Stack L1");
81
+ expect(itemById.get("C")?.body).toContain("Stack L0");
82
+ });
83
+ });
@@ -145,6 +145,24 @@ function buildEffectiveGroupMeta(
145
145
  });
146
146
  }
147
147
 
148
+ function resolvePrBaseBranch(
149
+ groupId: string,
150
+ baseBranch: string,
151
+ planGroups: StackPublishGroupMeta[] | undefined,
152
+ branchByGroupId: Map<string, string>,
153
+ ): string {
154
+ const planGroup = planGroups?.find((g) => g.id === groupId);
155
+ const directDeps: string[] = planGroup?.deps ?? [];
156
+ if (directDeps.length > 0) {
157
+ const depBranch = directDeps
158
+ .map((dep: string) => branchByGroupId.get(dep))
159
+ .find((b: string | undefined): b is string => Boolean(b));
160
+ if (depBranch) return depBranch;
161
+ }
162
+
163
+ return baseBranch;
164
+ }
165
+
148
166
  function isPreviewCompatible(execResult: StackExecResult, preview: StackPublishPreviewResult | null | undefined): boolean {
149
167
  if (!preview || preview.items.length === 0) return false;
150
168
  if (preview.items.length !== execResult.group_commits.length) return false;
@@ -502,18 +520,6 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
502
520
 
503
521
  const dagLevelMap = buildDagLevelMap(exec_result.group_commits, groupMetaById);
504
522
 
505
- const resolvePrBase = (gc: typeof exec_result.group_commits[number], index: number): string => {
506
- const planGroup = plan_groups?.find((g) => g.id === gc.group_id);
507
- const directDeps: string[] = planGroup?.deps ?? [];
508
- if (directDeps.length > 0) {
509
- const depBranch = directDeps
510
- .map((dep: string) => branchByGroupId.get(dep))
511
- .find((b: string | undefined): b is string => Boolean(b));
512
- if (depBranch) return depBranch;
513
- }
514
- return index === 0 ? base_branch : (exec_result.group_commits[index - 1]?.branch_name ?? base_branch);
515
- };
516
-
517
523
  for (const gc of exec_result.group_commits) {
518
524
  const pushResult = await Bun.$`git -C ${repo_path} push origin refs/heads/${gc.branch_name}:refs/heads/${gc.branch_name} --force-with-lease`.quiet().nothrow();
519
525
 
@@ -536,7 +542,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
536
542
  if (!branchInfo?.pushed) continue;
537
543
 
538
544
  const previewItem = previewByGroup.get(gc.group_id);
539
- const prBase = previewItem?.base_branch ?? resolvePrBase(gc, i);
545
+ const prBase = previewItem?.base_branch ?? resolvePrBaseBranch(gc.group_id, base_branch, plan_groups, branchByGroupId);
540
546
  if (!prBase) continue;
541
547
 
542
548
  const order = i + 1;
@@ -553,6 +559,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
553
559
  prBase,
554
560
  gc.branch_name,
555
561
  llmPrefillByGroup.get(gc.group_id),
562
+ dagLevel,
556
563
  );
557
564
 
558
565
  const prResult = await runWithBodyFile(
@@ -580,7 +587,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
580
587
  }
581
588
  }
582
589
 
583
- await updatePrBodies(ghRepo, prs, pr_meta, prTemplate, groupMetaById, llmPrefillByGroup, previewByGroup);
590
+ await updatePrBodies(ghRepo, prs, pr_meta, prTemplate, groupMetaById, llmPrefillByGroup, previewByGroup, dagLevelMap);
584
591
  await postStackNavigationComments(ghRepo, prs, dagLevelMap);
585
592
 
586
593
  return { branches, prs };
@@ -593,6 +600,8 @@ export async function buildStackPublishPreview(input: PublishInput): Promise<Sta
593
600
  const prTemplate = prTemplateData?.content ?? null;
594
601
  const total = exec_result.group_commits.length;
595
602
  const groupMetaById = buildGroupMetaMap(plan_groups);
603
+ const branchByGroupId = new Map(exec_result.group_commits.map((gc) => [gc.group_id, gc.branch_name]));
604
+ const dagLevelMap = buildDagLevelMap(exec_result.group_commits, groupMetaById);
596
605
  const effectiveGroups = buildEffectiveGroupMeta(exec_result, groupMetaById);
597
606
  const llmPrefillByGroup = await generateTemplatePrefillWithLlm(
598
607
  llm_client,
@@ -604,8 +613,9 @@ export async function buildStackPublishPreview(input: PublishInput): Promise<Sta
604
613
 
605
614
  const items = exec_result.group_commits.map((gc, i) => {
606
615
  const order = i + 1;
607
- const title = buildStackPrTitle(gc, pr_meta, order, total);
608
- const prBase = i === 0 ? base_branch : exec_result.group_commits[i - 1]?.branch_name ?? base_branch;
616
+ const dagLevel = dagLevelMap.get(gc.group_id);
617
+ const title = buildStackPrTitle(gc, pr_meta, order, total, dagLevel);
618
+ const prBase = resolvePrBaseBranch(gc.group_id, base_branch, plan_groups, branchByGroupId);
609
619
  const groupMeta = groupMetaById.get(gc.group_id);
610
620
  return {
611
621
  group_id: gc.group_id,
@@ -624,6 +634,7 @@ export async function buildStackPublishPreview(input: PublishInput): Promise<Sta
624
634
  prBase,
625
635
  gc.branch_name,
626
636
  llmPrefillByGroup.get(gc.group_id),
637
+ dagLevel,
627
638
  ),
628
639
  };
629
640
  });
@@ -642,6 +653,7 @@ async function updatePrBodies(
642
653
  groupMetaById: Map<string, StackPublishGroupMeta>,
643
654
  llmPrefillByGroup: Map<string, SectionBullets>,
644
655
  previewByGroup: Map<string, StackPublishPreviewItem>,
656
+ dagLevelMap: Map<string, number>,
645
657
  ): Promise<void> {
646
658
  if (prs.length === 0) return;
647
659
 
@@ -650,6 +662,7 @@ async function updatePrBodies(
650
662
  const previewItem = previewByGroup.get(pr.group_id);
651
663
  const previewBody = previewItem?.body;
652
664
  const groupMeta = groupMetaById.get(pr.group_id);
665
+ const dagLevel = dagLevelMap.get(pr.group_id);
653
666
  const body = previewBody ?? buildFullBody(
654
667
  pr,
655
668
  i,
@@ -660,6 +673,7 @@ async function updatePrBodies(
660
673
  pr.base_branch,
661
674
  pr.head_branch,
662
675
  llmPrefillByGroup.get(pr.group_id),
676
+ dagLevel,
663
677
  );
664
678
  const editResult = await runWithBodyFile(
665
679
  body,
@@ -718,8 +732,9 @@ function buildPlaceholderBody(
718
732
  baseBranch?: string,
719
733
  headBranch?: string,
720
734
  llmBullets?: SectionBullets,
735
+ dagLevel?: number,
721
736
  ): string {
722
- return buildDescriptionBody(groupId, order, total, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets);
737
+ return buildDescriptionBody(groupId, order, total, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets, dagLevel);
723
738
  }
724
739
 
725
740
  function buildStackPrTitle(
@@ -791,8 +806,9 @@ function buildFullBody(
791
806
  baseBranch?: string,
792
807
  headBranch?: string,
793
808
  llmBullets?: SectionBullets,
809
+ dagLevel?: number,
794
810
  ): string {
795
- return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets);
811
+ return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets, dagLevel);
796
812
  }
797
813
 
798
814
  function buildStackNavigationComment(
@@ -107,15 +107,22 @@ export interface StackGroupStats {
107
107
  files_deleted: number;
108
108
  }
109
109
 
110
+ export interface StackFileStats {
111
+ additions: number;
112
+ deletions: number;
113
+ }
114
+
110
115
  export interface StackGroup {
111
116
  id: string;
112
117
  name: string;
113
118
  type: GroupType;
114
119
  description: string;
115
120
  files: string[];
116
- deps: string[]; // groupIds
121
+ deps: string[];
122
+ explicit_deps?: string[];
117
123
  order: number;
118
124
  stats?: StackGroupStats;
125
+ file_stats?: Record<string, StackFileStats>;
119
126
  pr_title?: string;
120
127
  }
121
128
 
@@ -123,7 +130,8 @@ export interface StackPlan {
123
130
  base_sha: string;
124
131
  head_sha: string;
125
132
  groups: StackGroup[];
126
- expected_trees: Map<string, string>; // groupId -> treeSha
133
+ expected_trees: Map<string, string>;
134
+ ancestor_sets: Map<string, string[]>;
127
135
  }
128
136
 
129
137
  // ============================================================================
@@ -6,6 +6,7 @@ export interface VerifyInput {
6
6
  head_sha: string;
7
7
  exec_result: StackExecResult;
8
8
  ownership: Map<string, string>;
9
+ group_deps?: Map<string, string[]>;
9
10
  }
10
11
 
11
12
  export interface VerifyResult {
@@ -16,7 +17,7 @@ export interface VerifyResult {
16
17
  }
17
18
 
18
19
  export async function verifyStack(input: VerifyInput): Promise<VerifyResult> {
19
- const { repo_path, base_sha, head_sha, exec_result, ownership } = input;
20
+ const { repo_path, base_sha, head_sha, exec_result, ownership, group_deps } = input;
20
21
  const errors: string[] = [];
21
22
  const warnings: string[] = [];
22
23
  const structuredWarnings: StackWarning[] = [];
@@ -25,7 +26,7 @@ export async function verifyStack(input: VerifyInput): Promise<VerifyResult> {
25
26
  const unionMissing: string[] = [];
26
27
  const unionExtra: string[] = [];
27
28
 
28
- await verifyPerGroupDiffScope(repo_path, base_sha, exec_result, ownership, warnings, scopeLeaks);
29
+ await verifyPerGroupDiffScope(repo_path, base_sha, exec_result, ownership, group_deps ?? new Map(), warnings, scopeLeaks);
29
30
  await verifyUnionCompleteness(repo_path, base_sha, head_sha, exec_result, warnings, unionMissing, unionExtra);
30
31
  await verifyFinalTreeEquivalence(repo_path, head_sha, exec_result, errors);
31
32
 
@@ -70,17 +71,26 @@ async function verifyPerGroupDiffScope(
70
71
  baseSha: string,
71
72
  execResult: StackExecResult,
72
73
  ownership: Map<string, string>,
74
+ groupDeps: Map<string, string[]>,
73
75
  warnings: string[],
74
76
  scopeLeaks: string[],
75
77
  ): Promise<void> {
76
- let prevCommitSha = baseSha;
78
+ const commitByGroupId = new Map<string, string>();
79
+ for (const gc of execResult.group_commits) {
80
+ commitByGroupId.set(gc.group_id, gc.commit_sha);
81
+ }
77
82
 
78
83
  for (const gc of execResult.group_commits) {
79
- const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevCommitSha} ${gc.commit_sha}`.quiet().nothrow();
84
+ const parentTree = await resolveScopeParentTree(repoPath, baseSha, gc.group_id, groupDeps, commitByGroupId);
85
+ if (!parentTree) {
86
+ warnings.push(`Failed to resolve parent tree for group "${gc.group_id}"`);
87
+ continue;
88
+ }
89
+
90
+ const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${parentTree} ${gc.tree_sha}`.quiet().nothrow();
80
91
 
81
92
  if (diffResult.exitCode !== 0) {
82
93
  warnings.push(`Failed to diff group "${gc.group_id}": ${diffResult.stderr.toString().trim()}`);
83
- prevCommitSha = gc.commit_sha;
84
94
  continue;
85
95
  }
86
96
 
@@ -89,16 +99,62 @@ async function verifyPerGroupDiffScope(
89
99
  for (const path of changedPaths) {
90
100
  const fileOwner = ownership.get(path);
91
101
  if (fileOwner !== gc.group_id) {
92
- const detail = `"${path}" in "${gc.group_id}" diff, owned by "${fileOwner ?? "unassigned"}"`;
93
102
  warnings.push(
94
103
  `Group "${gc.group_id}" diff contains file "${path}" owned by "${fileOwner ?? "unassigned"}"`,
95
104
  );
96
- scopeLeaks.push(detail);
105
+ scopeLeaks.push(`"${path}" in "${gc.group_id}" diff, owned by "${fileOwner ?? "unassigned"}"`);
97
106
  }
98
107
  }
108
+ }
109
+ }
110
+
111
+ async function resolveScopeParentTree(
112
+ repoPath: string,
113
+ baseSha: string,
114
+ groupId: string,
115
+ groupDeps: Map<string, string[]>,
116
+ commitByGroupId: Map<string, string>,
117
+ ): Promise<string | null> {
118
+ const deps = groupDeps.get(groupId) ?? [];
119
+ if (deps.length === 0) {
120
+ return resolveTreeForRef(repoPath, baseSha);
121
+ }
122
+
123
+ const parentCommits: string[] = [];
124
+ for (const dep of deps) {
125
+ const depCommit = commitByGroupId.get(dep);
126
+ if (!depCommit) continue;
127
+ parentCommits.push(depCommit);
128
+ }
129
+
130
+ if (parentCommits.length === 0) return resolveTreeForRef(repoPath, baseSha);
131
+ if (parentCommits.length === 1) return resolveTreeForRef(repoPath, parentCommits[0]!);
132
+
133
+ let mergedCommit = parentCommits[0]!;
134
+ let mergedTree: string | null = null;
135
+ for (let i = 1; i < parentCommits.length; i++) {
136
+ const nextParentCommit = parentCommits[i]!;
137
+ const mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedCommit} ${nextParentCommit}`.quiet().nothrow();
138
+ if (mergeResult.exitCode !== 0) return null;
139
+ const nextTree = mergeResult.stdout.toString().trim().split("\n")[0]?.trim();
140
+ if (!nextTree) return null;
141
+ mergedTree = nextTree;
99
142
 
100
- prevCommitSha = gc.commit_sha;
143
+ const mergedCommitResult = await Bun.$`git -C ${repoPath} commit-tree ${mergedTree} -p ${mergedCommit} -p ${nextParentCommit} -m "newpr synthetic verify merged parent"`.quiet().nothrow();
144
+ if (mergedCommitResult.exitCode !== 0) return null;
145
+ mergedCommit = mergedCommitResult.stdout.toString().trim();
146
+ if (!mergedCommit) return null;
101
147
  }
148
+
149
+ if (!mergedTree) return resolveTreeForRef(repoPath, mergedCommit);
150
+ return mergedTree;
151
+ }
152
+
153
+ async function resolveTreeForRef(repoPath: string, ref: string): Promise<string | null> {
154
+ const result = await Bun.$`git -C ${repoPath} rev-parse ${ref}^{tree}`.quiet().nothrow();
155
+ if (result.exitCode !== 0) return null;
156
+ const tree = result.stdout.toString().trim();
157
+ return tree.length > 0 ? tree : null;
102
158
  }
103
159
 
104
160
  async function verifyUnionCompleteness(
@@ -119,18 +175,19 @@ async function verifyUnionCompleteness(
119
175
 
120
176
  const expectedPaths = new Set(extractPathsFromRawDiff(expectedResult.stdout));
121
177
 
122
- const actualPaths = new Set<string>();
123
- let prevSha = baseSha;
124
- for (const gc of execResult.group_commits) {
125
- const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevSha} ${gc.commit_sha}`.quiet().nothrow();
126
-
127
- if (diffResult.exitCode === 0) {
128
- for (const path of extractPathsFromRawDiff(diffResult.stdout)) {
129
- actualPaths.add(path);
130
- }
131
- }
132
- prevSha = gc.commit_sha;
178
+ const baseTreeResult = await Bun.$`git -C ${repoPath} rev-parse ${baseSha}^{tree}`.quiet().nothrow();
179
+ if (baseTreeResult.exitCode !== 0) {
180
+ warnings.push(`Failed to get base tree: ${baseTreeResult.stderr.toString().trim()}`);
181
+ return;
133
182
  }
183
+ const baseTree = baseTreeResult.stdout.toString().trim();
184
+ const finalTree = execResult.final_tree_sha;
185
+ if (!finalTree) return;
186
+
187
+ const actualResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z ${baseTree} ${finalTree}`.quiet().nothrow();
188
+ const actualPaths = actualResult.exitCode === 0
189
+ ? new Set(extractPathsFromRawDiff(actualResult.stdout))
190
+ : new Set<string>();
134
191
 
135
192
  for (const path of expectedPaths) {
136
193
  if (!actualPaths.has(path)) {