Skip to content

fix: consistency and correctness bugs across scanning, status and output - #54

Merged
bircni merged 9 commits into
mainfrom
fix/consistency-and-coverage
Jul 14, 2026
Merged

fix: consistency and correctness bugs across scanning, status and output#54
bircni merged 9 commits into
mainfrom
fix/consistency-and-coverage

Conversation

@bircni

@bircni bircni commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Fixes a set of bugs found while auditing the repo. Each is a separate commit with a regression test that fails without the fix.

Repositories silently disappearing or showing stale numbers

--fetch on a repository without a remote made fetch_origin return an error, which propagated and got the repository filed as failed — so it vanished from the output entirely. The same happened with --ff on a branch without an upstream.

Separately, merge_ff ran after ahead/behind, commit count and status were gathered, so a fast-forwarded repository was displayed with its pre-merge numbers: still "behind 1" while simultaneously marked as fast-forwarded. Fetch and merge now happen before any state is read, and both are best-effort with a warning.

fetch_origin also ran git in repo.path().parent(), which for a worktree is <main>/.git/worktrees, not a working directory.

A typechanged file was reported as Clean

Whether a repository is dirty and how many files changed were two separately maintained lists of git status bits, and neither included TYPECHANGE. Replacing a tracked file with a symlink — which git status reports as T — left the repository reported as Clean with zero changes. Both checks now share a single CHANGED bitmask.

--json ignored --non-clean

The filter lived inside the table printer, so --json --non-clean emitted every clean repository — useless for the exact scripting case JSON exists for. It now lives in Args::filter_repos and is applied once, so the formats cannot disagree. Filtering everything away now correctly reports "No repositories found." instead of printing a table header with no rows.

Non-deterministic output order

Repositories are collected in parallel via rayon and only the table sorted afterwards, so --json and the failure warnings emitted a different order on every run over the same directory.

Remote URL name parsing

git@host:repo.git (no slash in the path) became the whole git@host:repo string; a trailing slash produced an empty name; and trim_end_matches(".git") strips every trailing occurrence, so a repository genuinely named repo.git was displayed as repo.

Directory column for the scan root

Running git-statuses from inside a checkout — the no-argument default — left the relative path empty and fell back to the absolute path, so the column read /home/user/code/my-repo for a repository that would be listed as plain my-repo had the parent been scanned.

Depth guard and Windows path matching

The depth guard read depth != -1 && depth >= 0, in which the first half is dead. Any negative value already meant "unlimited", which is now what the help text says. Worktree metadata was excluded by matching the literal substring /.git/worktrees/, which cannot match on Windows; the .git directory is now pruned from the walk by file name.

Coverage

Coverage was 89.95%, below the 90% threshold the tarpaulin job already enforces — the gate was failing on main. Most of the gap was src/main.rs at 0/19 lines, since everything lived in main(), which no test can call. It is now split into a testable run(args, out).

Coverage is now 96.55% (printer.rs, status.rs, repoinfo.rs, util.rs at 100%) and the gate moves to 95%.


Two things in the diff worth flagging for review:

  • An existing test's assertions changed. test_integration_repository_fast_forward asserted commits == 1, behind == 1 on a repo it had just fast-forwarded — it had pinned the stale-stats bug in place.
  • test_integration_subdir_functionality never exercised --subdir. Its repository sat at a depth where the ordinary scan already found it. It is left in place and a real one added alongside.

bircni added 9 commits July 14, 2026 23:39
…-merge state

Three related problems around `--fetch` / `--ff`:

- A repository without a remote made `fetch_origin` return an error, which
  `RepoInfo::new` propagated. The repository was then counted as *failed* and
  dropped from the output entirely, so `--fetch` over a directory containing a
  local-only repository silently hid it. The same happened with `--ff` for a
  branch without an upstream. Both are now logged as warnings instead.

- `merge_ff` ran *after* ahead/behind, commit count and status were gathered, so
  a fast-forwarded repository was displayed with its pre-merge numbers: still
  "behind 1" while simultaneously marked as fast-forwarded. Fetch and merge now
  happen before any state is read.

- `fetch_origin` ran git in `repo.path().parent()`. For a worktree the git
  directory is `<main>/.git/worktrees/<name>`, whose parent is not a working
  directory. It now uses `workdir()` and only falls back to the git directory's
  parent for bare repositories.
Repositories are collected in parallel via rayon, so the order of the result
vectors depended on thread scheduling. Only the table sorted afterwards, which
left `--json` and the "failed to process" warnings emitting a different order on
every run over the same directory - unpleasant to read and impossible to diff.

Sort both vectors in `find_repositories` instead, so every consumer sees the same
order, and drop the now redundant sort from the printer. `repositories_table` no
longer mutates its input and takes a shared slice.
`--non-clean` was implemented inside the table printer, so it only ever affected
the table. `--json --non-clean` happily emitted every clean repository, which
makes the flag useless for the exact scripting use case JSON exists for.

