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
| @@ -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 @@ | ||
| 2 | 2 | import { EMPTY_FILE_HUNKS } from '../empty-file-hunks' |
| 3 | 3 | import Parse from '../parse' |
| 4 | 4 | import type Types from '../types' |
| 5 | +import { isLastOfDiff } from './is-last-of-diff' | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * One file's hunks against the base its row was read against, so body and |
| 8 | 9 | * counts agree, by literal pathspec, as the built-in panel reads hunks. |
| 9 | 10 | * |
| 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. | |
| 13 | 14 | * |
| 14 | 15 | * @param run runs git against the pinned repository |
| 15 | 16 | * @param data the fetch the row belongs to |
| @@ -22,7 +23,10 @@ | ||
| 22 | 23 | file: Types.FileStat, |
| 23 | 24 | ): Promise<Types.FileHunks | null> { |
| 24 | 25 | 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) | |
| 26 | 30 | |
| 27 | 31 | if (hasNoBody) { |
| 28 | 32 | return EMPTY_FILE_HUNKS |
| @@ -33,9 +37,10 @@ | ||
| 33 | 37 | ...Argv.DIFF_LEADING_ARGS, |
| 34 | 38 | data.baseRef, |
| 35 | 39 | '--', |
| 36 | - ...(file.renamedFrom === null ? [] : [file.renamedFrom]), | |
| 37 | 40 | file.path, |
| 38 | 41 | ]) |
| 39 | 42 | |
| 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 | |
| 41 | 46 | } |
| @@ -1,3 +1,4 @@ | ||
| 1 | 1 | export * from './fetch-file-hunks.js' |
| 2 | +export * from './is-last-of-diff' | |
| 2 | 3 | |
| 3 | 4 | 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 @@ | ||
| 14 | 14 | * one `rev-parse` in that directory, then every child pinned to it. |
| 15 | 15 | * |
| 16 | 16 | * 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). | |
| 18 | 19 | * |
| 19 | 20 | * @param host the bound host's runner and probes |
| 20 | 21 | * @returns the backend, or null outside a git working tree |
| @@ -46,6 +47,7 @@ | ||
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | const run = runOf(repository) |
| 50 | + const baseline = await Probes.dirtyPathsOf(run) | |
| 49 | 51 | |
| 50 | 52 | const depsOf = (): Types.GitDeps => ({ |
| 51 | 53 | run, |
| @@ -53,6 +55,7 @@ | ||
| 53 | 55 | mtimeOf: host.mtimeOf, |
| 54 | 56 | entryKindsOf: host.entryKindsOf, |
| 55 | 57 | sessionStartMs: host.sessionStartMsOf(), |
| 58 | + baseline, | |
| 56 | 59 | onBranchBase: host.onBranchBase, |
| 57 | 60 | }) |
| 58 | 61 |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './argv' |
| 2 | 2 | export * from './cut-hunks' |
| 3 | +export * from './display-path-of' | |
| 3 | 4 | export * from './empty-file-hunks' |
| 4 | 5 | export * from './failed-run' |
| 5 | 6 | export * from './fetch-diff' |
| @@ -11,6 +12,7 @@ | ||
| 11 | 12 | export * from './large-file-hunks' |
| 12 | 13 | export * from './parse' |
| 13 | 14 | export * from './probes' |
| 15 | +export * from './rename-display-of' | |
| 14 | 16 | export * from './tiers' |
| 15 | 17 | export * from './types' |
| 16 | 18 |
| @@ -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' | |
| 1 | 2 | export * from './hunk-header' |
| 2 | 3 | export * from './is-body-line' |
| 3 | 4 | export * from './is-whole-answer' |
| @@ -2,8 +2,14 @@ | ||
| 2 | 2 | * Whether a line after a hunk header is a body line (added, removed or |
| 3 | 3 | * context) rather than a note such as `\ No newline at end of file`. |
| 4 | 4 | * |
| 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 | + * | |
| 5 | 8 | * @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 | |
| 7 | 10 | */ |
| 8 | 11 | 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 @@ | ||
| 3 | 3 | import { hunkOf } from '../hunk-of' |
| 4 | 4 | import { LARGE_FILE_HUNKS } from '../large-file-hunks' |
| 5 | 5 | import type Types from '../types' |
| 6 | +import { bodyLineOf } from './body-line-of' | |
| 6 | 7 | import { HUNK_HEADER } from './hunk-header' |
| 7 | 8 | import { isBodyLine } from './is-body-line' |
| 8 | 9 | |
| @@ -32,7 +33,10 @@ | ||
| 32 | 33 | headerRows.map((at, ordinal) => |
| 33 | 34 | hunkOf( |
| 34 | 35 | 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), | |
| 36 | 40 | ), |
| 37 | 41 | ), |
| 38 | 42 | ), |
| @@ -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 @@ | ||
| 3 | 3 | export * from './current-branch-of' |
| 4 | 4 | export * from './datings-of' |
| 5 | 5 | export * from './default-branch-of' |
| 6 | +export * from './dirty-paths-of' | |
| 6 | 7 | export * from './head-key-of' |
| 7 | 8 | export * from './is-absolute-path' |
| 8 | 9 | export * from './is-safe-ref-name' |
| @@ -24,5 +25,6 @@ | ||
| 24 | 25 | export * from './untracked-files' |
| 25 | 26 | export * from './untracked-place' |
| 26 | 27 | export * from './untracked-rows-of' |
| 28 | +export * from './was-dirty-at-start' | |
| 27 | 29 | |
| 28 | 30 | export * as default from '.' |
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | import type Types from '../types' |
| 2 | 2 | import { datingsOf } from './datings-of' |
| 3 | +import { wasDirtyAtStart } from './was-dirty-at-start' | |
| 3 | 4 | |
| 4 | 5 | /** |
| 5 | 6 | * 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). | |
| 7 | 8 | * |
| 8 | 9 | * 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. | |
| 10 | 12 | * |
| 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 | |
| 12 | 14 | * @param files the tracked rows |
| 13 | 15 | * @returns the rows, tagged |
| 14 | 16 | */ |
| @@ -23,6 +25,8 @@ | ||
| 23 | 25 | |
| 24 | 26 | return files.map(file => ({ |
| 25 | 27 | ...file, |
| 26 | - isPreSession: datings.get(file.path) === 'pre-session', | |
| 28 | + isPreSession: | |
| 29 | + datings.get(file.path) === 'pre-session' && | |
| 30 | + wasDirtyAtStart(context, file.path), | |
| 27 | 31 | })) |
| 28 | 32 | } |
| @@ -2,14 +2,15 @@ | ||
| 2 | 2 | import type Types from '../../types' |
| 3 | 3 | import { datingsOf } from '../datings-of' |
| 4 | 4 | import type { UntrackedPlace } from '../untracked-place' |
| 5 | +import { wasDirtyAtStart } from '../was-dirty-at-start' | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * Listed untracked paths as 0/0 rows: the session's own first, then, |
| 8 | 9 | * when the scope asks, the tagged pre-session ones, up to the slots. |
| 9 | 10 | * |
| 10 | 11 | * 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). | |
| 13 | 14 | * |
| 14 | 15 | * @param context the fetch's stamp probe and session start |
| 15 | 16 | * @param paths the untracked paths, root-relative, as the lister printed them |
| @@ -24,9 +25,13 @@ | ||
| 24 | 25 | const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) |
| 25 | 26 | const datings = await datingsOf(context, probedPaths) |
| 26 | 27 | |
| 28 | + const isPreSessionOf = (path: string, dating: Types.Dating | undefined) => | |
| 29 | + dating === 'unlisted' || | |
| 30 | + (dating === 'pre-session' && wasDirtyAtStart(context, path)) | |
| 31 | + | |
| 27 | 32 | const probed = probedPaths.map(path => ({ |
| 28 | 33 | path, |
| 29 | - isPreSession: datings.get(path) !== 'session', | |
| 34 | + isPreSession: isPreSessionOf(path, datings.get(path)), | |
| 30 | 35 | })) |
| 31 | 36 | |
| 32 | 37 | 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 @@ | ||
| 2 | 2 | import type { StampProbe } from '../stamp-probe' |
| 3 | 3 | |
| 4 | 4 | /** |
| 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. | |
| 7 | 7 | */ |
| 8 | 8 | export type DatingContext = { |
| 9 | - deps: Pick<GitDeps, 'sessionStartMs'> | |
| 9 | + deps: Pick<GitDeps, 'sessionStartMs'> & Partial<Pick<GitDeps, 'baseline'>> | |
| 10 | 10 | stamps: StampProbe |
| 11 | 11 | } |
| @@ -33,6 +33,15 @@ | ||
| 33 | 33 | sessionStartMs: number |
| 34 | 34 | |
| 35 | 35 | /** |
| 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 | + /** | |
| 36 | 45 | * Told each time branch mode resolves its base, for telemetry. |
| 37 | 46 | */ |
| 38 | 47 | onBranchBase: (base: BranchBase) => void |