@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.
- package/package.json +45 -0
- package/src/app.ts +153 -0
- package/src/diagnose-stream.ts +305 -0
- package/src/env.ts +35 -0
- package/src/event-discovery.ts +355 -0
- package/src/event-driven-test.ts +72 -0
- package/src/index.ts +223 -0
- package/src/opencode.ts +278 -0
- package/src/prompt.ts +127 -0
- package/src/router/agents.ts +57 -0
- package/src/router/base.ts +10 -0
- package/src/router/commands.ts +57 -0
- package/src/router/context.ts +22 -0
- package/src/router/diffs.ts +46 -0
- package/src/router/index.ts +24 -0
- package/src/router/models.ts +55 -0
- package/src/router/permissions.ts +28 -0
- package/src/router/projects.ts +175 -0
- package/src/router/sessions.ts +316 -0
- package/src/router/snapshot.ts +9 -0
- package/src/server.ts +15 -0
- package/src/spawn-opencode.ts +166 -0
- package/src/sprite-configure-services.ts +302 -0
- package/src/sprite-sync.ts +413 -0
- package/src/sprites.ts +328 -0
- package/src/start-server.ts +49 -0
- package/src/state-stream.ts +711 -0
- package/src/transcribe.ts +100 -0
- package/src/types.ts +430 -0
- package/src/voice-prompt.ts +222 -0
- package/src/worktree-name.ts +62 -0
- package/src/worktree.ts +549 -0
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|