@@ -6,7 +6,7 @@ categories:
66comments : true
77date :
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
4848alias gitpush=' git ci -am "$gitmsg" ; git push origin $gitbranch'
4949alias 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>
246264git 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
311396Azure 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