@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/lib/git.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git and GitHub CLI operations for worktree management.
|
|
3
|
+
* Uses Bun's native $ shell API for all subprocess calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { $ } from "bun"
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export interface Worktree {
|
|
13
|
+
path: string
|
|
14
|
+
head: string
|
|
15
|
+
branch: string | null
|
|
16
|
+
isBare: boolean
|
|
17
|
+
isMain: boolean
|
|
18
|
+
isLocked: boolean
|
|
19
|
+
lockReason: string | null
|
|
20
|
+
isPrunable: boolean
|
|
21
|
+
prunableReason: string | null
|
|
22
|
+
isDetached: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PrInfo {
|
|
26
|
+
number: number
|
|
27
|
+
state: "OPEN" | "CLOSED" | "MERGED"
|
|
28
|
+
url: string
|
|
29
|
+
title: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PrStatus =
|
|
33
|
+
| { type: "loading" }
|
|
34
|
+
| { type: "found"; pr: PrInfo }
|
|
35
|
+
| { type: "none" }
|
|
36
|
+
| { type: "error"; message: string }
|
|
37
|
+
| { type: "skipped" }
|
|
38
|
+
|
|
39
|
+
export interface FileStatus {
|
|
40
|
+
/** Staged files (added/modified/deleted in index) */
|
|
41
|
+
staged: number
|
|
42
|
+
/** Unstaged modifications to tracked files */
|
|
43
|
+
modified: number
|
|
44
|
+
/** Untracked files */
|
|
45
|
+
untracked: number
|
|
46
|
+
/** Commits ahead of remote tracking branch */
|
|
47
|
+
ahead: number
|
|
48
|
+
/** Commits behind remote tracking branch */
|
|
49
|
+
behind: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type FileStatusResult =
|
|
53
|
+
| { type: "loading" }
|
|
54
|
+
| { type: "clean" }
|
|
55
|
+
| { type: "dirty"; status: FileStatus }
|
|
56
|
+
| { type: "error" }
|
|
57
|
+
|
|
58
|
+
/** Diagnostic result from checking gh CLI availability. */
|
|
59
|
+
export type GhDiagnostic =
|
|
60
|
+
| { type: "checking" }
|
|
61
|
+
| { type: "available" }
|
|
62
|
+
| { type: "not-installed" }
|
|
63
|
+
| { type: "not-authenticated"; detail: string }
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Worktree operations
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse `git worktree list --porcelain` into structured data.
|
|
71
|
+
* The first entry is always the main worktree.
|
|
72
|
+
*/
|
|
73
|
+
export async function listWorktrees(): Promise<Worktree[]> {
|
|
74
|
+
const result = await $`git worktree list --porcelain`.nothrow().quiet()
|
|
75
|
+
if (result.exitCode !== 0) return []
|
|
76
|
+
|
|
77
|
+
const stdout = result.stdout.toString()
|
|
78
|
+
const blocks = stdout.trim().split("\n\n")
|
|
79
|
+
const worktrees: Worktree[] = []
|
|
80
|
+
|
|
81
|
+
for (const block of blocks) {
|
|
82
|
+
const lines = block.trim().split("\n")
|
|
83
|
+
let path = ""
|
|
84
|
+
let head = ""
|
|
85
|
+
let branch: string | null = null
|
|
86
|
+
let isBare = false
|
|
87
|
+
let isDetached = false
|
|
88
|
+
let isLocked = false
|
|
89
|
+
let lockReason: string | null = null
|
|
90
|
+
let isPrunable = false
|
|
91
|
+
let prunableReason: string | null = null
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (line.startsWith("worktree ")) path = line.slice(9)
|
|
95
|
+
else if (line.startsWith("HEAD ")) head = line.slice(5)
|
|
96
|
+
else if (line.startsWith("branch "))
|
|
97
|
+
branch = line.slice(7).replace("refs/heads/", "")
|
|
98
|
+
else if (line === "bare") isBare = true
|
|
99
|
+
else if (line === "detached") isDetached = true
|
|
100
|
+
else if (line === "locked") isLocked = true
|
|
101
|
+
else if (line.startsWith("locked ")) {
|
|
102
|
+
isLocked = true
|
|
103
|
+
lockReason = line.slice(7)
|
|
104
|
+
} else if (line === "prunable") isPrunable = true
|
|
105
|
+
else if (line.startsWith("prunable ")) {
|
|
106
|
+
isPrunable = true
|
|
107
|
+
prunableReason = line.slice(9)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (path) {
|
|
112
|
+
worktrees.push({
|
|
113
|
+
path,
|
|
114
|
+
head,
|
|
115
|
+
branch,
|
|
116
|
+
isBare,
|
|
117
|
+
isMain: worktrees.length === 0,
|
|
118
|
+
isLocked,
|
|
119
|
+
lockReason,
|
|
120
|
+
isPrunable,
|
|
121
|
+
prunableReason,
|
|
122
|
+
isDetached,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return worktrees
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove a worktree directory and its git administrative tracking.
|
|
132
|
+
* Use force when the worktree has uncommitted changes.
|
|
133
|
+
*/
|
|
134
|
+
export async function removeWorktree(
|
|
135
|
+
path: string,
|
|
136
|
+
force = false,
|
|
137
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
138
|
+
const flags = force ? ["--force"] : []
|
|
139
|
+
const result = await $`git worktree remove ${flags} ${path}`.nothrow().quiet()
|
|
140
|
+
if (result.exitCode !== 0) {
|
|
141
|
+
return { ok: false, error: result.stderr.toString().trim() }
|
|
142
|
+
}
|
|
143
|
+
return { ok: true }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Dry-run prune to show what stale references would be cleaned.
|
|
148
|
+
* Returns human-readable descriptions of each stale entry.
|
|
149
|
+
*/
|
|
150
|
+
export async function pruneWorktreesDryRun(): Promise<string[]> {
|
|
151
|
+
const result = await $`git worktree prune --dry-run -v`.nothrow().quiet()
|
|
152
|
+
return result.stdout.toString().trim().split("\n").filter(Boolean)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Actually prune stale worktree references. */
|
|
156
|
+
export async function pruneWorktrees(): Promise<{
|
|
157
|
+
ok: boolean
|
|
158
|
+
error?: string
|
|
159
|
+
}> {
|
|
160
|
+
const result = await $`git worktree prune`.nothrow().quiet()
|
|
161
|
+
if (result.exitCode !== 0) {
|
|
162
|
+
return { ok: false, error: result.stderr.toString().trim() }
|
|
163
|
+
}
|
|
164
|
+
return { ok: true }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Delete a local git branch.
|
|
169
|
+
* Force (-D) deletes even when not fully merged.
|
|
170
|
+
*/
|
|
171
|
+
export async function deleteBranch(
|
|
172
|
+
name: string,
|
|
173
|
+
force = false,
|
|
174
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
175
|
+
const flag = force ? "-D" : "-d"
|
|
176
|
+
const result = await $`git branch ${flag} ${name}`.nothrow().quiet()
|
|
177
|
+
if (result.exitCode !== 0) {
|
|
178
|
+
return { ok: false, error: result.stderr.toString().trim() }
|
|
179
|
+
}
|
|
180
|
+
return { ok: true }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// File status (per-worktree)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the working tree status for a specific worktree path.
|
|
189
|
+
* Uses `git -C <path> status --porcelain=v2 --branch` to parse
|
|
190
|
+
* staged, modified, untracked counts and ahead/behind info.
|
|
191
|
+
*/
|
|
192
|
+
export async function fetchWorktreeFileStatus(
|
|
193
|
+
worktreePath: string,
|
|
194
|
+
): Promise<FileStatusResult> {
|
|
195
|
+
const result =
|
|
196
|
+
await $`git -C ${worktreePath} status --porcelain=v2 --branch`
|
|
197
|
+
.nothrow()
|
|
198
|
+
.quiet()
|
|
199
|
+
|
|
200
|
+
if (result.exitCode !== 0) return { type: "error" }
|
|
201
|
+
|
|
202
|
+
const stdout = result.stdout.toString()
|
|
203
|
+
let staged = 0
|
|
204
|
+
let modified = 0
|
|
205
|
+
let untracked = 0
|
|
206
|
+
let ahead = 0
|
|
207
|
+
let behind = 0
|
|
208
|
+
|
|
209
|
+
for (const line of stdout.split("\n")) {
|
|
210
|
+
if (line.startsWith("# branch.ab ")) {
|
|
211
|
+
// Format: "# branch.ab +N -M"
|
|
212
|
+
const match = line.match(/\+(\d+) -(\d+)/)
|
|
213
|
+
if (match) {
|
|
214
|
+
ahead = parseInt(match[1], 10)
|
|
215
|
+
behind = parseInt(match[2], 10)
|
|
216
|
+
}
|
|
217
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
218
|
+
// Ordinary/rename entries: XY field is chars 2-3
|
|
219
|
+
const xy = line.slice(2, 4)
|
|
220
|
+
const indexStatus = xy[0]
|
|
221
|
+
const worktreeStatus = xy[1]
|
|
222
|
+
if (indexStatus !== ".") staged++
|
|
223
|
+
if (worktreeStatus !== ".") modified++
|
|
224
|
+
} else if (line.startsWith("u ")) {
|
|
225
|
+
// Unmerged entry - count as modified
|
|
226
|
+
modified++
|
|
227
|
+
} else if (line.startsWith("? ")) {
|
|
228
|
+
untracked++
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const isDirty = staged + modified + untracked + ahead + behind > 0
|
|
233
|
+
if (!isDirty) return { type: "clean" }
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
type: "dirty",
|
|
237
|
+
status: { staged, modified, untracked, ahead, behind },
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A single file entry from git status with its change type. */
|
|
242
|
+
export interface FileEntry {
|
|
243
|
+
path: string
|
|
244
|
+
status: "staged" | "modified" | "untracked" | "unmerged"
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get the list of changed files in a worktree.
|
|
249
|
+
* Returns individual file paths with their status category.
|
|
250
|
+
*/
|
|
251
|
+
export async function fetchWorktreeFileList(
|
|
252
|
+
worktreePath: string,
|
|
253
|
+
): Promise<FileEntry[]> {
|
|
254
|
+
const result =
|
|
255
|
+
await $`git -C ${worktreePath} status --porcelain=v2 --branch`
|
|
256
|
+
.nothrow()
|
|
257
|
+
.quiet()
|
|
258
|
+
|
|
259
|
+
if (result.exitCode !== 0) return []
|
|
260
|
+
|
|
261
|
+
const entries: FileEntry[] = []
|
|
262
|
+
|
|
263
|
+
for (const line of result.stdout.toString().split("\n")) {
|
|
264
|
+
if (line.startsWith("1 ")) {
|
|
265
|
+
// Ordinary entry: "1 XY sub mH mI mW hH hI path"
|
|
266
|
+
const xy = line.slice(2, 4)
|
|
267
|
+
const path = line.split("\t")[0]?.split(" ").pop() ?? line.slice(113)
|
|
268
|
+
// Parse path from the fixed-width porcelain v2 format
|
|
269
|
+
const parts = line.split(" ")
|
|
270
|
+
const filePath = parts.slice(8).join(" ")
|
|
271
|
+
if (xy[0] !== ".") entries.push({ path: filePath, status: "staged" })
|
|
272
|
+
else if (xy[1] !== ".") entries.push({ path: filePath, status: "modified" })
|
|
273
|
+
} else if (line.startsWith("2 ")) {
|
|
274
|
+
// Rename entry: "2 XY sub mH mI mW hH hI X\tscore\tpath\torigPath"
|
|
275
|
+
const tabParts = line.split("\t")
|
|
276
|
+
const filePath = tabParts[1] ?? ""
|
|
277
|
+
entries.push({ path: filePath, status: "staged" })
|
|
278
|
+
} else if (line.startsWith("u ")) {
|
|
279
|
+
// Unmerged: "u XY sub m1 m2 m3 mW h1 h2 h3 path"
|
|
280
|
+
const parts = line.split(" ")
|
|
281
|
+
entries.push({ path: parts.slice(10).join(" "), status: "unmerged" })
|
|
282
|
+
} else if (line.startsWith("? ")) {
|
|
283
|
+
entries.push({ path: line.slice(2), status: "untracked" })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return entries
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Open a directory in the system file manager.
|
|
292
|
+
* macOS: Finder, Linux: xdg-open, Windows: explorer.
|
|
293
|
+
*/
|
|
294
|
+
export async function openDirectory(path: string): Promise<void> {
|
|
295
|
+
const platform = process.platform
|
|
296
|
+
if (platform === "darwin") {
|
|
297
|
+
await $`open ${path}`.nothrow().quiet()
|
|
298
|
+
} else if (platform === "win32") {
|
|
299
|
+
await $`explorer ${path}`.nothrow().quiet()
|
|
300
|
+
} else {
|
|
301
|
+
await $`xdg-open ${path}`.nothrow().quiet()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// GitHub CLI diagnostics
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Determine the availability of the `gh` CLI.
|
|
311
|
+
* Uses `Bun.which` to check the binary exists on PATH, then
|
|
312
|
+
* attempts a lightweight repo query to verify auth/repo access.
|
|
313
|
+
*/
|
|
314
|
+
export async function checkGhStatus(): Promise<GhDiagnostic> {
|
|
315
|
+
// Fast path: check if the binary is on PATH at all
|
|
316
|
+
if (!Bun.which("gh")) {
|
|
317
|
+
return { type: "not-installed" }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Binary exists - verify it can actually query this repo (auth + repo context)
|
|
321
|
+
const result = await $`gh repo view --json name`.nothrow().quiet()
|
|
322
|
+
if (result.exitCode === 0) {
|
|
323
|
+
return { type: "available" }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const stderr = result.stderr.toString()
|
|
327
|
+
return {
|
|
328
|
+
type: "not-authenticated",
|
|
329
|
+
detail: stderr.trim().split("\n")[0] ?? "unknown error",
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// GitHub PR lookups
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Fetch the most recent PR for a branch via `gh` CLI.
|
|
339
|
+
* Returns null when no PR exists or gh is unavailable.
|
|
340
|
+
*/
|
|
341
|
+
export async function fetchPrForBranch(
|
|
342
|
+
branch: string,
|
|
343
|
+
): Promise<PrInfo | null> {
|
|
344
|
+
const result =
|
|
345
|
+
await $`gh pr list --head ${branch} --state all --json number,state,url,title --limit 5`
|
|
346
|
+
.nothrow()
|
|
347
|
+
.quiet()
|
|
348
|
+
|
|
349
|
+
if (result.exitCode !== 0) return null
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const prs = JSON.parse(result.stdout.toString()) as Array<{
|
|
353
|
+
number: number
|
|
354
|
+
state: string
|
|
355
|
+
url: string
|
|
356
|
+
title: string
|
|
357
|
+
}>
|
|
358
|
+
if (prs.length === 0) return null
|
|
359
|
+
|
|
360
|
+
// Most recent PR first
|
|
361
|
+
prs.sort((a, b) => b.number - a.number)
|
|
362
|
+
const pr = prs[0]
|
|
363
|
+
return {
|
|
364
|
+
number: pr.number,
|
|
365
|
+
state: pr.state as PrInfo["state"],
|
|
366
|
+
url: pr.url,
|
|
367
|
+
title: pr.title,
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
}
|
package/lib/tui.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI primitives for fell.
|
|
3
|
+
* Stateless helpers: ANSI colours, key parsing, text formatting,
|
|
4
|
+
* and the help screen content.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PrStatus, FileStatusResult } from "./git"
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ANSI escape helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const ESC = "\x1b"
|
|
14
|
+
const CSI = `${ESC}[`
|
|
15
|
+
|
|
16
|
+
/** Colour/style wrappers using standard ANSI escape codes. */
|
|
17
|
+
export const c = {
|
|
18
|
+
dim: (s: string) => `${CSI}2m${s}${CSI}0m`,
|
|
19
|
+
bold: (s: string) => `${CSI}1m${s}${CSI}0m`,
|
|
20
|
+
italic: (s: string) => `${CSI}3m${s}${CSI}0m`,
|
|
21
|
+
underline: (s: string) => `${CSI}4m${s}${CSI}0m`,
|
|
22
|
+
inverse: (s: string) => `${CSI}7m${s}${CSI}0m`,
|
|
23
|
+
cyan: (s: string) => `${CSI}36m${s}${CSI}0m`,
|
|
24
|
+
green: (s: string) => `${CSI}32m${s}${CSI}0m`,
|
|
25
|
+
red: (s: string) => `${CSI}31m${s}${CSI}0m`,
|
|
26
|
+
yellow: (s: string) => `${CSI}33m${s}${CSI}0m`,
|
|
27
|
+
magenta: (s: string) => `${CSI}35m${s}${CSI}0m`,
|
|
28
|
+
white: (s: string) => `${CSI}37m${s}${CSI}0m`,
|
|
29
|
+
lime: (s: string) => `${CSI}38;5;154m${s}${CSI}0m`,
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
export const SPINNER_FRAMES = [
|
|
33
|
+
"\u28CB", "\u28D9", "\u28F9", "\u28F8", "\u28FC", "\u28F4",
|
|
34
|
+
"\u28E6", "\u28E7", "\u28C7", "\u28CF",
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Terminal control
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export const term = {
|
|
42
|
+
enterAltScreen: () => process.stdout.write(`${CSI}?1049h`),
|
|
43
|
+
exitAltScreen: () => process.stdout.write(`${CSI}?1049l`),
|
|
44
|
+
hideCursor: () => process.stdout.write(`${CSI}?25l`),
|
|
45
|
+
showCursor: () => process.stdout.write(`${CSI}?25h`),
|
|
46
|
+
home: () => process.stdout.write(`${CSI}H`),
|
|
47
|
+
clearBelow: () => process.stdout.write(`${CSI}J`),
|
|
48
|
+
clearScreen: () => process.stdout.write(`${CSI}2J`),
|
|
49
|
+
/** Erase from cursor to end of line - append to each line to prevent ghost chars. */
|
|
50
|
+
EL: `${CSI}K`,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Text utilities
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* OSC 8 hyperlink - clickable in iTerm2, Kitty, WezTerm, etc.
|
|
59
|
+
* Terminals that don't support it simply render the text.
|
|
60
|
+
*/
|
|
61
|
+
export function hyperlink(text: string, url: string): string {
|
|
62
|
+
return `${ESC}]8;;${url}\x07${text}${ESC}]8;;\x07`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strip ANSI escape codes (colours + OSC 8 links) for width calculation. */
|
|
66
|
+
export function stripAnsi(s: string): string {
|
|
67
|
+
return s
|
|
68
|
+
.replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences
|
|
69
|
+
.replace(/\x1b\[[0-9;]*m/g, "") // SGR sequences
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Visible character count excluding ANSI codes. */
|
|
73
|
+
export function visibleLength(s: string): number {
|
|
74
|
+
return stripAnsi(s).length
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pad a (possibly ANSI-coloured) string to `width` visible chars. */
|
|
78
|
+
export function pad(s: string, width: number): string {
|
|
79
|
+
const diff = width - visibleLength(s)
|
|
80
|
+
return diff > 0 ? s + " ".repeat(diff) : s
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Truncate plain text with ".." suffix when it exceeds `width`. */
|
|
84
|
+
export function truncate(s: string, width: number): string {
|
|
85
|
+
if (s.length <= width) return s
|
|
86
|
+
if (width <= 2) return s.slice(0, width)
|
|
87
|
+
return s.slice(0, width - 2) + ".."
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Replace $HOME prefix with ~ and truncate to maxWidth. */
|
|
91
|
+
export function shortenPath(fullPath: string, maxWidth: number): string {
|
|
92
|
+
const home = process.env.HOME ?? ""
|
|
93
|
+
let display = fullPath
|
|
94
|
+
if (home && display.startsWith(home)) {
|
|
95
|
+
display = "~" + display.slice(home.length)
|
|
96
|
+
}
|
|
97
|
+
return truncate(display, maxWidth)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Key parsing
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export type Key =
|
|
105
|
+
| "up"
|
|
106
|
+
| "down"
|
|
107
|
+
| "left"
|
|
108
|
+
| "right"
|
|
109
|
+
| "enter"
|
|
110
|
+
| "space"
|
|
111
|
+
| "escape"
|
|
112
|
+
| "backspace"
|
|
113
|
+
| "ctrl-c"
|
|
114
|
+
| { char: string }
|
|
115
|
+
|
|
116
|
+
/** Parse raw stdin bytes into a Key value. */
|
|
117
|
+
export function parseKey(data: Buffer): Key {
|
|
118
|
+
if (data[0] === 0x1b && data[1] === 0x5b) {
|
|
119
|
+
switch (data[2]) {
|
|
120
|
+
case 0x41:
|
|
121
|
+
return "up"
|
|
122
|
+
case 0x42:
|
|
123
|
+
return "down"
|
|
124
|
+
case 0x43:
|
|
125
|
+
return "right"
|
|
126
|
+
case 0x44:
|
|
127
|
+
return "left"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
switch (data[0]) {
|
|
131
|
+
case 0x03:
|
|
132
|
+
return "ctrl-c"
|
|
133
|
+
case 0x0d:
|
|
134
|
+
case 0x0a:
|
|
135
|
+
return "enter"
|
|
136
|
+
case 0x20:
|
|
137
|
+
return "space"
|
|
138
|
+
case 0x7f:
|
|
139
|
+
return "backspace"
|
|
140
|
+
case 0x1b:
|
|
141
|
+
return "escape"
|
|
142
|
+
}
|
|
143
|
+
return { char: data.toString("utf8") }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Extract the character value from a Key, or null for special keys. */
|
|
147
|
+
export function keyChar(key: Key): string | null {
|
|
148
|
+
if (typeof key === "object" && "char" in key) return key.char
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// PR status formatting
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** Render a PrStatus value to a coloured string for the worktree list. */
|
|
157
|
+
export function formatPrStatus(status: PrStatus, spinnerFrame: number): string {
|
|
158
|
+
switch (status.type) {
|
|
159
|
+
case "loading": {
|
|
160
|
+
const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]
|
|
161
|
+
return c.dim(`${frame} fetching`)
|
|
162
|
+
}
|
|
163
|
+
case "found": {
|
|
164
|
+
const { pr } = status
|
|
165
|
+
const tag = hyperlink(`#${pr.number}`, pr.url)
|
|
166
|
+
switch (pr.state) {
|
|
167
|
+
case "MERGED":
|
|
168
|
+
return c.green(`${tag} merged`)
|
|
169
|
+
case "OPEN":
|
|
170
|
+
return c.yellow(`${tag} open`)
|
|
171
|
+
case "CLOSED":
|
|
172
|
+
return c.red(`${tag} closed`)
|
|
173
|
+
}
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
case "none":
|
|
177
|
+
return c.dim("no PR")
|
|
178
|
+
case "error":
|
|
179
|
+
return c.dim("fetch error")
|
|
180
|
+
case "skipped":
|
|
181
|
+
return c.dim("-")
|
|
182
|
+
}
|
|
183
|
+
return ""
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// File status formatting
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render a file status sub-line for a worktree.
|
|
192
|
+
* Returns null if clean or still loading - only renders when dirty.
|
|
193
|
+
* Warning triangle prefix to draw attention to uncommitted/unpushed work.
|
|
194
|
+
*/
|
|
195
|
+
export function formatFileStatus(result: FileStatusResult): string | null {
|
|
196
|
+
if (result.type !== "dirty") return null
|
|
197
|
+
|
|
198
|
+
const { staged, modified, untracked, ahead, behind } = result.status
|
|
199
|
+
const parts: string[] = []
|
|
200
|
+
|
|
201
|
+
if (staged > 0) parts.push(c.green(`${staged} staged`))
|
|
202
|
+
if (modified > 0) parts.push(c.yellow(`${modified} modified`))
|
|
203
|
+
if (untracked > 0) parts.push(c.dim(`${untracked} untracked`))
|
|
204
|
+
if (ahead > 0) parts.push(c.cyan(`${ahead} unpushed`))
|
|
205
|
+
if (behind > 0) parts.push(c.magenta(`${behind} behind`))
|
|
206
|
+
|
|
207
|
+
if (parts.length === 0) return null
|
|
208
|
+
|
|
209
|
+
return `${c.yellow("\u26A0")} ${parts.join(c.dim(" \u00B7 "))}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Help screen
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export function renderHelpLines(): string[] {
|
|
217
|
+
return [
|
|
218
|
+
"",
|
|
219
|
+
` ${c.bold("KEYBINDINGS")}`,
|
|
220
|
+
"",
|
|
221
|
+
` ${c.cyan("up/down")} or ${c.cyan("k/j")} Navigate`,
|
|
222
|
+
` ${c.cyan("space")} Toggle selection on focused item`,
|
|
223
|
+
` ${c.cyan("a")} Select / deselect all`,
|
|
224
|
+
` ${c.cyan("e")} Expand / collapse file list for focused worktree`,
|
|
225
|
+
` ${c.cyan("o")} Open focused worktree in file manager`,
|
|
226
|
+
` ${c.cyan("d")} Delete focused or selected worktree(s)`,
|
|
227
|
+
` ${c.cyan("p")} Prune stale worktree references`,
|
|
228
|
+
` ${c.cyan("r")} Refresh list + re-fetch PR statuses`,
|
|
229
|
+
` ${c.cyan("?")} Toggle this help screen`,
|
|
230
|
+
` ${c.cyan("q")} / ${c.cyan("ctrl+c")} Quit`,
|
|
231
|
+
"",
|
|
232
|
+
` ${c.bold("PRUNE vs DELETE")}`,
|
|
233
|
+
"",
|
|
234
|
+
` ${c.yellow("prune")} Cleans up ${c.italic("stale administrative references")}. When a worktree`,
|
|
235
|
+
` directory has been manually deleted (rm -rf) but git still`,
|
|
236
|
+
` tracks it, prune removes those orphaned references.`,
|
|
237
|
+
` ${c.dim("Safe: only affects already-missing worktrees.")}`,
|
|
238
|
+
"",
|
|
239
|
+
` ${c.yellow("delete")} ${c.italic("Properly removes")} a worktree: deletes the working directory`,
|
|
240
|
+
` and cleans up git tracking. Optionally also force-deletes`,
|
|
241
|
+
` the associated branch. Use for worktrees you no longer need.`,
|
|
242
|
+
` ${c.dim("Destructive: removes files from disk.")}`,
|
|
243
|
+
"",
|
|
244
|
+
` ${c.dim("press ? or escape to return")}`,
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Non-interactive help (--help flag)
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export function printCliHelp(): void {
|
|
253
|
+
console.log()
|
|
254
|
+
console.log(` ${c.dim("▐▘ █▌ ▐ ▐")} ${c.dim("Interactive git worktree manager")}`)
|
|
255
|
+
console.log(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
|
|
256
|
+
console.log()
|
|
257
|
+
console.log(c.yellow(" USAGE"))
|
|
258
|
+
console.log()
|
|
259
|
+
console.log(` ${c.dim("$")} fell ${c.dim("Interactive mode (default)")}`)
|
|
260
|
+
console.log(` ${c.dim("$")} fell ${c.cyan("--list")} ${c.dim("Print worktrees and exit")}`)
|
|
261
|
+
console.log(` ${c.dim("$")} fell ${c.cyan("--help")} ${c.dim("Show this help")}`)
|
|
262
|
+
console.log()
|
|
263
|
+
console.log(c.yellow(" INTERACTIVE COMMANDS"))
|
|
264
|
+
console.log()
|
|
265
|
+
console.log(` ${c.cyan("up/down")} or ${c.cyan("k/j")} Navigate worktree list`)
|
|
266
|
+
console.log(` ${c.cyan("space")} Toggle selection`)
|
|
267
|
+
console.log(` ${c.cyan("a")} Select / deselect all`)
|
|
268
|
+
console.log(` ${c.cyan("e")} Expand / collapse file list`)
|
|
269
|
+
console.log(` ${c.cyan("o")} Open worktree in file manager`)
|
|
270
|
+
console.log(` ${c.cyan("d")} Delete worktree(s) + optionally branches`)
|
|
271
|
+
console.log(` ${c.cyan("p")} Prune stale references`)
|
|
272
|
+
console.log(` ${c.cyan("r")} Refresh list + PR statuses`)
|
|
273
|
+
console.log(` ${c.cyan("?")} In-app help (prune vs delete explained)`)
|
|
274
|
+
console.log(` ${c.cyan("q")} / ${c.cyan("ctrl+c")} Quit`)
|
|
275
|
+
console.log()
|
|
276
|
+
console.log(c.yellow(" PRUNE vs DELETE"))
|
|
277
|
+
console.log()
|
|
278
|
+
console.log(` ${c.bold("prune")} Removes stale git references to worktrees whose directories`)
|
|
279
|
+
console.log(` no longer exist. Equivalent to ${c.cyan("git worktree prune")}.`)
|
|
280
|
+
console.log(` Does ${c.underline("not")} touch any actual worktree directories.`)
|
|
281
|
+
console.log()
|
|
282
|
+
console.log(` ${c.bold("delete")} Removes the worktree directory from disk and cleans up git`)
|
|
283
|
+
console.log(` tracking. Equivalent to ${c.cyan("git worktree remove <path>")}.`)
|
|
284
|
+
console.log(` Optionally also deletes the associated branch.`)
|
|
285
|
+
console.log()
|
|
286
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doccy/fell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive git worktree manager. Navigate, inspect, delete, and prune worktrees with async PR status fetching.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fell": "cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": [
|
|
11
|
+
"cli.ts",
|
|
12
|
+
"lib/"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"git",
|
|
16
|
+
"worktree",
|
|
17
|
+
"cli",
|
|
18
|
+
"tui",
|
|
19
|
+
"interactive",
|
|
20
|
+
"prune",
|
|
21
|
+
"bun"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/medlo-aus/fell.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/medlo-aus/fell",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/medlo-aus/fell/issues"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"bun": ">=1.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|