stack-submit
Use when you need to push, submit, or create pull requests for a commit stack. Rebases only the current stack onto upstream main, validates, and pushes INSTEAD of manual git sync + git submit + PR/MR creation. Handles branch creation, stacked PR/MR creation with correct base branches.
- License 许可
- Unlicense
- 最近更新
- 2026-11-11
- Stars
- ★ 2
- Forks
- 0
01 前言 Front Matter
- name
- stack-submit
- description
- Use when you need to push, submit, or create pull requests for a commit stack. Rebases only the current stack onto upstream main, validates, and pushes INSTEAD of manual git sync + git submit + PR/MR creation. Handles branch creation, stacked PR/MR creation with correct base branches.
- argument-hint
- [revset]
- compatibility
- Requires git-branchless
- disable-model-invocation
- false
02 资源文件 1
- 参考文档references/git-branchless.md37 B
03 SKILL.md 正文
Submit this worktree's commit stack for review. If an argument is provided, use
it as a revset to select which commits to submit. Default is this worktree's own
stack ($STACK, pre-flight step 5).
Pre-flight
-
Load references — read
references/git-branchless.md(relative to this skill's directory) before proceeding. -
Confirm repository initialization. A devenv project with
git.branchless.enablerunsgit branchless initon every shell entry. Anywhere else, check and stop if it is missing:git config --get branchless.core.mainBranch >/dev/null || echo "not initialized: ask the user to run git branchless init"Do not run
git branchless inityourself: it rewrites the repository's shared configuration and hooks for every worktree. -
Require a clean working tree:
test -z "$(git status --porcelain --untracked-files=no)" || echo "dirty: commit first"The rebase below carries uncommitted edits across and could strand the checkout on a conflict, so if tracked files have changes, stop and ask the user to commit them (see Don't use
git stashinreferences/git-branchless.md). Untracked files do not affect the move. -
Check for stale rebase state:
ls -d "$(git rev-parse --git-path rebase-merge)" \ "$(git rev-parse --git-path rebase-apply)" 2>/dev/nullIf either exists, run
git rebase --abortbefore proceeding. Ask Git for the paths: in a linked worktree.gitis a file, and an on-disk rebase keeps its state in that worktree's own git dir. -
Derive and fetch the upstream ref, and select this worktree's stack. If
git remoteprints nothing, ask the user for the remote URL and add it first. The upstream is the main branch's upstream, falling back to<remote>/<main>:main_branch="$(git config branchless.core.mainBranch || echo main)" remote="$(git config "branch.${main_branch}.remote" || echo origin)" if [ "$remote" = . ]; then # main tracks a local branch: that branch is the upstream; nothing to fetch upstream="$(git rev-parse --abbrev-ref "${main_branch}@{upstream}")" else upstream="$(git rev-parse --abbrev-ref "${main_branch}@{upstream}" 2>/dev/null \ || echo "${remote}/${main_branch}")" git fetch "$remote" # updates $upstream; no local branch moves fi if git rev-parse --verify -q "${upstream}^{commit}" >/dev/null; then base="$upstream" else upstream="" # first push: the remote has no main branch yet base="$main_branch" fi STACK="descendants(roots((stack() & ::HEAD) - ::$base)) - ::$base"$STACKis this worktree's own stack: HEAD's first commit past$baseand everything descended from it. Every later revset in this skill uses it instead ofstack(). Localmainis never updated here, so while it is behind$upstreamthe upstream commits count as draft, andstack()then holds every stack that another worktree branched from them. See Selecting your own stack inreferences/git-branchless.md.When
$upstreamdoes not resolve (a first push to a remote that has no main branch yet), there is nothing to rebase onto.$basefalls back to localmain, and step 3 is skipped.$remoteis also where the push steps below push. If it is.(main tracks a local branch), ask the user which remote to push to and setremoteto it.In a new shell, re-derive these variables and redo step 2's selection and checks. Preserve any lease snapshot captured before a rewrite; fetching again never authorizes refreshing that snapshot.
-
Capture pinned leases before rewriting, including step 3's initial move. Run this block in the same Bash shell as the later push calls:
set -euETo pipefail shopt -s inherit_errexit 2>/dev/null || : lease_remote="$remote" lease_heads="$(git ls-remote --heads "$lease_remote")" || exit 1 push_selected() { : "${lease_remote:?capture remote leases before rewriting}" : "${lease_heads?capture remote leases before rewriting}" local b branches expected local -a leases=() refs=() branches="$(git query --branches "$SELECTED")" || return 1 test -n "$branches" || { echo "no selected branches" >&2; return 1; } for b in $branches; do expected="$(printf '%s\n' "$lease_heads" | awk -v ref="refs/heads/$b" '$2 == ref { print $1 }')" leases+=("--force-with-lease=refs/heads/$b:$expected") refs+=("refs/heads/$b:refs/heads/$b") done git push "$@" --set-upstream "${leases[@]}" "$lease_remote" "${refs[@]}" }This records the remote's branch tips before any rewrite. A branch absent from that snapshot gets an empty expected SHA: its lease requires that the remote branch still does not exist. This also covers branches named in step 6 after the move. A failed remote read stops the workflow; it must never be treated as an empty remote.
Keep
lease_heads,lease_remoteand the function in this shell until the push finishes. Do not recapture after a rewrite or fetch, including when resuming in a new shell: without the original snapshot, stop and recover it before pushing. For a later operation, capture a fresh snapshot before that operation's first rewrite. The push uses the captured remote and explicit branch refs, independent of tracking orpush.default.The packaged
git submithas no expected-SHA option. Its branch forge fetches before pushing with bare--force-with-lease; evengit submit -ccan update existing branches that way. See thegit submitentry inreferences/git-branchless.mdfor the source evidence. Usepush_selectedfor both new branches and updates throughout this skill.
Steps
-
Visualize the stack with
git slto understand what will be submitted. -
Determine and check the commit range from
$ARGUMENTS:# If $ARGUMENTS is empty, this worktree's stack: SELECTED="$STACK" # If $ARGUMENTS is a revset (use git sl or git branchless log, not git log). # current() follows a commit hash to its rewritten copy after step 3's move: SELECTED="current($ARGUMENTS) - ::$base" : "${STACK:?run pre-flight step 5 first}" selected_commits="$(git query -r "$SELECTED")" || exit 1 test -n "$selected_commits" || { echo "empty selection" >&2; exit 1; } stack_commits="$(git query -r "$STACK")" || exit 1 test -n "$stack_commits" || { echo "empty stack selection" >&2; exit 1; } outside="$(git query -r "only($SELECTED, $base) - ($STACK)")" || exit 1 test -z "$outside" || { printf '%s\n' "$outside"; exit 1; } current_worktree="$(git rev-parse --show-toplevel)" || exit 1 other_heads="$(git worktree list --porcelain | awk -v current="$current_worktree" ' $1 == "worktree" { if (path != "" && path != current && head != "") print head path = substr($0, 10); head = "" } $1 == "HEAD" { head = $2 } END { if (path != "" && path != current && head != "") print head } ')" || exit 1 overlap="" for other_head in $other_heads; do for stack_commit in $stack_commits; do if git merge-base --is-ancestor "$stack_commit" "$other_head"; then overlap="${overlap}${overlap:+ }${other_head}" fi done done test -z "$overlap" || { printf '%s\n' "$overlap"; exit 1; } merges="$(git query -r "merges() & ($STACK)")" || exit 1 test -z "$merges" || { printf '%s\n' "$merges"; exit 1; }- Empty selection: stop and ask which commits to submit. HEAD has no
commits of its own past
$base: you are onmain, in a fresh worktree, or everything selected is already upstream. Do not fall back todraft()orstack(): in a repository with several worktrees both select other worktrees' stacks. (A first push of a repository whose whole history is onmainalso lands here; use step 8's explicitSELECTEDand pinnedpush_selectedpath.) - The second query prints commits: stop, show those commits, and ask.
Either the revset reaches past this worktree's stack (
draft()does, for example), or localmainhas commits that are not on$upstreamand the stack is built on them. The move in step 3 would carry those commits along. - The worktree check prints a commit: another worktree, including a detached one, has its HEAD inside this selection. Stop and ask: step 3 would rewrite or abandon the commit under that worktree.
- The merge query prints a commit: the stack contains a merge commit, for
example from merging
origin/maininto it. Stop and tell the user this skill does not submit a stack with a merge in it. Step 3 would move only the commits below the merge and leave the merge and everything above it, HEAD included, on the old base, and it still exits 0.
- Empty selection: stop and ask which commits to submit. HEAD has no
commits of its own past
-
Rebase the stack onto the latest upstream. Pre-flight step 5 already fetched and step 6 captured the leases, so move only the selected stack onto the remote-tracking ref:
git move -b "$SELECTED" -d "$upstream" || exit 1 selected_commits="$(git query -r "$SELECTED")" || exit 1 test -n "$selected_commits" || { echo "selection became empty" >&2; exit 1; }-baccepts a revset and moves each selected commit's whole lineage from where it leaves$upstream, so commits already upstream are never re-moved. That holds for a stack without merge commits, which step 2 checked. If$upstreamhas not moved, this is a no-op — that's fine. On a first push ($upstreamempty), skip this step. If the selection is empty after the move, everything selected was already upstream: stop and say so instead of testing and submitting nothing.This skill does not use branchless sync because it targets local
mainand can span other draft stacks. If the guarded move reports a conflict, it exits 1 and changes nothing. Stop and ask the user; do not start an on-disk repair. -
Run tests across the stack to validate each commit independently. This is the one case where testing every commit is unambiguously right: every commit here is about to become its own PR, so every commit has to stand alone.
git test run -x '<test-command>' --jobs <N> "$SELECTED"Size
<N>by memory, not cores — see Sizing--jobsinreferences/git-branchless.md. Each job is a separate worktree with no shared cache, so peak usage isjobs x per-job footprint. Never pass--jobs 0— it means one job per physical CPU and overrides any bound the machine's config set, which for a multi-GB command such asnix flake checkexhausts RAM.Determine the test command from the project (package.json scripts, Makefile, Cargo.toml, etc.). If no obvious test command exists, ask the user. If the project has no tests, skip this step.
If any commit fails, stop and report which commit(s) failed. Do not submit a stack with failing tests.
-
Verify commit messages — review
git sloutput. Flag any commits with vague messages ("fix", "WIP", "update") and ask the user before rewriting them. -
Ensure branches exist on each commit. For commits without branches, generate branch names from commit messages:
# Pattern: type/scope or type/short-description # feat(flake): add minimal flake skeleton → feat/flake-skeleton # docs(references): add git-branchless reference → docs/git-branchless-reference # chore: add TODO.md → chore/add-todoCreate branches:
git branch <branch-name> <commit-hash>Present the branch list to the user for review before proceeding. Every local branch on a selected commit is pushed, including incidental review or worktree branches. Step 7's dry-run preview shows each remote ref that will be published; delete or move an unwanted local branch before publishing.
The pinned push sets upstream tracking to each branch's own remote branch, including branches that initially track
main. -
Confirm before pushing — preview what will be pushed:
set -euETo pipefail shopt -s inherit_errexit 2>/dev/null || : push_selected --dry-runShow the dry-run output to the user and ask for confirmation:
Ready to push N branches and create N PRs. Proceed? (y/n)Wait for explicit user approval. Pushing creates remote branches and PRs — these are visible side effects that cannot be easily undone. This gate is especially important when the skill is auto-invoked via
disable-model-invocation: false. -
Push branches with the captured leases:
set -euETo pipefail shopt -s inherit_errexit 2>/dev/null || : push_selectedThis creates absent branches and updates existing ones in the same push. If any lease is rejected, stop and inspect the remote change with the user; do not refresh the snapshot and retry or replace the command with
git submit. A dry-run does not reserve the remote refs, so the real push must use the same leases.For a first push whose entire history is on local
main, step 2 reports an empty selection. After agreeing which branches to publish, setSELECTEDto a revset union of their explicit names (separated by|, not spaces):SELECTED='feat/a|feat/b'Capture their remote snapshot with pre-flight step 6 before any rewrite, skip step 3, and use the same preview and push in steps 7 and 8. This path publishes public commits without rerunning step 2's empty-stack guard; there is no unpinned fallback.
-
Create stacked PRs/MRs — for each branch, create a pull/merge request targeting the previous branch in the stack (or
mainfor the first). Use the commit message as the title. Keep the body minimal — the commit diff speaks for itself.
GitHub
# First commit after main:
gh pr create --head <branch-1> --base main \
--title "<commit message>" --body "$(cat <<'EOF'
## Summary
<one-line description>
Stack: 1/N
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
# Subsequent commits:
gh pr create --head <branch-N> --base <branch-N-1> \
--title "<commit message>" --body "..."
-
Report results — show a summary table:
| # | Branch | PR/MR | Base | Status | |---|--------|----|------|--------| | 1 | feat/flake-skeleton | #2 | main | created | | 2 | chore/formatting | #3 | feat/flake-skeleton | created | | ... | ... | ... | ... | ... | | 25 | todo/pre-publish | — | (tracking only) | pushed |
Subsequent Updates
Pinned force-push
Before any amend, fix, restack or move, run pre-flight step 6 to capture the
remote tips and define push_selected. This applies to the initial step 3 move
as well as later updates. After the rewrite, re-derive $SELECTED using
pre-flight step 5 and step 2's selection and checks, keeping the original lease
snapshot. Preview with push_selected --dry-run, then push with push_selected
after the required approval.
Never push a base branch tip containing a still-open stacked PR's head. GitHub then marks that PR merged, and deletes its head branch when the repository deletes branches on merge.
Never refresh the expected tips after the rewrite. Bare --force-with-lease
compares against remote-tracking refs, which any fetch (including another
worktree's fetch) can update. The explicit SHA captured before rewriting still
rejects a competing push after such a fetch. An empty expected SHA requires an
absent branch, so a competing branch creation is rejected too.
PRs/MRs auto-update when their branches are force-pushed. No need to recreate them. Only branches downstream of the changed commit need updating, but pushing all is safe — unchanged branches are skipped automatically.
Before merging
Before the user or agent merges any PR/MR, find every open PR whose base is the merging PR's head branch:
gh pr list --base <branch> --state open --limit 1000 --json number
Here <branch> is the merging PR's head branch. Retarget each dependent PR to
the merging PR's current base BEFORE merging:
gh pr edit <dependent-PR-number> --base <current-base-branch>
For the bottom PR, the current base is main: use
gh pr edit <dependent-PR-number> --base main. For an out-of-order merge of PR
N+1 into branch N, use branch N instead. Confirm every affected PR is still open
and targets that current base:
gh pr view <dependent-PR-number> --json state,baseRefName
If any retarget or confirmation fails, stop before merging. Repeat this before every PR/MR merge. On GitLab or another forge, use its target-branch controls to move every open dependent MR/PR to the merging MR/PR's current base and confirm each remains open and targets that base before merging.
On GitHub, an observed merge that deleted the base branch recorded a
BaseRefDeletedEvent, followed by an AutomaticBaseChangeSucceededEvent (base
moved to main) and a ClosedEvent on the dependent PR. The retarget and close
happened in the same second, right after the deletion. That PR ended CLOSED with
state reason COMPLETED even though its base had moved to main. Do not rely on
automatic retargeting to keep dependent PRs open.
After each squash merge
After each squash-merged PR/MR, you MUST move the remaining stack onto the new upstream, confirm the next PR/MR's base, and force-push the remaining branches. Without this, downstream PRs/MRs show the full diff of all prior commits (the squash merge creates a new commit hash that downstream branches don't share). Do it after EVERY squash merge, not just the first: each merge changes main.
Move only the remaining commits with git move -s, not -b. -b moves the
whole lineage of the selection from where it leaves $upstream, so it drags the
merged (now squashed) commits along; -s <first remaining> moves exactly that
commit and its descendants. Never run a bare git restack here: it selects
other worktrees' stacks and stale branches repo-wide (see Rewriting in
references/git-branchless.md).
-
Fetch and detach review checkouts. Re-run pre-flight step 5 (it fetches). A worktree you own that has a stack commit checked out (for example a review checkout) would be rewritten under you, and step 2's worktree check refuses it:
git -C <path> switch --detach "$upstream". For a worktree you do not own, stop and ask. -
Select the remaining commits.
$STACKstill holds the merged commits, because the squash created a new commit upstream. Name the first commit whose PR/MR is still open, select it and its descendants, then re-run step 2's checks on that selection:first="<first remaining commit>" SELECTED="descendants($first) - ::$base" git query -r "$SELECTED" # must list only the unmerged commits tip="$(git query -r "heads($SELECTED)")" [ "$tip" = "$(git rev-parse HEAD)" ] || { echo "check out the tip: $tip"; exit 1; } old_base="$(git rev-parse "${first}^")" old_tip="$(git rev-parse HEAD)"Before the move, capture the lease for every remaining branch (Pinned force-push).
If every PR/MR in the stack merged, nothing remains to move: skip the rest of this recipe and tear the worktree down instead of submitting again.
-
Push a backup of the tip to the remote before rewriting, under a name of its own so an earlier rewrite's backup is never overwritten, for example
git push "$remote" "HEAD:refs/heads/archive/pre-move-$(git rev-parse --short HEAD)". A local tag or branch is not a safe backup. -
Move.
git move --dry-runonly tests the in-memory attempt, so use it as the preview and then run the real move:git move --dry-run -s "$first" -d "$upstream" || exit 1 git move -s "$first" -d "$upstream" || exit 1If it reports a conflict it exits 1 and changes nothing. Stop and ask the user.
-
Verify nothing else changed.
git range-diffmust show every remaining patch unchanged (a=row for each commit):git range-diff "$old_base..$old_tip" "$upstream..HEAD" git sl -
Force-push with pinned leases. Push every remaining branch with the lease step 2 captured (Pinned force-push). Do not fetch to refresh the lease: a fetch is what makes the bare form pass.
-
Confirm the next PR/MR is open and targets main. Retargeting had to happen before the merge (see Before merging); a base of
mainalone does not prove the PR/MR stayed open. If a dependent PR/MR closed unexpectedly, stop and report it before continuing.
Because -s starts at the first remaining commit, the move never has to skip an
already-merged patch, so a multi-commit squash needs no special handling. If a
remaining commit's edits overlap the new upstream, the move conflicts (exit 1,
nothing changed): stop and ask rather than hiding the still-live descendants
(arxanas/git-branchless#965).
Step 3's -b move, by contrast, skips a commit whose patch is already upstream
and deletes its branch. It detects a squash of several commits only when their
edits do not overlap, each applying as an empty commit; when they overlap, it
conflicts.
Out-of-order merge recovery
Before merging PR N+1 into branch N, apply Before merging: retarget every open dependent PR/MR (including N+2) from branch N+1 to branch N and confirm each remains open and targets branch N.
If a PR is merged out of order (e.g., PR N+1 merged before PR N), the squash
goes into the base branch — not main. Merge the base PR (N) next — it now
contains both changes. After merging, the commits for both PRs are upstream:
re-run pre-flight step 5 (it fetches), then run the squash-merge recipe above
with $SELECTED limited to what remains, and -s on the first commit that is
still unmerged.
Do not try to update local main with git branch -f main origin/main: Git
refuses it whenever another worktree has main checked out, and the move does
not need it.
If a merged commit is still in the selection, stop and ask. Do not start the move at a merged commit or hide orphaned commits; either can reach another worktree's line.
Addressing Review Feedback
When a reviewer (human, Copilot, etc.) comments on a specific PR in the stack:
-
Identify the target commit — the PR tells you which branch/commit the feedback applies to. Before step 2, capture the lease for the target and every downstream branch (Pinned force-push).
-
Apply the fix using
/stack-fix. It handles both absorb (line-level fixes) and manual amend (structural changes) paths, including dry-run preview, conflict resolution, and restacking. -
Force-push all downstream branches (target + every branch after it):
Push with the pinned lease (Pinned force-push) for the target and downstream branches.
-
Return to the stack tip after pushing:
git checkout <tip-branch> # e.g. todo/pre-publish or the last PR branch -
Reply to and resolve each review thread. Replying alone does NOT close the conversation — you must explicitly resolve each thread in the UI or via the platform's API/CLI.
GitHub
# Get unresolved thread IDs for a PR gh api graphql -f query='{ repository(owner: "<owner>", name: "<repo>") { pullRequest(number: <N>) { reviewThreads(first: 100) { nodes { id isResolved } } } } }' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved | not) | .id' # Resolve each thread gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread-id>"}) { thread { isResolved } } }'
Tips
-
Always start with
git slto understand the stack before submitting. -
Use one branch per commit so each stacked PR has a clear base.
-
For very large stacks (20+ PRs), consider batching — submit the first 5-10, get them merged, then submit the next batch. Reviewers struggle with 20+ open PRs at once.
-
For stacks > 10 commits, script branch creation and PR creation. Write a standalone bash script (not inline shell — zsh doesn't support bashisms like
${!array[@]}). Use#!/usr/bin/env bashwith strict mode. Addsleep 1betweengh pr createcalls to avoid GitHub rate limiting.Template for scripted stacked PR creation:
#!/usr/bin/env bash set -euETo pipefail shopt -s inherit_errexit 2>/dev/null || : # Define stack: branch names and commit messages in order BRANCHES=("feat/first" "feat/second" "feat/third") TITLES=("feat: first change" "feat: second change" "feat: third change") TOTAL=${#BRANCHES[@]} for i in "${!BRANCHES[@]}"; do branch="${BRANCHES[$i]}" title="${TITLES[$i]}" pos=$((i + 1)) base=$([[ $i -eq 0 ]] && echo "main" || echo "${BRANCHES[$((i - 1))]}") echo "Creating PR ${pos}/${TOTAL}: ${branch} -> ${base}" gh pr create --head "${branch}" --base "${base}" \ --title "${title}" --body "Stack: ${pos}/${TOTAL}" sleep 1 done
04 同仓库更多 Skills
stack-test
Use when you need to run tests or formatters across commits in a stack. Use INSTEAD of manual git test run or looping git checkout + test. Prevents: testing every commit when only the tip has to pass, unbounded parallel jobs exhausting memory, cache misunderstandings.
stack-summary
Use when you need to audit, analyze, or review stack quality before restructuring. Produces a structured summary with per-commit classification, philosophy audit, and violation flags. Output feeds directly into /stack-plan restructure mode. Use INSTEAD of manual git log inspection.
stack-split
Use when you need to break up, split, or decompose a large commit into smaller atomic commits. Use INSTEAD of manual git rebase -i + edit or git reset HEAD^. Prevents: non-working intermediate commits, wrong split ordering, missed downstream restack.
stack-plan
Use when you need to plan commits, restructure a stack, or commit uncommitted work as organized atomic commits. Builds a stack from a description, uncommitted work, or existing commits INSTEAD of dumping everything into one monolithic commit with git add -A && git commit, or manual git rebase -i, git reset --soft, or ad-hoc git move sequences. Prevents: wrong commit ordering, forward references, git move -F panics, untested intermediates.
stack-fix
Use when you need to fix, correct, or update content in an earlier commit — line-level edits, content moves, or structural changes. Absorbs staged changes into correct stack commits INSTEAD of running git absorb or git commit --fixup via Bash, or hand-running git prev + edit + git amend + git restack --merge. Prevents: missed dry-run preview, leftover staged changes going unnoticed, forgetting to restack. Falls back to guided manual amend when absorb cannot route hunks.
peer-communication
How to write replies a person reads: answer first, plain words, easy to scan. Load before your first reply in an interactive session.