Repository navigation
fix(server): project identity follows the gh base remote so fork PRs link - #130
Conversation
…link Upstream's repository identity resolver always prefers a remote named `upstream` over `origin`. In this fork PRs go to origin, and since upstream pingdotgg#10101 the thread PR reactor refuses any PR whose repository differs from the project identity, so no thread card in the fork's own checkout ever got a PR link while the composer chip kept showing it. Read the `remote.<name>.gh-resolved = base` marker that `gh repo set-default` writes, and let that remote be the identity when a checkout has more than one remote. Single-remote checkouts, no marker, or a stale marker keep upstream's order. Fenced as server-repository-identity-base-remote with a manifest entry, resolver tests and a fork guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Thermo-nuclear review
Approval bar not met. The identity layer is the right home and the file stays at 230 lines — that's not the problem. The problem is wiring a fork-only gh marker into the shared remote picker instead of returning early from the resolve path.
pickPrimaryRemote had one job: prefer upstream, then origin, then sorted first. This PR gives it a second parameter (unfenced), an unused = null default, a fenced if inside the picker, and a let whose only job is to thread a maybe-name through. That is spaghetti growth plus a merge hazard: every upstream edit of this function now conflicts on a signature line the fence does not own.
The judo is an early return in resolveRepositoryIdentityFromCacheKey. If a marked remote exists, build identity and return. Then call pickPrimaryRemote(remotes) with no second argument. That deletes the parameter, the default, the picker branch, and the mutable let. parseBaseRemoteName stays — that parse is real. Do not extract a new policy file; this is not that kind of complexity.
The fork guard's indexOf check cements the worse shape: it requires remotes.get(baseRemoteName) to appear earlier in the file than ["upstream", "origin"], which is only true because the lookup was stuffed into the picker. After the early-return, that assertion goes red even though the marked remote still wins. Assert the outcome, not source order.
Not findings: file size, putting this in the identity resolver vs the PR reactor, the remotes.size > 1 gate, or parseBaseRemoteName itself.
Sent by Cursor Automation: Thermo nuke 4.6
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
NoahHendrickson
left a comment
There was a problem hiding this comment.
Code review — fix(server): project identity follows the gh base remote so fork PRs link
Verdict: the diagnosis and the mechanism are right; the wiring puts a fork-only concept into an upstream function signature, and one guard assertion is coupled to source order. All checks green.
What I verified
- Reading
remote.<name>.gh-resolvedis the correct signal: it is exactly whatgh repo set-defaultwrites and whatgh pr listresolves from, so the identity and the PR lookup now name the same repository by construction. git config --get-regexpexits 1 when nothing matches, and thecode === 0check handles that as "no base set" rather than an error. Correct.- The
remotes.size > 1gate means single-remote checkouts pay nothing, and the identity cache (DEFAULT_POSITIVE_CACHE_TTL= 1 minute) means agh repo set-defaulttakes effect within a minute — no restart needed. Good. - The stale-marker fallback is genuinely exercised (
falls back to upstream when the gh base marker names a missing remote), andparseBaseRemoteName's regex is safe for remote names containing dots because.gh-resolvedis anchored at the end.
1. pickPrimaryRemote gains a fork-only parameter on an upstream signature
apps/server/src/project/RepositoryIdentityResolver.ts:68
The new second parameter sits outside the fence, so a future upstream edit to this function conflicts on a line no fence owns — and the = null default is dead, since the one call site always passes it. pickPrimaryRemote's job is still "upstream, then origin, then sorted first"; the gh marker isn't part of that.
Cursor suggested an early return in resolveRepositoryIdentityFromCacheKey, which works but duplicates the buildRepositoryIdentity call. Smaller edit that leaves upstream's picker byte-identical and keeps everything inside one fence:
/* fork:begin server-repository-identity-base-remote */
function pickBaseRemote(
remotes: ReadonlyMap<string, string>,
baseRemoteName: string | null,
): { readonly remoteName: string; readonly remoteUrl: string } | null {
if (baseRemoteName === null) return null;
const remoteUrl = remotes.get(baseRemoteName);
return remoteUrl ? { remoteName: baseRemoteName, remoteUrl } : null;
}
/* fork:end */
// at the call site:
const remote = pickBaseRemote(remotes, baseRemoteName) ?? pickPrimaryRemote(remotes);That deletes the parameter, the unused default, and the branch inside the picker, and the stale-marker fallback becomes "return null" rather than a nested if.
2. The guard's indexOf assertion pins source order, not behaviour
apps/web/src/__fork_guards__/serverRepositoryIdentityBaseRemote.test.ts:41
expect(resolver.indexOf("remotes.get(baseRemoteName)")).toBeLessThan(
resolver.indexOf('["upstream", "origin"] as const'),
);This only holds because the lookup was stuffed inside pickPrimaryRemote, above the preference array. Under either restructuring above the marked remote still wins and this assertion goes red — a guard that fails on a refactor that preserves the invariant is a guard measuring the wrong thing. The three resolver tests already cover the behaviour (marker wins, stale marker falls back, single remote skips the lookup); I'd drop the indexOf check and keep the remotes.size > 1 / gh-resolved hunk assertions.
3. The marker's other form is silently unhandled — worth a line in the intent
gh repo set-default only writes the literal base when the chosen default repository is one of the checkout's remotes. When it isn't, gh writes remote.<name>.gh-resolved = <owner>/<repo> instead. parseBaseRemoteName matches \s+base$ only, so those checkouts fall back to upstream-then-origin — the same failure mode this PR exists to fix.
I don't think that's fixable here: the identity is built from a remote URL, and in that case there is no remote naming the base repository, so there's nothing to build a locator from. But the manifest intent currently reads as though the marker is honoured in general. One sentence stating the scope ("only the base form; a default repo that isn't a remote keeps upstream's order") would save the next reader the trip through gh's source.
Not findings
File size, choosing the identity resolver over the PR reactor as the home, the remotes.size > 1 gate, and parseBaseRemoteName itself — all fine.
Move the marker lookup out of pickPrimaryRemote's signature into a fenced pickBaseRemote Effect.fn, so upstream's picker is byte-identical again and the call site is a single fallback expression. The guard asserts that shape and the size gate instead of source order, and the manifest states that only the literal `base` marker form is honoured. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Addressed in 843e5c5:
|
# Conflicts: # .fork/customizations.yaml


