@bprp/flockcode 0.0.2

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.
@@ -0,0 +1,549 @@
1
+ // Typed wrapper around git-worktree operations, driven by a worktree.toml config file.
2
+ // Handles creating, listing, merging, and removing worktrees with automatic
3
+ // post-checkout hook execution (e.g. dependency installation).
4
+
5
+ import { $ } from "bun";
6
+ import { copyFile, mkdir } from "node:fs/promises";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Schema for the `worktree.toml` configuration file. */
14
+ export interface WorktreeConfig {
15
+ hooks?: {
16
+ /** Command executed after a new worktree is checked out (e.g. `"bun install"`). */
17
+ post_checkout?: string;
18
+ };
19
+ /**
20
+ * Glob patterns for files to copy from the repo root into each new worktree.
21
+ * Patterns are relative to the repo root (e.g. `".env"`, `"config/*.json"`).
22
+ * Patterns that match no files are silently skipped.
23
+ */
24
+ include?: string[];
25
+ }
26
+
27
+ /** A single worktree as reported by `git worktree list --porcelain`. */
28
+ export interface WorktreeEntry {
29
+ /** Absolute path to the worktree directory. */
30
+ path: string;
31
+ /** HEAD commit hash. */
32
+ head: string;
33
+ /** Branch ref (e.g. `refs/heads/main`), or `null` for detached HEAD. */
34
+ branch: string | null;
35
+ /** Whether this is the bare/main worktree. */
36
+ bare: boolean;
37
+ /** Whether the worktree directory is missing from disk. */
38
+ prunable: boolean;
39
+ }
40
+
41
+ /** Options for {@link WorktreeDriver.create}. */
42
+ export interface CreateOptions {
43
+ /** Base branch / commit to create the worktree from. Defaults to HEAD. */
44
+ base?: string;
45
+ /**
46
+ * Explicit filesystem path for the new worktree.
47
+ * Defaults to `../<branch>` relative to the repo root.
48
+ */
49
+ path?: string;
50
+ /** Skip running the post_checkout hook. */
51
+ skipHooks?: boolean;
52
+ }
53
+
54
+ /** Options for {@link WorktreeDriver.merge}. */
55
+ export interface MergeOptions {
56
+ /** Branch to merge *into*. Defaults to the repo's current branch. */
57
+ into?: string;
58
+ /** Use `--squash` to collapse all commits into a single merge commit. */
59
+ squash?: boolean;
60
+ /** Use `--no-ff` to force a merge commit even for fast-forward merges. Defaults to `true`. */
61
+ noFf?: boolean;
62
+ }
63
+
64
+ /** Result of a merge dry-run check via {@link WorktreeDriver.canMerge}. */
65
+ export interface MergeCheck {
66
+ /** Whether the merge can proceed without conflicts. */
67
+ ok: boolean;
68
+ /** Human-readable reason when `ok` is false. */
69
+ reason?: string;
70
+ /** List of conflicting file paths (empty if no conflicts). */
71
+ conflictingFiles: string[];
72
+ }
73
+
74
+ /** Options for {@link WorktreeDriver.remove}. */
75
+ export interface RemoveOptions {
76
+ /** Force removal even if the worktree contains modifications. */
77
+ force?: boolean;
78
+ /** Also delete the branch after removing the worktree. */
79
+ deleteBranch?: boolean;
80
+ }
81
+
82
+ /** Structured error thrown when a git command fails. */
83
+ export class WorktreeError extends Error {
84
+ constructor(
85
+ message: string,
86
+ public readonly command: string,
87
+ public readonly exitCode: number,
88
+ public readonly stderr: string,
89
+ ) {
90
+ super(message);
91
+ this.name = "WorktreeError";
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Driver
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Manages git worktrees for a repository, driven by an optional `worktree.toml`
101
+ * config file that can specify lifecycle hooks (e.g. post-checkout commands).
102
+ *
103
+ * ```ts
104
+ * const driver = await WorktreeDriver.open("/path/to/repo");
105
+ * await driver.create("feat/cool-thing");
106
+ * const trees = await driver.list();
107
+ * await driver.merge("feat/cool-thing", { squash: true });
108
+ * await driver.remove("feat/cool-thing", { deleteBranch: true });
109
+ * ```
110
+ */
111
+ export class WorktreeDriver {
112
+ #repoRoot: string;
113
+ #config: WorktreeConfig;
114
+
115
+ private constructor(repoRoot: string, config: WorktreeConfig) {
116
+ this.#repoRoot = repoRoot;
117
+ this.#config = config;
118
+ }
119
+
120
+ /** The absolute path to the repository root. */
121
+ get repoRoot(): string {
122
+ return this.#repoRoot;
123
+ }
124
+
125
+ /** The parsed worktree.toml configuration (empty object if none found). */
126
+ get config(): Readonly<WorktreeConfig> {
127
+ return this.#config;
128
+ }
129
+
130
+ /**
131
+ * Open a repository directory, reading `worktree.toml` if present.
132
+ * @param repoRoot Absolute path to the git repository root.
133
+ */
134
+ static async open(repoRoot: string): Promise<WorktreeDriver> {
135
+ const config = await readConfig(repoRoot);
136
+ return new WorktreeDriver(repoRoot, config);
137
+ }
138
+
139
+ /**
140
+ * Create a new worktree (and its corresponding branch).
141
+ *
142
+ * Runs `git worktree add -b <branch> <path> [base]`, then executes the
143
+ * `post_checkout` hook from `worktree.toml` inside the new worktree directory.
144
+ *
145
+ * @throws {Error} If `skipHooks` is false and no `post_checkout` hook is configured.
146
+ */
147
+ async create(branch: string, options: CreateOptions = {}): Promise<WorktreeEntry> {
148
+ const { base, skipHooks = false } = options;
149
+ const worktreePath = options.path ?? this.#defaultPath(branch);
150
+
151
+ if (!skipHooks && !this.#config.hooks?.post_checkout) {
152
+ throw new Error(
153
+ "No post_checkout hook configured. Add a [hooks] section with post_checkout to worktree.toml, " +
154
+ "or pass { skipHooks: true } to skip the hook.",
155
+ );
156
+ }
157
+
158
+ // Build the git command
159
+ const args = ["git", "worktree", "add", "-b", branch, worktreePath];
160
+ if (base) args.push(base);
161
+
162
+ await this.#exec(args);
163
+
164
+ // Copy included files before running hooks (hooks may depend on them)
165
+ await this.#copyIncludes(worktreePath);
166
+
167
+ // Run post-checkout hook if configured
168
+ if (!skipHooks) {
169
+ await this.#runHook("post_checkout", worktreePath);
170
+ }
171
+
172
+ // Return the newly created entry
173
+ const entries = await this.list();
174
+ const entry = entries.find((e) => e.path === worktreePath);
175
+ if (!entry) {
176
+ throw new Error(`Worktree created but not found in list: ${worktreePath}`);
177
+ }
178
+ return entry;
179
+ }
180
+
181
+ /**
182
+ * List all worktrees for this repository.
183
+ *
184
+ * Parses the stable `--porcelain` output format from `git worktree list`.
185
+ */
186
+ async list(): Promise<WorktreeEntry[]> {
187
+ const output = await this.#execText(["git", "worktree", "list", "--porcelain"]);
188
+ return parsePorcelain(output);
189
+ }
190
+
191
+ /**
192
+ * Resolve the branch name for a worktree path.
193
+ *
194
+ * Returns the short branch name (e.g. `worktree/abc123`) or `null` if the
195
+ * path doesn't correspond to a known worktree or is in detached HEAD state.
196
+ */
197
+ async branchForPath(worktreePath: string): Promise<string | null> {
198
+ const entries = await this.list();
199
+ const entry = entries.find((e) => e.path === worktreePath);
200
+ if (!entry?.branch) return null;
201
+ return entry.branch.replace(/^refs\/heads\//, "");
202
+ }
203
+
204
+ /**
205
+ * Check whether a branch has been merged into the target branch.
206
+ *
207
+ * Uses `git branch --merged <target>` which works reliably for real merges
208
+ * and fast-forwards (but NOT for squash merges).
209
+ */
210
+ async isMerged(branch: string, into: string = "main"): Promise<boolean> {
211
+ const output = await this.#execText(["git", "branch", "--merged", into]);
212
+ const mergedBranches = output
213
+ .split("\n")
214
+ // Strip leading markers: * (current branch), + (checked out in linked worktree)
215
+ .map((line) => line.replace(/^[*+]?\s+/, "").trim())
216
+ .filter(Boolean);
217
+ return mergedBranches.includes(branch);
218
+ }
219
+
220
+ /**
221
+ * Check whether a worktree branch has unmerged commits relative to a target.
222
+ *
223
+ * Returns `true` if there are commits on `branch` that aren't reachable from `into`.
224
+ */
225
+ async hasUnmergedCommits(branch: string, into: string = "main"): Promise<boolean> {
226
+ const output = await this.#execText(["git", "log", `${into}..${branch}`, "--oneline"]);
227
+ return output.trim().length > 0;
228
+ }
229
+
230
+ /**
231
+ * Check whether a worktree directory has uncommitted changes (staged or unstaged).
232
+ *
233
+ * Runs `git status --porcelain` in the worktree directory. Returns `true` if
234
+ * there is any output (meaning dirty working tree or staged changes).
235
+ */
236
+ async hasUncommittedChanges(worktreePath: string): Promise<boolean> {
237
+ const output = await this.#execTextIn(worktreePath, ["git", "status", "--porcelain"]);
238
+ return output.trim().length > 0;
239
+ }
240
+
241
+ /**
242
+ * Dry-run a merge to check for conflicts without modifying the working tree.
243
+ *
244
+ * Attempts `git merge --no-commit --no-ff`, inspects the result, then aborts.
245
+ * The main worktree is left clean regardless of the outcome.
246
+ */
247
+ async canMerge(branch: string, into: string = "main"): Promise<MergeCheck> {
248
+ // Ensure we're on the target branch
249
+ await this.#exec(["git", "checkout", into]);
250
+
251
+ try {
252
+ await this.#exec(["git", "merge", "--no-commit", "--no-ff", branch]);
253
+ // Merge succeeded — abort to leave tree clean
254
+ await this.#exec(["git", "merge", "--abort"]);
255
+ return { ok: true, conflictingFiles: [] };
256
+ } catch (err) {
257
+ // Merge failed — check for conflicts, then abort
258
+ let conflictingFiles: string[] = [];
259
+ try {
260
+ const output = await this.#execText(["git", "diff", "--name-only", "--diff-filter=U"]);
261
+ conflictingFiles = output.split("\n").map((f) => f.trim()).filter(Boolean);
262
+ } catch {
263
+ // Could not list conflicts — that's fine, we still report the failure
264
+ }
265
+ try {
266
+ await this.#exec(["git", "merge", "--abort"]);
267
+ } catch {
268
+ // Abort may fail if merge didn't start — ignore
269
+ }
270
+
271
+ const reason = conflictingFiles.length > 0
272
+ ? `Merge conflicts in: ${conflictingFiles.join(", ")}`
273
+ : err instanceof WorktreeError
274
+ ? err.stderr
275
+ : "Merge would fail";
276
+
277
+ return { ok: false, reason, conflictingFiles };
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Merge a worktree's branch into another branch.
283
+ *
284
+ * This does NOT operate inside the worktree itself — it runs from the main
285
+ * repo root and merges `branch` into the target (default: current branch).
286
+ * Defaults to `--no-ff` so that merge state is discoverable via
287
+ * `git branch --merged`.
288
+ */
289
+ async merge(branch: string, options: MergeOptions = {}): Promise<void> {
290
+ const { into, squash = false, noFf = !squash } = options;
291
+
292
+ // If merging into a specific branch, check it out first in the main worktree
293
+ if (into) {
294
+ await this.#exec(["git", "checkout", into]);
295
+ }
296
+
297
+ const args = ["git", "merge", "--no-edit"];
298
+ if (squash) args.push("--squash");
299
+ // --squash and --no-ff are mutually exclusive in git
300
+ if (noFf && !squash) args.push("--no-ff");
301
+ args.push(branch);
302
+
303
+ await this.#exec(args);
304
+
305
+ // If we squashed, we need to commit (git merge --squash doesn't auto-commit)
306
+ if (squash) {
307
+ await this.#exec(["git", "commit", "--no-edit"]);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Remove a worktree and optionally delete its branch.
313
+ *
314
+ * Runs `git worktree remove` followed by `git branch -d` (or `-D` if forced).
315
+ */
316
+ async remove(branchOrPath: string, options: RemoveOptions = {}): Promise<void> {
317
+ const { force = false, deleteBranch = false } = options;
318
+
319
+ // Resolve which worktree to remove — accept either a path or branch name
320
+ const entries = await this.list();
321
+ const entry = this.#findEntry(entries, branchOrPath);
322
+
323
+ if (!entry) {
324
+ throw new Error(
325
+ `No worktree found matching "${branchOrPath}". ` +
326
+ `Known worktrees: ${entries.map((e) => e.path).join(", ")}`,
327
+ );
328
+ }
329
+
330
+ // Remove the worktree
331
+ const removeArgs = ["git", "worktree", "remove", entry.path];
332
+ if (force) removeArgs.push("--force");
333
+ await this.#exec(removeArgs);
334
+
335
+ // Optionally delete the branch
336
+ if (deleteBranch && entry.branch) {
337
+ const branchName = entry.branch.replace(/^refs\/heads\//, "");
338
+ const deleteFlag = force ? "-D" : "-d";
339
+ await this.#exec(["git", "branch", deleteFlag, branchName]);
340
+ }
341
+ }
342
+
343
+ // -------------------------------------------------------------------------
344
+ // Private helpers
345
+ // -------------------------------------------------------------------------
346
+
347
+ /**
348
+ * Copy files matching `include` glob patterns from the repo root into the worktree.
349
+ * Patterns that match no files are silently skipped so optional entries like
350
+ * `.env` don't break creation when absent.
351
+ */
352
+ async #copyIncludes(worktreePath: string): Promise<void> {
353
+ const patterns = this.#config.include;
354
+ if (!patterns?.length) return;
355
+
356
+ for (const pattern of patterns) {
357
+ const glob = new Bun.Glob(pattern);
358
+ for await (const relPath of glob.scan({ cwd: this.#repoRoot, dot: true })) {
359
+ const src = resolve(this.#repoRoot, relPath);
360
+ const dest = resolve(worktreePath, relPath);
361
+
362
+ // Ensure the destination directory exists (for nested paths)
363
+ await mkdir(dirname(dest), { recursive: true });
364
+ await copyFile(src, dest);
365
+ }
366
+ }
367
+ }
368
+
369
+ /** Derive a default worktree path from the branch name: `../<branch-slug>` */
370
+ #defaultPath(branch: string): string {
371
+ // Place sibling to the repo root, using the branch name (slashes → dashes)
372
+ const slug = branch.replace(/\//g, "-");
373
+ return `${this.#repoRoot}/../${slug}`;
374
+ }
375
+
376
+ /** Find a worktree entry by path or branch name. */
377
+ #findEntry(entries: WorktreeEntry[], branchOrPath: string): WorktreeEntry | undefined {
378
+ return entries.find((e) => {
379
+ if (e.path === branchOrPath) return true;
380
+ if (e.branch === branchOrPath) return true;
381
+ if (e.branch === `refs/heads/${branchOrPath}`) return true;
382
+ // Also match the slug form
383
+ const slug = branchOrPath.replace(/\//g, "-");
384
+ return e.path.endsWith(`/${slug}`);
385
+ });
386
+ }
387
+
388
+ /**
389
+ * Execute a git command in the repo root. Throws {@link WorktreeError} on failure.
390
+ * Uses `Bun.spawn` for precise argument passing (no shell interpolation).
391
+ */
392
+ async #exec(args: string[]): Promise<void> {
393
+ const proc = Bun.spawn(args, {
394
+ cwd: this.#repoRoot,
395
+ stdout: "pipe",
396
+ stderr: "pipe",
397
+ });
398
+ const exitCode = await proc.exited;
399
+ if (exitCode !== 0) {
400
+ const stderr = await new Response(proc.stderr).text();
401
+ throw new WorktreeError(
402
+ `Command failed: ${args.join(" ")}`,
403
+ args.join(" "),
404
+ exitCode,
405
+ stderr,
406
+ );
407
+ }
408
+ }
409
+
410
+ /** Execute a git command and return stdout as a string. */
411
+ async #execText(args: string[]): Promise<string> {
412
+ return this.#execTextIn(this.#repoRoot, args);
413
+ }
414
+
415
+ /** Execute a git command in a specific directory and return stdout as a string. */
416
+ async #execTextIn(cwd: string, args: string[]): Promise<string> {
417
+ const proc = Bun.spawn(args, {
418
+ cwd,
419
+ stdout: "pipe",
420
+ stderr: "pipe",
421
+ });
422
+ const [exitCode, stdout, stderr] = await Promise.all([
423
+ proc.exited,
424
+ new Response(proc.stdout).text(),
425
+ new Response(proc.stderr).text(),
426
+ ]);
427
+ if (exitCode !== 0) {
428
+ throw new WorktreeError(
429
+ `Command failed: ${args.join(" ")}`,
430
+ args.join(" "),
431
+ exitCode,
432
+ stderr,
433
+ );
434
+ }
435
+ return stdout;
436
+ }
437
+
438
+ /**
439
+ * Run a hook command from the config inside the given directory.
440
+ * Uses Bun's `$` shell so the hook string is interpreted as a shell command
441
+ * (supports pipes, env vars, etc.).
442
+ */
443
+ async #runHook(hook: keyof NonNullable<WorktreeConfig["hooks"]>, cwd: string): Promise<void> {
444
+ const command = this.#config.hooks?.[hook];
445
+ if (!command) return;
446
+
447
+ const result = await $`${{ raw: command }}`.quiet().nothrow().cwd(cwd);
448
+ if (result.exitCode !== 0) {
449
+ throw new WorktreeError(
450
+ `Hook "${hook}" failed: ${command}`,
451
+ command,
452
+ result.exitCode,
453
+ result.stderr.toString(),
454
+ );
455
+ }
456
+ }
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Config parsing
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /** Read and parse `worktree.toml` from the repo root. Returns `{}` if absent. */
464
+ async function readConfig(repoRoot: string): Promise<WorktreeConfig> {
465
+ const configPath = `${repoRoot}/worktree.toml`;
466
+ const file = Bun.file(configPath);
467
+ const exists = await file.exists();
468
+ if (!exists) return {};
469
+
470
+ const text = await file.text();
471
+ const raw = Bun.TOML.parse(text) as Record<string, unknown>;
472
+ return validateConfig(raw);
473
+ }
474
+
475
+ /** Minimal runtime validation of the config shape. */
476
+ function validateConfig(raw: Record<string, unknown>): WorktreeConfig {
477
+ const config: WorktreeConfig = {};
478
+
479
+ if (raw.hooks != null && typeof raw.hooks === "object") {
480
+ const hooks = raw.hooks as Record<string, unknown>;
481
+ config.hooks = {};
482
+ if (typeof hooks.post_checkout === "string") {
483
+ config.hooks.post_checkout = hooks.post_checkout;
484
+ }
485
+ }
486
+
487
+ if (Array.isArray(raw.include)) {
488
+ config.include = raw.include.filter(
489
+ (f): f is string => typeof f === "string",
490
+ );
491
+ }
492
+
493
+ return config;
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Porcelain output parsing
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /**
501
+ * Parse the output of `git worktree list --porcelain`.
502
+ *
503
+ * The format is a series of blocks separated by blank lines. Each block has:
504
+ * ```
505
+ * worktree /absolute/path
506
+ * HEAD <sha>
507
+ * branch refs/heads/<name> (or "detached" on its own line)
508
+ * ```
509
+ * Bare worktrees have a `bare` line. Prunable ones have a `prunable` line.
510
+ */
511
+ function parsePorcelain(output: string): WorktreeEntry[] {
512
+ const entries: WorktreeEntry[] = [];
513
+ // Split into blocks by double-newline (or by single blank lines in the stream)
514
+ const blocks = output.trim().split(/\n\n+/);
515
+
516
+ for (const block of blocks) {
517
+ if (!block.trim()) continue;
518
+
519
+ const lines = block.trim().split("\n");
520
+ const entry: Partial<WorktreeEntry> = {
521
+ bare: false,
522
+ prunable: false,
523
+ branch: null,
524
+ };
525
+
526
+ for (const line of lines) {
527
+ if (line.startsWith("worktree ")) {
528
+ entry.path = line.slice("worktree ".length);
529
+ } else if (line.startsWith("HEAD ")) {
530
+ entry.head = line.slice("HEAD ".length);
531
+ } else if (line.startsWith("branch ")) {
532
+ entry.branch = line.slice("branch ".length);
533
+ } else if (line === "detached") {
534
+ entry.branch = null;
535
+ } else if (line === "bare") {
536
+ entry.bare = true;
537
+ } else if (line.startsWith("prunable ")) {
538
+ entry.prunable = true;
539
+ }
540
+ }
541
+
542
+ // Only include entries that have the minimum required fields
543
+ if (entry.path && entry.head) {
544
+ entries.push(entry as WorktreeEntry);
545
+ }
546
+ }
547
+
548
+ return entries;
549
+ }