@cvr/stacked 0.4.3 → 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 +20 -0
- 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/README.md
CHANGED
|
@@ -130,7 +130,7 @@ stacked --yes clean # skip confirmation prompts
|
|
|
130
130
|
|
|
131
131
|
## Data Model
|
|
132
132
|
|
|
133
|
-
Stack metadata lives in `.git/stacked.json`.
|
|
133
|
+
Stack metadata lives in `.git/stacked.json`. Active stacks are stored as explicit linear parent links plus a per-stack root, and older v1 metadata is auto-migrated on load. `mergedBranches` remains a skip-list so `detect` does not try to rebuild stacks around already-merged branches.
|
|
134
134
|
|
|
135
135
|
Trunk is auto-detected on first use from `origin/HEAD` when available, then falls back to local `main`, `master`, or `develop`. Override with `stacked trunk <name>`.
|
|
136
136
|
|
package/bin/stacked
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cvr/stacked",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/cevr/stacked"
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"fmt": "oxfmt",
|
|
26
26
|
"fmt:check": "oxfmt --check",
|
|
27
27
|
"test": "bun test",
|
|
28
|
+
"bench:git": "bun run scripts/benchmark-git.ts",
|
|
29
|
+
"bench:detect": "bun run scripts/benchmark-detect.ts",
|
|
28
30
|
"gate": "concurrently -n type,lint,fmt,test,build -c blue,yellow,magenta,green,cyan \"bun run typecheck\" \"bun run lint\" \"bun run fmt\" \"bun run test\" \"bun run build\"",
|
|
29
31
|
"version": "changeset version",
|
|
30
32
|
"release": "changeset publish",
|
|
@@ -34,6 +36,7 @@
|
|
|
34
36
|
"dependencies": {
|
|
35
37
|
"@effect/platform-bun": "4.0.0-beta.27",
|
|
36
38
|
"effect": "4.0.0-beta.27",
|
|
39
|
+
"es-git": "^0.6.0",
|
|
37
40
|
"picocolors": "^1.1.1"
|
|
38
41
|
},
|
|
39
42
|
"devDependencies": {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// @effect-diagnostics effect/strictEffectProvide:off
|
|
2
|
+
// @effect-diagnostics effect/anyUnknownInErrorContext:off
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
import { GitService } from "../src/services/Git.js";
|
|
8
|
+
import type { GitBackend } from "../src/services/git-backend.js";
|
|
9
|
+
import { gitServiceLayerForBackend } from "../src/services/git-backend.js";
|
|
10
|
+
|
|
11
|
+
const DETECT_COMMIT_LIMIT = 2048;
|
|
12
|
+
|
|
13
|
+
const args = new Map(
|
|
14
|
+
process.argv.slice(2).map((arg) => {
|
|
15
|
+
const [key, value] = arg.split("=");
|
|
16
|
+
return [key ?? "", value ?? ""];
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const iterations = Number.parseInt(args.get("--iterations") ?? "5", 10);
|
|
21
|
+
|
|
22
|
+
const runCommand = async (cwd: string, command: string[]) => {
|
|
23
|
+
const proc = Bun.spawn(command, {
|
|
24
|
+
cwd,
|
|
25
|
+
stdout: "pipe",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
});
|
|
28
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
29
|
+
new Response(proc.stdout).text(),
|
|
30
|
+
new Response(proc.stderr).text(),
|
|
31
|
+
proc.exited,
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (exitCode !== 0) {
|
|
35
|
+
throw new Error(stderr.trim() || `${command.join(" ")} failed`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return stdout.trim();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const withCwd = async <A>(cwd: string, run: () => Promise<A>) => {
|
|
42
|
+
const previous = process.cwd();
|
|
43
|
+
process.chdir(cwd);
|
|
44
|
+
try {
|
|
45
|
+
return await run();
|
|
46
|
+
} finally {
|
|
47
|
+
process.chdir(previous);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const runWithBackend = async <A>(
|
|
52
|
+
cwd: string,
|
|
53
|
+
backend: GitBackend,
|
|
54
|
+
effect: Effect.Effect<A, unknown, GitService>,
|
|
55
|
+
) =>
|
|
56
|
+
withCwd(cwd, async () =>
|
|
57
|
+
Effect.runPromise(effect.pipe(Effect.provide(gitServiceLayerForBackend(backend)))),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const writeCommit = async (cwd: string, file: string, message: string) => {
|
|
61
|
+
await Bun.write(join(cwd, file), `${message} ${Date.now()}\n`);
|
|
62
|
+
await runCommand(cwd, ["git", "add", file]);
|
|
63
|
+
await runCommand(cwd, ["git", "commit", "-m", message]);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const createFixtureRepo = async (name: string) => {
|
|
67
|
+
const cwd = await mkdtemp(join(tmpdir(), `stacked-detect-${name}-`));
|
|
68
|
+
await runCommand(cwd, ["git", "init", "-b", "main"]);
|
|
69
|
+
await runCommand(cwd, ["git", "config", "user.name", "Stacked Bench"]);
|
|
70
|
+
await runCommand(cwd, ["git", "config", "user.email", "bench@example.com"]);
|
|
71
|
+
await writeCommit(cwd, "README.md", "initial");
|
|
72
|
+
return cwd;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const createLinearFixture = async () => {
|
|
76
|
+
const cwd = await createFixtureRepo("linear");
|
|
77
|
+
let base = "main";
|
|
78
|
+
for (let i = 1; i <= 24; i++) {
|
|
79
|
+
const branch = `linear-${i}`;
|
|
80
|
+
await runCommand(cwd, ["git", "checkout", "-b", branch, base]);
|
|
81
|
+
await writeCommit(cwd, `${branch}.txt`, branch);
|
|
82
|
+
base = branch;
|
|
83
|
+
}
|
|
84
|
+
await runCommand(cwd, ["git", "checkout", "main"]);
|
|
85
|
+
return cwd;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const createWideFixture = async () => {
|
|
89
|
+
const cwd = await createFixtureRepo("wide");
|
|
90
|
+
await runCommand(cwd, ["git", "checkout", "-b", "root", "main"]);
|
|
91
|
+
await writeCommit(cwd, "root.txt", "root");
|
|
92
|
+
for (let i = 1; i <= 16; i++) {
|
|
93
|
+
const branch = `child-${i}`;
|
|
94
|
+
await runCommand(cwd, ["git", "checkout", "-b", branch, "root"]);
|
|
95
|
+
await writeCommit(cwd, `${branch}.txt`, branch);
|
|
96
|
+
}
|
|
97
|
+
await runCommand(cwd, ["git", "checkout", "main"]);
|
|
98
|
+
return cwd;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const createMixedFixture = async () => {
|
|
102
|
+
const cwd = await createFixtureRepo("mixed");
|
|
103
|
+
|
|
104
|
+
let base = "main";
|
|
105
|
+
for (let i = 1; i <= 10; i++) {
|
|
106
|
+
const branch = `stack-a-${i}`;
|
|
107
|
+
await runCommand(cwd, ["git", "checkout", "-b", branch, base]);
|
|
108
|
+
await writeCommit(cwd, `${branch}.txt`, branch);
|
|
109
|
+
base = branch;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await runCommand(cwd, ["git", "checkout", "main"]);
|
|
113
|
+
await writeCommit(cwd, "main-advance.txt", "main advance");
|
|
114
|
+
|
|
115
|
+
await runCommand(cwd, ["git", "checkout", "-b", "fork-root", "main"]);
|
|
116
|
+
await writeCommit(cwd, "fork-root.txt", "fork-root");
|
|
117
|
+
for (let i = 1; i <= 8; i++) {
|
|
118
|
+
const branch = `fork-${i}`;
|
|
119
|
+
await runCommand(cwd, ["git", "checkout", "-b", branch, "fork-root"]);
|
|
120
|
+
await writeCommit(cwd, `${branch}.txt`, branch);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await runCommand(cwd, ["git", "checkout", "main"]);
|
|
124
|
+
for (let i = 1; i <= 12; i++) {
|
|
125
|
+
const branch = `solo-${i}`;
|
|
126
|
+
await runCommand(cwd, ["git", "checkout", "-b", branch, "main"]);
|
|
127
|
+
await writeCommit(cwd, `${branch}.txt`, branch);
|
|
128
|
+
await runCommand(cwd, ["git", "checkout", "main"]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return cwd;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const oldDetect = Effect.gen(function* () {
|
|
135
|
+
const git = yield* GitService;
|
|
136
|
+
const trunk = "main";
|
|
137
|
+
const candidates = (yield* git.listBranches()).filter((branch) => branch !== trunk);
|
|
138
|
+
const childOf = new Map<string, string>();
|
|
139
|
+
|
|
140
|
+
yield* Effect.forEach(
|
|
141
|
+
candidates,
|
|
142
|
+
(branch) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const potentialAncestors = [trunk, ...candidates.filter((other) => other !== branch)];
|
|
145
|
+
const ancestryResults = yield* Effect.forEach(
|
|
146
|
+
potentialAncestors,
|
|
147
|
+
(other) =>
|
|
148
|
+
git.isAncestor(other, branch).pipe(
|
|
149
|
+
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
150
|
+
Effect.map((is) => [other, is] as const),
|
|
151
|
+
),
|
|
152
|
+
{ concurrency: 8 },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const ancestors = ancestryResults.filter(([_, is]) => is).map(([name]) => name);
|
|
156
|
+
if (ancestors.length === 0) return;
|
|
157
|
+
|
|
158
|
+
let closest = ancestors[0] ?? trunk;
|
|
159
|
+
for (let i = 1; i < ancestors.length; i++) {
|
|
160
|
+
const candidate = ancestors[i];
|
|
161
|
+
if (candidate === undefined) continue;
|
|
162
|
+
const candidateIsCloser = yield* git
|
|
163
|
+
.isAncestor(closest, candidate)
|
|
164
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
165
|
+
if (candidateIsCloser) closest = candidate;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
childOf.set(branch, closest);
|
|
169
|
+
}),
|
|
170
|
+
{ concurrency: 8 },
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return childOf.size;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const newDetect = Effect.gen(function* () {
|
|
177
|
+
const git = yield* GitService;
|
|
178
|
+
const trunk = "main";
|
|
179
|
+
const candidates = (yield* git.listBranches()).filter((branch) => branch !== trunk);
|
|
180
|
+
|
|
181
|
+
const tipResults = yield* Effect.forEach(
|
|
182
|
+
candidates,
|
|
183
|
+
(branch) =>
|
|
184
|
+
git.revParse(branch).pipe(
|
|
185
|
+
Effect.map((oid) => [branch, oid] as const),
|
|
186
|
+
Effect.catchTag("GitError", () => Effect.succeed(null)),
|
|
187
|
+
),
|
|
188
|
+
{ concurrency: 8 },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const tipOwners = new Map<string, string[]>();
|
|
192
|
+
for (const result of tipResults) {
|
|
193
|
+
if (result === null) continue;
|
|
194
|
+
const [branch, oid] = result;
|
|
195
|
+
const owners = tipOwners.get(oid) ?? [];
|
|
196
|
+
owners.push(branch);
|
|
197
|
+
tipOwners.set(oid, owners);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const childOf = new Map<string, string>();
|
|
201
|
+
yield* Effect.forEach(
|
|
202
|
+
candidates,
|
|
203
|
+
(branch) =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const commits = yield* git
|
|
206
|
+
.firstParentUniqueCommits(branch, trunk, { limit: DETECT_COMMIT_LIMIT })
|
|
207
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed([])));
|
|
208
|
+
|
|
209
|
+
if (commits.length === 0) return;
|
|
210
|
+
|
|
211
|
+
let parent: string | null = null;
|
|
212
|
+
let ambiguous = false;
|
|
213
|
+
for (const oid of commits) {
|
|
214
|
+
const owners = (tipOwners.get(oid) ?? []).filter((owner) => owner !== branch);
|
|
215
|
+
if (owners.length > 1) {
|
|
216
|
+
ambiguous = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const [owner] = owners;
|
|
220
|
+
if (owner !== undefined) {
|
|
221
|
+
parent = owner;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (ambiguous || (commits.length >= DETECT_COMMIT_LIMIT && parent === null)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
childOf.set(branch, parent ?? trunk);
|
|
231
|
+
}),
|
|
232
|
+
{ concurrency: 8 },
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return childOf.size;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const average = (values: readonly number[]) =>
|
|
239
|
+
values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
240
|
+
|
|
241
|
+
const timeEffect = async (
|
|
242
|
+
cwd: string,
|
|
243
|
+
backend: GitBackend,
|
|
244
|
+
effect: Effect.Effect<unknown, unknown, GitService>,
|
|
245
|
+
) => {
|
|
246
|
+
const start = performance.now();
|
|
247
|
+
await runWithBackend(cwd, backend, effect);
|
|
248
|
+
return performance.now() - start;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const benchmarkFixture = async (cwd: string, backend: GitBackend) => {
|
|
252
|
+
const oldSamples: number[] = [];
|
|
253
|
+
const newSamples: number[] = [];
|
|
254
|
+
|
|
255
|
+
for (let i = 0; i < iterations; i++) {
|
|
256
|
+
oldSamples.push(await timeEffect(cwd, backend, oldDetect));
|
|
257
|
+
newSamples.push(await timeEffect(cwd, backend, newDetect));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
oldMs: average(oldSamples),
|
|
262
|
+
newMs: average(newSamples),
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const printRow = (
|
|
267
|
+
fixture: string,
|
|
268
|
+
backend: GitBackend,
|
|
269
|
+
result: { oldMs: number; newMs: number },
|
|
270
|
+
) => {
|
|
271
|
+
const faster =
|
|
272
|
+
result.oldMs === result.newMs
|
|
273
|
+
? "tie"
|
|
274
|
+
: result.oldMs < result.newMs
|
|
275
|
+
? `old x${(result.newMs / result.oldMs).toFixed(2)}`
|
|
276
|
+
: `new x${(result.oldMs / result.newMs).toFixed(2)}`;
|
|
277
|
+
|
|
278
|
+
console.log(
|
|
279
|
+
`${fixture.padEnd(12)} ${backend.padEnd(7)} ${result.oldMs.toFixed(2).padStart(9)} ${result.newMs.toFixed(2).padStart(9)} ${faster.padStart(10)}`,
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const fixtures = [
|
|
284
|
+
{ name: "linear", create: createLinearFixture },
|
|
285
|
+
{ name: "wide", create: createWideFixture },
|
|
286
|
+
{ name: "mixed", create: createMixedFixture },
|
|
287
|
+
] as const;
|
|
288
|
+
|
|
289
|
+
console.log(`Detect benchmark iterations: ${iterations}`);
|
|
290
|
+
console.log("fixture backend old(ms) new(ms) faster");
|
|
291
|
+
|
|
292
|
+
const cleanup: Array<() => Promise<void>> = [];
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
for (const fixture of fixtures) {
|
|
296
|
+
const cwd = await fixture.create();
|
|
297
|
+
cleanup.push(() => rm(cwd, { recursive: true, force: true }));
|
|
298
|
+
|
|
299
|
+
for (const backend of ["cli", "es-git"] satisfies readonly GitBackend[]) {
|
|
300
|
+
const result = await benchmarkFixture(cwd, backend);
|
|
301
|
+
printRow(fixture.name, backend, result);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
for (const dispose of cleanup.reverse()) {
|
|
306
|
+
await dispose();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// @effect-diagnostics effect/strictEffectProvide:off
|
|
2
|
+
// @effect-diagnostics effect/anyUnknownInErrorContext:off
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Effect, ServiceMap } from "effect";
|
|
7
|
+
import { GitService } from "../src/services/Git.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_GIT_BACKEND,
|
|
10
|
+
type GitBackend,
|
|
11
|
+
gitServiceLayerForBackend,
|
|
12
|
+
} from "../src/services/git-backend.js";
|
|
13
|
+
|
|
14
|
+
type ReadOperation = {
|
|
15
|
+
name: string;
|
|
16
|
+
run: Effect.Effect<unknown, unknown, GitService>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type MutationFixture = {
|
|
20
|
+
cwd: string;
|
|
21
|
+
cleanup?: () => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type MutationOperation = {
|
|
25
|
+
name: string;
|
|
26
|
+
setup: (sourceRepo: string) => Promise<MutationFixture>;
|
|
27
|
+
run: Effect.Effect<unknown, unknown, GitService>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const args = new Map(
|
|
31
|
+
process.argv.slice(2).map((arg) => {
|
|
32
|
+
const [key, value] = arg.split("=");
|
|
33
|
+
return [key ?? "", value ?? ""];
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const repoPath = args.get("--repo") ?? process.cwd();
|
|
38
|
+
const iterations = Number.parseInt(args.get("--iterations") ?? "5", 10);
|
|
39
|
+
|
|
40
|
+
const runCommand = async (cwd: string, command: string[]) => {
|
|
41
|
+
const proc = Bun.spawn(command, {
|
|
42
|
+
cwd,
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
});
|
|
46
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
47
|
+
new Response(proc.stdout).text(),
|
|
48
|
+
new Response(proc.stderr).text(),
|
|
49
|
+
proc.exited,
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
throw new Error(stderr.trim() || `${command.join(" ")} failed`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return stdout.trim();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const withCwd = async <A>(cwd: string, run: () => Promise<A>) => {
|
|
60
|
+
const previous = process.cwd();
|
|
61
|
+
process.chdir(cwd);
|
|
62
|
+
try {
|
|
63
|
+
return await run();
|
|
64
|
+
} finally {
|
|
65
|
+
process.chdir(previous);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const runWithBackend = async <A>(
|
|
70
|
+
cwd: string,
|
|
71
|
+
backend: GitBackend,
|
|
72
|
+
effect: Effect.Effect<A, unknown, GitService>,
|
|
73
|
+
) =>
|
|
74
|
+
withCwd(cwd, async () =>
|
|
75
|
+
Effect.runPromise(effect.pipe(Effect.provide(gitServiceLayerForBackend(backend)))),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const timeEffect = async <A>(
|
|
79
|
+
cwd: string,
|
|
80
|
+
backend: GitBackend,
|
|
81
|
+
effect: Effect.Effect<A, unknown, GitService>,
|
|
82
|
+
) => {
|
|
83
|
+
const start = performance.now();
|
|
84
|
+
await runWithBackend(cwd, backend, effect);
|
|
85
|
+
return performance.now() - start;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const createWorkClone = async (sourceRepo: string) => {
|
|
89
|
+
const root = await mkdtemp(join(tmpdir(), "stacked-git-bench-"));
|
|
90
|
+
const remote = join(root, "remote.git");
|
|
91
|
+
const work = join(root, "work");
|
|
92
|
+
|
|
93
|
+
await runCommand(root, ["git", "clone", "--bare", sourceRepo, remote]);
|
|
94
|
+
await runCommand(root, ["git", "clone", remote, work]);
|
|
95
|
+
await runCommand(work, ["git", "config", "user.name", "Stacked Bench"]);
|
|
96
|
+
await runCommand(work, ["git", "config", "user.email", "bench@example.com"]);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
root,
|
|
100
|
+
remote,
|
|
101
|
+
work,
|
|
102
|
+
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getCurrentBranch = async (cwd: string) =>
|
|
107
|
+
runCommand(cwd, ["git", "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
108
|
+
|
|
109
|
+
type GitApi = ServiceMap.Service.Shape<typeof GitService>;
|
|
110
|
+
|
|
111
|
+
const withGit = <A>(run: (git: GitApi) => Effect.Effect<A, unknown>) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const git = yield* GitService;
|
|
114
|
+
return yield* run(git);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const readOperations: ReadOperation[] = [
|
|
118
|
+
{ name: "currentBranch", run: withGit((git) => git.currentBranch()) },
|
|
119
|
+
{ name: "listBranches", run: withGit((git) => git.listBranches()) },
|
|
120
|
+
{ name: "branchExists", run: withGit((git) => git.branchExists("main")) },
|
|
121
|
+
{ name: "remoteDefaultBranch", run: withGit((git) => git.remoteDefaultBranch("origin")) },
|
|
122
|
+
{ name: "isClean", run: withGit((git) => git.isClean()) },
|
|
123
|
+
{ name: "revParse", run: withGit((git) => git.revParse("HEAD")) },
|
|
124
|
+
{ name: "mergeBase", run: withGit((git) => git.mergeBase("HEAD", "HEAD~1")) },
|
|
125
|
+
{ name: "isAncestor", run: withGit((git) => git.isAncestor("HEAD~1", "HEAD")) },
|
|
126
|
+
{
|
|
127
|
+
name: "firstParentUniqueCommits",
|
|
128
|
+
run: withGit((git) => git.firstParentUniqueCommits("HEAD", "HEAD~1", { limit: 20 })),
|
|
129
|
+
},
|
|
130
|
+
{ name: "log", run: withGit((git) => git.log("HEAD", { limit: 20, oneline: true })) },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const mutationOperations: MutationOperation[] = [
|
|
134
|
+
{
|
|
135
|
+
name: "create-delete-branch",
|
|
136
|
+
setup: async (sourceRepo) => {
|
|
137
|
+
const fixture = await createWorkClone(sourceRepo);
|
|
138
|
+
return { cwd: fixture.work, cleanup: fixture.cleanup };
|
|
139
|
+
},
|
|
140
|
+
run: Effect.gen(function* () {
|
|
141
|
+
const git = yield* GitService;
|
|
142
|
+
const base = yield* git.currentBranch();
|
|
143
|
+
yield* git.createBranch("bench-temp");
|
|
144
|
+
yield* git.checkout(base);
|
|
145
|
+
yield* git.deleteBranch("bench-temp", true);
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "checkout-roundtrip",
|
|
150
|
+
setup: async (sourceRepo) => {
|
|
151
|
+
const fixture = await createWorkClone(sourceRepo);
|
|
152
|
+
const base = await getCurrentBranch(fixture.work);
|
|
153
|
+
await runCommand(fixture.work, ["git", "checkout", "-b", "bench-checkout"]);
|
|
154
|
+
await runCommand(fixture.work, ["git", "checkout", base]);
|
|
155
|
+
return { cwd: fixture.work, cleanup: fixture.cleanup };
|
|
156
|
+
},
|
|
157
|
+
run: Effect.gen(function* () {
|
|
158
|
+
const git = yield* GitService;
|
|
159
|
+
const base = yield* git.currentBranch();
|
|
160
|
+
yield* git.checkout("bench-checkout");
|
|
161
|
+
yield* git.checkout(base);
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "fetch-origin",
|
|
166
|
+
setup: async (sourceRepo) => {
|
|
167
|
+
const fixture = await createWorkClone(sourceRepo);
|
|
168
|
+
const writer = join(fixture.root, "writer");
|
|
169
|
+
const base = await getCurrentBranch(fixture.work);
|
|
170
|
+
|
|
171
|
+
await runCommand(fixture.root, ["git", "clone", fixture.remote, writer]);
|
|
172
|
+
await runCommand(writer, ["git", "config", "user.name", "Stacked Bench"]);
|
|
173
|
+
await runCommand(writer, ["git", "config", "user.email", "bench@example.com"]);
|
|
174
|
+
await runCommand(writer, ["git", "checkout", base]);
|
|
175
|
+
await Bun.write(join(writer, "bench-fetch.txt"), `${Date.now()}\n`);
|
|
176
|
+
await runCommand(writer, ["git", "add", "bench-fetch.txt"]);
|
|
177
|
+
await runCommand(writer, ["git", "commit", "-m", "bench fetch"]);
|
|
178
|
+
await runCommand(writer, ["git", "push", "origin", base]);
|
|
179
|
+
|
|
180
|
+
return { cwd: fixture.work, cleanup: fixture.cleanup };
|
|
181
|
+
},
|
|
182
|
+
run: withGit((git) => git.fetch("origin")),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "push-delete-remote-branch",
|
|
186
|
+
setup: async (sourceRepo) => {
|
|
187
|
+
const fixture = await createWorkClone(sourceRepo);
|
|
188
|
+
await runCommand(fixture.work, ["git", "checkout", "-b", "bench-push"]);
|
|
189
|
+
await Bun.write(join(fixture.work, "bench-push.txt"), `${Date.now()}\n`);
|
|
190
|
+
await runCommand(fixture.work, ["git", "add", "bench-push.txt"]);
|
|
191
|
+
await runCommand(fixture.work, ["git", "commit", "-m", "bench push"]);
|
|
192
|
+
return { cwd: fixture.work, cleanup: fixture.cleanup };
|
|
193
|
+
},
|
|
194
|
+
run: Effect.gen(function* () {
|
|
195
|
+
const git = yield* GitService;
|
|
196
|
+
yield* git.push("bench-push", { force: true });
|
|
197
|
+
yield* git.deleteRemoteBranch("bench-push");
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const average = (values: readonly number[]) =>
|
|
203
|
+
values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
204
|
+
|
|
205
|
+
const benchmarkReads = async (backend: GitBackend) => {
|
|
206
|
+
const results: Array<{ name: string; ms: number }> = [];
|
|
207
|
+
|
|
208
|
+
for (const operation of readOperations) {
|
|
209
|
+
const samples: number[] = [];
|
|
210
|
+
for (let i = 0; i < iterations; i++) {
|
|
211
|
+
samples.push(await timeEffect(repoPath, backend, operation.run));
|
|
212
|
+
}
|
|
213
|
+
results.push({ name: operation.name, ms: average(samples) });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const benchmarkMutations = async (backend: GitBackend) => {
|
|
220
|
+
const results: Array<{ name: string; ms: number }> = [];
|
|
221
|
+
|
|
222
|
+
for (const operation of mutationOperations) {
|
|
223
|
+
const samples: number[] = [];
|
|
224
|
+
for (let i = 0; i < iterations; i++) {
|
|
225
|
+
const fixture = await operation.setup(repoPath);
|
|
226
|
+
try {
|
|
227
|
+
samples.push(await timeEffect(fixture.cwd, backend, operation.run));
|
|
228
|
+
} finally {
|
|
229
|
+
await fixture.cleanup?.();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
results.push({ name: operation.name, ms: average(samples) });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return results;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const printTable = (
|
|
239
|
+
title: string,
|
|
240
|
+
cliResults: Array<{ name: string; ms: number }>,
|
|
241
|
+
esGitResults: Array<{ name: string; ms: number }>,
|
|
242
|
+
) => {
|
|
243
|
+
console.log(`\n${title}`);
|
|
244
|
+
console.log("operation cli(ms) es-git(ms) faster");
|
|
245
|
+
|
|
246
|
+
for (const cliResult of cliResults) {
|
|
247
|
+
const esGitResult = esGitResults.find((entry) => entry.name === cliResult.name);
|
|
248
|
+
if (esGitResult === undefined) continue;
|
|
249
|
+
|
|
250
|
+
const faster =
|
|
251
|
+
cliResult.ms === esGitResult.ms
|
|
252
|
+
? "tie"
|
|
253
|
+
: cliResult.ms < esGitResult.ms
|
|
254
|
+
? `cli x${(esGitResult.ms / cliResult.ms).toFixed(2)}`
|
|
255
|
+
: `es-git x${(cliResult.ms / esGitResult.ms).toFixed(2)}`;
|
|
256
|
+
|
|
257
|
+
console.log(
|
|
258
|
+
`${cliResult.name.padEnd(25)} ${cliResult.ms.toFixed(2).padStart(8)} ${esGitResult.ms.toFixed(2).padStart(12)} ${faster.padStart(10)}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
console.log(`Benchmark repo: ${repoPath}`);
|
|
264
|
+
console.log(`Iterations: ${iterations}`);
|
|
265
|
+
console.log(`Default backend: ${DEFAULT_GIT_BACKEND}`);
|
|
266
|
+
|
|
267
|
+
const cliReads = await benchmarkReads("cli");
|
|
268
|
+
const esGitReads = await benchmarkReads("es-git");
|
|
269
|
+
printTable("Read Operations", cliReads, esGitReads);
|
|
270
|
+
|
|
271
|
+
const cliMutations = await benchmarkMutations("cli");
|
|
272
|
+
const esGitMutations = await benchmarkMutations("es-git");
|
|
273
|
+
printTable("Mutation Operations", cliMutations, esGitMutations);
|
package/src/commands/clean.ts
CHANGED
|
@@ -32,11 +32,11 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
let currentBranch = yield* git.currentBranch();
|
|
35
|
-
const
|
|
35
|
+
const entries = yield* stacks.listStacks();
|
|
36
36
|
|
|
37
37
|
// Fetch all PR statuses in parallel across all stacks
|
|
38
|
-
const allBranches =
|
|
39
|
-
stack.branches.map((branch) => ({ stackName, branch })),
|
|
38
|
+
const allBranches = entries.flatMap(({ name, stack }) =>
|
|
39
|
+
stack.branches.map((branch) => ({ stackName: name, branch })),
|
|
40
40
|
);
|
|
41
41
|
|
|
42
42
|
const prResults = yield* Effect.forEach(
|
|
@@ -53,7 +53,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
53
53
|
const toRemove: Array<{ stackName: string; branch: string }> = [];
|
|
54
54
|
const skippedMerged: Array<{ stackName: string; branch: string }> = [];
|
|
55
55
|
|
|
56
|
-
for (const
|
|
56
|
+
for (const { name: stackName, stack } of entries) {
|
|
57
57
|
let hitNonMerged = false;
|
|
58
58
|
for (const branch of stack.branches) {
|
|
59
59
|
const pr = prMap.get(branch) ?? null;
|
|
@@ -129,7 +129,8 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
129
129
|
),
|
|
130
130
|
);
|
|
131
131
|
if (deleted) {
|
|
132
|
-
yield* stacks.removeBranch(
|
|
132
|
+
yield* stacks.removeBranch(branch);
|
|
133
|
+
yield* stacks.markMergedBranches([branch]);
|
|
133
134
|
removed.push(branch);
|
|
134
135
|
yield* success(`Removed ${branch} from ${stackName}`);
|
|
135
136
|
}
|
package/src/commands/create.ts
CHANGED
|
@@ -80,20 +80,24 @@ export const create = Command.make("create", {
|
|
|
80
80
|
|
|
81
81
|
const existing = yield* stacks.findBranchStack(baseBranch);
|
|
82
82
|
let stackName = existing?.name ?? null;
|
|
83
|
+
let shouldAddBranch = true;
|
|
83
84
|
|
|
84
85
|
yield* git.createBranch(name, baseBranch);
|
|
85
86
|
|
|
86
87
|
if (stackName === null) {
|
|
87
88
|
if (baseBranch === trunk) {
|
|
88
89
|
stackName = name;
|
|
89
|
-
|
|
90
|
+
shouldAddBranch = false;
|
|
91
|
+
yield* stacks.createStack(name, [name]);
|
|
90
92
|
} else {
|
|
91
93
|
stackName = baseBranch;
|
|
92
94
|
yield* stacks.createStack(baseBranch, [baseBranch]);
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
if (shouldAddBranch) {
|
|
99
|
+
yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
|
|
100
|
+
}
|
|
97
101
|
|
|
98
102
|
if (json) {
|
|
99
103
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
package/src/commands/delete.ts
CHANGED
|
@@ -101,7 +101,7 @@ export const deleteCmd = Command.make("delete", {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
yield* git.deleteBranch(name, force);
|
|
104
|
-
yield* stacks.removeBranch(
|
|
104
|
+
yield* stacks.removeBranch(name);
|
|
105
105
|
|
|
106
106
|
if (willDeleteRemote) {
|
|
107
107
|
yield* git.deleteRemoteBranch(name).pipe(Effect.catchTag("GitError", () => Effect.void));
|