Skip to content

(changes): list every untracked file, and give it a real diff and real counts - #298

Open
jbr-sekoia wants to merge 5 commits into
devsuitup:mainfrom
jbr-sekoia:feat/changes-untracked
Open

jbr-sekoia wants to merge 5 commits into
devsuitup:mainfrom
jbr-sekoia:feat/changes-untracked

Conversation

@jbr-sekoia

@jbr-sekoia jbr-sekoia commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Why

The Changes panel listed untracked files, but two gaps made "what did I actually touch?" an unreliable question to ask it.

A brand-new directory collapsed to one row. status() ran git status --porcelain=v2 --branch -z with no -u flag, so git applied its default --untracked-files=normal, which reports a wholly untracked directory as a single entry and never descends into it. On a scratch repo with one tracked file modified and newdir/a.txt + newdir/sub/b.txt created:

--- default ---
1 .M N... ... tracked.txt
? newdir/
--- with -uall ---
1 .M N... ... tracked.txt
? newdir/a.txt
? newdir/sub/b.txt

A session that created a whole new directory — a new module, a scaffolded package, a test fixture tree — showed one row whose path was a directory. Clicking it produced nothing, and the header's file count was wrong.

An untracked file had no counts and no diff. mergeChanges() sets added: null, deleted: null for an untracked entry, because git diff --numstat genuinely never reports untracked files. The row rendered no counts, the header's +A −B excluded everything new, and clicking short-circuited to "Untracked file — nothing to diff yet." A new 400-line file is the largest change a session can make, and it was the one change the panel refused to show.

What

-uall, so each untracked file is its own record and no row's path is ever a directory.

A real diff and real counts for an untracked file, from git diff --no-index -- /dev/null <path>. Counts are derived from the diff already fetched on row click — no extra git invocation, and no per-file invocation in status(), which runs on every busy→idle edge.

--no-index is a different command with different rules

Three properties of --no-index do not match the pathspec path this panel was built on, and each one cost a defect before it was closed:

  1. It exits 1 when the inputs differ. The runner treats any non-zero code as failure via firstError(), so the untracked call needs its own success rule: 0 and 1 are both success, and exit 1 with empty stdout plus non-empty stderr is a genuine error (--no-index also uses exit 1 for error: Could not access 'x').

  2. Its operands are filesystem paths, not pathspecs. --literal-pathspecs does not reach them, and git performs no repository-containment check on them — measured: raw git printed the contents of a file outside the repository. On the pathspec path git refuses that itself, so nothing in this panel had ever needed its own containment.

  3. Git follows a symlink to a directory, then pairs the operands by basename. /dev/null against a symlinked directory becomes /dev/null<dirlink>/null. Since git status -uall lists a symlink as its own untracked row, this was reachable by an ordinary click, not only by a crafted IPC argument.

Containment is therefore closed by two independent layers, each separately load-bearing (removing either turns a different test red; removing both turns the leak test red):

  • Local — the cwd and the operand's parent are resolved with fs.realpathSync.native, the parent must be inside the resolved root by path.relative rather than a prefix test (so a sibling <repo>-evil does not pass), the leaf is lstated and stated so a symlink resolving to anything but a regular file is refused, and the operand handed to git is the guard's own value. A dangling symlink is allowed: git has nothing to follow and renders it as its target string, and refusing it would recreate the "listed but unopenable" shape.
  • Transport-agnostic — the diff's diff --git a/<p> b/<p> header must name exactly the path requested. +++ was rejected for this because it is absent from a binary diff and from an empty new file, while the diff --git line is present in every case, including the leak (a/dirlink/null b/dirlink/null); a missing header fails closed. The comparison is exact string equality against a constructed header, run under -c core.quotepath=false, against two candidate spellings (verbatim and git's C-quoted form).

The second layer is what covers the remote transport, where there is no local filesystem to interrogate, and it is also what catches the race on the deliberately-allowed dangling symlink: with the guard seeing a dangling link and the real filesystem holding a directory, raw git emitted the secret and the runner refused it.

