monkeyshape 0.1.0 → 0.2.0

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,616 @@
1
+ /**
2
+ * `mshape track new | list | current | rm` against git and the filesystem. Every path comes
3
+ * from `src/core/layout.ts`, and the main checkout from git's common directory, so each
4
+ * command works the same from the main checkout and from any worktree. The decisions are in
5
+ * `src/core/tracks.ts`.
6
+ */
7
+ import { lstat, mkdir, readdir, readlink, realpath, symlink, unlink } from "node:fs/promises";
8
+ import { posix } from "node:path";
9
+ import { Effect } from "effect";
10
+ import { parseHandoff, stubHandoff } from "../core/handoff.ts";
11
+ import {
12
+ HANDOFFS_LINK,
13
+ handoffPath,
14
+ handoffsLink,
15
+ handoffsStore,
16
+ MAIN_BRANCH,
17
+ mainCheckout,
18
+ TRACK_NAME,
19
+ trackBranch,
20
+ trackOf,
21
+ worktreePath,
22
+ worktreesDir,
23
+ } from "../core/layout.ts";
24
+ import {
25
+ type BeadsCheck,
26
+ currentTrack,
27
+ describeStep,
28
+ formatTracks,
29
+ nameProblem,
30
+ openBeadsByTrack,
31
+ parseWorktrees,
32
+ planRm,
33
+ type RmStep,
34
+ sharedPathProblems,
35
+ type TrackRow,
36
+ type Worktree,
37
+ } from "../core/tracks.ts";
38
+ import { TrackError, WorkspaceError } from "../schemas/errors.ts";
39
+ import { loadConfig } from "./config.ts";
40
+ import { beadRows } from "./workspace.ts";
41
+
42
+ export const refuse = (reason: string) => Effect.fail(new TrackError({ reason }));
43
+
44
+ export const git = (
45
+ cwd: string,
46
+ args: ReadonlyArray<string>,
47
+ /** `env` replaces the environment whole: the composition root builds it. */
48
+ options: {
49
+ readonly env?: Readonly<Record<string, string | undefined>>;
50
+ readonly timeout?: number;
51
+ readonly stdin?: string;
52
+ } = {},
53
+ ) =>
54
+ Effect.try({
55
+ try: () => {
56
+ const child = Bun.spawnSync(["git", ...args], {
57
+ cwd,
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ ...(options.env === undefined ? {} : { env: { ...options.env } }),
61
+ ...(options.timeout === undefined ? {} : { timeout: options.timeout }),
62
+ ...(options.stdin === undefined ? {} : { stdin: Buffer.from(options.stdin) }),
63
+ });
64
+ return {
65
+ stdout: child.stdout.toString(),
66
+ stderr: child.stderr.toString().trim(),
67
+ code: child.exitCode,
68
+ };
69
+ },
70
+ catch: (cause) =>
71
+ new WorkspaceError({
72
+ operation: "run",
73
+ path: `git ${args.join(" ")}`,
74
+ reason: String(cause),
75
+ }),
76
+ });
77
+
78
+ /** Git's stdout, trimmed; a non-zero exit is a TrackError carrying git's own message. */
79
+ export const gitOk = (cwd: string, args: ReadonlyArray<string>) =>
80
+ Effect.flatMap(git(cwd, args), (result) =>
81
+ result.code === 0
82
+ ? Effect.succeed(result.stdout.trim())
83
+ : refuse(`\`git ${args.join(" ")}\` failed: ${result.stderr || `exit ${result.code}`}`),
84
+ );
85
+
86
+ export const lexists = (path: string) =>
87
+ Effect.promise(() =>
88
+ lstat(path).then(
89
+ () => true,
90
+ () => false,
91
+ ),
92
+ );
93
+
94
+ /** Symlinks resolved. A missing path stays as is. */
95
+ const real = (path: string) => Effect.promise(() => realpath(path).catch(() => path));
96
+
97
+ /** A branch as a full ref: a short name would resolve to a same-named tag first. */
98
+ const head = (branch: string): string => `refs/heads/${branch}`;
99
+
100
+ /**
101
+ * Paths from git, in the layout's terms: resolved, then put back under `<repo>/.agents/worktrees`
102
+ * when they are inside it, even if that directory is a symlink (to another disk, say).
103
+ */
104
+ const canonical = (repo: string) =>
105
+ Effect.map(
106
+ real(worktreesDir(repo)),
107
+ (resolved) => (path: string) =>
108
+ Effect.map(real(path), (path) =>
109
+ path.startsWith(`${resolved}/`)
110
+ ? `${worktreesDir(repo)}${path.slice(resolved.length)}`
111
+ : path,
112
+ ),
113
+ );
114
+
115
+ export const fs = <A>(operation: string, path: string, run: () => Promise<A>) =>
116
+ Effect.tryPromise({
117
+ try: run,
118
+ catch: (cause) => new WorkspaceError({ operation, path, reason: String(cause) }),
119
+ });
120
+
121
+ /** Git's common directory and the main checkout, from git: never by walking up directories. */
122
+ export const locate = (cwd: string) =>
123
+ Effect.gen(function* () {
124
+ const found = yield* git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
125
+ if (found.code !== 0) return yield* refuse(`${cwd} is not inside a git repository`);
126
+ const common = yield* real(found.stdout.trim());
127
+ return { common, repo: mainCheckout(common) };
128
+ });
129
+
130
+ export const worktrees = (repo: string) =>
131
+ Effect.gen(function* () {
132
+ const listed = parseWorktrees(yield* gitOk(repo, ["worktree", "list", "--porcelain"]));
133
+ const resolve = yield* canonical(repo);
134
+ return yield* Effect.forEach(listed, (worktree) =>
135
+ Effect.map(resolve(worktree.path), (path): Worktree => ({ ...worktree, path })),
136
+ );
137
+ });
138
+
139
+ const branchExists = (repo: string, branch: string) =>
140
+ Effect.map(
141
+ git(repo, ["show-ref", "--verify", "--quiet", head(branch)]),
142
+ (result) => result.code === 0,
143
+ );
144
+
145
+ const requireMain = (repo: string) =>
146
+ Effect.flatMap(branchExists(repo, MAIN_BRANCH), (found) =>
147
+ found
148
+ ? Effect.void
149
+ : refuse(`this repository has no \`${MAIN_BRANCH}\` branch: tracks branch from it`),
150
+ );
151
+
152
+ /**
153
+ * What counts as merged: `main`, and its upstream (`origin/main`) when that ref exists, so a PR
154
+ * merged on the remote counts before anyone pulls `main`. Full refs, for the reason `head` gives.
155
+ */
156
+ const mergeTargets = (repo: string) =>
157
+ Effect.gen(function* () {
158
+ const found = yield* git(repo, ["for-each-ref", "--format=%(upstream)", head(MAIN_BRANCH)]);
159
+ const upstream = found.code === 0 ? found.stdout.trim() : "";
160
+ const present =
161
+ upstream !== "" &&
162
+ (yield* git(repo, ["show-ref", "--verify", "--quiet", upstream])).code === 0;
163
+ return present ? [head(MAIN_BRANCH), upstream] : [head(MAIN_BRANCH)];
164
+ });
165
+
166
+ /** `refs/heads/main` → `main`, `refs/remotes/origin/main` → `origin/main`: as people say them. */
167
+ const shortRef = (ref: string): string => ref.replace(/^refs\/(heads|remotes)\//, "");
168
+
169
+ const FETCH_TIMEOUT_MS = 30_000;
170
+
171
+ /**
172
+ * Brings `main`'s upstream up to date, so `rm` sees a merge done on the remote. It only moves
173
+ * remote-tracking refs, never a branch. `env` is from the composition root, with terminal
174
+ * prompts off; SSH runs in batch mode unless the user configured their own SSH command. It
175
+ * gives up after a while (git is killed; an ssh it started may outlive it briefly). A failure is a note, and the
176
+ * judgement falls back to the refs already here.
177
+ */
178
+ const fetchUpstream = (repo: string, env: Readonly<Record<string, string | undefined>>) =>
179
+ Effect.gen(function* () {
180
+ const remote = (yield* git(repo, ["config", `branch.${MAIN_BRANCH}.remote`])).stdout.trim();
181
+ const merge = (yield* git(repo, ["config", `branch.${MAIN_BRANCH}.merge`])).stdout.trim();
182
+ if (remote === "" || merge === "") return undefined;
183
+ // SSH batch mode only when the user has no SSH command of their own: an environment
184
+ // variable overrides `core.sshCommand`, which picks deploy keys and accounts.
185
+ const configured = (yield* git(repo, ["config", "--get", "core.sshCommand"])).stdout.trim();
186
+ const ownSsh =
187
+ configured !== "" || env["GIT_SSH_COMMAND"] !== undefined || env["GIT_SSH"] !== undefined;
188
+ const fetched = yield* git(repo, ["fetch", "--quiet", remote, merge], {
189
+ env: ownSsh ? env : { ...env, GIT_SSH_COMMAND: "ssh -o BatchMode=yes" },
190
+ timeout: FETCH_TIMEOUT_MS,
191
+ });
192
+ const why =
193
+ fetched.code === null
194
+ ? `timed out after ${FETCH_TIMEOUT_MS / 1000}s`
195
+ : fetched.stderr || `exit ${fetched.code}`;
196
+ return fetched.code === 0
197
+ ? undefined
198
+ : `couldn't fetch \`${MAIN_BRANCH}\` from \`${remote}\` (${why}), so merges done there since the last fetch aren't seen`;
199
+ });
200
+
201
+ /** `git patch-id --stable` of a patch text: one id per commit, or one for a bare diff. */
202
+ const patchIds = (repo: string, patch: string) =>
203
+ Effect.map(git(repo, ["patch-id", "--stable"], { stdin: patch }), (result) =>
204
+ result.stdout
205
+ .split("\n")
206
+ .map((line) => line.split(" ")[0] ?? "")
207
+ .filter((id) => id !== ""),
208
+ );
209
+
210
+ /**
211
+ * Whether merging the branch into `target` would change nothing, so its work is all there,
212
+ * whichever way it got there. A clean merge is exact about content, so its answer is final
213
+ * both ways. `unknown` when git can't say: before 2.38 (no `merge-tree --write-tree`), or on a
214
+ * conflict.
215
+ */
216
+ const contentMerged = (repo: string, branch: string, target: string) =>
217
+ Effect.gen(function* () {
218
+ const merged = yield* git(repo, ["merge-tree", "--write-tree", target, head(branch)]);
219
+ const tree = yield* git(repo, ["rev-parse", `${target}^{tree}`]);
220
+ if (merged.code !== 0 || tree.code !== 0) return "unknown" as const;
221
+ return merged.stdout.split("\n")[0] === tree.stdout.trim() ? ("yes" as const) : ("no" as const);
222
+ });
223
+
224
+ /**
225
+ * The fallback when `contentMerged` can't say: the branch's changes are already in `target`
226
+ * as patches, the whole change as one commit (a squash) or every commit one by one (a
227
+ * rebase). Patch ids keep their default three lines of context. Without context they ignore
228
+ * line numbers too, so one fixed copy of a repeated line would match another copy fixed in
229
+ * `main`, and `rm` would delete unmerged work. With context, a squash after `main` edited a
230
+ * nearby line isn't recognised, and `rm` asks for --force instead. Any git version.
231
+ */
232
+ export const patchMerged = (repo: string, branch: string, target: string) =>
233
+ Effect.gen(function* () {
234
+ const found = yield* git(repo, ["merge-base", target, head(branch)]);
235
+ if (found.code !== 0) return false;
236
+ const base = found.stdout.trim();
237
+ const since = (tip: string) =>
238
+ gitOk(repo, ["log", "-p", "--no-merges", "--format=commit %H", `${base}..${tip}`]);
239
+ const landed = new Set(yield* patchIds(repo, yield* since(target)));
240
+ const whole = yield* patchIds(repo, yield* gitOk(repo, ["diff", base, head(branch)]));
241
+ if (whole.length > 0 && whole.every((id) => landed.has(id))) return true;
242
+ const each = yield* patchIds(repo, yield* since(head(branch)));
243
+ return each.length > 0 && each.every((id) => landed.has(id));
244
+ });
245
+
246
+ /**
247
+ * Commits on the branch in no target, and whether its work is merged anyway, by any of the
248
+ * ways a PR merges: a merge commit (every commit contained), a rebase or a squash. Content
249
+ * decides when git can; patches only when it can't. Erring toward "unmerged" is the safe
250
+ * side: it asks for --force, where a wrong "merged" deletes work without asking.
251
+ */
252
+ const mergeState = (repo: string, branch: string, targets: ReadonlyArray<string>) =>
253
+ Effect.gen(function* () {
254
+ const ahead = Number(
255
+ yield* gitOk(repo, ["rev-list", "--count", head(branch), "--not", ...targets]),
256
+ );
257
+ if (ahead === 0) return { ahead, merged: true };
258
+ for (const target of targets) {
259
+ const content = yield* contentMerged(repo, branch, target);
260
+ if (content === "yes") return { ahead, merged: true };
261
+ if (content === "unknown" && (yield* patchMerged(repo, branch, target)))
262
+ return { ahead, merged: true };
263
+ }
264
+ return { ahead, merged: false };
265
+ });
266
+
267
+ /**
268
+ * `link` → `target`. Relative when the link's real directory is inside the repo, so it survives
269
+ * moving the repo; measured from that real directory, since that is where the OS resolves it
270
+ * (`.agents/worktrees` may be a symlink). Absolute when the directory lies outside the repo:
271
+ * a worktree on another disk doesn't move with the repo anyway.
272
+ */
273
+ export const linkTo = (repo: string, link: string, target: string) =>
274
+ Effect.gen(function* () {
275
+ const dir = posix.dirname(link);
276
+ yield* fs("mkdir", dir, () => mkdir(dir, { recursive: true }));
277
+ const realDir = yield* real(dir);
278
+ const inside = realDir === repo || realDir.startsWith(`${repo}/`);
279
+ const text = inside ? posix.relative(realDir, target) : target;
280
+ yield* fs("symlink", link, () => symlink(text, link));
281
+ });
282
+
283
+ /**
284
+ * A missing link is created, and so is one of ours that dangles: a link holds no data. One
285
+ * that resolves elsewhere, or isn't a link, is left alone with a note.
286
+ */
287
+ const ensureLink = (repo: string, link: string, target: string) =>
288
+ Effect.gen(function* () {
289
+ if (!(yield* lexists(link))) {
290
+ yield* linkTo(repo, link, target);
291
+ return `linked ${link} -> ${target}`;
292
+ }
293
+ const state = yield* Effect.promise(() =>
294
+ readlink(link).then(
295
+ () =>
296
+ realpath(link).then(
297
+ async (resolved) => (resolved === (await realpath(target)) ? "ok" : "elsewhere"),
298
+ () => "dangling" as const,
299
+ ),
300
+ () => "not a link" as const,
301
+ ),
302
+ );
303
+ if (state === "ok") return undefined;
304
+ if (state === "dangling") {
305
+ yield* fs("unlink", link, () => unlink(link));
306
+ yield* linkTo(repo, link, target);
307
+ return `relinked ${link} -> ${target} (it dangled)`;
308
+ }
309
+ return `note: ${link} exists but is not a link to ${target}; left alone`;
310
+ });
311
+
312
+ /** The store, `.agents/handoffs` and every checkout's `.handoffs`: what `git clean` may have removed. */
313
+ const ensureHandoffLinks = (common: string, repo: string, checkouts: ReadonlyArray<string>) =>
314
+ Effect.gen(function* () {
315
+ const store = handoffsStore(common);
316
+ yield* fs("mkdir", store, () => mkdir(store, { recursive: true }));
317
+ const links = [handoffsLink(repo), ...[repo, ...checkouts].map((c) => `${c}/${HANDOFFS_LINK}`)];
318
+ const said = yield* Effect.forEach(links, (link) => ensureLink(repo, link, store));
319
+ return said.filter((line): line is string => line !== undefined);
320
+ });
321
+
322
+ /**
323
+ * Every bead with a `track:` label that isn't closed, by track, in the beads state `mshape
324
+ * check` uses: one loader, so the two can't disagree about what a beads failure means.
325
+ */
326
+ const trackBeads = (repo: string) =>
327
+ Effect.map(beadRows(repo, ["--label-pattern", "track:*", "--brief"]), (loaded) => ({
328
+ integration: loaded.integration,
329
+ byTrack: openBeadsByTrack(loaded.value ?? []),
330
+ }));
331
+
332
+ export const ignored = (repo: string, path: string) =>
333
+ Effect.map(git(repo, ["check-ignore", "-q", path]), (result) => result.code === 0);
334
+
335
+ /** A shared path git tracks in `main`, itself or through a parent that is a file: it would be checked out. */
336
+ const trackedInMain = (repo: string, path: string) =>
337
+ Effect.gen(function* () {
338
+ const segments = path.split("/");
339
+ for (let end = 1; end <= segments.length; end++) {
340
+ const prefix = segments.slice(0, end).join("/");
341
+ const type = yield* git(repo, ["cat-file", "-t", `${head(MAIN_BRANCH)}:${prefix}`]);
342
+ if (type.code !== 0) return false;
343
+ if (end === segments.length || type.stdout.trim() !== "tree") return true;
344
+ }
345
+ return false;
346
+ });
347
+
348
+ /**
349
+ * Shared paths come from the main checkout's `shape.config.ts`: they name the main checkout's
350
+ * data, whichever checkout the command runs from.
351
+ */
352
+ export const trackNew = (cwd: string, track: string, date: string) =>
353
+ Effect.gen(function* () {
354
+ const invalid = nameProblem(track);
355
+ if (invalid !== undefined) return yield* refuse(invalid);
356
+ const { common, repo } = yield* locate(cwd);
357
+ const config = yield* loadConfig(repo);
358
+ const shared = config.tracks.sharedPaths;
359
+ const problems = sharedPathProblems(shared);
360
+ if (problems.length > 0) return yield* refuse(problems.join("\n"));
361
+ yield* requireMain(repo);
362
+
363
+ const path = worktreePath(repo, track);
364
+ const branch = trackBranch(track);
365
+ const stub = stubHandoff(track, date, path);
366
+ if ("problems" in stub) return yield* refuse(stub.problems.join("\n"));
367
+ if (yield* lexists(path)) return yield* refuse(`track \`${track}\` exists: ${path} is there`);
368
+ if ((yield* worktrees(repo)).some((worktree) => worktree.path === path))
369
+ return yield* refuse(
370
+ `git still has a worktree registered at ${path}: run \`git worktree prune\`, or \`mshape track rm ${track}\``,
371
+ );
372
+ if (yield* branchExists(repo, branch))
373
+ return yield* refuse(
374
+ `track \`${track}\` exists: branch \`${branch}\` is there. Finish it with \`mshape track rm ${track}\`, or pick another name`,
375
+ );
376
+ const notes: Array<string> = [];
377
+ const linkable: Array<string> = [];
378
+ for (const entry of shared) {
379
+ if (yield* trackedInMain(repo, entry))
380
+ return yield* refuse(
381
+ `shared path \`${entry}\` is tracked in \`${MAIN_BRANCH}\`, so the worktree would already have it: a shared path must be gitignored, never tracked`,
382
+ );
383
+ if (yield* lexists(`${repo}/${entry}`)) linkable.push(entry);
384
+ else notes.push(`note: shared path \`${entry}\` is missing in ${repo}; not linked`);
385
+ }
386
+ for (const [what, at] of [
387
+ ["the worktrees directory", posix.relative(repo, path)],
388
+ [HANDOFFS_LINK, HANDOFFS_LINK],
389
+ ] as const)
390
+ if (!(yield* ignored(repo, at)))
391
+ notes.push(`note: ${what} (\`${at}\`) is not gitignored; add it to .gitignore`);
392
+
393
+ yield* gitOk(repo, ["worktree", "add", "--quiet", "-b", branch, path, head(MAIN_BRANCH)]);
394
+ const said = [`created ${path} on \`${branch}\` from \`${MAIN_BRANCH}\``];
395
+ // From here the track exists: a failure must say what was done and how to undo it.
396
+ const finish = Effect.gen(function* () {
397
+ said.push(...(yield* ensureHandoffLinks(common, repo, [path])));
398
+ for (const entry of linkable) {
399
+ const link = `${path}/${entry}`;
400
+ if (yield* lexists(link))
401
+ return yield* refuse(
402
+ `${link} already exists in the new worktree, so shared path \`${entry}\` is not linked. A shared path must be gitignored, never tracked`,
403
+ );
404
+ yield* linkTo(repo, link, `${repo}/${entry}`);
405
+ said.push(`linked ${link} -> ${repo}/${entry}`);
406
+ }
407
+ const handoff = handoffPath(common, track);
408
+ if (yield* lexists(handoff)) said.push(`kept the existing handoff ${handoff}`);
409
+ else {
410
+ yield* fs("write", handoff, () => Bun.write(handoff, stub.text));
411
+ said.push(`wrote the stub handoff ${handoff}`);
412
+ }
413
+ });
414
+ yield* finish.pipe(
415
+ Effect.catch((error) =>
416
+ refuse(
417
+ [
418
+ `track new ${track}: stopped: ${error.message}`,
419
+ ...said.map((line) => `done: ${line}`),
420
+ `undo with \`mshape track rm ${track}\`, fix the cause, then run \`mshape track new ${track}\` again`,
421
+ ].join("\n "),
422
+ ),
423
+ ),
424
+ );
425
+ return [...notes, ...said, `next: cd ${path}, then run /track`].join("\n");
426
+ });
427
+
428
+ export const trackList = (cwd: string) =>
429
+ Effect.gen(function* () {
430
+ const { common, repo } = yield* locate(cwd);
431
+ yield* requireMain(repo);
432
+ const registered = yield* worktrees(repo);
433
+ const byTrack = new Map<string, Worktree>();
434
+ for (const worktree of registered) {
435
+ const track = trackOf(repo, worktree.path);
436
+ if (track !== undefined) byTrack.set(track, worktree);
437
+ }
438
+ const branches = (yield* gitOk(repo, [
439
+ "for-each-ref",
440
+ // The name after `refs/heads/track/`; `:short` may say `heads/track/x` when a tag shares it.
441
+ "--format=%(refname:lstrip=3)",
442
+ "refs/heads/track/",
443
+ ]))
444
+ .split("\n")
445
+ .filter((line) => line !== "");
446
+ const store = handoffsStore(common);
447
+ const handoffs = yield* Effect.promise(() =>
448
+ readdir(store).then(
449
+ (files) => files.filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3)),
450
+ () => [],
451
+ ),
452
+ );
453
+ const beads = yield* trackBeads(repo);
454
+ // No fetch here: list stays offline and fast, and rm fetches before it decides.
455
+ const targets = yield* mergeTargets(repo);
456
+ // A label with open beads is a track too, even after its worktree is gone: that work is orphaned.
457
+ const labelled = [...beads.byTrack.keys()];
458
+ const tracks = [...new Set([...byTrack.keys(), ...branches, ...handoffs, ...labelled])]
459
+ .filter((track) => TRACK_NAME.test(track))
460
+ .sort();
461
+
462
+ const checkouts = [...byTrack.values()].map((worktree) => worktree.path);
463
+ const present = yield* Effect.filter(checkouts, lexists);
464
+ const notes = yield* ensureHandoffLinks(common, repo, present);
465
+ // list reports, it doesn't gate: a failing bd is a note here, and a refusal in rm.
466
+ if (beads.integration.state === "unavailable" || beads.integration.state === "failed")
467
+ notes.push(`note: open beads not counted: ${beads.integration.reason}`);
468
+
469
+ const rows = yield* Effect.forEach(tracks, (track) =>
470
+ Effect.gen(function* () {
471
+ const handoff = handoffPath(common, track);
472
+ const text = (yield* lexists(handoff))
473
+ ? yield* fs("read", handoff, () => Bun.file(handoff).text())
474
+ : undefined;
475
+ const parsed = text === undefined ? undefined : parseHandoff(text);
476
+ const worktree = byTrack.get(track);
477
+ const row: TrackRow = {
478
+ track,
479
+ status: parsed === undefined ? "none" : "problems" in parsed ? "invalid" : parsed.status,
480
+ problems: parsed !== undefined && "problems" in parsed ? parsed.problems : undefined,
481
+ beads:
482
+ beads.integration.state === "ok" ? (beads.byTrack.get(track)?.length ?? 0) : undefined,
483
+ ...(branches.includes(track)
484
+ ? yield* mergeState(repo, trackBranch(track), targets)
485
+ : { ahead: undefined, merged: undefined }),
486
+ worktree: worktree === undefined ? undefined : posix.relative(repo, worktree.path),
487
+ worktreeBranch:
488
+ worktree === undefined || worktree.branch === trackBranch(track)
489
+ ? undefined
490
+ : (worktree.branch ?? "detached HEAD"),
491
+ };
492
+ return row;
493
+ }),
494
+ );
495
+ return [...notes, formatTracks(rows)].join("\n");
496
+ });
497
+
498
+ export const trackCurrent = (cwd: string) =>
499
+ Effect.gen(function* () {
500
+ const { repo } = yield* locate(cwd);
501
+ const toplevel = yield* Effect.flatMap(
502
+ gitOk(cwd, ["rev-parse", "--show-toplevel"]),
503
+ yield* canonical(repo),
504
+ );
505
+ const found = currentTrack(repo, toplevel, yield* worktrees(repo));
506
+ return "track" in found ? found.track : yield* refuse(found.reason);
507
+ });
508
+
509
+ /** `say` prints at once: what `--force` will discard is said before it happens. */
510
+ export const trackRm = (
511
+ cwd: string,
512
+ track: string,
513
+ options: {
514
+ readonly force: boolean;
515
+ readonly keepHandoff: boolean;
516
+ /** The environment for fetching `main`'s upstream: one where git can't prompt. */
517
+ readonly fetchEnv: Readonly<Record<string, string | undefined>>;
518
+ },
519
+ say: (line: string) => Effect.Effect<void>,
520
+ ) =>
521
+ Effect.gen(function* () {
522
+ const invalid = nameProblem(track);
523
+ if (invalid !== undefined) return yield* refuse(invalid);
524
+ const { common, repo } = yield* locate(cwd);
525
+ yield* requireMain(repo);
526
+ const path = worktreePath(repo, track);
527
+ const branch = trackBranch(track);
528
+ const registered = yield* worktrees(repo);
529
+ const worktree = registered.find((candidate) => candidate.path === path);
530
+ const pathExists = yield* lexists(path);
531
+ const status =
532
+ worktree !== undefined && pathExists
533
+ ? yield* git(path, ["status", "--porcelain", "--untracked-files=all"])
534
+ : { stdout: "", stderr: "", code: 0 };
535
+ const changes =
536
+ status.code === 0 ? status.stdout.split("\n").filter((line) => line.trim() !== "") : [];
537
+ const hasBranch = yield* branchExists(repo, branch);
538
+ const handoff = handoffPath(common, track);
539
+ const unfetched = hasBranch ? yield* fetchUpstream(repo, options.fetchEnv) : undefined;
540
+ if (unfetched !== undefined) yield* say(`note: ${unfetched}`);
541
+ const targets = yield* mergeTargets(repo);
542
+ const found = yield* trackBeads(repo);
543
+ // A repo without shape.config.ts gets the default, `optional`; a failing bd refuses either way.
544
+ const setting = yield* loadConfig(repo).pipe(
545
+ Effect.map((config) => config.beads),
546
+ Effect.orElseSucceed(() => "optional" as const),
547
+ );
548
+ const beads: BeadsCheck = {
549
+ integration: found.integration,
550
+ setting,
551
+ open: found.byTrack.get(track) ?? [],
552
+ };
553
+ const plan = planRm({
554
+ track,
555
+ worktreePath: path,
556
+ worktree,
557
+ pathExists,
558
+ changes,
559
+ statusError: status.code === 0 ? undefined : status.stderr || `exit ${status.code}`,
560
+ branchExists: hasBranch,
561
+ ...(hasBranch
562
+ ? yield* Effect.map(mergeState(repo, branch, targets), (state) => ({
563
+ unmerged: state.ahead,
564
+ merged: state.merged,
565
+ }))
566
+ : { unmerged: 0, merged: true }),
567
+ mergeTargets: targets.map(shortRef),
568
+ branchCheckedOutAt: registered.filter(
569
+ (candidate) => candidate.branch === branch && candidate.path !== path,
570
+ ),
571
+ handoffPath: handoff,
572
+ handoffExists: yield* lexists(handoff),
573
+ beads,
574
+ force: options.force,
575
+ keepHandoff: options.keepHandoff,
576
+ });
577
+ yield* Effect.forEach(plan.notes, (note) => say(`note: ${note}`));
578
+ if (plan.refusals.length > 0)
579
+ return yield* refuse(
580
+ [`track rm ${track}: refused, nothing removed`, ...plan.refusals].join("\n "),
581
+ );
582
+
583
+ yield* Effect.forEach(plan.warnings, (warning) => say(`--force: ${warning}`));
584
+ const run = (step: RmStep) =>
585
+ step.kind === "worktree"
586
+ ? gitOk(repo, ["worktree", "remove", ...(step.force ? ["--force"] : []), step.path])
587
+ : step.kind === "branch"
588
+ ? // Merged into `main` was checked above; `-d` would check against HEAD instead.
589
+ gitOk(repo, ["branch", "-D", step.name])
590
+ : Effect.mapError(
591
+ fs("unlink", step.path, () => unlink(step.path)),
592
+ (error) => new TrackError({ reason: error.message }),
593
+ );
594
+ const removed: Array<string> = [];
595
+ for (const [index, step] of plan.steps.entries()) {
596
+ const failed = yield* Effect.flip(run(step)).pipe(
597
+ Effect.map((error) => error.message),
598
+ Effect.orElseSucceed(() => undefined),
599
+ );
600
+ if (failed !== undefined)
601
+ return yield* refuse(
602
+ [
603
+ `track rm ${track}: stopped at ${describeStep(step)}: ${failed}`,
604
+ `removed: ${removed.join(", ") || "nothing"}`,
605
+ `remains: ${plan.steps.slice(index).map(describeStep).join(", ")}`,
606
+ `fix the cause, then run \`mshape track rm ${track}\` again to finish`,
607
+ ].join("\n "),
608
+ );
609
+ removed.push(describeStep(step));
610
+ }
611
+ yield* gitOk(repo, ["worktree", "prune"]);
612
+ return [
613
+ ...removed.map((what) => `removed ${what}`),
614
+ ...plan.kept.map((what) => `kept ${what}`),
615
+ ].join("\n");
616
+ });