Skip to content

fix: compare a repo against its siblings off the render thread - #432

Merged
matt-edmondson merged 5 commits into
mainfrom
similar-repos-off-render-thread
Sep 22, 2026
Merged

matt-edmondson merged 5 commits into
mainfrom
similar-repos-off-render-thread

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #414

What was wrong

SwitchPage and RefreshPage called UpdateSimilarRepos inline on the ImGui render thread. For each sibling repository that is two git ls-files subprocesses, a File.ReadAllText of every shared file from both working trees, and a whole-file DiffPlex pass — on every repo click, not once at startup. FetchRepo, PullRepo, PushRepo and Clone all wrap their git calls in a Task for exactly this reason; this path never got the same treatment, and it sits on the app's most frequent interaction.

What changed

Backgrounded. CompareSiblingsAsync snapshots the sibling list on the calling thread — so the task never enumerates Options.Repos while the UI can change it, and the unsupported-repository throw stays where it is observable — then does the work on a Task which it returns, so a caller that cares can wait for it. UpdateSimilarRepos is now one line handing it Options.Repos and QueueLog.

Published atomically. SimilarRepoDiffs moved from a fill-in-place Dictionary to a swappable snapshot behind IReadOnlyDictionary. A reader on the render thread sees the previous answer or the next one, never a half-built dictionary.

Newest run wins. Clicking through several repositories leaves that many comparisons in flight and they do not finish in the order they started, so each run carries a token and only the newest may publish. Without this, backgrounding alone would let a slow earlier run overwrite the answer for the repository the user is now looking at.

Cheaper, not just later. The base repository's tracked-file list is read once per run rather than once per sibling. Across N siblings that is N + 1 calls to git ls-files where there were 2N. I deliberately did not add a persistent per-repo cache as the triage suggested: its invalidation points (fetch, pull, commit, propagate, clone) are spread across the file, and getting one wrong shows a stale comparison rather than a slow one. Per-run is the half of that benefit with no staleness at all — say the word if you want the persistent cache too and I'll do it as its own change.

No stale answer on screen. Requesting a comparison drops the previous one, since it describes whichever repository was selected before. The triage asked for a pending state rather than last frame's answer, so while a run is outstanding the panel draws Comparing repositories.... The three panels that render a comparison are mutually exclusive, so that decision is made once where they are dispatched rather than repeated at the top of each.

Render paths ask instead of indexing. Three sites indexed SimilarRepoDiffs[...][...] directly, which was safe only because the dictionary was always fully populated before the frame. RefreshPage keeps CompareRepo/CompareFile across a refresh, so those would now throw during the pending window — they go through FindDiff, whose nullable result the two divider callbacks pass straight into ShowDiffLeft/ShowDiffRight, which already guard it. RefreshFileDiff likewise tolerates a comparison publishing between the frame deciding to draw a file and the apply button being pressed.

Testing

17 tests in SimilarReposTests, 82 total (was 65), 79 passing and the same 3 skipped for the missing git-lfs. No existing test changed.

Per CLAUDE.md's convention, the parts with a rule in them are plain methods drivable without a live ImGui context — PairSiblings, CompareSiblingsAsync, DiffAgainstAll, FindDiff, RefreshFileDiff and the request/publish pair on GitRepository — driven against throwaway repositories the way GitCliTests drives GitCli.

Verified the tests pin the new behaviour: reverting the request/publish guard to the old fill-in-place semantics (last writer wins, previous answer retained) fails RequestingAComparisonClearsTheLastAnswerAndReportsPending, ASupersededComparisonCannotPublish and ALateComparisonCannotOverwriteAnAlreadyPublishedNewerOne, and restoring it returns the suite to green.

On the coverage gate

The first push failed SonarCloud's quality gate at 34.7% new-code coverage against a required 80%, because most of the change sat in the ImGui layer this repo does not unit-test. Lifting those rules into testable methods is what the third commit does, and the gate now passes — at 80.0%, exactly on the bar. My own line-based measurement said 83.6%; Sonar counts branches as well as lines, which is where the difference comes from, and its number is the one that counts.

Worth knowing for whatever lands next here: there is no headroom. The 13 lines still uncovered are the render sites themselves — the two divider callbacks, the one-line UpdateSimilarRepos, the pending branch at the panel dispatcher, and ShowComparedFile's null guard — and this repo does not unit-test the ImGui layer by design. Any further UI-side addition to this diff would drop it under the gate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JnRJuBBrg4LLgytqvkRmLJ

matt-edmondson and others added 2 commits September 22, 2026 07:37
Selecting a repository ran UpdateSimilarRepos inline on the ImGui thread.
For each sibling that is two git ls-files subprocesses, a ReadAllText of
every shared file from both working trees, and a whole-file DiffPlex pass.
At the dozens of sibling repositories this tool is for, that is on the
order of 60 process launches before the frame can render, on every click.
Fetch, pull, push and clone all background their git calls for exactly
this reason; this path did not.