The remote path additionally requires git ls-files --others --exclude-standard -z -- <path> to return exactly that path before any diff is sent. One extra sequential ssh round-trip per untracked click; status() makes none.

Scale

-uall walks every untracked file. At 20k untracked files the time is fine (8 ms → 70 ms) but the output reaches 570 KB, and the remote transport's 2 MiB stdout cap is a hard error — above roughly 25–35k untracked files at realistic path lengths the whole panel went dark, including tracked changes that had worked before. That is a regression, not a limit, so a failing -uall status is retried once with git's default untracked mode and the result is flagged untrackedCollapsed; tracked rows still render with a note. The retry fires only on the two real overrun signatures (the remote transport's own stdout exceeded <n> bytes and execFile's stdout maxBuffer length exceeded); any other failure returns its own error untouched. Node does not localise those messages, verified under fr_FR.UTF-8.

The renderer builds one DOM node and one listener per file on every busy→idle refresh, so rows are capped at 500 with a +N more files not shown note. Header totals still count every file. Porcelain emits tracked records strictly before untracked (measured, including with untracked sorting alphabetically first), so the cap drops untracked first and can never hide a tracked change.

Also fixed

isSafeGitPath matched .. as a substring, so a legitimately named file like has..dots.txt was listed but refused to open with "invalid path". It is now a path-segment test on both separators; real traversal (.., ../x, a/.., a/../../b, ..\windows\x) is still rejected, and the file both lists and opens.

A FIFO in the working tree hung the diff for the full timeout; the same two mechanisms close it (10007 ms → 0 ms, rejected before git runs).

test/git-changes-runner-real-git.test.js asserted /outside repository/ against a localised git message and failed on a French host. It now pins LC_ALL=C/LANGUAGE=C; it is the only test file in the suite that shells out to real git.

Not in scope

  • Ignored files (--ignored): the parser drops ! records and keeps dropping them.
  • Staging, committing, reverting — the panel stays a viewer.

Verification

git diff --no-index was measured on real git rather than assumed, including the leak and every guard case. The security tests run against a real git init repo with no injected exec.

Mutations that must stay red: the --no-index success rule reverted to code !== 0 (7), -uall dropped (3), either containment layer removed (1 each), both removed (3, including the directory-symlink leak test), the .. segment test reverted to a substring (2), the -uall fallback removed (1), the stale-count identity check removed (1), the row cap removed (1), the header check reduced to verbatim-only (2).

npx eslint . → 0 errors, 317 warnings. npm test → 1586 tests, 1583 pass, 1 fail, 2 skipped, plus 120/119 in the isolated stage. The single failure is test/ipc-path-validator.test.js "allows files under ~/.claude/", pre-existing on main and environmental: this machine's ~/.claude/CLAUDE.md is a symlink out of ~/.claude and the validator resolves on disk. This branch touches neither that test nor ipc-path-validator.js.

/dev/null on Windows is executed evidence, not reasoning. The windows-2022 legs run real git: an untracked file yields a new-file diff and its added-line count, which drives both raw git diff --no-index -- /dev/null <path> through spawnSync and the runner itself. Git for Windows accepts the literal /dev/null. The containment tests also ran there by name rather than skipping — including the directory-symlink leak case — so symlink creation works on that runner and no t.skip fallback fired.

That leg also caught two real defects in the guard, neither of them a test-fixture problem:

  • isInsideRoot treated path.relative returning the empty string as outside. An empty result means the same directory, and two spellings of one directory arise on Windows for ordinary reasons — a drive-less root, a trailing separator, a case difference. Any untracked file sitting directly in the repository root would have been refused on a Windows checkout.
  • The operand was returned natively spelled (newdir\a.txt), while git writes newdir/a.txt in the diff --git header the second containment layer compares against. The two would have disagreed for every file in a subdirectory.

