Last active
September 10, 2026 10:06
-
-
Save debugloop/a2f6eda9c306526c26800e79e822a22e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| for tool in gh git git-absorb git-trim realpath; do | |
| command -v "$tool" >/dev/null || { echo "missing tool: $tool"; exit 1; } | |
| done | |
| SCRIPT_PATH=$(realpath "$0") | |
| export SCRIPT_PATH | |
| gh auth status --active --hostname github.com >/dev/null || exit 1 | |
| gh stack --version >/dev/null || { | |
| echo "missing extension: gh extension install github/gh-stack" | |
| exit 1 | |
| } | |
| GH_SCOPES=$(gh auth status --active --hostname github.com --json hosts \ | |
| --jq '.hosts["github.com"][] | select(.active) | .scopes') | |
| if [ -n "$GH_SCOPES" ] && [[ "$GH_SCOPES" != *delete_repo* ]]; then | |
| echo "missing GitHub scope: gh auth refresh -s delete_repo" | |
| exit 1 | |
| fi | |
| DIR=$(mktemp -d) | |
| export DIR | |
| cd "$DIR" | |
| git init -b main | |
| export GIT_AUTHOR_DATE="2000-01-01T12:34:56" | |
| export GIT_COMMITTER_DATE="2000-01-01T12:34:56" | |
| # Keep the demo configuration in one block. The alias values need shell quoting. | |
| # These aliases take no arguments, and they use main as the fixed trunk branch. | |
| # Use origin/main because the local main branch does not move after a remote merge. | |
| # Disable rerere so that each presentation shows the conflict in step 3. | |
| cat >> .git/config <<'EOF' | |
| [user] | |
| name = Someone | |
| email = someone@example.com | |
| [commit] | |
| gpgsign = false | |
| [rebase] | |
| autosquash = true | |
| updateRefs = true | |
| [rerere] | |
| enabled = false | |
| [alias] | |
| ab = "absorb --base origin/main" | |
| lg = "log --oneline --graph --decorate --all" | |
| rb = "!git fetch -q origin && git rebase origin/main" | |
| rbi = "rebase --interactive --keep-base origin/main" | |
| stack = "!git rev-list --reverse --topo-order origin/main..HEAD \ | |
| | xargs -r -n1 git branch --format='%(refname:short)' --points-at" | |
| puf = "!git stack \ | |
| | xargs -r git push --set-upstream --force-with-lease --force-if-includes origin" | |
| stack-link = "!git stack | xargs -r gh stack link" | |
| stack-pull = "!git fetch -pq && git stack | while read -r branch; do \ | |
| git switch -q \"$branch\" || continue; \ | |
| if git cherry @{u} | grep -q '^+'; then \ | |
| echo \"$branch: holds commits that are not upstream, skipped\"; \ | |
| else \ | |
| git reset -q --keep @{u}; \ | |
| fi; \ | |
| done" | |
| EOF | |
| printf 'change M1\n\n' > foo | |
| git add foo | |
| git commit -m 'root, change M1' | |
| printf 'change M2\n\n' >> foo | |
| git commit -am 'change M2' | |
| git switch -c feature-a | |
| printf 'change A1\n\n' >> foo | |
| git commit -am 'change A1' | |
| printf 'change A2\n\n' >> foo | |
| git commit -am 'change A2' | |
| printf 'change A3\n\n' >> foo | |
| git commit -am 'change A3' | |
| git switch main | |
| printf 'change M3\n\n' >> foo | |
| git commit -am 'change M3' | |
| git switch feature-a | |
| cleanup_scratch_repo() { | |
| local status=$? | |
| local answer= | |
| trap - EXIT | |
| echo | |
| printf 'Delete scratch repository %s and directory %s? [y/N] ' "$REPO" "$DIR" | |
| if read -r answer && [[ "$answer" =~ ^[Yy]([Ee][Ss])?$ ]]; then | |
| if gh repo delete "$REPO" --yes; then | |
| cd / | |
| rm -rf -- "$DIR" | |
| echo "Deleted the scratch repository and directory." | |
| else | |
| echo "Could not delete the scratch repository." | |
| status=1 | |
| fi | |
| else | |
| echo "Kept the scratch repository and directory." | |
| echo "Delete the repository later: gh repo delete \"$REPO\" --yes" | |
| echo "Delete the directory later: rm -rf \"$DIR\"" | |
| fi | |
| exit "$status" | |
| } | |
| # Create a private scratch repository. Push main before you set it as the default. | |
| REPO="$(gh api user --jq .login)/gitfun-$(date +%s)-$$" | |
| export REPO | |
| gh repo create "$REPO" --private --source=. --remote=origin | |
| trap cleanup_scratch_repo EXIT | |
| git push -q origin main | |
| gh repo edit "$REPO" --default-branch main --enable-merge-commit --delete-branch-on-merge=false | |
| git remote set-head origin -a | |
| echo | |
| git lg | |
| echo | |
| echo "Agenda:" | |
| echo " 1. Create stacked branches" | |
| echo " 2. Absorb a fix and run an interactive rebase" | |
| echo " 3. Rebase the stack onto the upstream branch" | |
| echo " 4. Update and push the stack" | |
| echo " 5. Link and merge stacked pull requests on GitHub" | |
| echo " 6. Adopt the GitHub restack after a partial merge" | |
| echo " 7. Compare the demo aliases with the production aliases" | |
| echo " 8. Exit and clean up" | |
| echo | |
| echo "Scratch directory: $DIR" | |
| echo "Scratch repository: $REPO" | |
| echo "Presenter notes: $SCRIPT_PATH" | |
| echo "Exit the demo shell to start the cleanup prompt." | |
| echo | |
| "$SHELL" | |
| # Presenter notes | |
| # | |
| # Run each command marked with an asterisk. Text without an asterisk is a cue. | |
| # | |
| # 1. Create stacked branches | |
| # | |
| # The feature branch contains three commits. Create one branch for each PR. | |
| # | |
| # * git branch -f pr-1 @~~ | |
| # * git branch -f pr-2 @~ | |
| # * git lg | |
| # | |
| # Point out pr-1, pr-2, and feature-a on the three feature commits. | |
| # Show how stack finds the branch at each commit before you run it. | |
| # | |
| # * git config --get alias.stack | |
| # * git stack | |
| # | |
| # The command prints the local stack from bottom to top. | |
| # | |
| # 2. Absorb a fix and run an interactive rebase | |
| # | |
| # Change the A1 line. This change targets pr-1, not the top commit. | |
| # | |
| # * sed -i 's/change A1/change A1 fixed/' foo | |
| # * git diff | |
| # * git add -u | |
| # * git config --get alias.ab | |
| # * git ab | |
| # * git lg | |
| # | |
| # Point out the new fixup commit above A3. The pr-1 branch still marks A1. | |
| # Show the interactive rebase alias and its fixed base before you run it. | |
| # | |
| # * git config --get alias.rbi | |
| # * git rbi | |
| # | |
| # Point out the fixup line directly after A1 in the rebase plan. | |
| # Do not change the plan. Save the file and close the editor. | |
| # | |
| # * git lg | |
| # * git stack | |
| # | |
| # Autosquash puts the fix into A1. updateRefs moves pr-1 and pr-2. | |
| # | |
| # 3. Rebase the stack onto the upstream branch | |
| # | |
| # M3 and A1 both add a line after M2. The next command stops with a conflict. | |
| # Show that rb fetches origin before it rebases the stack. | |
| # | |
| # * git config --get alias.rb | |
| # * git rb | |
| # * cat foo | |
| # | |
| # Keep M3 from main and the fixed A1 line. Replace the conflict with this file: | |
| # | |
| # * printf 'change M1\n\nchange M2\n\nchange M3\n\nchange A1 fixed\n\n' > foo | |
| # * git add foo | |
| # * git rebase --continue | |
| # | |
| # If an editor opens, keep the commit message. Save the file and close the editor. | |
| # | |
| # * git lg | |
| # * git stack | |
| # | |
| # The stack now starts at origin/main. updateRefs moves both PR branches again. | |
| # | |
| # 4. Update and push the stack | |
| # | |
| # Push all three branches. This command also sets their Git upstream branches. | |
| # Show the lease options before the first force-push. | |
| # | |
| # * git config --get alias.puf | |
| # * git puf | |
| # * git branch -vv | |
| # | |
| # Change A3 to give the top PR a new version. | |
| # | |
| # * sed -i 's/change A3/change A3 polished/' foo | |
| # * git diff | |
| # * git add -u | |
| # * git commit --amend --no-edit | |
| # * git lg | |
| # * git puf | |
| # | |
| # The second push updates only feature-a. The lease rejects unknown remote work. | |
| # | |
| # 5. Link and merge stacked pull requests on GitHub | |
| # | |
| # Create the PRs, chain their base branches, and link them as one GitHub stack. | |
| # Show how stack-link supplies the branches to gh stack link. | |
| # | |
| # * git config --get alias.stack-link | |
| # * git stack-link | |
| # | |
| # Open the stack URL that the command prints. Show the PR order and base branches. | |
| # link does not create local gh-stack metadata. The demo keeps its own local model. | |
| # | |
| # Mark each draft PR as ready for review. | |
| # | |
| # * git stack | xargs -n1 gh pr ready | |
| # * gh pr list | |
| # * gh pr view pr-1 --web | |
| # | |
| # Show the stack panel on the PR page. Then get the number of the bottom PR. | |
| # | |
| # * BOTTOM_PR=$(gh pr view pr-1 --json number --jq .number) | |
| # * echo "$BOTTOM_PR" | |
| # | |
| # Merge only the bottom PR. This leaves two PRs for the restack demonstration. | |
| # | |
| # * gh stack merge "$BOTTOM_PR" --merge | |
| # | |
| # Confirm the merge. The selected prefix contains only pr-1 in this example. | |
| # GitHub completes the selected prefix as one atomic operation. | |
| # | |
| # * gh pr list --state all | |
| # | |
| # 6. Adopt the GitHub restack after a partial merge | |
| # | |
| # GitHub retargets pr-2 to main. GitHub also restacks pr-2 and feature-a. | |
| # Adopt those remote commits instead of rebasing the old local commits again. | |
| # Show the patch-ID safety check before stack-pull changes local branches. | |
| # | |
| # * git config --get alias.stack-pull | |
| # * git stack-pull | |
| # * git lg | |
| # | |
| # stack-pull uses patch IDs to identify a remote restack. It resets only safe branches. | |
| # If a branch contains an unpushed patch, stack-pull reports it and skips that branch. | |
| # | |
| # Show the merged branch before deletion. | |
| # | |
| # * git trim --dry-run | |
| # * git trim | |
| # | |
| # Confirm the deletion. git trim deletes merged pr-1 locally and on origin. | |
| # | |
| # * git puf | |
| # * git status --short | |
| # | |
| # The push now accepts the GitHub restack. Do not run stack-link again. | |
| # link cannot update a stack that contains a merged PR. | |
| # | |
| # 7. Compare the demo aliases with the production aliases | |
| # | |
| # The demo aliases take no arguments. They also use main as a fixed branch name. | |
| # Production aliases must get the trunk name from the remote repository. | |
| # | |
| # * git symbolic-ref --short refs/remotes/origin/HEAD | |
| # | |
| # The production aliases take arguments and get the trunk name automatically: | |
| # https://github.com/debugloop/dotfiles/blob/main/modules/features/cli/git.nix | |
| # | |
| # 8. Exit and clean up | |
| # | |
| # Exit the demo shell to return to the setup script. | |
| # | |
| # * exit | |
| # | |
| # The setup script asks before it deletes the repository and directory. | |
| # Answer yes to delete both items. Answer no to keep both items. | |
| # If you keep them, the script prints the two cleanup commands. |
Author
Author
The mentioned git absorb tool (automatic fixup generation) and git trim tool (branch deletion).
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Execute the script to start the demo. Presenter's notes are the comment at the bottom and map onto the printed instructions.
Important message to get across: Set appropriate defaults for these flags in gitconfig, and otherwise create some aliases.