fix: reject a null success value in GitResult.FromValue [minor] - #117
Conversation
GitResult<T>.Success is derived from Error alone, so FromValue(null) produced an instance reporting Success with a null Value — precisely the state the type's own remarks promise cannot occur. FromError already guarded against the mirror case with Ensure.NotNull; FromValue guarded nothing. The path there is public: GitCommandBuilder<TResult> is unsealed with no constraint on TResult, so a derived builder whose ParseResult returns null on some edge case reached FromValue with null and turned a would-be NullReferenceException into a silently successful result. Constrain the type parameter to notnull on GitResult<T>, IGitCommandBuilder<TResult> and GitCommandBuilder<TResult>, and guard FromValue at run time as well, since the constraint is a compile-time check a caller can suppress. The constraint stops short of class because two result types are value types: rev-list counts commits into an int, and Divergence() answers with a GitDivergence struct. For the same reason the run-time guard reaches Ensure.NotNull through an object cast, which the value-type instantiations fold away rather than boxing on every result. Fixes #116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPXBwr1rm8hybVmmWhx7wd
|
CI green, and a note on the one SonarCloud findingAll 14 checks pass, including the test legs on ubuntu, windows and macOS, and the quality gate passed with 100% coverage on new code. The gate did report 1 new issue — a MINOR code smell on
That is rule S3236 firing on the rather than Happy to drop it if you'd rather have a clean Sonar report than the accurate parameter name, but that is the trade being made. The cast itself is unavoidable while Generated by Claude Code |



Fixes #116
The problem
GitResult<T>.Successis derived fromErroralone, soFromValue(null)produced an instance reportingSuccess == truewithValue == null— precisely the state the type's own<remarks>promise cannot occur ("Neither reading is safe for a consumer that reasonably dereferences the field matching the branch it is on").FromErroralready guarded the mirror case withEnsure.NotNull;FromValueguarded nothing.The path there is public, not hypothetical:
GitCommandBuilder<TResult>is unsealed with no constraint onTResult, and itsTryExecuteAsynccallsGitResult<TResult>.FromValue(ParseResult(result)). A derived builder whoseParseResultreturns null on some edge case turned a would-beNullReferenceExceptioninto a silently successful result.The fix
Both halves of what the issue proposed, because neither alone is sufficient:
where T : notnullonGitResult<T>,IGitCommandBuilder<TResult>andGitCommandBuilder<TResult>, so the extension point is constrained at the signature.FromValuenow rejects null, since the constraint is a compile-time check a caller can suppress (FromValue(null!)) and the derived-builder seam is reachable from outside this assembly.Two details worth flagging for review:
notnull, notclass. I triedclassfirst and the compiler rejected it:IGitRevListBuilder : IGitCommandBuilder<int>andIGitRevListDivergenceBuilder : IGitCommandBuilder<GitDivergence>(areadonly record struct) are both real instantiations, soGitResult<int>andGitResult<GitDivergence>exist today.notnulladmits both.Ensure.NotNullthrough anobjectcast.Ensure.NotNullconstrains its own argument to a reference type, whichTis not, and theKTSU0003/KTSU0004analyzers reject bothArgumentNullException.ThrowIfNulland a manualif (value is null) throw. Casting toobjectsatisfies the constraint for the reference-type instantiations, where null is the only place it is reachable, behind atypeof(T).IsValueTypetest that is a per-instantiation constant — so the value-type instantiations fold the check away instead of boxing on every result. No suppression was needed, keeping this repo's zero-[SuppressMessage]record intact.Testing
Two tests added to
GitResultTests, mirroring the existingFromErrorRejectsNullError: one forGitResult<string>, one forGitResult<IReadOnlyList<string>>to cover the derived-builder scenario over a non-stringreference type.Verified they actually catch the bug by reverting
FromValueto its original expression body and re-running: both fail with "Expected exception of exact type ArgumentNullException but no exception was thrown", and the other 11 tests in the class still pass. With the fix restored, the full suite is green — 610 passed, 0 failed.Tagged
[minor]rather than[patch]: the public signatures changed. It is not a compile break for consumers, since violatingnotnullis a warning (CS8714) rather than an error.🤖 Generated with Claude Code
https://claude.ai/code/session_01NPXBwr1rm8hybVmmWhx7wd
Generated by Claude Code