resolveLocalNoIndexOperand now takes an injectable pathOps, and the suite runs its containment cases over path.win32 and path.posix on whichever platform executes it: a legitimate path resolves and comes back forward-slashed, traversal is refused, an out-of-tree symlink on a different drive is refused, a sibling sharing the root's string prefix is refused, and the leaf-type rules hold.

Still unverified: the remote transport against a real ssh host, which is untested by design — the rule against ssh-ing to a real host from tests stands, and everything downstream of the transport is covered with injected fakes.

git status ran with git's default --untracked-files=normal, so a brand-new
directory arrived as a single row whose path was a directory: unopenable, and
hiding every file under it. -uall lists them one by one.

An untracked row also had no diff and no counts — git diff --numstat never
reports an untracked file. Clicking one now runs git diff --no-index against
/dev/null, which yields the new-file diff and, from that same output, the
added-line count the status pass cannot know. The count lands on the row and in
the header total at no extra process cost; computing it up front would mean one
git invocation per untracked file on every refresh, and one ssh round-trip each
on a remote session.

--no-index offers neither of the guarantees the pathspec path relies on: it
exits 1 whenever the two inputs differ (success here, not failure), and it has
no repository-boundary check, so its operand gets a guard that also rejects
absolute paths and a leading dash.

The real-git test file now pins LC_ALL=C — its assertions match git's English
diagnostics, which a localized machine does not produce.
…its shape

A syntactic guard cannot enforce containment. A symlink inside the repository
pointing at a directory outside it produces an operand with no "..", not
absolute, no leading dash — and git diff --no-index, which has no
repository-boundary check of its own, then reads whatever is under it.

The operand is now resolved before git sees it, and what git receives is the
guard's own value. Locally the cwd and the operand's parent directory are
resolved with realpath and the parent must lie inside the resolved root; the
parent and not the leaf, so a symlink git itself lists stays openable and still
diffs as its target string rather than the target's content. An lstat then
requires a regular file or a symlink, which also keeps a FIFO — where
--no-index blocks until the timeout — away from git. Remotely there is no
filesystem to resolve against, so git's own listing is the oracle: ls-files
--others must return the path before any diff is sent.

-uall now has a floor and a ceiling. Its output is a hard error when it
overruns the transport's stdout cap, around 25-35k untracked files, which would
blank a panel that used to work; a failing -uall status is retried once with
git's default untracked mode and comes back flagged, so tracked changes still
render. The renderer caps the list at 500 rows with a note for the remainder:
every row is a DOM node plus a listener, rebuilt on every busy-to-idle edge.

Two more defects fixed. A count computed against one status result could be
written onto a later one, replacing git's authoritative numstat with a stale
"+N -0" when a refresh landed while the diff was in flight; the write now
requires the status result it was computed from. And the ".." guard was a
substring test, so a file legitimately named "has..dots.txt" was listed and
then refused on click — it now tests whole path segments.

The real-git test file held a literal NUL byte where the escape belonged, which
made git store it as a binary blob: no diff on review, no text search, no
blame.
Resolving the operand's parent is not enough. Git lstats a symlink whose target
is a file — that one diffs as its target string and leaks nothing — but it
follows a symlink whose target is a directory, and git diff --no-index then
pairs the two operands by basename: /dev/null against <dirlink>/null. A symlink
in the working tree pointing at any directory holding a file called null reads
that file, and git status -uall lists such a symlink as a row of its own, so it
takes a click and no crafted path. The claim in the code, the docs and a test
name was true only for a symlink to a file, and now says so.

Two layers close it. The leaf gets a stat() as well as an lstat(), and a
symlink resolving to anything but a regular file is refused; a dangling symlink
still opens, since there is nothing to follow. And the diff itself must name
the file that was asked for — every --no-index diff opens with
"diff --git a/<p> b/<p>", including a binary one with no +++ line and an empty
new file with no hunk, while the paired case says a/dirlink/null. That check
needs no filesystem, which is what makes it work for a remote session, where
ls-files legitimately lists the symlink itself. The call runs under
core.quotepath=false so the comparison is against two spellings, verbatim and
C-quoted, rather than against every escaping git might choose.