The filter now lives in `Args::filter_repos` and is applied once in `main`, so
the table and the JSON output cannot disagree about what the user asked to see.
It borrows the scan result unless a filter is actually active.

Two side effects of moving it out of the printer:

- `repositories_table` now receives an already filtered slice, so filtering
  everything away correctly reports "No repositories found." instead of printing
  a table header with no rows underneath it.
- `--summary` keeps describing the whole scan rather than the filtered subset.
The name shown for a repository is derived from its remote URL by stripping a
`.git` suffix and taking the last `/`-separated segment. That mishandles three
shapes git accepts:

- `git@host:repo.git` (SCP-like, no slash in the path) contains no `/` after the
  host, so the whole `git@host:repo` string became the repository name.
- A trailing slash (`https://host/user/repo/`) left the last segment empty, so
  the repository was displayed without a name at all.
- `trim_end_matches(".git")` strips *every* trailing occurrence, so a repository
  genuinely named `repo.git` was displayed as `repo`.

Parsing now lives in `repo_name_from_url`, splits on `/`, `:` and `\`, strips at
most one `.git` suffix, and returns `None` when nothing usable is left - which
makes the caller fall back to the directory name, as it already did for
repositories without a remote.
…itory itself

`repo_path` is the repository's path relative to the scanned directory. Running
git-statuses from inside a checkout - the no-argument default - makes those two
the same path, so the relative path is empty and the code fell back to the full
absolute path.

The Directory column then read `/home/user/code/my-repo` for a repository that
would have been listed as plain `my-repo` had the parent directory been scanned
instead. Fall back to the directory name, which is what the column shows in every
other case. The absolute location remains available via `--path`.
Whether a repository is dirty and how many changed files it has were decided by
two separately maintained lists of git status bits. Both listed new / modified /
deleted / conflicted and neither listed TYPECHANGE, so replacing a tracked file
with a symlink - which `git status` reports as a modification - left the
repository reported as Clean with zero changes.

Both checks now share a single `CHANGED` bitmask, which additionally covers
TYPECHANGE and RENAMED, so they cannot drift apart again. `get_changed_count`
also skips ignored entries explicitly, as the dirty check already did.
The depth guard read `depth != -1 && depth >= 0`, in which the first half is dead
- anything that is `>= 0` is not `-1`. What it actually did was send *any*
negative value down the unlimited branch, so `--depth -5` silently scanned the
entire tree while the help text only ever promised that for `-1`.

Rather than reject the other negative values, document what they already do: any
negative depth means "no limit". Depth 0, which would produce a max depth of 0 and
find nothing, is clamped to 1 as before, now via `max(1)` instead of an
if/else that duplicated the constant.

`i32::MIN` is covered by a test: it never reaches the `as usize` cast.
…tching

Worktree metadata was kept out of the results by testing whether the path
contained the literal substring `/.git/worktrees/`. That is a Unix path, so on
Windows - where the separator is `\` - the check never fired.

Prune the whole `.git` directory from the walk instead, using `filter_entry` on
the file name rather than a rendered path. That is separator independent, and it
also stops an unlimited scan from walking and stat-ing every object, ref and hook
inside every repository it finds, none of which can ever be a result.
Coverage was 89.95%, which is *below* the 90% threshold the tarpaulin job already
enforces - the gate was failing on main. The bulk of the gap was `src/main.rs` at
0/19 lines: everything lived in `main()`, which no test can call.

Split the body out into `run(args, out)`, which takes the completion output as a
writer so tests can drive every branch (table, JSON, legend, summary, non-clean,
failed repositories, completions) without spawning a process. `run` turned out not
to be fallible at all - repositories that cannot be read are collected into the
failed list rather than aborting the scan - so it no longer returns a `Result` it
never uses.

New tests also cover behaviour that had none:

- every in-progress git operation (merge, revert, cherry-pick, bisect, the three
  rebase flavours, `git am`) and the sequencer variants of revert/cherry-pick
- Clean vs Unpushed against a real remote, via a clone
- bare repositories, which have no working directory
- `--subdir`. The existing test for it passed without ever exercising the flag:
  its repository sat at a depth where the ordinary scan already found it. The new
  one puts the checkout below the scanned level, where only `--subdir` can find it.

Coverage is now 96.55%, with printer.rs, status.rs, repoinfo.rs and util.rs fully
covered. The gate moves to 95% to hold the line. What remains uncovered is
`main()` itself and a handful of defensive branches that need a repository git
cannot produce.
@bircni
bircni merged commit 1d384c7 into main Jul 14, 2026
9 checks passed
@bircni
bircni deleted the fix/consistency-and-coverage branch July 14, 2026 22:07
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.

1 participant