SLOPSHOPPER

loc-split

Lines changed against main, split into code, comments, tests, docs and generated files, in a row above the prompt that expands into a per-commit table

newbandguardcommandprocesstimer
v0.1.2MITupdated 2026-10-08RomanHotsiy/claude-mods/loc-split
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loc-split
› 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 › /loc-split vs main · 0 commits since code Σ net vs main · commits ↑0 ▾ no changes ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
vs main · 0 commits since code Σ net vs main · commits ↑0 ▾ no changes
README

loc-split

A Claude Code mod that shows what your branch changed against main, split into code, comments, tests, docs and gen (generated files), in a row right above the prompt.

loc-split, collapsed and expanded

commits ↑7 ▴              code +788 −89 · comments +135 · tests +1210 −1 · docs +52 · gen +6

Click commits ↑7 (or type /loc-split) to open the per-commit table above it: every commit since the branch left main, your uncommitted changes, and the net total. The list scrolls on its own when it is long; the totals and the row stay put.

What it counts

The numbers are lines added and removed between the merge base with main and your working tree, untracked files included. The base is the first of origin/main, main, origin/master, master that exists, then whatever origin/HEAD points at.

CategoryFiles
genfiles a tool writes or a project vendors (full list in hooks/split.ts):<br>• lockfiles: npm, pnpm, Yarn (and PnP), Bun, Deno, Cargo, Go, Poetry, uv, PDM, Pipenv, Bundler, Composer, CocoaPods, SwiftPM, Pub, Mix, Gradle, NuGet, Nix, Terraform, Bazel and more<br>• migrations and ORMs: Drizzle meta/ snapshots and journal, Prisma migration_lock.toml, Django migrations/0001_*.py, Rails db/schema.rb, EF Core designer files and model snapshots, sqlc *.sql.go<br>• test snapshots: Jest/Vitest .snap and __snapshots__/, insta, syrupy, swift-snapshot-testing<br>• codegen: *.gen.*, *.generated.*, *_generated.*, zz_generated.*, __generated__/, protobuf and gRPC for Go, Python, C++, JS/TS, Swift and Dart, build_runner and freezed, .NET designer files, next-env.d.ts, Xcode project.pbxproj<br>• bundles: dist/, *.min.js, *.min.css, source maps<br>• vendored: a top-level vendor/, node_modules/<br>• anything .gitattributes marks linguist-generated or linguist-vendored, as GitHub reads it
tests• by name: *.test.*, *.spec.*, *_test.* (Go, Python, Ruby, Elixir, Dart, C++, Rust), test_*, *.e2e.*, *.e2e-spec.*, Cypress *.cy.*, type tests *.test-d.ts, *_unittest.*, FooTest / FooTests / FooSpec / FooIT / FooSuite / FooTestCase (JVM, .NET, Swift, Objective-C, PHP, Dart, Haskell, C++), conftest.py, Django tests.py, RSpec helpers, Bats *.bats, Perl *.t, testthat test-*.R<br>• by folder: test/, tests/, __tests__/, __mocks__/, mocks/, fixtures/, e2e/, integration_test/, androidTest/, testFixtures/, testdata/, cypress/, playwright/, .NET *.Tests/ projects, and spec/ for code (an OpenAPI spec/openapi.yaml stays code)
docs.md, .mdx, .rst, .adoc, .txt, .org, .tex, .pod, README, CHANGELOG, LICENSE, CONTRIBUTING, SECURITY and the like, and anything under docs/, doc/, documentation/, wiki/, guides/
code / commentseverything else, each changed line sorted by whether it is a comment

Categories are checked in that order, so src/__snapshots__/button.test.tsx.snap is gen, not tests.

Comments are told apart per language:

SyntaxLanguages
//, /* */ (JSDoc and Javadoc * lines included)JavaScript, TypeScript, Java, Kotlin, Scala, Groovy, Go, Rust, C, C++, Objective-C, C#, Swift, Dart, PHP, Zig, Solidity, Protobuf, Thrift, Prisma, GLSL/HLSL/WGSL/Metal, CUDA, Verilog, SCSS, Less; CSS has /* */ alone
#Python, Ruby, shell, PowerShell, Perl, R, Elixir, Crystal, Nim, Julia, CoffeeScript, YAML, TOML, CMake, Makefile, Dockerfile, Bazel/Starlark, GraphQL, .env, ignore files
# with // and /* */Terraform, HCL, Nix
block commentsPython and GraphQL docstrings, Ruby =begin, Perl POD, Julia #= =#, PowerShell <# #>, CoffeeScript ###, Nim #[ ]#
--SQL, Lua (--[[ ]]), Haskell, Elm, PureScript ({- -}), Ada, VHDL
(* *)OCaml, F# (with //)
;Clojure, Emacs Lisp, Scheme, Racket, Fennel (`##`), INI, assembly
othersErlang %, Fortran !, Visual Basic ', batch REM / ::, Vim "
markupHTML, XML, SVG, XAML, MSBuild and plist files <!-- -->; Vue, Svelte and Astro (<!-- --> and //); Jinja, Twig, Nunjucks {# #}; Handlebars and Mustache {{! }}

A line with code before its comment counts as code; a blank line inside a block comment counts as a comment.

Generated files are counted with git diff --numstat, so a large lockfile change costs nothing to measure, and they are left out of the comment analysis entirely.

Categories with no changed lines are left off the row. When the band is narrow the row shortens the labels (cmt, test, doc), then shows added lines only, then drops the word "commits".

When it refreshes

When the session starts, a moment after Claude edits a file or runs a command, at the end of every turn, and every 20 seconds, so commits you make in your own terminal show up too.

Install

Claude desktop app

  1. Open Settings.
  2. Type plugins and press Enter.
  3. In the top right corner, click Add.
  4. Enter RomanHotsiy/claude-mods and click Sync.
  5. You should now see Loc split in the list.

Claude Code in a terminal

/plugin marketplace add RomanHotsiy/claude-mods
/plugin install loc-split@claude-mods
/reload-plugins

The row shows in any session whose working directory is a git repository with a main or master branch.

What it runs, reads and sends

A mod is code that runs inside Claude Code on your machine, with the same access Claude Code has; read the source before you install one. This one is four files under hooks/: register.ts (the hooks, and every call it makes), band.tsx (the drawing), split.ts (sorting lines) and commands.ts (the git command lines).

Sends: nothing. It makes no network calls, and nothing it reads leaves your machine. The model sees nothing from it either, apart from the one line /loc-split prints when the session is not in a git repository with a main or master branch. It writes no files: what it measures is kept in the session's memory and drawn in the band above the prompt.

Runs: git, read-only, in your repository. Every call is in one function, refresh in hooks/register.ts; the commands that print paths are built in hooks/commands.ts and go through git -c core.quotePath=false, so paths with non-ASCII names come back as written:

CommandWhy
git rev-parse --show-toplevelfind the repository root
git symbolic-ref --quiet --short refs/remotes/origin/HEADthe remote's default branch, the last fallback for the base
git rev-parse --verify --quiet <ref>^{commit}which of origin/main, main, origin/master, master exists
git merge-base HEAD <base>where your branch left main
git log --no-merges --max-count=50 --format=%H%x1f%s <merge-base>..HEADthe commits to list
git log --no-merges --max-count=50 -p -U0 -M … <merge-base>..HEAD -- :/ <excludes>each commit's changed lines, to sort into code, comments, tests and docs
git log --no-merges --max-count=50 --numstat --no-renames … -- <generated files>each commit's line counts for generated files
git rev-list --no-merges --count <merge-base>..HEADhow many commits are ahead
git diff -U0 -M … HEAD -- :/ <excludes> and git diff --numstat --no-renames … HEAD -- <generated files>uncommitted changes
git diff -U0 -M … <merge-base> -- :/ <excludes> and git diff --numstat --no-renames … <merge-base> -- <generated files>the net change against main
git ls-files --others --exclude-standard -zuntracked files, which count as added
git check-attr -z --stdin linguist-generated linguist-vendoredwhich untracked files .gitattributes marks generated; their paths go in on stdin
git diff --quiet HEAD -- ':(top,attr:linguist-generated)'once per session: whether this git can filter by .gitattributes; older git falls back to the file patterns alone

<excludes> and <generated files> are the generated-file globs from the table above, as :(top,exclude,glob) and :(top,glob) pathspecs, plus :(top,attr:linguist-generated) and :(top,attr:linguist-vendored) (bare and =true) where git supports them.

Reads: the output of those commands, and the text of untracked files (up to 500, through Claude Code's file access) to count their lines.

Hooks:

EventWhat it does
session.startregisters /loc-split, measures once, and starts a 20-second refresh timer
tool.calllets every tool call through unchanged; after an Edit, MultiEdit, Write, NotebookEdit or Bash call finishes it schedules a refresh. It never reads or changes the call's input or result
turn.completeschedules a refresh
command.run for /loc-splitopens or closes the per-commit table
ui.render for AbovePromptdraws the row and the table in the band above the prompt; it steps aside while a survey uses the band

It reads nothing from the conversation: no prompts, no replies, no tool output.

Develop

claude --plugin-dir ./loc-split
claude plugin validate ./loc-split
claude plugin test ./loc-split

tests/fixture.ts holds git output recorded from a small scratch repository, so the test runs without git.

Source 5 files
hooks/register.ts 227 lines
1import type { EngineInterface, On, Timer } from 'claude-code'
2
3import type { LocCommit, LocReport, LocSplit } from '../types'
4import { drawBand } from './band'
5import { ATTRIBUTE_PROBE, BASES, CHECK_ATTRIBUTES, gitArgv, measureCommands } from './commands'
6import {
7  addSplits,
8  emptySplit,
9  markedGenerated,
10  tallyLog,
11  tallyNewFile,
12  tallyNumstat,
13  tallyNumstatLog,
14  tallyPatch,
15} from './split'
16
17const REPORT = { plugin: 'loc-split', key: 'report' } as const
18const IS_EXPANDED = { plugin: 'loc-split', key: 'isExpanded' } as const
19/** The first commit the expanded list shows; 0 is the newest. */
20const SCROLL = { plugin: 'loc-split', key: 'scroll' } as const
21
22const UNTRACKED_LIMIT = 500
23const REFRESH_MS = 20_000
24const MUTATING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
25
26const GIT_TIMEOUT_MS = 20_000
27
28type GitRun = { exitCode: number; stdout: string; isStdoutTruncated: boolean }
29
30/** The report, from the git runs `refresh` made; no `$` here. */
31const assemble = (
32  base: string,
33  mergeBase: string,
34  runs: Record<'listed' | 'patched' | 'generated' | 'counted' | 'dirty' | 'dirtyGenerated' | 'net' | 'netGenerated', GitRun>,
35  untracked: LocSplit,
36  isUntrackedCut: boolean,
37): LocReport => {
38  const patchedBySha = new Map(tallyLog(runs.patched.stdout).map(one => [one.sha, one.split]))
39  const generatedBySha = tallyNumstatLog(runs.generated.stdout)
40  const commits: LocCommit[] = runs.listed.stdout
41    .split('\n')
42    .filter(line => line !== '')
43    .map(line => {
44      const [sha = '', subject = ''] = line.split('\x1f')
45      const split = addSplits(patchedBySha.get(sha) ?? emptySplit(), generatedBySha.get(sha) ?? emptySplit())
46
47      return { sha: sha.slice(0, 7), subject, split }
48    })
49
50  return {
51    base,
52    mergeBase,
53    commits,
54    commitCount: Number(runs.counted.stdout.trim()) || commits.length,
55    uncommitted: addSplits(tallyNumstat(runs.dirtyGenerated.stdout, tallyPatch(runs.dirty.stdout)), untracked),
56    net: addSplits(tallyNumstat(runs.netGenerated.stdout, tallyPatch(runs.net.stdout)), untracked),
57    isPartial: isUntrackedCut || [runs.patched, runs.generated, runs.dirty, runs.net].some(run => run.isStdoutTruncated),
58  }
59}
60
61let pending: Timer | null = null
62let isRefreshing = false
63/** Whether this git takes `.gitattributes` pathspecs; asked once per load. */
64let canFilterByAttribute: boolean | null = null
65
66/**
67 * Measures the branch against main and stores the report. Every `$` call is
68 * written here, in one function, so what the mod runs and reads is all in
69 * one place: read-only git commands (listed in the README) and the text of
70 * untracked files.
71 */
72async function refresh($: EngineInterface) {
73  if (isRefreshing) return
74  isRefreshing = true
75  try {
76    let measured: LocReport | null = null
77    measure: {
78      const cwd = await $.session.cwd()
79      const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: GIT_TIMEOUT_MS })
80      if (top.exitCode !== 0) break measure
81      const root = top.stdout.trim()
82      const options = { cwd: root, timeoutMs: GIT_TIMEOUT_MS }
83
84      const head = await $.process.run(['git', 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], options)
85      let base: string | null = null
86      for (const ref of head.exitCode === 0 ? [...BASES, head.stdout.trim()] : BASES) {
87        const found = await $.process.run(['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], options)
88        if (found.exitCode === 0) {
89          base = ref
90          break
91        }
92      }
93      if (base === null) break measure
94      const found = await $.process.run(['git', 'merge-base', 'HEAD', base], options)
95      if (found.exitCode !== 0) break measure
96      const mergeBase = found.stdout.trim()
97
98      if (canFilterByAttribute === null) {
99        const probe = await $.process.run(gitArgv(ATTRIBUTE_PROBE), options)
100        canFilterByAttribute = probe.exitCode === 0 || probe.exitCode === 1
101      }
102      const commands = measureCommands(mergeBase, canFilterByAttribute)
103      const [listed, patched, generated, counted, dirty, dirtyGenerated, net, netGenerated, others] = await Promise.all([
104        $.process.run(gitArgv(commands.listed), options),
105        $.process.run(gitArgv(commands.patched), options),
106        $.process.run(gitArgv(commands.generated), options),
107        $.process.run(gitArgv(commands.counted), options),
108        $.process.run(gitArgv(commands.dirty), options),
109        $.process.run(gitArgv(commands.dirtyGenerated), options),
110        $.process.run(gitArgv(commands.net), options),
111        $.process.run(gitArgv(commands.netGenerated), options),
112        $.process.run(gitArgv(commands.others), options),
113      ])
114
115      const paths = others.stdout.split('\0').filter(path => path !== '')
116      const readable = paths.slice(0, UNTRACKED_LIMIT)
117      const attributes =
118        readable.length === 0
119          ? null
120          : await $.process.run(gitArgv(CHECK_ATTRIBUTES), { ...options, stdin: `${readable.join('\0')}\0` })
121      const marked = markedGenerated(attributes?.exitCode === 0 ? attributes.stdout : '')
122      const untracked = emptySplit()
123      for (const path of readable) {
124        let text = ''
125        try {
126          text = await $.fs.read(`${root}/${path}`)
127        } catch {
128          text = ''
129        }
130        tallyNewFile(path, text, untracked, marked.has(path))
131      }
132
133      measured = assemble(
134        base,
135        mergeBase,
136        { listed, patched, generated, counted, dirty, dirtyGenerated, net, netGenerated },
137        untracked,
138        paths.length > UNTRACKED_LIMIT,
139      )
140    }
141
142    const held = await $.state.get(REPORT)
143    if (JSON.stringify(measured) !== JSON.stringify(held.value ?? null)) {
144      await $.state.set(REPORT, measured)
145    }
146  } finally {
147    isRefreshing = false
148  }
149}
150
151/** Opens or closes the per-commit table. */
152async function toggleExpanded($: EngineInterface) {
153  const held = await $.state.get(IS_EXPANDED)
154  await $.state.set(IS_EXPANDED, !(held.value ?? false), { ifVersion: held.version })
155}
156
157/** Moves the commit list's window by `by` rows, kept within `[0, lastOffset]`. */
158async function scrollBy($: EngineInterface, by: number, lastOffset: number) {
159  const held = await $.state.get(SCROLL)
160  const at = Math.min(held.value ?? 0, lastOffset)
161  await $.state.set(SCROLL, Math.max(0, Math.min(lastOffset, at + by)), { ifVersion: held.version })
162}
163
164export function register(on: On) {
165  on('session.start', async ($, e, next) => {
166    await $.command.register({
167      name: 'loc-split',
168      description: 'Open or close the per-commit breakdown of lines changed against main',
169    })
170    pending?.cancel()
171    pending = $.clock.after(0, () => void refresh($))
172    $.clock.every(REFRESH_MS, () => void refresh($))
173
174    return next(e)
175  })
176
177  on('command.run', { command: 'loc-split' }, async ($, e, next) => {
178    await refresh($)
179    if (((await $.state.get(REPORT)).value ?? null) === null) {
180      return { text: 'loc-split: not in a git repository with a main or master branch.' }
181    }
182    await toggleExpanded($)
183
184    return {}
185  })
186
187  on('turn.complete', ($, e, next) => {
188    pending?.cancel()
189    pending = $.clock.after(0, () => void refresh($))
190
191    return next(e)
192  })
193
194  on('tool.call', async ($, e, next) => {
195    const ran = await next(e)
196    if (MUTATING_TOOLS.has(e.tool)) {
197      pending?.cancel()
198      pending = $.clock.after(1500, () => void refresh($))
199    }
200
201    return ran
202  })
203
204  // The band above the prompt: the summary row, and above it, when expanded,
205  // the per-commit table, so it opens upward like an accordion. The commit
206  // list scrolls on its own under a fixed title, header and net row.
207  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208    if (e.props.hasSurvey) return next(e)
209    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
210    const r = (await $.state.get(REPORT)).value ?? null
211    if (r === null) return next(e)
212    const isOpen = (await $.state.get(IS_EXPANDED)).value ?? false
213    const offset = (await $.state.get(SCROLL)).value ?? 0
214    const { Box, Button, Text } = $.ui.resolve(e)
215
216    return drawBand(
217      { Box, Button, Text },
218      r,
219      { isOpen, offset, surface: e.surface, bodyColumns: e.props.bodyColumns, maxRows: e.props.maxRows },
220      {
221        toggle: () => void toggleExpanded($),
222        scrollBy: (by, lastOffset) => void scrollBy($, by, lastOffset),
223      },
224    )
225  })
226}
227
hooks/band.tsx 281 lines
1import type { ElementTable, RenderChildren } from 'claude-code'
2
3import type { LocCount, LocReport, LocSplit } from '../types'
4import { CATEGORIES, isEmptySplit } from './split'
5
6/**
7 * The band above the prompt: the summary row, and above it, when expanded,
8 * the per-commit table, so it opens upward like an accordion. The commit
9 * list scrolls on its own under a fixed title, header and net row.
10 *
11 * Pure drawing: no `$` here. `register.ts` reads the state, resolves the
12 * surface's elements and hands over plain callbacks for the two controls.
13 */
14
15/** The element constructors the band draws with; the terminal and the desktop carry all three. */
16export type BandUi = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Button'>
17
18export type BandView = {
19  isOpen: boolean
20  /** The first commit the list shows, as stored; clamped here. */
21  offset: number
22  surface: 'terminal' | 'desktop'
23  bodyColumns: number
24  maxRows: number
25}
26
27export type BandActions = {
28  toggle: () => void
29  scrollBy: (by: number, lastOffset: number) => void
30}
31
32const GAP = 2
33
34type Category = (typeof CATEGORIES)[number]
35
36const LABELS: Record<Category, { full: string; short: string }> = {
37  code: { full: 'code', short: 'code' },
38  comments: { full: 'comments', short: 'cmt' },
39  tests: { full: 'tests', short: 'test' },
40  docs: { full: 'docs', short: 'doc' },
41  gen: { full: 'gen', short: 'gen' },
42}
43
44/** Columns the terminal's band keeps clear for its own `[-]` collapse mark at the right edge; the desktop draws none. */
45const BAND_CHROME = 4
46/** At most this many commits show at once; the list scrolls under a fixed title and totals. */
47const LIST_ROWS = 8
48/** Rows around the list: title, header, the gap above net (the scroll bar's down arrow when it scrolls), net, the gap below and the summary. */
49const FIXED_ROWS = 6
50/** The short sha's own column, a cell wider than the sha, so subjects line up in any font. */
51const SHA_CELLS = 9
52/** The scroll bar's column: a gap, then the arrow or the track. */
53const SCROLLBAR_CELLS = 3
54/** The band's padding on both sides, the table and the summary row alike. */
55const PADDING = 1
56/** The padding the desktop draws inside a native button, pulled back so its label lines up with the table. */
57const BUTTON_INSET = 1
58
59/** The row's forms, widest first; the first that fits the band is drawn. "commits" is the last word to go. */
60const FITS = [
61  { isWordy: true, isShort: false, hasRemoved: true },
62  { isWordy: true, isShort: true, hasRemoved: true },
63  { isWordy: true, isShort: true, hasRemoved: false },
64  { isWordy: false, isShort: true, hasRemoved: false },
65] as const
66
67type Fit = (typeof FITS)[number]
68
69const isZero = (count: LocCount) => count.added === 0 && count.removed === 0
70
71const countText = (count: LocCount): string =>
72  isZero(count)
73    ? '·'
74    : [count.added > 0 ? `+${count.added}` : '', count.removed > 0 ? `−${count.removed}` : '']
75        .filter(part => part !== '')
76        .join(' ')
77
78const truncate = (text: string, width: number): string =>
79  text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
80
81const shortRef = (ref: string) => ref.replace(/^origin\//, '')
82
83/** The categories with any line changed; the rest stay off the row and the card. */
84const changed = (split: LocSplit): Category[] => CATEGORIES.filter(category => !isZero(split[category]))
85
86const titleOf = (r: LocReport) =>
87  `vs ${shortRef(r.base)} · ${r.commitCount} commit${r.commitCount === 1 ? '' : 's'} since ${r.mergeBase.slice(0, 7)}`
88
89/** The table's columns (the categories with changes), their names and widths, and the label column's width. */
90const layout = (r: LocReport, maxWidth: number) => {
91  const net = changed(r.net)
92  const columns: Category[] = net.length > 0 ? net : ['code']
93  const splits = [r.net, r.uncommitted, ...r.commits.map(one => one.split)]
94  const sized = (form: 'full' | 'short') => {
95    const names = columns.map(column => LABELS[column][form])
96    const widths = columns.map((column, i) =>
97      Math.max(names[i]?.length ?? 0, ...splits.map(split => countText(split[column]).length)),
98    )
99
100    return { names, widths, cells: widths.reduce((sum, width) => sum + GAP + width, 0) }
101  }
102  const full = sized('full')
103  const { names, widths, cells } = full.cells + 24 <= maxWidth ? full : sized('short')
104  const longest = Math.max(...r.commits.map(one => one.sha.length + 1 + one.subject.length), 'Σ net vs main'.length)
105  const label = Math.max(12, Math.min(longest, 52, maxWidth - cells))
106
107  return { columns, names, widths, label }
108}
109
110const countFor = (count: LocCount, fit: Fit): LocCount =>
111  fit.hasRemoved || count.added === 0 ? count : { added: count.added, removed: 0 }
112
113const toggleLabel = (r: LocReport, fit: Fit, isOpen: boolean) =>
114  `${fit.isWordy ? 'commits ' : ''}↑${r.commitCount} ${isOpen ? '▾' : '▴'}`
115
116const rowWidth = (r: LocReport, fit: Fit): number => {
117  const shown = changed(r.net)
118  const left = toggleLabel(r, fit, false).length + (isEmptySplit(r.uncommitted) ? 0 : 2)
119  const right = shown.reduce(
120    (sum, category, i) =>
121      sum + (i > 0 ? 3 : 0) + LABELS[category][fit.isShort ? 'short' : 'full'].length + 1 + countText(countFor(r.net[category], fit)).length,
122    0,
123  )
124
125  return left + 2 + Math.max(right, 'no changes'.length)
126}
127
128const fitFor = (r: LocReport, columns: number): Fit =>
129  FITS.find(fit => rowWidth(r, fit) <= columns) ?? FITS[FITS.length - 1]!
130
131export function drawBand(ui: BandUi, r: LocReport, view: BandView, actions: BandActions) {
132  const { Box, Button, Text } = ui
133  const { isOpen } = view
134
135  const counts = (count: LocCount) =>
136    isZero(count)
137      ? [<Text dimColor>·</Text>]
138      : [
139          count.added > 0 && <Text color="success">+{count.added}</Text>,
140          count.added > 0 && count.removed > 0 && ' ',
141          count.removed > 0 && <Text color="error">−{count.removed}</Text>,
142        ]
143
144  const shown = changed(r.net)
145  const isDirty = !isEmptySplit(r.uncommitted)
146  // The terminal draws the band's collapse mark at its edge; the desktop does not.
147  const chrome = view.surface === 'terminal' ? BAND_CHROME : 0
148  const fit = fitFor(r, view.bodyColumns - chrome - 2 * PADDING)
149  const summary = (
150    <Box flexDirection="row" justifyContent="space-between">
151      <Box flexShrink={0} marginLeft={view.surface === 'desktop' ? -BUTTON_INSET : 0}>
152        <Button key="toggle" plain label={toggleLabel(r, fit, isOpen)} onPress={actions.toggle} />
153        {isDirty && <Text color="warning"> ✎</Text>}
154      </Box>
155      <Box flexShrink={1} marginLeft={2}>
156        <Text wrap="truncate">
157          {shown.length === 0 ? (
158            <Text dimColor>no changes</Text>
159          ) : (
160            shown.map((category, i) => [
161              i > 0 && <Text dimColor> · </Text>,
162              <Text dimColor>{LABELS[category][fit.isShort ? 'short' : 'full']} </Text>,
163              counts(countFor(r.net[category], fit)),
164            ])
165          )}
166        </Text>
167      </Box>
168    </Box>
169  )
170  if (!isOpen) {
171    return (
172      <Box key="loc-split" flexDirection="column" paddingLeft={PADDING} paddingRight={PADDING + chrome}>
173        {summary}
174      </Box>
175    )
176  }
177
178  const { columns, names, widths, label } = layout(r, view.bodyColumns - chrome - 2 * PADDING - SCROLLBAR_CELLS)
179  // Each cell is a box of fixed width, so the columns line up in a
180  // proportional font (the desktop) as well as in a terminal's grid.
181  const cells = (split: LocSplit) =>
182    columns.map((column, i) => (
183      <Box width={(widths[i] ?? 0) + GAP} flexShrink={0} justifyContent="flex-end">
184        <Text>{counts(split[column])}</Text>
185      </Box>
186    ))
187  const tableRow = (head: RenderChildren, split: LocSplit, marginTop = 0, bar?: RenderChildren) => (
188    <Box flexDirection="row" marginTop={marginTop}>
189      <Box width={label} flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
190        {head}
191      </Box>
192      {cells(split)}
193      {bar}
194    </Box>
195  )
196
197  const older = r.commitCount - r.commits.length
198  const fixed = FIXED_ROWS + (isDirty ? 1 : 0) + (older > 0 ? 1 : 0)
199  const room = view.maxRows - fixed
200  const listRows = Math.max(2, Math.min(LIST_ROWS, room))
201  const canScroll = r.commits.length > listRows
202  const lastOffset = Math.max(0, r.commits.length - listRows)
203  const offset = Math.min(view.offset, lastOffset)
204  const visible = r.commits.slice(offset, offset + listRows)
205  const page = Math.max(1, listRows - 1)
206
207  // A scroll bar beside the list: an arrow above and below a track of dots
208  // whose bright run is as long, and sits as far down, as the window over
209  // the commits. Dots, centered, read the same in a terminal's grid and in
210  // the desktop's proportional font, where line glyphs never join up.
211  const thumb = Math.max(1, Math.round((listRows * listRows) / Math.max(1, r.commits.length)))
212  const thumbAt = lastOffset === 0 ? 0 : Math.round((offset / lastOffset) * (listRows - thumb))
213  const barCell = (child: RenderChildren) => (
214    <Box width={SCROLLBAR_CELLS} flexShrink={0} justifyContent="center">
215      {child}
216    </Box>
217  )
218  const track = (row: number) =>
219    canScroll &&
220    barCell(row >= thumbAt && row < thumbAt + thumb ? <Text>•</Text> : <Text dimColor>·</Text>)
221
222  return (
223    <Box key="loc-split" flexDirection="column" paddingLeft={PADDING} paddingRight={PADDING + chrome}>
224      <Box flexDirection="column" marginBottom={1}>
225        <Box flexDirection="row" justifyContent="space-between">
226          <Text bold wrap="truncate">
227            {titleOf(r)}
228          </Text>
229          {canScroll && (
230            <Box flexShrink={0} marginLeft={2}>
231              <Text dimColor>
232                {offset + 1}–{offset + visible.length} of {r.commits.length}
233              </Text>
234            </Box>
235          )}
236        </Box>
237        <Box flexDirection="row">
238          <Box width={label} flexGrow={1} flexShrink={1} />
239          {names.map((name, i) => (
240            <Box width={(widths[i] ?? 0) + GAP} flexShrink={0} justifyContent="flex-end">
241              <Text dimColor>{name}</Text>
242            </Box>
243          ))}
244          {canScroll &&
245            barCell(<Button key="up" plain dimColor={offset === 0} label="↑" onPress={() => actions.scrollBy(-page, lastOffset)} />)}
246        </Box>
247        {visible.map((one, row) =>
248          tableRow(
249            <Box flexDirection="row" minWidth={0} overflow="hidden">
250              <Box width={SHA_CELLS} flexShrink={0}>
251                <Text dimColor>{one.sha}</Text>
252              </Box>
253              <Box flexShrink={1} minWidth={0} overflow="hidden" marginRight={1}>
254                <Text wrap="truncate-end">{one.subject}</Text>
255              </Box>
256            </Box>,
257            one.split,
258            0,
259            track(row),
260          ),
261        )}
262        {canScroll && (
263          <Box flexDirection="row">
264            <Box flexGrow={1} />
265            {barCell(<Button key="down" plain dimColor={offset === lastOffset} label="↓" onPress={() => actions.scrollBy(page, lastOffset)} />)}
266          </Box>
267        )}
268        {older > 0 && (
269          <Text dimColor>
270            … {older} older commit{older === 1 ? '' : 's'} not listed
271          </Text>
272        )}
273        {isDirty && tableRow(<Text color="warning">✎ uncommitted</Text>, r.uncommitted)}
274        {tableRow(<Text bold>Σ net vs {shortRef(r.base)}</Text>, r.net, canScroll ? 0 : 1)}
275        {r.isPartial && <Text dimColor>partial: git output was cut</Text>}
276      </Box>
277      {summary}
278    </Box>
279  )
280}
281
hooks/commands.ts 39 lines
1import { GENERATED_ATTRIBUTES, generatedPathspecs, pathspecs } from './split'
2
3export const BASES = ['origin/main', 'main', 'origin/master', 'master']
4export const SHOWN_COMMITS = 50
5
6const DIFF = ['-U0', '-M', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/']
7const NUMSTAT = ['--numstat', '--no-renames', '--no-color', '--no-ext-diff']
8
9export const gitArgv = (args: readonly string[]) => ['git', '-c', 'core.quotePath=false', ...args]
10
11/**
12 * Whether this git takes `.gitattributes` pathspecs in diff and log: exit 0
13 * or 1 (no difference, or some), never the 128 of a pathspec it refuses.
14 */
15export const ATTRIBUTE_PROBE = ['diff', '--quiet', 'HEAD', '--', `:(top,attr:${GENERATED_ATTRIBUTES[0]})`]
16
17/** The untracked paths `.gitattributes` marks generated or vendored; the paths go in on stdin. */
18export const CHECK_ATTRIBUTES = ['check-attr', '-z', '--stdin', ...GENERATED_ATTRIBUTES]
19
20/** Every git run one measurement makes once the merge base is known, by name. */
21export const measureCommands = (mergeBase: string, withAttributes: boolean) => {
22  const range = `${mergeBase}..HEAD`
23  const recent = ['--no-merges', `--max-count=${SHOWN_COMMITS}`]
24  const authored = pathspecs(withAttributes)
25  const generated = generatedPathspecs(withAttributes)
26
27  return {
28    listed: ['log', ...recent, '--format=%H%x1f%s', range],
29    patched: ['log', ...recent, '-p', ...DIFF, '--format=%x00%H%x1f%s', range, '--', ...authored],
30    generated: ['log', ...recent, ...NUMSTAT, '--format=%x00%H', range, '--', ...generated],
31    counted: ['rev-list', '--no-merges', '--count', range],
32    dirty: ['diff', ...DIFF, 'HEAD', '--', ...authored],
33    dirtyGenerated: ['diff', ...NUMSTAT, 'HEAD', '--', ...generated],
34    net: ['diff', ...DIFF, mergeBase, '--', ...authored],
35    netGenerated: ['diff', ...NUMSTAT, mergeBase, '--', ...generated],
36    others: ['ls-files', '--others', '--exclude-standard', '-z'],
37  } as const
38}
39
hooks/split.ts 572 lines
1import type { LocCount, LocSplit } from '../types'
2
3export type Category = 'code' | 'tests' | 'docs' | 'gen'
4
5export const CATEGORIES = ['code', 'comments', 'tests', 'docs', 'gen'] as const
6
7type Syntax = {
8  line: readonly string[]
9  block: readonly (readonly [string, string])[]
10  /** C-style `* ...` continuation lines of a block opened outside the hunk. */
11  isStarred?: boolean
12}
13
14const C: Syntax = { line: ['//'], block: [['/*', '*/']], isStarred: true }
15const C_HASH: Syntax = { line: ['//', '#'], block: [['/*', '*/']], isStarred: true }
16const HASH: Syntax = { line: ['#'], block: [] }
17const NIX: Syntax = { line: ['#'], block: [['/*', '*/']], isStarred: true }
18const PYTHON: Syntax = { line: ['#'], block: [['"""', '"""'], ["'''", "'''"]] }
19const RUBY: Syntax = { line: ['#'], block: [['=begin', '=end']] }
20const PERL: Syntax = { line: ['#'], block: [['=pod', '=cut'], ['=head', '=cut'], ['=begin', '=end'], ['=item', '=cut']] }
21const JULIA: Syntax = { line: ['#'], block: [['#=', '=#']] }
22const POWERSHELL: Syntax = { line: ['#'], block: [['<#', '#>']] }
23const COFFEE: Syntax = { line: ['#'], block: [['###', '###']] }
24const NIM: Syntax = { line: ['#'], block: [['#[', ']#']] }
25const GRAPHQL: Syntax = { line: ['#'], block: [['"""', '"""']] }
26const INI: Syntax = { line: [';', '#'], block: [] }
27const SQL: Syntax = { line: ['--'], block: [['/*', '*/']], isStarred: true }
28const LUA: Syntax = { line: ['--'], block: [['--[[', ']]']] }
29const HASKELL: Syntax = { line: ['--'], block: [['{-', '-}']] }
30const ADA: Syntax = { line: ['--'], block: [] }
31const ML: Syntax = { line: [], block: [['(*', '*)']] }
32const FSHARP: Syntax = { line: ['//'], block: [['(*', '*)']] }
33const MARKUP: Syntax = { line: [], block: [['<!--', '-->']] }
34const COMPONENT: Syntax = { line: ['//'], block: [['<!--', '-->'], ['/*', '*/']], isStarred: true }
35const TEMPLATE: Syntax = { line: [], block: [['{{!--', '--}}'], ['{{!', '}}'], ['{#', '#}'], ['<!--', '-->']] }
36const CSS: Syntax = { line: [], block: [['/*', '*/']], isStarred: true }
37const LISP: Syntax = { line: [';'], block: [['#|', '|#']] }
38const PERCENT: Syntax = { line: ['%'], block: [] }
39const BANG: Syntax = { line: ['!'], block: [] }
40const VB: Syntax = { line: ["'", 'REM ', 'rem '], block: [] }
41const BATCH: Syntax = { line: ['::', 'REM ', 'rem ', '@REM ', '@rem '], block: [] }
42const VIM: Syntax = { line: ['"'], block: [] }
43const ASSEMBLY: Syntax = { line: [';', '#', '//'], block: [['/*', '*/']] }
44const NONE: Syntax = { line: [], block: [] }
45
46const BY_EXTENSION: Record<string, Syntax> = {}
47const assign = (syntax: Syntax, extensions: string) => {
48  for (const ext of extensions.split(/\s+/)) BY_EXTENSION[ext] = syntax
49}
50// JavaScript and TypeScript, the JVM, Go, Rust, the C family, C#, Swift,
51// Dart, PHP, shaders and hardware languages, and the C-commented styles.
52assign(
53  C,
54  `js jsx ts tsx mjs cjs mts cts java kt kts scala sc sbt groovy gvy gradle go rs
55   c h cc cpp cxx c++ hpp hh hxx h++ ipp inl tpp m mm cs csx swift dart php phtml
56   proto thrift sol zig v sv svh vala d cu cuh glsl hlsl wgsl frag vert comp geom
57   metal ino pde qml hx as gleam move cairo odin prisma smithy jsonc json5 scss less
58   sass styl`,
59)
60assign(C_HASH, 'tf tfvars hcl nomad')
61assign(NIX, 'nix')
62assign(
63  HASH,
64  `sh bash zsh fish ksh csh tcsh r yaml yml toml cmake ex exs cr mk mak make conf
65   cfg properties gitignore gitattributes dockerignore npmignore prettierignore
66   eslintignore env envrc bashrc zshrc profile bash_profile awk sed tcl gd bzl
67   bazel star sky bats raku rakumod p6 pl6 dockerfile containerfile`,
68)
69assign(PYTHON, 'py pyi pyx pxd pyw')
70assign(RUBY, 'rb rake gemspec ru podspec jbuilder')
71assign(PERL, 'pl pm t pod')
72assign(JULIA, 'jl')
73assign(POWERSHELL, 'ps1 psm1 psd1')
74assign(COFFEE, 'coffee')
75assign(NIM, 'nim nims nimble')
76assign(GRAPHQL, 'graphql gql graphqls')
77assign(INI, 'ini editorconfig npmrc')
78assign(SQL, 'sql psql pgsql plsql ddl')
79assign(LUA, 'lua')
80assign(HASKELL, 'hs elm purs dhall agda idr')
81assign(ADA, 'ada adb ads vhd vhdl')
82assign(ML, 'ml mli')
83assign(FSHARP, 'fs fsi fsx')
84assign(
85  MARKUP,
86  'html htm xhtml xml svg xsd xsl xslt xaml csproj fsproj vbproj props targets plist resx storyboard xib wsdl',
87)
88assign(COMPONENT, 'vue svelte astro')
89assign(TEMPLATE, 'jinja jinja2 j2 twig njk hbs handlebars mustache')
90assign(CSS, 'css pcss postcss')
91assign(LISP, 'clj cljs cljc edn el lisp lsp scm ss rkt fnl')
92assign(PERCENT, 'erl hrl')
93assign(BANG, 'f f90 f95 f03 f08 for')
94assign(VB, 'vb vbs bas')
95assign(BATCH, 'bat cmd')
96assign(VIM, 'vim')
97assign(ASSEMBLY, 'asm s')
98
99const BY_NAME: Record<string, Syntax> = {
100  dockerfile: HASH,
101  containerfile: HASH,
102  makefile: HASH,
103  gnumakefile: HASH,
104  justfile: HASH,
105  procfile: HASH,
106  caddyfile: HASH,
107  codeowners: HASH,
108  build: HASH,
109  'build.bazel': HASH,
110  workspace: HASH,
111  'workspace.bazel': HASH,
112  'module.bazel': HASH,
113  tiltfile: HASH,
114  pipfile: HASH,
115  'cmakelists.txt': HASH,
116  'requirements.txt': HASH,
117  gemfile: RUBY,
118  rakefile: RUBY,
119  podfile: RUBY,
120  brewfile: RUBY,
121  vagrantfile: RUBY,
122  fastfile: RUBY,
123  appfile: RUBY,
124  dangerfile: RUBY,
125  guardfile: RUBY,
126  berksfile: RUBY,
127  jenkinsfile: C,
128}
129
130export const syntaxFor = (path: string): Syntax => {
131  const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase()
132  const byName =
133    BY_NAME[name] ??
134    (name.startsWith('dockerfile') || name.startsWith('.env') || /^requirements[^/]*\.txt$/.test(name) ? HASH : undefined)
135  if (byName !== undefined) return byName
136  const dot = name.lastIndexOf('.')
137
138  return dot === -1 ? NONE : (BY_EXTENSION[name.slice(dot + 1)] ?? NONE)
139}
140
141/** Test files by name, across ecosystems. */
142const TEST_FILE = new RegExp(
143  [
144    // foo.test.ts, foo.spec.js, foo-test.js, foo_test.go/py/rb/exs/dart/cc/rs, foo_spec.rb/lua, foo.e2e.ts, foo.e2e-spec.ts
145    String.raw`[._-](test|tests|spec|specs|e2e|e2e-spec|unittest|unittests|integration-test|it)\.[^/]+$`,
146    // Cypress, type tests (tsd, Vitest), Bats, Perl
147    String.raw`\.cy\.[cm]?[jt]sx?$`,
148    String.raw`\.test-d\.[cm]?tsx?$`,
149    String.raw`\.bats$`,
150    String.raw`(^|/)[^/]+\.t$`,
151    // test_foo.py, test_foo.c, test_foo.sh; testthat's test-foo.R
152    String.raw`(^|/)test_[^/]+$`,
153    String.raw`(^|/)test-[^/]+\.[rR]$`,
154    // pytest and Django; RSpec helpers
155    String.raw`(^|/)(conftest|tests?)\.py$`,
156    String.raw`(^|/)(spec_helper|rails_helper)\.rb$`,
157    // FooTest.java, FooTests.cs, FooSpec.scala, FooIT.java, FooSuite.scala, FooTestCase.php, FooSpec.hs, FooTest.cpp
158    String.raw`[^/](Test|Tests|TestCase|Spec|Specs|Suite|IT|IntegrationTest|IntegrationTests)\.(java|kt|kts|scala|groovy|cs|fs|vb|swift|m|mm|php|dart|hs|cpp|cc|cxx|h|hpp)$`,
159  ].join('|'),
160)
161/** Directories whose files are tests, any case. */
162const TEST_DIR = new RegExp(
163  [
164    String.raw`(^|/)(tests?|__tests?__|__mocks__|mocks?|__fixtures__|fixtures|e2e|integration[-_]tests?|integration_test|androidTest|testFixtures|testdata|test[-_]data|testing|cypress|playwright)/`,
165    // .NET test projects: MyApp.Tests/, MyApp.UnitTests/, MyApp.Specs/
166    String.raw`(^|/)[^/]+\.(tests?|unittests|integrationtests|specs)/`,
167  ].join('|'),
168  'i',
169)
170/** RSpec's spec/: tests, except data such as an OpenAPI spec/openapi.yaml. */
171const SPEC_DIR = /(^|\/)specs?\//i
172const DATA_FILE = /\.(ya?ml|json|xml|graphql|proto|csv)$/i
173const DOC_FILE = /\.(md|mdx|markdown|rst|adoc|asciidoc|txt|org|rdoc|textile|tex|pod|rmd)$/i
174const DOC_NAME =
175  /(^|\/)(readme|changelog|changes|contributing|license|licence|authors|notice|history|code_of_conduct|security|support|governance|maintainers|citation)(\.[^/]*)?$/i
176const NOT_DOC = /(^|\/)(requirements[^/]*\.txt|constraints[^/]*\.txt|cmakelists\.txt|robots\.txt|llms\.txt)$/i
177const DOC_DIR = /(^|\/)(docs?|documentation|wiki|man|guides?)\//i
178
179/**
180 * Files a tool writes, or a project vendors, rather than a person writes:
181 * lockfiles, migration and ORM output, test snapshots, codegen output,
182 * bundles, source maps and vendored code. Globs as git's `:(glob)` pathspec
183 * magic reads them; files `.gitattributes` marks `linguist-generated` or
184 * `linguist-vendored` count too (see `GENERATED_ATTRIBUTES`).
185 */
186export const GENERATED = [
187  // JavaScript and TypeScript
188  '**/package-lock.json',
189  '**/npm-shrinkwrap.json',
190  '**/yarn.lock',
191  '**/pnpm-lock.yaml',
192  '**/bun.lock',
193  '**/bun.lockb',
194  '**/deno.lock',
195  '**/.pnp.cjs',
196  '**/.pnp.loader.mjs',
197  '**/.yarn/releases/**',
198  '**/.yarn/plugins/**',
199  '**/.yarn/sdks/**',
200  '**/.yarn/cache/**',
201  '**/next-env.d.ts',
202  '**/*.tsbuildinfo',
203  // Python
204  '**/poetry.lock',
205  '**/Pipfile.lock',
206  '**/uv.lock',
207  '**/pdm.lock',
208  '**/pixi.lock',
209  '**/conda-lock.yml',
210  // Ruby, PHP, Go, Rust, Elixir, Erlang, Dart
211  '**/Gemfile.lock',
212  '**/composer.lock',
213  '**/go.sum',
214  '**/go.work.sum',
215  '**/Cargo.lock',
216  '**/mix.lock',
217  '**/rebar.lock',
218  '**/pubspec.lock',
219  // JVM, .NET, Apple, C and C++
220  '**/gradle.lockfile',
221  '**/packages.lock.json',
222  '**/paket.lock',
223  '**/Package.resolved',
224  '**/Podfile.lock',
225  '**/Cartfile.resolved',
226  '**/*.pbxproj',
227  '**/conan.lock',
228  // Nix, Terraform, Bazel, Julia, R, Haskell, Crystal
229  '**/flake.lock',
230  '**/devbox.lock',
231  '**/.terraform.lock.hcl',
232  '**/MODULE.bazel.lock',
233  '**/Manifest.toml',
234  '**/renv.lock',
235  '**/stack.yaml.lock',
236  '**/cabal.project.freeze',
237  '**/shard.lock',
238  // Migrations and ORMs: Drizzle, Prisma, Django, Rails, EF Core, sqlc
239  '**/meta/*_snapshot.json',
240  '**/meta/_journal.json',
241  '**/migrations/migration_lock.toml',
242  '**/migrations/[0-9][0-9][0-9][0-9]_*.py',
243  '**/db/schema.rb',
244  '**/db/structure.sql',
245  '**/Migrations/*.Designer.cs',
246  '**/Migrations/*ModelSnapshot.cs',
247  '**/*.sql.go',
248  // Test snapshots: Jest, Vitest, insta, syrupy, swift-snapshot-testing
249  '**/*.snap',
250  '**/*.snap.new',
251  '**/*.ambr',
252  '**/__snapshots__/**',
253  '**/__Snapshots__/**',
254  // Codegen
255  '**/__generated__/**',
256  '**/*.gen.*',
257  '**/*.generated.*',
258  '**/*_generated.*',
259  '**/zz_generated.*',
260  '**/*.pb.go',
261  '**/*.pb.gw.go',
262  '**/*_pb2.py',
263  '**/*_pb2.pyi',
264  '**/*_pb2_grpc.py',
265  '**/*.pb.cc',
266  '**/*.pb.h',
267  '**/*_pb.js',
268  '**/*_pb.d.ts',
269  '**/*_grpc_pb.js',
270  '**/*_grpc_pb.d.ts',
271  '**/*.pb.swift',
272  '**/*.grpc.swift',
273  '**/*.pb.dart',
274  '**/*.pbenum.dart',
275  '**/*.pbjson.dart',
276  '**/*.pbgrpc.dart',
277  '**/*.g.dart',
278  '**/*.freezed.dart',
279  '**/*.mocks.dart',
280  '**/*.g.cs',
281  '**/*.g.i.cs',
282  '**/*.designer.cs',
283  '**/*.Designer.cs',
284  // Bundles and source maps
285  '**/dist/**',
286  '**/*.min.js',
287  '**/*.min.mjs',
288  '**/*.min.css',
289  '**/*.js.map',
290  '**/*.mjs.map',
291  '**/*.cjs.map',
292  '**/*.css.map',
293  '**/*.d.ts.map',
294  // Vendored code
295  'vendor/**',
296  '**/node_modules/**',
297]
298
299/** `.gitattributes` attributes that mark a file generated or vendored, as GitHub reads them. */
300export const GENERATED_ATTRIBUTES = ['linguist-generated', 'linguist-vendored']
301
302const globToRegExp = (glob: string): RegExp => {
303  let source = ''
304  for (let i = 0; i < glob.length; i += 1) {
305    const char = glob[i] ?? ''
306    if (glob.startsWith('**/', i)) {
307      source += '(?:.*/)?'
308      i += 2
309    } else if (glob.startsWith('/**', i) && i + 3 === glob.length) {
310      source += '/.*'
311      i += 2
312    } else if (char === '*') {
313      source += '[^/]*'
314    } else if (char === '?') {
315      source += '[^/]'
316    } else if (char === '[') {
317      const close = glob.indexOf(']', i + 1)
318      source += glob.slice(i, close + 1)
319      i = close
320    } else {
321      source += '.+^${}()|\\'.includes(char) ? `\\${char}` : char
322    }
323  }
324
325  return new RegExp(`^${source}$`)
326}
327
328const GENERATED_PATTERNS = GENERATED.map(globToRegExp)
329
330export const isGenerated = (path: string): boolean => GENERATED_PATTERNS.some(pattern => pattern.test(path))
331
332export const classify = (path: string): Category => {
333  if (isGenerated(path)) return 'gen'
334  if (TEST_FILE.test(path)) return 'tests'
335  if ((DOC_FILE.test(path) || DOC_NAME.test(path)) && !NOT_DOC.test(path)) return 'docs'
336  if (TEST_DIR.test(path)) return 'tests'
337  if (SPEC_DIR.test(path) && !DATA_FILE.test(path)) return 'tests'
338  if (DOC_DIR.test(path)) return 'docs'
339
340  return 'code'
341}
342
343const attributeSpecs = (magic: string) =>
344  GENERATED_ATTRIBUTES.flatMap(attribute => [`:(${magic}attr:${attribute})`, `:(${magic}attr:${attribute}=true)`])
345
346/**
347 * The whole repository less the generated files, which `--numstat` counts
348 * instead. `withAttributes` adds the `.gitattributes` marks; git before
349 * attribute pathspecs reached diff and log refuses them, so the caller asks.
350 */
351export const pathspecs = (withAttributes: boolean) => [
352  ':/',
353  ...GENERATED.map(glob => `:(top,exclude,glob)${glob}`),
354  ...(withAttributes ? attributeSpecs('top,exclude,') : []),
355]
356
357/** Only the generated files. */
358export const generatedPathspecs = (withAttributes: boolean) => [
359  ...GENERATED.map(glob => `:(top,glob)${glob}`),
360  ...(withAttributes ? attributeSpecs('top,') : []),
361]
362
363export const emptySplit = (): LocSplit => ({
364  code: { added: 0, removed: 0 },
365  comments: { added: 0, removed: 0 },
366  tests: { added: 0, removed: 0 },
367  docs: { added: 0, removed: 0 },
368  gen: { added: 0, removed: 0 },
369})
370
371export const addSplits = (into: LocSplit, from: LocSplit): LocSplit => {
372  for (const key of CATEGORIES) {
373    into[key].added += from[key].added
374    into[key].removed += from[key].removed
375  }
376
377  return into
378}
379
380export const isEmptySplit = (split: LocSplit): boolean =>
381  Object.values(split).every((count: LocCount) => count.added === 0 && count.removed === 0)
382
383type BlockState = { close: string | null }
384
385const isComment = (raw: string, syntax: Syntax, state: BlockState): boolean => {
386  const text = raw.trim()
387  if (state.close !== null) {
388    if (text.includes(state.close)) state.close = null
389
390    return true
391  }
392  if (text === '') return false
393  for (const [open, close] of syntax.block) {
394    if (text.startsWith(open)) {
395      if (!text.slice(open.length).includes(close)) state.close = close
396
397      return true
398    }
399  }
400  if (syntax.line.some(marker => text.startsWith(marker))) return true
401
402  return syntax.isStarred === true && (text === '*' || text.startsWith('* ') || text.startsWith('*/'))
403}
404
405/** Tallies one side of a hunk (or a whole new file) into `split`. */
406export const tallyLines = (
407  path: string,
408  lines: readonly string[],
409  side: keyof LocCount,
410  split: LocSplit,
411  category: Category = classify(path),
412): void => {
413  if (lines.length === 0) return
414  if (category !== 'code') {
415    split[category][side] += lines.length
416
417    return
418  }
419  const syntax = syntaxFor(path)
420  const state: BlockState = { close: null }
421  for (const line of lines) {
422    split[isComment(line, syntax, state) ? 'comments' : 'code'][side] += 1
423  }
424}
425
426const unquote = (spelled: string): string => {
427  const path = spelled.replace(/\t$/, '')
428  if (!path.startsWith('"')) return path
429  const bytes: number[] = []
430  const encoder = new TextEncoder()
431  for (let i = 1; i < path.length - 1; i += 1) {
432    const char = path[i] ?? ''
433    if (char !== '\\') {
434      bytes.push(...encoder.encode(char))
435      continue
436    }
437    const next = path[i + 1] ?? ''
438    if (/[0-7]/.test(next)) {
439      bytes.push(parseInt(path.slice(i + 1, i + 4), 8))
440      i += 3
441      continue
442    }
443    const escapes: Record<string, string> = { n: '\n', t: '\t', '"': '"', '\\': '\\', a: '\x07', b: '\b', f: '\f', r: '\r', v: '\v' }
444    bytes.push(...encoder.encode(escapes[next] ?? next))
445    i += 1
446  }
447
448  return new TextDecoder().decode(new Uint8Array(bytes))
449}
450
451const pathOf = (header: string): string | null => {
452  const path = unquote(header)
453  if (path === '/dev/null') return null
454
455  return path.replace(/^[ab]\//, '')
456}
457
458const HUNK = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/
459
460/**
461 * Tallies a unified diff made with `-U0 --src-prefix=a/ --dst-prefix=b/`
462 * into `split`. Hunk bodies are read by their header's counts, so a removed
463 * `-- comment` line is never taken for a file header.
464 */
465export const tallyPatch = (patch: string, split: LocSplit = emptySplit()): LocSplit => {
466  const lines = patch.split('\n')
467  let oldPath: string | null = null
468  let path: string | null = null
469  let i = 0
470  while (i < lines.length) {
471    const line = lines[i] ?? ''
472    i += 1
473    if (line.startsWith('diff --git ')) {
474      oldPath = null
475      path = null
476    } else if (line.startsWith('--- ')) {
477      oldPath = pathOf(line.slice(4))
478    } else if (line.startsWith('+++ ')) {
479      path = pathOf(line.slice(4)) ?? oldPath
480    } else if (path !== null && line.startsWith('@@ ')) {
481      const counts = HUNK.exec(line)
482      if (counts === null) continue
483      let minus = counts[1] === undefined ? 1 : Number(counts[1])
484      let plus = counts[2] === undefined ? 1 : Number(counts[2])
485      const removed: string[] = []
486      const added: string[] = []
487      while (i < lines.length && (minus > 0 || plus > 0)) {
488        const body = lines[i] ?? ''
489        if (body.startsWith('-') && minus > 0) {
490          removed.push(body.slice(1))
491          minus -= 1
492        } else if (body.startsWith('+') && plus > 0) {
493          added.push(body.slice(1))
494          plus -= 1
495        } else if (body.startsWith(' ')) {
496          minus -= 1
497          plus -= 1
498        } else if (!body.startsWith('\\')) {
499          break
500        }
501        i += 1
502      }
503      tallyLines(path, removed, 'removed', split)
504      tallyLines(path, added, 'added', split)
505    }
506  }
507
508  return split
509}
510
511/** `git log -p --format=%x00%h%x1f%s` output, one entry per commit, newest first. */
512export const tallyLog = (log: string): { sha: string; subject: string; split: LocSplit }[] =>
513  log
514    .split('\0')
515    .slice(1)
516    .map(entry => {
517      const newline = entry.indexOf('\n')
518      const head = newline === -1 ? entry : entry.slice(0, newline)
519      const [sha = '', subject = ''] = head.split('\x1f')
520
521      return { sha, subject, split: tallyPatch(newline === -1 ? '' : entry.slice(newline + 1)) }
522    })
523
524/** Adds `--numstat` lines (`added<TAB>removed<TAB>path`) to `gen`; binary files read `-`. */
525export const tallyNumstat = (numstat: string, split: LocSplit = emptySplit()): LocSplit => {
526  for (const line of numstat.split('\n')) {
527    const [added = '', removed = ''] = line.split('\t')
528    if (!/^\d+$/.test(added) || !/^\d+$/.test(removed)) continue
529    split.gen.added += Number(added)
530    split.gen.removed += Number(removed)
531  }
532
533  return split
534}
535
536/** `git log --numstat --format=%x00%H` output, by commit. */
537export const tallyNumstatLog = (log: string): Map<string, LocSplit> =>
538  new Map(
539    log
540      .split('\0')
541      .slice(1)
542      .map(entry => {
543        const newline = entry.indexOf('\n')
544        const sha = (newline === -1 ? entry : entry.slice(0, newline)).split('\x1f')[0] ?? ''
545
546        return [sha, tallyNumstat(newline === -1 ? '' : entry.slice(newline + 1))] as const
547      }),
548  )
549
550/** Counts a file git does not track yet as all added lines; `isMarkedGenerated` when `.gitattributes` says so. */
551export const tallyNewFile = (path: string, text: string, split: LocSplit, isMarkedGenerated = false): void => {
552  if (text.includes('\0')) return
553  const lines = text.split('\n')
554  if (lines[lines.length - 1] === '') lines.pop()
555  tallyLines(path, lines, 'added', split, isMarkedGenerated ? 'gen' : classify(path))
556}
557
558/**
559 * The paths `git check-attr -z` output marks generated or vendored: records
560 * of path, attribute and value, each ending in NUL.
561 */
562export const markedGenerated = (output: string): Set<string> => {
563  const fields = output.split('\0')
564  const marked = new Set<string>()
565  for (let i = 0; i + 2 < fields.length; i += 3) {
566    const value = fields[i + 2]
567    if (value === 'set' || value === 'true') marked.add(fields[i] ?? '')
568  }
569
570  return marked
571}
572
types/index.d.ts 32 lines
1export type LocCount = { added: number; removed: number }
2
3export type LocSplit = {
4  code: LocCount
5  comments: LocCount
6  tests: LocCount
7  docs: LocCount
8  /** Lockfiles, snapshots, codegen output: counted by `--numstat`, never split. */
9  gen: LocCount
10}
11
12export type LocCommit = { sha: string; subject: string; split: LocSplit }
13
14export type LocReport = {
15  /** The ref the branch is measured against, as resolved (`origin/main`). */
16  base: string
17  mergeBase: string
18  /** Newest first, at most the ones the expanded list holds. */
19  commits: LocCommit[]
20  commitCount: number
21  uncommitted: LocSplit
22  /** Working tree (untracked files included) against the merge base. */
23  net: LocSplit
24  isPartial: boolean
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'loc-split': { report: LocReport | null; isExpanded: boolean; scroll: number }
30  }
31}
32