Skip to content

fix: reject an unsupported saved repository at load instead of crashing [patch] - #430

Merged
matt-edmondson merged 2 commits into
mainfrom
fix/427-reject-unsupported-repos-at-load
Sep 22, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
fix/427-reject-unsupported-repos-at-load

Conversation

@matt-edmondson

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

Copy link
Copy Markdown
Contributor

Fixes #427

What was wrong

GitRepository registers AzureDevOpsRepository as 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 handle GitHubRepository and throw new InvalidOperationException("Only GitHub Repos are supported at this time") otherwise.

UpdateClonedStatus runs from the constructor's RefreshPage(), 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.Create returns a GitHubRepository or null and cannot produce anything else, so no other path can introduce one. Rejecting it at that single boundary is what turns every later is GitHubRepository test 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 surrounding TryGetValue checks 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:

System.ArgumentException: Cannot convert "C:\dev" to AbsoluteDirectoryPath
   at ktsu.ProjectDirector.ProjectDirectorOptions..ctor() in ProjectDirectorOptions.cs:21

DevDirectory defaulted to a hardcoded C:\dev, which is not an absolute path off Windows, so AbsoluteDirectoryPath rejected it in the field initializer — meaning ProjectDirectorOptions could 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 MakeLoadedOptionsSafe call 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.

AnAzureDevOpsRepositoryDeserializesIntoALiveObject pins 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.

LoadedOptionsCarryingAnUnsupportedRepoAreMadeSafe covers 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, EveryRepoBeingUnsupportedLeavesAnEmptyButUsableState and LoadedOptionsCarryingAnUnsupportedRepoAreMadeSafe all 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 Release is clean. Coverage on new code goes from 75% to 96%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EdV5iCFkUxFqkLZQAGLAVT

…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
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 3fa9f40 into main Sep 22, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the fix/427-reject-unsupported-repos-at-load branch September 22, 2026 00:20
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
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.

AzureDevOpsRepository is accepted as a saved-options JSON type but crashes the app wherever it's used

2 participants