Skip to main content

Publish a stack

gitq publish turns the local-only branches in a stack into a chain of merge requests, each one targeting the branch below it, and keeps the MRs that already exist pointing at the right branch. Which forge it opens them on comes from the git remote's host, so the same command covers GitLab and GitHub. It is the only gitq command that talks to a network API, the only one that needs a token, and (inside the agent skill flow) the only one with a human gate in front of it.

This page is verified line by line against src/cli/commands/forge.ts, src/core/forge-sync.ts, src/cli/provider.ts, and src/core/secrets.ts, not against a live run: publishing hits a real forge API, so there is no throwaway repo to demonstrate it against the way the other guides do. The command shapes and JSON below are the actual interfaces, not a captured transcript.

The token​

For a GitLab remote, gitq looks in two places, in order, and stops at the first hit:

  1. GITLAB_TOKEN in the environment.
  2. The rt daemon's grant-gated secrets:forge-token verb, backed by rt's own encrypted store (set with rt secrets set rt gitlabToken) — not a plaintext file gitq reads itself. This needs the repo tracked with rt (rt daemon track <repo> live branches).

gitq never writes either; both are read-only inputs. Find neither and publish (and import) fail before any network call, naming which of the two steps came up empty, e.g. for a repo not tracked with rt:

gitq: no gitlab token for gitlab.com (set GITLAB_TOKEN or track the repo with rt (rt daemon track <repo> live branches); <repoPath> is not registered with rt (~/.mattstack/rt/repos.json))

Which token gitq wants follows from the repo's remote, not from configuration: a GitHub remote asks for GITHUB_TOKEN and is never offered a GitLab one. The instance comes from the remote too, so self-hosted GitLab works, given a forges entry naming it (or a tokenEnv on that entry, for a credential of its own). The board follows the same rule per repo, so a board showing a GitLab repo and a GitHub repo enriches both, each with its own credential. See the forge token.

What publish does, per branch​

