@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/LICENSE +21 -0
- package/README.md +71 -0
- package/cli.ts +1160 -0
- package/lib/git.ts +372 -0
- package/lib/tui.ts +286 -0
- package/package.json +34 -0
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
|
+
})
|