Merge Methods, Auto-Merge, Merge Queue, Conflict Handling, and Branch Cleanup: Guided Hands-On Workflow and Core Operations
Operate a disposable repository through three merge methods, a live auto-merge gate, a documented merge-queue simulation, a deliberately engineered conflict, local recovery, and safe head-branch cleanup with independent verification.
Learning objectives
- Create a disposable public repository and inspect/enable all three repository merge methods before using them.
- Merge separate PRs with merge-commit, squash, and rebase strategies and prove their different graph/commit-identity outcomes.
- Install a minimal public-repository required check, enable auto-merge, observe a deliberately blocked PR, then repair only the failing condition and verify automatic integration.
- Model a merge queue using documented merge-group state and optionally exercise a live queue in an organization-owned public disposable repository.
- Engineer a competing-line conflict, resolve it locally without force-pushing, and verify the PR head and mergeability afterward.
- Delete/retain branches deliberately and verify both hosted and local state rather than assuming cleanup propagated everywhere.
\, command substitution such as $(...),
here-documents, printf, and rm -rf are Git
Bash/Bash/zsh syntax. PowerShell users can put the same Git/gh
arguments on one line, use the backtick for continuation, and use
Set-Content/Add-Content/Remove-Item -Recurse -Force
for file operations. GitHub merge and policy semantics are
shell-independent.
1. Scenario and safety envelope
Use c09-merge-policy-lab. It exists only for this
lesson. You will create short-lived branches, merge test PRs,
install and later remove/leave a simple protection rule, and finally
archive the repository. Do not copy these policy mutations onto a
valuable repository.
2. Preflight: identity, collision gate, and current repository context
gh auth status --active --hostname github.com
OWNER=$(gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq .login)
REPO="c09-merge-policy-lab"
gh repo view "$OWNER/$REPO" >/dev/null 2>&1 && {
echo "STOP: $OWNER/$REPO already exists" >&2
exit 2
}
git --version
gh --version
PowerShell equivalent:
$OWNER = gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq
.login; set $REPO explicitly and stop if
gh repo view succeeds. Never reuse an existing
repository just because it has a tutorial-like name.
3. Create the repository and establish a baseline graph
gh repo create "$OWNER/$REPO" --public --add-readme --clone
cd "$REPO"
DEFAULT_BRANCH=$(git branch --show-current)
printf 'baseline
' > integration.txt
git add integration.txt
git commit -m "chore: establish integration baseline"
git push
BASELINE=$(git rev-parse HEAD)
gh repo view "$OWNER/$REPO" --json defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,deleteBranchOnMerge
Record BASELINE. All later graph assertions are
relative to this known commit.
4. Enable all merge methods so the lab can compare them
This changes repository-level GitHub policy; it does not alter any existing commit.
gh repo edit "$OWNER/$REPO" --enable-merge-commit --enable-squash-merge --enable-rebase-merge --enable-auto-merge --delete-branch-on-merge=false
gh repo view "$OWNER/$REPO" --json mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,deleteBranchOnMerge
5. PR A — prove merge-commit history
git switch -c method/merge-commit
printf 'merge method line 1
' >> integration.txt
git add integration.txt && git commit -m "feat: merge-method change one"
printf 'merge method line 2
' >> integration.txt
git add integration.txt && git commit -m "test: merge-method change two"
TOPIC_A=$(git rev-parse HEAD)
git push -u origin method/merge-commit
PR_A=$(gh pr create --base "$DEFAULT_BRANCH" --head method/merge-commit --title "test: merge commit history" --body "Disposable Chapter 09 graph comparison.")
N_A=${PR_A##*/}
gh pr view "$N_A" --json headRefOid,baseRefOid,commits,mergeable
# Prediction: original two topic commits remain ancestors of base and one merge commit is added.
gh pr merge "$N_A" --merge
git switch "$DEFAULT_BRANCH"
git pull --ff-only
MERGED_A=$(git rev-parse HEAD)
git show --no-patch --pretty='format:%H parents=%P subject=%s' "$MERGED_A"
git merge-base --is-ancestor "$TOPIC_A" HEAD && echo "topic tip is ancestor of base"
Expected: MERGED_A has two parents. The original topic
tip is reachable from the base. That evidence distinguishes a merge
commit from squash/rebase outcomes.
6. PR B — prove squash history
git switch -c method/squash "$DEFAULT_BRANCH"
printf 'squash line 1
' >> integration.txt
git add integration.txt && git commit -m "feat: squash change one"
printf 'squash line 2
' >> integration.txt
git add integration.txt && git commit -m "fix: squash change two"
TOPIC_B=$(git rev-parse HEAD)
git push -u origin method/squash
PR_B=$(gh pr create --base "$DEFAULT_BRANCH" --head method/squash --title "test: squash history" --body "Disposable squash comparison.")
N_B=${PR_B##*/}
gh pr merge "$N_B" --squash
git switch "$DEFAULT_BRANCH"
git pull --ff-only
SQUASH_SHA=$(git rev-parse HEAD)
git show --no-patch --pretty='format:%H parents=%P subject=%s' "$SQUASH_SHA"
if git merge-base --is-ancestor "$TOPIC_B" HEAD; then
echo "unexpected: original squash tip is reachable"
else
echo "expected: original squash tip is not an ancestor of base"
fi
The base receives one new squash commit. The original two topic commits remain in the head branch until that ref is deleted, but they are not ancestors of the updated base.
7. PR C — prove rebase-merge identity rewrite
git switch -c method/rebase "$DEFAULT_BRANCH"
printf 'rebase line 1
' >> integration.txt
git add integration.txt && git commit -m "feat: rebase change one"
ORIGINAL_R1=$(git rev-parse HEAD)
printf 'rebase line 2
' >> integration.txt
git add integration.txt && git commit -m "fix: rebase change two"
ORIGINAL_R2=$(git rev-parse HEAD)
git push -u origin method/rebase
PR_C=$(gh pr create --base "$DEFAULT_BRANCH" --head method/rebase --title "test: rebase history" --body "Disposable rebase comparison.")
N_C=${PR_C##*/}
gh pr merge "$N_C" --rebase
git switch "$DEFAULT_BRANCH"
git pull --ff-only
printf 'original topic commits: %s %s
' "$ORIGINAL_R1" "$ORIGINAL_R2"
git log -4 --pretty='format:%H parents=%P subject=%s'
Expected: the two logical commit subjects appear linearly on the
base, but with new SHAs. Compare the recorded original SHAs to the
new git log values.
8. Install a tiny required check with merge-queue-ready triggering
The workflow deliberately fails only when a PR title contains
[block]. The event-provided title is evaluated in a
GitHub expression; it is not interpolated into a shell command. The
workflow also listens to merge_group so the same check
would work in a future queue-enabled repository.
name: integration-gate
on:
pull_request:
types: [opened, synchronize, reopened, edited]
merge_group:
permissions:
contents: read
jobs:
integration-gate:
runs-on: ubuntu-latest
steps:
- name: Controlled block
if: github.event_name == 'pull_request' && contains(github.event.pull_request.title, '[block]')
run: exit 1
- name: Pass gate
if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.title, '[block]')) }}
run: echo "integration gate passed"
Create .github/workflows/integration-gate.yml on the
default branch, commit, and push. Then open one harmless seed PR so
the exact check name is observed before branch protection depends on
it.
mkdir -p .github/workflows
# Save the YAML above as .github/workflows/integration-gate.yml
git add .github/workflows/integration-gate.yml
git commit -m "ci: add merge integration gate"
git push
git switch -c ci/observe-gate
printf 'gate seed
' >> integration.txt
git add integration.txt && git commit -m "test: observe integration gate name"
git push -u origin ci/observe-gate
SEED_URL=$(gh pr create --base "$DEFAULT_BRANCH" --head ci/observe-gate --title "test: observe required check" --body "Seeds the check context before protection.")
SEED_PR=${SEED_URL##*/}
gh pr checks "$SEED_PR" --watch
gh pr merge "$SEED_PR" --squash --delete-branch
git switch "$DEFAULT_BRANCH" && git pull --ff-only
The check list should show integration-gate. If it
shows a different stable context name, use the observed context in
the protection request rather than guessing.
9. Require the check on the disposable default branch
printf 'protection-target=%s/%s branch=%s
' "$OWNER" "$REPO" "$DEFAULT_BRANCH"
gh api -X PUT -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2026-03-10" "repos/$OWNER/$REPO/branches/$DEFAULT_BRANCH/protection" --input - <<'JSON'
{
"required_status_checks": {"strict": true, "contexts": ["integration-gate"]},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null
}
JSON
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$OWNER/$REPO/branches/$DEFAULT_BRANCH/protection/required_status_checks" --jq '{strict,contexts}'
PowerShell: put the JSON object in a temporary
protection.json file and pass
--input protection.json. Do not paste a token into the
file; gh uses its existing authenticated credential.
10. Live auto-merge: block it, inspect it, repair only the failing condition
git switch -c auto/blocked "$DEFAULT_BRANCH"
printf 'auto merge demonstration
' >> integration.txt
git add integration.txt && git commit -m "test: auto merge gate"
git push -u origin auto/blocked
PR_AUTO=$(gh pr create --base "$DEFAULT_BRANCH" --head auto/blocked --title "[block] test: auto merge waits for gate" --body "Required check intentionally fails until the title is repaired.")
N_AUTO=${PR_AUTO##*/}
gh pr checks "$N_AUTO" || true
gh pr merge "$N_AUTO" --auto --squash
gh pr view "$N_AUTO" --json autoMergeRequest,mergeStateStatus,statusCheckRollup,headRefOid
Prediction: the PR remains open because the required
integration-gate is failing, while
autoMergeRequest records the deferred merge request.
Repair the exact gate condition rather than bypassing protection:
gh pr edit "$N_AUTO" --title "test: auto merge proceeds after gate repair"
gh pr checks "$N_AUTO" --watch
# After the edited event reruns and passes, auto-merge can complete.
gh pr view "$N_AUTO" --json state,mergedAt,mergeCommit,statusCheckRollup,autoMergeRequest
git switch "$DEFAULT_BRANCH"
git pull --ff-only
--admin to make the lesson pass.
That would hide the required-check cause rather than repair it.
11. Merge queue: mandatory simulation, optional live organization extension
The personal lab cannot enable a live queue. Use this documented fixture to reason about what changes when two otherwise-ready PRs enter a queue:
{
"target": "main@B17",
"queue": ["PR-21@H21", "PR-22@H22"],
"merge_groups": [
{"ref": "gh-readonly-queue/main/pr-21-a1", "sha": "G21", "contains": ["B17", "H21"]},
{"ref": "gh-readonly-queue/main/pr-22-b2", "sha": "G22", "contains": ["B17", "PR-21 result", "H22"]}
],
"required_check": "integration-gate",
"workflow_events": ["pull_request", "merge_group"]
}
If G21 passes, PR-21 can advance. PR-22 is validated
against the state that includes changes ahead of it, not merely its
original base. If the required workflow omits
merge_group, no required queue result is produced and
the integration stalls/fails.
Optional live path: in a disposable public
organization-owned repository where you have admin rights, configure
a branch rule/ruleset that requires a merge queue. With a queued
target, current gh pr merge PR -R ORG/REPO does not
require a merge-strategy flag: if checks are pending it enables
auto-merge; when checks have passed it adds the PR to the queue. Do
not use --admin to bypass the queue.
12. Engineer a conflict and resolve it locally
Because the default branch is protected, every base-branch advance in this exercise goes through a passing PR. First add the baseline file through a small PR, then create the topic branch from that known base, and finally merge a competing base update.
git switch "$DEFAULT_BRANCH" && git pull --ff-only
# Baseline PR: establish conflict.env without bypassing protection.
git switch -c conflict/baseline
printf 'mode=baseline
' > conflict.env
git add conflict.env && git commit -m "chore: add conflict baseline"
git push -u origin conflict/baseline
BASELINE_URL=$(gh pr create --base "$DEFAULT_BRANCH" --head conflict/baseline --title "test: add conflict baseline" --body "Establishes the disposable conflict file.")
BASELINE_PR=${BASELINE_URL##*/}
gh pr checks "$BASELINE_PR" --watch
gh pr merge "$BASELINE_PR" --squash --delete-branch
# Topic PR starts from the now-current protected base.
git switch "$DEFAULT_BRANCH" && git pull --ff-only
git switch -c conflict/topic
printf 'mode=topic
' > conflict.env
git add conflict.env && git commit -m "feat: topic changes integration mode"
git push -u origin conflict/topic
PR_CONFLICT=$(gh pr create --base "$DEFAULT_BRANCH" --head conflict/topic --title "test: local conflict resolution" --body "Disposable conflict exercise.")
N_CONFLICT=${PR_CONFLICT##*/}
CONFLICT_HEAD1=$(git rev-parse HEAD)
# Competing base PR changes the same line and merges first.
git switch "$DEFAULT_BRANCH" && git pull --ff-only
git switch -c conflict/base-update
printf 'mode=base
' > conflict.env
git add conflict.env && git commit -m "fix: base changes integration mode"
git push -u origin conflict/base-update
PR_BASE=$(gh pr create --base "$DEFAULT_BRANCH" --head conflict/base-update --title "test: advance base for conflict" --body "Creates the competing line change.")
N_BASE=${PR_BASE##*/}
gh pr checks "$N_BASE" --watch
gh pr merge "$N_BASE" --squash --delete-branch
# Bring the protected base into the topic locally and observe the conflict.
git switch conflict/topic
git fetch origin "$DEFAULT_BRANCH"
git merge "origin/$DEFAULT_BRANCH" || true
git status
Inspect conflict.env and the conflict markers. Choose
the intended final value, remove markers, stage, commit, and push
the merge-resolution commit. Do not force-push:
printf 'mode=resolved
' > conflict.env
git add conflict.env
git commit -m "fix: resolve integration-mode conflict"
git push
RESOLVED_HEAD=$(git rev-parse HEAD)
printf 'conflict_before=%s after=%s
' "$CONFLICT_HEAD1" "$RESOLVED_HEAD"
gh pr view "$N_CONFLICT" --json headRefOid,mergeable,mergeStateStatus,statusCheckRollup
gh pr checks "$N_CONFLICT" --watch
The PR head moved because conflict resolution added a commit. Re-review requirements from Chapter 08 may therefore matter in a production repository.
13. Merge and clean the head branch with evidence
gh pr merge "$N_CONFLICT" --squash --delete-branch
git switch "$DEFAULT_BRANCH"
git pull --ff-only
git branch -vv
git ls-remote --heads origin conflict/topic
Expected hosted result: the remote conflict/topic ref
is gone because --delete-branch requested cleanup. The
local branch may still exist depending on CLI/local state; inspect
before deleting it. GitHub’s PR page can restore an eligible deleted
head branch if a recovery exercise is needed.
14. Challenge: choose the surface/control, not a memorized command
A PR has two well-structured commits, the team requires linear history, the protected branch has a passing required check, and the project does not use a merge queue. Which change belongs where?
- Choose between squash and rebase based on whether the two commit identities should remain separate on the base.
- If the merge option is absent for everyone, inspect repository merge settings/rules—not local Git config.
- If integration should happen automatically after a pending check, use auto-merge—not a cron loop that repeatedly invokes merge.
- If the branch should be removed after merge, decide based on downstream dependencies, then use repository automatic deletion or explicit branch cleanup.
15. Verification and cleanup
- All three merge methods were enabled before comparison and their graph results were independently inspected.
- Merge-commit result has an explicit two-parent merge commit and original topic tip reachable from base.
- Squash result has one new base commit and original squash tip not reachable from base.
- Rebase result preserves logical commits but shows new base commit SHAs.
- Required integration-gate is configured on the disposable public default branch and auto-merge waited for it.
- No admin bypass or plain force push was used.
- Queue behavior was clearly labeled simulated unless an optional organization repository was used.
- Conflict resolution produced a normal new commit and the updated head/check state was inspected before merge.
- Remote branch deletion was verified rather than assumed.
Archive the repository when the lesson is complete. Archiving preserves evidence while making the disposable repository read-only; permanent deletion is optional and not required.
cd ..
printf 'archive-check=%s/%s
' "$OWNER" "$REPO"
gh repo archive "$OWNER/$REPO"
rm -rf "$REPO"
Knowledge check
After a squash merge, why can the original topic tip fail an ancestor test against main?
Because GitHub created one new squash commit containing the changes; the original topic commits were not added as ancestors of main.
Why did the auto-merge lab use a failing required check rather than simply enabling auto-merge on an immediately mergeable PR?
GitHub exposes auto-merge when requirements are pending; the failure makes deferred integration observable and testable.
What is the safe repair when a required check blocks auto-merge?
Fix the condition that makes the check fail and let the check rerun; do not bypass protection just to complete the lab.
Why was merge queue simulated on the personal repository?
GitHub currently limits live queues to organization-owned public repositories or qualifying private Enterprise Cloud organization repositories.
What proves branch cleanup actually happened?
Inspect the hosted ref, for example with git ls-remote or the branches/API state; do not infer it from the merge button alone.
Authoritative references
About merge methods on GitHub
Configuring pull request merges
Automatically merging a pull request
Merging a pull request with a merge queue
Managing a merge queue
Events that trigger workflows: merge_group
Resolving a merge conflict using the command line
Deleting and restoring branches in a pull request
About protected branches
gh pr merge
gh repo edit
REST API endpoints for protected branches
REST API versions
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.