Skip to content

fix: pin --untracked-files so status.showUntrackedFiles cannot hide work [patch] - #113

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-vtqqc1
Sep 14, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-vtqqc1

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #112

What was wrong

GitStatusBuilder added --untracked-files=<mode> only when a caller had called WithUntrackedFiles(...). With no such call the vector was just git status --porcelain=v2 --branch -z, and git then resolves the mode from status.showUntrackedFiles — a variable a minimal CI image or a developer's ~/.gitconfig can set to no to keep git status fast in a large tree.

Reproduced against git 2.43, the version the issue reports:

$ git status --porcelain=v2 --branch          # default config
# branch.oid 56f00a4dfe66a7f0f90c067ce0e1ee791d9fd268
# branch.head main
? untracked.txt

$ git config status.showUntrackedFiles no
$ git status --porcelain=v2 --branch          # same working copy
# branch.oid 56f00a4dfe66a7f0f90c067ce0e1ee791d9fd268
# branch.head main

So the same repository state produced IsClean == true with an untracked file sitting in it — silently, and precisely for the caller asking "is there work here I would destroy?" before discarding a working copy.

This is the one verb that still left a host-configurable default unpinned. GitCommandBuilder.BuildArguments already pins --no-pager, core.quotepath=false and color.ui=false unconditionally, and RunCommandGitProcessRunner forces LC_ALL=C and GIT_TERMINAL_PROMPT=0, for the same reason.

The change

  • GitStatusBuilder.DefaultUntrackedFiles (GitUntrackedFilesMode.Normal — git's own documented default, so nothing changes on a host that has not set the variable) initialises the field, and the flag is emitted unconditionally.
  • Because the default lives in the field rather than at the emission site, WithUntrackedFiles(...) replaces it. The vector never carries two --untracked-files values, so a caller passing No does not first pay for a normal walk it declined.
  • IGitStatusBuilder.WithUntrackedFiles and the class summary record the pinned default; CLAUDE.md gets design point 14 beside the other non-obvious, load-bearing choices.

GitStatusParser's doc comment is left alone: it names the output format it reads, which this flag does not change.

Tests

Builder tier (GitIntegration.Test/Builders/GitStatusBuilderTests.cs):

Test Covers
BuildsTheDefaultStatusVector updated — the acceptance criterion, asserting --untracked-files=normal is in the vector with WithUntrackedFiles never called
ChoosingAModeReplacesThePinnedDefaultRatherThanJoiningIt new — exactly one --untracked-files= argument, and it is the caller's; the hazard an unconditional default introduces

Integration tier (GitIntegration.Test/Integration/GitRoundTripTests.cs), which is where a config-resolution bug can actually be caught:

  • StatusReportsUntrackedWorkEvenWhereTheHostHidesItAsync — sets status.showUntrackedFiles = no in the throwaway repository's own config (the same variable resolved from the nearest scope, so the test neither depends on nor disturbs the runner's global config), then asserts Status() still reports IsClean == false and an entry for the untracked file. It closes by asserting WithUntrackedFiles(No) still reports clean, so the test cannot pass by the builder simply ignoring the caller.

Verification

  • dotnet build and dotnet build -c Release: 0 warnings, 0 errors.
  • KTSU_GIT_INTEGRATION_TESTS_REQUIRED=1 GIT_CONFIG_NOSYSTEM=1 dotnet test: 590 passed, 0 failed, 0 skipped — integration tier exercised rather than skipped, and GIT_CONFIG_NOSYSTEM=1 set per CLAUDE.md so the POSIX path is what ran.
  • Mutation-checked by substitution, per CLAUDE.md: restoring the nullable field and the if (_untrackedFiles is GitUntrackedFilesMode mode) guard fails exactly BuildsTheDefaultStatusVector and StatusReportsUntrackedWorkEvenWhereTheHostHidesItAsync (2 failed / 588 passed). ChoosingAModeReplacesThePinnedDefaultRatherThanJoiningIt passes either way, as a guardrail on behaviour this change must not disturb. The tree was re-verified against a pre-mutation copy afterwards, and the full suite re-run green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WVrdn7dcoGasLJEgrnb67u


Generated by Claude Code

matt-edmondson and others added 2 commits September 14, 2026 18:26
…ork [patch]

GitStatusBuilder only emitted --untracked-files when a caller called
WithUntrackedFiles. Without it git resolves the mode from the host's
status.showUntrackedFiles, so a machine that sets it to "no" reported the
same working copy as IsClean with untracked files sitting in it.

The flag is now emitted unconditionally, defaulting to git's own documented
"normal", the way GitCommandBuilder already pins --no-pager, core.quotepath
and color.ui. WithUntrackedFiles replaces that default rather than adding a
second value.

Fixes #112

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVrdn7dcoGasLJEgrnb67u
SonarCloud's analysis of the new tests raised three MSTest suggestions, all
in code this branch added:

- MSTEST0037 -> Assert.ContainsSingle for the "exactly one --untracked-files
  argument" check, which also folds the following DoesNotContain into an
  equality check on the single argument found
- MSTEST0068 -> resolved by that fold; the CollectionAssert call is gone
- MSTEST0037 -> Assert.Contains for the untracked-entry check, keeping the
  failure message

MSTEST0054 (TestContext.CancellationToken over
TestContext.CancellationTokenSource.Token) is left alone: the older form is
used at roughly 250 call sites across 32 files here, and changing one new
line would make it the only outlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVrdn7dcoGasLJEgrnb67u
@matt-edmondson
matt-edmondson force-pushed the claude/nice-davinci-vtqqc1 branch from 6d82315 to dccc7a8 Compare September 14, 2026 18:39
@sonarqubecloud

Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor Author

Notes on the two CI signals above, so neither needs chasing down:

SonarCloud's remaining "1 New issue" is MSTEST0054 at GitIntegration.Test/Integration/GitRoundTripTests.cs:164Use TestContext.CancellationToken instead of TestContext.CancellationTokenSource.Token, INFO severity, gate passed.

It is left as-is deliberately. The older form is this suite's universal idiom — roughly 250 call sites across 32 files — and it is flagged here only because the line is new. Changing this one line would make it the sole outlier in the repository rather than fixing anything. If the newer property is wanted, it is a suite-wide sweep of its own, not a drive-by in a fix PR.

The other three findings from the first analysis were real and are fixed (Assert.ContainsSingle and Assert.Contains in place of the AreEqual/IsTrue forms; the fold removed the CollectionAssert.DoesNotContain call entirely).

Test on macos-latest in Cross-Platform Tests was cancelled on the first attempt, having sat for 20 minutes against a timeout-minutes: 15 budget with no uploaded logs. That was a stalled runner, not this change: the ubuntu leg of the same run passed in 55 seconds, the same macOS job passed in ~2 minutes on the previous head (which already carried the fix and the new integration test), and the same commit's macOS test passed in the templated workflow. One re-run confirmed it — 19:02:15 → 19:04:07, green.

All checks are green on dccc7a8 and the branch merges cleanly.


Generated by Claude Code

@matt-edmondson
matt-edmondson merged commit 0b7660d into main Sep 14, 2026
16 of 17 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-vtqqc1 branch September 14, 2026 21:20
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.

Status() can silently omit untracked files depending on the host's status.showUntrackedFiles config

1 participant