@cvr/stacked 0.4.2 → 0.4.4
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/README.md +1 -1
- package/bin/stacked +0 -0
- package/package.json +4 -1
- package/scripts/benchmark-detect.ts +308 -0
- package/scripts/benchmark-git.ts +273 -0
- package/src/commands/clean.ts +6 -5
- package/src/commands/create.ts +6 -2
- package/src/commands/delete.ts +1 -1
- package/src/commands/detect.ts +64 -32
- package/src/commands/doctor.ts +14 -8
- package/src/commands/helpers/pr-metadata.ts +131 -0
- package/src/commands/list.ts +3 -4
- package/src/commands/rename.ts +5 -9
- package/src/commands/reorder.ts +11 -17
- package/src/commands/split.ts +4 -28
- package/src/commands/stacks.ts +3 -5
- package/src/commands/submit.ts +15 -87
- package/src/commands/sync.ts +33 -13
- package/src/main.ts +31 -29
- package/src/services/Git.ts +20 -0
- package/src/services/GitEs.ts +309 -0
- package/src/services/Stack.ts +621 -182
- package/src/services/git-backend.ts +18 -0
package/src/commands/detect.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { StackService } from "../services/Stack.js";
|
|
|
5
5
|
import { success, warn, info } from "../ui.js";
|
|
6
6
|
import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
|
|
7
7
|
|
|
8
|
+
const DETECT_COMMIT_LIMIT = 2048;
|
|
9
|
+
|
|
8
10
|
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
9
11
|
Flag.withDescription("Show what would be detected without making changes"),
|
|
10
12
|
);
|
|
@@ -26,8 +28,11 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
26
28
|
const candidates = allBranches.filter((b) => b !== trunk);
|
|
27
29
|
|
|
28
30
|
const data = yield* stacks.load();
|
|
29
|
-
const alreadyTracked = new Set(Object.
|
|
30
|
-
const
|
|
31
|
+
const alreadyTracked = new Set(Object.keys(data.branches));
|
|
32
|
+
const mergedBranches = new Set(data.mergedBranches);
|
|
33
|
+
const untrackedAll = candidates.filter(
|
|
34
|
+
(b) => !alreadyTracked.has(b) && !mergedBranches.has(b),
|
|
35
|
+
);
|
|
31
36
|
const detectLimit = yield* detectLimitConfig;
|
|
32
37
|
const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
|
|
33
38
|
|
|
@@ -42,42 +47,63 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
42
47
|
);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
// Build parent map: for each branch, find its direct parent among other branches
|
|
46
|
-
// A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
|
|
47
50
|
const childOf = new Map<string, string>();
|
|
51
|
+
const unclassified: string[] = [];
|
|
52
|
+
|
|
53
|
+
const tipResults = yield* Effect.forEach(
|
|
54
|
+
untracked,
|
|
55
|
+
(branch) =>
|
|
56
|
+
git.revParse(branch).pipe(
|
|
57
|
+
Effect.map((oid) => [branch, oid] as const),
|
|
58
|
+
Effect.catchTag("GitError", () => Effect.succeed(null)),
|
|
59
|
+
),
|
|
60
|
+
{ concurrency: 5 },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const tipOwners = new Map<string, string[]>();
|
|
64
|
+
for (const result of tipResults) {
|
|
65
|
+
if (result === null) continue;
|
|
66
|
+
const [branch, oid] = result;
|
|
67
|
+
const owners = tipOwners.get(oid) ?? [];
|
|
68
|
+
owners.push(branch);
|
|
69
|
+
tipOwners.set(oid, owners);
|
|
70
|
+
}
|
|
48
71
|
|
|
49
72
|
yield* Effect.forEach(
|
|
50
73
|
untracked,
|
|
51
74
|
(branch) =>
|
|
52
75
|
Effect.gen(function* () {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const commits = yield* git
|
|
77
|
+
.firstParentUniqueCommits(branch, trunk, { limit: DETECT_COMMIT_LIMIT })
|
|
78
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed([])));
|
|
79
|
+
|
|
80
|
+
if (commits.length === 0) {
|
|
81
|
+
unclassified.push(branch);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let parent: string | null = null;
|
|
86
|
+
let ambiguous = false;
|
|
87
|
+
|
|
88
|
+
for (const oid of commits) {
|
|
89
|
+
const owners = (tipOwners.get(oid) ?? []).filter((owner) => owner !== branch);
|
|
90
|
+
if (owners.length > 1) {
|
|
91
|
+
ambiguous = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
const [owner] = owners;
|
|
95
|
+
if (owner !== undefined) {
|
|
96
|
+
parent = owner;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (ambiguous || (commits.length >= DETECT_COMMIT_LIMIT && parent === null)) {
|
|
102
|
+
unclassified.push(branch);
|
|
103
|
+
return;
|
|
78
104
|
}
|
|
79
105
|
|
|
80
|
-
childOf.set(branch,
|
|
106
|
+
childOf.set(branch, parent ?? trunk);
|
|
81
107
|
}),
|
|
82
108
|
{ concurrency: 5 },
|
|
83
109
|
);
|
|
@@ -137,8 +163,8 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
137
163
|
for (const chain of chains) {
|
|
138
164
|
const name = chain[0];
|
|
139
165
|
if (name === undefined) continue;
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
166
|
+
const existing = yield* stacks.getStack(name);
|
|
167
|
+
if (existing !== null) {
|
|
142
168
|
yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
|
|
143
169
|
continue;
|
|
144
170
|
}
|
|
@@ -162,6 +188,12 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
162
188
|
yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
|
|
163
189
|
}
|
|
164
190
|
}
|
|
191
|
+
|
|
192
|
+
if (unclassified.length > 0 && !json) {
|
|
193
|
+
yield* warn(
|
|
194
|
+
`Skipped ${unclassified.length} unclassified branch${unclassified.length === 1 ? "" : "es"}: ${unclassified.join(", ")}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
165
197
|
}),
|
|
166
198
|
),
|
|
167
199
|
);
|
package/src/commands/doctor.ts
CHANGED
|
@@ -26,6 +26,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
26
26
|
|
|
27
27
|
const data = yield* stacks.load();
|
|
28
28
|
const findings: Finding[] = [];
|
|
29
|
+
const stackEntries = yield* stacks.listStacks().pipe(
|
|
30
|
+
Effect.catchTag("StackError", (error) =>
|
|
31
|
+
Effect.sync(() => {
|
|
32
|
+
findings.push({ type: "parse_error", message: error.message, fixed: false });
|
|
33
|
+
return [] as const;
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
29
37
|
|
|
30
38
|
// Check 1: trunk branch exists
|
|
31
39
|
const trunkExists = yield* git
|
|
@@ -58,14 +66,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
// Check 2: all tracked branches exist in git
|
|
61
|
-
for (const
|
|
69
|
+
for (const { name: stackName, stack } of stackEntries) {
|
|
62
70
|
for (const branch of stack.branches) {
|
|
63
71
|
const exists = yield* git
|
|
64
72
|
.branchExists(branch)
|
|
65
73
|
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
66
74
|
if (!exists) {
|
|
67
75
|
if (fix) {
|
|
68
|
-
yield* stacks.removeBranch(
|
|
76
|
+
yield* stacks.removeBranch(branch);
|
|
69
77
|
findings.push({
|
|
70
78
|
type: "stale_branch",
|
|
71
79
|
message: `Removed stale branch "${branch}" from stack "${stackName}"`,
|
|
@@ -84,12 +92,10 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
84
92
|
|
|
85
93
|
// Check 3: no branches in multiple stacks
|
|
86
94
|
const branchToStacks = new Map<string, string[]>();
|
|
87
|
-
for (const [
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
branchToStacks.set(branch, existing);
|
|
92
|
-
}
|
|
95
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
96
|
+
const existing = branchToStacks.get(branch) ?? [];
|
|
97
|
+
existing.push(record.stack);
|
|
98
|
+
branchToStacks.set(branch, existing);
|
|
93
99
|
}
|
|
94
100
|
for (const [branch, stackNames] of branchToStacks) {
|
|
95
101
|
if (stackNames.length > 1) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ServiceMap } from "effect";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import type { GitHubService } from "../../services/GitHub.js";
|
|
4
|
+
|
|
5
|
+
const STACKED_MARKER_START = "<!-- stacked -->";
|
|
6
|
+
const STACKED_MARKER_END = "<!-- /stacked -->";
|
|
7
|
+
|
|
8
|
+
type PullRequest = {
|
|
9
|
+
number: number;
|
|
10
|
+
url: string;
|
|
11
|
+
state: string;
|
|
12
|
+
body?: string | null;
|
|
13
|
+
} | null;
|
|
14
|
+
|
|
15
|
+
type GitHubApi = ServiceMap.Service.Shape<typeof GitHubService>;
|
|
16
|
+
|
|
17
|
+
export const generateStackMetadata = (
|
|
18
|
+
branches: readonly string[],
|
|
19
|
+
prMap: Map<string, PullRequest>,
|
|
20
|
+
currentIdx: number,
|
|
21
|
+
stackName: string,
|
|
22
|
+
): string => {
|
|
23
|
+
const rows = branches.map((branch, i) => {
|
|
24
|
+
const pr = prMap.get(branch) ?? null;
|
|
25
|
+
const isCurrent = i === currentIdx;
|
|
26
|
+
const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
|
|
27
|
+
const numCol = i + 1;
|
|
28
|
+
const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
|
|
29
|
+
|
|
30
|
+
let prCol: string;
|
|
31
|
+
if (pr === null) {
|
|
32
|
+
prCol = "—";
|
|
33
|
+
} else if (pr.state === "MERGED") {
|
|
34
|
+
prCol = `[#${pr.number}](${pr.url}) ✅`;
|
|
35
|
+
} else if (isCurrent) {
|
|
36
|
+
prCol = `**#${pr.number} ← you are here**`;
|
|
37
|
+
} else {
|
|
38
|
+
prCol = `[#${pr.number}](${pr.url})`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return `| ${numStr} | ${branchCol} | ${prCol} |`;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
STACKED_MARKER_START,
|
|
46
|
+
`**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
|
|
47
|
+
"",
|
|
48
|
+
"| # | Branch | PR |",
|
|
49
|
+
"|---|--------|----|",
|
|
50
|
+
...rows,
|
|
51
|
+
STACKED_MARKER_END,
|
|
52
|
+
].join("\n");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const composePRBody = (userBody: string | undefined, metadata: string): string => {
|
|
56
|
+
if (userBody !== undefined) {
|
|
57
|
+
return `${userBody}\n\n---\n\n${metadata}`;
|
|
58
|
+
}
|
|
59
|
+
return metadata;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const updatePRBody = (
|
|
63
|
+
existingBody: string | undefined,
|
|
64
|
+
userBody: string | undefined,
|
|
65
|
+
metadata: string,
|
|
66
|
+
): string => {
|
|
67
|
+
if (userBody !== undefined) {
|
|
68
|
+
return composePRBody(userBody, metadata);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (existingBody !== undefined) {
|
|
72
|
+
const startIdx = existingBody.indexOf(STACKED_MARKER_START);
|
|
73
|
+
if (startIdx !== -1) {
|
|
74
|
+
const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
|
|
75
|
+
if (prefix.trim().length > 0) {
|
|
76
|
+
return `${prefix.trim()}\n\n---\n\n${metadata}`;
|
|
77
|
+
}
|
|
78
|
+
return metadata;
|
|
79
|
+
}
|
|
80
|
+
return `${existingBody.trim()}\n\n---\n\n${metadata}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return metadata;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const refreshStackedPRBodies = ({
|
|
87
|
+
branches,
|
|
88
|
+
stackName,
|
|
89
|
+
gh,
|
|
90
|
+
initialPrMap,
|
|
91
|
+
shouldUpdateBranch,
|
|
92
|
+
getUserBody,
|
|
93
|
+
}: {
|
|
94
|
+
branches: readonly string[];
|
|
95
|
+
stackName: string;
|
|
96
|
+
gh: GitHubApi;
|
|
97
|
+
initialPrMap?: Map<string, PullRequest>;
|
|
98
|
+
shouldUpdateBranch?: (branch: string) => boolean;
|
|
99
|
+
getUserBody?: (branch: string, idx: number) => string | undefined;
|
|
100
|
+
}) =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const prEntries = yield* Effect.all(
|
|
103
|
+
branches.map((branch) => {
|
|
104
|
+
const existing = initialPrMap?.get(branch);
|
|
105
|
+
if (existing !== undefined) {
|
|
106
|
+
return Effect.succeed([branch, existing] as const);
|
|
107
|
+
}
|
|
108
|
+
return gh.getPR(branch).pipe(Effect.map((pr) => [branch, pr] as const));
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const prMap = new Map(prEntries);
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < branches.length; i++) {
|
|
114
|
+
const branch = branches[i];
|
|
115
|
+
if (branch === undefined) continue;
|
|
116
|
+
if (shouldUpdateBranch !== undefined && !shouldUpdateBranch(branch)) continue;
|
|
117
|
+
|
|
118
|
+
const existingPrData = prMap.get(branch) ?? null;
|
|
119
|
+
if (existingPrData === null || existingPrData.state !== "OPEN") continue;
|
|
120
|
+
|
|
121
|
+
const metadata = generateStackMetadata(branches, prMap, i, stackName);
|
|
122
|
+
const body = updatePRBody(
|
|
123
|
+
existingPrData.body ?? undefined,
|
|
124
|
+
getUserBody?.(branch, i),
|
|
125
|
+
metadata,
|
|
126
|
+
);
|
|
127
|
+
yield* gh.updatePR({ branch, body });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return prMap;
|
|
131
|
+
});
|
package/src/commands/list.ts
CHANGED
|
@@ -25,15 +25,14 @@ export const list = Command.make("list", { stackName: stackNameArg, json: jsonFl
|
|
|
25
25
|
const stacks = yield* StackService;
|
|
26
26
|
|
|
27
27
|
const currentBranch = yield* git.currentBranch();
|
|
28
|
-
const
|
|
29
|
-
const trunk = data.trunk;
|
|
28
|
+
const trunk = yield* stacks.getTrunk();
|
|
30
29
|
|
|
31
30
|
let targetStackName: string | null = null;
|
|
32
31
|
let targetStack: { readonly branches: readonly string[] } | null = null;
|
|
33
32
|
|
|
34
33
|
if (Option.isSome(stackName)) {
|
|
35
|
-
const s =
|
|
36
|
-
if (s ===
|
|
34
|
+
const s = yield* stacks.getStack(stackName.value);
|
|
35
|
+
if (s === null) {
|
|
37
36
|
return yield* new StackError({
|
|
38
37
|
code: ErrorCode.STACK_NOT_FOUND,
|
|
39
38
|
message: `Stack "${stackName.value}" not found`,
|
package/src/commands/rename.ts
CHANGED
|
@@ -16,27 +16,23 @@ export const rename = Command.make("rename", { old: oldArg, new: newArg, json: j
|
|
|
16
16
|
Command.withHandler(({ old: oldName, new: newName, json }) =>
|
|
17
17
|
Effect.gen(function* () {
|
|
18
18
|
const stacks = yield* StackService;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (data.stacks[oldName] === undefined) {
|
|
19
|
+
const existing = yield* stacks.getStack(oldName);
|
|
20
|
+
if (existing === null) {
|
|
23
21
|
return yield* new StackError({
|
|
24
22
|
code: ErrorCode.STACK_NOT_FOUND,
|
|
25
23
|
message: `Stack "${oldName}" not found`,
|
|
26
24
|
});
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
const collision = yield* stacks.getStack(newName);
|
|
28
|
+
if (collision !== null) {
|
|
30
29
|
return yield* new StackError({
|
|
31
30
|
code: ErrorCode.STACK_EXISTS,
|
|
32
31
|
message: `Stack "${newName}" already exists`,
|
|
33
32
|
});
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
if (stack === undefined) return;
|
|
38
|
-
const { [oldName]: _, ...rest } = data.stacks;
|
|
39
|
-
yield* stacks.save({ ...data, stacks: { ...rest, [newName]: stack } });
|
|
35
|
+
yield* stacks.renameStack(oldName, newName);
|
|
40
36
|
|
|
41
37
|
if (json) {
|
|
42
38
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
package/src/commands/reorder.ts
CHANGED
|
@@ -74,28 +74,22 @@ export const reorder = Command.make("reorder", {
|
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// Insert at target position
|
|
81
|
-
const newTargetIdx = branches.indexOf(target);
|
|
82
|
-
if (Option.isSome(before)) {
|
|
83
|
-
branches.splice(newTargetIdx, 0, branch);
|
|
84
|
-
} else {
|
|
85
|
-
branches.splice(newTargetIdx + 1, 0, branch);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const data = yield* stacks.load();
|
|
89
|
-
yield* stacks.save({
|
|
90
|
-
...data,
|
|
91
|
-
stacks: { ...data.stacks, [stackName]: { branches } },
|
|
77
|
+
const updated = yield* stacks.reorderBranch(branch, {
|
|
78
|
+
before: Option.getOrUndefined(before),
|
|
79
|
+
after: Option.getOrUndefined(after),
|
|
92
80
|
});
|
|
93
81
|
|
|
94
82
|
if (json) {
|
|
95
83
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
96
|
-
yield* Console.log(
|
|
84
|
+
yield* Console.log(
|
|
85
|
+
JSON.stringify(
|
|
86
|
+
{ branch, stack: updated.name, branches: [...updated.stack.branches] },
|
|
87
|
+
null,
|
|
88
|
+
2,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
97
91
|
} else {
|
|
98
|
-
yield* success(`Moved "${branch}" in stack "${
|
|
92
|
+
yield* success(`Moved "${branch}" in stack "${updated.name}"`);
|
|
99
93
|
yield* warn("Run 'stacked sync' to rebase branches in new order");
|
|
100
94
|
}
|
|
101
95
|
}),
|
package/src/commands/split.ts
CHANGED
|
@@ -35,17 +35,9 @@ export const split = Command.make("split", {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const { name: stackName, stack } = result;
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
if (splitIdx === 0) {
|
|
42
|
-
return yield* new StackError({
|
|
43
|
-
message: `Branch "${branch}" is at the bottom of the stack — nothing to split`,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const below = branches.slice(0, splitIdx);
|
|
48
|
-
const above = branches.slice(splitIdx);
|
|
38
|
+
const splitIdx = stack.branches.indexOf(branch);
|
|
39
|
+
const below = stack.branches.slice(0, splitIdx);
|
|
40
|
+
const above = stack.branches.slice(splitIdx);
|
|
49
41
|
const newStackName = branch;
|
|
50
42
|
|
|
51
43
|
if (dryRun) {
|
|
@@ -68,23 +60,7 @@ export const split = Command.make("split", {
|
|
|
68
60
|
return;
|
|
69
61
|
}
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (data.stacks[newStackName] !== undefined) {
|
|
74
|
-
return yield* new StackError({
|
|
75
|
-
code: ErrorCode.STACK_EXISTS,
|
|
76
|
-
message: `Stack "${newStackName}" already exists — choose a different split point or rename it first`,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
yield* stacks.save({
|
|
81
|
-
...data,
|
|
82
|
-
stacks: {
|
|
83
|
-
...data.stacks,
|
|
84
|
-
[stackName]: { branches: below },
|
|
85
|
-
[newStackName]: { branches: above },
|
|
86
|
-
},
|
|
87
|
-
});
|
|
63
|
+
yield* stacks.splitStack(branch);
|
|
88
64
|
|
|
89
65
|
if (json) {
|
|
90
66
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
package/src/commands/stacks.ts
CHANGED
|
@@ -18,9 +18,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
|
|
|
18
18
|
const stackService = yield* StackService;
|
|
19
19
|
|
|
20
20
|
const currentBranch = yield* git.currentBranch();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const entries = Object.entries(data.stacks);
|
|
21
|
+
const entries = yield* stackService.listStacks();
|
|
24
22
|
if (entries.length === 0) {
|
|
25
23
|
if (json) {
|
|
26
24
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
@@ -32,7 +30,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
if (json) {
|
|
35
|
-
const stackList = entries.map((
|
|
33
|
+
const stackList = entries.map(({ name, stack }) => ({
|
|
36
34
|
name,
|
|
37
35
|
branches: stack.branches.length,
|
|
38
36
|
current: stack.branches.includes(currentBranch),
|
|
@@ -43,7 +41,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
|
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
const lines: string[] = [];
|
|
46
|
-
for (const
|
|
44
|
+
for (const { name, stack } of entries) {
|
|
47
45
|
const isCurrent = stack.branches.includes(currentBranch);
|
|
48
46
|
const marker = isCurrent ? yield* stdout.green("* ") : " ";
|
|
49
47
|
const label = isCurrent ? yield* stdout.bold(name) : name;
|
package/src/commands/submit.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { GitService } from "../services/Git.js";
|
|
|
4
4
|
import { StackService } from "../services/Stack.js";
|
|
5
5
|
import { GitHubService } from "../services/GitHub.js";
|
|
6
6
|
import { ErrorCode, StackError } from "../errors/index.js";
|
|
7
|
+
import {
|
|
8
|
+
composePRBody,
|
|
9
|
+
generateStackMetadata,
|
|
10
|
+
refreshStackedPRBodies,
|
|
11
|
+
} from "./helpers/pr-metadata.js";
|
|
7
12
|
import { withSpinner, success } from "../ui.js";
|
|
8
13
|
|
|
9
14
|
const draftFlag = Flag.boolean("draft").pipe(
|
|
@@ -38,78 +43,6 @@ interface SubmitResult {
|
|
|
38
43
|
action: "created" | "updated" | "unchanged" | "would-create" | "would-update" | "would-unchanged";
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
const STACKED_MARKER_START = "<!-- stacked -->";
|
|
42
|
-
const STACKED_MARKER_END = "<!-- /stacked -->";
|
|
43
|
-
|
|
44
|
-
const generateStackMetadata = (
|
|
45
|
-
branches: readonly string[],
|
|
46
|
-
prMap: Map<string, { number: number; url: string; state: string } | null>,
|
|
47
|
-
currentIdx: number,
|
|
48
|
-
stackName: string,
|
|
49
|
-
): string => {
|
|
50
|
-
const rows = branches.map((branch, i) => {
|
|
51
|
-
const pr = prMap.get(branch) ?? null;
|
|
52
|
-
const isCurrent = i === currentIdx;
|
|
53
|
-
const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
|
|
54
|
-
const numCol = i + 1;
|
|
55
|
-
const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
|
|
56
|
-
|
|
57
|
-
let prCol: string;
|
|
58
|
-
if (pr === null) {
|
|
59
|
-
prCol = "—";
|
|
60
|
-
} else if (pr.state === "MERGED") {
|
|
61
|
-
prCol = `[#${pr.number}](${pr.url}) ✅`;
|
|
62
|
-
} else if (isCurrent) {
|
|
63
|
-
prCol = `**#${pr.number} ← you are here**`;
|
|
64
|
-
} else {
|
|
65
|
-
prCol = `[#${pr.number}](${pr.url})`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return `| ${numStr} | ${branchCol} | ${prCol} |`;
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
return [
|
|
72
|
-
STACKED_MARKER_START,
|
|
73
|
-
`**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
|
|
74
|
-
"",
|
|
75
|
-
"| # | Branch | PR |",
|
|
76
|
-
"|---|--------|----|",
|
|
77
|
-
...rows,
|
|
78
|
-
STACKED_MARKER_END,
|
|
79
|
-
].join("\n");
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const composePRBody = (userBody: string | undefined, metadata: string): string => {
|
|
83
|
-
if (userBody !== undefined) {
|
|
84
|
-
return `${userBody}\n\n---\n\n${metadata}`;
|
|
85
|
-
}
|
|
86
|
-
return metadata;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const updatePRBody = (
|
|
90
|
-
existingBody: string | undefined,
|
|
91
|
-
userBody: string | undefined,
|
|
92
|
-
metadata: string,
|
|
93
|
-
): string => {
|
|
94
|
-
if (userBody !== undefined) {
|
|
95
|
-
return composePRBody(userBody, metadata);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (existingBody !== undefined) {
|
|
99
|
-
const startIdx = existingBody.indexOf(STACKED_MARKER_START);
|
|
100
|
-
if (startIdx !== -1) {
|
|
101
|
-
const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
|
|
102
|
-
if (prefix.trim().length > 0) {
|
|
103
|
-
return `${prefix.trim()}\n\n---\n\n${metadata}`;
|
|
104
|
-
}
|
|
105
|
-
return metadata;
|
|
106
|
-
}
|
|
107
|
-
return `${existingBody.trim()}\n\n---\n\n${metadata}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return metadata;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
46
|
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
114
47
|
|
|
115
48
|
export const submit = Command.make("submit", {
|
|
@@ -294,21 +227,16 @@ export const submit = Command.make("submit", {
|
|
|
294
227
|
|
|
295
228
|
// Update all processed PRs with complete stack metadata.
|
|
296
229
|
// This includes newly created PRs so placeholders get replaced in one submit run.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const existingBody = existingPrData?.body ?? undefined;
|
|
308
|
-
const userBody = getBodyForBranch(branch, i);
|
|
309
|
-
const body = updatePRBody(existingBody, userBody, metadata);
|
|
310
|
-
yield* gh.updatePR({ branch, body });
|
|
311
|
-
}
|
|
230
|
+
yield* refreshStackedPRBodies({
|
|
231
|
+
branches,
|
|
232
|
+
stackName: result.name,
|
|
233
|
+
gh,
|
|
234
|
+
initialPrMap: prMap,
|
|
235
|
+
shouldUpdateBranch: (branch) =>
|
|
236
|
+
(!only || branch === currentBranch) && results.some((entry) => entry.branch === branch),
|
|
237
|
+
getUserBody: getBodyForBranch,
|
|
238
|
+
});
|
|
239
|
+
yield* stacks.unmarkMergedBranches(branches);
|
|
312
240
|
|
|
313
241
|
// Print structured output to stdout
|
|
314
242
|
if (json) {
|