Skip to content
| Marketplace
Sign in
Visual Studio Code>SCM Providers>RestackNew to Visual Studio Code? Get it now.
Restack

Restack

Felix Zhang

|
3 installs
| (0) | Free
Drag to reorder GitHub stacked PRs. Shows the exact git rebase plan before running it, and can undo. Requires the gh CLI with gh-stack.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

Restack

Visualize GitHub stacked pull requests in VS Code or Cursor, drag branches into a new order, and see exactly which git commands that reorder would require.

Restack shows you the plan before anything runs, then runs it on your say-so. Nothing executes until you confirm, and the local half is reversible.

Requires the gh CLI with gh-stack: gh extension install github/gh-stack. Restack reads and writes gh-stack's state — it is a UI for it, not a replacement. See Requirements.

Restack showing a stack, the branch tray, and the generated plan

What stacking is, and what this does

A stack is a chain of branches where each one is based on the one below it rather than on trunk, so a large change ships as a series of small, reviewable PRs. feat/auth → feat/api → feat/ui: each PR's diff shows only its own work, and each targets its parent instead of main.

The cost is that the chain is fragile. Reordering it, inserting a branch, or pulling one out means rebasing every branch above the change — each onto a parent whose commits have just been rewritten. Do it by hand and one wrong upstream argument silently folds another branch's commits into yours, with no error and nothing to warn you.

Restack does that arithmetic. Drag to reorder, and it shows the exact git rebase --onto sequence before running anything.

Why

gh stack modify can reorder a stack, but it is TUI-only — its only flags are --abort and --continue, so there is no way to hand it a plan headlessly. Restack computes the plan itself and shows it to you before anything runs.

How it works

  1. Reads gh stack view --json in the workspace folder. No stack there yet? See Initializing a stack.
  2. Renders the stack top-down (trunk at the bottom), matching gh stack view.
  3. Dragging reorders a local copy only — nothing touches git.
  4. Recomputes the plan on every drop.
  5. Apply runs the plan, pausing on conflicts.

Initializing a stack

On a repo with no stack, the panel is a builder rather than a dead end: pick a trunk, drag local branches in bottom-first, optionally type the name of a branch that does not exist yet, and Initialize stack runs the gh stack init --base <trunk> … shown right above the button.

Restack's create-a-stack view: two dragged branches, one typed, and the gh stack init command they produce

Two things about gh stack init are worth knowing, because both are visible afterwards:

  • Adopting does not rebase. Branches that already exist keep sitting wherever they were; init only records the order. gh-stack then flags them needsRebase, and Restack shows a banner naming them with a Rebase stack button. That runs the same plan-then-apply path as a reorder — steps on screen first, conflicts pause, undo available.
  • It checks out the top branch, after writing .git/gh-stack. A dirty tree that blocks that checkout leaves a stack half-created, so Restack refuses to run init at all until the tree is clean.

If stacks exist but the current branch is not in one, the panel lists them with a Check out button above the builder — that is usually what you wanted, not a second stack. Once you are in one, the same list is a step away in the switcher: see Several stacks in one repository.

Working against another branch

A stack does not have to sit on the default branch. gh stack init --base takes any branch, and the case it exists for is building on top of a colleague's work before it merges: their branch becomes your trunk, your bottom PR targets it, and the whole stack lands after theirs does.

The Trunk dropdown lists local branches and, in a second group, remote-tracking ones. A colleague's branch usually only exists as origin/their-work, so picking it creates a local branch tracking it first — gh-stack records a trunk by name and needs it to resolve locally. That happens before gh stack init, and a failure aborts the whole thing rather than recording a trunk that is not there.

Picking a remote branch also fetches first, and the reason is the second time you do it. By then their-work already exists locally, sitting at the commit it was created at, while its owner has pushed twice since — so creating it is right once and wrong every time after. Restack fast-forwards it instead, which cannot lose anything, and says by how many commits in the output channel. If the local copy has commits of its own it is neither adopted nor rewritten: someone else's branch is not ours to rebase, so Restack names the drift and stops.