It now runs on a Task, publishing the whole comparison in one assignment
so a reader on the render thread sees the previous answer or the next one
and never a half-built dictionary. Each run carries a token and only the
newest may publish, because clicking through several repositories leaves
that many runs in flight and they do not finish in the order they started.

The base repository's tracked-file list is now read once per run instead
of once per sibling, which is N + 1 calls to git ls-files where there were
2N, with no cache to invalidate since it does not outlive the run.

Requesting a comparison drops the previous one rather than leaving it on
screen, since it describes whichever repository was selected before. The
three render paths that indexed SimilarRepoDiffs directly now ask, and
draw a pending line while a run is outstanding.

Fixes #414

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnRJuBBrg4LLgytqvkRmLJ
The request/publish pair is plain and takes no ImGui context, so the
ordering rules are driven directly: a superseded run cannot publish, a
late one cannot overwrite a newer published answer, and a request clears
what was on screen. DiffAgainstAll is covered against throwaway
repositories the way GitCliTests drives GitCli, including the
not-yet-cloned case, which now runs where a throw would go unobserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnRJuBBrg4LLgytqvkRmLJ
Comment thread ProjectDirector.Test/SimilarReposTests.cs Fixed

public bool IsCloned => Directory.Exists(LocalPath);

private Dictionary<FullyQualifiedGitHubRepoName, Dictionary<RelativeFilePath, DiffResult>> similarRepoDiffs = [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not fixing this one — readonly does not compile here.

The field is reassigned outside the constructor, through Volatile.Write(ref similarRepoDiffs, …) in both RequestSimilarRepoDiffs and TryApplySimilarRepoDiffs. That needs a writable ref, so marking it readonly gives three CS0192: A readonly field cannot be used as a ref or out value (except in a constructor), which I confirmed by building it.

The whole point of the field being reassignable is that the comparison is published as one whole dictionary rather than filled in place, so a reader on the render thread never sees a half-built one.

The other findings in this review are fixed in 1a85981.


Generated by Claude Code

Comment thread ProjectDirector.Test/SimilarReposTests.cs Fixed
Comment thread ProjectDirector.Test/SimilarReposTests.cs Fixed
Comment thread ProjectDirector.Test/SimilarReposTests.cs Fixed
Comment thread ProjectDirector.Test/SimilarReposTests.cs Fixed
matt-edmondson and others added 2 commits September 22, 2026 07:47
Only the request/publish pair and DiffAgainstAll were reachable from the
test project, leaving the rest of the change in the ImGui layer this repo
does not unit-test. CLAUDE.md's answer to that is to pull the part with a
rule in it into a plain method, so:

- PairSiblings resolves the sibling names and rejects a repository that is
  not on GitHub, which is why that throw stays on the calling thread.
- CompareSiblingsAsync starts the background task and returns it, so a
  caller that cares can wait for the comparison rather than guess.
  UpdateSimilarRepos is now one line handing it Options.Repos and QueueLog.
- FindDiff replaces the TryGetDiff out-parameter, so the two divider
  callbacks pass the nullable straight into ShowDiffLeft/Right, which
  already guard it, instead of repeating the check.
- RefreshFileDiff is internal, so its supersession guard is driven directly.

The three panels that draw a comparison are mutually exclusive, so the
pending state is decided once where they are dispatched instead of at the
top of each.

New-code line coverage over the changed files goes 34.7% -> 83.6%
locally, against SonarCloud's required 80%. What is left uncovered is the
render sites themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnRJuBBrg4LLgytqvkRmLJ
github-code-quality flagged four things in SimilarReposTests:

- Path.Combine discards its base when a later segment turns out rooted.
  The names here are generated or literal and never rooted, but Path.Join
  has no such rule and reads the same.
- A ContainsKey followed by an indexer is two lookups where TryGetValue is
  one.
- The UnauthorizedAccessException handler was empty; it now carries the
  same best-effort rationale as the IOException one above it.

Its fourth finding, that similarRepoDiffs can be readonly, is wrong: the
field is reassigned through Volatile.Write, which needs a writable ref,
so readonly does not compile. Replied on the thread rather than changing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnRJuBBrg4LLgytqvkRmLJ
Comment thread ProjectDirector/ProjectDirector.cs Fixed
github-code-quality flagged PairSiblings' foreach as implicitly filtering
its sequence. Same change this repo has already made to the branch
switches and the element scans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnRJuBBrg4LLgytqvkRmLJ
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 197b470 into main Sep 22, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the similar-repos-off-render-thread branch September 22, 2026 08:15
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.

Switching the selected repo blocks the UI thread with synchronous git subprocesses and full file diffs

1 participant