gitq publish
Creates a merge request for each not-yet-published branch in a stack, on whichever forge the repo's remote names, and updates the merge requests of the branches that already have one.
Usage
gitq publish [--stack <name>] [--mr-meta <path>]
Flags
| flag | meaning |
|---|---|
--stack <name> | Which tracked stack to publish. Optional when the repo has exactly one; required otherwise. See Global flags. |
--mr-meta <path> | Kept under this name deliberately: renaming it would break the CLI and the publish skill's contract, and "MR" reads as a generic here rather than as a GitLab word. Path to a JSON file of {"<branch>": {"title": "...", "description": "..."}}. Both fields are required strings for every entry you include, and an empty string reads as "not provided". On a new MR, a branch you omit falls back to the branch name as title and an empty description; on an existing MR, a branch you omit keeps the title and description it already has. |
Behavior
publish creates MRs and updates existing ones. It walks the stack in topological order and does one of two things per node (ForgeSync.publishStack, src/core/forge-sync.ts:369-522):
- A node whose
statusislocal-onlyand whosemrIidis stillnullis created: pushed, then opened as a draft MR against its target (src/core/forge-sync.ts:397-445). - A node that already has an open MR is updated: the MR is retargeted when its target on the forge no longer matches the node's target here, and its title and description are rewritten when
--mr-metanames that branch. The branch itself is not pushed (src/core/forge-sync.ts:447-518).
A node needing neither is left untouched and does not appear in the results at all. The retarget goes through ForgeSync.retargetMR (src/core/forge-sync.ts:534-544), the same function you can call on a single branch.
The target is the nearest live ancestor, not the raw parent. gitq keeps a merged branch in the tree with its children still pointing at it, so publish walks up past any node whose status is merged and targets the first one that is not (resolveLiveTarget, src/core/forge-sync.ts:626-634, the same rule the cascade rebases by). Both the comparison and the write use it: without that, a child would be retargeted onto a branch the forge deleted when it merged the parent, and an MR the forge had already auto-retargeted to the trunk would be dragged back onto the merged branch.
Order of checks, before any network call:
--mr-metais parsed and validated first, before the store or stack are even touched, so a malformed file fails the same way regardless of stack state or token presence (src/cli/commands/forge.ts:105-113).- The store loads, the stack resolves, and the stack lease is checked.
- Only then is the forge provider built (
createForgeProvider,src/cli/provider.ts:44-78), which is where the remote's host decides both the forge and the credential, and where the missing-token refusal happens: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))for a repo not tracked with rt. A remote whose host names no forge gitq knows is refused here too, rather than guessed at. See Configuration.
Then, before the walk, one read: the MRs of every already-published node, batch-fetched by iid (fetchPullRequests({ iids, projectPath }), src/core/forge-sync.ts:378-386). An MR's current target is the only way to tell a retarget from a no-op, so publish asks the forge rather than trusting the local status. A stack with nothing published yet skips this read entirely, and a failure here fails the whole command before anything is pushed. Merged nodes are left out of the read on purpose: their MRs are done, and publish never writes to them.
Skipped branches
Publish writes only to an open MR of the branch it belongs to. A published node that fails that check is skipped: it is not touched, not counted as a failure, and reported on its own line and in a skipped array rather than disappearing (src/core/forge-sync.ts:447-484). Three reasons, all carrying the node's branch, the mrIid publish would have written to, and a one-line detail:
reason | what publish saw | example detail |
|---|---|---|
mr-not-open | the MR came back in any state other than opened, so merged or closed | MR !12 is closed |
mr-unreadable | the batch read did not return that iid at all | MR !12 was not returned by the forge |
source-branch-mismatch | the iid names an MR opened from a different branch, so writing to it would retarget an unrelated MR | MR !12 is for other/branch, not feat-ui |
A locally merged node appears in neither list. It is the steady state gitq keeps in the tree, its iid is never read, and there is nothing to say about it.
Exit code is unaffected: a run whose only news is skips still exits 0. What changes is that you are told, so results staying empty means every branch genuinely needed nothing.
For each branch it creates, in topological (parent-before-child) order:
- Push.
git push --force-with-lease origin <branch>. - Create the MR, as a draft, unconditionally.
sourceBranchis the node's branch;targetBranchis its live target in the local tree, not whatever it might have targeted before (there was nothing to have targeted, since it had no MR). - Record it.
mrIidandmrUrlare set,statusbecomessynced.
For each branch it updates, in the same order:
- Retarget, only when the MR's
targetBranchdiffers from the node's live target:updatePullRequest(projectPath, mrIid, { targetBranch }), and the node'sstatusgoes back tosynced. This is the half that matters after areparent, which moves a branch locally and leaves its MR pointing at the old parent until publish runs. - Rewrite the title and description, only when
--mr-metacarries an entry for that branch. The entry replaces what is on the forge, including edits made in its UI; omit the branch, or leave a field empty, and that prose is left alone. - No push. A rebased, already-published branch is not pushed here.
gitq pushis the command for that.
A failed create stops the walk. A failed update does not. A create is the branch's push plus its MR: fail it and the branches below have no base to sit on, so publish stops rather than open MRs against a branch that was never pushed. A failed update leaves the branch and its MR exactly as they were, so the walk carries on and later branches are still created and updated. Either way the failed node is recorded with success: false and the command exits 1.
Each entry in results says which of the two happened: action is created or updated, and an update also carries changes (target, metadata, or both) and the targetBranch it now points at. Human output reads <branch>: created <url>, <branch>: updated (retargeted to <target>) <url>, or <branch>: skipped (<detail>), acted-on branches first and skips after (src/cli/commands/forge.ts:77-100).
--mr-meta's entries are validated up front; a malformed one names the offending branch: invalid --mr-meta: entry "<branch>" must be {"title": string, "description": string} (src/cli/commands/forge.ts:47-64). Every entry is read, whether the branch is getting a new MR or already has one, so an entry for an already-published branch is an instruction to overwrite that MR's title and description. An empty string means "not provided", identically on both paths: an empty title falls back to the branch name on a new MR and leaves an existing MR's title alone, and an empty description is never sent. Wiping an MR body is not something this flag can do, on purpose.
With nothing to create, nothing to update, and nothing skipped, prints nothing to publish (no branches to create or update) and exits 0.
Exit codes
0: every attempted branch succeeded (including the no-op "nothing to publish" case, and a run that only skipped branches).1: a hard refusal (--mr-metamalformed, a lease held, no forge token,--stackunresolved, or the pre-walk read of the existing MRs failing), or completed with at least one failed branch. Check the per-branchsuccessfield under--jsonrather than assuming nothing happened: branches before a failed create are already pushed and already have MRs, and a failed update does not stop the branches after it. See Exit codes for the shape this shares withsyncandreparent.
Never exits 2.
See also
- Publish a stack: the full walkthrough, verified line by line against source, including the token lookup and
--mr-metashape. - Drive gitq with agents: the human gate that sits in front of this command in the
gitq:publishskill, not in the CLI itself. gitq import: the reverse direction.