publish walks the stack in topological order (parents before children) and treats a branch one of two ways, depending on whether it already has an MR. (A third outcome, skipped, is for the branches whose MR is not publish's to write to.)

A branch with no MR yet (status is local-only and no mrIid) is created:

  1. Push. git push --force-with-lease origin <branch>.
  2. Create the MR. sourceBranch is the node's branch, targetBranch is the branch below it in the local tree (not whatever the branch happened to target before, if it had no MR there was nothing to have targeted). The MR is created as a draft unconditionally.
  3. Record it. The node's mrIid and mrUrl are set and its status becomes synced, along with lastKnownHead if the local branch head could be read.

A branch that already has an open MR is updated, never pushed:

  1. Retarget, when the MR's target on the forge is no longer the branch below it in the local tree: updatePullRequest(projectPath, mrIid, { targetBranch }), and the node's status goes back to synced. This is what fixes an MR after a reparent moved its branch under something else.
  2. Rewrite the title and description, only when --mr-meta has an entry for that branch (see below).

Needing neither, the branch is left completely alone and does not appear in the output at all.

To tell a retarget from a no-op, publish reads the current target of every already-published MR before the walk, batch-fetched by iid in one call. A stack with nothing published yet skips that read; a stack where it fails stops the command before anything is pushed.

If a create fails, publish stops the walk right there: the branch was never pushed, so a child MR targeting it would have no base. A failed update leaves that branch's MR exactly as it was, so the walk carries on and the branches after it are still published.

bash
gitq publish --stack demo
feat-api: created https://gitlab.com/group/project/-/merge_requests/101
feat-ui: updated (retargeted to feat-api) https://gitlab.com/group/project/-/merge_requests/102

Under --json, the same distinction is per-result: action is created or updated, and an update carries changes (target, metadata, or both).

Exit 0 when every attempted branch succeeded, 1 if any failed. Check the per-branch success field under --json rather than assuming a failure means nothing happened; branches before a failed create are already pushed and already have MRs, and a failed update does not stop what comes after it.

The branch below is the nearest live one​

A merged branch stays in your stack until you remove it, and its children keep pointing at it. The forge, meanwhile, deletes the source branch when it merges an MR and moves the child MR to the trunk itself. So publish never targets a merged node: it walks down past every branch whose status is merged and targets the first one that is still live, the same nearest-live-ancestor rule the cascade rebases by.

That is what keeps the ordinary end of a stack's life boring. The bottom branch merges, you add another branch on top, you republish: the surviving MRs are compared against the trunk (which is where the forge already put them, so nothing is written), and the new branch's MR is opened against the branch below it. Without it, the child MR would be pointed back at a branch that no longer exists.

Branches publish skips​

publish only ever writes to an open MR belonging to that branch. When the MR it has an iid for is merged, closed, missing from the read, or turns out to be some other branch's MR, it writes nothing and tells you:

feat-ui: skipped (MR !102 is closed)

Under --json those come back in their own skipped array, each with a branch, mrIid, a machine-readable reason, and that same detail line. A skip is not a failure and does not change the exit code... it exists so that a branch you expected to be republished cannot quietly do nothing. See gitq publish for the full list of reasons.

A merged branch of your own stack is not a skip. It is where a branch is supposed to end up, and publish leaves it alone silently.

With nothing to create, nothing to update, and nothing skipped:

nothing to publish (no branches to create or update)

Exit 0.

--mr-meta: writing the MR content yourself​

Without it, a new MR's title is the branch name and its description is empty, and an existing MR's title and description are left exactly as they are. --mr-meta <path> points at a JSON file that supplies both, keyed by branch:

json
{
"feat-api": {
"title": "Add the API client",
"description": "Wraps the new endpoints in a typed client.\n\n- add ApiClient\n- add retry logic"
},
"feat-ui": {
"title": "Wire the API client into the UI",
"description": "Depends on feat-api."
}
}

Both title and description are required strings for every entry you include: gitq validates the whole file before touching the store or the network, and a malformed entry fails the command outright with a message naming the branch:

gitq: invalid --mr-meta: entry "feat-ui" must be {"title": string, "description": string}

A branch you omit from the file falls back to the branch-name-as-title, empty-description default on a new MR, and keeps what it already has on an existing one. Every entry is read either way, so an entry for a branch that already has an MR is an instruction to overwrite that MR's title and description on the forge, including any edit made in its UI. Name a published branch in --mr-meta only when you mean that.

An empty string counts as omitted, on both paths and for either field: "description": "" leaves an existing MR's body alone rather than wiping it, and "title": "" falls back to the branch name on a new MR. There is deliberately no way to empty an MR body from here... do that in the forge's own UI, where you can see what you are deleting.

Re-running publish​

Run publish again on a stack it already published and it does three things: opens MRs for any branch added since, retargets the MRs whose branch has moved under a different parent in the meantime, and rewrites the title and description of exactly the published branches you named in --mr-meta. A branch needing none of that is not touched and does not show up in the output.

The one thing it still does not do is push a branch that already has an MR: publish pushes a branch exactly once, when it opens that branch's MR. A rebased, already-published branch is gitq push's job, which is the usual next step after a restack.

Retarget, not resync

Retargeting changes what the MR merges into. It does not update what the MR contains. After a gitq sync rebase, publish will point the MR at the right parent, but the MR still shows whatever commits are on the remote branch until you push it.

The human gate lives in the skill, not the CLI​

gitq publish itself pushes and opens MRs the moment you run it; there is no confirmation prompt in the CLI. The gate exists one layer up, in gitq:publish's workflow: the skill drafts the MR titles and descriptions, shows you the whole plan, and waits for a yes before it ever invokes gitq publish. If you run the bare CLI command yourself, you are the gate. See Drive gitq with agents for what the skill does around this command.

import --replace: recovery, not routine​

gitq import rebuilds the local store from the forge directly: it fetches the open MRs of this project, the one your origin remote points at, walks targetBranch chains to discover stack-shaped trees, and replaces the tracked stacks with what it found. MRs in other projects on the same GitLab instance are filtered out before the walk, so an identically named branch elsewhere cannot end up in a discovered stack. It is the reverse of publish (forge state becomes the source of truth instead of the local store), and it exists for one situation: your local store is gone or wrong, and GitLab still has the truth.

It refuses to run over a non-empty store without --replace:

bash
gitq import
gitq: import would discard 2 locally tracked stack(s) and re-mint stack ids; pass --replace to overwrite the local store

Exit 1, checked before the token or the network are touched, on purpose: you get told what would be lost even offline. The reason it refuses at all: import does not merge, it replaces. Every stack import creates gets a fresh, random id (crypto.randomUUID(), unrelated to anything a previous track, add, or an earlier import assigned), so anything keyed to the old ids, most importantly any gitq undo history for operations run before the import, stops resolving to a current stack the moment you replace it. The stack names it derives are a readable slug from the tip branch's MR title or branch name (deduplicated on collision), which can look stable across two imports of the same MRs, but the id underneath never is.

--replace also refuses outright while any cascade in the repo is active:

bash
gitq import --replace
gitq: cascades are active; finish or abort them first

Exit 1, and unlike the per-stack lease guard everywhere else, this one fires on any lease in the repo, parked or running, for any stack, because import is about to discard the store those leases are tracked against.

Reach for it when Recover says to, not as a way to "refresh" a stack that is merely behind: that is what gitq sync is for.

Next​

  • Drive gitq with agents: the gitq:publish skill that gates this command behind a human.
  • Recover: the decision list, including when import --replace is the right call.
  • Where state lives: the token, and everything else gitq reads without owning.