Problem
Thread cards in the fork's own checkout never show a PR link, while the composer chip shows the PR fine (threads
7db829e5,6319d3f7). The two read different sources.Since upstream pingdotgg#10101 (absorbed in the 2026-09-08 sync), the sidebar card renders only the PR the server has stored on the thread. The
ThreadPullRequestReactorthat stores it refuses any PR whose repository differs from the project's repository identity. The identity resolver always prefers a remote namedupstreamoverorigin, so this checkout identifies aspingdotgg/t3codewhile its PRs live onNoahHendrickson/t3code. The reactor finds the PR, sees a foreign repository, and silently drops it. The database confirms it: zero PR link events ever for the t3code project, dozens for every single-remote project.The composer chip reads live git status, which asks
ghdirectly and has no identity check, which is why it kept working.Fix
gh repo set-defaultwritesremote.<name>.gh-resolved = base, andgh pr listalready answers from that remote. The resolver now reads the same marker: when a checkout has more than one remote and one carries the base marker, that remote is the identity. Single-remote checkouts, no marker, or a marker naming a missing remote keep upstream's upstream-then-origin order, so every other project resolves exactly as before. The extragit configcall runs only for multi-remote checkouts and is cached with the identity.Fenced as
server-repository-identity-base-remotewith a manifest entry, three fenced resolver test cases, and a fork guard. Composes with #129, which fixes the step before this one (looking up the right branch); the two touch different files.Verification
RepositoryIdentityResolver.test.ts: 13 passed, including the 3 new cases (marker wins over upstream; stale marker falls back to upstream; single remote makes no config call).serverRepositoryIdentityBaseRemote.test.ts: 2 passed.tsc --noEmit: 0 errors. Fork lint: no blocking warnings.vp fmtclean.Claude Fable 5.1 in Claude Code.
🤖 Generated with Claude Code