Skip to content

Commit e393008

Browse files
committed
add git pull request review flow and gh stack flow
1 parent 5c3e00f commit e393008

1 file changed

Lines changed: 312 additions & 3 deletions

File tree

docs/posts/2019/2019-06-19-git-cheat-sheet.md

Lines changed: 312 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ categories:
66
comments: true
77
date:
88
created: 2019-06-19
9-
updated: 2026-08-10
9+
updated: 2026-08-25
1010
---
1111

1212
# Git Cheat Sheet
@@ -47,8 +47,26 @@ git config --global alias.sync-pr '!git fetch && git reset --hard @{u}'
4747
```bash
4848
alias gitpush='git ci -am "$gitmsg" ; git push origin $gitbranch'
4949
alias gitamendpush='git add . ; git amend ; git push origin $gitbranch -f'
50-
alias gitrebasemain='git cm ; git rom ; git fetch origin --prune ; if [[ -n "$gitbranch" && "$gitbranch" != "main" ]]; then git diff --quiet "$gitbranch" main && git br -D "$gitbranch" || echo "keep $gitbranch: content differs from main, please confirm"; fi'
51-
alias gitrebasedev='git cd ; git rod ; git fetch origin --prune ; if [[ -n "$gitbranch" && "$gitbranch" != "dev" ]]; then git diff --quiet "$gitbranch" dev && git br -D "$gitbranch" || echo "keep $gitbranch: content differs from dev, please confirm"; fi'
50+
gitbrclean() {
51+
# delete branch $1 only if its content already landed in $2.
52+
# Compares just the files the branch touched: an unscoped
53+
# `git diff --quiet "$br" "$base"` reports a difference as soon as base
54+
# moves ahead, so it would never delete anything on a shared repo.
55+
# See "Deleting a branch after a squash merge" below.
56+
local br=$1 base=$2 files
57+
files=$(git diff --name-only "$base...$br")
58+
59+
if [[ -z "$files" ]]; then
60+
echo "keep $br: introduces nothing over $base"
61+
elif git diff --quiet "$br" "$base" -- $files; then
62+
git br -D "$br"
63+
else
64+
echo "keep $br: content differs from $base, please confirm"
65+
fi
66+
}
67+
68+
alias gitrebasemain='git cm ; git rom ; git fetch origin --prune ; if [[ -n "$gitbranch" && "$gitbranch" != "main" ]]; then gitbrclean "$gitbranch" main; fi'
69+
alias gitrebasedev='git cd ; git rod ; git fetch origin --prune ; if [[ -n "$gitbranch" && "$gitbranch" != "dev" ]]; then gitbrclean "$gitbranch" dev; fi'
5270
```
5371

