Git Branching Workflows for Project Teams: Feature Branches, Pull Requests and Conflict Resolution

Tired of teammates overwriting each other's code on shared main? This guide sets up a team Git workflow: feature branches with naming conventions, pull request templates and review checklists, a merge-vs-rebase decision table, step-by-step merge conflict resolution, branch protection on main, and a diagnosis table for the errors every student team hits.

Written by Projectech15 min readPublished
For B.E./B.Tech student project teams collaborating on code with Git and GitHub Topics: Git, GitHub, Pull Requests, Merge, Rebase
Illustration of two student collaborators with laptops and a colorful branching diagram of git branches merging together.
Illustration generated for this guide.
In this guide

Every student team has a version of this story. Four people, one repository, everyone pushing to main. It works for a week. Then someone's login page disappears, overwritten by a teammate's older copy. Someone else "fixes" it by force-pushing, which deletes a third person's committed work. The night before the demo, nobody knows which version of the code actually runs.

The fix is not "be more careful". The fix is a branching workflow: a small set of rules about where code lives, how it gets reviewed, and how it reaches main. This guide gives your team that workflow — feature branches, pull requests, merge vs rebase, and conflict resolution — with the exact commands and the mistakes to avoid. If you are still getting comfortable with the basics, read the companion guide on using Git and GitHub for your final-year project first; this guide starts where that one ends.

Scope note: this guide covers the workflow for a typical 2–5 person student team on GitHub. The commands are standard Git and work identically on GitLab or Bitbucket.

The mental model: branches are just pointers

A branch in Git is not a copy of your code. It is a movable pointer to a commit — a sticky note on a snapshot. Creating a branch is nearly instant and costs almost nothing, which is why the workflow below treats branches as disposable: create one per task, merge it, delete it.

git log --oneline --graph --all -8
* 9f3a1c2 (HEAD -> main) Merge pull request #14: payment validation
|\
| * 4b2e8d1 (feature/payment-validation) Reject expired cards before submit
| * 77aa0f3 Add card expiry check to checkout form
|/
* c81d4f5 Add order history page
* 2e6b9a1 Fix mobile nav overlap

Read this bottom-up: main moved forward with the order history page and a nav fix, while the payment-validation work happened on its own branch, then merged back in. Nothing on main was ever half-finished. That is the entire goal of the workflow.

The workflow: feature branches + pull requests

One rule governs everything: nobody commits directly to main. All work happens on short-lived branches and enters main through a pull request (PR) that at least one teammate has reviewed.

Branch naming that survives a semester

Agree on prefixes on day one. Future-you, scrolling through 40 stale branches before the viva, will be grateful:

Prefix Used for Example
feature/ New functionality feature/upi-payment, feature/admin-dashboard
fix/ Bug fixes fix/login-crash-android-12, fix/typo-homepage
docs/ Documentation only docs/api-readme, docs/report-figures
chore/ Build, config, dependencies chore/upgrade-react-18
hotfix/ Urgent fix to a demo/release hotfix/demo-crash-on-startup

Keep names lowercase, hyphenated, short. Never name a branch after a person (rahul-work) — branches describe work, not owners, and work gets reassigned.

The daily loop

# 1. Start from a fresh main
git checkout main
git pull origin main

# 2. Create your branch
git checkout -b feature/upi-payment

# ... write code, then commit in small logical chunks ...
git add -A
git commit -m "Add UPI intent handler for checkout"

# 3. Push the branch (first push sets the upstream)
git push -u origin feature/upi-payment

Then open the pull request on GitHub: base main, compare your branch, fill in the description template (below), request a reviewer, and wait for a review before merging. While you wait, start the next task on a new branch — never stack unrelated work onto a branch that is under review.

What a good pull request looks like

A PR is a handoff document. The reviewer was not watching you code; the description must let them review in five minutes:

## What
Adds UPI payment option to the checkout flow.

## Why
Cash-on-delivery was the only option; the project requires
at least two payment modes.

## How to test
1. Add any item to cart, go to checkout
2. Select UPI, enter test UPI id reviewer@example
3. Confirm the success screen shows the transaction reference

