g2g
Command identity and discovery
- The command, project, module, repository, formula and this skill are all
g2g. The tool was once calledgt2gh, and that name survives only where it is a historical fact: tags and release assets published under it, and the tap'sformula_renames.json, which is what migrates installs made under the old name. Do not reintroduce it anywhere else. - At the start of a task, reuse a usable command already resolved in the task
context. Otherwise discover it once locally: try
g2g --version, thengt2gh --version, which is worth one attempt only for an install predating the rename. Select the first that succeeds. If neither works, say the user must runbrew install shhac/tap/g2gor provide a built binary; do not assume either exists. - Record the selected command and use it for every later invocation. Do not re-detect unless it fails or the environment changes.
Work in this repository
- Read
README.mdanddesign-docs/initial-scope.mdbefore changing behavior. For anything touching the g2g-owned branch forest, readdesign-docs/g2g-owned-graphs.mdfirst. - Graphite is not authoritative. Structure is resolved per invocation, and a
branch the g2g graph records wins over anything Graphite declares; Graphite
answers for whatever g2g has not adopted. Read
design-docs/source-resolution.mdbefore changing how a command selects a stack, and never reintroduce the assumption that Graphite decides. linkpreviews by default. Its optional--branchtarget must work without checkout;--applyis the only path that may invokegh stack link. Bare invocation prints help.synchas nothing to do with pull requests. It brings a stack up to date with its remote: fetch into g2g's own ref namespace, fast-forward the base or refuse if it has diverged, and replay. It never callsgh.pruneforgets branches whose work has landed. It is its own command rather than sync's tail because it answers a different question on the same boundary, it edits the recorded graph and deletes no branch, and it refuses to strand a branch recorded under a landed one rather than reparenting around it.pushis a preview-first publication escape hatch. It selects a path through source resolution, must never submit or restack, and must never callgh; only--applymay run exactly onegit push --atomic --force-with-lease <remote> <branches>call. Keep the remote default explicit (origin), validate it, and never fall back to a weaker push mode.- How much of the structure a command means is
--scope, and it means the same thing whichever record answered. Readdesign-docs/stack-scope.mdbefore changing it.--frompins which source answers for one invocation. - A command named inside a hint is marked at the point the sentence is written
(
runnable("g2g link")) and drawn when the sentence is styled. Do not add a highlighter that finds commands by pattern: what is drawn as runnable must be exactly what can be copied and run. Marks are control characters, never content, so keep them out of anything that becomes anerror— stderr is undecorated and would print them verbatim — and strip them for--jsonand--porcelain.Presentation.stylere-opens the enclosing style after each command, because ANSI ends a style by returning to the default and a subdued hint would otherwise come back bright from its first command onwards. The chip's padding column is painted, never written into the sentence, so plain output is unchanged. - A package that refuses says why and what to do as a
repair.Note, and derives itsBlockedsentence from it.internal/clirenders the sentence for a machine and lays the same values out for a person — reason on its own line, one way out per line, each command drawn as a command. Do not hand-write a refusal sentence beside the structure: the pair drifts, and the sentence is the half a machine reads. A refusal delegated from another package carries no structure here, sorefusingtakes the sentence too and still says why. - Linking has two halves and they must stay apart:
Presentation.hyperlinkis the capability (may this output carry a link), andinternal/cli/links.gois the policy (what does a thing point at, and which service wins). A render site never builds a URL. Add a destination by adding a resolver to an ordered list; add a linkable thing by adding a subject type and its own list. GitHub outranks Graphite for a pull request because its address was reported rather than assembled. --debugis a persistent, stderr-only diagnostic flag on every command. It is safe for local investigation but must not alter command behavior or cause agents to enable Graphite's owngt --debug.- Never guess a Graphite trunk from its name. The selected ancestry determines
the inferred trunk; multiple valid declared trunks require
--trunk, whose value must itself be declared and ancestral. A g2g-owned path has one root, so--trunkmay only confirm it and must refuse any other value rather than ignoring it. - Read
design-docs/graphite-cli-contract.mdbefore changing discovery. Do not read Graphite internal metadata/configuration or usegt --debug: supported production discovery is strict, compatibility-gated noninteractive CLI parsing. mirroris the only command that writes Graphite, through exactlygt track <branch> --parent <p> --no-interactiveandgt untrack <branch> --force --no-interactive, both behind the same version gate as discovery. Every other command's Graphite use stays read-only. Readdesign-docs/source-alignment.mdbefore touchinginternal/align.- No g2g command may enrol a repository into Graphite — including the ones
that write it. Reading Graphite's forest is what creates state, so
mirrorandimportcheckgraphite.Configuredand refuse before reading. A repository with no Graphite has no trunk and could not be mirrored into anyway, so nothing is lost by refusing first.
g2g-owned graphs
graph,track, anduntrackoperate on a branch forest g2g owns itself. They read Git only: never call Graphite or GitHub from these paths, and never make them require a network. That independence is the feature.- The model is a forest: at most one parent per branch, many children per parent, several roots. Do not reintroduce a linear assumption. Graph identity is derived from the edges, never stored; do not add graph IDs.
- Authority is per branch (
g2gorgraphite), never per graph. A whole-graph rule cannot survive two components becoming connected by an action g2g never observed. trackmust never choose a parent. Preview the ordered candidates and block. Recording a structure every later command trusts is not a place for a good guess.track --stackis not an exception: the user asserts the trunk and ancestry supplies the rest, and it refuses wherever ancestry cannot order two branches. It records a forest, never a chain — a branch whose only selected ancestor is the trunk is a separate stack and must be left alone.- A trunk is a branch nothing sits under.
Graph.Adoptowns both halves of that invariant; do not pairTrackwith a hand-rolled promotion step, and never take the trunk list from the graph as it was before the edge was recorded. untrackmust never reparent the children it strands. Report them.mirrorandimportmust never remove a branch from the g2g graph. Alignment keeps the two records in step; it does not transfer ownership.mirrorwrites Graphite only,importwrites the g2g graph only, andimportrefuses a branch the g2g graph already records under a different parent rather than resolving the disagreement.- Mirror ordering is dictated by Graphite's CLI, not by taste: writes go
parents before children because
gt track --parentrequires a tracked parent, and prunes go deepest first — refusing any stranger with a surviving child — becausegt untrackcascades to the subtree. - Do not record commit SHAs in the store: commits and force-pushes are content movement, not structural drift. Validate against Git at read time instead.
restackis the only code permitted to rewrite history, and only throughinternal/git's two engines.git replaypreviews exact object ids without moving a ref and applies cleanly without touching the checkout;git rebase --update-refsis used only once a preview has established the rewrite conflicts, and it runs in the user's own working tree because resolving a conflict needs a tree they can edit. Do not move it to a private worktree: git refuses to check out a branch already checked out elsewhere, and the--detachworkaround silently splits the stack in two.- The replay range is
forkPoint..branch, neverbase..branch. Before any rewrite, the fork point must be an ancestor of the branch — a branch someone rebased by hand fails that, and replaying anyway pulls the base's own commits into the range. Refuse and tell the user to retrack. - A branch whose parent is being rewritten must be rewritten too, even when it still sits exactly where its fork point says. Every range passed to an engine starts at the topmost step's fork point, because the engines replay the union onto one base.
- restack is the only resumable operation. Every other mutating command must
refuse while its journal exists,
--continuerecomputes from the refs rather than resuming a stored queue, and--abortrestores tips the journal recorded because git only rolls back the invocation it was running. - The store lives under the Git common directory and is located with
git rev-parse --path-format=absolute --git-common-dir. The bare form is relative to the working directory and silently wrong from a subdirectory. Writes are temp-file plus rename.storeSchemaVersionis separate from the--jsonschemaVersion; an unrecognised store version fails closed. --scope branch|path|subtree|stack|trunk|allis selection, not projection policy. Displaying a subtree does not imply a subtree can be linked on GitHub. The type and the traversal live instackbecause both records answer them.- A command must refuse any scope it did not offer, and name its own
default. They genuinely differ:
statusandgraphdefault tostackbecause reading is free,restacktosubtreebecause rewriting is not, and only a read-only command offersall.ParseScopetakes both the accepted set and the fallback; there is no global default left to inherit. - Projection is a capability, not a scope.
link,submit,pushandretargettakestack|pathand refuse a forked selection throughSnapshot.RequireLinear, which names the remedy instead of choosing a line. - Selected from a trunk,
stackis the whole tree under it — a trunk's path is itself. That is how a rewrite asks for an entire shape without being handed a scope that could reach another trunk. trunkandallare deliberately absent fromRewriteScopes. A wide rewrite is far likelier to reach a branch checked out in another worktree, and Git refuses to check out a branch already checked out elsewhere.
Source resolution
Every stack-selecting command resolves which source describes the branch: g2g's own store first (adoption is the claim), then Graphite. The answer is derived per branch on every run and never stored — there is no owner field, and adding one reintroduces state that goes stale through actions g2g never observes.
Never run Graphite in a repository that does not already use it. Its discovery command creates state, so
Describesis answered from the repository's own configuration andSelectis the only call that runsgt. Checking for that file is the single deliberate exception to reading none of Graphite's paths, and only its existence is ever read.Authority governs mutation, not description. Reading composes across sources;
restackrefuses a branch it has no fork point for and namesg2g track.GitHub's native stack is not a source. It is written from the others, and is only ever read to report membership or to find a stack to unlink.
pull-requestis the third source and answers only via--from pull-request, never by precedence: reading a base invokesgh, andpushmust never do that. It describes published branches only, and GitHub retargets a child when its base is deleted on merge, so it reports what a merge will do rather than what the stack was.linkcovers both creating and repairing the GitHub relationship; there is no separate reconcile command.syncmeans fetch, advance the base, replay, prune — the meaninggt synchas.A diverged base is reported, never merged or reset. Pruning edits the graph and never deletes a branch.
retargetis the only command that changes what a merge will do. It writes through exactlygh pr edit <number> --base <branch>, moves only the bases that disagree with the resolved stack, and refuses a branch with more than one open pull request rather than choosing between them. Do not fold it intosubmitor run it as the tail ofrestack.
Develop and test
- Keep external process calls behind
internal/subprocess.Runner. Tests must use fakegtandghexecutables onPATH, including captured supported Graphite text fixtures; never require credentials, network access, or real CLI installations. Graph ancestry is the one exception where a PATH fake proves nothing — it answers whatever it is asked, and the question is what Git considers reachable — so those cases build a throwaway local repository with synthetic branch names and no remote. - Preserve the
completion bash|zsh|fishinterface. Dynamic--branchand--trunkcompletion must remain deterministic, read-only, and checkout-free — and must reach no source the command itself would not reach. Completing a flag must never be what enrols a repository into Graphite, and must keep working with no Graphite installed. - Run
gofmt -won changed Go files andgo test ./.... Usego vet ./...when changing Go code or preparing a release. - Use
git hunkfor any staging. Do not commit, tag, push, or invoke realgt/ghmutations unless the user explicitly asks.
Use safely
- For a person who wants an editor workflow,
g2g submit --editopens one temporary JSON document, not a buffer per PR. It retains the document on all failures and after preview; successful--edit --applycleans it up unless--keep-specis present.
Submitting pull requests
submitis a preview-first PR creation recovery path. It must never invokegt submit, restack Graphite, or retarget an existing PR. Its--applyboundary validates/revalidates first, atomically pushes refs, creates only missing draft PRs, then links the eligible stack.For non-interactive use, create a private temporary directory with
g2g submit --write-spec <dir>, completesubmission.json, validate withg2g submit --spec <dir>/submission.json, then add--apply. Keep the spec on failure and state exact repair/validation/retry commands. Multiple PR templates require--template <name>or--no-template; never guess.A refusal reaches a machine as
blocked(the reason, no label) andrepair(the ways out, each with its command separate from the prose). Readrepairrather than parsing the sentence, and treat a way with nocommandas a real answer that is not a thing to run.Prefer
--json(or--porcelain) over parsing the human preview. Both are renderers over the same validated view, they suppress colour and every human-facing line, andschemaVersionsignals breaking changes. Never scrape the pretty graph.A blocked preview names the repairing command: merged pull requests point at
g2g syncwhen the recorded stack needs its base advanced and replayed, missing or closed ones atg2g submit, and a wrong base atg2g retarget. Two open pull requests for one branch is deliberately unadvised — a person must choose.A branch's annotation is a list of
stackMark— one axis each, one severity each:base✓/base✗,head✗,pr✗, and a subject-less mark for what is about no axis. Build them and callstackNode.marked, which rendersStateand the worstSeverityfrom them; never setStatealongside marks, and do not fold two axes into one mark, which is the failure this replaced. A merged pull request ispr✓and neutral, never grouped with a missing or closed one: it succeeded, and only the leftover branch is a problem.Batch before parallelising, and do not reintroduce a call whose only purpose is to feed the next one:
Inspectis a singlegh api graphqlnaming the repository through{owner}/{repo}, and revisions resolve throughgit.Client.ResolveAllin one process.Per-branch Git reads go through
link.eachBranch, which runs them several at a time and cancels the rest on the first failure. Write results into a slice index the read was given, never a shared map, and keep any fake it can reach safe to call concurrently.go test -race ./internal/link ./internal/cli.Currency is counted by content (
Cherry, neverDivergence) and bounded to a branch's own commits — above its parent, not above the trunk. Counting commit ids reported every commit the trunk had gained as unpushed work of the reader's own. A branch replayed since it was pushed isCurrency.Rewritten: nothing missing, needs pushing, and not a divergence."Landed" is a content question Git answers and GitHub cannot, and it needs both halves:
Cherryper commit, thenAbsorbedfor the squash merge. A landed branch islink.IssueLanded, neverIssueMissing— the advice for missing isg2g submit, and submitting work already in the trunk is the bug this prevents. What forgets it depends on the source (g2g prunefor g2g's graph,gt syncfor Graphite), so do not hardcode one.statusis the read-only triage entry point. It renders one selected path from the resolved g2g or Graphite structure and reports each selected PR's native GitHub stack membership from the same batched PR query; keep the healthy case to one compact summary line and annotate only missing/conflicting nodes.unlinkis the deliberate inverse oflink: it discovers the GitHub stack number from the selected path and refuses rather than guesses when that path is unlinked or spans several stacks, accepts--stack-numberto override, previews first, and only--applyinvokesgh stack unstack. It must never alter Graphite, branches, PR content, reviewers, or PR lifecycle.After command discovery, use the resolved command's
--helporlink --helpto inspect the current interface (for example,g2g link --helpafter a Homebrew install).Require read-only discovery and dry-run output before
sync --applycan change GitHub state.