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.
- package/package.json +1 -1
- package/src/stack/delta.test.ts +79 -1
- package/src/stack/delta.ts +130 -12
- package/src/stack/execute.test.ts +129 -1
- package/src/stack/execute.ts +79 -32
- package/src/stack/integration.test.ts +1 -0
- package/src/stack/merge-groups.test.ts +23 -0
- package/src/stack/merge-groups.ts +17 -1
- package/src/stack/plan.test.ts +30 -0
- package/src/stack/plan.ts +20 -14
- package/src/stack/publish.test.ts +83 -0
- package/src/stack/publish.ts +34 -18
- package/src/stack/types.ts +10 -2
- package/src/stack/verify.ts +76 -19
- package/src/web/client/components/StackDagView.tsx +264 -194
- package/src/web/client/hooks/useStack.ts +43 -0
- package/src/web/client/panels/StackPanel.tsx +1 -0
- package/src/web/server/routes.ts +5 -1
- package/src/web/server/stack-manager.ts +77 -3
- package/src/web/styles/built.css +1 -1
package/src/stack/plan.test.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/src/stack/publish.ts
CHANGED
|
@@ -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 ??
|
|
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
|
|
608
|
-
const
|
|
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(
|
package/src/stack/types.ts
CHANGED
|
@@ -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[];
|
|
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>;
|
|
133
|
+
expected_trees: Map<string, string>;
|
|
134
|
+
ancestor_sets: Map<string, string[]>;
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
// ============================================================================
|
package/src/stack/verify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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)) {
|