A Claude Mod (function-hooks plugin): /git draws a clickable panel above the prompt for everyday git — pick a branch with a button (or filter + Enter, or a…

A Claude Mod (a Claude Code plugin built on function hooks): /git draws a clickable git panel in the band above the prompt. Pick a branch with a button, confirm, and it checks out — plus pull / push / fetch, stage-all and commit, without typing a git command.

Git · main (origin/main, ahead 4) ↓ pull ↑ push fetch refresh
▸ branches (12) changes (13)
checkout: type to filter… ← previous branch
[ feat/sandbox-rewrite ] [ feature/seo-optimization ]
[ fix/sandbox-trailing-slash ] ● main (current)
[ feat/github-link-card ] [ pensive-lamport ]
It shells out to plain git through $.process.run (an argv array, never a shell string), so it needs no auth or config of its own: whatever git already resolves in the current working directory — remotes, credentials, upstream tracking — is what runs.
Deliberately limited to safe, everyday operations: status, branch listing, checkout (existing or new branch), pull, push, fetch, stage-all, commit. No force-push, reset, rebase or clean — a panel button shouldn't put a destructive operation one click away.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Function hooks are early-access and their API can change; builds without them simply ignore this plugin's hooks module.git on PATH, and a git repository as the working directory./plugin marketplace add nogu66/claude-code
/plugin install git-ops@nogu-marketplace
Or try it without installing:
git clone https://github.com/nogu66/claude-code.git
cd claude-code/git-ops
claude --plugin-dir . # .claude/settings.json here turns function hooks on
Then run /git inside any git repository.
/git (or /git show) shows the panel; /git stop hides it.
Move the keys into the panel first. Opening the panel leaves your keystrokes with the normal prompt — text meant for the filter box, or a digit hotkey, never reaches the panel. Press ctrl+x tab to focus it; then Tab/arrows move, Enter presses, Esc returns to the prompt. Mouse clicks only work where the terminal reports them (the fullscreen layout).
↓ pull, ↑ push, fetch, refresh on the right. They act on the current branch's existing upstream.branches (n) and changes (n). Only one body is drawn at a time, which keeps the band short.branches tab — built for getting to another branch quickly:● name (current) label instead;1–9, the first nine switchable branches);/-segment prefix, then substring), and the line beneath says what Enter will do — Enter → checkout <best match>, or Enter → create new branch <name> when nothing matches;← previous branch — git checkout -;Check out <branch>? · [ OK ] / [ cancel ], hotkeys y / n), with a warning when there are uncommitted changes.changes tab — staged (green) on the left, unstaged (yellow) and untracked on the right; below them + stage all (git add -A) and a commit box (Enter runs git commit -m).Checkout asks first. Everything else — pull, push, fetch, stage all, commit — runs the moment you click or press Enter, exactly as if you had typed the git command. push reaches outside your machine.
The same argv builders, without the panel:
| Command | Runs |
|---|---|
/git status | git status |
/git branch | git branch |
/git checkout <branch> | git checkout <branch> |
/git checkout -b <branch> | git checkout -b <branch> |
/git back | git checkout - |
/git pull · /git push · /git fetch | the same, no flags |
/git stage | git add -A |
/git commit <message> | git commit -m <message> |
/git help | prints this list |
Text subcommands run immediately, without the confirmation step.
refresh click — not when git runs elsewhere (for instance in a Bash tool call).- and whitespace (so a name can't be read as a git flag); git itself rejects anything else invalid.next(e) so they stack, but that combination isn't tested.bun test # pure logic in hooks/lib/gitops.ts — no $ or UI to mock
claude plugin validate . # lists the hooked events and $ calls
hooks/register.tsx — the only file that touches $: registers /git, runs git, draws the AbovePrompt panel, and holds its in-memory UI state.hooks/lib/gitops.ts — pure functions: command parsing, git argv builders, branch/status output parsing, branch filtering and the Enter-target decision, column layout.tests/gitops.test.ts — tests for the above.For exact types, run /plugin-types in a session with function hooks on; it writes .claude/types/claude-code.d.ts.
MIT
hooks/register.tsx 451 lines1/* @jsx h */
2// Hooks module. Wires events to the pure logic in ./lib/gitops.ts — this file owns all `$`
3// calls; command parsing, git argv construction, and status/branch parsing live in
4// lib/gitops.ts.
5//
6// `/git` (no args, or `show`) draws a clickable panel in the `AbovePrompt` band (above the
7// prompt input, not a side Pane): a title row (current branch + ahead/behind on the left,
8// pull/push/fetch/refresh buttons on the right) and two tabs — `branches` (the local branches
9// most-recent first in side-by-side columns: click one, press its digit, or type in the
10// filter box and press Enter — no match creates the branch; plus "previous branch") and
11// `changes` (staged
12// on the left, unstaged/untracked on the right, plus "stage all" and a commit Input).
13// `/git stop` hides it. The original text subcommands (`status | branch | checkout <branch> |
14// checkout -b <branch> | pull | push | fetch | stage | commit <message>`) still work for
15// scripting/quick use and share the same pure argv builders.
16//
17// `Button`/`Input` are wired directly in this hooks module — no `Client` surface module, since
18// nothing here needs its own frame clock. Function hooks are early-access: run `/plugin-types`
19// and check the generated `.claude/types/claude-code.d.ts` if an event or `$` call is refused.
20//
21// Deliberately scoped to safe, everyday operations: status, branch listing, checkout, pull,
22// push, fetch, stage-all, commit. No force-push, reset, rebase, or clean are exposed here — a
23// panel button shouldn't make a destructive git operation a single click away.
24
25import type { Register } from "claude-code"
26import {
27 buildBranchListArgv,
28 buildStatusArgv,
29 exitCodeOf,
30 filterBranches,
31 formatBranchRowLabel,
32 formatCheckoutHint,
33 formatGitFailure,
34 formatStatusHeader,
35 formatUsage,
36 isGitFailure,
37 parseBranchListOutput,
38 parseGitCommand,
39 parseStatusOutput,
40 resolveCheckoutTarget,
41 rowsForWidth,
42 splitIntoColumns,
43 summarizeList,
44 toArgv,
45 type BranchInfo,
46 type GitCommand,
47 type StatusInfo,
48} from "./lib/gitops"
49
50const BRANCH_LIST_LIMIT = 60
51const BRANCH_ROWS = 6
52// `[ label ]` plus a hotkey prefix and a gap before the next column
53const BUTTON_CHROME_WIDTH = 10
54const FILE_LIST_LIMIT = 8
55
56type Tab = "branches" | "changes"
57
58let open = false
59let tab: Tab = "branches"
60let branches: BranchInfo[] = []
61let status: StatusInfo | undefined
62let loadError: string | undefined
63let loadingRefresh = false
64
65let branchQuery = ""
66// a checkout waits here for the person's OK; nothing switches branches without it
67let pendingCheckout: GitCommand | undefined
68let commitDraft = ""
69let actionBusy: string | undefined
70let actionError: string | undefined
71
72function runFailureText(result: { stderr?: string; stdout?: string } | undefined, fallback: string) {
73 return (result?.stderr || result?.stdout || fallback).trim()
74}
75
76async function refreshAll($: any): Promise<void> {
77 loadingRefresh = true
78 try {
79 const branchResult = await $.process.run(["git", ...buildBranchListArgv()])
80 if (isGitFailure(branchResult)) {
81 loadError = runFailureText(branchResult, "not a git repository (or git branch failed)")
82 branches = []
83 status = undefined
84 return
85 }
86 const branchParsed = parseBranchListOutput(String(branchResult?.stdout ?? ""))
87 branches = branchParsed.ok ? branchParsed.value : []
88
89 const statusResult = await $.process.run(["git", ...buildStatusArgv()])
90 if (isGitFailure(statusResult)) {
91 loadError = runFailureText(statusResult, "git status failed")
92 status = undefined
93 return
94 }
95 const statusParsed = parseStatusOutput(String(statusResult?.stdout ?? ""))
96 if (!statusParsed.ok) {
97 loadError = statusParsed.error
98 status = undefined
99 return
100 }
101 status = statusParsed.value
102 loadError = undefined
103 } catch (err) {
104 loadError = `git unavailable: ${err}`
105 branches = []
106 status = undefined
107 } finally {
108 loadingRefresh = false
109 }
110}
111
112async function runAction($: any, command: GitCommand, busyLabel: string): Promise<void> {
113 actionBusy = busyLabel
114 actionError = undefined
115 pendingCheckout = undefined
116 $.ui.invalidate("ui.render")
117 try {
118 const result = await $.process.run(["git", ...toArgv(command)])
119 if (isGitFailure(result)) {
120 actionError = formatGitFailure(command, exitCodeOf(result), result.stderr ?? "", result.stdout ?? "")
121 return
122 }
123 if (command.kind === "commit") commitDraft = ""
124 if (command.kind === "checkout" || command.kind === "back") branchQuery = ""
125 await refreshAll($)
126 } catch (err) {
127 actionError = `git unavailable: ${err}`
128 } finally {
129 actionBusy = undefined
130 $.ui.invalidate("ui.render")
131 }
132}
133
134export const register: Register = (on) => {
135 on("session.start", async ($, e, next) => {
136 const r = await next(e)
137 await $.command
138 .register({
139 name: "git",
140 description:
141 "Browse branches/status and checkout/pull/push/fetch/stage/commit in a clickable panel, or via text subcommands (git-ops)",
142 argumentHint:
143 "[show | stop | status | branch | checkout <branch> | checkout -b <branch> | back | pull | push | fetch | stage | commit <message>]",
144 immediate: true,
145 })
146 .catch((err: unknown) => $.ui.log(`git-ops: command.register failed: ${err}`))
147 return r
148 })
149
150 on("command.run", { command: "git" }, async ($, e) => {
151 const trimmed = e.args.trim()
152 const lower = trimmed.toLowerCase()
153
154 if (trimmed === "" || lower === "show") {
155 if (!open) {
156 open = true
157 await refreshAll($)
158 $.ui.invalidate("ui.render")
159 }
160 return {
161 text: "Git panel showing above the prompt — ctrl+x tab moves the keys into it (Tab/arrows walk, Enter presses, Esc returns to the prompt); clicks work in the fullscreen terminal · /git stop hides it",
162 }
163 }
164 if (lower === "stop") {
165 open = false
166 $.ui.invalidate("ui.render")
167 return { text: "Git panel hidden · /git show brings it back" }
168 }
169
170 // Text subcommands still work standalone, sharing the same argv builders the panel's
171 // buttons use.
172 const parsed = parseGitCommand(trimmed)
173 if (!parsed.ok) return { text: parsed.error ?? formatUsage() }
174 const command = parsed.command as GitCommand
175 if (command.kind === "help") return { text: formatUsage() }
176
177 try {
178 const result = await $.process.run(["git", ...toArgv(command)])
179 if (isGitFailure(result)) {
180 return { text: formatGitFailure(command, exitCodeOf(result), result?.stderr ?? "", result?.stdout ?? "") }
181 }
182 if (open) {
183 await refreshAll($)
184 $.ui.invalidate("ui.render")
185 }
186 // git often writes normal progress (pull/push/fetch) to stderr even on success
187 const stdout = String(result?.stdout ?? "").trim()
188 const stderr = String(result?.stderr ?? "").trim()
189 return { text: stdout || stderr || "(git reported success with no output)" }
190 } catch (err) {
191 return {
192 text: `git-ops: failed to run git — ${err}\nIs git installed and on PATH, and are you inside a git repository?`,
193 }
194 }
195 })
196
197 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
198 if (!open || e.surface !== "terminal") return next(e)
199 const { Box, Text, Button, Input } = await $.ui.resolve(e)
200 const cols = e.props.bodyColumns ?? 80
201
202 const header = status ? formatStatusHeader(status) : loadingRefresh ? "loading…" : "(no status yet)"
203 const changeCount = status ? status.staged.length + status.unstaged.length + status.untracked.length : 0
204
205 const selectTab = (target: Tab) => () => {
206 tab = target
207 $.ui.invalidate("ui.render")
208 }
209
210 const askCheckout = (command: GitCommand) => {
211 pendingCheckout = command
212 actionError = undefined
213 $.ui.invalidate("ui.render")
214 }
215
216 const titleRow = (
217 <Box flexDirection="row" justifyContent="space-between" width={cols}>
218 <Text bold wrap="truncate-end">
219 Git · {header}
220 </Text>
221 <Box flexDirection="row" columnGap={2}>
222 <Button key="act-pull" label="↓ pull" plain onPress={() => runAction($, { kind: "pull" }, "pulling…")} />
223 <Button key="act-push" label="↑ push" plain onPress={() => runAction($, { kind: "push" }, "pushing…")} />
224 <Button key="act-fetch" label="fetch" plain onPress={() => runAction($, { kind: "fetch" }, "fetching…")} />
225 <Button
226 key="act-refresh"
227 label="refresh"
228 plain
229 dimColor
230 onPress={async () => {
231 await refreshAll($)
232 $.ui.invalidate("ui.render")
233 }}
234 />
235 </Box>
236 </Box>
237 )
238
239 const tabRow = (
240 <Box flexDirection="row" columnGap={3}>
241 <Button
242 key="tab-branches"
243 label={`${tab === "branches" ? "▸ " : " "}branches (${branches.length})`}
244 plain
245 dimColor={tab !== "branches"}
246 onPress={selectTab("branches")}
247 />
248 <Button
249 key="tab-changes"
250 label={`${tab === "changes" ? "▸ " : " "}changes (${changeCount})`}
251 plain
252 dimColor={tab !== "changes"}
253 onPress={selectTab("changes")}
254 />
255 {actionBusy && <Text color="cyan">{actionBusy}</Text>}
256 </Box>
257 )
258
259 const notices = (
260 <Box flexDirection="column">
261 {loadError && <Text color="red">{loadError}</Text>}
262 {actionError && <Text color="red">{actionError}</Text>}
263 </Box>
264 )
265
266 let body
267 if (pendingCheckout) {
268 const pending = pendingCheckout
269 const question =
270 pending.kind === "back"
271 ? "Switch back to the previous branch?"
272 : pending.kind === "checkout" && pending.create
273 ? `Create new branch ${pending.branch} and check it out?`
274 : pending.kind === "checkout"
275 ? `Check out ${pending.branch}?`
276 : ""
277 const dirty = status ? status.staged.length + status.unstaged.length : 0
278 const busyLabel =
279 pending.kind === "checkout" ? `checking out ${pending.branch}…` : "switching back…"
280 body = (
281 <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
282 <Text bold>{question}</Text>
283 {dirty > 0 && (
284 <Text color="yellow">
285 {dirty} uncommitted change{dirty === 1 ? "" : "s"} will come along (git refuses if they conflict)
286 </Text>
287 )}
288 <Box flexDirection="row" columnGap={2}>
289 <Button key="confirm-ok" label="OK" hotkey="y" autoFocus onPress={() => runAction($, pending, busyLabel)} />
290 <Button
291 key="confirm-cancel"
292 label="cancel"
293 hotkey="n"
294 onPress={() => {
295 pendingCheckout = undefined
296 $.ui.invalidate("ui.render")
297 }}
298 />
299 </Box>
300 </Box>
301 )
302 } else if (tab === "branches") {
303 const matches = filterBranches(branches, branchQuery)
304 const shown = matches.slice(0, BRANCH_LIST_LIMIT)
305 const more = Math.max(0, matches.length - BRANCH_LIST_LIMIT)
306 const target = resolveCheckoutTarget(branches, branchQuery)
307 // hotkeys go to the branches a press can actually switch to, in the order drawn
308 const hotkeys = new Map(
309 shown
310 .filter((b) => !b.current)
311 .slice(0, 9)
312 .map((b, i) => [b.name, String(i + 1)]),
313 )
314 const cellWidth = Math.min(
315 cols,
316 Math.max(0, ...shown.map((b) => b.name.length + (b.current ? 12 : 0))) + BUTTON_CHROME_WIDTH,
317 )
318 const columns = splitIntoColumns(shown, rowsForWidth(shown.length, cellWidth, cols, BRANCH_ROWS))
319
320 body = (
321 <Box flexDirection="column">
322 <Box flexDirection="row" columnGap={2}>
323 <Input
324 key="branch-query"
325 label="checkout"
326 placeholder="type to filter…"
327 value={branchQuery}
328 submitLabel="go"
329 autoFocus
330 onInput={(v: string) => {
331 branchQuery = v
332 $.ui.invalidate("ui.render")
333 }}
334 onSubmit={(v: string) => {
335 branchQuery = v
336 const submitted = resolveCheckoutTarget(branches, v)
337 if (submitted.kind === "checkout") {
338 askCheckout({ kind: "checkout", branch: submitted.branch, create: false })
339 } else if (submitted.kind === "create") {
340 askCheckout({ kind: "checkout", branch: submitted.branch, create: true })
341 } else {
342 actionError =
343 submitted.kind === "invalid"
344 ? `git-ops: ${formatCheckoutHint(submitted)}`
345 : v.trim()
346 ? `git-ops: already on ${v.trim()} — nothing to check out`
347 : "git-ops: type a branch name first, or click a branch button"
348 $.ui.invalidate("ui.render")
349 }
350 }}
351 />
352 <Button key="act-back" label="← previous branch" plain onPress={() => askCheckout({ kind: "back" })} />
353 </Box>
354 <Text dimColor={target.kind === "none"} color={target.kind === "invalid" ? "red" : target.kind === "none" ? undefined : "cyan"}>
355 {formatCheckoutHint(target)}
356 </Text>
357 <Box flexDirection="row">
358 {columns.map((column, i) => (
359 <Box key={`branch-col-${i}`} flexDirection="column" width={cellWidth}>
360 {column.map((b) =>
361 b.current ? (
362 <Text key={`branch-${b.name}`} bold color="green" wrap="truncate-end">
363 ● {b.name} (current)
364 </Text>
365 ) : (
366 <Button
367 key={`branch-${b.name}`}
368 label={formatBranchRowLabel(b, cellWidth - BUTTON_CHROME_WIDTH).trimStart()}
369 hotkey={hotkeys.get(b.name)}
370 onPress={() => askCheckout({ kind: "checkout", branch: b.name, create: false })}
371 />
372 ),
373 )}
374 </Box>
375 ))}
376 </Box>
377 {more > 0 && <Text dimColor>+{more} more — type to narrow</Text>}
378 </Box>
379 )
380 } else {
381 const half = Math.max(20, Math.floor(cols / 2))
382 const fileList = (title: string, files: string[], color: string | undefined, keyPrefix: string) => {
383 const info = summarizeList(files, FILE_LIST_LIMIT)
384 return (
385 <Box flexDirection="column">
386 <Text bold color={color}>
387 {title} ({files.length})
388 </Text>
389 {files.length === 0 && <Text dimColor> —</Text>}
390 {info.shown.map((f, i) => (
391 <Text key={`${keyPrefix}-${i}`} color={color} wrap="truncate-start">
392 {" "}
393 {f}
394 </Text>
395 ))}
396 {info.more > 0 && <Text dimColor> +{info.more} more</Text>}
397 </Box>
398 )
399 }
400
401 body = (
402 <Box flexDirection="column">
403 <Box flexDirection="row">
404 <Box flexDirection="column" width={half} borderStyle="round" borderDimColor paddingX={1}>
405 {fileList("staged", status?.staged ?? [], "green", "staged")}
406 </Box>
407 <Box flexDirection="column" width={half} borderStyle="round" borderDimColor paddingX={1}>
408 {fileList("unstaged", status?.unstaged ?? [], "yellow", "unstaged")}
409 {fileList("untracked", status?.untracked ?? [], undefined, "untracked")}
410 </Box>
411 </Box>
412 <Box flexDirection="row" columnGap={2}>
413 <Button key="act-stage" label="+ stage all" plain onPress={() => runAction($, { kind: "stage" }, "staging…")} />
414 <Input
415 key="commit-message"
416 label="commit"
417 placeholder="message — Enter commits staged changes"
418 value={commitDraft}
419 submitLabel="commit"
420 onInput={(v: string) => {
421 commitDraft = v
422 }}
423 onSubmit={(v: string) => {
424 commitDraft = v
425 if (!v.trim()) {
426 actionError = "git-ops: commit needs a message"
427 $.ui.invalidate("ui.render")
428 return
429 }
430 runAction($, { kind: "commit", message: v.trim() }, "committing…")
431 }}
432 />
433 </Box>
434 </Box>
435 )
436 }
437
438 return (
439 <Box flexDirection="column">
440 {titleRow}
441 {tabRow}
442 {notices}
443 {body}
444 {/* keys stay with the prompt until the person moves them here; without this the panel looks dead */}
445 <Text dimColor>ctrl+x tab: focus this panel · Tab/arrows: move · Enter: press · Esc: back to prompt</Text>
446 {await next(e)}
447 </Box>
448 )
449 })
450}
451hooks/lib/gitops.ts 340 lines1// Pure logic for git-ops. No `$`, no hooks, no UI — testable directly with `bun test`.
2//
3// Parses `/git ...` command text into a structured request, turns that request into the `git`
4// CLI argv to run (argv array via $.process.run, never a shell string), and parses/formats
5// git's output for the transcript and the clickable panel.
6//
7// Deliberately scoped to safe, everyday operations: status, branch listing, checkout (existing
8// or new branch), pull, push, fetch, stage-all, commit. No force-push, reset, rebase, or clean —
9// those stay a Bash command away, not a button.
10
11export type GitCommand =
12 | { kind: "status" }
13 | { kind: "branch" }
14 | { kind: "checkout"; branch: string; create: boolean }
15 | { kind: "back" }
16 | { kind: "pull" }
17 | { kind: "push" }
18 | { kind: "fetch" }
19 | { kind: "stage" }
20 | { kind: "commit"; message: string }
21 | { kind: "help" }
22
23export interface ParseResult {
24 ok: boolean
25 command?: GitCommand
26 error?: string
27}
28
29const USAGE = [
30 "/git status — show working tree status",
31 "/git branch — list local branches",
32 "/git checkout <branch> — switch to an existing branch",
33 "/git checkout -b <branch> — create and switch to a new branch",
34 "/git back — switch to the previously checked-out branch (git checkout -)",
35 "/git pull — pull the current branch's upstream",
36 "/git push — push the current branch to its upstream",
37 "/git fetch — fetch from the remote",
38 "/git stage — stage all changes (git add -A)",
39 "/git commit <message> — commit staged changes",
40].join("\n")
41
42export function formatUsage(): string {
43 return `git-ops usage:\n${USAGE}`
44}
45
46// Guards against argument injection through a typed branch name (e.g. "--upload-pack=evil") —
47// $.process.run's argv array already rules out shell injection, but a leading "-" would still
48// be read by git itself as a flag rather than a branch name.
49export function isValidBranchName(name: string): boolean {
50 const trimmed = name.trim()
51 if (!trimmed) return false
52 if (trimmed.startsWith("-")) return false
53 if (/\s/.test(trimmed)) return false
54 return true
55}
56
57export function parseGitCommand(rawArgs: string): ParseResult {
58 const trimmed = rawArgs.trim()
59 if (trimmed.length === 0) return { ok: true, command: { kind: "help" } }
60
61 const firstSpace = trimmed.search(/\s/)
62 const sub = (firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase()
63 const rest = (firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1)).trim()
64
65 switch (sub) {
66 case "status":
67 return { ok: true, command: { kind: "status" } }
68 case "branch":
69 return { ok: true, command: { kind: "branch" } }
70 case "checkout": {
71 let body = rest
72 let create = false
73 if (body === "-b" || body.startsWith("-b ")) {
74 create = true
75 body = body.slice(2).trim()
76 } else if (body === "--create" || body.toLowerCase().startsWith("--create ")) {
77 create = true
78 body = body.slice("--create".length).trim()
79 }
80 if (!isValidBranchName(body)) {
81 return {
82 ok: false,
83 error:
84 "git-ops: /git checkout needs a branch name — `/git checkout <branch>` or `/git checkout -b <branch>`",
85 }
86 }
87 return { ok: true, command: { kind: "checkout", branch: body, create } }
88 }
89 case "back":
90 return { ok: true, command: { kind: "back" } }
91 case "pull":
92 return { ok: true, command: { kind: "pull" } }
93 case "push":
94 return { ok: true, command: { kind: "push" } }
95 case "fetch":
96 return { ok: true, command: { kind: "fetch" } }
97 case "stage":
98 return { ok: true, command: { kind: "stage" } }
99 case "commit": {
100 const message = rest.trim()
101 if (!message) {
102 return { ok: false, error: "git-ops: /git commit needs a message — `/git commit <message>`" }
103 }
104 return { ok: true, command: { kind: "commit", message } }
105 }
106 case "help":
107 return { ok: true, command: { kind: "help" } }
108 default:
109 return { ok: false, error: `git-ops: unknown subcommand "${sub}"\n${USAGE}` }
110 }
111}
112
113export function toArgv(command: GitCommand): string[] {
114 switch (command.kind) {
115 case "status":
116 return ["status"]
117 case "branch":
118 return ["branch"]
119 case "checkout":
120 return command.create ? ["checkout", "-b", command.branch] : ["checkout", command.branch]
121 case "back":
122 return ["checkout", "-"]
123 case "pull":
124 return ["pull"]
125 case "push":
126 return ["push"]
127 case "fetch":
128 return ["fetch"]
129 case "stage":
130 return ["add", "-A"]
131 case "commit":
132 return ["commit", "-m", command.message]
133 case "help":
134 return []
135 }
136}
137
138// `$.process.run` reports the exit status as `exitCode`; older builds (and our first version)
139// used `code`. Read either — a status we fail to read makes every git failure look like success.
140export function exitCodeOf(result: unknown): number | undefined {
141 if (!result || typeof result !== "object") return undefined
142 const r = result as { exitCode?: unknown; code?: unknown }
143 if (typeof r.exitCode === "number") return r.exitCode
144 if (typeof r.code === "number") return r.code
145 return undefined
146}
147
148export function isGitFailure(result: unknown): boolean {
149 const code = exitCodeOf(result)
150 return code !== undefined && code !== 0
151}
152
153export function formatGitFailure(command: GitCommand, code: number | undefined, stderr: string, stdout: string): string {
154 const detail = (stderr || stdout || "").trim() || "(no output)"
155 return `git-ops: git failed on \`${command.kind}\` (exit ${code ?? "?"})\n${detail}`
156}
157
158// --- The clickable panel: branch list + status, fetch/parse/format -------------------------
159
160export interface BranchInfo {
161 name: string
162 current: boolean
163}
164
165export function buildBranchListArgv(): string[] {
166 // most recently committed-to first: the branch you want is usually one you just worked on
167 return ["branch", "--sort=-committerdate", "--format=%(HEAD)\t%(refname:short)"]
168}
169
170export type JsonResult<T> = { ok: true; value: T } | { ok: false; error: string }
171
172export function parseBranchListOutput(raw: string): JsonResult<BranchInfo[]> {
173 const lines = raw
174 .split("\n")
175 .map((l) => l.replace(/\r$/, ""))
176 .filter((l) => l.trim().length > 0)
177 const branches: BranchInfo[] = []
178 for (const line of lines) {
179 const tabIdx = line.indexOf("\t")
180 if (tabIdx === -1) continue
181 const marker = line.slice(0, tabIdx).trim()
182 const name = line.slice(tabIdx + 1).trim()
183 if (!name) continue
184 branches.push({ name, current: marker === "*" })
185 }
186 return { ok: true, value: branches }
187}
188
189// Case-insensitive match, best first: exact, then prefix of the whole name, then prefix of a
190// "/"-separated segment, then substring. Order within a rank is the input's (recency).
191export function filterBranches(branches: BranchInfo[], query: string): BranchInfo[] {
192 const q = query.trim().toLowerCase()
193 if (!q) return branches
194 const rank = (name: string): number => {
195 const n = name.toLowerCase()
196 if (n === q) return 0
197 if (n.startsWith(q)) return 1
198 if (n.split("/").some((segment) => segment.startsWith(q))) return 2
199 if (n.includes(q)) return 3
200 return -1
201 }
202 return branches
203 .map((branch, index) => ({ branch, index, rank: rank(branch.name) }))
204 .filter((entry) => entry.rank !== -1)
205 .sort((a, b) => a.rank - b.rank || a.index - b.index)
206 .map((entry) => entry.branch)
207}
208
209export type CheckoutTarget =
210 | { kind: "none" }
211 | { kind: "invalid" }
212 | { kind: "checkout"; branch: string }
213 | { kind: "create"; branch: string }
214
215// What Enter in the branch box does: check out the best match that isn't already checked out,
216// or, when nothing matches, create a branch of that name.
217export function resolveCheckoutTarget(branches: BranchInfo[], query: string): CheckoutTarget {
218 const q = query.trim()
219 if (!q) return { kind: "none" }
220 const matches = filterBranches(branches, q)
221 if (matches.length === 0) return isValidBranchName(q) ? { kind: "create", branch: q } : { kind: "invalid" }
222 const target = matches.find((b) => !b.current)
223 return target ? { kind: "checkout", branch: target.name } : { kind: "none" }
224}
225
226export function formatCheckoutHint(target: CheckoutTarget): string {
227 switch (target.kind) {
228 case "checkout":
229 return `Enter → checkout ${target.branch}`
230 case "create":
231 return `Enter → create new branch ${target.branch}`
232 case "invalid":
233 return "not a valid branch name (no spaces, can't start with '-')"
234 case "none":
235 return "type to filter · Enter checks out the first match, or creates the branch if nothing matches"
236 }
237}
238
239export interface StatusInfo {
240 branch: string
241 upstream?: string
242 ahead: number
243 behind: number
244 staged: string[]
245 unstaged: string[]
246 untracked: string[]
247}
248
249export function buildStatusArgv(): string[] {
250 return ["status", "--porcelain=v1", "-b"]
251}
252
253export function parseStatusOutput(raw: string): JsonResult<StatusInfo> {
254 const lines = raw.split("\n").map((l) => l.replace(/\r$/, ""))
255 if (lines.length === 0 || !lines[0].startsWith("##")) {
256 return { ok: false, error: "unexpected git status output (missing branch header)" }
257 }
258
259 const header = lines[0].slice(2).trim()
260 let ahead = 0
261 let behind = 0
262 let headPart = header
263
264 const bracketMatch = header.match(/^(.*?)\s*\[(.+)\]$/)
265 if (bracketMatch) {
266 headPart = bracketMatch[1].trim()
267 const bracket = bracketMatch[2]
268 const aheadMatch = bracket.match(/ahead (\d+)/)
269 const behindMatch = bracket.match(/behind (\d+)/)
270 if (aheadMatch) ahead = Number(aheadMatch[1])
271 if (behindMatch) behind = Number(behindMatch[1])
272 }
273
274 let branch = headPart
275 let upstream: string | undefined
276 const dotsIdx = headPart.indexOf("...")
277 if (dotsIdx !== -1) {
278 branch = headPart.slice(0, dotsIdx)
279 upstream = headPart.slice(dotsIdx + 3)
280 }
281
282 const staged: string[] = []
283 const unstaged: string[] = []
284 const untracked: string[] = []
285
286 for (const line of lines.slice(1)) {
287 if (line.trim().length === 0) continue
288 const x = line[0]
289 const y = line[1]
290 const file = line.slice(3)
291 if (x === "?" && y === "?") {
292 untracked.push(file)
293 continue
294 }
295 if (x !== " " && x !== undefined) staged.push(file)
296 if (y !== " " && y !== undefined && y !== "?") unstaged.push(file)
297 }
298
299 return { ok: true, value: { branch, upstream, ahead, behind, staged, unstaged, untracked } }
300}
301
302function truncate(text: string, maxLen: number): string {
303 return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text
304}
305
306export function formatBranchRowLabel(branch: BranchInfo, maxLen = 60): string {
307 const marker = branch.current ? "* " : " "
308 return truncate(`${marker}${branch.name}`, maxLen)
309}
310
311export function formatStatusHeader(status: StatusInfo): string {
312 const bits: string[] = []
313 if (status.upstream) bits.push(status.upstream)
314 if (status.ahead > 0) bits.push(`ahead ${status.ahead}`)
315 if (status.behind > 0) bits.push(`behind ${status.behind}`)
316 const tracking = bits.length > 0 ? ` (${bits.join(", ")})` : ""
317 return `${status.branch}${tracking}`
318}
319
320// Column-major split: the first column fills top to bottom before the second starts, so an
321// alphabetical branch list still reads down, then across.
322export function splitIntoColumns<T>(items: T[], rowsPerColumn: number): T[][] {
323 const rows = Math.max(1, Math.floor(rowsPerColumn))
324 const columns: T[][] = []
325 for (let i = 0; i < items.length; i += rows) columns.push(items.slice(i, i + rows))
326 return columns
327}
328
329// Rows per column so that `count` cells of `cellWidth` fit across `bodyColumns`: the preferred
330// height when that many columns fit, taller when the band is too narrow for them.
331export function rowsForWidth(count: number, cellWidth: number, bodyColumns: number, preferredRows: number): number {
332 const maxColumns = Math.max(1, Math.floor(bodyColumns / Math.max(1, cellWidth)))
333 return Math.max(preferredRows, Math.ceil(count / maxColumns))
334}
335
336export function summarizeList(items: string[], limit: number): { shown: string[]; more: number } {
337 if (items.length <= limit) return { shown: items, more: 0 }
338 return { shown: items.slice(0, limit), more: items.length - limit }
339}
340