Change base… on the trunk row moves an existing stack, which is the other half of the same story: once their branch merges into main, the stack should sit on main. It opens a picker of local and remote branches, then replays the bottom branch onto the new base and cascades everything above it — an ordinary apply, with the plan on screen first, conflicts pausing, and undo available. Picking a remote branch goes through the same fetch-and-catch-up as init; picking a local one does not, so if it has fallen behind its own upstream the confirmation says so before you commit to it, rather than silently landing the stack on an old commit. That one is a note and not a refusal — basing on an older commit deliberately is legitimate, and Sync stack is the fix if it was not. The metadata write records the new trunk, which is what makes the next gh stack submit retarget the bottom PR. Restack says so in the confirmation, naming the PR, because that retarget is the part that reaches GitHub.

Nothing about this is special-cased in the rebase arithmetic. Changing the base is {...stack, trunk: newBase} handed to the same computePlan; the bottom branch's recorded base still anchors its replay, so it takes its own commits and not the new base's. See the recorded-SHA detail.

Knowing where you are, and moving

The row you are standing on carries a HEAD pill and a filled node, and a line above the columns says it in words: You are on feat/api — 2nd of 3 in the stack. Trunk is a position like any other — gh stack view still reports the whole stack from there — so standing on main marks the trunk row rather than falling back to the empty state. Standing on a branch gh-stack does not track is the one case shown as a warning, because nothing you drag will move where you are.

To move somewhere else, take whichever route is nearest:

  • the ⇣ button that appears on a row when the pointer is over it, or when it takes keyboard focus;
  • the status bar item ($(git-branch) feat/api 2/3), which opens the picker;
  • Restack: Check Out Branch in Stack, listing the stack top-down with trunk last and the current branch ticked;
  • double-clicking a row in the Proposed column, as before.

All four refuse for the same reasons: a dirty worktree, an apply in flight, or a rebase already in progress — checking out mid-rebase abandons it.

Adding and removing branches

Below the stack, Available lists the repo's other local branches — every refs/heads entry that is not trunk, not already stacked, and not fully merged into trunk. Drag one onto the stack at any position to insert it; drag a stacked branch down into the tray to take it out.

Under the tray, Add on top is the other way in, and covers what dragging cannot: a branch that does not exist yet. It runs gh stack add <name>, which creates the branch if the name is new and adopts it if it is not, then checks it out. Top-only, because gh-stack is — anywhere else it exits 5 with can only add branches to the top of the stack — so Restack checks the top branch out first rather than passing that refusal on. To put a branch further down, drag it in.

Two consequences worth knowing. Adding is not an apply: no commit is rewritten, so there is no plan to preview and nothing to undo. And an adopted branch arrives exactly as gh stack init leaves one — recorded in order but not rebased onto its new parent — so gh-stack flags it needsRebase and the drift banner's Rebase stack button is the step that replays it, with the usual snapshot behind it.

The dirty-tree refusal here is Restack's, not gh-stack's: gh stack add will happily run with uncommitted changes, but the checkout to the top of the stack in front of it will not.

A branch gh-stack has never seen has no recorded base, so Restack uses git merge-base <branch> <trunk> as the anchor. That is the same kind of value as gh-stack's own base — a pre-rebase SHA — so an inserted branch replays exactly like a stacked one, and the recorded-SHA rule below applies unchanged.

Un-stacking rebases the branch back onto trunk rather than discarding anything: its commits survive, it just stops being part of the stack. If it already has a PR, the panel says so — that PR keeps pointing at a base that is no longer its parent, and gh stack submit will not retarget a branch it no longer tracks.

Removing the whole stack

Remove stack in the toolbar is the counterpart to init: it runs gh stack unstack on the stack you are standing in. Worth separating from the paragraph above, because the two are not the same kind of operation. Dragging one branch out is a rebase — real commits are rewritten, and it goes through the usual plan-then-apply path with a snapshot behind it. Removing the stack rewrites nothing. Every branch stays on the commit it is on; gh-stack simply stops recording them as a stack, and the panel falls back to the builder.

