Chapter 09Lesson 02~190 minutes

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.

gh pr mergeRequired checksCleanupVerification

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.
Availability: Mandatory path: GitHub.com, GitHub Free, one disposable public personal repository, Git, GitHub CLI, and GitHub Actions for a tiny public-repository check. Protected branches and auto-merge are available on this path. Live merge queue is optional because GitHub currently requires an organization-owned public repository (or Enterprise Cloud for a private organization repository).
Shell portability: multi-line examples with trailing \, 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.

Security-sensitive actions in this lesson: changing merge settings, branch protection, auto-merge, and deleting head branches change repository policy or refs. Every mutation is preceded by inspection and scoped to the disposable 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

Repository policy mutation: branch protection can block pushes/merges. Run only against the disposable repository after confirming owner, repo, and branch. The API request uses the current GitHub REST version.
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
Do not use --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?

  1. Choose between squash and rebase based on whether the two commit identities should remain separate on the base.
  2. If the merge option is absent for everyone, inspect repository merge settings/rules—not local Git config.
  3. If integration should happen automatically after a pending check, use auto-merge—not a cron loop that repeatedly invokes merge.
  4. 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?

Why did the auto-merge lab use a failing required check rather than simply enabling auto-merge on an immediately mergeable PR?

What is the safe repair when a required check blocks auto-merge?

Why was merge queue simulated on the personal repository?

What proves branch cleanup actually happened?

Next lesson

Turn mechanics into an integration policy

Lesson 03 compares merge history, auto timing, queue strategy, up-to-date requirements, and head-branch retention as governance choices rather than per-PR preferences.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.