Conflicts in a long-running branch are the worst — you sit down to ship one small change and spend the next hour untangling someone else's rebase. The fix is not heroics. The fix is a boring, predictable workflow that you run the same way every time. Here's the path I actually follow, from a clean checkout to a merged PR, with no detours.
I write this with one assumption: you've cloned a Git repo before and you have a GitHub account. Beyond that, every command below is something I run multiple times a day. None of it is clever. The point is that none of it has to be.
01/Set up the repo
The first time you touch a repo, the goal is to land in a state where every later command does what you expect. That means a fresh clone, the right dependencies installed, your Git identity set, and a remote you can actually push to. Three minutes of setup saves three hours of debugging the wrong commit author later.
Start with the clone. I always use the SSH URL when I have keys configured — it skips the password prompt that breaks scripts and CI loops.
// Clone with SSH so push works without a password prompt git clone git@github.com:your-org/your-repo.git cd your-repo
Next, install dependencies. The exact command depends on the project — npm i for Node, uv sync for Python with uv, bundle install for Ruby. Read the README once and run whatever it says. If the README is silent, default to whatever is listed in the package manifest.
// Most JavaScript / TypeScript projects npm i // Python projects using uv uv sync
Now set your Git identity for this repo. Setting it locally (instead of globally) is a small kindness to your future self — work commits get your work email, personal commits get your personal email, and reviewers stop asking who that mystery contributor is.
git config user.name "Your Name" git config user.email "you@example.com"
Finally, verify the remote is what you expect. I've pushed to the wrong fork more than once. The verbose flag shows you both fetch and push URLs — they should both point at the repo you intend to contribute to.
git remote -v // origin git@github.com:your-org/your-repo.git (fetch) // origin git@github.com:your-org/your-repo.git (push)
origin for your fork and upstream for the canonical repo. Add upstream with git remote add upstream <url>, then pull from upstream and push to origin. It keeps the directionality obvious.One more thing before you start coding
Run the project once before you change a single line. Spin up the dev server, run the test suite, open the local URL. The point is to confirm the baseline works on your machine — because if you make a change and something breaks, you want to know whether you broke it or whether it was already broken when you cloned. I've burned an entire afternoon debugging a "regression" that turned out to be a stale dependency from before I even started.
The dev server command lives in the same place as the install command — usually a script in the package manifest. npm run dev for most Node projects, npm test for the test suite. If they both come up green, you have a known-good starting point. If either fails, fix that first or open an issue. Don't pile your change on top of an already-broken trunk.
02/Create a branch
Never commit directly to main. I know it's a one-line change. I know nobody will see. Do it anyway — branch every time. The discipline costs you four seconds and saves you the moment when one-line-change turns into three-file-change midway through and you have to untangle it from the trunk.
Branch names matter more than people admit. A good name tells the next person — including future you — what the branch is about without opening a single file. I use kebab-case, descriptive verbs, and I keep it under about forty characters. If a teammate scans the branch list and has to ask what fix-stuff does, the name failed.
Examples that work
fix-checkout-redirect-on-empty-cart— bug, area, conditionadd-stripe-webhook-handler— feature, what it addsrefactor-auth-context-to-hook— refactor, scope, direction
Examples that don't
aqsa-branch— who, not whatwip— work in progress on what?final-final-2— you know who you are
Create the branch and switch to it in one command. The -b flag tells git checkout to create the branch if it doesn't exist. Modern Git also accepts git switch -c which reads more naturally to me, but old habits die hard.
// Always start from a fresh main git checkout main git pull git checkout -b add-stripe-webhook-handler
Why feature branches? Three reasons. First, they isolate your change so a bad commit doesn't poison main. Second, they map cleanly to a PR, which makes review a single conversation about a single concern. Third — and this is the one nobody talks about — they let you walk away. If something more urgent comes in, you stash, switch to a new branch, and come back later with no mental tax. Trying to do the same thing with three unrelated changes piled on main is how you ship the wrong thing.
A clean branch is a clean review. A clean review is a fast merge. A fast merge is the whole point.
Keep branches short-lived
The longer a branch lives, the more painful the eventual merge. Every day your branch sits unmerged is another day for main to drift, for someone else to refactor the file you're editing, for a dependency to update, for the migration to land. Two days is fine. Two weeks is a future support ticket. If a piece of work is going to take longer than a few days, break it into smaller PRs you can ship behind a feature flag.
I aim for branches that are open for less than 48 hours from creation to merge. When that slips, I look at the diff and ask: is there a smaller, useful change in here that could ship today? Most of the time the answer is yes — a refactor, a config split, a stub endpoint — and slicing it off keeps the parent change small.
03/Raise the PR
You have a branch, you've made your changes, the tests pass locally. Now the work becomes communication: stage what you actually want, write a commit message that survives the squash, push, and open the PR. Each step has one job — don't fold them into each other.
Stage with git add -p when you can. The -p flag walks you through every hunk and asks if you want it. It catches the console.log you forgot, the config tweak that was for local debugging, and the unrelated file you touched while you were in there. Two extra minutes of staging saves you the "why is this here?" review comment.
// Walk every hunk — y to stage, n to skip, s to split git add -p // Or stage everything when you really mean everything git add .
Commit messages are documentation for the next person reading git log. I keep a 50-character subject line in the imperative mood — Add Stripe webhook handler, not Added, not Adding. If the change needs context, leave a blank line and write a body that explains why, not what. The diff already shows what.
// Short subject — this becomes the squash commit on merge git commit -m "Add Stripe webhook handler for failed payments"
Push with -u origin <branch> the first time. The -u sets the upstream so future git push and git pull on this branch don't need arguments. It's a one-time tax for ergonomics on every command after.
git push -u origin add-stripe-webhook-handler
Open the PR from the command line with gh. The web UI is fine, but the CLI keeps you in your terminal and lets you scaffold the body from a template file or pipe in notes from another tool. Title goes in --title, body in --body or --body-file.
gh pr create --title "Add Stripe webhook handler" --body-file .github/pr-template.md
PR titles are scanned in a list — keep them short and scoped. PR bodies are read by reviewers — keep them structured. I follow the same skeleton every time: a one-paragraph summary, a checklist of what changed, and a short test plan. Reviewers skim the summary, spot-check the checklist, and trust the test plan if it looks deliberate.
The PR body skeleton I reuse
- Summary — two sentences. What changed and why.
- Changes — bullet list of the actual modifications.
- Test plan — bullet list of what you ran and what you observed.
- Screenshots or logs — only when visual or output proves the change.
Linking issues and tagging reviewers
If your PR closes an issue, write Closes #123 in the body. GitHub picks this up and auto-closes the issue when the PR merges, which keeps your issue tracker honest without you having to remember to check things off. Fixes and Resolves work the same way — pick whichever reads best in the sentence.
Reviewers on autopilot is a smell. Tag the person who actually understands the area — usually whoever last touched the code (use git blame) or whoever owns the domain. If you're not sure, ask in chat first. A drive-by review from someone without context is worse than no review at all because it creates the illusion of safety.
Responding to review
When comments come in, batch your responses into a single push instead of force-pushing after every fix. Reviewers prefer to see "Updated based on review" once than to watch the PR refresh five times in ten minutes. Mark each thread resolved when you push the fix, and leave a one-line reply explaining the change if it isn't obvious from the diff. Threads left unresolved tell the next reviewer that something is still outstanding.
gh pr create --draft. Drafts skip CODEOWNERS notifications and signal "please don't look yet." Mark ready when you actually want eyes on it. Misusing draft status is the second-fastest way to annoy reviewers; opening for review before the build is green is the first.04/Sync after merge
The PR merged. The change is in main. Resist the urge to start the next thing from your now-stale feature branch. Ten seconds of cleanup keeps your local repo honest and your branch list short — both of which matter more after the third or fourth PR than they do today.
Switch back to main, pull the merged change, and delete the local branch. The -d flag refuses to delete an unmerged branch, which is the safety you want. Use -D only when you're sure — it force-deletes and won't protect you from losing work.
git checkout main git pull git branch -d add-stripe-webhook-handler
The remote branch is usually deleted automatically by GitHub when the PR merges (the "Automatically delete head branches" repo setting handles this). If your repo doesn't have that on, do it manually so the branch list on GitHub stays scannable.
// Only needed if GitHub didn't auto-delete the remote branch git push origin --delete add-stripe-webhook-handler
Rebase or merge?
On long-lived feature branches that fall behind main, you have two ways to catch up: rebase your branch onto the new main, or merge main into your branch. Rebase rewrites your branch's history so it appears to start from the new tip — clean linear log, but you have to force-push and any open PR review threads can lose their anchors. Merge keeps your branch's history intact and adds a merge commit — uglier log, but every reviewer comment stays where it was.
Rule of thumb: rebase when the branch is yours alone and the PR isn't open yet; merge when the PR is open or others have pulled the branch. When in doubt, merge — it's the less-destructive default.
Prune stale remote tracking refs
After a few weeks of branches coming and going, your local repo accumulates remote-tracking references for branches that no longer exist on the server. git fetch --prune cleans those out so git branch -a stops showing ghosts. I run it once a week out of habit; it takes half a second and keeps tab-completion useful.
// Remove local refs for branches deleted on the remote git fetch --prune
That's the whole loop. Clone, branch, commit, push, PR, merge, sync. Run it the same way every time and the rituals stop being rituals — they become the part of the day where you don't have to think, so you can save the thinking for the actual code.
The first few PRs you ship will feel slow. You'll second-guess the branch name. You'll rewrite the commit message twice. You'll worry the PR body sounds wrong. That's normal — it's the same feeling as the first time you wrote a function in a new language. By the tenth PR, the workflow disappears. By the fiftieth, you'll wonder how anyone ships software without it. The goal isn't to be fast at Git. The goal is to make Git invisible so the only thing left to think about is the change itself.