Skip to content
8 changes: 8 additions & 0 deletions .changes/source-control/fix-fork-repo-resolution-3f9c2a71.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: patch
---

### Fixed

- In a fork checkout, `pull-request` create, ready and evidence no longer leave the target repository to `gh`, which can pick upstream. Each phase resolves it once with the new `scripts/target-repo.sh`, prints it, and names it in every write. Ready and create export it as `GH_REPO` once it is resolved, so their later reads, and the scripts they run, target it too; evidence resolves it first and names it on every read. `branch-update-signals.sh` without `--repo` now reads the repository through `gh api`, which follows `GH_REPO`, instead of `gh repo view`, which ignores it. A fork checkout resolves to the fork even when `gh` records upstream as its default, but only a fork you can push to, so a maintainer who adds a contributor's fork as a remote keeps writing to the recorded default; a failed repository read stops the phase instead of falling back to upstream. Remotes naming two or more GitHub repositories, none a fork of another that you can push to, with no `gh repo set-default` recorded, are refused with that fix.
- The create provider reference no longer says the worktree-anchored default-branch read returns the fork's branch: it returns the branch of whichever repository `gh` resolves, which can be upstream.
9 changes: 9 additions & 0 deletions .changes/work-items/fix-fork-repo-resolution-8d41e6b0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
bump: patch
---

### Fixed

- `setup check` now reports when the remotes name more than one GitHub repository and no `gh repo set-default` is recorded, or the recorded default is not the fork, and prints the `gh repo set-default <owner>/<repo>` fix.
- The tracker's `CONTRACT.md`, the GitHub adapter and the morning briefing script's comments describe `gh`'s real repository order (`GH_REPO`, the recorded default, then the first remote by name) instead of "the working directory's git remote".
- Plan comments, schedule reconciliation, label provisioning and the auto-merge disarm name the repository with `-R` instead of leaving it to `gh`.
54 changes: 49 additions & 5 deletions plugins/source-control/reference/providers/github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,50 @@ A pull request is GitHub's change request; this file uses its terms.
| The repository's default branch | `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`; in a sandboxed session, `gh api "repos/{owner}/{repo}" --jq '.default_branch'` |
| Read a tracked file from a repository's default branch | `gh api repos/<owner>/<repo>/contents/<path>` (the `content` field is base64) |

### Target repository

A `gh` command given no repository does not simply use `origin`. It takes `-R` or `GH_REPO`
first, then the remote `gh repo set-default` recorded, and without either, when it cannot
prompt (an agent's shell, or any `$(...)` capture), the first remote in its own order by remote
name, where `upstream` comes before `origin`. A stock `gh repo clone` of a fork records `upstream`
as the default. So in a fork checkout a bare write can land on the upstream repository.

Each phase that writes resolves the repository once, before its first write (ready and evidence
before their first read too), with `bash "<skill-dir>/scripts/target-repo.sh" [--dir <checkout>]`
(the pull-request skill's script), reports the `target repository:` line it prints, and targets
that repository with every later read and write of the phase. Each write names it:
`-R <owner>/<repo>` on `gh pr` and `gh issue`, `repos/<owner>/<repo>/...` on `gh api`, as
[merge](pull-request/merge.md#merge-command) already does. A phase that runs shell blocks also
exports `GH_REPO` with it, which every other `gh` call follows, the scripts the phase runs
included, and which `{owner}/{repo}` placeholders expand from. `gh repo view` with no argument
ignores `GH_REPO` (gh 2.103.3), so a script that needs the repository reads
`gh api 'repos/{owner}/{repo}' --jq .full_name` instead. The script resolves in this order:

1. `GH_REPO`, when set: the caller's explicit choice, or the repository an earlier block resolved.
2. When the remotes name two or more github.com repositories, the fork among them: a remote's
repository the caller can push to (`.permissions.push`) whose parent (`.parent.full_name`,
both from `gh api repos/<owner>/<repo>`) is the recorded default or another remote's
repository, `origin` checked first. A fork checkout works on the fork, even when a stock fork
clone recorded upstream as gh's default; a contributor's fork added as a remote does not
capture a maintainer's writes. A failed read of a repository stops the script with exit 1.
3. The recorded default.
4. The one github.com repository the remotes name.
5. With no github.com remote, gh's own resolution.

It refuses when the remotes cannot decide: two or more github.com repositories, none a fork of
another that the caller can push to, and no default recorded. It then prints the `gh repo set-default <owner>/<repo>` fix,
and the refusal stops the phase: which repository is the target is the user's choice, not the
script's. A pull request meant for upstream from a fork names its base repository explicitly
([out-of-tree create](pull-request/create.md#open-the-pr-from-an-out-of-tree-orchestrator),
triangular flow).

- **Pointer**: [`gh repo set-default`](https://cli.github.com/manual/gh_repo_set-default),
[`gh repo clone`](https://cli.github.com/manual/gh_repo_clone) and
[`gh help environment`](https://cli.github.com/manual/gh_help_environment) (`GH_REPO`).
- **As of**: 2026-11-11, gh 2.103.3.
- **Recheck trigger**: a gh release note that changes how commands pick the base repository or
what `gh repo set-default --view` prints.

## Resolve a pull request

| Operation | GitHub command |
Expand All @@ -49,12 +93,12 @@ entry and pass it to every later `gh` call in that phase.

| Operation | GitHub command |
|---|---|
| Open a draft PR | `gh pr create --draft` (with `--head <branch>` on the PR-only entry), never `--reviewer` |
| Open a draft PR | `gh pr create -R <owner>/<repo> --draft` (with `--head <branch>` on the PR-only entry), never `--reviewer` |
| Check out the PR head | `gh pr checkout <N>`, or `gh pr checkout <N> --detach` when the branch is locked in another worktree. It is fork-safe: a fork's head branch is not fetchable from `origin` by name |
| Merge the base into the head | `gh api --method PUT repos/{owner}/{repo}/pulls/<N>/update-branch -f expected_head_sha=<sha>`, pinned to the decision's head (pull-request `ready-for-review.md`) |
| Flip a draft ready | `gh pr ready <N>`, run only inside `/source-control:pull-request ready`, never bare |
| Merge the base into the head | `gh api --method PUT repos/<owner>/<repo>/pulls/<N>/update-branch -f expected_head_sha=<sha>`, pinned to the decision's head (pull-request `ready-for-review.md`) |
| Flip a draft ready | `gh pr ready <N> -R <owner>/<repo>`, run only inside `/source-control:pull-request ready`, never bare |
| Merge a PR | `gh pr merge <N> -R <owner>/<repo>` with the PR number and repository written literally, plus the flags the repository needs: `--squash`, `--auto` on a merge-queue or auto-merge base, `--admin` only where a code-owner review or merge queue blocks an otherwise ready merge and the consumer's policy allows it; `--match-head-commit <sha>` is optional for an immediate merge and required with `--auto`. A refused merge is reported, never handed to the user or retried in another shape ([pull-request/merge.md](pull-request/merge.md#merge-command); pull-request `merge.md` §4.2.1 owns when to merge without asking). A babysit lane merges only through its merge-gate wrapper (babysit-prs `safety.md`) |
| Open a tracking issue | `gh issue create` |
| Open a tracking issue | `gh issue create -R <owner>/<repo>` |

## Checks and CI logs

Expand Down Expand Up @@ -85,7 +129,7 @@ conversation comments carry only `created_at`.
|---|---|
| React to a comment | `POST` to the comment's `/reactions` endpoint (`+1`, `-1`, `eyes`); verify with a `GET` on the same endpoint. Review bodies have no reactions endpoint |
| Reply on an inline thread | `gh api repos/<owner>/<repo>/pulls/<N>/comments/<id>/replies -F body=@<body-file>` |
| Reply on the conversation | `gh pr comment <N> --body-file <body-file>` |
| Reply on the conversation | `gh pr comment <N> -R <owner>/<repo> --body-file <body-file>` |
| Verify an inline reply | the inline surface filtered with `--jq '.[] \| select(.in_reply_to_id == <original-id>)'` |
| Verify a conversation reply (D5, before any fix) | the conversation surface filtered with `--jq '.[] \| select(.user.login == "<posting-identity>") \| .body'` |
| Verify a follow-up reply citing the fix | the conversation surface filtered with `--jq '.[] \| select((.body \| contains("<sha>")) and .user.login == "<posting-identity>") \| .body'`, never `.[-1]` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ from `gh api "repos/{owner}/{repo}/issues/${REFS_NUM}"` (state `open`) and the s

## Placeholder anchoring

`gh api` has no `--repo` flag. `{owner}` and `{repo}` expand from the repository of the **current
directory**, or from `GH_REPO`. Out of tree, run the call from the worktree
`gh api` has no `--repo` flag. `{owner}` and `{repo}` expand from `GH_REPO`, else from the
repository gh resolves for the **current directory**, which in a fork checkout can be upstream
([target repository](../README.md#target-repository)). Once `target-repo.sh` has resolved the
checkout's repository, the calls below name `$REPO` and the exported `GH_REPO` expands the
placeholders. Before that, out of tree, run a placeholder call from the worktree
(`( cd "$WT" && gh api … )`) or prefix `GH_REPO=<owner>/<repo>`.

## Body overrides the template
Expand All @@ -103,14 +106,28 @@ A body passed with `gh pr create --body-file` (or `--body`) fully overrides
`.github/PULL_REQUEST_TEMPLATE.md` (cli/cli #10751). Create passes no `--attach` (gh 2.99.0 and
later): captures go to the PR through the pull-request skill's `evidence` action.

## Resolve the target repository

```bash
REPO=$(bash "<skill-dir>/scripts/target-repo.sh" --dir "<checkout>") || exit 1
export GH_REPO="$REPO"
```

A non-zero exit opens no PR: report the script's stderr, which names the failed repository read,
or the `gh repo set-default` fix when the remotes name more than one repository, none a pushable
fork of another, and no default is recorded ([target repository](../README.md#target-repository)).
From here on every read and write targets `$REPO`: the writes name it, and the exported `GH_REPO`
carries it to every other `gh` call. On a triangular flow the base repository takes `$REPO`'s
place in both.

## Open the draft PR

```bash
# Identity: plain `gh` (the human PR author) by default.
# No reviewers are auto-requested by default — `gh pr create` runs without
# `--reviewer`.
# Always a draft: a `ready` open state is reached by Phase 2.5's flip.
PR_URL=$(gh pr create --draft --head "$BRANCH" --title "<type>: <description>" --body-file "$BODY_FILE")
PR_URL=$(gh pr create -R "$REPO" --draft --head "$BRANCH" --title "<type>: <description>" --body-file "$BODY_FILE")

# Extract PR number from URL (gh pr create outputs the URL on success).
# This number is the source of truth for the rest of this phase — pass it
Expand All @@ -133,8 +150,8 @@ nothing. `POST /repos/{owner}/{repo}/pulls` is REST and works. It requires `head
- **`draft` is a boolean, so it goes with `-F`.** `-f draft=true` sends the string `"true"`, which the API rejects; `-F` is the flag that type-converts it. The other literal fields (`title`, `head`, `base`) stay on `-f`, so a title that reads `true` or a number is still sent as a string.

```bash
BASE=$(gh api "repos/{owner}/{repo}" --jq '.default_branch')
PR_JSON=$(gh api --method POST "repos/{owner}/{repo}/pulls" \
BASE=$(gh api "repos/$REPO" --jq '.default_branch')
PR_JSON=$(gh api --method POST "repos/$REPO/pulls" \
-f title="<type>: <description>" \
-f head="$BRANCH" \
-f base="$BASE" \
Expand All @@ -153,31 +170,33 @@ The REST form has no hook backstop: `pr-body-linkage-gate.sh` matches `gh pr cre
## PR identity

```bash
gh pr view --json number,url
gh pr view "$PR_NUMBER" -R "$REPO" --json number,url
```

In a sandboxed session, take identity from the create response, or re-read it with
`gh api "repos/{owner}/{repo}/pulls/<n>" --jq '{number, html_url}'`.
`gh api "repos/$REPO/pulls/<n>" --jq '{number, html_url}'`.

## Open the PR from an out-of-tree orchestrator

`gh pr create` MUST pass `--head "$BRANCH"` explicitly, since the invoker is not on the branch:
Resolve the repository for the worker's checkout, not the orchestrator's:
`REPO=$(bash "<skill-dir>/scripts/target-repo.sh" --dir "$WT") || exit 1`, then
`export GH_REPO="$REPO"`. `gh pr create` MUST pass `--head "$BRANCH"` explicitly, since the
invoker is not on the branch:

```bash
PR_URL=$(gh pr create --draft --head "$BRANCH" --title "<type>: <description>" --body-file "$BODY_FILE")
PR_URL=$(gh pr create -R "$REPO" --draft --head "$BRANCH" --title "<type>: <description>" --body-file "$BODY_FILE")
```

In a sandboxed session that 403s, the REST form, run from the worktree:
In a sandboxed session that 403s, the REST form, which names `$REPO` and so needs no `cd`:

```bash
PR_JSON=$( cd "$WT" && gh api --method POST "repos/{owner}/{repo}/pulls" \
-f title="<type>: <description>" -f head="$BRANCH" -f base="$BASE" -F body=@"$BODY_FILE" -F draft=true )
BASE=$(gh api "repos/$REPO" --jq '.default_branch')
PR_JSON=$(gh api --method POST "repos/$REPO/pulls" \
-f title="<type>: <description>" -f head="$BRANCH" -f base="$BASE" -F body=@"$BODY_FILE" -F draft=true)
```

Resolve the base branch the same anchored way:
`BASE=$( cd "$WT" && gh api "repos/{owner}/{repo}" --jq '.default_branch' )`.

On a triangular flow, name the base repository explicitly and namespace `head`:
On a triangular flow, the base repository takes `$REPO`'s place, named explicitly, and `head` is
namespaced:

```bash
BASE_REPO="<base-owner>/<repo>" # the PR's target, not the push destination
Expand All @@ -188,5 +207,7 @@ PR_JSON=$(GH_REPO="$BASE_REPO" gh api --method POST "repos/{owner}/{repo}/pulls"

`GH_REPO` overrides the cwd-derived placeholders, so this form needs no `cd`. On a triangular flow
resolve `$BASE` through `GH_REPO` too, as
`BASE=$(GH_REPO="$BASE_REPO" gh api "repos/{owner}/{repo}" --jq '.default_branch')`, not through the
`cd "$WT"` form above, which would read the fork's default branch.
`BASE=$(GH_REPO="$BASE_REPO" gh api "repos/{owner}/{repo}" --jq '.default_branch')`, never from the
worktree's own resolution: that reads the default branch of whichever repository gh resolves for
the checkout, the fork or, where a fork clone recorded `upstream` as gh's default, upstream, and
neither is guaranteed to be `$BASE_REPO`.
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ and `gh` facts behind them.

## Find the PR for the branch

The repository is resolved once, before the PR, and every read and write below targets it
([target repository](../README.md#target-repository)): the writes name it, and the exported
`GH_REPO` carries it to every other `gh` call, the scripts' included. A shell that runs a later
block runs this block's first two lines first. A non-zero exit from `target-repo.sh` ends the phase with its stderr
reported.

```bash
REPO=$(bash "<skill-dir>/scripts/target-repo.sh") || exit 1
export GH_REPO="$REPO"
BRANCH=$(git branch --show-current)
PR_NUMBER=$(gh pr view "$BRANCH" --json number -q '.number' 2>/dev/null)
PR_NUMBER=$(gh pr view "$BRANCH" -R "$REPO" --json number -q '.number' 2>/dev/null)
```

## Read the draft flag
Expand Down Expand Up @@ -86,7 +94,7 @@ DECISION_HEAD=$(jq -r .head_sha <<<"$DECISION") || exit 1
[ -n "$DECISION_HEAD" ] && [ "$DECISION_HEAD" != unknown ] || exit 1

UPDATED=0
if gh api --method PUT "repos/{owner}/{repo}/pulls/$PR_NUMBER/update-branch" \
if gh api --method PUT "repos/$REPO/pulls/$PR_NUMBER/update-branch" \
-f expected_head_sha="$DECISION_HEAD" >/dev/null; then
for _ in $(seq 12); do # 12 polls x 5 s = 60 s
NOW=$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid')
Expand Down Expand Up @@ -158,7 +166,7 @@ REQUESTED=$(gh pr view "$PR_NUMBER" --json reviewRequests -q '.reviewRequests |
echo '```text'
cat "$RANGE_DIFF_FILE"
echo '```'
} | gh pr comment "$PR_NUMBER" --body-file -
} | gh pr comment "$PR_NUMBER" -R "$REPO" --body-file -
```

## Read the diff
Expand Down Expand Up @@ -194,7 +202,7 @@ build posts nothing:
LOGIN=$(gh api user --jq .login) || exit 1
COMMENT=$(bash "<skill-dir>/scripts/ready-skip-comment.sh" --login "$LOGIN" \
--reason "$REASON" --step code-review) || exit 1
printf '%s\n' "$COMMENT" | gh pr comment "$PR_NUMBER" --body-file -
printf '%s\n' "$COMMENT" | gh pr comment "$PR_NUMBER" -R "$REPO" --body-file -
```

`check-ready-holds.sh` reads the title, body, labels and closing issue references, never
Expand Down Expand Up @@ -256,7 +264,7 @@ RUNS_BEFORE=$(gh run list --commit "$HEAD_SHA" --event pull_request -L 100 \
[ "$HEAD_MATCH" = 1 ] &&
[ "$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid')" = "$HEAD_SHA" ] &&
bash "<skill-dir>/scripts/verify-gate.sh" check --head "$HEAD_SHA" &&
gh pr ready "$PR_NUMBER"
gh pr ready "$PR_NUMBER" -R "$REPO"
```

The two head tests exit 1 when the PR's head on GitHub is not `HEAD_SHA`: the wait never saw it
Expand All @@ -276,7 +284,7 @@ route, guarded the same way:
[ "$HEAD_MATCH" = 1 ] &&
[ "$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid')" = "$HEAD_SHA" ] &&
bash "<skill-dir>/scripts/verify-gate.sh" check --head "$HEAD_SHA" &&
gh api --method POST "repos/{owner}/{repo}/pulls/$PR_NUMBER/ccr/ready_for_review"
gh api --method POST "repos/$REPO/pulls/$PR_NUMBER/ccr/ready_for_review"
```

Or the GitHub MCP `update_pull_request` call with `draft: false`. It is a tool call, so a shell
Expand Down Expand Up @@ -316,7 +324,7 @@ help:
```bash
RUNS_BEFORE=$(gh run list --commit "$HEAD_SHA" --event pull_request -L 100 \
--json databaseId -q 'map(.databaseId)') || RUNS_BEFORE='[]'
gh pr ready "$PR_NUMBER" --undo && gh pr ready "$PR_NUMBER" && ready_run_seen
gh pr ready "$PR_NUMBER" -R "$REPO" --undo && gh pr ready "$PR_NUMBER" -R "$REPO" && ready_run_seen
```

Exit 0 after the toggle means a new run appeared; report it as "a new run appeared", never as a
Expand Down
3 changes: 2 additions & 1 deletion plugins/source-control/scripts/branch-update-signals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ count_answer() {
}

load() {
[[ -n "$REPO" ]] || REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null | tr -d '\r')
# gh api's placeholders follow GH_REPO; gh repo view with no argument ignores it.
[[ -n "$REPO" ]] || REPO=$(api 'repos/{owner}/{repo}' --jq .full_name)
if [[ -n "$PR" ]]; then
PULL=$(api "repos/$REPO/pulls/$PR")
COMMITS=$(paged "repos/$REPO/pulls/$PR/commits")
Expand Down
11 changes: 10 additions & 1 deletion plugins/source-control/scripts/branch-update-signals.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ if [[ -n ${FAKE_REFUSE:-} && "$*" =~ $FAKE_REFUSE ]]; then
fi
slurp() { [[ -f "$FAKE/$1" ]] && printf '[%s]\n' "$(cat "$FAKE/$1")" || echo '[[]]'; }
case "$*" in
"repo view --json nameWithOwner"*) echo example-org/app ;;
"api repos/{owner}/{repo} --jq .full_name") echo "${GH_REPO:-upstream-org/app}" ;;
"repo view "*) cat "$FAKE/repo" ;;
"pr list "*) cat "$FAKE/dependents" ;;
*"/protection/required_status_checks")
Expand Down Expand Up @@ -483,6 +483,15 @@ reset_fake "$(git -C "$WC" rev-parse HEAD)"
LABEL="other merge" run "$WC" --pr 7 --repo example-org/merged-other
expect "base_merged: a merge of another branch" base_merged no

########################################################################
# No --repo: the repository comes from GH_REPO, not gh's recorded default
########################################################################
WC="$WORKDIR/app"
reset_fake "$SHA"
GH_REPO=example-org/app LABEL="GH_REPO" run "$WC" --pr 7
assert_contains "GH_REPO: the pull request is read from GH_REPO's repository" "$(cat "$FAKE/calls")" "api repos/example-org/app/pulls/7"
expect "GH_REPO: head_sha read" head_sha "$SHA"

########################################################################
# Refused reads: every field unknown, nothing fails
########################################################################
Expand Down
Loading
Loading