Like apply, it is split by how far it reaches:

  • Remove Locally runs gh stack unstack --local, which touches only .git/gh-stack. Any pull requests stay stacked on GitHub.
  • Remove & Unstack PRs runs the full gh stack unstack, which also detaches the PRs on GitHub. Restack cannot undo that, so it is only offered when the stack actually has PRs and an origin to reach them through.

One gh-stack behaviour to know about: GitHub refuses to unstack PRs that are queued for merge or have auto-merge enabled. When that happens the whole stack is kept — local tracking included — even though the command exits successfully. So Restack re-reads the stack afterwards rather than trusting the exit code, and says plainly when the stack is still there.

To remove a different stack, switch to it first: gh stack unstack with no argument targets the active one. See Several stacks in one repository.

Several stacks in one repository

A repository can hold as many stacks as you like. gh-stack models this outright — .git/gh-stack is an array, and gh stack checkout takes a stack number — but only one is active at a time, and which one is decided by where HEAD is. gh stack view reports the stack the current branch belongs to and nothing else.

Restack's stack switcher: three stacks in one repository, with PR badges and the active one marked

The switcher above the toolbar is where they all live. It stays collapsed to a single line (Stack 2 of 3 · on main) and disappears entirely when there is only one stack, so a one-stack repository is unchanged. Expanded, each row is a stack: its number, its branches top-down with the trunk last, a PR badge per branch, and ↓N when the remote has commits that would block a rewrite.

Switching is a checkout, not a display mode. Restack checks out that stack's top branch — the one branch guaranteed to belong to it and no other, since a stack's trunk is routinely another stack's branch — and gh stack view then reports it in full. That means one render path rather than two, and everything below the switcher behaves identically whichever stack you are in. It refuses for the same reasons every other checkout does: a dirty worktree, an apply in flight, or a rebase already in progress.

+ New stack creates another one. gh stack init refuses while HEAD is part of a stack, so Restack asks first and then checks out the trunk — the existing stack is untouched, and the builder appears with the trunk's other branches available. Branches already claimed by any stack are kept out of the tray, because a branch in two stacks is the ambiguity gh-stack refuses outright.

Restack: Switch Stack and Restack: New Stack do the same from the command palette.

Standing on a trunk shared by several stacks is its own position: gh-stack has no single stack to report, so Restack shows the switcher rather than an error.

Applying

Apply is split in two, and the halves are not equally recoverable.

The local half is the rebases plus the .git/gh-stack write. Before the first rebase, Restack records every branch SHA and the verbatim bytes of the metadata file; Undo restores both. It refuses to start over a dirty worktree, mid-rebase, or on a stack with merged branches.

The remote half is gh stack push and gh stack submit --auto. It takes its own confirmation even if you chose "Apply & Publish" up front, and once it runs, Undo is withdrawn rather than offered and quietly useless. Without an origin remote that button is not offered at all — preflight refuses the whole apply on an impossible scope, so offering it would cost you the local reorder too.

gh stack push rather than a hand-rolled git push --force-with-lease: it does the per-branch lease itself and skips merged and queued branches, rules that would otherwise have to be reproduced here and would drift as gh-stack changes them. It reads its branch list from .git/gh-stack, so it has to run after the metadata write. Pushing before submitting is not redundant even though submit pushes too — a rejected lease surfaces before any PR base is retargeted.

On a conflict the rebase pauses in place. Resolve the listed files, stage them, and hit Continue; or Abort to roll everything back. See Resolving a conflict.

Resolving a conflict

Each conflicted file gets a Resolve button that opens VS Code's own three-way merge editor — git.openMergeEditor, the same command the SCM view offers, which handles the rebase case by diffing REBASE_HEAD against HEAD. Its Complete Merge stages the file, which is exactly what Continue needs. The path itself stays a plain-text link for when the merge editor is not what you want, markers and all.

