SLOPSHOPPER

git-ops

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…

newbandcommandprocess
v0.1.0MITupdated 2026-09-18nogu66/claude-code/git-ops
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-ops
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /git ⎿ git-ops: Git panel showing above the prompt — ctrl+x tab moves the keys into it (Tab/arrows walk, Enter presses, Esc re Git · (no status yet) ↓ pull ↑ push fetch refresh ▸ branches (0) changes (0) unexpected git status output (missing branch header) checkout: type to filter… ⏎ go ← previous branch type to filter · Enter checks out the first match, or creates the branch if nothing matches ctrl+x tab: focus this panel · Tab/arrows: move · Enter: press · Esc: back to prompt ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Git · (no status yet) ↓ pull ↑ push fetch refresh ▸ branches (0) changes (0) unexpected git status output (missing branch header) checkout: type to filter… ⏎ go ← previous branch type to filter · Enter checks out the first match, or creates the branch if nothing matches ctrl+x tab: focus this panel · Tab/arrows: move · Enter: press · Esc: back to prompt ⟨Claude Code's own drawing⟩
README

git-ops

日本語

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-ops demo: /git opens the panel, pick a branch, confirm, checked out

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.

Requirements

  • A Claude Code build with function hooks, enabled with 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.
  • The terminal surface (the panel passes through untouched on desktop/mobile).

Install

/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.

The panel

/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).

  • Title row — current branch, upstream and ahead/behind on the left; ↓ pull, ↑ push, fetch, refresh on the right. They act on the current branch's existing upstream.
  • Tabs — 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:
  • local branches, most recently committed-to first, as framed buttons in side-by-side columns; the current branch is a green ● name (current) label instead;
  • click a button (or Tab to it and press Enter), or press its digit (1–9, the first nine switchable branches);
  • a filter box: typing narrows the list (exact match, then name prefix, then /-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 -;
  • nothing switches without an OK: every one of the above opens a confirmation first (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.

Text subcommands

The same argv builders, without the panel:

CommandRuns
/git statusgit status
/git branchgit branch
/git checkout <branch>git checkout <branch>
/git checkout -b <branch>git checkout -b <branch>
/git backgit checkout -
/git pull · /git push · /git fetchthe same, no flags
/git stagegit add -A
/git commit <message>git commit -m <message>
/git helpprints this list

Text subcommands run immediately, without the confirmation step.

Limitations

  • The panel refreshes after its own actions or a refresh click — not when git runs elsewhere (for instance in a Bash tool call).
  • Panel state (filter text, commit draft, open tab) lives in memory for the session only.
  • Branch names typed into the filter box are only guarded against a leading - and whitespace (so a name can't be read as a git flag); git itself rejects anything else invalid.
  • Local branches only, at most 60 shown; no remote-branch picker, merge, rebase, stash, log or diff view.
  • It shares the above-prompt band with any other mod that draws there; it chains next(e) so they stack, but that combination isn't tested.

Development

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.

License

MIT

Source 2 files
hooks/register.tsx 451 lines
1/* @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}
451
hooks/lib/gitops.ts 340 lines
1// 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