Skip to content

fix: canonicalize the upstream key, not just the repository path [patch] - #39

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/gbsc-37-canonicalize-upstream
Sep 24, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/gbsc-37-canonicalize-upstream

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #37.

BranchStateHandler.Resolve canonicalized one half of the MirrorKey and passed the other through verbatim:

MirrorKey key = new(route.Upstream, Canonicalize(route.RepositoryPath));

route.Upstream comes straight off the URL segment. Both checks immediately above it key their dictionaries with StringComparer.OrdinalIgnoreCase — verified in UpstreamRegistry and RepositoryAllowList — so GitHub and github are accepted as the same configured upstream. Everything derived from the key afterwards compares ordinally.

The result is the duplication issue #24 removed for repository-path casing, still reachable through the sibling field. Measured, not assumed: with the fix reverted, TwoSpellingsOfOneUpstream_ShareOneMirrorFetchDiffAndAdmission reports clone running 2 times where it expects 1, and a second mirror.git appears on the volume.

The change

One line — both halves of the key now go through Canonicalize:

MirrorKey key = new(Canonicalize(route.Upstream), Canonicalize(route.RepositoryPath));

Canonicalize's doc comment is rewritten to match: it had spelled out at length exactly why the path must be canonicalized while describing only the path, which is what made the omission easy to miss. It now names the upstream key as the other half and says why the same argument covers it.

Nothing reaching the forge changes

Worth stating explicitly, because canonicalizing something a caller sent could look like rewriting a request:

  • The upstream base URL comes from registry.TryResolve, i.e. from configuration — never from the caller's segment. The canonical key is only this service's own bookkeeping.
  • The repository URL is still built with UpstreamUrl.TryCombine(upstreamBase, route.RepositoryPath, …) — the caller's spelling of the path, untouched, per the existing note that the forge is the authority on how it spells its own names.
  • registry.TryResolve and allowList.IsAllowed still receive route.Upstream, and EndpointLog.UnknownUpstream still logs the literal spelling the caller used.

What the canonical key now feeds consistently: MirrorStore's on-disk directory, MirrorKey.ToFlightKey() for SingleFlight coalescing, DiffKey, the admission key, and the metric labels.

Tests

Added to RepositoryIdentityTests, the class #24's fix created, mirroring its four cases with upstream casing varying instead of path casing. Verified by stashing the BranchStateHandler.cs change and re-running: three of the four fail against the old implementation.

Test Old implementation
TwoSpellingsOfOneUpstream_ShareOneMirrorFetchDiffAndAdmission fails — clone count is 2, expected 1
TwoSpellingsOfOneUpstream_ProduceOneMirrorDirectory fails — two mirror.git directories on the volume
ARequestWithTheUpstreamSpelledDifferently_MirrorsUnderTheCanonicalUpstream fails — nothing under <root>/github/…
TwoSpellingsOfOneUpstream_AreBothServed passes — this one is the premise (both spellings are served), not the bug

The class <remarks> is widened from "the path" to "the key", since it is now documenting both fields.

Full suite: 203 passed, 0 failed, 0 skipped. Whole solution builds with 0 warnings, 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_018VSTy8Ye7JXqeFqvhrmnRt


Generated by Claude Code

BranchStateHandler.Resolve built its MirrorKey from the caller's literal
upstream segment while canonicalizing only the sibling repository path:

    MirrorKey key = new(route.Upstream, Canonicalize(route.RepositoryPath));

IUpstreamRegistry.TryResolve and IRepositoryAllowList.IsAllowed both key
their dictionaries with StringComparer.OrdinalIgnoreCase, so "GitHub" and
"github" pass as one configured upstream. Everything downstream of that
compares ordinally, so the two spellings produced two bare mirrors of the
same repository, two credential probes, and two diffs — exactly the
duplication issue #24 removed for repository-path casing, reachable
through the other half of the key.

Both halves now go through Canonicalize. Nothing sent to the forge
changes: the upstream base URL comes from configuration, not from the
caller's segment, and the repository URL is still built from the caller's
spelling of the path.

Fixes #37

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit f318fb1 into main Sep 24, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/gbsc-37-canonicalize-upstream branch September 24, 2026 00:46
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.

Upstream key casing isn't canonicalized, duplicating mirrors like the already-fixed repository-path case bug

2 participants