While paused, Restack watches .git/index and re-reads the unmerged paths on every write, so files flip to ✓ staged as you resolve them and Continue stays disabled until none are left. Watching the index rather than the merge editor is what makes git add in a terminal and the ➕ in the SCM view work identically — whatever stages the file is what the panel reacts to.

Resolved files stay in the list rather than disappearing: a list that shrank as you went would erase the record of what you had already done. And the disabled button is a hint, not the guard — Continue re-reads the index host-side before running anything.

Publishing without a reorder

Push & Submit is also a toolbar button and a command (Restack: Push & Submit Stack), independent of any apply session. Reorder and dismiss the panel, reload the window, land the reorder some other way — the branches are still sitting there rebased and unpushed, and this is the route to origin. It runs the same two steps through the same progress UI, with Undo correctly unavailable: there is no local snapshot behind it because nothing local was rewritten.

Remote state

Restack showing the trunk 3 commits behind its upstream, the sync banner, and per-branch ahead/behind pills

Every count Restack shows about the remote is read from refs/remotes and the branch config — one git for-each-ref, no network. That is what makes it safe to re-read on every .git/HEAD change, and it is also the catch: those refs are only as fresh as your last fetch. Fetch in the toolbar is the control that goes and asks (git fetch --prune <remote>), and its tooltip says how long ago that last happened. Nothing polls on a timer — a network call on a schedule is not something an editor panel should be doing on your behalf. The only other call that leaves the machine unasked is the one GitHub read described in Stacks that live on GitHub, which happens once when the view first loads and again on Fetch.

--prune is not incidental. Without it, a branch deleted on the remote keeps a stale refs/remotes entry and reads as level forever, so gone would never appear.

Rows carry a small pill for where they stand: ↑2 ahead, ↓3 behind, ↑2 ↓1 diverged, plus unpushed and gone — those two spelled out because an absence of counts is otherwise indistinguishable from being level. A branch that matches its upstream gets nothing at all; a row of in sync badges would bury the two states that matter. The trunk row carries the same pill, so "3 behind origin/main" is visible without opening a plan. The Proposed column does not, because it describes a future arrangement and these counts are about the present.

When the trunk is behind, a banner offers Sync stack. It fetches first — always, because a plan built from stale refs would fast-forward the trunk to a commit that is no longer its tip — then re-reads the stack and builds an ordinary plan: a fast-forward step, then the whole cascade replayed on top, then the metadata write. The fast-forward has two forms, because git does. With the trunk not checked out it is git fetch <remote> <trunk>:<trunk>, which git refuses unless it really is a fast-forward; standing on the trunk it is git merge --ff-only <remote>/<trunk>, because git will not fetch into a branch you have checked out. Everything after that is a normal apply — snapshot, conflict pause, undo — and nothing is pushed.

The other banner blocks. If a stack branch is behind its upstream, somebody pushed commits to your branch that you do not have, and preflight refuses the apply outright:

feat/api is 2 behind origin/feat/api. Fetch and sync before rewriting — gh stack push force-pushes with a lease against a stale remote ref, which would drop those commits.

--force-with-lease is not protection here. It compares against the remote-tracking ref, which is the stale one; the lease passes and the commits go. Restack has no safe automatic answer either, since both sides have moved — so it names the branches and stops, and the banner renders above the sync one and disables its button, so the UI never offers an action preflight will reject. gone is excluded from all of this on purpose: a branch deleted after its PR merged has nothing left to clobber, and refusing there would block every stack that had ever landed anything.

Stacks that live on GitHub

Restack showing GitHub stack numbers on two switcher rows, a drift warning, a stack that exists only on the remote, and a PR base badge in the Current column

Everything above is local-first by construction, and that leaves a blind spot. gh stack view reports the stack HEAD is standing in; .git/gh-stack records the stacks this clone knows about. A stack that exists only on the server is invisible to both — a colleague's, your own from another laptop, or a PR someone appended to your stack on GitHub while you were working.