## Screenshots
[before/after or it didn't happen]

Review checklist for the reviewer — check these, not just "looks fine":

  • Does it do what the description claims? (Run the test steps.)
  • Does main still build and run after merging this? (Or at least the affected part.)
  • No secrets, API keys, or personal credentials in the diff.
  • No commented-out blocks of dead code or leftover console.log / debug prints.
  • Follows the team's existing patterns (naming, file layout) rather than inventing new ones.

Small PRs get reviewed; big ones get skimmed. A PR that touches 12 files across 3 features will be approved without reading. Keep branches focused so PRs stay under ~300 changed lines whenever possible. If a branch grows past that, split it — merge the first half, then continue.

Merge vs rebase: the decision table

When your branch is reviewed and ready, there are two ways to integrate it. They produce different history, and teams argue about this endlessly, so here is the practical version:

Merge Rebase
What it does Creates a merge commit joining two histories Replays your commits on top of main, as if you started later
History shape Truthful but bushy: shows exactly when work happened Linear and clean: reads like a single sequence
Conflicts Resolved once, in the merge commit Resolved per-commit, can recur across commits
Safe when Always — it never rewrites existing commits Only on branches you alone use and nobody else has pulled

The rule for student teams: merge your pull requests (GitHub's "Merge pull request" button), and rebase only your own local feature branch onto updated main before opening the PR. That combination gives you clean PRs without ever rewriting shared history.

# Safe rebase: your private branch, catching up with main
git checkout feature/upi-payment
git fetch origin
git rebase origin/main
# resolve any conflicts, then:
git push --force-with-lease

The golden rule of rebasing, stated plainly: never rebase (or force-push) a branch someone else is using. Rewriting a commit changes its identity; your teammate's local copy still points at the old one, and reconciling the two is the fastest way to lose work. --force-with-lease instead of --force is the seatbelt: it refuses to push if someone else updated the branch since you last fetched.

Merge conflicts: reading the markers

A conflict is Git saying "you both changed the same lines and I cannot decide". It is normal, not an emergency. When it happens, Git marks the file:

<<<<<<< HEAD
const API_URL = "https://api.projectech.example/v1";
=======
const API_URL = "https://staging-api.projectech.example/v1";
>>>>>>> feature/upi-payment

Everything between <<<<<<< and ======= is your current branch's version; between ======= and >>>>>>> is the incoming version. Resolution is a three-step loop:

  1. Open each conflicted file (git status lists them under "both modified") and decide, hunk by hunk, what the correct final code is. It is often neither version verbatim — e.g. keep the production URL but adopt the new timeout from the other side.
  2. Delete the markers and verify the file is syntactically valid (run it, or at least open it in the editor — VS Code highlights leftover markers and offers "Accept Current / Incoming / Both" buttons for the 3-way view).
  3. Mark resolved and continue:
    git add <the-resolved-files>
    git rebase --continue   # if you were rebasing
    # or, if you were merging:
    git commit              # completes the merge commit
    

If you get lost mid-resolution, git rebase --abort (or git merge --abort) restores everything to before you started — no harm done. Conflicts feel scary the first twice and routine forever after.

The conflict-proofing habits that actually work: pull main into your branch (via rebase) before opening the PR, keep branches short-lived (a branch open for three weeks will conflict with everything), and when two people must touch the same file, say so in the group chat first. Splitting work sensibly across modules is covered in the guide on group project work split — the workflow here assumes that split is sane.

Protect main: the five-minute setting that prevents disasters

On GitHub: repository Settings → Branches → Add branch protection rule, pattern main, then enable:

  • Require a pull request before merging — makes direct pushes to main impossible, including your own.
  • Require approvals: 1 — one teammate must approve before merge.
  • Dismiss stale approvals on new commits — pushing more changes after approval re-requires review.
  • Require status checks to pass — if you have CI (even just a build check), a red build blocks the merge.

Do this on day one, before the first real code lands. Adding protection after someone has already force-pushed main twice is closing the gate after the code escaped.

.gitignore: what never belongs in the repo

Every student stack has files that must not be committed. Create .gitignore in the first commit:

# Dependencies (reinstallable, huge)
node_modules/
venv/
__pycache__/
*.pyc

# Secrets (the big one — see below)
.env
*.key
*.pem

# Build outputs
dist/
build/
*.apk
*.aab

# OS and editor noise
.DS_Store
.idea/
.vscode/

The secrets incident, and how to survive it: sooner or later someone commits an API key or a Firebase config. When it happens: (1) revoke the key immediately in the provider's console — removing it from the repo is not enough, it is in history forever; (2) remove it from the code and use environment variables; (3) clean history only if the repo is private and small (git filter-repo or BFG), and tell the team to re-clone. Prevention beats cleanup: .env in .gitignore from commit one, and a .env.example with dummy values showing which variables the project needs.

Diagnosis table: the errors you will definitely see

Error message What it means Fix
Your branch is behind 'origin/main' Teammates merged PRs since you last pulled git pull --rebase origin main, resolve conflicts, continue
failed to push some refs … updates were rejected Same as above, at push time Pull/rebase first, then push
Your local changes would be overwritten by merge Uncommitted changes conflict with incoming ones git stash, pull, git stash pop — or commit first
refusing to merge unrelated histories Two repos with no common ancestor (e.g. local init + GitHub template) git pull origin main --allow-unrelated-histories, then resolve
CONFLICT (content): Merge conflict in … Both sides edited the same lines Edit markers, git add, continue (see conflict section)
You are in 'detached HEAD' state You checked out a commit, not a branch; new commits here are orphaned Create a branch: git checkout -b rescue-branch, or go back to main
Permission denied (publickey) SSH key not set up or not added to the account Add the key under GitHub Settings → SSH keys; test with ssh -T git@github.com
Merge button greyed out on the PR Branch protection failing: missing approval or red checks Get the review; fix the failing check; re-request review

Two recovery tools worth knowing before you need them: git stash shelves uncommitted work safely ("I need a clean tree for two minutes"), and git reflog shows where every branch pointer has been — including commits you thought you deleted. Almost nothing is unrecoverable within the reflog's ~90-day window.

Commit hygiene: write commits a teammate can actually read

In a shared repo, your commits are messages to the team. Three habits separate a readable history from archaeology:

  1. Imperative subject line, ~50 characters: "Add UPI intent handler", not "added stuff" or "final final v2". Git tooling and GitHub both display the subject prominently; vague subjects make git log useless when you are hunting a regression at midnight.
  2. Atomic commits: one logical change per commit. "Add login API client" and "Fix typo in README" are two commits, not one. Atomic commits make reverts safe — reverting a 500-line "everything" commit is how working features die.
  3. Body explains the why: the subject says what, the body says why, in one or two lines. "Use exponential backoff for FCM retries — the server rate-limits aggressive clients during peak hours" stops the next person from "optimising" it away.

Many teams adopt Conventional Commits prefixes, which also power automatic changelogs:

Prefix Meaning Example
feat: New feature feat: add UPI payment option to checkout
fix: Bug fix fix: crash when cart is empty on Android 12
docs: Documentation only docs: add API setup steps to README
refactor: Code change, no behaviour change refactor: extract payment validation to helper
chore: Build, config, dependencies chore: upgrade React to 18.3
test: Tests test: add checkout validation unit tests

You do not need to adopt this on day one, but agree on something — even "imperative subject lines" as the only rule beats chaos.

Forks vs a shared repository

Two collaboration models exist on GitHub. For a student team the choice is easy, but it helps to know why:

  • Shared repository + feature branches (this guide's model): everyone has write access, branches are cheap, PRs stay in one repo. Right for a trusted team of 2–5 equal contributors.
  • Fork + pull request: each contributor works in their own fork and PRs back to the original. Right for open-source projects with untrusted or occasional contributors, where the maintainer never grants write access.

Student teams sometimes fork "so we don't break each other's code", then drown in keeping forks synced — extra remotes, fetching upstream, rebasing across repositories. The shared repo with branch protection gives you the same safety with a tenth of the ceremony.

Tagging the demo release

When the code that will be demoed is merged and green, freeze it with a tag:

git tag -a v1.0 -m "Viva demo release: all features frozen"
git push origin v1.0

Tags are immutable pointers — unlike branches, they never move. If a last-minute "improvement" breaks the demo build the night before, git checkout v1.0 restores the known-good state in seconds. On GitHub, a tag can be promoted to a Release with the built AAB/APK attached — and if the report PDF goes with it, the project report guide covers structuring that document — a single page holding everything an examiner might ask for.

A minimal CI check

Continuous integration runs your build on every pull request, so a broken main becomes structurally impossible rather than merely discouraged. This GitHub Actions workflow builds a Node project on each PR — adapt the last two lines for your stack (./gradlew assembleDebug for native Android, flutter build apk --debug for Flutter):

name: build-check
on: [pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build

Even this minimal version catches the classic "works on my machine" PR where someone forgot to commit a new file. The required-status-check branch protection from earlier then blocks merging until it passes.

Reviewing code you do not fully understand

Junior reviewers often approve PRs they did not understand because asking feels slow. Better approaches:

  • Check out the branch and run it. With the GitHub CLI: gh pr checkout 14. Five minutes of clicking through the actual change beats twenty minutes of squinting at a diff.
  • Ask questions as review comments instead of approvals. "Why does this retry 5 times instead of 3?" is a legitimate review outcome — it either teaches you something or catches a bug.
  • Review in two passes. First the description and test steps (does the PR do what it claims?), then the diff itself. Most rubber-stamp approvals skip pass one, which is the pass that catches wrong features.

Three more errors you will meet eventually:

  • warning: LF will be replaced by CRLF — line-ending noise when Windows and Linux teammates collaborate. Silence it per-repo with a committed .gitattributes file containing * text=auto.
  • fatal: not a git repository — the command ran outside any repository. cd into the project root (where .git/ lives) and retry.
  • Conflict in a binary file (image, PDF, APK): Git cannot merge binaries. Decide which side wins — git checkout --ours -- <file> or --theirs — then git add and continue.

Keeping the repo small: binaries do not belong in Git

Git stores every version of every file forever. Commit a 40 MB APK or a 25 MB report PDF a few times and your clone becomes hundreds of megabytes — slow for everyone, painful on hostel Wi-Fi. Rules:

  • Never commit build outputs (*.apk, *.aab, build/ directories) — the .gitignore from earlier covers this.
  • Large assets go in GitHub Releases, attached to the version tag, not in the repo. The report PDF, the demo video link, and the signed AAB live next to v1.0, downloadable without cloning.
  • If you genuinely need versioned large files (a 200 MB dataset), that is what Git LFS (Large File Storage) is for — but for a final-year project, "put it in the release" is almost always the right answer.

A quick health check: git count-objects -vH shows the repo size. If it is over ~50 MB for a code project, find the committed binaries with git rev-list --objects --all | git cat-file --batch-check sorted by size, and move them to releases.

Team kickoff checklist

Do this in the first team meeting, before writing features:

  • One repository, one main branch; branch protection enabled (PR + 1 approval).
  • Branch naming prefixes agreed (feature/, fix/, docs/, chore/).
  • .gitignore committed covering dependencies, secrets, and build outputs.
  • Everyone has cloned via SSH (or HTTPS with a credential helper) and pushed a test branch.
  • PR description template saved (in .github/pull_request_template.md so GitHub prefills it).
  • Agreed: rebase only your own unshared branches; --force-with-lease never bare --force.
  • A README.md with setup steps, so a new clone runs in under 15 minutes.

Projects with a live web component — like the online code editor with live preview — are ideal for practising this workflow, because the frontend/backend split gives each teammate a natural branch boundary. The discipline compounds: teams that run this workflow spend their final month building features, while teams on shared-main chaos spend it reconstructing which code was whose.

More project guides

More in Web Development