fix: reject an unsupported saved repository at load instead of crashing [patch] - #430
Merged
Merged
Conversation
…ng [patch] GitRepository registers AzureDevOpsRepository as a JsonDerivedType, so a saved options file carrying one deserializes without complaint. Nothing acts on it: all six sites that pattern-match a repository handle GitHubRepository and throw otherwise, and GitRepository.Create cannot produce anything else in the first place. UpdateClonedStatus runs from the constructor's RefreshPage, so the throw landed on the very next launch -- before the user could open the UI and delete the entry causing it, which made it unrecoverable without hand-editing the file. Deserialization is the only boundary such an entry can arrive through, so reject it there rather than guarding six call sites separately. That is what turns every later `is GitHubRepository` test into an invariant rather than a live branch that throws on the render thread. Each rejection is named in the log. The registration stays so an existing file still parses; it is the live object that is refused. A selection and a clone record naming a rejected repository are cleared with it: the panels reach for a selection through Options.Repos[Options.BaseRepo] once something is selected, so leaving one behind would turn one crash into another. An empty selection is the state a fresh install starts in, which the surrounding TryGetValue checks already handle. Fixes #427 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdV5iCFkUxFqkLZQAGLAVT
…oad path [patch] The post-load block in the constructor was five uncovered lines, because nothing could drive it: ProjectDirectorOptions could not be constructed anywhere but Windows. Its DevDirectory default was a hardcoded C:\dev, which is not an absolute path off Windows, so AbsoluteDirectoryPath rejected it in the field initializer and the constructor threw. That is a real defect in its own right -- the application cannot start on Linux or macOS, both of which this repository runs its tests on -- and it is what blocked testing the fix in this PR. Give the default a platform-aware fallback: Windows keeps the path it has always had, everywhere else gets ~/dev. Only a fresh install reads it, so a saved options file keeps whatever the user chose. With options constructible, collapse the constructor block into MakeLoadedOptionsSafe and drive the whole of it against real options: the unsupported entry is dropped, a selection naming it is cleared, a selection naming a survivor is left alone, the clone record goes, and the rejection is reported once. Coverage on new code goes from 75% to 96%, which is what the SonarCloud gate wanted, but the point is that the path the constructor actually takes is now tested rather than merely the helper underneath it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdV5iCFkUxFqkLZQAGLAVT
|
matt-edmondson
pushed a commit
that referenced
this pull request
Sep 22, 2026
Brings in #429 and #430, both of which changed ProjectDirector.cs, so the combination is built and tested here rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdV5iCFkUxFqkLZQAGLAVT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #427
What was wrong
GitRepositoryregistersAzureDevOpsRepositoryas a[JsonDerivedType], so a saved options file carrying one deserializes without complaint. Nothing then acts on it — all six sites that pattern-match a repository handleGitHubRepositoryandthrow new InvalidOperationException("Only GitHub Repos are supported at this time")otherwise.UpdateClonedStatusruns from the constructor'sRefreshPage(), so the throw landed on the very next launch, before the user could open the UI to delete the entry causing it. That made it unrecoverable without hand-editing the file.The fix
The issue offered two routes: finish the Azure path everywhere, or drop the registration. This takes a third that gets the acceptance criteria without doing either.
Deserialization is the only boundary an unsupported repository can arrive through.
GitRepository.Createreturns aGitHubRepositoryornulland cannot produce anything else, so no other path can introduce one. Rejecting it at that single boundary is what turns every lateris GitHubRepositorytest into a genuine invariant rather than a live branch that throws on the render thread — rather than guarding six call sites separately and having to keep doing so for the seventh.Each rejection is named in the application log. The
[JsonDerivedType]registration deliberately stays, so an existing options file still parses; it is the live object that is refused, not the document. Dropping the registration instead would make the whole options file fail to load, which trades one startup crash for another.A selection or clone record naming a rejected repository is cleared with it. The panels reach for a selection through
Options.Repos[Options.BaseRepo]once something is selected, so leaving one behind would turn one crash into another. An empty selection is the state a fresh install starts in, which the surroundingTryGetValuechecks at lines 459 and 554 already handle.The second commit: the dev directory default off Windows
The first commit left the constructor's post-load block uncovered, and SonarCloud failed the PR at 75% coverage on new code. The reason nothing could drive that block is a real defect in its own right:
DevDirectorydefaulted to a hardcodedC:\dev, which is not an absolute path off Windows, soAbsoluteDirectoryPathrejected it in the field initializer — meaningProjectDirectorOptionscould not be constructed at all on Linux or macOS. Not by a test, and not by the application: it cannot start on either, and this repository runs its tests on both.So the second commit gives the default a platform-aware fallback — Windows keeps the path it has always had, everywhere else gets
~/dev. Only a fresh install reads it, so a saved options file keeps whatever the user chose.With options constructible, the constructor block collapses to a single
MakeLoadedOptionsSafecall that is driven end to end against real options.I flagged this as wanting its own issue when I opened the PR. It turned out to be load-bearing for testing #427's own fix, so it is here rather than deferred; happy to split it out if you would rather review it separately.
Tests
ProjectDirector.Test/UnsupportedRepoTests.cs, following the pattern CLAUDE.md sets out — the part with a rule in it is a plain method, drivable without a live ImGui context.AnAzureDevOpsRepositoryDeserializesIntoALiveObjectpins the premise: the type discriminator alone is enough to produce one, which is exactly what a hand-edited file or a leftover from a partially built feature looks like. If that ever stops holding, the test says so and the rule can go.LoadedOptionsCarryingAnUnsupportedRepoAreMadeSafecovers the whole of what the constructor does after loading: the crashing entry goes, the selection naming it is cleared, a selection naming a survivor is left alone, the clone record goes, and the rejection is reported once.Verified by substituting the unfixed behaviour back in (rejecting nothing, so an unsupported repo survives into the six throwing sites):
AnUnsupportedRepoIsDroppedAndTheSupportedOnesAreKept,ACloneRecordedAgainstADroppedRepoIsCleared,EveryRepoBeingUnsupportedLeavesAnEmptyButUsableStateandLoadedOptionsCarryingAnUnsupportedRepoAreMadeSafeall fail against it and pass against the fix.Full suite on this branch: 52 total, 50 passed, 0 failed, 2 inconclusive (both need git-lfs, unrelated and present on
main).dotnet build --configuration Releaseis clean. Coverage on new code goes from 75% to 96%.🤖 Generated with Claude Code
https://claude.ai/code/session_01EdV5iCFkUxFqkLZQAGLAVT