GitHub's GraphQL API now models stacks natively (PullRequest.stack, with number, size, baseRefName, and its ordered entries), so Restack asks for them in the same call that fetches PR badges. That call already existed and was already authenticated; it went from gh pr list --json to gh api graphql, so this costs one request, not two. Three things come out of it:

  • A ⧉14025 badge on a switcher row whose PRs all report the same GitHub stack. When they report two, the badge is omitted rather than guessed at — the same ambiguity gh-stack refuses locally with belongs to multiple stacks.
  • A +1 on GitHub warning when that stack has an open PR this clone has no branch for. The tooltip names the branches and points at gh stack sync, which is the command that reconciles it. The reverse — a local branch not yet submitted — is ordinary and gets no warning. Merged and closed entries count as neither; the normal end of a stack's life is not drift.
  • An "On GitHub only" list, for stacks sharing no branch with anything here, with a Check out button running gh stack checkout <pr>. Fully-merged stacks are dropped, and the button targets the bottom-most open PR, since a merged one may have had its branch deleted. It renders nothing when the list is empty, so a solo repository sees no new chrome.

Per-branch, the Current column gains a PR base: X badge when a PR targets something other than the row beneath it. gh-stack records a base SHA locally and never reads the PR's own baseRefName, so a base retargeted on the server leaves the local view complete and wrong.

The freshness rule is the one above: read once when the view first loads, then on Fetch and on a stack switch, and cached in between so the .git/HEAD watcher stays network-free. The first read is deferred and not awaited — it lands after first paint rather than delaying it.

Set restack.readRemoteStacks: false to skip the whole path. Restack then falls back to the gh pr list call it always made, so the PR badges stay and only the three surfaces above disappear. The same fallback happens automatically on a GitHub Enterprise Server old enough to have no stack field — the API answers undefinedField, which Restack reads as "ask the old way" rather than as an error.

Why Restack writes .git/gh-stack

Rebasing moves branch refs but leaves gh-stack's own state file alone. Verified against v0.1.0: after reordering a stack by hand, gh stack view still printed the old order with a drift marker, and the recorded base SHAs were unchanged. Since gh stack submit retargets PR bases from that file, submitting on top of it would point PRs at the wrong parents.

So Restack rewrites it — the reordered branch list, and each branch's new base resolved after the rebases. Writing another tool's pre-1.0 data is a real liability, so: the schemaVersion is checked and an unfamiliar one aborts, every unrecognized key is carried through untouched, and the original bytes are restored on abort. See src/metadata.ts.

This is also why the plan shows a #-commented metadata step. If you copy the commands and run them yourself, you inherit the same problem — reorder that file too, or use gh stack modify.

The recorded-SHA detail

Each generated git rebase --onto passes the branch's recorded base SHA as the upstream argument, captured before any rebase runs. gh-stack hands us that SHA directly — its base field is already resolved, not a ref name.

Using a branch name as upstream is what breaks. Moving the bottom branch of auth → api → ui to the top with name-based upstreams produces:

feat/ui: 070c59b feat: add ui components  144f76f feat: add auth layer  1b42e58 feat: add api routes

feat/ui has silently absorbed auth's commit. With recorded SHAs the same reorder yields one commit per branch, correctly. Both outcomes were verified by executing the commands against a real repository; see test/plan.test.ts.

Install

VS Code — search "Restack" in the Extensions view, or:

code --install-extension felixzhang.restack

Cursor — Cursor pulls from Open VSX, not the VS Code Marketplace, so search there or:

cursor --install-extension felixzhang.restack

From a .vsix — for either editor, or for a build that is not published yet. Grab one from Releases, then use Extensions: Install from VSIX… in the command palette, or:

code --install-extension restack-0.1.0.vsix     # or: cursor --install-extension

Requirements

Restack is a UI over the gh stack CLI, not a reimplementation of it. Without these it will tell you what is missing rather than doing anything:

  • gh, authenticated (gh auth login).
  • gh-stack: gh extension install github/gh-stack.
  • A git repository. A stack is not a prerequisite: on a repo without one Restack offers to create it (see Initializing a stack).
  • An origin remote, for Push & Submit only. Everything local works without one, and the publish button is disabled rather than failing.
  • Built against gh-stack v0.1.0. That schema is pre-1.0 and will drift — the parser is deliberately tolerant, but check here after a gh-stack upgrade.

