fix: compare a repo against its siblings off the render thread - #432
Conversation
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
|
|
||
| public bool IsCloned => Directory.Exists(LocalPath); | ||
|
|
||
| private Dictionary<FullyQualifiedGitHubRepoName, Dictionary<RelativeFilePath, DiffResult>> similarRepoDiffs = []; |
There was a problem hiding this comment.
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
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
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
|



Fixes #414
What was wrong
SwitchPageandRefreshPagecalledUpdateSimilarReposinline on the ImGui render thread. For each sibling repository that is twogit ls-filessubprocesses, aFile.ReadAllTextof every shared file from both working trees, and a whole-file DiffPlex pass — on every repo click, not once at startup.FetchRepo,PullRepo,PushRepoand Clone all wrap their git calls in aTaskfor exactly this reason; this path never got the same treatment, and it sits on the app's most frequent interaction.What changed
Backgrounded.
CompareSiblingsAsyncsnapshots the sibling list on the calling thread — so the task never enumeratesOptions.Reposwhile the UI can change it, and the unsupported-repository throw stays where it is observable — then does the work on aTaskwhich it returns, so a caller that cares can wait for it.UpdateSimilarReposis now one line handing itOptions.ReposandQueueLog.Published atomically.
SimilarRepoDiffsmoved from a fill-in-placeDictionaryto a swappable snapshot behindIReadOnlyDictionary. 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-fileswhere 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.RefreshPagekeepsCompareRepo/CompareFileacross a refresh, so those would now throw during the pending window — they go throughFindDiff, whose nullable result the two divider callbacks pass straight intoShowDiffLeft/ShowDiffRight, which already guard it.RefreshFileDifflikewise 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 missinggit-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,RefreshFileDiffand the request/publish pair onGitRepository— driven against throwaway repositories the wayGitCliTestsdrivesGitCli.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,ASupersededComparisonCannotPublishandALateComparisonCannotOverwriteAnAlreadyPublishedNewerOne, 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, andShowComparedFile'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