spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

mods/diff: renames list as git prints them with no body, a file that turned up since the start is session work, and each file but the diff's last keeps git's closing lineBrowse files
poteat authored on Sep 13, 2026 parent 3668b47 · commit 883b12cc195d
26 changed files+239 -20compared with 3668b47
Amods/diff/hooks/git/display-path-of/display-path-of.ts
Amods/diff/hooks/git/display-path-of/index.ts
Mmods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts
Mmods/diff/hooks/git/fetch-file-hunks/index.ts
Amods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts
Amods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts
Mmods/diff/hooks/git/git-backend-of.ts
Mmods/diff/hooks/git/index.ts
Amods/diff/hooks/git/parse/body-line-of/body-line-of.ts
Amods/diff/hooks/git/parse/body-line-of/index.ts
Mmods/diff/hooks/git/parse/index.ts
Mmods/diff/hooks/git/parse/is-body-line/is-body-line.ts
Mmods/diff/hooks/git/parse/parse-file-diff.ts
Amods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts
Amods/diff/hooks/git/probes/dirty-paths-of/index.ts
Amods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/index.ts
Amods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/status-path-offset.ts
Mmods/diff/hooks/git/probes/index.ts
Mmods/diff/hooks/git/probes/tag-pre-session.ts
Mmods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts
Amods/diff/hooks/git/probes/was-dirty-at-start/index.ts
Amods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts
Amods/diff/hooks/git/rename-display-of/index.ts
Amods/diff/hooks/git/rename-display-of/rename-display-of.ts
Mmods/diff/hooks/git/types/dating-context/dating-context.ts
Mmods/diff/hooks/git/types/git-deps/git-deps.ts
@@ -1,0 +1,17 @@
1+import { renameDisplayOf } from '../rename-display-of'
2+import type Types from '../types'
3+
4+/**
5+ * A row's name as the built-in lists it: the path, or for a rename the one
6+ * line `git diff --stat` prints for it (renameDisplayOf).
7+ *
8+ * @param file the row
9+ * @returns the name to draw and to sort by
10+ */
11+export function displayPathOf(
12+ file: Pick<Types.FileStat, 'path' | 'renamedFrom'>,
13+): string {
14+ const { renamedFrom, path } = file
15+
16+ return renamedFrom === null ? path : renameDisplayOf(renamedFrom, path)
17+}
@@ -1,0 +1,3 @@
1+export * from './display-path-of.js'
2+
3+export * as default from '.'
@@ -2,14 +2,15 @@
22 import { EMPTY_FILE_HUNKS } from '../empty-file-hunks'
33 import Parse from '../parse'
44 import type Types from '../types'
5+import { isLastOfDiff } from './is-last-of-diff'
56
67 /**
78 * One file's hunks against the base its row was read against, so body and
89 * counts agree, by literal pathspec, as the built-in panel reads hunks.
910 *
10- * A rename passes both its paths so git pairs them; an untracked or binary
11- * row, or on an unborn HEAD a file edited after staging, has no honest
12- * body and answers no hunks.
11+ * An untracked, binary or renamed row (the built-in shows a rename's
12+ * counts, never its body), or on an unborn HEAD a file edited after
13+ * staging, answers no hunks; the diff's last file, no closing empty line.
1314 *
1415 * @param run runs git against the pinned repository
1516 * @param data the fetch the row belongs to
@@ -22,7 +23,10 @@
2223 file: Types.FileStat,
2324 ): Promise<Types.FileHunks | null> {
2425 const hasNoBody =
25- file.isUntracked || file.isBinary || data.stalePaths.includes(file.path)
26+ file.isUntracked ||
27+ file.isBinary ||
28+ file.renamedFrom !== null ||
29+ data.stalePaths.includes(file.path)
2630
2731 if (hasNoBody) {
2832 return EMPTY_FILE_HUNKS
@@ -33,9 +37,10 @@
3337 ...Argv.DIFF_LEADING_ARGS,
3438 data.baseRef,
3539 '--',
36- ...(file.renamedFrom === null ? [] : [file.renamedFrom]),
3740 file.path,
3841 ])
3942
40- return exitCode === 0 ? Parse.parseFileDiff(stdout) : null
43+ const printed = isLastOfDiff(data, file) ? stdout.replace(/\n$/, '') : stdout
44+
45+ return exitCode === 0 ? Parse.parseFileDiff(printed) : null
4146 }
@@ -1,3 +1,4 @@
11 export * from './fetch-file-hunks.js'
2+export * from './is-last-of-diff'
23
34 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './is-last-of-diff.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type Types from '../../types'
2+
3+/**
4+ * Whether a row is the last file of the whole diff git printed for its
5+ * fetch: the last tracked row, the untracked ones being listed apart.
6+ *
7+ * The built-in reads that diff in one piece and trims its end, so the last
8+ * file alone loses git's closing empty line and the context row it becomes.
9+ *
10+ * @param data the fetch the row belongs to
11+ * @param file the row
12+ * @returns whether git printed this file last
13+ */
14+export const isLastOfDiff = (
15+ data: Types.DiffData,
16+ file: Types.FileStat,
17+): boolean =>
18+ data.files.filter(row => !row.isUntracked).at(-1)?.path === file.path
@@ -14,7 +14,8 @@
1414 * one `rev-parse` in that directory, then every child pinned to it.
1515 *
1616 * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under
17- * the C locale and the fetch's timeout (the built-in's execPinnedGit).
17+ * the C locale and the fetch's timeout (the built-in's execPinnedGit); the
18+ * paths dirty at the start are read once, first (Probes.dirtyPathsOf).
1819 *
1920 * @param host the bound host's runner and probes
2021 * @returns the backend, or null outside a git working tree
@@ -46,6 +47,7 @@
4647 }
4748
4849 const run = runOf(repository)
50+ const baseline = await Probes.dirtyPathsOf(run)
4951
5052 const depsOf = (): Types.GitDeps => ({
5153 run,
@@ -53,6 +55,7 @@
5355 mtimeOf: host.mtimeOf,
5456 entryKindsOf: host.entryKindsOf,
5557 sessionStartMs: host.sessionStartMsOf(),
58+ baseline,
5659 onBranchBase: host.onBranchBase,
5760 })
5861
@@ -1,5 +1,6 @@
11 export * from './argv'
22 export * from './cut-hunks'
3+export * from './display-path-of'
34 export * from './empty-file-hunks'
45 export * from './failed-run'
56 export * from './fetch-diff'
@@ -11,6 +12,7 @@
1112 export * from './large-file-hunks'
1213 export * from './parse'
1314 export * from './probes'
15+export * from './rename-display-of'
1416 export * from './tiers'
1517 export * from './types'
1618
@@ -1,0 +1,8 @@
1+/**
2+ * A body line as a hunk keeps it: git's closing empty line becomes the
3+ * empty context row the built-in panel draws for it; the rest stand.
4+ *
5+ * @param line a line isBodyLine passed
6+ * @returns the line, a lone space for the empty one
7+ */
8+export const bodyLineOf = (line: string) => (line === '' ? ' ' : line)
@@ -1,0 +1,3 @@
1+export * from './body-line-of.js'
2+
3+export * as default from '.'
@@ -1,3 +1,4 @@
1+export * from './body-line-of'
12 export * from './hunk-header'
23 export * from './is-body-line'
34 export * from './is-whole-answer'
@@ -2,8 +2,14 @@
22 * Whether a line after a hunk header is a body line (added, removed or
33 * context) rather than a note such as `\ No newline at end of file`.
44 *
5+ * The empty line that ends git's output counts, as the built-in panel
6+ * counts it: the last hunk of a file closes on one more context row.
7+ *
58 * @param line one line of `git diff` output
6- * @returns whether it starts with `+`, `-` or a space
9+ * @returns whether it starts with `+`, `-` or a space, or is empty
710 */
811 export const isBodyLine = (line: string) =>
9- line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')
12+ line === '' ||
13+ line.startsWith('+') ||
14+ line.startsWith('-') ||
15+ line.startsWith(' ')
@@ -3,6 +3,7 @@
33 import { hunkOf } from '../hunk-of'
44 import { LARGE_FILE_HUNKS } from '../large-file-hunks'
55 import type Types from '../types'
6+import { bodyLineOf } from './body-line-of'
67 import { HUNK_HEADER } from './hunk-header'
78 import { isBodyLine } from './is-body-line'
89
@@ -32,7 +33,10 @@
3233 headerRows.map((at, ordinal) =>
3334 hunkOf(
3435 HUNK_HEADER.exec(rows[at] ?? '')?.groups,
35- rows.slice(at + 1, headerRows[ordinal + 1]).filter(isBodyLine),
36+ rows
37+ .slice(at + 1, headerRows[ordinal + 1])
38+ .filter(isBodyLine)
39+ .map(bodyLineOf),
3640 ),
3741 ),
3842 ),
@@ -1,0 +1,35 @@
1+import Argv from '../../argv'
2+import Parse from '../../parse'
3+import type Types from '../../types'
4+import { STATUS_PATH_OFFSET } from './status-path-offset'
5+
6+/**
7+ * Every path dirty in the working tree right now, tracked changes and
8+ * untracked files alike, as `git status -z` lists them; null unread.
9+ *
10+ * Read once when the backend is pinned, so a fetch can tell a path that
11+ * was already dirty when the session began from one that turned up since
12+ * (GitDeps `baseline`). Renames are not paired: each side is its own path.
13+ *
14+ * @param run runs git against the pinned repository
15+ * @returns the paths, root-relative, or null on a failed or cut listing
16+ */
17+export async function dirtyPathsOf(
18+ run: Types.GitRun,
19+): Promise<ReadonlySet<string> | null> {
20+ const listing = await run([
21+ Argv.NO_OPTIONAL_LOCKS,
22+ 'status',
23+ '--porcelain',
24+ '-z',
25+ '--untracked-files=all',
26+ '--no-renames',
27+ '--ignore-submodules=dirty',
28+ ])
29+
30+ const records = listing.stdout.split('\0').filter(record => record !== '')
31+
32+ return Parse.isWholeAnswer(listing)
33+ ? new Set(records.map(record => record.slice(STATUS_PATH_OFFSET)))
34+ : null
35+}
@@ -1,0 +1,4 @@
1+export * from './dirty-paths-of.js'
2+export * from './status-path-offset'
3+
4+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './status-path-offset.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * Where the path starts in a `status --porcelain -z` record: after the two
3+ * status letters and a space.
4+ */
5+export const STATUS_PATH_OFFSET = 3
@@ -3,6 +3,7 @@
33 export * from './current-branch-of'
44 export * from './datings-of'
55 export * from './default-branch-of'
6+export * from './dirty-paths-of'
67 export * from './head-key-of'
78 export * from './is-absolute-path'
89 export * from './is-safe-ref-name'
@@ -24,5 +25,6 @@
2425 export * from './untracked-files'
2526 export * from './untracked-place'
2627 export * from './untracked-rows-of'
28+export * from './was-dirty-at-start'
2729
2830 export * as default from '.'
@@ -1,14 +1,16 @@
11 import type Types from '../types'
22 import { datingsOf } from './datings-of'
3+import { wasDirtyAtStart } from './was-dirty-at-start'
34
45 /**
56 * The tracked rows, `isPreSession` set on each whose file was modified
6- * before the session began, by mtime alone as the built-in panel tags them.
7+ * before the session began and was already dirty then (the built-in's tag).
78 *
89 * A file that cannot be dated (deleted this session, a symbolic link, past
9- * the listing budget) stays session work, so nothing real is hidden.
10+ * the listing budget), or that turned up since the start (wasDirtyAtStart),
11+ * stays session work, so nothing real is hidden.
1012 *
11- * @param context the fetch: its stamp probe and the session's start
13+ * @param context the fetch: its stamp probe, the session's start, its baseline
1214 * @param files the tracked rows
1315 * @returns the rows, tagged
1416 */
@@ -23,6 +25,8 @@
2325
2426 return files.map(file => ({
2527 ...file,
26- isPreSession: datings.get(file.path) === 'pre-session',
28+ isPreSession:
29+ datings.get(file.path) === 'pre-session' &&
30+ wasDirtyAtStart(context, file.path),
2731 }))
2832 }
@@ -2,14 +2,15 @@
22 import type Types from '../../types'
33 import { datingsOf } from '../datings-of'
44 import type { UntrackedPlace } from '../untracked-place'
5+import { wasDirtyAtStart } from '../was-dirty-at-start'
56
67 /**
78 * Listed untracked paths as 0/0 rows: the session's own first, then,
89 * when the scope asks, the tagged pre-session ones, up to the slots.
910 *
1011 * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the
11- * listing budget reads as pre-session, an undatable one as the session's
12- * (the built-in panel's rule).
12+ * listing budget reads as pre-session, an undatable one or one that turned
13+ * up since the start (wasDirtyAtStart) as the session's (the built-in's).
1314 *
1415 * @param context the fetch's stamp probe and session start
1516 * @param paths the untracked paths, root-relative, as the lister printed them
@@ -24,9 +25,13 @@
2425 const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES)
2526 const datings = await datingsOf(context, probedPaths)
2627
28+ const isPreSessionOf = (path: string, dating: Types.Dating | undefined) =>
29+ dating === 'unlisted' ||
30+ (dating === 'pre-session' && wasDirtyAtStart(context, path))
31+
2732 const probed = probedPaths.map(path => ({
2833 path,
29- isPreSession: datings.get(path) !== 'session',
34+ isPreSession: isPreSessionOf(path, datings.get(path)),
3035 }))
3136
3237 const isWithPreSession = place.scope === 'with-pre-session'
@@ -1,0 +1,3 @@
1+export * from './was-dirty-at-start.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type Types from '../../types'
2+
3+/**
4+ * Whether a path may predate the session at all: it was dirty when the
5+ * session began, or nothing says otherwise (no baseline was read).
6+ *
7+ * A path missing from the baseline turned up since, so its old timestamp
8+ * (a rename keeps the file's, a move keeps the source's) does not date it,
9+ * as the built-in's change-time check does not let it.
10+ *
11+ * @param context the fetch's dating context
12+ * @param path the root-relative path
13+ * @returns false only when a baseline exists and lacks the path
14+ */
15+export const wasDirtyAtStart = (
16+ context: Types.DatingContext,
17+ path: string,
18+): boolean => context.deps.baseline?.has(path) ?? true
@@ -1,0 +1,3 @@
1+export * from './rename-display-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,54 @@
1+/**
2+ * A rename as `git diff --stat` prints it: the shared leading and trailing
3+ * directories once, the differing middles in braces, `old => new` between.
4+ *
5+ * `docs/{notes => renamed}`, `{src => lib}/main`, or with nothing shared
6+ * `old => new`; a prefix or suffix only counts up to a slash, as git counts
7+ * it (pprint_rename).
8+ *
9+ * @param oldPath the path before the rename
10+ * @param newPath the path after it
11+ * @returns the one-line rename
12+ */
13+export function renameDisplayOf(oldPath: string, newPath: string): string {
14+ let prefix = 0
15+ let suffix = 0
16+
17+ for (let at = 0; at < oldPath.length && at < newPath.length; at++) {
18+ if (oldPath[at] !== newPath[at]) {
19+ break
20+ }
21+
22+ if (oldPath[at] === '/') {
23+ prefix = at + 1
24+ }
25+ }
26+
27+ const floor = prefix - (prefix > 0 ? 1 : 0)
28+
29+ for (
30+ let back = 1;
31+ oldPath.length - back >= floor && newPath.length - back >= floor;
32+ back++
33+ ) {
34+ const character = oldPath[oldPath.length - back]
35+
36+ if (character !== newPath[newPath.length - back]) {
37+ break
38+ }
39+
40+ if (character === '/') {
41+ suffix = back
42+ }
43+ }
44+
45+ const middleOf = (path: string) =>
46+ path.slice(prefix, Math.max(prefix, path.length - suffix))
47+
48+ const middle = `${middleOf(oldPath)} => ${middleOf(newPath)}`
49+ const isBraced = prefix + suffix > 0
50+ const head = oldPath.slice(0, prefix)
51+ const tail = oldPath.slice(oldPath.length - suffix)
52+
53+ return isBraced ? `${head}{${middle}}${tail}` : middle
54+}
@@ -2,10 +2,10 @@
22 import type { StampProbe } from '../stamp-probe'
33
44 /**
5- * What dating a path against the session takes: the session's start and
6- * one stamp probe over the working tree's top.
5+ * What dating a path against the session takes: the session's start, one
6+ * stamp probe over the tree's top, and when read, the paths dirty then.
77 */
88 export type DatingContext = {
9- deps: Pick<GitDeps, 'sessionStartMs'>
9+ deps: Pick<GitDeps, 'sessionStartMs'> & Partial<Pick<GitDeps, 'baseline'>>
1010 stamps: StampProbe
1111 }
@@ -33,6 +33,15 @@
3333 sessionStartMs: number
3434
3535 /**
36+ * The paths that were dirty when the backend was pinned, at the session's
37+ * start (Probes.dirtyPathsOf), or null when git could not list them.
38+ *
39+ * A path absent from it turned up during the session (a rename's new
40+ * name, a file moved in), whatever timestamp it carries.
41+ */
42+ baseline: ReadonlySet<string> | null
43+
44+ /**
3645 * Told each time branch mode resolves its base, for telemetry.
3746 */
3847 onBranchBase: (base: BranchBase) => void