Set restack.ghPath if gh is not on your PATH. It is a machine-scoped setting, so it lives in your user settings and a workspace cannot override it — Restack executes that path on startup, and a repository you have just cloned should not get to choose what runs.

restack.readRemoteStacks (default on) controls the GitHub read described in Stacks that live on GitHub. It needs no scope gh does not already have, and turning it off costs only the badges it feeds.

Development

npm install
npm run build      # or: npm run watch
npm test           # parser, plan, metadata + apply against real temp repos
npm run typecheck
npm run media      # regenerate the icon and every screenshot

Press F5 in VS Code to launch an Extension Development Host.

Every screenshot is rendered from the real webview bundle through test/harness/index.html — a scripted reorder for screenshot.png, drag-and-type for screenshot-init.png, the harness's ?view=behind scene for screenshot-remote.png, which needs no gesture because the state is the subject, ?view=multi with the disclosure clicked open for screenshot-switcher.png, and ?view=github for screenshot-github.png, whose three subjects sit far enough apart on the screen to need a taller frame than the rest. They cannot drift from the UI, because they are the UI.

Sandbox

sandbox/ (gitignored, has its own .git) is a throwaway repo with a three-branch stack, used to capture real CLI output:

mkdir sandbox && cd sandbox && git init -b main
# ...commit three stacked branches...
gh stack init feat/auth feat/api feat/ui
gh stack view --json > ../fixtures/stack-no-prs.json

sandbox-conflict/ (also gitignored) is a second throwaway stack whose three commits all rewrite the same line, so reordering any pair conflicts — that is the one to use when exercising the pause / continue / abort path. Applying mutates it, so rebuild it between runs rather than unpicking the last attempt:

./test/make-conflict-sandbox.sh

sandbox-multi/ (gitignored too) holds two independent stacks off main, plus a branch in neither — the state the switcher exists for, and the one to use when checking that an apply in one stack leaves the other's .git/gh-stack entry alone:

./test/make-multi-stack-sandbox.sh

The first two are wired up in .vscode/launch.json; pick a config from the Run panel.

Browser harness

test/harness/ renders the real webview bundle in Chrome with the VS Code API stubbed, driven by the same parseStack/computePlan the extension host uses, fed by the captured fixture. This is how drag behavior is verified without launching an Extension Development Host.

npm run build && node test/harness/build-driver.mjs
open test/harness/index.html

?view= reaches the states a single fixture cannot be in at once — init, outside, drift, trunk, away, multi, github, and conflict. The github view is the only one carrying data no local command can produce, so it is also the only place the GitHub surfaces are reachable without a remote. The conflict view is interactive rather than a still: clicking Resolve marks the file staged and re-emits after a beat, standing in for the merge editor and the index watcher, so the gating on Continue can be exercised with no repository behind it.

Publishing

Two registries, because Cursor cannot install from the VS Code Marketplace — its terms restrict it to Microsoft products, so Cursor and the other forks pull from Open VSX. Publishing to only one leaves half the audience out, and the same .vsix goes to both.

vscode:prepublish runs typecheck, the test suite, and a production build, so vsce package cannot ship a bundle that does not compile or pass tests.

npm run package                 # -> restack-<version>.vsix, inspect before publishing
npx vsce ls --tree              # exactly what is inside it

One-time setup:

  • Marketplace — create a publisher at marketplace.visualstudio.com/manage, then an Azure DevOps PAT scoped to Marketplace → Manage, all organizations. npx vsce login felixzhang.
  • Open VSX — sign in at open-vsx.org with GitHub, sign the publisher agreement, and create an access token. Export it as OVSX_PAT.

The publisher name in both must match publisher in package.json.

Releasing is two halves. Pushing a v* tag runs .github/workflows/release.yml, which typechecks, tests, packages, and creates the GitHub release with the matching CHANGELOG.md section as its notes and the .vsix attached. Publishing to the registries stays manual, because neither of them lets you unpublish a version — a bad tag can be deleted and re-cut, a bad publish is permanent.