5472
## Restore
@@ -246,6 +264,73 @@ git branch -r --points-at <commit>
246264
git branch -r --points-at HEAD
247265
```
248266

267+
### Deleting a branch after a squash merge
268+
269+
Squash merge writes a brand new commit on main, so the local branch shares no
270+
hash ancestry with it. `git branch -d` checks ancestry, not content, but it
271+
accepts the branch when it is merged into *either* HEAD *or* its upstream:
272+
273+
```bash
274+
# remote branch still exists, upstream is reachable, -d succeeds
275+
$ git branch -d feat/greet
276+
warning: deleting branch 'feat/greet' that has been merged to
277+
'refs/remotes/origin/feat/greet', but not yet merged to HEAD
278+
Deleted branch feat/greet (was dd5a6b1).
279+
280+
# repo deletes the head branch on merge and you pruned, upstream is gone, -d fails
281+
$ git fetch --prune
282+
$ git branch -d feat/greet
283+
error: the branch 'feat/greet' is not fully merged.
284+
If you are sure you want to delete it, run 'git branch -D feat/greet'
285+
```
286+
287+
So `-d` only blocks once `delete_branch_on_merge` (or a manual remote delete)
288+
plus a `--prune` has removed the upstream ref. That is exactly the state the
289+
`gitrebasemain` alias creates, which is why it has to reach for `-D`.
290+
291+
#### Guarding -D with a content diff
292+
293+
`-D` skips the safety check, so guard it by comparing content. The obvious
294+
comparison is wrong:
295+
296+
```bash
297+
git diff --quiet feat/greet main # exit 1, but not because the branch is unmerged
298+
```
299+
300+
It reports a difference as soon as *main* moves ahead, which any other merged PR
301+
does:
302+
303+
```text
304+
$ git diff --stat feat/greet main
305+
src/api.py | 11 +++++++++++
306+
src/auth.py | 18 ++++++++++++++++++
307+
src/config.py | 22 ++++++++++++++++++++++
308+
src/frontend.py | 11 +++++++++++
309+
src/retry.py | 16 ++++++++++++++++
310+
```
311+
312+
None of those files belong to `feat/greet`, whose own `src/greet.py` landed in
313+
main earlier. On a shared repo this guard almost never passes, so the branch is
314+
kept forever and the cleanup silently stops working.
315+
316+
Scope the comparison to the files the branch actually touched:
317+
318+
```bash
319+
files=$(git diff --name-only main...feat/greet) # three dots: branch vs merge-base
320+
git diff --quiet feat/greet main -- $files # exit 0, content fully landed
321+
```
322+
323+
Beware the empty case: when `$files` is empty the `--` guard matches everything
324+
again and the check silently inverts, so test for it separately. The
325+
`gitbrclean` helper in the `~/.bashrc` section above covers the four cases:
326+
327+
| branch state | `$files` | scoped diff | outcome |
328+
| --- | --- | --- | --- |
329+
| squash-merged into base | non-empty | equal | deleted |
330+
| carries unmerged work | non-empty | differs | kept |
331+
| touches a file base also changed | non-empty | differs | kept |
332+
| introduces nothing | empty | not run | kept |
333+
249334
## Show diff
250335

251336
### show content in staging area
@@ -310,6 +395,230 @@ git gui
310395

311396
Azure devops doc: [https://devblogs.microsoft.com/devops/pull-requests-with-rebase/](https://devblogs.microsoft.com/devops/pull-requests-with-rebase/)
312397

398+
### One fix commit per review comment
399+
400+
One commit per PR at creation; review fixes land as one commit per comment so
401+
the reviewer can diff each fix directly.
402+
403+
```bash
404+
git commit --fixup=<commit> # adds a commit labeled fixup! <title>, the reviewer sees only this incremental diff
405+
git rebase -i --autosquash <base> # folds each fixup into its target commit and rewrites history, needs force push after
406+
```
407+
408+
`--fixup` is just a normal commit with a special message; nothing else changes.
409+
410+
`--autosquash` moves each fixup right after its target, folds its content in
411+
and drops its message. Hashes are rewritten, so it needs a force push.
412+
413+
#### What the reviewer gets
414+
415+
A fixup is an ordinary commit, so the push is a fast-forward and each fix stays
416+
addressable on its own:
417+
418+
```text
419+
feat/greet reviewer clicks that commit and sees
420+
------------------------------------------------------------------------
421+
8f6645b Add greet module the whole feature
422+
e33cf51 fixup! Add greet module only the fix for review comment 1
423+
dd5a6b1 fixup! Add greet module only the fix for review comment 2
424+
```
425+
426+
Compare with `git commit --amend` + force push: that replaces the tip, so the
427+
"changes since your last review" link breaks and the reviewer re-reads
428+
everything.
429+
430+
#### What lands in main
431+
432+
Two independent knobs decide the outcome, and only the first one lives in git:
433+
434+
1. whether `--autosquash` ran before the merge
435+
2. how the forge builds the merge commit message
436+
437+
```text
438+
no autosquash no autosquash autosquash
439+
+ rebase merge + squash merge + either merge
440+
-------------------------- ---------------------------- ---------------------------
441+
main: main: main:
442+
dd5a6b1 fixup! Add greet 2677ce4 Add greet module (#1) a73c729 Add greet module
443+
e33cf51 fixup! Add greet * Add greet module
444+
8f6645b Add greet module * fixup! Add greet module
445+
* fixup! Add greet module
446+
447+
3 commits, 2 are noise 1 commit, message polluted 1 commit, clean message
448+
(GitHub default setting) (force push re-runs CI)
449+
```
450+
451+
Full matrix, where the third column is the repo's `squash_merge_commit_message`
452+
setting:
453+
454+
| autosquash | merge method | squash message | result in main |
455+
| --- | --- | --- | --- |
456+
| no | rebase | n/a | every `fixup!` commit lands verbatim |
457+
| no | squash | `COMMIT_MESSAGES` | one commit, body lists every `fixup!` |
458+
| no | squash | `PR_BODY` | one commit, title + PR description |
459+
| no | squash | `BLANK` | one commit, title only |
460+
| yes | rebase | n/a | one clean commit, force push needed |
461+
| yes | squash | any | one clean commit, force push wasted |
462+
463+
#### Squash merge does not clean the message
464+
465+
A squash merge folds the tree into one commit, but the *message* is built from
466+
whatever the repo is configured to use. GitHub's default is `COMMIT_MESSAGES`,
467+
which concatenates every commit subject in the PR, so unsquashed fixups leak:
468+
469+
```text
470+
Add greet module (#1)
471+
472+
* Add greet module
473+
474+
* fixup! Add greet module
475+
476+
* fixup! Add greet module
477+
```
478+
479+
The tree is correct and only one commit is added, but the history now carries
480+
review-round noise that means nothing to anyone reading main later.
481+
482+
#### Fix it once at the repo level
483+
484+
Set the message source instead of reaching for `--autosquash` on every PR:
485+
486+
```bash
487+
# title = PR title (+ PR number), body = empty
488+
gh api -X PATCH repos/OWNER/REPO \
489+
-f squash_merge_commit_title=PR_TITLE \
490+
-f squash_merge_commit_message=BLANK
491+
492+
# or keep the PR description as the body
493+
gh api -X PATCH repos/OWNER/REPO \
494+
-f squash_merge_commit_title=PR_TITLE \
495+
-f squash_merge_commit_message=PR_BODY
496+
```
497+
498+
With this set, a PR carrying unsquashed `fixup!` commits still squash-merges
499+
into a clean `Add retry helper (#3)`. No autosquash, no force push, no wasted
500+
CI run. Prefer `BLANK` when the repo also uses `gh stack`, whose generated PR
501+
bodies contain an HTML banner that `PR_BODY` would copy into the commit message.
502+
503+
#### Autosquash rewrites hashes, not content
504+
505+
Worth knowing before paying for a force push: autosquash never changes the tip
506+
tree, so the CI re-run it triggers tests byte-identical content.
507+
508+
```bash
509+
git rev-parse HEAD^{tree} # abb1f0ec44bf6feb...
510+
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main # non-interactive
511+
git rev-parse HEAD^{tree} # abb1f0ec44bf6feb... identical
512+
```
513+
514+
So `--autosquash` is only worth it on rebase-merge repos, where `fixup!`
515+
commits would otherwise land in main verbatim. On squash-merge repos, configure
516+
the message source instead.
517+
518+
## Stacked pull requests
519+
520+
[gh stack](https://github.com/github/gh-stack) turns a chain of branches into a
521+
chain of PRs, each based on the branch below it, so a reviewer sees one layer
522+
instead of the whole feature.
523+
524+
```bash
525+
gh extension install github/gh-stack
526+
git config rerere.enabled true # remember conflict resolutions
527+
git config remote.pushDefault origin # required when the repo has several remotes
528+
```
529+
530+
### Building a stack
531+
532+
```bash
533+
gh stack init auth # create the stack, check out its first branch
534+
git add . && git commit -m "Add auth middleware"
535+
gh stack add api # next layer, branched from the current one
536+
git add . && git commit -m "Add user API route"
537+
gh stack add frontend
538+
git add . && git commit -m "Add profile rendering"
539+
gh stack submit --auto --open # push every branch, open PRs ready for review
540+
```
541+
542+
`--auto` skips the per-PR title prompt. Without `--open` the PRs are created as
543+
drafts, and a draft cannot be merged.
544+
545+
The result is a chain where each PR targets the one below it:
546+
547+
```text
548+
stack #7
549+
550+
PR #6 frontend -> api 11 lines top, merges last
551+
PR #5 api -> auth 11 lines
552+
PR #4 auth -> main 18 lines bottom, merges first
553+
```
554+
555+
Each reviewer sees only their own layer: PR #5 is 11 lines, not the 18 lines of
556+
`auth` sitting underneath it.
557+
558+
### Merging a whole stack with one command
559+
560+
```bash
561+
gh stack merge 7 --yes --squash # stack number: every unmerged PR in the stack
562+
gh stack merge 6 --yes --squash # PR number: that PR plus everything below it
563+
```
564+
565+
A bare number is resolved as a stack number first, and as a PR number only when
566+
no stack matches. That order never turns ambiguous, because stack numbers are
567+
drawn from the same sequence as issues and pull requests: a given number is
568+
either a stack or a PR, never both.
569+
570+
```text
571+
PR #1 .. #6 six pull requests
572+
stack #7 a 3-layer stack, consuming number 7
573+
issue #8 next number in the shared sequence
574+
PR #9 single-branch stack, no stack number allocated
575+
PR #10 its second layer
576+
stack #11 stack object created once it held 2 PRs
577+
```
578+
579+
A stack is registered on GitHub, and consumes a number, only once it holds at
580+
least two PRs. A one-branch stack stays local and has no number to pass, so
581+
`merge` and `checkout` take its PR number instead.
582+
583+
The operation is all or nothing. If any PR in the set cannot merge, none do.
584+
Merging runs bottom to top and produces one commit per PR, not one commit for
585+
the whole stack:
586+
587+
```text
588+
$ gh stack merge 7 --yes --squash
589+
Merging #4, #5, #6 into main via squash...
590+
Merged #4, #5, #6 into main (08cfd25)
591+
592+
$ git log --oneline -3
593+
08cfd25 Add profile rendering (#6)
594+
7aa33e5 Add user API route (#5)
595+
31f2bb1 Add auth middleware (#4)
596+
```
597+
598+
Without a method flag the last-used method is reused. If the base branch uses a
599+
merge queue the stack is queued instead, the queue picks the method, and any
600+
flag passed is ignored with a warning.
601+
602+
### Things that bite
603+
604+
`gh stack sync --prune` prunes merged branches **locally only**. The remote
605+
branches stay until deleted explicitly.
606+
607+
Generated PR bodies carry an HTML banner:
608+
609+
```text
610+
<sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> ...
611+
```
612+
613+
With `squash_merge_commit_message=PR_BODY` that banner is copied verbatim into
614+
the commit message on main. Use `BLANK` instead, or rewrite the body before
615+
merging.
616+
617+
Most commands change behaviour depending on whether stdout is a TTY. In scripts,
618+
pass the non-interactive flags: `view --json`, `submit --auto`, `merge --yes`,
619+
`init <branch>`, `add <branch>`. `gh stack modify` is TUI-only, so restructure
620+
with `unstack` then `init` instead.
621+
313622
## Moving Git repository content to another repository preserving history
314623

315624
```bash

0 commit comments

Comments
 (0)