@doccy/fell 0.1.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.
package/cli.ts ADDED
@@ -0,0 +1,1160 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * fell - Interactive git worktree manager.
5
+ *
6
+ * Navigate worktrees with arrow keys, view async PR statuses,
7
+ * delete worktrees + branches, and prune stale references.
8
+ *
9
+ * Usage:
10
+ * fell # interactive (default)
11
+ * fell --list # print and exit
12
+ * fell --help # show help
13
+ */
14
+
15
+ import { parseArgs } from "util"
16
+ import {
17
+ listWorktrees,
18
+ removeWorktree,
19
+ pruneWorktreesDryRun,
20
+ pruneWorktrees,
21
+ deleteBranch,
22
+ fetchPrForBranch,
23
+ fetchWorktreeFileStatus,
24
+ fetchWorktreeFileList,
25
+ openDirectory,
26
+ checkGhStatus,
27
+ type Worktree,
28
+ type PrStatus,
29
+ type FileStatusResult,
30
+ type FileEntry,
31
+ type GhDiagnostic,
32
+ } from "./lib/git"
33
+ import {
34
+ c,
35
+ term,
36
+ SPINNER_FRAMES,
37
+ parseKey,
38
+ keyChar,
39
+ pad,
40
+ truncate,
41
+ shortenPath,
42
+ formatPrStatus,
43
+ formatFileStatus,
44
+ renderHelpLines,
45
+ printCliHelp,
46
+ type Key,
47
+ } from "./lib/tui"
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // State
51
+ // ---------------------------------------------------------------------------
52
+
53
+ interface WorktreeItem {
54
+ worktree: Worktree
55
+ prStatus: PrStatus
56
+ fileStatus: FileStatusResult
57
+ }
58
+
59
+ /** Per-item progress entry for the deleting status line. */
60
+ interface DeleteProgress {
61
+ label: string
62
+ status: "pending" | "removing" | "branch" | "done" | "error" | "needs-force"
63
+ message?: string
64
+ }
65
+
66
+ type Mode =
67
+ | { type: "browse" }
68
+ | { type: "confirm-delete"; indices: number[] }
69
+ | { type: "confirm-force"; indices: number[]; withBranch: boolean }
70
+ | { type: "confirm-prune"; candidates: string[] }
71
+ | { type: "deleting"; progress: DeleteProgress[]; withBranch: boolean }
72
+ | { type: "result"; lines: string[] }
73
+ | { type: "help" }
74
+
75
+ interface State {
76
+ items: WorktreeItem[]
77
+ mainWorktree: Worktree
78
+ cursor: number
79
+ selected: Set<number>
80
+ mode: Mode
81
+ spinnerFrame: number
82
+ message: { text: string; kind: "info" | "success" | "error" } | null
83
+ shouldQuit: boolean
84
+ ghDiagnostic: GhDiagnostic
85
+ /** Index of the expanded item, or null if none expanded. */
86
+ expandedIndex: number | null
87
+ /** Cached file list for the expanded item. Null while loading. */
88
+ expandedFiles: FileEntry[] | null
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Terminal I/O helpers
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function setupTerminal(): void {
96
+ term.enterAltScreen()
97
+ term.hideCursor()
98
+ term.clearScreen()
99
+ if (process.stdin.isTTY) {
100
+ process.stdin.setRawMode(true)
101
+ }
102
+ process.stdin.resume()
103
+ }
104
+
105
+ function cleanupTerminal(): void {
106
+ if (process.stdin.isTTY) {
107
+ process.stdin.setRawMode(false)
108
+ }
109
+ process.stdin.pause()
110
+ term.showCursor()
111
+ term.exitAltScreen()
112
+ }
113
+
114
+ function waitForKey(): Promise<Buffer> {
115
+ return new Promise((resolve) => {
116
+ process.stdin.once("data", (data: Buffer) => resolve(data))
117
+ })
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Rendering
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const BRANCH_COL = 30
125
+ const SHA_COL = 7
126
+
127
+ /**
128
+ * Build lines for a single worktree entry.
129
+ * Returns the main row + an optional indented sub-line for dirty file status.
130
+ */
131
+ function renderRow(
132
+ item: WorktreeItem,
133
+ index: number,
134
+ state: State,
135
+ ): string[] {
136
+ const isFocused = index === state.cursor
137
+ const isSelected = state.selected.has(index)
138
+ const wt = item.worktree
139
+
140
+ const cursor = isFocused ? c.cyan("\u276F") : " "
141
+ const check = isSelected ? c.lime("\u25CF") : c.dim("\u25CB")
142
+
143
+ const branchRaw = wt.branch ?? "(detached)"
144
+ let branchStr = truncate(branchRaw, BRANCH_COL)
145
+ branchStr = isFocused ? c.bold(branchStr) : branchStr
146
+
147
+ const sha = c.dim(wt.head.slice(0, SHA_COL))
148
+
149
+ // Indicators: locked, prunable
150
+ const tags: string[] = []
151
+ if (wt.isLocked) tags.push(c.yellow("locked"))
152
+ if (wt.isPrunable) tags.push(c.red("prunable"))
153
+ const tagStr = tags.length > 0 ? ` ${tags.join(" ")}` : ""
154
+
155
+ const pr = formatPrStatus(item.prStatus, state.spinnerFrame)
156
+
157
+ // Pad branch column for alignment
158
+ const branchPadded = pad(branchStr, BRANCH_COL + 2)
159
+
160
+ const mainLine = ` ${cursor} ${check} ${branchPadded}${sha} ${pr}${tagStr}`
161
+ const lines = [mainLine]
162
+
163
+ // Sub-line: dirty file status with warning icon, indented under the branch name
164
+ const fileStatusLine = formatFileStatus(item.fileStatus)
165
+ if (fileStatusLine) {
166
+ // cursor+check+space = " X X " = 6 chars, then indent to align under branch
167
+ lines.push(` ${fileStatusLine}`)
168
+ }
169
+
170
+ // Expanded file list (progressive disclosure via "e" key)
171
+ const MAX_EXPANDED_FILES = 12
172
+ if (state.expandedIndex === index) {
173
+ if (state.expandedFiles === null) {
174
+ const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]
175
+ lines.push(` ${c.dim(`${frame} loading files...`)}`)
176
+ } else if (state.expandedFiles.length === 0) {
177
+ lines.push(` ${c.dim("no changed files")}`)
178
+ } else {
179
+ const shown = state.expandedFiles.slice(0, MAX_EXPANDED_FILES)
180
+ for (const file of shown) {
181
+ const statusColor =
182
+ file.status === "staged" ? c.green
183
+ : file.status === "modified" ? c.yellow
184
+ : file.status === "unmerged" ? c.red
185
+ : c.dim
186
+ const tag = statusColor(file.status.slice(0, 1).toUpperCase())
187
+ lines.push(` ${tag} ${c.dim(file.path)}`)
188
+ }
189
+ const remaining = state.expandedFiles.length - MAX_EXPANDED_FILES
190
+ if (remaining > 0) {
191
+ lines.push(` ${c.dim(`... ${remaining} more`)}`)
192
+ }
193
+ }
194
+ }
195
+
196
+ return lines
197
+ }
198
+
199
+ /** Full-screen render: build lines, write once. */
200
+ function render(state: State): void {
201
+ const cols = process.stdout.columns ?? 100
202
+ const lines: string[] = []
203
+
204
+ // Title bar
205
+ lines.push("")
206
+ const selectedInfo =
207
+ state.selected.size > 0
208
+ ? ` ${c.lime(`${state.selected.size} selected`)}`
209
+ : ` ${c.dim(c.italic("worktree cli"))}`
210
+ lines.push(` ${c.dim("▐▘ █▌ ▐ ▐")}${selectedInfo}`)
211
+ lines.push(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
212
+ lines.push("")
213
+
214
+ // Main worktree (always visible, non-interactive)
215
+ const mainSha = state.mainWorktree.head.slice(0, SHA_COL)
216
+ const mainPath = shortenPath(state.mainWorktree.path, cols - 25)
217
+ lines.push(
218
+ ` ${c.dim(" main")} ${c.dim(mainSha)} ${c.dim(mainPath)}`,
219
+ )
220
+ lines.push("")
221
+
222
+ if (state.mode.type === "help") {
223
+ lines.push(...renderHelpLines())
224
+ } else {
225
+ // Worktree list (each item may produce 1-2 lines)
226
+ for (let i = 0; i < state.items.length; i++) {
227
+ lines.push(...renderRow(state.items[i], i, state))
228
+ }
229
+
230
+ // Focused item detail: full path + PR title
231
+ lines.push("")
232
+ const focused = state.items[state.cursor]
233
+ if (focused) {
234
+ const fullPath = shortenPath(focused.worktree.path, cols - 4)
235
+ lines.push(` ${c.dim(fullPath)}`)
236
+
237
+ if (focused.prStatus.type === "found") {
238
+ const title = truncate(focused.prStatus.pr.title, cols - 6)
239
+ lines.push(` ${c.dim(c.italic(title))}`)
240
+ }
241
+ }
242
+
243
+ // Status message (persists until next action)
244
+ if (state.message) {
245
+ lines.push("")
246
+ const prefix =
247
+ state.message.kind === "error"
248
+ ? c.red("error")
249
+ : state.message.kind === "success"
250
+ ? c.green("done")
251
+ : c.cyan("info")
252
+ lines.push(` ${prefix} ${state.message.text}`)
253
+ }
254
+
255
+ // Inline gh CLI warning (progressive disclosure above key hints)
256
+ if (
257
+ state.mode.type === "browse" &&
258
+ state.ghDiagnostic.type !== "available" &&
259
+ state.ghDiagnostic.type !== "checking"
260
+ ) {
261
+ lines.push("")
262
+ if (state.ghDiagnostic.type === "not-installed") {
263
+ lines.push(
264
+ ` ${c.yellow("\u26A0")} ${c.dim('install')} ${c.yellow("gh")} ${c.dim("to see PR statuses (created, merged, open) for each worktree")}`,
265
+ )
266
+ lines.push(
267
+ ` ${c.dim("brew install gh")} ${c.dim("or")} ${c.dim("https://cli.github.com")}`,
268
+ )
269
+ } else if (state.ghDiagnostic.type === "not-authenticated") {
270
+ lines.push(
271
+ ` ${c.yellow("\u26A0")} ${c.dim('run')} ${c.yellow("gh auth login")} ${c.dim("to see PR statuses for each worktree")}`,
272
+ )
273
+ lines.push(
274
+ ` ${c.dim(state.ghDiagnostic.detail)}`,
275
+ )
276
+ }
277
+ }
278
+
279
+ // Bottom bar: mode-specific key hints
280
+ lines.push("")
281
+ switch (state.mode.type) {
282
+ case "browse": {
283
+ lines.push(
284
+ ` ${c.dim("\u2191\u2193")} navigate ${c.dim("\u2423")} select ${c.dim("a")} all ${c.dim("e")} expand ${c.dim("o")} open ${c.dim("d")} delete ${c.dim("p")} prune ${c.dim("?")} help ${c.dim("q")} quit`,
285
+ )
286
+ break
287
+ }
288
+ case "confirm-delete": {
289
+ const n = state.mode.indices.length
290
+ const s = n > 1 ? "s" : ""
291
+ lines.push(
292
+ ` Delete ${c.bold(String(n))} worktree${s}? ${c.cyan("y")} confirm ${c.cyan("b")} + delete branch${s} ${c.cyan("n")} cancel`,
293
+ )
294
+ break
295
+ }
296
+ case "confirm-force": {
297
+ const n = state.mode.indices.length
298
+ const s = n > 1 ? "s" : ""
299
+ lines.push(
300
+ ` Worktree${s} ha${n > 1 ? "ve" : "s"} uncommitted changes. Force delete? ${c.cyan("y")} force ${c.cyan("n")} cancel`,
301
+ )
302
+ break
303
+ }
304
+ case "confirm-prune": {
305
+ for (const candidate of state.mode.candidates) {
306
+ lines.push(` ${c.dim("\u2022")} ${candidate}`)
307
+ }
308
+ lines.push("")
309
+ lines.push(
310
+ ` Prune ${c.bold(String(state.mode.candidates.length))} stale reference(s)? ${c.cyan("y")} confirm ${c.cyan("n")} cancel`,
311
+ )
312
+ break
313
+ }
314
+ case "deleting": {
315
+ const { progress } = state.mode
316
+ const done = progress.filter((p) => p.status === "done").length
317
+ const total = progress.length
318
+ const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]
319
+
320
+ for (const entry of progress) {
321
+ let icon: string
322
+ let detail = ""
323
+ switch (entry.status) {
324
+ case "pending":
325
+ icon = c.dim("\u25CB")
326
+ break
327
+ case "removing":
328
+ icon = c.cyan(frame)
329
+ detail = c.dim(" removing worktree")
330
+ break
331
+ case "branch":
332
+ icon = c.cyan(frame)
333
+ detail = c.dim(" deleting branch")
334
+ break
335
+ case "done":
336
+ icon = c.green("\u2713")
337
+ detail = entry.message ? ` ${c.dim(entry.message)}` : ""
338
+ break
339
+ case "error":
340
+ icon = c.red("\u2717")
341
+ detail = entry.message ? ` ${c.dim(entry.message)}` : ""
342
+ break
343
+ case "needs-force":
344
+ icon = c.yellow("!")
345
+ detail = c.dim(" has uncommitted changes")
346
+ break
347
+ }
348
+ lines.push(` ${icon} ${entry.label}${detail}`)
349
+ }
350
+
351
+ lines.push("")
352
+ lines.push(
353
+ ` ${c.cyan(frame)} Deleting ${done}/${total}...`,
354
+ )
355
+ break
356
+ }
357
+ case "result": {
358
+ for (const line of state.mode.lines) {
359
+ lines.push(` ${line}`)
360
+ }
361
+ lines.push("")
362
+ lines.push(` ${c.dim("press any key to continue")}`)
363
+ break
364
+ }
365
+ }
366
+ }
367
+
368
+ lines.push("")
369
+
370
+ // Write entire frame at once to prevent flicker.
371
+ // Each line gets an EL (Erase in Line) suffix so that when a line shrinks
372
+ // between frames the leftover characters from the previous render are wiped.
373
+ term.home()
374
+ process.stdout.write(lines.map((l) => l + term.EL).join("\n"))
375
+ term.clearBelow()
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Actions (async side-effects)
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Run delete operations in the background with per-item progress.
384
+ * Enters "deleting" mode, processes items sequentially (updating state
385
+ * and re-rendering after each step), then transitions to "result" mode.
386
+ * The event loop stays responsive for spinner animation during this time.
387
+ */
388
+ function startDelete(
389
+ state: State,
390
+ indices: number[],
391
+ withBranch: boolean,
392
+ force: boolean,
393
+ rerender: () => void,
394
+ ): void {
395
+ // Build progress entries
396
+ const progress: DeleteProgress[] = indices.map((idx) => {
397
+ const item = state.items[idx]
398
+ return {
399
+ label: item?.worktree.branch ?? item?.worktree.path ?? `index ${idx}`,
400
+ status: "pending" as const,
401
+ }
402
+ })
403
+
404
+ state.mode = { type: "deleting", progress, withBranch }
405
+ state.message = null
406
+ rerender()
407
+
408
+ // Run the actual deletes in a fire-and-forget async block.
409
+ // Each step updates progress + re-renders, keeping the TUI alive.
410
+ ;(async () => {
411
+ const resultLines: string[] = []
412
+ let needsForce = false
413
+
414
+ for (let i = 0; i < indices.length; i++) {
415
+ const idx = indices[i]
416
+ const item = state.items[idx]
417
+ if (!item) continue
418
+ const wt = item.worktree
419
+ const entry = progress[i]
420
+
421
+ // Step 1: remove worktree
422
+ entry.status = "removing"
423
+ rerender()
424
+
425
+ const removeResult = await removeWorktree(wt.path, force)
426
+
427
+ if (removeResult.ok) {
428
+ // Step 2 (optional): delete branch
429
+ if (withBranch && wt.branch) {
430
+ entry.status = "branch"
431
+ rerender()
432
+
433
+ const branchResult = await deleteBranch(wt.branch, true)
434
+ if (branchResult.ok) {
435
+ entry.status = "done"
436
+ entry.message = "worktree + branch removed"
437
+ resultLines.push(`${c.green("\u2713")} Removed ${entry.label} + branch`)
438
+ } else {
439
+ entry.status = "done"
440
+ entry.message = `branch: ${branchResult.error}`
441
+ resultLines.push(`${c.green("\u2713")} Removed ${entry.label}`)
442
+ resultLines.push(`${c.red("\u2717")} Branch ${wt.branch}: ${branchResult.error}`)
443
+ }
444
+ } else {
445
+ entry.status = "done"
446
+ entry.message = "removed"
447
+ resultLines.push(`${c.green("\u2713")} Removed ${entry.label}`)
448
+ }
449
+ } else {
450
+ const isUncommitted =
451
+ removeResult.error?.includes("uncommitted") ||
452
+ removeResult.error?.includes("modified") ||
453
+ removeResult.error?.includes("untracked") ||
454
+ removeResult.error?.includes("changes")
455
+
456
+ if (isUncommitted && !force) {
457
+ needsForce = true
458
+ entry.status = "needs-force"
459
+ resultLines.push(`${c.yellow("!")} ${entry.label}: has uncommitted changes`)
460
+ } else {
461
+ entry.status = "error"
462
+ entry.message = removeResult.error
463
+ resultLines.push(`${c.red("\u2717")} ${entry.label}: ${removeResult.error}`)
464
+ }
465
+ }
466
+
467
+ rerender()
468
+ }
469
+
470
+ // All items processed. Refresh the worktree list.
471
+ await refreshWorktrees(state)
472
+ startPrFetching(state, rerender)
473
+ startFileStatusFetching(state, rerender)
474
+
475
+ if (needsForce) {
476
+ state.mode = {
477
+ type: "result",
478
+ lines: [
479
+ ...resultLines,
480
+ "",
481
+ `${c.yellow("Some worktrees have uncommitted changes.")}`,
482
+ `${c.dim("Select them again and press")} ${c.cyan("d")} ${c.dim("to retry with force.")}`,
483
+ ],
484
+ }
485
+ } else {
486
+ state.mode = { type: "result", lines: resultLines }
487
+ }
488
+
489
+ state.message = null
490
+ rerender()
491
+ })()
492
+ }
493
+
494
+ /** Refresh the worktree list and reset selection state. */
495
+ async function refreshWorktrees(state: State): Promise<void> {
496
+ const allWorktrees = await listWorktrees()
497
+ const main = allWorktrees.find((w) => w.isMain)
498
+ if (main) state.mainWorktree = main
499
+
500
+ state.items = allWorktrees
501
+ .filter((w) => !w.isMain)
502
+ .map((w) => ({
503
+ worktree: w,
504
+ prStatus: { type: "loading" as const },
505
+ fileStatus: { type: "loading" as const },
506
+ }))
507
+
508
+ state.selected.clear()
509
+ state.expandedIndex = null
510
+ state.expandedFiles = null
511
+ state.cursor = Math.min(state.cursor, Math.max(0, state.items.length - 1))
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Async PR fetching (background, concurrent)
516
+ // ---------------------------------------------------------------------------
517
+
518
+ const PR_CONCURRENCY = 4
519
+
520
+ /**
521
+ * Fetch PR statuses for all non-main worktrees in background.
522
+ * Skips entirely if gh CLI is unavailable.
523
+ * Updates state items in-place and triggers re-renders.
524
+ */
525
+ function startPrFetching(state: State, rerender: () => void): void {
526
+ // Skip if gh is known to be unavailable
527
+ if (
528
+ state.ghDiagnostic.type === "not-installed" ||
529
+ state.ghDiagnostic.type === "not-authenticated"
530
+ ) {
531
+ for (const item of state.items) {
532
+ item.prStatus = { type: "skipped" }
533
+ }
534
+ rerender()
535
+ return
536
+ }
537
+
538
+ const branches = state.items
539
+ .map((item, index) => ({
540
+ branch: item.worktree.branch,
541
+ index,
542
+ }))
543
+ .filter(
544
+ (b): b is { branch: string; index: number } => b.branch !== null,
545
+ )
546
+
547
+ // Mark items without a branch as skipped
548
+ for (let i = 0; i < state.items.length; i++) {
549
+ if (!state.items[i].worktree.branch) {
550
+ state.items[i].prStatus = { type: "skipped" }
551
+ }
552
+ }
553
+
554
+ // Concurrent fetch with limited parallelism
555
+ const executing: Promise<void>[] = []
556
+
557
+ const fetchOne = async ({ branch, index }: { branch: string; index: number }) => {
558
+ try {
559
+ const pr = await fetchPrForBranch(branch)
560
+ // Guard against stale index (list may have been refreshed)
561
+ if (state.items[index]?.worktree.branch === branch) {
562
+ state.items[index].prStatus = pr
563
+ ? { type: "found", pr }
564
+ : { type: "none" }
565
+ }
566
+ } catch {
567
+ if (state.items[index]?.worktree.branch === branch) {
568
+ state.items[index].prStatus = {
569
+ type: "error",
570
+ message: "fetch failed",
571
+ }
572
+ }
573
+ }
574
+ rerender()
575
+ }
576
+
577
+ ;(async () => {
578
+ for (const item of branches) {
579
+ const task = fetchOne(item)
580
+ executing.push(task)
581
+ // Remove from pool on completion
582
+ task.then(() => {
583
+ const idx = executing.indexOf(task)
584
+ if (idx !== -1) executing.splice(idx, 1)
585
+ })
586
+
587
+ if (executing.length >= PR_CONCURRENCY) {
588
+ await Promise.race(executing)
589
+ }
590
+ }
591
+ await Promise.all(executing)
592
+ })()
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Async file status fetching (background, concurrent)
597
+ // ---------------------------------------------------------------------------
598
+
599
+ const FILE_STATUS_CONCURRENCY = 6
600
+
601
+ /**
602
+ * Fetch file statuses for all non-main worktrees in background.
603
+ * Runs concurrently since each call is a local git command (fast).
604
+ */
605
+ function startFileStatusFetching(state: State, rerender: () => void): void {
606
+ const entries = state.items.map((item, index) => ({
607
+ path: item.worktree.path,
608
+ index,
609
+ }))
610
+
611
+ const executing: Promise<void>[] = []
612
+
613
+ const fetchOne = async ({ path, index }: { path: string; index: number }) => {
614
+ try {
615
+ const result = await fetchWorktreeFileStatus(path)
616
+ // Guard against stale index (list may have been refreshed)
617
+ if (state.items[index]?.worktree.path === path) {
618
+ state.items[index].fileStatus = result
619
+ }
620
+ } catch {
621
+ if (state.items[index]?.worktree.path === path) {
622
+ state.items[index].fileStatus = { type: "error" }
623
+ }
624
+ }
625
+ rerender()
626
+ }
627
+
628
+ ;(async () => {
629
+ for (const entry of entries) {
630
+ const task = fetchOne(entry)
631
+ executing.push(task)
632
+ task.then(() => {
633
+ const idx = executing.indexOf(task)
634
+ if (idx !== -1) executing.splice(idx, 1)
635
+ })
636
+
637
+ if (executing.length >= FILE_STATUS_CONCURRENCY) {
638
+ await Promise.race(executing)
639
+ }
640
+ }
641
+ await Promise.all(executing)
642
+ })()
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Key handling
647
+ // ---------------------------------------------------------------------------
648
+
649
+ /** Process a keypress in browse mode. Returns true if the event was handled. */
650
+ async function handleBrowseKey(state: State, key: Key): Promise<void> {
651
+ const ch = keyChar(key)
652
+
653
+ // Navigation (collapse expand on cursor move)
654
+ if (key === "up" || ch === "k") {
655
+ state.cursor = Math.max(0, state.cursor - 1)
656
+ state.expandedIndex = null
657
+ state.expandedFiles = null
658
+ state.message = null
659
+ return
660
+ }
661
+ if (key === "down" || ch === "j") {
662
+ state.cursor = Math.min(state.items.length - 1, state.cursor + 1)
663
+ state.expandedIndex = null
664
+ state.expandedFiles = null
665
+ state.message = null
666
+ return
667
+ }
668
+
669
+ // Selection
670
+ if (key === "space") {
671
+ if (state.selected.has(state.cursor)) {
672
+ state.selected.delete(state.cursor)
673
+ } else {
674
+ state.selected.add(state.cursor)
675
+ }
676
+ // Auto-advance cursor after toggle
677
+ if (state.cursor < state.items.length - 1) {
678
+ state.cursor++
679
+ }
680
+ return
681
+ }
682
+
683
+ // Select / deselect all
684
+ if (ch === "a") {
685
+ if (state.selected.size === state.items.length) {
686
+ state.selected.clear()
687
+ } else {
688
+ for (let i = 0; i < state.items.length; i++) {
689
+ state.selected.add(i)
690
+ }
691
+ }
692
+ return
693
+ }
694
+
695
+ // Delete
696
+ if (ch === "d") {
697
+ const indices =
698
+ state.selected.size > 0
699
+ ? Array.from(state.selected).sort((a, b) => a - b)
700
+ : [state.cursor]
701
+
702
+ // Guard against deleting locked worktrees without warning
703
+ const lockedItems = indices.filter((i) => state.items[i]?.worktree.isLocked)
704
+ if (lockedItems.length > 0) {
705
+ const names = lockedItems
706
+ .map((i) => state.items[i].worktree.branch ?? state.items[i].worktree.path)
707
+ .join(", ")
708
+ state.message = {
709
+ text: `Cannot delete locked worktree(s): ${names}. Unlock first.`,
710
+ kind: "error",
711
+ }
712
+ return
713
+ }
714
+
715
+ state.mode = { type: "confirm-delete", indices }
716
+ state.message = null
717
+ return
718
+ }
719
+
720
+ // Prune
721
+ if (ch === "p") {
722
+ state.message = { text: "Checking for stale references...", kind: "info" }
723
+ render(state)
724
+
725
+ const candidates = await pruneWorktreesDryRun()
726
+ if (candidates.length === 0) {
727
+ state.message = {
728
+ text: "No stale worktree references found.",
729
+ kind: "info",
730
+ }
731
+ } else {
732
+ state.mode = { type: "confirm-prune", candidates }
733
+ state.message = null
734
+ }
735
+ return
736
+ }
737
+
738
+ // Refresh
739
+ if (ch === "r") {
740
+ state.message = { text: "Refreshing...", kind: "info" }
741
+ render(state)
742
+ await refreshWorktrees(state)
743
+ startPrFetching(state, () => render(state))
744
+ startFileStatusFetching(state, () => render(state))
745
+ state.message = { text: "Refreshed.", kind: "success" }
746
+ return
747
+ }
748
+
749
+ // Expand: toggle file list for the focused worktree
750
+ if (ch === "e") {
751
+ if (state.expandedIndex === state.cursor) {
752
+ // Collapse
753
+ state.expandedIndex = null
754
+ state.expandedFiles = null
755
+ } else {
756
+ // Expand: set loading, fetch in background
757
+ state.expandedIndex = state.cursor
758
+ state.expandedFiles = null
759
+ const worktreePath = state.items[state.cursor].worktree.path
760
+ const cursorAtExpand = state.cursor
761
+ fetchWorktreeFileList(worktreePath).then((files) => {
762
+ // Only apply if the expanded item hasn't changed
763
+ if (state.expandedIndex === cursorAtExpand) {
764
+ state.expandedFiles = files
765
+ render(state)
766
+ }
767
+ })
768
+ }
769
+ return
770
+ }
771
+
772
+ // Open: open focused worktree directory in system file manager
773
+ if (ch === "o") {
774
+ const focused = state.items[state.cursor]
775
+ if (focused) {
776
+ openDirectory(focused.worktree.path)
777
+ state.message = { text: `Opened ${shortenPath(focused.worktree.path, 50)}`, kind: "info" }
778
+ }
779
+ return
780
+ }
781
+
782
+ // Help
783
+ if (ch === "?") {
784
+ state.mode = { type: "help" }
785
+ return
786
+ }
787
+
788
+ // Quit
789
+ if (key === "ctrl-c" || ch === "q") {
790
+ state.shouldQuit = true
791
+ return
792
+ }
793
+ }
794
+
795
+ /** Process a keypress in confirm-delete mode. */
796
+ async function handleConfirmDeleteKey(
797
+ state: State,
798
+ key: Key,
799
+ indices: number[],
800
+ ): Promise<void> {
801
+ const ch = keyChar(key)
802
+
803
+ // Yes: delete worktrees only
804
+ // b: delete worktrees + branches
805
+ if (ch === "y" || ch === "b") {
806
+ const withBranch = ch === "b"
807
+ // Fire-and-forget: enters "deleting" mode, processes in background
808
+ startDelete(state, indices, withBranch, false, () => render(state))
809
+ return
810
+ }
811
+
812
+ // Cancel
813
+ if (ch === "n" || key === "escape") {
814
+ state.mode = { type: "browse" }
815
+ state.message = null
816
+ return
817
+ }
818
+ }
819
+
820
+ /** Process a keypress in confirm-force mode. */
821
+ async function handleConfirmForceKey(
822
+ state: State,
823
+ key: Key,
824
+ indices: number[],
825
+ withBranch: boolean,
826
+ ): Promise<void> {
827
+ const ch = keyChar(key)
828
+
829
+ if (ch === "y") {
830
+ startDelete(state, indices, withBranch, true, () => render(state))
831
+ return
832
+ }
833
+
834
+ if (ch === "n" || key === "escape") {
835
+ state.mode = { type: "browse" }
836
+ state.message = null
837
+ return
838
+ }
839
+ }
840
+
841
+ /** Process a keypress in confirm-prune mode. */
842
+ async function handleConfirmPruneKey(state: State, key: Key): Promise<void> {
843
+ const ch = keyChar(key)
844
+
845
+ if (ch === "y") {
846
+ const result = await pruneWorktrees()
847
+ if (result.ok) {
848
+ await refreshWorktrees(state)
849
+ startPrFetching(state, () => render(state))
850
+ startFileStatusFetching(state, () => render(state))
851
+ state.mode = {
852
+ type: "result",
853
+ lines: [`${c.green("\u2713")} Stale references pruned.`],
854
+ }
855
+ } else {
856
+ state.mode = {
857
+ type: "result",
858
+ lines: [`${c.red("\u2717")} Prune failed: ${result.error}`],
859
+ }
860
+ }
861
+ return
862
+ }
863
+
864
+ if (ch === "n" || key === "escape") {
865
+ state.mode = { type: "browse" }
866
+ state.message = null
867
+ return
868
+ }
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // Non-interactive --list mode
873
+ // ---------------------------------------------------------------------------
874
+
875
+ async function printListAndExit(): Promise<void> {
876
+ const worktrees = await listWorktrees()
877
+ const ghDiagnostic = await checkGhStatus()
878
+
879
+ console.log()
880
+ console.log(` ${c.dim("▐▘ █▌ ▐ ▐")} ${c.dim("--list")}`)
881
+ console.log(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
882
+ console.log()
883
+
884
+ // Fetch file statuses for all worktrees concurrently
885
+ const fileStatuses = await Promise.all(
886
+ worktrees.map(async (wt) => {
887
+ if (wt.isBare) return { type: "clean" as const }
888
+ return fetchWorktreeFileStatus(wt.path)
889
+ }),
890
+ )
891
+
892
+ for (let i = 0; i < worktrees.length; i++) {
893
+ const wt = worktrees[i]
894
+ const branch = wt.branch ?? "(detached)"
895
+ const sha = wt.head.slice(0, 7)
896
+ const tags: string[] = []
897
+ if (wt.isMain) tags.push(c.cyan("main"))
898
+ if (wt.isLocked) tags.push(c.yellow("locked"))
899
+ if (wt.isPrunable) tags.push(c.red("prunable"))
900
+ const tagStr = tags.length > 0 ? ` ${tags.join(" ")}` : ""
901
+
902
+ const home = process.env.HOME ?? ""
903
+ let path = wt.path
904
+ if (home && path.startsWith(home)) path = "~" + path.slice(home.length)
905
+
906
+ console.log(
907
+ ` ${branch.padEnd(35)} ${c.dim(sha)} ${c.dim(path)}${tagStr}`,
908
+ )
909
+
910
+ // Sub-line for dirty file status
911
+ const fsLine = formatFileStatus(fileStatuses[i])
912
+ if (fsLine) {
913
+ console.log(` ${fsLine}`)
914
+ }
915
+ }
916
+
917
+ // Fetch PR statuses if gh is available
918
+ if (ghDiagnostic.type === "available") {
919
+ console.log()
920
+ console.log(c.dim(" Fetching PR statuses..."))
921
+
922
+ const nonMain = worktrees.filter((w) => !w.isMain && w.branch)
923
+ const results = await Promise.all(
924
+ nonMain.map(async (wt) => {
925
+ const pr = await fetchPrForBranch(wt.branch!)
926
+ return { branch: wt.branch!, pr }
927
+ }),
928
+ )
929
+
930
+ // Overwrite the "Fetching" line
931
+ process.stdout.write("\x1b[1A\x1b[2K")
932
+
933
+ for (const { branch, pr } of results) {
934
+ if (pr) {
935
+ const stateColor =
936
+ pr.state === "MERGED"
937
+ ? c.green
938
+ : pr.state === "OPEN"
939
+ ? c.yellow
940
+ : c.red
941
+ console.log(
942
+ ` ${branch.padEnd(35)} ${stateColor(`#${pr.number} ${pr.state.toLowerCase()}`)} ${c.dim(pr.url)}`,
943
+ )
944
+ }
945
+ }
946
+ } else if (ghDiagnostic.type === "not-installed") {
947
+ console.log()
948
+ console.log(
949
+ ` ${c.yellow("\u26A0")} ${c.dim('install')} ${c.yellow("gh")} ${c.dim("to see PR statuses (created, merged, open) for each worktree")}`,
950
+ )
951
+ console.log(
952
+ ` ${c.dim("brew install gh")} ${c.dim("or")} ${c.dim("https://cli.github.com")}`,
953
+ )
954
+ } else if (ghDiagnostic.type === "not-authenticated") {
955
+ console.log()
956
+ console.log(
957
+ ` ${c.yellow("\u26A0")} ${c.dim('run')} ${c.yellow("gh auth login")} ${c.dim("to see PR statuses for each worktree")}`,
958
+ )
959
+ console.log(` ${c.dim(ghDiagnostic.detail)}`)
960
+ }
961
+
962
+ console.log()
963
+ }
964
+
965
+ // ---------------------------------------------------------------------------
966
+ // Spinner timer (animates loading indicators)
967
+ // ---------------------------------------------------------------------------
968
+
969
+ function startSpinnerTimer(
970
+ state: State,
971
+ rerender: () => void,
972
+ ): ReturnType<typeof setInterval> {
973
+ return setInterval(() => {
974
+ // Animate when there are loading statuses or an active delete in progress
975
+ const hasLoading = state.items.some((i) => i.prStatus.type === "loading")
976
+ const isDeleting = state.mode.type === "deleting"
977
+ if (hasLoading || isDeleting) {
978
+ state.spinnerFrame =
979
+ (state.spinnerFrame + 1) % SPINNER_FRAMES.length
980
+ rerender()
981
+ }
982
+ }, 80)
983
+ }
984
+
985
+ // ---------------------------------------------------------------------------
986
+ // Main
987
+ // ---------------------------------------------------------------------------
988
+
989
+ async function main() {
990
+ const { values } = parseArgs({
991
+ options: {
992
+ help: { type: "boolean", short: "h", default: false },
993
+ list: { type: "boolean", short: "l", default: false },
994
+ },
995
+ allowPositionals: true,
996
+ })
997
+
998
+ if (values.help) {
999
+ printCliHelp()
1000
+ process.exit(0)
1001
+ }
1002
+
1003
+ if (values.list) {
1004
+ await printListAndExit()
1005
+ process.exit(0)
1006
+ }
1007
+
1008
+ // Check prerequisites
1009
+ if (!process.stdin.isTTY) {
1010
+ console.error("fell requires an interactive terminal.")
1011
+ process.exit(1)
1012
+ }
1013
+
1014
+ const allWorktrees = await listWorktrees()
1015
+ const mainWorktree = allWorktrees.find((w) => w.isMain)
1016
+ if (!mainWorktree) {
1017
+ console.error("Could not determine main worktree. Are you in a git repo?")
1018
+ process.exit(1)
1019
+ }
1020
+
1021
+ const nonMain = allWorktrees.filter((w) => !w.isMain)
1022
+ if (nonMain.length === 0) {
1023
+ console.log(c.dim(" No worktrees to manage (only main). Nothing to do."))
1024
+ process.exit(0)
1025
+ }
1026
+
1027
+ // Initialise state
1028
+ const state: State = {
1029
+ items: nonMain.map((w) => ({
1030
+ worktree: w,
1031
+ prStatus: { type: "loading" },
1032
+ fileStatus: { type: "loading" },
1033
+ })),
1034
+ mainWorktree,
1035
+ cursor: 0,
1036
+ selected: new Set(),
1037
+ mode: { type: "browse" },
1038
+ spinnerFrame: 0,
1039
+ message: null,
1040
+ shouldQuit: false,
1041
+ ghDiagnostic: { type: "checking" },
1042
+ expandedIndex: null,
1043
+ expandedFiles: null,
1044
+ }
1045
+
1046
+ // Check gh availability (non-blocking, runs before PR fetching starts)
1047
+ checkGhStatus().then((diagnostic) => {
1048
+ state.ghDiagnostic = diagnostic
1049
+ if (diagnostic.type !== "available") {
1050
+ // gh unavailable - mark all PR statuses so spinners stop
1051
+ for (const item of state.items) {
1052
+ if (item.prStatus.type === "loading") {
1053
+ item.prStatus = { type: "skipped" }
1054
+ }
1055
+ }
1056
+ render(state)
1057
+ }
1058
+ })
1059
+
1060
+ // Setup terminal
1061
+ setupTerminal()
1062
+
1063
+ // Ensure cleanup on unexpected exit
1064
+ const cleanup = () => {
1065
+ cleanupTerminal()
1066
+ }
1067
+ process.on("SIGINT", () => {
1068
+ cleanup()
1069
+ process.exit(0)
1070
+ })
1071
+ process.on("SIGTERM", () => {
1072
+ cleanup()
1073
+ process.exit(0)
1074
+ })
1075
+ // Re-render on terminal resize
1076
+ process.on("SIGWINCH", () => render(state))
1077
+
1078
+ try {
1079
+ // Initial render
1080
+ render(state)
1081
+
1082
+ // Start background PR fetching + file status checks
1083
+ startPrFetching(state, () => render(state))
1084
+ startFileStatusFetching(state, () => render(state))
1085
+
1086
+ // Start spinner animation timer
1087
+ const spinnerTimer = startSpinnerTimer(state, () => render(state))
1088
+
1089
+ // Event loop
1090
+ while (!state.shouldQuit) {
1091
+ const data = await waitForKey()
1092
+ const key = parseKey(data)
1093
+
1094
+ switch (state.mode.type) {
1095
+ case "browse":
1096
+ await handleBrowseKey(state, key)
1097
+ break
1098
+
1099
+ case "confirm-delete":
1100
+ await handleConfirmDeleteKey(state, key, state.mode.indices)
1101
+ break
1102
+
1103
+ case "confirm-force":
1104
+ await handleConfirmForceKey(
1105
+ state,
1106
+ key,
1107
+ state.mode.indices,
1108
+ state.mode.withBranch,
1109
+ )
1110
+ break
1111
+
1112
+ case "confirm-prune":
1113
+ await handleConfirmPruneKey(state, key)
1114
+ break
1115
+
1116
+ case "deleting":
1117
+ // Background delete in progress - ignore keys (spinner keeps animating via timer)
1118
+ if (key === "ctrl-c") {
1119
+ state.shouldQuit = true
1120
+ }
1121
+ break
1122
+
1123
+ case "result":
1124
+ // Any key returns to browse
1125
+ state.mode = { type: "browse" }
1126
+ state.message = null
1127
+ break
1128
+
1129
+ case "help":
1130
+ if (key === "escape" || keyChar(key) === "?" || keyChar(key) === "q") {
1131
+ state.mode = { type: "browse" }
1132
+ }
1133
+ // Quit from help screen
1134
+ if (key === "ctrl-c") {
1135
+ state.shouldQuit = true
1136
+ }
1137
+ break
1138
+ }
1139
+
1140
+ if (!state.shouldQuit) {
1141
+ render(state)
1142
+ }
1143
+ }
1144
+
1145
+ clearInterval(spinnerTimer)
1146
+ } finally {
1147
+ cleanup()
1148
+ }
1149
+ }
1150
+
1151
+ main().catch((err) => {
1152
+ // Ensure terminal is restored even on crash
1153
+ try {
1154
+ cleanupTerminal()
1155
+ } catch {
1156
+ /* ignore */
1157
+ }
1158
+ console.error(c.red("Fatal:"), err instanceof Error ? err.message : err)
1159
+ process.exit(1)
1160
+ })