# 1. CHANGELOG.md first: add a `## <version>` section. The workflow reads it,
#    and fails the release if it is missing.
npm version minor               # or patch; bumps package.json and tags
git push --follow-tags          # -> the workflow builds and cuts the release

The workflow refuses to run if the tag and package.json disagree, so a hand-written tag cannot ship a .vsix labelled with a different version.

# 2. Once the release looks right, publish the exact bytes it attached.
gh release download v<version>  # the CI-built .vsix
npm run publish -- restack-<version>.vsix

Publishing the packaged .vsix rather than letting each registry rebuild is deliberate: both then serve bytes that were tested here, and CI's artifact is the same file the release page offers.

To release without the workflow — a local npm run package followed by npm run publish — works the same way; npm run publish defaults to restack-<package.json version>.vsix in the working directory.

Status

Working:

  • Reads and renders the stack, with PR numbers, merged/queued/needs-rebase badges
  • Drag to reorder, with moved rows highlighted
  • Insert an unstacked local branch at any position; drag one out to un-stack it
  • Add a branch on top of an existing stack, created or adopted, via gh stack add
  • Plan generation, verified against real git
  • Distinct UI for: not on a stack, not a git repo, gh missing, parse failure
  • Reordering disabled when the stack has merged branches — gh-stack rejects inserting next to one
  • Executing the plan: rebases, the gh-stack metadata rewrite, push, submit
  • Push & Submit as a standalone toolbar action, with no apply session
  • Conflict pause / continue / abort, with snapshot-based rollback, resolved in VS Code's three-way merge editor and tracked live off .git/index
  • A HEAD indicator showing where you are in the stack, and checkout from a row button, the status bar, or the command palette
  • Many stacks per repository: a switcher listing every one with PR badges, switching between them, and a + New stack button that parks on the trunk first because gh stack init refuses from inside a stack
  • Basing a stack on any branch, local or remote-only — a colleague's work rather than the default branch — and moving an existing stack onto a different base
  • Remote state read from local refs: ahead/behind pills per row, a Fetch button as the only git network call, and Sync stack to fast-forward a trunk that moved
  • Stacks read from GitHub itself, via the GraphQL PullRequest.stack field: a stack-number badge and a drift warning on stacks you already have, a checkout-able list of stacks that exist only on the remote, and a PR base badge when a PR targets something other than the row beneath it
  • Dirty-worktree and mid-rebase refusal before anything runs
  • Refusal to rewrite a branch that is behind its upstream, which gh stack push would then force-push over
  • Apply state persisted to workspaceState, so a window reload mid-apply comes back with Continue/Abort/Undo live
  • Clickable PR links and conflicted files; Alt+↑/Alt+↓ to reorder a focused row, double-click to check a branch out
  • A Restack output channel logging every command, exit code, and full stderr

Not built yet:

  • No stash offer on a dirty worktree — Restack refuses and leaves it to you.
  • Multi-root workspace support (reads the first workspace folder only).
  • A stack branch that is behind its upstream is refused, not reconciled. Pulling it is left to you: once both sides have moved there is no safe automatic answer, and guessing one is how commits go missing.
  • Nothing fetches on a schedule. The ahead/behind counts are as old as your last Fetch, which the tooltip tells you, and the GitHub stack data is as old as the last time the view loaded or you pressed Fetch.
  • Remote stacks are read, not written. Restack can check one out, but reordering or appending to a stack still happens locally and reaches GitHub through Push & Submit.
  • Push and submit are verified by unit tests and by hand, not end to end in CI — that needs a throwaway GitHub repo.

Upstream ask

If gh stack modify gained a headless --plan <json> mode, the entire execution layer would collapse into one CLI call. The binary already carries an internal plan model (BuildPlan/ApplyPlan/StateFile). Worth requesting via gh stack feedback.

License

MIT

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
© 2026 Microsoft