Git has no concept of a pull request. It is a forge feature — a request to merge branch A into branch B, plus a conversation and some gates. Understanding which parts are Git and which parts are GitHub or GitLab is what lets you debug the thing when it misbehaves.
Topic 1: The Lifecycle
git switch main && git pull # start from current main
git switch -c fix/token-expiry # branch
# ...work, commit in logical pieces...
git push -u origin fix/token-expiry # publish
# open the PR in the forge, or:
gh pr create --fill --base main
Then: CI runs, reviewers are requested (often automatically via CODEOWNERS), comments arrive, you push more commits, approval lands, and someone presses a merge button.
The forge adds the parts Git has no opinion about: required approvals, required status checks, branch protection, merge queues, automatic branch deletion. All of them are policy, and all of them are configured per repository — which is why “why can’t I push to main” is a forge question, not a Git question.
Topic 2: The Three Merge Buttons
Each produces a different history. Mixing them at random is the main reason a repository’s log becomes unreadable.
Merge commit — every commit from the branch, plus a merge commit joining the two lines.
Keeps: the true sequence of work, and the fact that these commits were one feature.
Costs: a busy repository’s graph becomes hard to follow.
Reverting the whole feature: one git revert -m 1.
Squash and merge — all the branch’s commits become one commit on main.
Keeps: a clean, linear main where each commit is one reviewed change.
Costs: the intermediate commits are gone; a bisect lands on a large commit; git branch -d will report the branch as unmerged because no ancestry link exists.
Best when: branches are short and contain “wip” commits nobody needs to keep.
Rebase and merge — commits are replayed onto main with no merge commit.
Keeps: linear history and individual commits.
Costs: every commit gets a new hash, so the commits on main are not the ones that were reviewed; and each replayed commit must apply cleanly.
Best when: the team already writes clean, self-contained commits.
Choose one per repository, configure the forge to allow only that one, and enable automatic branch deletion on merge. The decision matters less than the consistency.
Topic 3: Making a Change Reviewable
Review quality collapses with size. The strongest predictors of a useful review are small diffs and a clear description — and both are the author’s job.
Size. Under ~400 lines gets real review; over ~1,000 gets “LGTM”. If a change is genuinely large, split it:
- Mechanical change first (rename, move, format), on its own, marked as such.
- Then the behavioural change, which is now readable.
- Or ship behind a feature flag in several PRs, each individually safe.
Description. The template that works:
## What
One paragraph. What changes for a user or a caller.
## Why
The reason. Link the ticket, but do not delegate the explanation to it.
## How
Only the non-obvious parts. Why this approach and not the obvious one.
## Testing
What you ran. What you could not test and why.
## Risk / rollback
What could break, how you would notice, how to undo it.
The “risk / rollback” section is the one that separates a reviewed change from a rubber-stamped one, and it takes two lines.
Commits within the PR. Each commit should build and pass tests. git rebase -i --exec 'make test' verifies that before you ask anyone to look.
Topic 4: Responding to Review Without Destroying It
The mistake that wastes the most reviewer time: force-pushing a rebased branch mid-review. Every comment is now attached to commits that no longer exist, and the “what changed since I last looked” view is destroyed.
Do this instead:
git commit --fixup=a1b2c3 # a new commit, marked as fixing an earlier one
git push # no force needed — reviewer's diff still works
# ...after approval:
git rebase -i --autosquash main
git push --force-with-lease # once, at the end
The reviewer sees incremental changes during the review; main receives a clean series. If the repository squashes on merge anyway, you can skip the final rebase entirely — the forge does it.
Reviewing other people’s code, briefly, because it is half the workflow:
- Pull the branch and run it when the change is non-trivial. Reading a diff is not the same as using the thing.
- Distinguish blocking from non-blocking. Prefix suggestions with “nit:” so the author knows what actually stops the merge.
- Review the tests as carefully as the code. A test that cannot fail is worse than no test.
- Ask questions rather than issuing instructions when you are not certain. “What happens if this is nil?” is faster than a wrong assertion.
- Approve when it is good enough to ship, not when it is what you would have written.
Topic 5: Merge Queues and Semantic Conflicts
Two PRs can each be green against main and broken together. One renames a function; the other adds a caller. Git reports no conflict — the text does not overlap — and CI passed on both branches, each tested against a main that predates the other.
This is a semantic conflict, and it is the reason merge queues exist. A merge queue tests each PR against main plus the other PRs ahead of it in the queue, and only merges if that combination is green.
Without a queue, the cheap approximations:
- Require branches to be up to date with
mainbefore merging (the forge can enforce this). - Run CI on the merge result, not on the branch tip — most CI systems do this by default; check yours.
- Keep branches short, which reduces the window in which a semantic conflict can form.
Topic 6: Forks, Upstreams and Contributing Outward
The fork model is how open source works and how some companies isolate write access:
# after forking on the forge
git clone git@github.com:you/repo.git
cd repo
git remote add upstream git@github.com:org/repo.git
# keep your fork current
git fetch upstream
git switch main
git merge --ff-only upstream/main
git push origin main
# work on a branch, never on main
git switch -c fix/typo-in-readme
git push -u origin fix/typo-in-readme
gh pr create --repo org/repo --base main
Why never work on your fork’s main: it makes syncing with upstream a merge instead of a fast-forward, and every subsequent PR carries your unrelated commits.
Etiquette that gets a contribution merged, which is mostly project-specific and always worth checking first: read CONTRIBUTING.md, match the existing commit message style, sign off if the project uses DCO (git commit -s), keep the PR to one concern, and open an issue first for anything large. A maintainer’s scarcest resource is attention, and a 900-line unsolicited refactor consumes it without permission.
Try it yourself: merge three trivial PRs into a scratch repository — one with each button — then run git log --graph --oneline --all. The three shapes are unmistakable, and seeing them side by side makes the team-standard conversation concrete rather than theoretical.
Common mistake: treating a PR as a checkpoint at the end of the work rather than a communication tool during it. Open it as a draft on the first commit. CI starts running, the direction is visible before three days are spent on it, and the eventual review is against something nobody is surprised by.