fix(git): correct fork→parent push target and PR base#296
Conversation
no-mistakes used one DB field (repos.upstream_url) for both the push target and the PR base repo, so it could not push to a contributor's fork while opening a PR against the parent. The failure mode was silent: a fork self-PR returns HTTP 200 and looks like success. Add a nullable repos.fork_url column. When set, it is the push target and upstream_url remains the PR base (parent); when empty, behavior is unchanged. A single decision point (Repo.PushURL) routes pushes to the fork, and GitHub PR creation emits --head <fork_owner>:<branch> against the parent (--repo). Bitbucket sources the branch from the fork; GitLab carries the fork identity on the host. - schema: add fork_url column + idempotent migration (NULL = no fork) - db: Repo.ForkURL + Repo.PushURL(); UpdateRepoMetadata folds in fork_url - cli: 'no-mistakes init --fork-url <url>'; status/eject show the fork - gate: InitWithFork records fork_url (Init delegates; no caller churn) - pipeline: push step + CI auto-fix push target Repo.PushURL() - scm: github.NewWithFork emits cross-repo --head; bitbucket/glab mirrors - tests: push-to-fork guard, cross-repo PR --head guard, db round-trip + migration, bitbucket fork source, github headRef unit test Resolves kunchenguid#293.
|
First off, thanks - and the #293 writeup is one of the better bug reports I've gotten. Root cause is exactly right: one Here's where I've landed though. This is core SCM routing - push, PR create, existing-PR lookup, init, migrations, all three providers at once - so I'd rather do it as one clean-slate pass than land it incrementally. A few things from your own analysis I want to get exactly right in that pass:
So I'm going to supersede this with my own PR and close this one out once that's up. Your analysis shaped it directly - appreciated. I'll link the replacement here when it lands. |
|
Superseded by #306, which lands a clean-slate implementation of the fork-routing fix (push routes to the fork, PR opens against the parent with |
Summary
Fixes #293.
no-mistakesv1.29.1 cannot ship a fork-based contribution: the push step and the PR step both derived their target from a single DB field (repos.upstream_url). There was no way to push to a contributor's fork while opening the PR against the parent. Worse, the failure mode is silent — a fork self-PR returns HTTP 200 and looks like success.This PR adds a separate push target so push→fork and PR→parent route correctly.
The bug
One URL field served two purposes:
sctx.Repo.UpstreamURLdirectly as the push URL (internal/pipeline/steps/push.go,internal/pipeline/steps/ci_fix.go).gh --repo, and--headwas always the bare branch name — never the<fork_owner>:<branch>formgh pr createrequires for a cross-repo PR.no-mistakes initread onlyorigin, so whateveroriginpointed at became both the push target and the PR base.Two failure modes depending on what
originwas atinittime:propens a self-PR inside the fork (base = fork'smain). CI watches checks on the fork. HTTP 200 → looks like success.Manually editing the bare gate's remotes doesn't help: the steps read the DB field, not the worktree remotes, and
initis idempotently self-healing (git remote set-urloverwrites manual changes back to the stored value).Repro
(any repo where you lack push access to the parent)
kunchenguid/no-mistakes→<you>/no-mistakes; clone the fork (origin = fork).go build -o ./bin/no-mistakes ./cmd/no-mistakes && ./bin/no-mistakes initgit switch -c scout/repro-fork && git commit -am "scout: fork repro"git push no-mistakes HEADBefore: push + PR both target the single
upstream_url→ self-PR in the fork (or push 403).After:
--fork-url <you>/no-mistakes.gitat init (or auto-detected); push lands on the fork and the PR opens against the parent withhead = <you>:scout/repro-fork.The fix
Add a nullable
repos.fork_urlcolumn. When set, it is the push target andupstream_urlremains the PR base (parent); when empty, behavior is unchanged (no regression for non-fork repos).A single decision point routes the two paths:
Repo.PushURL()(internal/db/repo.go) → fork when set, else upstream. The push step and the CI auto-fix push path both call it. No inline re-derivation.--head "<fork_owner>:<branch>"against the parent (--repo), viagithub.NewWithFork+Host.headRef. Bitbucket sources the branch from the fork in the POST body; GitLab carries the fork identity on the host.Surfacing / plumbing:
internal/db/schema.go):ALTER TABLE repos ADD COLUMN fork_url TEXT+ idempotent migration (NULL= no fork).COALESCE(fork_url, '')on read;nullableStringwrites genuineNULLfor non-fork repos.internal/db/repo.go):Repo.ForkURL+Repo.PushURL();UpdateRepoMetadatafolds infork_url.internal/cli/init.go):no-mistakes init --fork-url <url>(recommended setup:origin= parent read-only,--fork-url= your fork).statusandejectshow both URLs when set.internal/gate/gate.go):InitWithForkrecordsfork_url;Initdelegates so no caller churn.Repo.PushURL().github.NewWithForkemits cross-repo--head;bitbucketandgitlabmirror it.Verification
gofmt -l .clean ·go vet ./...clean ·go test -race ./...green (Go 1.26.4) ·go build ./cmd/no-mistakesOK.Two guard tests pin the corrected behavior (the bug's defining symptom is a silent success, so these exist to catch regressions):
TestPushStep_TargetsForkWhenForkURLSet(internal/pipeline/steps/push_test.go) — push resolves to the fork whenForkURLis set.TestPRStep_ForkCreatesCrossRepoPR(internal/pipeline/steps/pr_test.go) — PR--repois the parent and--headis<fork_owner>:<branch>; explicitly asserts the PR does not target the fork (no self-PR).Plus:
TestPRStep_BitbucketForkSourcesBranchFromFork(fork as POSTsource.repositoryon parent), a GitHubheadRefunit test, and DB round-trip + migration tests (internal/db/repo_test.go).upstream_url→ fork or parent, can't splitRepo.PushURL()→ fork when set--repo <same URL> --head <branch>(self-PR in fork, or push 403)--repo <parent> --head <fork_owner>:<branch>No behavior change for non-fork repos: empty
fork_urlpreserves today's single-URL behavior exactly.AI disclosure: Human-reviewed. The change was produced with AI assistance and reviewed by a human contributor before submission.