Skip to content

Worktree verbs, single sign-on URL, and OAuth device flow - #118

Merged
matt-edmondson merged 17 commits into
mainfrom
feat/worktrees-and-github-auth
Sep 22, 2026
Merged

matt-edmondson merged 17 commits into
mainfrom
feat/worktrees-and-github-auth

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Three additive capabilities a consuming desktop launcher needs and this library did not have. No existing member changes shape, and no existing behaviour changes for a caller that does not opt in.

Changed since first opened. This branch originally also added a GitHubOwnerKind property for routing repository enumeration. While it was in flight, #115 solved the same problem by inferring the owner's type from GET /users/{login} instead of taking an explicit kind from the caller. Ours has been dropped in favour of upstream's, which handles cases ours did not: an unauthenticated provider skips the probes, a GitHub App installation token whose GET /user answers 403 falls back to the public route, and it deliberately avoids inferring from a 404 because an organisation enforcing SSO also answers 404. origin/main is merged in and the enumeration code here is upstream's.

Worktree verbs

Worktrees(), AddWorktree(path), RemoveWorktree(path) and PruneWorktrees() on GitRepository, with a GitWorktree model and a parser for git worktree list --porcelain. The library previously had no worktree verb at all — GitProbes.IsWorkTreeAsync asks whether a path is a working tree and GitStatusEntry.WorkTreeState names half a status code, but neither creates, lists, or removes anything.

GitWorktree.IsMain is positional: git's porcelain has no attribute for it, the main working tree is simply the first record. It exists so a caller can refuse to remove the worktree that owns the repository.

CheckingOut and From write the same <commit-ish> operand, so they are one field under two names and the later call wins — the same last-call-wins rule that governs the four mode settings, matching IGitBranchListBuilder's existing LocalOnly/RemoteOnly.

Deliberately out of scope: worktree move, lock and unlock, and --dry-run on prune.

Single sign-on authorisation URL

A 403 carrying X-GitHub-SSO now surfaces the authorisation URL in the exception message. Previously a token one click away from working was indistinguishable from a bad one, and the fix was a link the caller could not see.

The header is read case-insensitively by scanning rather than by keyed lookup, matching how TryGetRetryAfterSeconds already reads Retry-After and for the same reason. The URL is only ever appended — a 403 without the header produces exactly the exception it produced before, and the arm ordering in Translate is untouched.

This reaches all three of Translate's call sites, so pull request listing and creation benefit from it as well as enumeration.

OAuth device flow

GitHubDeviceFlow obtains a credential rather than only resolving one. Two calls rather than one, because the pause between them is minutes long and the user code must stay on screen throughout; a single call taking a callback would fire it from an HTTP continuation and force every graphical caller to marshal back to a UI thread. It stores nothing — the caller decides where the secret lives.

Built on a raw HttpClient rather than Octokit's OauthClient. Two problems turned up empirically: InitiateDeviceFlow percent-encodes the scope colon, and CreateAccessTokenForDeviceFlow throws ApiException for an OAuth error body instead of returning it — collapsing a refused authorisation and an expired code into the same shape a transport failure gets, which is precisely the distinction this feature exists to make. It mirrors AzureDevOpsProvider, which already talks raw HttpClient with a source-generated JsonSerializerContext. The design document records the reasoning.

GitHubDeviceCode carries both UserCode (shown to a person) and DeviceCode (sent in the poll). They are not interchangeable.

The poll loop enforces a monotonic expiry deadline from ExpiresIn and a five-second interval floor — an interval of 0 off the wire would otherwise busy-loop against github.com. Error codes are split deliberately: access_denied and expired_token are authentication failures, while incorrect_client_credentials, unsupported_grant_type and device_flow_disabled are request failures, so a deleted client ID surfaces as a configuration fault rather than looping a user through a sign-in that can never succeed.

Not yet verified end to end

No GitHub OAuth App exists for this yet, so every device-flow test runs against a fake transport. Registering one — device flow enabled, SSO-approved, scopes repo and read:org — is what remains before the flow can be confirmed against the real service.

One further note: these two endpoints accept a JSON request body although GitHub's documentation shows form encoding. That is relied upon and recorded in the type's remarks.

Testing

668 passing, 0 failing. Build clean on net10.0 and net9.0, zero warnings, and no [SuppressMessage] added anywhere in GitIntegration/.

Device-flow tests inject the delay and clock, so the suite exercises the poll loop's wait, cumulative slow_down widening, expiry and cancellation without sleeping.

🤖 Generated with Claude Code

Matthew Edmondson and others added 15 commits September 21, 2026 15:42
Three additions a consuming desktop launcher needs and this library does
not have: worktree list/add/remove/prune, repository enumeration that can
see an organisation's private repositories, and a way to obtain a GitHub
credential rather than only resolve one.

