mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat: add /diff command and git diff statistics utility (#3491)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(core): add git diff statistics utility
Port numstat + unified-diff parsing into `packages/core/src/utils/gitDiff.ts`
to surface structured working-tree change summaries (files changed, lines
added/removed, per-file hunks) against HEAD. Caps mirror issue #2997:
50 files, 1MB per file, 400 lines per file, with a 500-file short-circuit
via `git diff --shortstat` to avoid expensive work on massive diffs.
- `fetchGitDiff(cwd)` returns stats + per-file summaries (tracked + untracked).
- `fetchGitDiffHunks(cwd)` returns structured hunks on demand.
- `resolveGitDir(cwd)` follows `.git` file indirection so linked worktrees
and submodules report the correct gitdir.
- Transient-state short-circuit covers merge, cherry-pick, revert, and both
`rebase-merge` / `rebase-apply` layouts.
- `core.quotepath=false` is forced so non-ASCII filenames stay as UTF-8.
Refs #2997
* feat(cli): add /diff slash command
Surface the `fetchGitDiff` utility through an interactive `/diff` command.
Prints a header (`N files changed, +A / -R`) followed by per-file rows with
padded add/remove counts. Untracked files are marked `?`, binary files are
marked `~`. When the change set exceeds the per-file cap, a trailing
`…and N more` note tells the user how many entries are hidden.
Returns a `MessageActionReturn` so it renders the same way in interactive
and non-interactive modes.
* fix(cli): harden /diff command after adversarial audit
- Wrap `fetchGitDiff` in try/catch so permission errors on `.git` surface
as a friendly error message instead of crashing the action.
- Declare `supportedModes: ['interactive', 'non_interactive', 'acp']` so
the command is reachable outside the interactive Ink UI — the default
for `commandType: 'local'` is interactive-only.
- Align `?` (untracked) and `~` (binary) markers with the `+X -Y` stat
column via a padded prefix, so filenames line up regardless of row kind.
- Drop the "…and N more" hint when no rows are shown (shortstat fast-path
with >500 files) — the count alone is sufficient and "showing first 0"
is noise.
- Switch header to full-phrase i18n templates (separate singular/plural
variants) instead of word-by-word `t()` calls that don't survive
non-English locales.
- Extend tests to 12 scenarios: empty cwd, fetch rejection, singular
"file" form, mixed untracked/binary/tracked alignment, 4-digit padding,
shortstat fast-path, and supportedModes declaration. Mocks carry a
`satisfies GitDiffResult` annotation so shape drift in core breaks the
test at compile time.
* fix(cli): clean up /diff feature review issues
- Remove invalid `commandType` field from diffCommand (SlashCommand has
no such property; caused a TS build failure).
- Drop duplicate `NumstatResult` interface in gitDiff.ts — it is
structurally identical to `GitDiffResult`.
- Register the 9 missing `/diff` i18n strings in en.js / zh.js so the
command is translatable (previously only `Configuration not available.`
had entries).
* fix(core): harden git diff stats after multi-round review
- fetchUntrackedPaths now uses `ls-files -z` so filenames containing
newlines, tabs, or non-ASCII bytes round-trip cleanly instead of
being C-style quoted and split into phantom entries.
- fetchGitDiff runs the `--shortstat` probe and the untracked-paths
lookup in parallel, since both are needed regardless of which path
the function takes.
- parseGitDiff measures per-file diff size via Buffer.byteLength so
MAX_DIFF_SIZE_BYTES matches its documented meaning on non-ASCII diffs.
- Adds a regression test for an untracked file whose name contains a
literal newline.
* fix(core): address /diff PR review comments
Addresses the five open review threads on #3491:
- parseShortstat: anchored and bounded the regex (`^...$` with `\d{1,10}`)
so adversarial inputs can no longer drive polynomial backtracking. Closes
CodeQL alert #137.
- fetchGitDiff: only parse the untracked-path list when we actually need
it; the fast path now counts NUL bytes in the raw `ls-files -z` stdout
(wenshao P1).
- fetchGitDiff: base the `MAX_FILES_FOR_DETAILS` short-circuit on
`tracked + untracked`, so repos with few edits but many untracked files
still take the summary-only path (wenshao P2).
- fetchGitDiff: count newlines in each untracked text file (binary sniff +
1 MB read cap) and fold that into both the header `+N` and the per-file
row, so a brand-new file no longer renders as `+0 / -0` (BZ-D P2).
- parseGitNumstat: switch to `git diff --numstat -z`. The parser now uses
index-based slicing and a rename-pair state machine, so tracked
filenames containing tabs/newlines/non-ASCII keep their real bytes
(BZ-D P3). Renames collapse into a single `old => new` entry.
UI: untracked rows render as `+N filename (new)` (or
`~ filename (binary, new)`) instead of the placeholder `?` marker;
`/diff` now shows real additions for fresh files.
* fix(core): surface truncated untracked counts and decouple totals from display
Two issues surfaced during a directionless multi-round audit of the /diff
feature:
1. `countUntrackedLines` reads at most `UNTRACKED_READ_CAP_BYTES` (1 MB)
per file, so a 10 MB new log was silently reported as `+~20k` when the
real count is ~10×. The helper now `fstat`s the file and returns a
`truncated: true` flag when size exceeds the read window; `/diff`
surfaces it as `(new, partial)` so the `+N` isn't read as exact.
2. Line-count aggregation was coupled to the per-file display cap: when
tracked changes filled the `MAX_FILES` slot, untracked line counts
beyond the remaining slots were dropped from `stats.linesAdded`
entirely (header under-reported additions). Decoupled: we now read up
to `MAX_FILES` untracked files for their line counts regardless of
display slots, and only restrict the visible rows to `remainingSlots`.
Added regression tests for both: a 1.5 MB new file asserts `truncated:
true` and a lower-bound line count, and a `MAX_FILES`-saturated tracked
set + 5 untracked files asserts that untracked additions still appear in
the header totals even though none of them get displayed.
* fix(core): parse filenames from +++/--- lines to handle paths with ' b/'
`diff --git a/X b/Y` is ambiguous when X contains ` b/` — a file literally
named `a b/c.txt` produces `diff --git a/a b/c.txt b/a b/c.txt` with no
escape or quoting, and the previous regex `^a\/(.+?) b\/(.+)$` keyed the
hunks under the wrong path. Consumers of the exported `fetchGitDiffHunks`
API would then fail to correlate hunks with stats or editor paths.
Introduces `extractFilePath(lines)` which walks the block for the
unambiguous markers (`rename to` / `copy to` / `+++ b/<path>` with a
`/dev/null` fallback to `--- a/<path>`) and strips the trailing TAB git
appends to paths containing whitespace. Adds unit tests for the
`a b/c.txt`, rename, delete, and new-file cases plus an end-to-end test
that creates a real `a b/c.txt` file and asserts `fetchGitDiffHunks`
keys the hunks correctly.
Addresses wenshao review comment #3136657141 on #3491.
* feat(cli): colorize /diff output via a themed Ink component
The /diff stats used to come back as a plain-text MessageActionReturn.
Pipes and ACP still get that, but in interactive terminals we now dispatch
a structured history item so the numbers can carry theme colors.
- packages/cli/src/ui/types.ts — new DiffRenderRow / DiffRenderModel /
HistoryItemDiffStats, MessageType.DIFF_STATS.
- packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx — renders
+N in theme.status.success (green), -M in theme.status.error (red), and
the (new) / (binary) / (new, partial) markers in theme.text.secondary
(dim). Column alignment matches the plain-text fallback.
- packages/cli/src/ui/components/HistoryItemDisplay.tsx — routes the new
item type.
- packages/cli/src/ui/commands/diffCommand.ts — builds a DiffRenderModel
once and fans out: interactive calls context.ui.addItem; other modes
fall through to renderDiffModelText() for the plain-text path. Error
and "clean tree" branches keep the existing info/error
MessageActionReturn in every mode.
- Tests: existing diffCommand suite moved to an explicit non_interactive
context (it was asserting text content); new interactive suite covers
addItem dispatch and model shape; DiffStatsDisplay component tests
cover the four row variants and the "…and N more" note.
* refactor(cli): factor /diff column widths into a shared helper
Audit of the colorize commit found one real DRY hazard: DiffStatsDisplay
and renderDiffModelText each independently re-derived addWidth /
remWidth / statColumnWidth from the same row list. If anyone later
changed one formula, the interactive Ink output and the non-interactive
plain text would silently fall out of column alignment.
Extract the computation into computeDiffColumnWidths() exported from
diffCommand.ts; both renderers now call it. Adds a focused unit test of
the contract (empty rows, widest non-binary row wins, binary rows are
ignored, untracked text rows count). Drop a redundant
`Omit<HistoryItemDiffStats, 'id'>` annotation since the type already has
no id field.
* fix(core): pin /diff git ops to repo root and lstat untracked entries
Two Critical findings on PR #3491:
1. (line 63) When /diff is invoked from a subdirectory of the worktree,
`git diff` emits repo-root-relative paths but `git ls-files --others`
is scoped to cwd and emits cwd-relative paths. Result: mixed path
bases in `perFileStats` and silent omission of untracked files in
sibling directories. Resolve `findGitRoot(cwd)` once and run every
git invocation (and `path.join(...)` for line counting) from there,
so all keys are repo-root-relative and the listing is repo-wide.
2. (line 455) `countUntrackedLines` opened every untracked path with
`open(absPath, 'r')`. Git's `ls-files --others` can list FIFOs
(whose `open()` blocks indefinitely waiting on a writer) and
symlinks (which `open()` dereferences, potentially reading outside
the worktree). Add an `lstat` gate: only regular files are counted;
symlinks and other special files render as binary `~` rows.
Two new integration tests cover both regressions: one creates a
sibling untracked file at the repo root and invokes fetchGitDiff from
a subdir asserting all three changes (root + sub) come back keyed by
repo-root-relative paths; the other creates a symlink pointing at
content outside the worktree and asserts it lands as a binary row
with no contribution to linesAdded.
* chore: revert stray .npmrc/README.md test edits swept into bb0164d99
The previous fix(core) commit accidentally bundled two unrelated
working-tree edits (a test comment in .npmrc and a TODO in README.md)
that I had used while sanity-testing /diff. They have nothing to do
with the fix; restore them to their pre-bb0164d99 state.
* perf(core): stream untracked-file line counts in 64 KB chunks
`countUntrackedLines` allocated a fresh `UNTRACKED_READ_CAP_BYTES`
(1 MB) buffer per file. With up to MAX_FILES (=50) line-counts
running concurrently via `Promise.all`, the worst-case heap
footprint of a single `/diff` invocation was ~50 MB of transient
buffers — avoidable spike on small containers / low-memory hosts
flagged by wenshao on PR #3491.
Switch to a fixed 64 KB chunk buffer and read in a loop, accumulating
line counts and tracking the last byte across iterations. Peak
footprint is now ~3.2 MB (50 × 64 KB). Behavior is identical: same
binary sniff over the first 8 KB, same truncation flag when the read
hits the cap with bytes still on disk, same trailing-partial-line
rule. All 44 gitDiff tests pass unchanged, including the 1.5 MB
truncation test which now crosses chunk boundaries.
* refactor(core): collapse redundant ancestor walks; harden untracked open
Multi-round audit of the recent /diff fixes turned up two real issues:
1. `fetchGitDiff` and `fetchGitDiffHunks` walked worktree ancestors
three times each — `isGitRepository(cwd)`, then
`isInTransientGitState → resolveGitDir → findGitRoot`, then
`findGitRoot(cwd)` to pin git ops to the root. Resolve once at the
top, then thread `gitRoot` everywhere. Removes the now-dead
`isGitRepository` import and adds a private
`resolveGitDirFromRoot(gitRoot)` so the public `resolveGitDir(cwd)`
contract stays untouched for the test suite and external callers.
2. `countUntrackedLines` had a TOCTOU window between `lstat` (which
gates on regular files) and `open(absPath, 'r')` — if the path was
replaced by a symlink in that gap, `open` would silently follow it
and read the target. Open with `O_RDONLY | O_NOFOLLOW` (falling
back to `O_RDONLY` on platforms that don't expose the flag, e.g.
Windows) so the open rejects with `ELOOP` instead. Also unified the
five "couldn't read this file" branches (lstat throws / non-regular
/ open throws / binary sniff / mid-read failure) to all return
`{isBinary:true, added:0}` — the row appears in the listing as an
opaque `~ (binary, new)` marker rather than masquerading as an
empty text file with `+0 (new)`.
44 gitDiff tests pass unchanged.
* docs(cli): clarify row-order contract; simplify DiffRow key
Two non-blocking suggestions from qqqys's CR on PR #3491:
- `buildDiffRenderModel`: expand the JSDoc to call out the implicit
row-ordering contract that both renderers depend on (tracked entries
first in numstat order, then untracked appended in ls-files order).
Future replacements of the underlying Map need to preserve this
sequence.
- `DiffStatsDisplay`: drop the `${i}-${filename}` React key in favor of
bare `filename`. Filenames are unique within a single
`DiffRenderModel` (perFileStats is a Map keyed by filename), so the
index prefix added no information.
* feat(cli): add (deleted) marker for files removed in the worktree
Symmetrical to the (new) marker for untracked files: tracked files that
were removed from the worktree relative to HEAD now render with a
(deleted) suffix (or (binary, deleted) for binary deletes), so users
can tell a delete apart from a heavy edit.
Implementation:
- core: `fetchGitDiff` now runs `git diff HEAD --name-status -z` in
parallel with the existing numstat call. `parseDeletedFromNameStatus`
extracts the set of D-status paths (skipping R/C rename and copy
pairs, both halves of which still exist on disk under one name or
the other). Each `perFileStats` entry whose key is in that set gets
`isDeleted: true`. Numstat alone could not distinguish a delete
(`0\t10\tpath`) from a heavy edit; the name-status pass disambiguates.
- cli: `DiffRenderRow` carries `isDeleted: boolean`; both the plain-text
renderer and the Ink component append the new suffix in
`theme.text.secondary` (dim).
- i18n: new `(deleted)` and `(binary, deleted)` keys in en/zh/zh-TW.
Tests:
- Unit: `parseDeletedFromNameStatus` covers D-only extraction, R/C pair
skipping, NUL-safe paths (tabs / non-ASCII), and empty input.
- Integration: real repo deletes a tracked text + a tracked binary plus
edits another file; asserts the deleted entries get `isDeleted: true`
but the heavy edit does not. Second test verifies neither half of a
`git mv` rename gets flagged as deleted.
- CLI / component: `(deleted)` and `(binary, deleted)` rendering
variants with column alignment intact.
* fix(core): pin --no-ext-diff on every git diff invocation
Plain `git diff HEAD` honors `GIT_EXTERNAL_DIFF` and configured
`diff.<name>.command` drivers, so the exported `fetchGitDiffHunks`
utility could execute arbitrary commands when invoked inside a
worktree whose user-global or repo-local config registers an
external driver.
Add `--no-ext-diff` to every `git diff` call:
- `fetchGitDiffHunks`'s plain `git diff HEAD` — the actual
vulnerability surface.
- `fetchGitDiff`'s `--shortstat`, `--numstat`, `--name-status`
variants — defense-in-depth. Empirically these stats modes
already bypass external drivers in current git, but git's
behavior here has shifted between versions before, and
pinning the flag everywhere is a zero-cost hardening that
keeps the policy uniform across every `git diff` we run.
Regression test plants `GIT_EXTERNAL_DIFF=evil.sh` (a driver that
writes a sentinel file as its side effect) before calling
`fetchGitDiffHunks`, then asserts the sentinel never appears —
confirming `--no-ext-diff` actually stops git from spawning the
driver.
Closes wenshao critical comment on PR #3491.
* fix(core): lazy O_NOFOLLOW lookup so vitest fs mocks don't blow up
PR #3491 CI was failing across all 9 platform/Node combos with:
Error: [vitest] No "constants" export is defined on the "node:fs" mock.
at gitDiff.ts:70 const UNTRACKED_OPEN_FLAGS =
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)
at index.ts:279 export * from './utils/gitDiff.js'
Six unrelated test files (`client.test.ts`, `geminiChat.test.ts`,
`marketplace.test.ts`, `npm.test.ts`, `mcp-client.test.ts`,
`nextSpeakerChecker.test.ts`) `vi.mock('node:fs', ...)` without
returning `constants`, and their transitive import of
`@qwen-code/qwen-code-core` pulls in `gitDiff.ts`, whose
module-load-time `import { constants as fsConstants }` plus the
top-level `UNTRACKED_OPEN_FLAGS` constant tripped vitest's strict
mock proxy.
Two changes:
1. Switch `import { constants }` to `import * as nodeFs from 'node:fs'`.
Strict-mock no longer rejects the import statement itself.
2. Move the flag computation out of a module-load constant into a
memoized `getUntrackedOpenFlags()` called from inside
`countUntrackedLines`. Tests that don't actually invoke
`fetchGitDiff` / `fetchGitDiffHunks` (i.e. all six broken ones)
never reach the property access, so vitest's proxy never trips.
`?? 0` fallback on each constant lookup is preserved so Windows
(no `O_NOFOLLOW`) and the genuine "constants is undefined" mock
path both degrade to plain `O_RDONLY` without throwing.
Locally re-ran all six previously-failing files (199 tests) — all
green. Existing 51 gitDiff tests unchanged.
* fix(core): make resolveGitDir worktree assertion platform-agnostic
Windows CI was failing only on:
resolveGitDir > follows the gitdir pointer for linked worktrees
AssertionError: expected 'C:/Users/runneradmin/.../main/.git/worktrees/wt'
to contain '.git\worktrees'
Git writes the linked-worktree pointer in the `.git` *file* using
forward slashes — `gitdir: C:/Users/.../main/.git/worktrees/wt` —
even on Windows. `resolveGitDir` surfaces that string verbatim
(intentional, since fs APIs on Windows accept both separators). But
the assertion used `path.join('.git', 'worktrees')`, which is
`'.git\\worktrees'` on Windows, so the substring-contains check
failed despite the value being correct.
Switch to a regex that matches either separator: `/[/\\]\.git[/\\]worktrees[/\\]/`.
Now the assertion holds on POSIX (where path.join uses `/` anyway)
and Windows (where git's value uses `/` but the host uses `\`).
6285/6289 Windows tests already passed before this; only this one
assertion was platform-dependent.
* fix(core): C-unquote diff path headers and fix untracked-only fast path
Two Critical findings + one suggestion from wenshao on PR #3491:
1. (line 615) `extractFilePath` only accepted unquoted `+++ b/...` /
`--- a/...` headers. Git wraps a path in `"..."` and applies
C-style escaping (`\t`, `\n`, `\r`, `\"`, `\\`, plus octal `\NNN`
for non-ASCII bytes) whenever the raw path contains a character
that breaks space-delimited parsing. `core.quotepath=false` only
disables the octal form for non-ASCII bytes — control chars and
quotes are still escaped — so `fetchGitDiffHunks` silently dropped
hunks for any tracked file whose name contained a tab, newline,
or quote.
Add `unquoteCStylePath()`: detects the surrounding quotes, decodes
`\t`/`\n`/`\r`/`\"`/`\\` plus octal `\NNN` to raw bytes, then
UTF-8-decodes the byte sequence so multi-byte octal sequences like
`\346\226\207` (= `文`) round-trip correctly. `extractFilePath`
pipes every candidate through `stripTab` -> `unquoteCStylePath`
before checking the `a/` / `b/` prefix.
Two unit tests cover the tab and octal cases; one integration test
creates a real `tab\there.txt` tracked file, modifies it, and asserts
`fetchGitDiffHunks` keys hunks under the real name. The integration
test no-ops on filesystems that reject tab-in-name (NTFS).
2. (line 146) The >MAX_FILES_FOR_DETAILS fast path was guarded by
`quickStats &&`, which short-circuited to false when shortstat
returned an empty string. A workspace with 0 tracked changes plus
501 untracked files therefore slipped past the guardrail and ran
the slow path, line-counting only the first MAX_FILES untracked
files — header reported `filesCount: 501` but `linesAdded` missed
the other 451.
Treat empty/null/unparseable shortstat as `EMPTY_STATS` and apply
the threshold on `tracked + untracked` uniformly. Integration test
plants 501 untracked files + 0 tracked and asserts the result has
`filesCount: 501` with an empty perFileStats Map (summary-only).
3. (line 263) `fetchGitDiffHunks` reads the full `git diff HEAD`
stdout before parser caps apply. Documented in the JSDoc as a
known limitation: streaming the parser to terminate git early at
MAX_FILES is a reasonable follow-up but a non-trivial refactor
(spawn + incremental parse + UTF-8 boundary handling) and out of
scope for this PR. The existing `runGit` 64 MB maxBuffer keeps
pathological cases from runaway-allocating.
55 gitDiff tests pass (51 + 4 new).
* fix(core): also pass --no-textconv to block .gitattributes textconv drivers
Builds on the earlier `--no-ext-diff` hardening. wenshao pointed out
that `--no-ext-diff` covers `GIT_EXTERNAL_DIFF` and
`diff.<name>.command`, but it does NOT block textconv filters
registered via `.gitattributes` + `diff.<name>.textconv` — those run
on a separate code path inside `git diff`.
Verified locally:
git config diff.evil.textconv /tmp/evil.sh
echo '*.pdf diff=evil' > .gitattributes
# ... commit + modify doc.pdf ...
git diff HEAD --no-ext-diff -> /tmp/evil.sh fires
git diff HEAD --no-ext-diff --no-textconv -> driver does NOT fire
Add `--no-textconv` to all four `git diff` invocations
(shortstat / numstat / name-status / plain hunks). As with
`--no-ext-diff`, only the plain-diff call (`fetchGitDiffHunks`) is
known to invoke textconv in current git, but pinning both flags
uniformly is defense-in-depth and keeps the policy declarative.
Regression test plants a real textconv driver in a worktree's
`.git/config` + `.gitattributes` and asserts the driver's sentinel
file is NOT written when `fetchGitDiffHunks` runs. Without the new
flag the test fails with the sentinel present.
* fix(diff): close untracked-line undercount and ANSI injection in /diff output
Two Critical issues from PR #3491 review:
1. fetchGitDiff slow path only line-counted the first MAX_FILES (50)
untracked paths via `untrackedPaths.slice(0, MAX_FILES)`. With 51-500
untracked files in a clean tree the header reported the full file count
but only ~50 files' worth of additions, materially under-reporting the
total. Now read every untracked path that survived the
>MAX_FILES_FOR_DETAILS fast-path filter, with concurrency bounded to
MAX_FILES so peak heap stays around MAX_FILES *
UNTRACKED_READ_CHUNK_BYTES (~3.2 MB) regardless of input size.
2. renderDiffModelText interpolated raw filenames into the non-interactive
/ ACP text path. The interactive history is sanitized via
escapeAnsiCtrlCodes(item) inside HistoryItemDisplay, but the text path
streams to stdout / logs / transports with no equivalent hop, so a
tracked or untracked filename containing \x1b[2J etc. could inject
color resets, cursor moves, or full screen clears into CI logs and
downstream terminals. Pipe r.filename through escapeAnsiCtrlCodes at
the rendering boundary on every row variant (binary, untracked,
deleted, modified).
Tests:
- gitDiff.test.ts: regression that asserts every one of MAX_FILES + 10
untracked one-line files contributes to stats.linesAdded (would be 50
pre-fix vs 60 actual).
- diffCommand.test.ts: two new specs covering ANSI escapes in
modified-file rows and in binary / untracked / deleted suffix rows.
Verifies raw \x1b never reaches stdout while suffix markers ((binary),
(new), (deleted)) still render.
* fix(diff): harden quoted-path decoder and filename sanitizer
- unquoteCStylePath now walks Unicode code points so non-BMP characters
(e.g. emoji) inside a forced-quoted path no longer get split into lone
surrogates and decoded as replacement characters.
- Add explicit C-escape mappings for \a, \b, \f, \v so paths using those
control bytes decode to BEL/BS/FF/VT instead of dropping the backslash.
- Replace escapeAnsiCtrlCodes(filename) at the /diff text-rendering
boundary with a sanitizer that also escapes standalone C0/C1 control
bytes plus DEL, closing newline / CR / BS / BEL injection vectors that
ansi-regex does not match.
This commit is contained in:
parent
f4d0ad6b7f
commit
6556adcdba
14 changed files with 3513 additions and 2 deletions
|
|
@ -153,6 +153,29 @@ export default {
|
|||
'Configure authentication information for login',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copy the last result or code snippet to clipboard',
|
||||
'Show working-tree change stats versus HEAD':
|
||||
'Show working-tree change stats versus HEAD',
|
||||
'Could not determine current working directory.':
|
||||
'Could not determine current working directory.',
|
||||
'Failed to compute git diff stats': 'Failed to compute git diff stats',
|
||||
'No diff available. Either this is not a git repository, HEAD is missing, or a merge/rebase/cherry-pick/revert is in progress.':
|
||||
'No diff available. Either this is not a git repository, HEAD is missing, or a merge/rebase/cherry-pick/revert is in progress.',
|
||||
'Clean working tree — no changes against HEAD.':
|
||||
'Clean working tree — no changes against HEAD.',
|
||||
'{{count}} file changed, +{{added}} / -{{removed}}':
|
||||
'{{count}} file changed, +{{added}} / -{{removed}}',
|
||||
'{{count}} files changed, +{{added}} / -{{removed}}':
|
||||
'{{count}} files changed, +{{added}} / -{{removed}}',
|
||||
'{{count}} file changed': '{{count}} file changed',
|
||||
'{{count}} files changed': '{{count}} files changed',
|
||||
'…and {{hidden}} more (showing first {{shown}})':
|
||||
'…and {{hidden}} more (showing first {{shown}})',
|
||||
'(binary)': '(binary)',
|
||||
'(binary, new)': '(binary, new)',
|
||||
'(new)': '(new)',
|
||||
'(new, partial)': '(new, partial)',
|
||||
'(deleted)': '(deleted)',
|
||||
'(binary, deleted)': '(binary, deleted)',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Agents
|
||||
|
|
|
|||
|
|
@ -136,6 +136,28 @@ export default {
|
|||
'Configure authentication information for login': '配置登錄認證信息',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'將最後的結果或代碼片段複製到剪貼板',
|
||||
'Show working-tree change stats versus HEAD':
|
||||
'顯示工作區相對 HEAD 的變更統計',
|
||||
'Could not determine current working directory.': '無法確定當前工作目錄。',
|
||||
'Failed to compute git diff stats': '計算 git diff 統計失敗',
|
||||
'No diff available. Either this is not a git repository, HEAD is missing, or a merge/rebase/cherry-pick/revert is in progress.':
|
||||
'無可用 diff。可能不是 Git 倉庫、HEAD 缺失,或正在執行 merge/rebase/cherry-pick/revert。',
|
||||
'Clean working tree — no changes against HEAD.':
|
||||
'工作區乾淨 —— 與 HEAD 無差異。',
|
||||
'{{count}} file changed, +{{added}} / -{{removed}}':
|
||||
'{{count}} 個檔案變更,+{{added}} / -{{removed}}',
|
||||
'{{count}} files changed, +{{added}} / -{{removed}}':
|
||||
'{{count}} 個檔案變更,+{{added}} / -{{removed}}',
|
||||
'{{count}} file changed': '{{count}} 個檔案變更',
|
||||
'{{count}} files changed': '{{count}} 個檔案變更',
|
||||
'…and {{hidden}} more (showing first {{shown}})':
|
||||
'…還有 {{hidden}} 個(僅顯示前 {{shown}} 個)',
|
||||
'(binary)': '(二進位)',
|
||||
'(binary, new)': '(二進位,新增)',
|
||||
'(new)': '(新增)',
|
||||
'(new, partial)': '(新增,部分統計)',
|
||||
'(deleted)': '(已刪除)',
|
||||
'(binary, deleted)': '(二進位,已刪除)',
|
||||
'Manage subagents for specialized task delegation.':
|
||||
'管理用於專門任務委派的子智能體',
|
||||
'Manage existing subagents (view, edit, delete).':
|
||||
|
|
|
|||
|
|
@ -149,6 +149,28 @@ export default {
|
|||
'Configure authentication information for login': '配置登录认证信息',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'将最后的结果或代码片段复制到剪贴板',
|
||||
'Show working-tree change stats versus HEAD':
|
||||
'显示工作区相对 HEAD 的变更统计',
|
||||
'Could not determine current working directory.': '无法确定当前工作目录。',
|
||||
'Failed to compute git diff stats': '计算 git diff 统计失败',
|
||||
'No diff available. Either this is not a git repository, HEAD is missing, or a merge/rebase/cherry-pick/revert is in progress.':
|
||||
'无可用 diff。可能不是 Git 仓库、HEAD 缺失,或正在执行 merge/rebase/cherry-pick/revert。',
|
||||
'Clean working tree — no changes against HEAD.':
|
||||
'工作区干净 —— 与 HEAD 无差异。',
|
||||
'{{count}} file changed, +{{added}} / -{{removed}}':
|
||||
'{{count}} 个文件变更,+{{added}} / -{{removed}}',
|
||||
'{{count}} files changed, +{{added}} / -{{removed}}':
|
||||
'{{count}} 个文件变更,+{{added}} / -{{removed}}',
|
||||
'{{count}} file changed': '{{count}} 个文件变更',
|
||||
'{{count}} files changed': '{{count}} 个文件变更',
|
||||
'…and {{hidden}} more (showing first {{shown}})':
|
||||
'…还有 {{hidden}} 个(仅显示前 {{shown}} 个)',
|
||||
'(binary)': '(二进制)',
|
||||
'(binary, new)': '(二进制,新增)',
|
||||
'(new)': '(新增)',
|
||||
'(new, partial)': '(新增,部分统计)',
|
||||
'(deleted)': '(已删除)',
|
||||
'(binary, deleted)': '(二进制,已删除)',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Agents
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { contextCommand } from '../ui/commands/contextCommand.js';
|
|||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { doctorCommand } from '../ui/commands/doctorCommand.js';
|
||||
import { diffCommand } from '../ui/commands/diffCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { exportCommand } from '../ui/commands/exportCommand.js';
|
||||
|
|
@ -105,6 +106,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
compressCommand,
|
||||
contextCommand,
|
||||
copyCommand,
|
||||
diffCommand,
|
||||
deleteCommand,
|
||||
docsCommand,
|
||||
doctorCommand,
|
||||
|
|
|
|||
559
packages/cli/src/ui/commands/diffCommand.test.ts
Normal file
559
packages/cli/src/ui/commands/diffCommand.test.ts
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Mock } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { computeDiffColumnWidths, diffCommand } from './diffCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { fetchGitDiff, type GitDiffResult } from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@qwen-code/qwen-code-core')
|
||||
>('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
fetchGitDiff: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function makeContextWithCwd(cwd = '/tmp/repo'): CommandContext {
|
||||
// Non-interactive by default here because these tests assert on the
|
||||
// plain-text `MessageActionReturn`; interactive mode dispatches via
|
||||
// `context.ui.addItem` and is covered in a separate describe block.
|
||||
return createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: () => cwd,
|
||||
getProjectRoot: () => cwd,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeInteractiveContext(cwd = '/tmp/repo'): CommandContext {
|
||||
return createMockCommandContext({
|
||||
executionMode: 'interactive',
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: () => cwd,
|
||||
getProjectRoot: () => cwd,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('diffCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockFetchGitDiff: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchGitDiff = vi.mocked(fetchGitDiff);
|
||||
mockContext = makeContextWithCwd();
|
||||
});
|
||||
|
||||
it('errors when config is unavailable', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
const noConfigContext = createMockCommandContext();
|
||||
const result = await diffCommand.action(noConfigContext, '');
|
||||
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
|
||||
});
|
||||
|
||||
it('errors when getWorkingDir and getProjectRoot both return empty', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
const noCwdContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: () => '',
|
||||
getProjectRoot: () => '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
const result = await diffCommand.action(noCwdContext, '');
|
||||
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
|
||||
});
|
||||
|
||||
it('surfaces an error when fetchGitDiff throws', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockRejectedValueOnce(new Error('permission denied'));
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
|
||||
expect((result as { content: string }).content).toContain(
|
||||
'permission denied',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports when not in a git repo or transient state', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue(null);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
});
|
||||
expect((result as { content: string }).content).toMatch(
|
||||
/not a git repository|merge|rebase/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports clean working tree when stats show zero changes', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 },
|
||||
perFileStats: new Map(),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
expect((result as { content: string }).content).toMatch(
|
||||
/Clean working tree/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses singular "file" when exactly one file changed', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1, linesAdded: 3, linesRemoved: 1 },
|
||||
perFileStats: new Map([
|
||||
['src/a.ts', { added: 3, removed: 1, isBinary: false }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
expect(content).toMatch(/\b1 file\b/);
|
||||
expect(content).not.toMatch(/\b1 files\b/);
|
||||
});
|
||||
|
||||
it('renders header and per-file rows with +added / -removed', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 },
|
||||
perFileStats: new Map([
|
||||
['src/a.ts', { added: 5, removed: 2, isBinary: false }],
|
||||
['src/b.ts', { added: 2, removed: 1, isBinary: false }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
expect(content).toContain('2 files changed');
|
||||
expect(content).toContain('+7');
|
||||
expect(content).toContain('-3');
|
||||
expect(content).toContain('src/a.ts');
|
||||
expect(content).toContain('src/b.ts');
|
||||
});
|
||||
|
||||
it('shows untracked text files with their line count and a (new) marker', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 2, linesAdded: 12, linesRemoved: 2 },
|
||||
perFileStats: new Map([
|
||||
['src/a.ts', { added: 10, removed: 2, isBinary: false }],
|
||||
[
|
||||
'notes.md',
|
||||
{ added: 2, removed: 0, isBinary: false, isUntracked: true },
|
||||
],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
const lines = content.split('\n');
|
||||
const aLine = lines.find((l) => l.endsWith('src/a.ts'))!;
|
||||
const newLine = lines.find((l) => l.includes('notes.md'))!;
|
||||
expect(newLine).toContain('+ 2');
|
||||
expect(newLine).toContain('(new)');
|
||||
// Stat columns stay aligned across tracked and new rows.
|
||||
expect(aLine.indexOf('src/a.ts')).toBe(newLine.indexOf('notes.md'));
|
||||
});
|
||||
|
||||
it('marks truncated untracked text files with (new, partial)', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1, linesAdded: 10000, linesRemoved: 0 },
|
||||
perFileStats: new Map([
|
||||
[
|
||||
'big.log',
|
||||
{
|
||||
added: 10000,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
isUntracked: true,
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
const row = content.split('\n').find((l) => l.includes('big.log'))!;
|
||||
expect(row).toContain('(new, partial)');
|
||||
expect(row).not.toContain(' (new)');
|
||||
});
|
||||
|
||||
it('marks deleted tracked files with (deleted)', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1, linesAdded: 0, linesRemoved: 5 },
|
||||
perFileStats: new Map([
|
||||
[
|
||||
'gone.txt',
|
||||
{ added: 0, removed: 5, isBinary: false, isDeleted: true },
|
||||
],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
const row = content.split('\n').find((l) => l.includes('gone.txt'))!;
|
||||
expect(row).toContain('(deleted)');
|
||||
expect(row).toContain('-5');
|
||||
});
|
||||
|
||||
it('marks deleted binary tracked files with (binary, deleted)', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1, linesAdded: 0, linesRemoved: 0 },
|
||||
perFileStats: new Map([
|
||||
['gone.bin', { added: 0, removed: 0, isBinary: true, isDeleted: true }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
const row = content.split('\n').find((l) => l.includes('gone.bin'))!;
|
||||
expect(row).toContain('(binary, deleted)');
|
||||
expect(row.trimStart().startsWith('~')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks binary untracked files with (binary, new) and no line count', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1, linesAdded: 0, linesRemoved: 0 },
|
||||
perFileStats: new Map([
|
||||
[
|
||||
'blob.bin',
|
||||
{ added: 0, removed: 0, isBinary: true, isUntracked: true },
|
||||
],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
const binaryLine = content.split('\n').find((l) => l.includes('blob.bin'))!;
|
||||
expect(binaryLine).toContain('(binary, new)');
|
||||
expect(binaryLine).not.toMatch(/\+\d/);
|
||||
expect(binaryLine.trimStart().startsWith('~')).toBe(true);
|
||||
});
|
||||
|
||||
it('pads counts consistently for 4-digit values', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 2, linesAdded: 9999, linesRemoved: 1 },
|
||||
perFileStats: new Map([
|
||||
['big.ts', { added: 9999, removed: 0, isBinary: false }],
|
||||
['tiny.ts', { added: 0, removed: 1, isBinary: false }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
// Both rows must use the same prefix width so they align.
|
||||
const bigLine = content.split('\n').find((l) => l.endsWith('big.ts'))!;
|
||||
const tinyLine = content.split('\n').find((l) => l.endsWith('tiny.ts'))!;
|
||||
expect(bigLine.indexOf('big.ts')).toBe(tinyLine.indexOf('tiny.ts'));
|
||||
expect(content).toContain('+9999');
|
||||
});
|
||||
|
||||
it('notes how many files were hidden beyond the per-file cap', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 },
|
||||
perFileStats: new Map([
|
||||
['src/a.ts', { added: 1, removed: 0, isBinary: false }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
expect(content).toContain('60 files changed');
|
||||
expect(content).toMatch(/59 more/);
|
||||
});
|
||||
|
||||
it('shows header only when the shortstat fast path yields no per-file data', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1000, linesAdded: 50_000, linesRemoved: 8_000 },
|
||||
perFileStats: new Map(),
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(mockContext, '');
|
||||
const content = (result as { content: string }).content;
|
||||
expect(content).toContain('1000 files changed');
|
||||
expect(content).not.toMatch(/more \(showing first/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffCommand interactive mode', () => {
|
||||
let mockFetchGitDiff: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchGitDiff = vi.mocked(fetchGitDiff);
|
||||
});
|
||||
|
||||
it('dispatches a diff_stats history item instead of returning text', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
const ctx = makeInteractiveContext();
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 },
|
||||
perFileStats: new Map([
|
||||
['src/a.ts', { added: 5, removed: 2, isBinary: false }],
|
||||
['src/b.ts', { added: 2, removed: 1, isBinary: false }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
|
||||
const result = await diffCommand.action(ctx, '');
|
||||
expect(result).toBeUndefined();
|
||||
expect(ctx.ui.addItem).toHaveBeenCalledTimes(1);
|
||||
const call = (ctx.ui.addItem as Mock).mock.calls[0][0];
|
||||
expect(call.type).toBe('diff_stats');
|
||||
expect(call.model).toMatchObject({
|
||||
filesCount: 2,
|
||||
linesAdded: 7,
|
||||
linesRemoved: 3,
|
||||
hiddenCount: 0,
|
||||
});
|
||||
expect(call.model.rows).toHaveLength(2);
|
||||
expect(call.model.rows[0]).toMatchObject({
|
||||
filename: 'src/a.ts',
|
||||
added: 5,
|
||||
removed: 2,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('still returns a plain-text info message for the "clean tree" case', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
const ctx = makeInteractiveContext();
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 },
|
||||
perFileStats: new Map(),
|
||||
} satisfies GitDiffResult);
|
||||
|
||||
const result = await diffCommand.action(ctx, '');
|
||||
expect(result).toMatchObject({ type: 'message', messageType: 'info' });
|
||||
expect(ctx.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still returns an error MessageActionReturn when fetchGitDiff throws', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
const ctx = makeInteractiveContext();
|
||||
mockFetchGitDiff.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const result = await diffCommand.action(ctx, '');
|
||||
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
|
||||
expect(ctx.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates hiddenCount to the history item for fast-path results', async () => {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
const ctx = makeInteractiveContext();
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 },
|
||||
perFileStats: new Map([
|
||||
['src/a.ts', { added: 1, removed: 0, isBinary: false }],
|
||||
]),
|
||||
} satisfies GitDiffResult);
|
||||
|
||||
await diffCommand.action(ctx, '');
|
||||
const call = (ctx.ui.addItem as Mock).mock.calls[0][0];
|
||||
expect(call.model.hiddenCount).toBe(59);
|
||||
expect(call.model.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDiffColumnWidths', () => {
|
||||
// Direct contract test — both the Ink component and the plain-text
|
||||
// renderer call this helper, so its output binds their column alignment.
|
||||
// If anyone changes the formula, both paths must shift together.
|
||||
|
||||
it('reports min widths of 1 for an empty row list', () => {
|
||||
expect(computeDiffColumnWidths([])).toEqual({
|
||||
addWidth: 1,
|
||||
remWidth: 1,
|
||||
statColumnWidth: 5, // `+_ -_` with single-digit padding
|
||||
});
|
||||
});
|
||||
|
||||
it('sizes columns to the widest non-binary row', () => {
|
||||
const widths = computeDiffColumnWidths([
|
||||
{
|
||||
filename: 'a',
|
||||
added: 9999,
|
||||
removed: 5,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
filename: 'b',
|
||||
added: 2,
|
||||
removed: 100,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
// 1 (`+`) + 4 (digits) + 1 (` `) + 1 (`-`) + 3 (digits) = 10
|
||||
expect(widths).toEqual({ addWidth: 4, remWidth: 3, statColumnWidth: 10 });
|
||||
});
|
||||
|
||||
it('ignores binary rows when computing widths', () => {
|
||||
// A binary row must not push the numeric column wider, otherwise the
|
||||
// `~` placeholder ends up padded to a column that no real number ever
|
||||
// occupies.
|
||||
const widths = computeDiffColumnWidths([
|
||||
{
|
||||
filename: 'a',
|
||||
added: 1,
|
||||
removed: 1,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
filename: 'b.bin',
|
||||
isBinary: true,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(widths).toEqual({ addWidth: 1, remWidth: 1, statColumnWidth: 5 });
|
||||
});
|
||||
|
||||
it('counts untracked text rows in width calculation', () => {
|
||||
// Untracked rows render as `+N -0 filename (new)`; their `added`
|
||||
// value must be allowed to widen the column.
|
||||
const widths = computeDiffColumnWidths([
|
||||
{
|
||||
filename: 'fresh.log',
|
||||
added: 12345,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
isUntracked: true,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(widths.addWidth).toBe(5);
|
||||
expect(widths.statColumnWidth).toBe(1 + 5 + 1 + 1 + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffCommand registration', () => {
|
||||
it('declares all execution modes so it works in non-interactive and ACP', () => {
|
||||
expect(diffCommand.supportedModes).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDiffModelText filename sanitization', () => {
|
||||
// Regression for the non-interactive ANSI-injection vector: the
|
||||
// interactive path runs the full HistoryItem through
|
||||
// `escapeAnsiCtrlCodes(item)` in `HistoryItemDisplay`, but text output
|
||||
// (non-interactive / ACP) was streaming `r.filename` straight into
|
||||
// stdout / logs / transports without that hop. A hostile filename like
|
||||
// `evil\x1b[31m.txt` could therefore inject color resets, cursor moves,
|
||||
// or full screen clears into CI logs. The renderer now pipes filenames
|
||||
// through `escapeAnsiCtrlCodes` at the text boundary.
|
||||
let mockFetchGitDiff: Mock;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchGitDiff = vi.mocked(fetchGitDiff);
|
||||
});
|
||||
|
||||
async function renderText(perFileStats: Map<string, unknown>) {
|
||||
if (!diffCommand.action) throw new Error('Command has no action');
|
||||
mockFetchGitDiff.mockResolvedValue({
|
||||
stats: { filesCount: 1, linesAdded: 1, linesRemoved: 0 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
perFileStats: perFileStats as any,
|
||||
} satisfies GitDiffResult);
|
||||
const result = await diffCommand.action(makeContextWithCwd(), '');
|
||||
return (result as { content: string }).content;
|
||||
}
|
||||
|
||||
it('escapes raw ANSI escape sequences embedded in tracked filenames', async () => {
|
||||
const evil = 'safe\x1b[31mEVIL\x1b[0m.txt';
|
||||
const content = await renderText(
|
||||
new Map([[evil, { added: 1, removed: 0, isBinary: false }]]),
|
||||
);
|
||||
// The raw ESC byte must not survive into the text output — it would
|
||||
// otherwise be interpreted as an SGR by any downstream terminal.
|
||||
expect(content).not.toContain('\x1b[');
|
||||
// The literal escaped form is what `escapeAnsiCtrlCodes` produces.
|
||||
expect(content).toContain('\\u001b[31m');
|
||||
});
|
||||
|
||||
it('escapes ANSI sequences in untracked / binary / deleted suffix rows', async () => {
|
||||
const evilBinary = 'img\x1b[2J.png';
|
||||
const evilUntracked = 'note\x1b[H.md';
|
||||
const evilDeleted = 'gone\x1b[0K.txt';
|
||||
const content = await renderText(
|
||||
new Map<string, unknown>([
|
||||
[evilBinary, { added: 0, removed: 0, isBinary: true }],
|
||||
[
|
||||
evilUntracked,
|
||||
{ added: 1, removed: 0, isBinary: false, isUntracked: true },
|
||||
],
|
||||
[
|
||||
evilDeleted,
|
||||
{ added: 0, removed: 1, isBinary: false, isDeleted: true },
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(content).not.toContain('\x1b[');
|
||||
// All three suffix branches still render their markers.
|
||||
expect(content).toContain('(binary)');
|
||||
expect(content).toContain('(new)');
|
||||
expect(content).toContain('(deleted)');
|
||||
});
|
||||
|
||||
it('escapes standalone control bytes that ansi-regex does not match', async () => {
|
||||
// Filenames git permits but that aren't part of an ANSI escape sequence:
|
||||
// raw newline, carriage return, backspace, BEL, and DEL. Each one would
|
||||
// otherwise reorder, overwrite, or beep its way through the rendered
|
||||
// diff in the non-interactive / ACP path.
|
||||
const newline = 'bad\nINJECTED.txt';
|
||||
const cr = 'bad\roverwrite.txt';
|
||||
const bs = 'noisy\x08\x08\x08gone.txt';
|
||||
const bel = 'beep\x07.txt';
|
||||
const del = 'tail\x7f.txt';
|
||||
const content = await renderText(
|
||||
new Map<string, unknown>([
|
||||
[newline, { added: 1, removed: 0, isBinary: false }],
|
||||
[cr, { added: 1, removed: 0, isBinary: false }],
|
||||
[bs, { added: 1, removed: 0, isBinary: false }],
|
||||
[bel, { added: 1, removed: 0, isBinary: false }],
|
||||
[del, { added: 1, removed: 0, isBinary: false }],
|
||||
]),
|
||||
);
|
||||
// None of the raw control bytes should survive into the rendered text.
|
||||
expect(content).not.toContain('\n\nINJECTED.txt');
|
||||
expect(content).not.toMatch(/\roverwrite\.txt/);
|
||||
expect(content).not.toContain('\x08');
|
||||
expect(content).not.toContain('\x07');
|
||||
expect(content).not.toContain('\x7f');
|
||||
// The escaped forms (JSON-style) are what we render instead.
|
||||
expect(content).toContain('bad\\nINJECTED.txt');
|
||||
expect(content).toContain('bad\\roverwrite.txt');
|
||||
});
|
||||
});
|
||||
297
packages/cli/src/ui/commands/diffCommand.ts
Normal file
297
packages/cli/src/ui/commands/diffCommand.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
fetchGitDiff,
|
||||
type GitDiffResult,
|
||||
type PerFileStats,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type MessageActionReturn,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
MessageType,
|
||||
type DiffRenderModel,
|
||||
type DiffRenderRow,
|
||||
type HistoryItemDiffStats,
|
||||
} from '../types.js';
|
||||
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
|
||||
|
||||
async function diffAction(
|
||||
context: CommandContext,
|
||||
): Promise<MessageActionReturn | void> {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Configuration not available.'),
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = config.getWorkingDir() || config.getProjectRoot();
|
||||
if (!cwd) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Could not determine current working directory.'),
|
||||
};
|
||||
}
|
||||
|
||||
let result: GitDiffResult | null;
|
||||
try {
|
||||
result = await fetchGitDiff(cwd);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `${t('Failed to compute git diff stats')}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t(
|
||||
'No diff available. Either this is not a git repository, HEAD is missing, or a merge/rebase/cherry-pick/revert is in progress.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (result.stats.filesCount === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Clean working tree — no changes against HEAD.'),
|
||||
};
|
||||
}
|
||||
|
||||
const model = buildDiffRenderModel(result);
|
||||
|
||||
// Interactive path: dispatch a structured history item so `DiffStatsDisplay`
|
||||
// can render with theme colors. Non-interactive / ACP stay on the
|
||||
// plain-text MessageActionReturn path so pipes, logs, and transports that
|
||||
// don't speak Ink still see legible output.
|
||||
if (context.executionMode === 'interactive') {
|
||||
const item: HistoryItemDiffStats = {
|
||||
type: MessageType.DIFF_STATS,
|
||||
model,
|
||||
};
|
||||
context.ui.addItem(item, Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: renderDiffModelText(model),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the raw `fetchGitDiff` result into a display-ready structure that
|
||||
* both the Ink component and the plain-text renderer consume.
|
||||
*
|
||||
* Row order is the iteration order of `result.perFileStats`, which is a
|
||||
* `Map` and therefore preserves insertion order: tracked numstat entries
|
||||
* first (alphabetical, as git emits them), then untracked entries appended
|
||||
* by `fetchGitDiff` in their `ls-files --others` order. Renderers depend on
|
||||
* this — if `perFileStats` ever switches to a different container, the row
|
||||
* sequence must continue to be stable across runs.
|
||||
*/
|
||||
export function buildDiffRenderModel(result: GitDiffResult): DiffRenderModel {
|
||||
const rows: DiffRenderRow[] = [];
|
||||
for (const [filename, s] of result.perFileStats) {
|
||||
rows.push(toRow(filename, s));
|
||||
}
|
||||
const hiddenCount = Math.max(0, result.stats.filesCount - rows.length);
|
||||
return {
|
||||
filesCount: result.stats.filesCount,
|
||||
linesAdded: result.stats.linesAdded,
|
||||
linesRemoved: result.stats.linesRemoved,
|
||||
rows,
|
||||
hiddenCount,
|
||||
};
|
||||
}
|
||||
|
||||
function toRow(filename: string, s: PerFileStats): DiffRenderRow {
|
||||
if (s.isBinary) {
|
||||
return {
|
||||
filename,
|
||||
isBinary: true,
|
||||
isUntracked: Boolean(s.isUntracked),
|
||||
isDeleted: Boolean(s.isDeleted),
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
filename,
|
||||
added: s.added,
|
||||
removed: s.isUntracked ? 0 : s.removed,
|
||||
isBinary: false,
|
||||
isUntracked: Boolean(s.isUntracked),
|
||||
isDeleted: Boolean(s.isDeleted),
|
||||
truncated: Boolean(s.truncated),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the per-row column layout. Used by both the
|
||||
* Ink component and the plain-text renderer so the two paths can never
|
||||
* silently disagree on alignment.
|
||||
*/
|
||||
export interface DiffColumnWidths {
|
||||
/** Digits in the widest non-binary `added` value (min 1). */
|
||||
addWidth: number;
|
||||
/** Digits in the widest non-binary `removed` value (min 1). */
|
||||
remWidth: number;
|
||||
/** Visual width of the `+X -Y` stat column, used to pad the binary `~`
|
||||
* marker so it lines up with the numeric rows. */
|
||||
statColumnWidth: number;
|
||||
}
|
||||
|
||||
export function computeDiffColumnWidths(
|
||||
rows: readonly DiffRenderRow[],
|
||||
): DiffColumnWidths {
|
||||
let maxAdded = 0;
|
||||
let maxRemoved = 0;
|
||||
for (const r of rows) {
|
||||
if (r.isBinary) continue;
|
||||
if ((r.added ?? 0) > maxAdded) maxAdded = r.added ?? 0;
|
||||
if ((r.removed ?? 0) > maxRemoved) maxRemoved = r.removed ?? 0;
|
||||
}
|
||||
const addWidth = String(maxAdded).length;
|
||||
const remWidth = String(maxRemoved).length;
|
||||
// `+` + addDigits + ' ' + `-` + remDigits.
|
||||
const statColumnWidth = 1 + addWidth + 1 + 1 + remWidth;
|
||||
return { addWidth, remWidth, statColumnWidth };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text rendering of a `DiffRenderModel`. Used in non-interactive / ACP
|
||||
* modes where no Ink renderer is available, and as the source of truth for
|
||||
* the text column layout the Ink component mirrors.
|
||||
*/
|
||||
export function renderDiffModelText(model: DiffRenderModel): string {
|
||||
const { filesCount, linesAdded, linesRemoved, rows, hiddenCount } = model;
|
||||
const header =
|
||||
filesCount === 1
|
||||
? t('{{count}} file changed, +{{added}} / -{{removed}}', {
|
||||
count: String(filesCount),
|
||||
added: String(linesAdded),
|
||||
removed: String(linesRemoved),
|
||||
})
|
||||
: t('{{count}} files changed, +{{added}} / -{{removed}}', {
|
||||
count: String(filesCount),
|
||||
added: String(linesAdded),
|
||||
removed: String(linesRemoved),
|
||||
});
|
||||
const lines = formatRowsText(rows);
|
||||
const capNote =
|
||||
hiddenCount > 0 && rows.length > 0
|
||||
? `\n ${t('…and {{hidden}} more (showing first {{shown}})', {
|
||||
hidden: String(hiddenCount),
|
||||
shown: String(rows.length),
|
||||
})}`
|
||||
: '';
|
||||
return lines.length > 0 ? `${header}\n${lines.join('\n')}${capNote}` : header;
|
||||
}
|
||||
|
||||
// Match standalone C0 controls (incl. TAB/CR/LF/BEL/BS), DEL, and C1 controls.
|
||||
// `escapeAnsiCtrlCodes` only neutralizes multi-byte ANSI sequences, so a
|
||||
// filename like `bad\nINJECTED.txt` or `bad\roverwrite.txt` would otherwise
|
||||
// still break layout in the non-interactive / ACP rendering path.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const FILENAME_CONTROL_CHARS_REGEX = /[\x00-\x1f\x7f-\x9f]/g;
|
||||
|
||||
function escapeControlChar(ch: string): string {
|
||||
switch (ch) {
|
||||
case '\b':
|
||||
return '\\b';
|
||||
case '\t':
|
||||
return '\\t';
|
||||
case '\n':
|
||||
return '\\n';
|
||||
case '\f':
|
||||
return '\\f';
|
||||
case '\r':
|
||||
return '\\r';
|
||||
default: {
|
||||
// JSON.stringify only escapes 0x00-0x1F (and `"` / `\`); DEL (0x7F) and
|
||||
// C1 controls (0x80-0x9F) are returned as raw bytes, which is exactly
|
||||
// what we are trying to keep out of the rendered output. Hand-roll the
|
||||
// \uXXXX escape so every matched code point becomes printable.
|
||||
const code = ch.charCodeAt(0);
|
||||
return `\\u${code.toString(16).padStart(4, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilenameForDisplay(name: string): string {
|
||||
return escapeAnsiCtrlCodes(name).replace(
|
||||
FILENAME_CONTROL_CHARS_REGEX,
|
||||
escapeControlChar,
|
||||
);
|
||||
}
|
||||
|
||||
function formatRowsText(rows: DiffRenderRow[]): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
const { addWidth, remWidth, statColumnWidth } = computeDiffColumnWidths(rows);
|
||||
|
||||
const out: string[] = [];
|
||||
for (const r of rows) {
|
||||
// Escape ANSI sequences AND standalone control bytes in the filename. Git
|
||||
// permits raw bytes like `\x1b`, `\n`, `\r`, BEL, BS in tracked / untracked
|
||||
// paths, and the non-interactive (and ACP) text path streams straight to
|
||||
// stdout / logs / transports without going through the interactive
|
||||
// history's `escapeAnsiCtrlCodes(item)` sanitizer in `HistoryItemDisplay`.
|
||||
// Without this hop, a hostile filename could inject color resets, cursor
|
||||
// moves, full screen clears, or layout-breaking newlines into CI logs and
|
||||
// any consumer's terminal.
|
||||
const safeName = sanitizeFilenameForDisplay(r.filename);
|
||||
if (r.isBinary) {
|
||||
const suffix = r.isUntracked
|
||||
? ` ${t('(binary, new)')}`
|
||||
: r.isDeleted
|
||||
? ` ${t('(binary, deleted)')}`
|
||||
: ` ${t('(binary)')}`;
|
||||
out.push(` ${padMarker('~', statColumnWidth)} ${safeName}${suffix}`);
|
||||
continue;
|
||||
}
|
||||
const added = `+${String(r.added ?? 0).padStart(addWidth)}`;
|
||||
const removed = `-${String(r.removed ?? 0).padStart(remWidth)}`;
|
||||
let suffix = '';
|
||||
if (r.isUntracked) {
|
||||
suffix = r.truncated ? ` ${t('(new, partial)')}` : ` ${t('(new)')}`;
|
||||
} else if (r.isDeleted) {
|
||||
suffix = ` ${t('(deleted)')}`;
|
||||
}
|
||||
out.push(` ${added} ${removed} ${safeName}${suffix}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function padMarker(marker: string, width: number): string {
|
||||
if (marker.length >= width) return marker;
|
||||
return `${marker}${' '.repeat(width - marker.length)}`;
|
||||
}
|
||||
|
||||
export const diffCommand: SlashCommand = {
|
||||
name: 'diff',
|
||||
get description() {
|
||||
return t('Show working-tree change stats versus HEAD');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: diffAction,
|
||||
};
|
||||
|
|
@ -54,6 +54,7 @@ import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
|
|||
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
||||
import { BtwMessage } from './messages/BtwMessage.js';
|
||||
import { MemorySavedMessage } from './messages/MemorySavedMessage.js';
|
||||
import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js';
|
||||
import { useCompactMode } from '../contexts/CompactModeContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
|
|
@ -198,6 +199,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'stats' && (
|
||||
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'diff_stats' && (
|
||||
<DiffStatsDisplay model={itemForDisplay.model} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
<ModelStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DiffStatsDisplay } from './DiffStatsDisplay.js';
|
||||
import type { DiffRenderModel } from '../../types.js';
|
||||
|
||||
function stripAnsi(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('DiffStatsDisplay', () => {
|
||||
it('renders header and per-file rows aligned in columns', () => {
|
||||
const model: DiffRenderModel = {
|
||||
filesCount: 2,
|
||||
linesAdded: 7,
|
||||
linesRemoved: 3,
|
||||
hiddenCount: 0,
|
||||
rows: [
|
||||
{
|
||||
filename: 'src/a.ts',
|
||||
added: 5,
|
||||
removed: 2,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
filename: 'src/b.ts',
|
||||
added: 2,
|
||||
removed: 1,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame } = render(<DiffStatsDisplay model={model} />);
|
||||
const visible = stripAnsi(lastFrame() ?? '');
|
||||
expect(visible).toContain('2 files changed');
|
||||
expect(visible).toContain('+7');
|
||||
expect(visible).toContain('-3');
|
||||
const aRow = visible.split('\n').find((l) => l.endsWith('src/a.ts'))!;
|
||||
const bRow = visible.split('\n').find((l) => l.endsWith('src/b.ts'))!;
|
||||
// Columns align — "src/a.ts" and "src/b.ts" start at the same offset.
|
||||
expect(aRow.indexOf('src/a.ts')).toBe(bRow.indexOf('src/b.ts'));
|
||||
});
|
||||
|
||||
it('renders the (new) marker for untracked text files', () => {
|
||||
const model: DiffRenderModel = {
|
||||
filesCount: 1,
|
||||
linesAdded: 3,
|
||||
linesRemoved: 0,
|
||||
hiddenCount: 0,
|
||||
rows: [
|
||||
{
|
||||
filename: 'notes.md',
|
||||
added: 3,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
isUntracked: true,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame } = render(<DiffStatsDisplay model={model} />);
|
||||
const visible = stripAnsi(lastFrame() ?? '');
|
||||
expect(visible).toContain('notes.md');
|
||||
expect(visible).toContain('(new)');
|
||||
expect(visible).not.toContain('(new, partial)');
|
||||
});
|
||||
|
||||
it('renders the (new, partial) marker for truncated untracked text files', () => {
|
||||
const model: DiffRenderModel = {
|
||||
filesCount: 1,
|
||||
linesAdded: 10000,
|
||||
linesRemoved: 0,
|
||||
hiddenCount: 0,
|
||||
rows: [
|
||||
{
|
||||
filename: 'big.log',
|
||||
added: 10000,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
isUntracked: true,
|
||||
isDeleted: false,
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const visible = stripAnsi(
|
||||
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
|
||||
);
|
||||
expect(visible).toContain('(new, partial)');
|
||||
});
|
||||
|
||||
it('renders the (deleted) marker for tracked files removed from the worktree', () => {
|
||||
const model: DiffRenderModel = {
|
||||
filesCount: 1,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 5,
|
||||
hiddenCount: 0,
|
||||
rows: [
|
||||
{
|
||||
filename: 'gone.txt',
|
||||
added: 0,
|
||||
removed: 5,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: true,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const visible = stripAnsi(
|
||||
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
|
||||
);
|
||||
const row = visible.split('\n').find((l) => l.includes('gone.txt'))!;
|
||||
expect(row).toContain('(deleted)');
|
||||
expect(row).toContain('-5');
|
||||
});
|
||||
|
||||
it('renders binary rows with a ~ marker and no +N/-M', () => {
|
||||
const model: DiffRenderModel = {
|
||||
filesCount: 1,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
hiddenCount: 0,
|
||||
rows: [
|
||||
{
|
||||
filename: 'img.png',
|
||||
isBinary: true,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const visible = stripAnsi(
|
||||
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
|
||||
);
|
||||
const rowLine = visible.split('\n').find((l) => l.includes('img.png'))!;
|
||||
expect(rowLine).toContain('~');
|
||||
expect(rowLine).toContain('(binary)');
|
||||
expect(rowLine).not.toMatch(/\+\d/);
|
||||
});
|
||||
|
||||
it('renders the "…and N more" note when hiddenCount > 0', () => {
|
||||
const model: DiffRenderModel = {
|
||||
filesCount: 60,
|
||||
linesAdded: 100,
|
||||
linesRemoved: 20,
|
||||
hiddenCount: 59,
|
||||
rows: [
|
||||
{
|
||||
filename: 'src/a.ts',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
isUntracked: false,
|
||||
isDeleted: false,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const visible = stripAnsi(
|
||||
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
|
||||
);
|
||||
expect(visible).toContain('60 files changed');
|
||||
expect(visible).toMatch(/59 more/);
|
||||
});
|
||||
});
|
||||
128
packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx
Normal file
128
packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { DiffRenderModel, DiffRenderRow } from '../../types.js';
|
||||
import { computeDiffColumnWidths } from '../../commands/diffCommand.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface DiffStatsDisplayProps {
|
||||
model: DiffRenderModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored rendering of `/diff` output for interactive mode. Mirrors the
|
||||
* layout of the plain-text fallback (see `renderDiffModelText`) so the two
|
||||
* modes stay visually aligned, but uses Ink primitives with `theme.status.*`
|
||||
* tokens instead of baking ANSI into the text.
|
||||
*/
|
||||
export const DiffStatsDisplay: React.FC<DiffStatsDisplayProps> = ({
|
||||
model,
|
||||
}) => {
|
||||
const { filesCount, linesAdded, linesRemoved, rows, hiddenCount } = model;
|
||||
// Single source of truth shared with `renderDiffModelText`, so the
|
||||
// interactive Ink output and the non-interactive plain text never drift
|
||||
// out of column alignment.
|
||||
const { addWidth, remWidth, statColumnWidth } = computeDiffColumnWidths(rows);
|
||||
|
||||
const headerLabel =
|
||||
filesCount === 1
|
||||
? t('{{count}} file changed', { count: String(filesCount) })
|
||||
: t('{{count}} files changed', { count: String(filesCount) });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.text.primary}>{headerLabel}</Text>
|
||||
<Text color={theme.text.secondary}>, </Text>
|
||||
<Text color={theme.status.success}>+{linesAdded}</Text>
|
||||
<Text color={theme.text.secondary}> / </Text>
|
||||
<Text color={theme.status.error}>-{linesRemoved}</Text>
|
||||
</Text>
|
||||
{rows.map((row) => (
|
||||
<DiffRow
|
||||
key={row.filename}
|
||||
row={row}
|
||||
addWidth={addWidth}
|
||||
remWidth={remWidth}
|
||||
statColumnWidth={statColumnWidth}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 && rows.length > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{t('…and {{hidden}} more (showing first {{shown}})', {
|
||||
hidden: String(hiddenCount),
|
||||
shown: String(rows.length),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface DiffRowProps {
|
||||
row: DiffRenderRow;
|
||||
addWidth: number;
|
||||
remWidth: number;
|
||||
statColumnWidth: number;
|
||||
}
|
||||
|
||||
const DiffRow: React.FC<DiffRowProps> = ({
|
||||
row,
|
||||
addWidth,
|
||||
remWidth,
|
||||
statColumnWidth,
|
||||
}) => {
|
||||
if (row.isBinary) {
|
||||
const marker = padRight('~', statColumnWidth);
|
||||
const suffix = row.isUntracked
|
||||
? t('(binary, new)')
|
||||
: row.isDeleted
|
||||
? t('(binary, deleted)')
|
||||
: t('(binary)');
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.text.secondary}>{marker}</Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.text.primary}>{row.filename}</Text>
|
||||
<Text color={theme.text.secondary}> {suffix}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const added = String(row.added ?? 0).padStart(addWidth);
|
||||
const removed = String(row.removed ?? 0).padStart(remWidth);
|
||||
let suffix: string | null = null;
|
||||
if (row.isUntracked) {
|
||||
suffix = row.truncated ? t('(new, partial)') : t('(new)');
|
||||
} else if (row.isDeleted) {
|
||||
suffix = t('(deleted)');
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.status.success}>+{added}</Text>
|
||||
<Text color={theme.text.primary}> </Text>
|
||||
<Text color={theme.status.error}>-{removed}</Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.text.primary}>{row.filename}</Text>
|
||||
{suffix && <Text color={theme.text.secondary}> {suffix}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function padRight(s: string, width: number): string {
|
||||
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
||||
}
|
||||
|
|
@ -175,6 +175,41 @@ export type HistoryItemStats = HistoryItemBase & {
|
|||
duration: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Structured payload rendered by `/diff`. Kept as plain data (not React nodes)
|
||||
* so the same model can feed both the Ink-based interactive display and the
|
||||
* plain-text non-interactive / ACP output.
|
||||
*/
|
||||
export interface DiffRenderRow {
|
||||
filename: string;
|
||||
/** `undefined` for binary files; a line count (lower bound if `truncated`)
|
||||
* otherwise. */
|
||||
added?: number;
|
||||
/** `undefined` for binary and untracked files. */
|
||||
removed?: number;
|
||||
isBinary: boolean;
|
||||
isUntracked: boolean;
|
||||
/** `true` when the file is removed from the worktree relative to HEAD.
|
||||
* Mutually exclusive with `isUntracked`. */
|
||||
isDeleted: boolean;
|
||||
/** Only set for untracked text files that exceeded the read cap. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface DiffRenderModel {
|
||||
filesCount: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
rows: DiffRenderRow[];
|
||||
/** `filesCount - rows.length` when the per-file cap truncated the listing. */
|
||||
hiddenCount: number;
|
||||
}
|
||||
|
||||
export type HistoryItemDiffStats = HistoryItemBase & {
|
||||
type: 'diff_stats';
|
||||
model: DiffRenderModel;
|
||||
};
|
||||
|
||||
export type HistoryItemModelStats = HistoryItemBase & {
|
||||
type: 'model_stats';
|
||||
};
|
||||
|
|
@ -506,7 +541,8 @@ export type HistoryItemWithoutId =
|
|||
| HistoryItemUserPromptSubmitBlocked
|
||||
| HistoryItemStopHookLoop
|
||||
| HistoryItemStopHookSystemMessage
|
||||
| HistoryItemDoctor;
|
||||
| HistoryItemDoctor
|
||||
| HistoryItemDiffStats;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
|
|
@ -535,6 +571,7 @@ export enum MessageType {
|
|||
ARENA_SESSION_COMPLETE = 'arena_session_complete',
|
||||
INSIGHT_PROGRESS = 'insight_progress',
|
||||
BTW = 'btw',
|
||||
DIFF_STATS = 'diff_stats',
|
||||
}
|
||||
|
||||
export interface InsightProgressProps {
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ export * from './utils/filesearch/fileSearch.js';
|
|||
export * from './utils/formatters.js';
|
||||
export * from './utils/generateContentResponseUtilities.js';
|
||||
export * from './utils/getFolderStructure.js';
|
||||
export * from './utils/gitDiff.js';
|
||||
export * from './utils/gitIgnoreParser.js';
|
||||
export * from './utils/gitUtils.js';
|
||||
export * from './utils/ignorePatterns.js';
|
||||
|
|
|
|||
1306
packages/core/src/utils/gitDiff.test.ts
Normal file
1306
packages/core/src/utils/gitDiff.test.ts
Normal file
File diff suppressed because it is too large
Load diff
930
packages/core/src/utils/gitDiff.ts
Normal file
930
packages/core/src/utils/gitDiff.ts
Normal file
|
|
@ -0,0 +1,930 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
// Namespace import (vs `import { constants }`) so vitest tests that
|
||||
// `vi.mock('node:fs', ...)` without supplying every named export don't
|
||||
// blow up in strict-mock mode just because they transitively load this
|
||||
// file via `@qwen-code/qwen-code-core`. The `constants?.X ?? 0` accesses
|
||||
// below absorb a missing `constants` field by falling through to plain
|
||||
// `O_RDONLY` (= 0 on POSIX) — harmless in mock environments where no
|
||||
// real `open()` ever runs.
|
||||
import * as nodeFs from 'node:fs';
|
||||
import { access, lstat, open, readFile, stat } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Hunk } from 'diff';
|
||||
import { findGitRoot } from './gitUtils.js';
|
||||
|
||||
/** Re-export so consumers don't need to depend on `diff` directly. */
|
||||
export type GitDiffHunk = Hunk;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface GitDiffStats {
|
||||
filesCount: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
}
|
||||
|
||||
export interface PerFileStats {
|
||||
added: number;
|
||||
removed: number;
|
||||
isBinary: boolean;
|
||||
isUntracked?: boolean;
|
||||
/** `true` when the file is removed in the worktree relative to HEAD.
|
||||
* Mutually exclusive with `isUntracked`. Detected via
|
||||
* `git diff HEAD --name-status -z` (status letter `D`); a row like
|
||||
* `0\t10\tfoo.ts` from numstat alone is not enough to distinguish
|
||||
* "deleted" from "heavy edit that drops 10 lines". */
|
||||
isDeleted?: boolean;
|
||||
/** Only meaningful for untracked files: `true` when the file exceeded the
|
||||
* line-counting read cap and `added` is therefore a lower bound. */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface GitDiffResult {
|
||||
stats: GitDiffStats;
|
||||
perFileStats: Map<string, PerFileStats>;
|
||||
}
|
||||
|
||||
const GIT_TIMEOUT_MS = 5000;
|
||||
/** Maximum files retained in per-file results. Matches issue #2997 "50 files" cap. */
|
||||
export const MAX_FILES = 50;
|
||||
/** Per-file diff content cap. Matches issue #2997 "1MB" cap. */
|
||||
export const MAX_DIFF_SIZE_BYTES = 1_000_000;
|
||||
/** Per-file diff line cap (GitHub's auto-load threshold). */
|
||||
export const MAX_LINES_PER_FILE = 400;
|
||||
/** Skip per-file parsing when the diff touches more than this many files. */
|
||||
export const MAX_FILES_FOR_DETAILS = 500;
|
||||
/** Sentinel used when `git diff --shortstat` returns nothing — most often
|
||||
* because there are no tracked changes at all. The fast-path threshold
|
||||
* is then driven entirely by the untracked count. */
|
||||
const EMPTY_STATS: GitDiffStats = {
|
||||
filesCount: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
};
|
||||
/** How much of an untracked file to read when counting its lines. */
|
||||
const UNTRACKED_READ_CAP_BYTES = MAX_DIFF_SIZE_BYTES;
|
||||
/** Per-file read buffer for line counting. With up to MAX_FILES (=50) files
|
||||
* reading concurrently, the worst-case heap footprint is ~3.2 MB instead of
|
||||
* the ~50 MB a single full-cap allocation per file would cost. */
|
||||
const UNTRACKED_READ_CHUNK_BYTES = 64 * 1024;
|
||||
/** Scan the first N bytes for NUL to detect binary files (matches git's heuristic). */
|
||||
const BINARY_SNIFF_BYTES = 8 * 1024;
|
||||
/** Memoized open flags for line counting. `O_NOFOLLOW` closes the TOCTOU
|
||||
* window between the `lstat` symlink check and `open` — if the path is
|
||||
* replaced with a symlink in that gap, `open` rejects with `ELOOP` instead
|
||||
* of silently dereferencing it. Falls back to plain `O_RDONLY` on platforms
|
||||
* that don't expose the flag (Windows constants omit `O_NOFOLLOW`).
|
||||
*
|
||||
* Computed lazily on first call (rather than at module load) so test files
|
||||
* that `vi.mock('node:fs', ...)` without supplying `constants` can still
|
||||
* load this module transitively via `@qwen-code/qwen-code-core` without
|
||||
* vitest's strict-mock proxy throwing on the property access. Tests that
|
||||
* do not actually exercise `countUntrackedLines` never trigger the lookup. */
|
||||
let untrackedOpenFlagsCache: number | undefined;
|
||||
function getUntrackedOpenFlags(): number {
|
||||
if (untrackedOpenFlagsCache === undefined) {
|
||||
untrackedOpenFlagsCache =
|
||||
(nodeFs.constants?.O_RDONLY ?? 0) | (nodeFs.constants?.O_NOFOLLOW ?? 0);
|
||||
}
|
||||
return untrackedOpenFlagsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch numstat-based git diff stats (files changed, lines added/removed) and
|
||||
* per-file summaries comparing the working tree to HEAD. Structured hunks are
|
||||
* available separately via `fetchGitDiffHunks`.
|
||||
*
|
||||
* Returns `null` when not inside a git repo, when git itself fails, or when
|
||||
* the working tree is in a transient state (merge, rebase, cherry-pick,
|
||||
* revert) — those states carry incoming changes that weren't intentionally
|
||||
* made by the user.
|
||||
*/
|
||||
export async function fetchGitDiff(cwd: string): Promise<GitDiffResult | null> {
|
||||
// Walk ancestors once to find the worktree root; reuse the result for the
|
||||
// transient-state probe and every git invocation below. `findGitRoot`
|
||||
// doubles as the "is this a git repo" check — a non-null return implies a
|
||||
// repo. `git diff` already emits repo-root-relative paths regardless of
|
||||
// cwd, but `git ls-files --others` is scoped to cwd, so pinning everything
|
||||
// to the same root keeps the path keys consistent and ensures untracked
|
||||
// files in sibling directories aren't silently dropped when /diff is
|
||||
// invoked from a subdirectory of the worktree.
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
if (!gitRoot) return null;
|
||||
if (await isInTransientGitState(gitRoot)) return null;
|
||||
|
||||
// Shortstat probe + untracked scan run in parallel — both are needed
|
||||
// regardless of which path we take, and shortstat is O(1) memory so it can
|
||||
// short-circuit huge generated workspaces before we pay the per-file
|
||||
// numstat cost. For untracked we hold the raw stdout rather than the parsed
|
||||
// list so the fast path only has to count NUL bytes instead of allocating
|
||||
// a full path array.
|
||||
// Every `git diff` invocation passes both `--no-ext-diff` AND
|
||||
// `--no-textconv` so the worktree's config can never run user-supplied
|
||||
// commands while /diff is only inspecting changes. The two flags cover
|
||||
// independent attack surfaces: `--no-ext-diff` blocks `GIT_EXTERNAL_DIFF`
|
||||
// and `diff.<name>.command`, while `--no-textconv` blocks the textconv
|
||||
// filter that .gitattributes + `diff.<name>.textconv` register (e.g.
|
||||
// `pdftotext` to render PDFs). In practice the stats variants
|
||||
// (`--shortstat`, `--numstat`, `--name-status`) do not invoke either
|
||||
// mechanism, but pinning both flags everywhere is defense-in-depth —
|
||||
// git's behavior around these drivers has shifted between versions
|
||||
// before.
|
||||
const [shortstatOut, untrackedOut] = await Promise.all([
|
||||
runGit(
|
||||
[
|
||||
'--no-optional-locks',
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'HEAD',
|
||||
'--shortstat',
|
||||
],
|
||||
gitRoot,
|
||||
),
|
||||
runGit(
|
||||
[
|
||||
'--no-optional-locks',
|
||||
'ls-files',
|
||||
'-z',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
],
|
||||
gitRoot,
|
||||
),
|
||||
]);
|
||||
const untrackedCount = countNulDelimited(untrackedOut);
|
||||
|
||||
// Apply the >500-file fast path on tracked + untracked, treating "no
|
||||
// shortstat output" (no tracked changes) and "shortstat unparseable"
|
||||
// both as zero tracked stats. Without this fall-through, a workspace
|
||||
// with 0 tracked + 501 untracked files would slip past the guardrail:
|
||||
// shortstat would be empty, parseShortstat would return null, and the
|
||||
// slow path would only line-count the first MAX_FILES untracked
|
||||
// entries — leaving `filesCount: 501` paired with a `linesAdded` that
|
||||
// missed the other 451 files.
|
||||
const quickStats =
|
||||
(shortstatOut != null && parseShortstat(shortstatOut)) || EMPTY_STATS;
|
||||
if (quickStats.filesCount + untrackedCount > MAX_FILES_FOR_DETAILS) {
|
||||
return {
|
||||
stats: {
|
||||
...quickStats,
|
||||
filesCount: quickStats.filesCount + untrackedCount,
|
||||
},
|
||||
perFileStats: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
// Numstat gives us +/- counts; name-status tells us *why* a row exists
|
||||
// (D = deleted, M = modified, R<score> = rename, etc.). We need both
|
||||
// because numstat alone can't distinguish a delete (`0\tN\tpath`) from
|
||||
// a heavy edit that drops N lines.
|
||||
const [numstatOut, nameStatusOut] = await Promise.all([
|
||||
runGit(
|
||||
[
|
||||
'--no-optional-locks',
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'HEAD',
|
||||
'--numstat',
|
||||
'-z',
|
||||
],
|
||||
gitRoot,
|
||||
),
|
||||
runGit(
|
||||
[
|
||||
'--no-optional-locks',
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'HEAD',
|
||||
'--name-status',
|
||||
'-z',
|
||||
],
|
||||
gitRoot,
|
||||
),
|
||||
]);
|
||||
if (numstatOut == null) return null;
|
||||
|
||||
const { stats, perFileStats } = parseGitNumstat(numstatOut);
|
||||
const deletedPaths =
|
||||
nameStatusOut != null ? parseDeletedFromNameStatus(nameStatusOut) : null;
|
||||
if (deletedPaths && deletedPaths.size > 0) {
|
||||
for (const [filename, s] of perFileStats) {
|
||||
if (deletedPaths.has(filename)) s.isDeleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (untrackedCount > 0) {
|
||||
// Count every untracked file in the totals, even if the per-file map is
|
||||
// already full. Otherwise `filesCount` under-reports whenever tracked
|
||||
// changes already fill the `MAX_FILES` slot.
|
||||
stats.filesCount += untrackedCount;
|
||||
const untrackedPaths = splitNulDelimited(untrackedOut);
|
||||
// Read line counts for *every* untracked path that survived the
|
||||
// `>MAX_FILES_FOR_DETAILS` fast-path filter (so up to ~500 files at the
|
||||
// outer cap, not just the first MAX_FILES). Otherwise a workspace with
|
||||
// 51-500 untracked files would surface in the header as e.g. "60 files
|
||||
// changed, +50 lines" — the +50 only covering the first 50 files,
|
||||
// bypassing the contributions of the remaining 10. Concurrency is
|
||||
// bounded to MAX_FILES so peak heap stays around
|
||||
// `MAX_FILES * UNTRACKED_READ_CHUNK_BYTES` (~3.2 MB) regardless of how
|
||||
// many untracked files are in the slow-path window.
|
||||
const lineStats = await mapWithConcurrency(
|
||||
untrackedPaths,
|
||||
MAX_FILES,
|
||||
(relPath) => countUntrackedLines(path.join(gitRoot, relPath)),
|
||||
);
|
||||
for (const s of lineStats) stats.linesAdded += s.added;
|
||||
|
||||
// Per-file rendering still caps at MAX_FILES — only the first
|
||||
// `remainingSlots` untracked entries become visible rows. The rest are
|
||||
// already folded into `linesAdded` above and into `filesCount`, so
|
||||
// `hiddenCount` covers them faithfully on the renderer side.
|
||||
const remainingSlots = Math.max(0, MAX_FILES - perFileStats.size);
|
||||
const visibleCount = Math.min(remainingSlots, untrackedPaths.length);
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const relPath = untrackedPaths[i] ?? '';
|
||||
const u = lineStats[i] ?? {
|
||||
added: 0,
|
||||
isBinary: false,
|
||||
truncated: false,
|
||||
};
|
||||
perFileStats.set(relPath, {
|
||||
added: u.added,
|
||||
removed: 0,
|
||||
isBinary: u.isBinary,
|
||||
isUntracked: true,
|
||||
truncated: u.truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { stats, perFileStats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch structured hunks for the current working tree vs HEAD. Separate
|
||||
* from `fetchGitDiff` so callers that only need stats do not pay the full
|
||||
* diff cost.
|
||||
*
|
||||
* NOTE on memory: this reads the full `git diff HEAD` stdout via `execFile`
|
||||
* before applying parser caps (`MAX_FILES`, `MAX_DIFF_SIZE_BYTES`,
|
||||
* `MAX_LINES_PER_FILE`). For very large diffs we can buffer up to the
|
||||
* `runGit` `maxBuffer` (64 MB) before dropping content. Streaming the
|
||||
* parser would let us terminate `git` early at `MAX_FILES`; that's a
|
||||
* reasonable follow-up but out of scope for this utility's first cut.
|
||||
*/
|
||||
export async function fetchGitDiffHunks(
|
||||
cwd: string,
|
||||
): Promise<Map<string, Hunk[]>> {
|
||||
// Walk ancestors once; reuse for the transient-state probe and the diff
|
||||
// call. Running from the repo root also keeps hunk keys repo-root-relative
|
||||
// regardless of which subdirectory the caller is in.
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
if (!gitRoot) return new Map();
|
||||
if (await isInTransientGitState(gitRoot)) return new Map();
|
||||
|
||||
// Plain `git diff` honors both `GIT_EXTERNAL_DIFF` / `diff.<name>.command`
|
||||
// (blocked by `--no-ext-diff`) AND .gitattributes-driven textconv filters
|
||||
// like `diff.<name>.textconv` (blocked by `--no-textconv`) — independent
|
||||
// command-execution surfaces, both of which we have to disable on this
|
||||
// read-only utility. The stats variants in `fetchGitDiff` already bypass
|
||||
// both, but plain diff fires both unless told not to.
|
||||
const diffOut = await runGit(
|
||||
['--no-optional-locks', 'diff', '--no-ext-diff', '--no-textconv', 'HEAD'],
|
||||
gitRoot,
|
||||
);
|
||||
if (diffOut == null) return new Map();
|
||||
return parseGitDiff(diffOut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --numstat -z` output.
|
||||
*
|
||||
* Wire format (stable per `git-diff(1)`):
|
||||
* - Non-rename: `<added>\t<removed>\t<path>\0`
|
||||
* - Rename: `<added>\t<removed>\t\0<oldpath>\0<newpath>\0`
|
||||
*
|
||||
* Using `-z` (vs the default newline-delimited form) keeps paths byte-accurate:
|
||||
* tabs, newlines, and non-ASCII characters all round-trip without git's
|
||||
* C-style quoting, so `perFileStats` keys match the real on-disk filenames.
|
||||
*
|
||||
* Binary files use `-` for both counts. Only the first `MAX_FILES` entries are
|
||||
* retained in `perFileStats`; totals account for every entry.
|
||||
*/
|
||||
export function parseGitNumstat(stdout: string): GitDiffResult {
|
||||
// Drop the trailing empty chunk from the terminating NUL.
|
||||
const tokens = stdout.split('\0');
|
||||
if (tokens.length > 0 && tokens[tokens.length - 1] === '') tokens.pop();
|
||||
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let validFileCount = 0;
|
||||
const perFileStats = new Map<string, PerFileStats>();
|
||||
|
||||
// Rename entries span three tokens ({counts}, oldPath, newPath). When we
|
||||
// see an empty path in the counts token we stash the counts here and
|
||||
// consume the next two tokens as the rename pair.
|
||||
let pending: { added: number; removed: number; isBinary: boolean } | null =
|
||||
null;
|
||||
let renameOld: string | null = null;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (pending) {
|
||||
if (renameOld === null) {
|
||||
renameOld = token;
|
||||
continue;
|
||||
}
|
||||
commitEntry(
|
||||
`${renameOld} => ${token}`,
|
||||
pending.added,
|
||||
pending.removed,
|
||||
pending.isBinary,
|
||||
);
|
||||
pending = null;
|
||||
renameOld = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Index-based parse — `split('\t')` is unsafe because `-z` preserves
|
||||
// literal tabs inside filenames.
|
||||
const firstTab = token.indexOf('\t');
|
||||
if (firstTab < 0) continue;
|
||||
const secondTab = token.indexOf('\t', firstTab + 1);
|
||||
if (secondTab < 0) continue;
|
||||
const addStr = token.slice(0, firstTab);
|
||||
const remStr = token.slice(firstTab + 1, secondTab);
|
||||
const filePath = token.slice(secondTab + 1);
|
||||
const isBinary = addStr === '-' || remStr === '-';
|
||||
const fileAdded = isBinary ? 0 : parseInt(addStr, 10) || 0;
|
||||
const fileRemoved = isBinary ? 0 : parseInt(remStr, 10) || 0;
|
||||
|
||||
if (filePath === '') {
|
||||
// Rename header — wait for oldPath and newPath tokens.
|
||||
pending = { added: fileAdded, removed: fileRemoved, isBinary };
|
||||
continue;
|
||||
}
|
||||
commitEntry(filePath, fileAdded, fileRemoved, isBinary);
|
||||
}
|
||||
|
||||
function commitEntry(
|
||||
filePath: string,
|
||||
fileAdded: number,
|
||||
fileRemoved: number,
|
||||
isBinary: boolean,
|
||||
): void {
|
||||
validFileCount++;
|
||||
added += fileAdded;
|
||||
removed += fileRemoved;
|
||||
if (perFileStats.size < MAX_FILES) {
|
||||
perFileStats.set(filePath, {
|
||||
added: fileAdded,
|
||||
removed: fileRemoved,
|
||||
isBinary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: {
|
||||
filesCount: validFileCount,
|
||||
linesAdded: added,
|
||||
linesRemoved: removed,
|
||||
},
|
||||
perFileStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff output into per-file hunks.
|
||||
*
|
||||
* Limits applied:
|
||||
* - Stop once `MAX_FILES` files have been collected.
|
||||
* - Skip files whose raw diff exceeds `MAX_DIFF_SIZE_BYTES`.
|
||||
* - Truncate per-file content at `MAX_LINES_PER_FILE` lines.
|
||||
*/
|
||||
export function parseGitDiff(stdout: string): Map<string, Hunk[]> {
|
||||
const result = new Map<string, Hunk[]>();
|
||||
if (!stdout.trim()) return result;
|
||||
|
||||
const fileDiffs = stdout.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const fileDiff of fileDiffs) {
|
||||
if (result.size >= MAX_FILES) break;
|
||||
// Use UTF-8 byte length (not JS string .length, which counts UTF-16 code
|
||||
// units) so the cap matches the documented `MAX_DIFF_SIZE_BYTES` semantic
|
||||
// on non-ASCII diffs.
|
||||
if (Buffer.byteLength(fileDiff, 'utf8') > MAX_DIFF_SIZE_BYTES) continue;
|
||||
|
||||
const lines = fileDiff.split('\n');
|
||||
// The `diff --git a/X b/Y` header is ambiguous for paths that contain
|
||||
// ` b/` (e.g. `a b/c.txt` yields `diff --git a/a b/c.txt b/a b/c.txt`).
|
||||
// Prefer the unambiguous metadata that follows: `rename to`, `copy to`,
|
||||
// or the `+++ b/<path>` / `--- a/<path>` lines. Git appends a trailing
|
||||
// TAB to those paths when they contain whitespace — that's our real
|
||||
// end-of-path marker.
|
||||
const filePath = extractFilePath(lines);
|
||||
if (filePath === null) continue;
|
||||
|
||||
const fileHunks: Hunk[] = [];
|
||||
let currentHunk: Hunk | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
const hunkMatch = line.match(
|
||||
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/,
|
||||
);
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) fileHunks.push(currentHunk);
|
||||
currentHunk = {
|
||||
oldStart: parseInt(hunkMatch[1] ?? '0', 10),
|
||||
oldLines: parseInt(hunkMatch[2] ?? '1', 10),
|
||||
newStart: parseInt(hunkMatch[3] ?? '0', 10),
|
||||
newLines: parseInt(hunkMatch[4] ?? '1', 10),
|
||||
lines: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pre-hunk metadata is only skipped before the first `@@` header. Once
|
||||
// inside a hunk, a line like `---foo` is a removed source line whose
|
||||
// content happens to start with `---`, and must not be dropped.
|
||||
if (!currentHunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith('+') ||
|
||||
line.startsWith('-') ||
|
||||
line.startsWith(' ')
|
||||
) {
|
||||
if (lineCount >= MAX_LINES_PER_FILE) break;
|
||||
// Force a flat string copy to break V8 sliced-string references so the
|
||||
// whole raw diff can be GC'd once parsing finishes.
|
||||
currentHunk.lines.push('' + line);
|
||||
lineCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) fileHunks.push(currentHunk);
|
||||
if (fileHunks.length > 0) result.set(filePath, fileHunks);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a path field from a `diff --git` header — handles both unquoted
|
||||
* (`b/foo.txt`) and C-style quoted (`"b/tab\there.txt"`) forms.
|
||||
*
|
||||
* Git wraps a path in `"..."` and applies C-style escaping (`\t`, `\n`,
|
||||
* `\r`, `\"`, `\\`, plus octal `\NNN` for non-ASCII bytes) whenever the
|
||||
* raw path contains a character that breaks the simple space-delimited
|
||||
* format. `core.quotepath=false` disables ONLY the octal escaping for
|
||||
* non-ASCII bytes; control chars and quotes are still escaped, so we
|
||||
* must decode them ourselves to preserve the real on-disk filename.
|
||||
*
|
||||
* Octal escapes are decoded as raw byte values then UTF-8-decoded en
|
||||
* masse so multi-byte sequences like `\346\226\207` (文) round-trip
|
||||
* correctly even though we never set quotepath=true ourselves.
|
||||
*/
|
||||
function unquoteCStylePath(s: string): string {
|
||||
if (!s.startsWith('"') || !s.endsWith('"') || s.length < 2) return s;
|
||||
const inner = s.slice(1, -1);
|
||||
// Build raw bytes first so octal `\NNN` sequences (each one byte of a
|
||||
// potentially multi-byte UTF-8 character) reassemble correctly. We walk by
|
||||
// Unicode code points (not UTF-16 code units), so non-BMP characters such as
|
||||
// emoji that may appear inside a quoted path under `core.quotepath=false`
|
||||
// round-trip through UTF-8 instead of being split into lone surrogates.
|
||||
const bytes: number[] = [];
|
||||
let i = 0;
|
||||
while (i < inner.length) {
|
||||
const c = inner.charCodeAt(i);
|
||||
if (c !== 0x5c /* '\' */) {
|
||||
const cp = inner.codePointAt(i);
|
||||
if (cp === undefined) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const ch = String.fromCodePoint(cp);
|
||||
bytes.push(...Buffer.from(ch, 'utf8'));
|
||||
i += ch.length;
|
||||
continue;
|
||||
}
|
||||
const next = inner[i + 1];
|
||||
if (next === undefined) {
|
||||
bytes.push(0x5c);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
switch (next) {
|
||||
case 'a':
|
||||
bytes.push(0x07);
|
||||
i += 2;
|
||||
break;
|
||||
case 'b':
|
||||
bytes.push(0x08);
|
||||
i += 2;
|
||||
break;
|
||||
case 'f':
|
||||
bytes.push(0x0c);
|
||||
i += 2;
|
||||
break;
|
||||
case 'v':
|
||||
bytes.push(0x0b);
|
||||
i += 2;
|
||||
break;
|
||||
case 't':
|
||||
bytes.push(0x09);
|
||||
i += 2;
|
||||
break;
|
||||
case 'n':
|
||||
bytes.push(0x0a);
|
||||
i += 2;
|
||||
break;
|
||||
case 'r':
|
||||
bytes.push(0x0d);
|
||||
i += 2;
|
||||
break;
|
||||
case '"':
|
||||
bytes.push(0x22);
|
||||
i += 2;
|
||||
break;
|
||||
case '\\':
|
||||
bytes.push(0x5c);
|
||||
i += 2;
|
||||
break;
|
||||
default:
|
||||
if (next >= '0' && next <= '7') {
|
||||
let octal = '';
|
||||
while (
|
||||
octal.length < 3 &&
|
||||
i + 1 + octal.length < inner.length &&
|
||||
(inner[i + 1 + octal.length] ?? '') >= '0' &&
|
||||
(inner[i + 1 + octal.length] ?? '') <= '7'
|
||||
) {
|
||||
octal += inner[i + 1 + octal.length];
|
||||
}
|
||||
bytes.push(parseInt(octal, 8) & 0xff);
|
||||
i += 1 + octal.length;
|
||||
} else {
|
||||
bytes.push(...Buffer.from(next, 'utf8'));
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the real filename from a `diff --git` file block, avoiding the
|
||||
* ambiguity of `diff --git a/X b/Y` when `X` itself contains ` b/`.
|
||||
*
|
||||
* Preference order:
|
||||
* 1. `rename to <path>` / `copy to <path>` — the authoritative new name.
|
||||
* 2. `+++ b/<path>` — the new-side path for in-place modifications. When
|
||||
* the file was deleted the line reads `+++ /dev/null`; we then fall back
|
||||
* to `--- a/<path>` for the old name.
|
||||
* 3. `--- a/<path>` alone — for the rare case where `+++` is absent.
|
||||
*
|
||||
* Each candidate path goes through `stripTab` (cut at the trailing TAB git
|
||||
* appends after whitespace-containing paths) and `unquoteCStylePath`
|
||||
* (decode `"..."` C-quoted form for paths whose raw bytes include tabs,
|
||||
* newlines, quotes, or non-ASCII characters that core.quotepath does not
|
||||
* suppress). Without the unquote step, fetchGitDiffHunks would silently
|
||||
* drop hunks for any tracked file whose name contains those characters.
|
||||
*
|
||||
* Returns `null` when the block has no hunks or no recognizable path line
|
||||
* (mode-only changes, for example).
|
||||
*/
|
||||
function extractFilePath(lines: string[]): string | null {
|
||||
let plus: string | null = null;
|
||||
let minus: string | null = null;
|
||||
let renameTo: string | null = null;
|
||||
let copyTo: string | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@ ')) break;
|
||||
if (line.startsWith('+++ ')) plus = line.slice(4);
|
||||
else if (line.startsWith('--- ')) minus = line.slice(4);
|
||||
else if (line.startsWith('rename to ')) renameTo = line.slice(10);
|
||||
else if (line.startsWith('copy to ')) copyTo = line.slice(8);
|
||||
}
|
||||
const stripTab = (s: string): string => {
|
||||
const t = s.indexOf('\t');
|
||||
return t >= 0 ? s.slice(0, t) : s;
|
||||
};
|
||||
// Strip the TAB-end-of-path marker first, then C-unquote — git emits the
|
||||
// TAB AFTER the closing quote on quoted paths.
|
||||
const normalize = (s: string): string => unquoteCStylePath(stripTab(s));
|
||||
if (renameTo !== null) return normalize(renameTo);
|
||||
if (copyTo !== null) return normalize(copyTo);
|
||||
if (plus !== null) {
|
||||
const p = normalize(plus);
|
||||
if (p !== '/dev/null' && p.startsWith('b/')) return p.slice(2);
|
||||
// Deleted file — fall back to the old path.
|
||||
if (minus !== null) {
|
||||
const m = normalize(minus);
|
||||
if (m !== '/dev/null' && m.startsWith('a/')) return m.slice(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (minus !== null) {
|
||||
const m = normalize(minus);
|
||||
if (m !== '/dev/null' && m.startsWith('a/')) return m.slice(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --shortstat` output, e.g.
|
||||
* ` 3 files changed, 42 insertions(+), 7 deletions(-)`.
|
||||
*
|
||||
* The regex is anchored (line start/end with the `m` flag) and uses single
|
||||
* literal spaces plus bounded `\d{1,10}` digit runs. This closes CodeQL alert
|
||||
* #137: the previous unanchored form with `\s+` and `\d+` in nested optional
|
||||
* groups could backtrack polynomially on crafted strings of `0`s.
|
||||
*/
|
||||
export function parseShortstat(stdout: string): GitDiffStats | null {
|
||||
const match = stdout.match(
|
||||
/^ ?(\d{1,10}) files? changed(?:, (\d{1,10}) insertions?\(\+\))?(?:, (\d{1,10}) deletions?\(-\))?$/m,
|
||||
);
|
||||
if (!match) return null;
|
||||
return {
|
||||
filesCount: parseInt(match[1] ?? '0', 10),
|
||||
linesAdded: parseInt(match[2] ?? '0', 10),
|
||||
linesRemoved: parseInt(match[3] ?? '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff HEAD --name-status -z` output and return the paths whose
|
||||
* status is `D` (deleted in the worktree).
|
||||
*
|
||||
* Wire format with `-z`: `<status>\0<path>\0` per entry, except renames and
|
||||
* copies which span three tokens: `R<score>\0<oldpath>\0<newpath>\0` (and
|
||||
* `C<score>\0...`). We only care about deletions here, so renames/copies
|
||||
* are walked past — neither half of a rename pair is "deleted" in the
|
||||
* user-facing sense (the file still exists under the new name).
|
||||
*/
|
||||
export function parseDeletedFromNameStatus(stdout: string): Set<string> {
|
||||
const tokens = stdout.split('\0');
|
||||
if (tokens.length > 0 && tokens[tokens.length - 1] === '') tokens.pop();
|
||||
|
||||
const deleted = new Set<string>();
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const status = tokens[i] ?? '';
|
||||
i++;
|
||||
if (status === '') continue;
|
||||
const head = status[0];
|
||||
// Rename / copy entries are followed by TWO path tokens.
|
||||
if (head === 'R' || head === 'C') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const path = tokens[i] ?? '';
|
||||
i++;
|
||||
if (head === 'D' && path !== '') deleted.add(path);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function countNulDelimited(stdout: string | null): number {
|
||||
if (!stdout) return 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < stdout.length; i++) {
|
||||
if (stdout.charCodeAt(i) === 0) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function splitNulDelimited(stdout: string | null): string[] {
|
||||
if (!stdout) return [];
|
||||
return stdout.split('\0').filter(Boolean);
|
||||
}
|
||||
|
||||
interface UntrackedLineStats {
|
||||
added: number;
|
||||
isBinary: boolean;
|
||||
/** `true` when the file was larger than the read cap so `added` is a lower
|
||||
* bound (the caller is expected to surface this so the user knows). */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count lines in an untracked file so the /diff totals include it. Reads up
|
||||
* to `UNTRACKED_READ_CAP_BYTES`, bails on NUL in the first `BINARY_SNIFF_BYTES`
|
||||
* (git's own heuristic), and swallows read errors into a zero-result so one
|
||||
* unreadable file can't block the whole command. `truncated` is set when
|
||||
* `fstat(size) > bytesRead`, so the UI can mark partial counts honestly
|
||||
* instead of silently under-reporting a 10 MB log as `+20k`.
|
||||
*
|
||||
* Uses `lstat` before `open` to gate on regular files only — git's
|
||||
* `ls-files --others` can list FIFOs (whose `open()` would block forever
|
||||
* waiting on a writer) and symlinks (whose target may live outside the
|
||||
* worktree). Symlinks and non-regular files render as binary `~` rows.
|
||||
*/
|
||||
async function countUntrackedLines(
|
||||
absPath: string,
|
||||
): Promise<UntrackedLineStats> {
|
||||
let st;
|
||||
try {
|
||||
st = await lstat(absPath);
|
||||
} catch {
|
||||
// File raced out from under ls-files (deleted, permission revoked, etc.).
|
||||
// Surface it as a binary row to be consistent with the open-failure /
|
||||
// non-regular-file branches below — `+0 (new)` would lie about it being
|
||||
// an empty text file when we genuinely have no signal.
|
||||
return { added: 0, isBinary: true, truncated: false };
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
return { added: 0, isBinary: true, truncated: false };
|
||||
}
|
||||
let fh;
|
||||
try {
|
||||
fh = await open(absPath, getUntrackedOpenFlags());
|
||||
} catch {
|
||||
// ELOOP from O_NOFOLLOW (path raced into a symlink between lstat and
|
||||
// open) and any other open error all collapse to a binary row so the
|
||||
// file appears once in the listing without contributing line counts.
|
||||
return { added: 0, isBinary: true, truncated: false };
|
||||
}
|
||||
try {
|
||||
// Stream the file in fixed-size chunks instead of allocating one full
|
||||
// `UNTRACKED_READ_CAP_BYTES` buffer per call. With up to MAX_FILES
|
||||
// line-counts running concurrently the heap footprint stays around
|
||||
// `MAX_FILES * UNTRACKED_READ_CHUNK_BYTES` (~3.2 MB) rather than the
|
||||
// ~50 MB a one-shot full-cap alloc would have cost on a constrained
|
||||
// host. Behavior (line count, binary sniff, truncation flag) is
|
||||
// identical to the single-shot path.
|
||||
const buf = Buffer.allocUnsafe(UNTRACKED_READ_CHUNK_BYTES);
|
||||
let totalRead = 0;
|
||||
let lines = 0;
|
||||
let lastByte = -1;
|
||||
let sniffedBytes = 0;
|
||||
while (totalRead < UNTRACKED_READ_CAP_BYTES) {
|
||||
const remaining = UNTRACKED_READ_CAP_BYTES - totalRead;
|
||||
const toRead = Math.min(buf.length, remaining);
|
||||
const { bytesRead } = await fh.read(buf, 0, toRead, totalRead);
|
||||
if (bytesRead === 0) break;
|
||||
|
||||
// Binary sniff on the first BINARY_SNIFF_BYTES across cumulative reads.
|
||||
// Almost always completes inside the first chunk because chunk size
|
||||
// (64 KB) is much larger than the sniff window (8 KB).
|
||||
if (sniffedBytes < BINARY_SNIFF_BYTES) {
|
||||
const sniffEnd = Math.min(bytesRead, BINARY_SNIFF_BYTES - sniffedBytes);
|
||||
for (let i = 0; i < sniffEnd; i++) {
|
||||
if (buf[i] === 0) {
|
||||
return { added: 0, isBinary: true, truncated: false };
|
||||
}
|
||||
}
|
||||
sniffedBytes += sniffEnd;
|
||||
}
|
||||
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buf[i] === 0x0a) lines++;
|
||||
}
|
||||
lastByte = buf[bytesRead - 1] ?? -1;
|
||||
totalRead += bytesRead;
|
||||
}
|
||||
|
||||
if (totalRead === 0) {
|
||||
return { added: 0, isBinary: false, truncated: false };
|
||||
}
|
||||
// Truncated only when we hit the cap with more bytes still on disk.
|
||||
// A `read()` returning 0 means EOF, so we naturally exit untruncated.
|
||||
let truncated = false;
|
||||
if (totalRead >= UNTRACKED_READ_CAP_BYTES) {
|
||||
const { size } = await fh.stat();
|
||||
truncated = size > totalRead;
|
||||
}
|
||||
// If the portion we read ends mid-line (no trailing `\n`) and the read
|
||||
// reached EOF, count that trailing partial line. When the read was cut
|
||||
// short by the cap, the "trailing partial" is really a line that
|
||||
// continues past the cap; counting it here would double-count once the
|
||||
// cap is raised.
|
||||
if (!truncated && lastByte !== 0x0a) lines++;
|
||||
return { added: lines, isBinary: false, truncated };
|
||||
} catch {
|
||||
// Mid-read failure (EIO, fh.stat throwing, etc.). Discard the partial
|
||||
// count and surface as binary — same opaque marker as every other
|
||||
// "we couldn't read this" branch in this function.
|
||||
return { added: 0, isBinary: true, truncated: false };
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the real git directory for a working tree, following `.git` file
|
||||
* indirection used by linked worktrees (`git worktree add`) and submodules.
|
||||
* Returns `null` when the location is not inside a git repo.
|
||||
*/
|
||||
export async function resolveGitDir(cwd: string): Promise<string | null> {
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
if (!gitRoot) return null;
|
||||
return resolveGitDirFromRoot(gitRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same contract as `resolveGitDir`, but skips the ancestor walk when the
|
||||
* caller has already resolved the worktree root. Used by `fetchGitDiff` /
|
||||
* `fetchGitDiffHunks` so they walk ancestors at most once per invocation.
|
||||
*/
|
||||
async function resolveGitDirFromRoot(gitRoot: string): Promise<string | null> {
|
||||
const dotGit = path.join(gitRoot, '.git');
|
||||
try {
|
||||
const s = await stat(dotGit);
|
||||
if (s.isDirectory()) return dotGit;
|
||||
if (!s.isFile()) return null;
|
||||
const content = await readFile(dotGit, 'utf8');
|
||||
const match = content.match(/^gitdir:\s*(.+?)\s*$/m);
|
||||
if (!match || !match[1]) return null;
|
||||
const raw = match[1];
|
||||
return path.isAbsolute(raw) ? raw : path.resolve(gitRoot, raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isInTransientGitState(gitRoot: string): Promise<boolean> {
|
||||
const gitDir = await resolveGitDirFromRoot(gitRoot);
|
||||
if (!gitDir) return false;
|
||||
|
||||
// Rebase-in-progress is signalled by a directory, not a ref file. Both
|
||||
// rebase-apply (git-am backed) and rebase-merge (interactive / `-m`) forms
|
||||
// are covered. REBASE_HEAD alone misses the common case.
|
||||
const transientPaths = [
|
||||
'MERGE_HEAD',
|
||||
'CHERRY_PICK_HEAD',
|
||||
'REVERT_HEAD',
|
||||
'rebase-merge',
|
||||
'rebase-apply',
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
transientPaths.map((name) =>
|
||||
access(path.join(gitDir, name))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
),
|
||||
);
|
||||
return results.some(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async mapper over `items` with at most `limit` operations in
|
||||
* flight at once. Used for untracked-file line counting so a workspace with
|
||||
* a few hundred untracked files doesn't open 500 file descriptors in
|
||||
* parallel — peak heap stays at `limit * UNTRACKED_READ_CHUNK_BYTES`
|
||||
* regardless of `items.length`.
|
||||
*
|
||||
* Order-preserving: `results[i]` corresponds to `items[i]`. Failures
|
||||
* propagate as thrown errors (`countUntrackedLines` already swallows its
|
||||
* own I/O errors, so callers here see no rejections in practice).
|
||||
*/
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) return [];
|
||||
const effective = Math.max(1, limit);
|
||||
const results: R[] = new Array(items.length);
|
||||
for (let i = 0; i < items.length; i += effective) {
|
||||
const slice = items.slice(i, i + effective);
|
||||
const batch = await Promise.all(slice.map((item) => fn(item)));
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
results[i + j] = batch[j] as R;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<string | null> {
|
||||
// `core.quotepath=false` keeps non-ASCII filenames as UTF-8 in git's output
|
||||
// instead of octal-escaping them (`\346\226\207.txt`), which would otherwise
|
||||
// end up as literal keys in `perFileStats`.
|
||||
const fullArgs = ['-c', 'core.quotepath=false', ...args];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', fullArgs, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generatedAt": "2026-04-24T07:06:13.173Z",
|
||||
"generatedAt": "2026-04-28T03:29:36.589Z",
|
||||
"keys": [
|
||||
" Models: Qwen latest models\n",
|
||||
" qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue