Skip to content

GitHub

These commands read or write pull requests, and so invoke gh. That is why they live under g2g github rather than beside the commands that need only Git: a command says in its own words when it will talk to GitHub, and someone who does not use GitHub never has to read about them.

A branch is identified by its single open pull request. Closed and merged pull requests on a reused branch name are history and never block github link, github retarget, or github status. Two or more open pull requests for one branch is the only ambiguity, and it fails closed. See Push and submit.

A GitHub native stack is linear, so github link, github unlink and github retarget take --scope stack or path only, and refuse a selection that forks, naming the remedy rather than choosing a line. Selecting a leaf is that remedy and needs no flag. See Scope.

g2g github status is the read-only first step for triaging a stack’s pull requests, and never changes GitHub or Graphite. Where g2g status answers from this checkout alone, this one asks GitHub.

Terminal window
g2g github status
g2g github status --from graphite # the pull requests, as Graphite groups them
g2g github status --from github --scope stack # the structure the pull request bases describe

It renders the selected stack from the resolved structure, a chain as a flat column and a fork as a tree, with its open pull request mappings and blocked relationships highlighted. It reports each branch against its own parent rather than whichever sibling sorts first, and says which record described it. A branch no source describes is rendered as such rather than refused: “nothing is stacked here” answers what was asked. --scope defaults to stack, because reading is free.

The same bounded pull request read reports native stack number, size, and position for each selected pull request, without a checkout or a second graph, and marks the members of the native stack running through the tree. A healthy path ends with one compact GitHub stack #… · selected path … · aligned line; only missing or conflicting membership is annotated on individual nodes.

Each branch is annotated one axis at a time, so a mark means one thing and carries its own colour. Read the column for ✗:

Mark Means
base✓ the pull request is based where the resolved structure puts it
base✗ it is not
head✗ the pull request is not on the commit the branch is; a current one stays unannotated, so the stale ones stand out
pr✗ a pull request that is missing, closed, or ambiguous — not a statement about a base, because a branch with no pull request has no base to be wrong about
pr✓ a merged one, in the ordinary colour: it did what it was for

A branch whose work is already in the branch below it reads as landed in …, in the ordinary colour, and is offered forgetting rather than submitting. That is a question Git answers and GitHub cannot: a squash merge lands the work under a pull request whose head the branch never had, and a series somebody cherry-picked has no pull request at all. Without that check the branch would look like one merely missing a pull request, and the advice would be to open one for a change already in the trunk.

--from github reads the structure from the pull request bases themselves, with nothing recorded locally. It says what GitHub will merge rather than what you intended, describes published branches only, and right after a parent lands its children point at the trunk. See Sources for what that source can and cannot tell you.

After a restack the local stack is correct and GitHub may still record where each pull request used to sit. A base is what a merge follows, so leaving it stale means merging into the wrong branch.

Terminal window
g2g github retarget # which bases would move, and where from
g2g github retarget --apply

It is separate from submit deliberately. Creating a pull request and changing what an existing one will merge into are different classes of act, and the second wants its own preview: every line names the pull request, the base it has, and the base it would get. It writes through gh pr edit <number> --base <branch>.

It touches only the pull requests whose base disagrees with the resolved stack, leaves branches with no pull request to submit, ignores merged and closed ones, and refuses outright when a branch has more than one open pull request, because nothing here can tell which one you meant. When GitHub already agrees it is a no-op, which is what makes it safe to run after every restack.

github link projects a resolved linear path onto GitHub’s native stack feature. It works with a g2g-owned or Graphite-described path; Graphite is not a prerequisite. Creating the relationship and repairing it are the same act, so there is no separate reconcile command.

Terminal window
g2g github link # the path ending at the current branch
g2g github link --branch feature/top # another local branch, without checking it out
g2g github link --branch feature/top --from graphite # pin a source
g2g github link --branch feature/top --trunk main # pin a Graphite multi-trunk ancestry's trunk
g2g github link --branch feature/middle --scope path # stop at the selected branch
g2g github link --branch feature/top --apply # revalidate, then let gh create or update it

When at least two branches with pull requests need linking, it prints the exact bottom-to-top gh stack link command. A path with one pull request is a successful no-op: it prints Nothing to link and never constructs an invalid command.

The preview renders the selected stack once, as a fixed-indent column because the path is linear: the trunk marked, the branches bottom-to-top, and pull request numbers and state in their own column.

Target beta · --branch
○ main trunk
│
● alpha #1
● beta #2 ← target
Command to run
gh stack link --base main alpha beta

It always shows the exact gh stack link command it validated, even when apply is blocked, because the command is the plan’s destination and running it by hand is a legitimate way to get gh’s own, often more specific, error. A blocked preview states the reason above the command and heads it Command to run once unblocked:

Target beta · --branch
○ main trunk
│
● alpha #1
● beta unresolved: no open pull request ← target
Apply blocked: resolve every unresolved GitHub PR mapping first
Command to run once unblocked
gh stack link --base main alpha beta

--apply re-discovers and revalidates, prints one Ready to apply graph and command, flushes that output, and invokes it. Copying the displayed command by hand is a separate, deliberate snapshot and does not make g2g re-resolve anything.

A linked stack cannot be landed with g2g land: GitHub will not merge a linked pull request through gh pr merge, which is how land merges each one. Unlink it first.

g2g github unlink previews removal of a GitHub native stack relationship.

Terminal window
g2g github unlink
g2g github unlink --apply

It discovers the stack number from the selected path, with the same batched read github status uses, so the number does not have to be copied by hand. Discovery refuses rather than guesses: a path that is not linked, or that spans more than one stack, is an error naming --stack-number, which remains available to choose deliberately and always wins.

--apply invokes the supported gh stack unstack <number> after the selected structure and pull request path are revalidated. It never changes Graphite, branches, pull request metadata, review state, or pull request lifecycle. Unlinking leaves the pull requests and their bases as they are.

link and unlink are a pair rather than one command with a removal flag on purpose: unlink removes a whole projection from a published, shared artifact, which is worth typing on purpose and worth being able to do without first computing a diff.

GitHub shows a pull request in isolation. github comment keeps one comment on each pull request in the stack that lists the rest of it, with that pull request in bold, so a reviewer can move through the stack without reading bases.

Terminal window
g2g github comment # what each comment would say, and which would change
g2g github comment --apply
**Stack**
- base `synthetic-main`
- #10 `synthetic-zero` · merged
- #11 `synthetic-one`
- #14 `synthetic-side` · +1 above
- **#12 `synthetic-two`** 👈 this pull request
- #13 `synthetic-three`
<sub>Kept up to date by [g2g 0.38.0](https://g2g.paulie.app), which edits this comment when the stack changes.</sub>

The path from the base to this pull request is one flat column; what forks off it hangs beside the branch it grew from, counted rather than drawn; and everything built on this pull request is drawn in full. Every line names its branch, because the branch is what a reviewer matches against their own checkout, and GitHub already shows each pull request number with its title and state.

It keeps the whole stack the branch belongs to, whichever branch you run it from, and so has no --scope. Each comment draws the stack from its own pull request, and keeping only part of a stack would leave the rest describing a different one. Taking the whole stack is what makes every run, from anywhere on it, converge on the same comments. Run from a trunk, it keeps every stack on it, each separately.

When the branch a fork grew from merges, one stack becomes two, since each fork is now its own child of the trunk. From then on each one’s comments list only itself. That is correct, because they no longer share an unmerged ancestor, and it can be surprising the first time.

Rerunning edits the comment it finds rather than adding another, found by an HTML marker in its first line. Pull requests that have merged out of the stack stay listed where they sat, marked merged: each comment records every pull request the stack has listed, so the history survives the branch being pruned and deleted. A pull request that merged before any comment was written was never recorded, and is not recovered.

The pull request Its comment
open, no comment yet added, if the stack lists at least two pull requests
any, one comment that is out of date edited
any, one comment already saying this left alone
merged, no comment never added: nobody is reviewing it, and a new comment notifies everyone who did
one comment you cannot edit left alone, and said
a conversation that takes no new comment (locked) left alone, and said
two or more comments left alone, and said: a person deletes the extra
a branch with two open pull requests the whole run refuses, as github link and github retarget do

A comment is current when what it says, the stack and the pull requests it records, matches what this run would write; the footer is left out of that comparison, so upgrading g2g alone does not edit every comment in every stack. A comment edited by hand stands until the stack changes, and is overwritten then. A comment is never deleted, even when a stack shrinks to one pull request.

If a run stops after writing some of its comments, it exits 3: it did part of what was asked. See Preview and apply.

submit and land keep the comments as their last act, because they change which pull requests the stack is made of: submit once the pull requests are opened, land on what remains above the branches it landed. Both say so in their preview, and --no-comment skips it. If keeping the comments fails there, the command’s own work stands and it exits 3, naming g2g github comment --apply.

push, github retarget and github link never touch them: push never calls gh, and the other two change no pull request’s membership of the stack.

See design-docs/stack-comment.md for how history is read back and whose history a comment is believed to hold.

A stack someone else published has its structure in one place: the bases of its pull requests. Rather than switching to each branch and tracking it by hand, adopt it from there.

Terminal window
git fetch
git switch synthetic-their-lower # every branch of the stack, here
git switch synthetic-their-top
g2g github adopt # preview the stack of the branch you are on
g2g github adopt --apply

It reads the stack exactly as g2g github status --from github does, with --branch picking another branch’s stack and --scope stack (the default) or trunk saying how much, and records it in g2g’s graph, so the branches can be restacked. It is the only adoption that needs the network, because reading a base invokes gh.

Its rules are graphite adopt’s: it writes only g2g’s graph, adds what is missing, and refuses a branch g2g already records under a different parent. Nothing is written to GitHub. Adoption is the authority claim: from then on g2g answers for every branch it adopted.

Three things it will not do:

  • Create a branch. The graph records local branches, so a branch the pull requests place that is only on the remote refuses the adoption by name, with git fetch && git switch <branch> or git branch <branch> origin/<branch> as the way out.
  • Make a trunk of a feature branch. The stack must start from the repository’s default branch (what refs/remotes/origin/HEAD names) or from a branch g2g already records; otherwise it names the g2g track that establishes one.
  • Take a base’s tip as the fork point. The base may have moved since the pull request was opened, so each fork point is where the branch and its base last agreed. A base that is not an ancestor of its branch is still recorded, and the preview says the branch will read as needing a restack.

Revalidation re-reads GitHub. What is written is local, but it is written from what the pull requests said, and a base retargeted between the preview and the apply is exactly the change revalidation exists to catch.