All additive, targeting 3.2.0. Also ignores .worktrees/, matching the
convention used for isolated feature work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight tasks over the 2026-09-21 design, test-first throughout. Resolves
the spec's one open question: Octokit 14.0.0 does expose
InitiateDeviceFlow and CreateAccessTokenForDeviceFlow, and the latter
polls internally, so the device flow is a translation layer rather than
two hand-rolled posts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ktsu.Sdk's Sdk.targets copies its own packaged gitignore template over
SolutionDir/.gitignore on every build, so an entry added here does not
survive. The worktree directory is excluded through .git/info/exclude
instead, which is local, uncommitted, and not SDK-managed.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onse

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No user.email is configured on this machine, so git derives one from the
hostname: commits land as matt@Mac.home or matt@MattBookPro.local rather
than under an address .mailmap already knows. Both now resolve to the
same identity as every other alias, which also cleans up the
matt@MattBookPro.local commits already on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Address review findings on the OAuth device flow: WaitForTokenAsync now
tracks an elapsed-time budget from a monotonic clock and throws once the
device code's expiry passes, rather than polling forever against a server
that never resolves. Every wait between polls is floored to GitHub's
documented 5-second minimum, applied at the point the wait is issued so a
hand-built GitHubDeviceCode is covered as well as a parsed one. Also stops
echoing the token endpoint's response body into a failure message, and adds
coverage for the pending/slow-down/expiry/floor/cancellation paths the poll
loop previously left untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the README's Features bullets and adds three usage examples for
the worktree verbs, GitHubProvider's OwnerKind routing, and the
GitHubDeviceFlow sign-in. Also corrects the design doc's Transport and
polling section, which still described the device flow as wrapping
Octokit's OauthClient; it posts directly to GitHub's device and token
endpoints instead, because Octokit percent-encodes the read:org scope's
colon and collapses OAuth error bodies into ApiException.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the GitHubProvider reference entry, which still claimed
GetRepositoriesAsync is public-only after OwnerKind made that
conditional. Adds the missing worktree rows across the GitRepository,
Verb Builders, and Result Models tables, a GitHubDeviceFlow reference
section, and GitHubOAuthClientId to Semantic Types. Also corrects the
non-compiling CredentialWithToken.Token assignment carried over into
the design doc and GitHubDeviceFlow's own XML remarks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RequestDeviceCodeAsync now validates verification_uri with
Uri.TryCreate(..., UriKind.Absolute, ...) instead of letting a malformed
value throw UriFormatException past this type's documented
GitHostingRequestException-only failure surface. WaitForTokenAsync's error
switch now distinguishes access_denied/expired_token (authentication
failure) from incorrect_client_credentials/unsupported_grant_type/
device_flow_disabled and any unrecognised code (request failure, since none
of those can be fixed by retrying sign-in), matching the design spec's
Failures table, which is amended to document the device_flow_disabled and
unrecognised-code additions. Also renames NowTicks to NowMilliseconds to
name it by its unit rather than by the member it happens to default to,
stops a future edit from reintroducing the token endpoint's response body
into its failure message unnoticed, and fixes the org-repositories fixture's
owner.type to Organization.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread GitIntegration/Hosting/GitHubDeviceFlow.cs Fixed
Comment thread GitIntegration/GitHubProvider.cs Fixed
Comment thread GitIntegration/Parsing/GitWorktreeParser.cs Fixed
Merges origin/main, which brought in PR #115's GitHub repository
enumeration routing. Our own OwnerKind-based routing on this branch
solved the same problem and is dropped in favour of upstream's
approach, per the owner's decision.

Upstream infers the owner's account type from GET /users/{login}
rather than taking an explicit kind from the caller, and it handles
three cases ours did not: an unauthenticated provider skips the type
probe entirely and calls the public route directly; a GitHub App
installation token whose GET /user answers 403 falls back to the
public route instead of failing; and the routing decision is never
inferred from a 404, which an SSO-blocked organisation answers just
as an absent one would, so ours risked silently under-reporting an
organisation's repositories.

Removed the GitHubOwnerKind enum, the OwnerKind property, and their
tests and documentation. The worktree verbs, the X-GitHub-SSO
authorisation URL in failure messages, and GitHubDeviceFlow are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@matt-edmondson matt-edmondson changed the title Worktree verbs, GitHub owner kinds, and OAuth device flow Worktree verbs, single sign-on URL, and OAuth device flow Sep 22, 2026
Three findings from the code quality review.

The device flow's `case null when string.IsNullOrEmpty(parsed.AccessToken)`
tested a constant: the preceding `case string` arm consumes every non-null
error, so the error is provably null by the time control reaches it. The
null test is dropped and the token check moves into the default arm, which
also lets the null-forgiveness operator go, since the compiler narrows
AccessToken through the plain check where it could not through the pattern.

The two loops in GitWorktreeParser.Parse and TryGetSingleSignOnUrl each
remapped their iteration variable on the first line of the body. Both now
map at the enumeration source instead.

No behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit fba2341 into main Sep 22, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the feat/worktrees-and-github-auth branch September 22, 2026 05:12
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.

1 participant