The -uall retry is gated on the failure it was written for. It fired on any
non-zero exit and always reported a collapsed listing, so a repository that
could not be read told the user it had too many untracked files, and the real
error was discarded. It now retries only on a stdout-cap overrun, ours or
execFile's, and returns every other failure untouched.
…path rules

The containment check is path arithmetic, and on Windows that arithmetic has
two outcomes the POSIX-only reading missed.

path.relative returns the empty string for two spellings of one directory, and
the guard read that as "outside": a drive-less root like /repo against the
\repo that path.resolve derives from it, a trailing separator, a different
case. Every untracked file sitting directly in the repository root was refused
before git was ever invoked — which is what the fifteen red tests on the
windows-2022 legs were saying, all of them failing with the injected exec never
called. An empty relative path now means the same directory, which is what it
means.

The operand handed to git was also spelled with backslashes there, while git
writes forward slashes in the diff header the second containment layer compares
against. The guard converts on the way out, so the requested path, the operand
and the header agree on one spelling on every platform.

The fixtures were wrong in the same way: "/repo" is drive-relative on Windows,
so a fake realpath returning it verbatim described a directory the operand
never resolved into, and the tests exercised nothing. They build their roots
with path.resolve now. path.win32 and path.posix exist everywhere, so the guard
takes an injectable path implementation and both flavours are asserted from
whichever machine runs the suite — a legitimate path resolving, traversal and
an out-of-tree symlink refused, a sibling sharing the root's prefix refused,
and the leaf type rules — rather than only the one the runner happens to be on.
The test matrix is ubuntu-latest and windows-2022; macos-14 appears only in
build.yml, which never invokes the suite. The paragraph exists to separate
executed evidence from inference, so an unsupported leg in it is the one
kind of claim it must not make.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed b14a585 against main 9004ec5: full read of git-changes-runner.js, git-changes.js, the IPC surface and the changes tab, attacks replayed on real scratch repos with real NTFS symlinks (fs.symlinkSync — note for anyone re-running these: Git-Bash ln -s silently byte-copies when unprivileged on Windows), three of the listed mutations applied to a scratch copy, and the windows-2022 job log read rather than the badge.

Holds, confirmed: --no-index exit rule (mutation → 12 red); .. segment test (mutation → 3 red, has..dots.txt flips); symlink-to-file yields the link's own target string, never the target's bytes; symlink-to-directory refused by the leaf stat; the /dev/null<dirlink>/null pairing ran unskipped on windows-2022 (git 2.55, ok 548/549 in the log — git 2.24 here does not even exhibit it); header check anchored to the first line; porcelain lists tracked before untracked so the 500 cap drops untracked first; every remote argument goes through shQuote with -- on both invocations; both overrun signatures match the real strings; renderer uses textContent throughout; comments at the one-line ceiling; no attribution trailers. 145/145 locally, CI green.

One inaccuracy in the body, not in the code: "removing either containment layer turns a different test red" is false for the local parent check. With isInsideRoot removed from resolveLocalNoIndexOperand (line 90 → if (!root || !parent) return null;), the unmodified suites stay green — 79/79 and 12/12 real-git, including the sibling <repo>-evil and the cross-drive D:\secrets cases. The trailing isSafeNoIndexPath(operand) on the computed relative operand subsumes every escape it is meant to catch (path.relative from a realpath'd root yields a leading .. segment or, cross-drive, the untouched absolute path). No exploit results — it is defense in depth, and worth keeping — but the "must stay red: either containment layer removed (1 each)" bullet overstates what the suite proves. Please reword that paragraph (or add a test that isolates it, if one can be constructed; I could not).

Approving: the code is sound and the security tests are real; the correction is to the PR text.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants