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

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.

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.
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.
| Category | Files |
|---|---|
| gen | files 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 / comments | everything 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:
| Syntax | Languages | ||
|---|---|---|---|
//, /* */ (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 comments | Python 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 | |
| others | Erlang %, Fortran !, Visual Basic ', batch REM / ::, Vim " | ||
| markup | HTML, 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 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.
plugins and press Enter.RomanHotsiy/claude-mods and click Sync./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.
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:
| Command | Why |
|---|---|
git rev-parse --show-toplevel | find the repository root |
git symbolic-ref --quiet --short refs/remotes/origin/HEAD | the 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>..HEAD | the 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>..HEAD | how 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 -z | untracked files, which count as added |
git check-attr -z --stdin linguist-generated linguist-vendored | which 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:
| Event | What it does |
|---|---|
session.start | registers /loc-split, measures once, and starts a 20-second refresh timer |
tool.call | lets 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.complete | schedules a refresh |
command.run for /loc-split | opens or closes the per-commit table |
ui.render for AbovePrompt | draws 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.
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.
hooks/register.ts 227 lines1import 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}
227hooks/band.tsx 281 lines1import 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}
281hooks/commands.ts 39 lines1import { 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}
39hooks/split.ts 572 lines1import 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}
572types/index.d.ts 32 lines1export 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