Home CS Fundamentals Version Control Best Practices — Commits, Branches and Workflows Explained

Version Control Best Practices — Commits, Branches and Workflows Explained

In Plain English 🔥
Imagine you're writing a 30-page school essay in Google Docs. Every time you finish a paragraph, Google secretly saves a snapshot — so if you accidentally delete three pages, you can rewind to yesterday's version in seconds. Version control is exactly that snapshot system, but for code. Instead of Google doing it automatically, you decide when to save a snapshot, what to name it, and who else can see it. Every professional software team on Earth uses this — and the habits you build around it will define how trustworthy you look as a developer.
⚡ Quick Answer
Imagine you're writing a 30-page school essay in Google Docs. Every time you finish a paragraph, Google secretly saves a snapshot — so if you accidentally delete three pages, you can rewind to yesterday's version in seconds. Version control is exactly that snapshot system, but for code. Instead of Google doing it automatically, you decide when to save a snapshot, what to name it, and who else can see it. Every professional software team on Earth uses this — and the habits you build around it will define how trustworthy you look as a developer.

Every software project eventually becomes a time machine problem. You ship a feature on Monday, a bug report lands on Wednesday, and by Friday you genuinely can't remember what the code looked like before you touched it. Without a disciplined approach to version control, that's not a inconvenience — it's a crisis. Teams lose work, bugs get shipped twice, and developers overwrite each other's changes without ever knowing it happened. This is not a rare edge case. It happens on real projects, at real companies, every single week.

Version control solves this by giving every change a permanent address in history. Every time you save a meaningful checkpoint (called a commit), the tool records exactly what changed, who changed it, and when. You can compare any two points in history, undo a disastrous change in seconds, and let ten developers work on the same codebase simultaneously without stepping on each other's toes. The tool most teams use today is Git — but the practices around Git are what separate senior engineers from people who just know the commands.

By the end of this article you'll understand what a commit really is and how to write one that makes sense six months later, how to use branches to work safely without breaking anything, how to think about merging versus rebasing, and the three common mistakes that silently wreck team codebases. You don't need any prior experience — just an appetite to build habits that will make every team you join immediately trust your work.

What a Commit Actually Is — And Why Tiny Commits Win Every Time

A commit is a permanent snapshot of your project at a specific moment. Think of it like a save point in a video game — except each save point also has a label you wrote, explaining exactly what changed and why. The label is called a commit message, and it's more important than most beginners realise.

The golden rule is: one commit, one logical change. Not one commit per day. Not one giant commit when the feature is done. One commit per idea. If you fixed a login bug and also tweaked a button colour, those are two separate commits — even if you did both in the same five minutes. Why? Because six months later, when a colleague hunts down the bug that the button-colour change introduced, they need to be able to isolate it instantly.

A good commit message follows this structure: a short subject line (under 50 characters) that completes the sentence 'If applied, this commit will...' — followed by a blank line and an optional body explaining why, not what. The diff already shows what changed. The message should explain the reasoning a future developer won't have access to.

Small, focused commits are also much easier to review in a pull request, much easier to revert if something goes wrong, and they make git blame (a command that shows who last touched each line) genuinely useful instead of pointing at one massive commit that changed 800 lines.

good_commit_workflow.sh · BASH
12345678910111213141516171819202122232425262728
# --- STEP 1: Check which files you've changed ---
git status
# Output shows modified files — review this before staging anything

# --- STEP 2: Stage ONLY the files related to one logical change ---
# Bad habit: git add .  (stages everything at once — loses granularity)
# Good habit: stage file by file, or by hunk
git add src/auth/login_validator.py
# Only staging the login fix — not the button colour change

# --- STEP 3: Confirm exactly what you're about to commit ---
git diff --staged
# Shows line-by-line what is staged — always read this before committing

# --- STEP 4: Write a commit message that explains WHY, not just WHAT ---
git commit -m "Fix login validator rejecting valid emails with plus signs"
# Subject line: under 50 chars, imperative mood, no full stop at the end

# --- STEP 5: Stage the second unrelated change as its own commit ---
git add src/components/submit_button.css
git commit -m "Update submit button colour to match new brand guidelines"

# --- STEP 6: View the clean, readable history you just created ---
git log --oneline
# Output:
# a3f9c12 Update submit button colour to match new brand guidelines
# 7e8b401 Fix login validator rejecting valid emails with plus signs
# 1d2a9ff Add password strength indicator to registration form
▶ Output
On branch feature/login-improvements
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: src/auth/login_validator.py

[feature/login-improvements a3f9c12] Fix login validator rejecting valid emails with plus signs
1 file changed, 3 insertions(+), 1 deletion(-)

[feature/login-improvements 7e8b401] Update submit button colour to match new brand guidelines
1 file changed, 2 insertions(+), 2 deletions(-)

a3f9c12 Update submit button colour to match new brand guidelines
7e8b401 Fix login validator rejecting valid emails with plus signs
1d2a9ff Add password strength indicator to registration form
⚠️
Pro Tip: Use git add -p for surgical stagingRun `git add -p` (patch mode) to stage individual *chunks* within a single file — not the whole file at once. This is the move when you've made two unrelated changes in the same file and want to split them into separate commits. Interviewers who dig into Git deeply will be impressed if you mention this unprompted.

Branching Strategy — How to Work Without Breaking Everything

A branch is a parallel universe for your code. The main branch (usually called main or master) represents the code that's live, working, and trusted. A feature branch is a copy of that universe where you can experiment, break things, and rebuild them — without touching the version everyone else is relying on.

The core idea is simple: never commit directly to main. Every piece of work — no matter how small — gets its own branch. You work there, you test there, you get it reviewed there, and only then does it merge back into main. This keeps main in a permanently deployable state, which is the entire point.

Branch names should be descriptive and follow a consistent pattern. A common convention is type/short-description — for example: feature/user-profile-page, bugfix/cart-total-rounding-error, or hotfix/payment-gateway-timeout. This tells every teammate at a glance what type of work is happening and what it's about, without opening a single file.

Keep branches short-lived. A branch that lives for three weeks becomes a nightmare to merge because the main branch has moved on. Aim to open a pull request within a day or two of starting a branch. If a feature is too large to finish that quickly, break it into smaller deliverable pieces — that's a design skill, not just a Git skill, and it signals seniority.

branching_workflow.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738
# --- Always start from an up-to-date main branch ---
git checkout main
git pull origin main
# Pulling first ensures your new branch starts at the latest point,
# not a stale snapshot from yesterday

# --- Create and immediately switch to your feature branch ---
git checkout -b feature/user-profile-avatar-upload
# The -b flag creates the branch AND switches to it in one step
# Naming convention: type/kebab-case-description

# --- Do your work, committing in small logical chunks ---
git add src/profile/avatar_uploader.py
git commit -m "Add image format validation to avatar uploader"

git add src/profile/avatar_uploader.py
git commit -m "Add file size limit of 5MB for avatar uploads"

git add tests/profile/test_avatar_uploader.py
git commit -m "Add unit tests for avatar upload validation rules"

# --- Push your branch to the remote so teammates can see it ---
git push -u origin feature/user-profile-avatar-upload
# The -u flag sets the upstream tracking — after this you just use 'git push'

# --- Check the current state of your branches ---
git branch -a
# Output:
#   main
# * feature/user-profile-avatar-upload
#   remotes/origin/main
#   remotes/origin/feature/user-profile-avatar-upload

# --- When the pull request is approved, delete the branch cleanly ---
git checkout main
git pull origin main
git branch -d feature/user-profile-avatar-upload
# -d (lowercase) only deletes if it's already been merged — a safe guard
▶ Output
Switched to a new branch 'feature/user-profile-avatar-upload'

[feature/user-profile-avatar-upload 4c1e8a3] Add image format validation to avatar uploader
1 file changed, 14 insertions(+)

[feature/user-profile-avatar-upload 9f2d7b1] Add file size limit of 5MB for avatar uploads
1 file changed, 5 insertions(+), 1 deletion(-)

[feature/user-profile-avatar-upload b3a0c55] Add unit tests for avatar upload validation rules
1 file changed, 38 insertions(+)

Branch 'feature/user-profile-avatar-upload' set up to track remote branch.

main
* feature/user-profile-avatar-upload
remotes/origin/main
remotes/origin/feature/user-profile-avatar-upload

Deleted branch feature/user-profile-avatar-upload (was b3a0c55).
⚠️
Watch Out: Long-lived branches are technical debtEvery day your branch stays open, `main` is moving forward without you. After two weeks, merging your branch can feel like defusing a bomb — conflict after conflict, context you've forgotten. The fix isn't to merge faster carelessly; it's to make branches smaller. If a feature takes three weeks, it should probably be three separate branches merged one at a time.

Merge vs Rebase — Choosing the Right Way to Combine Work

Once your feature branch is ready, you need to bring it back into main. There are two ways to do this: merge and rebase. They both achieve the same end result — your code ends up in main — but they create very different histories, and understanding the difference is a genuine mark of seniority.

A merge takes both branches and creates a new 'merge commit' that ties them together. History is preserved exactly as it happened — parallel work looks parallel in the log. It's honest, non-destructive, and safe for branches that other people are also working on. The downside is that a project with lots of branches and merges can produce a git log that looks like a tube map — hard to read linearly.

A rebase replays your branch's commits on top of the latest main, one by one, as if you had started your branch today instead of a week ago. The history comes out perfectly linear — no merge commits, no diverging lines. It's much easier to read. The downside: rebase rewrites commit hashes, which means if anyone else has your branch checked out, their history will conflict. The rule of thumb is: never rebase a branch that other people are working on.

The most common professional workflow is: rebase your feature branch on top of main before opening a pull request (to keep history clean), then use a regular merge (or a 'squash merge') when the pull request is approved. This gives you the readability of rebase with the safety of merge at the critical moment.

merge_vs_rebase.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940
# ============================================================
# SCENARIO: Your feature branch is behind main by 3 commits.
# You want to update your branch before opening a pull request.
# ============================================================

# --- Option A: MERGE (preserves full history, safe for shared branches) ---
git checkout feature/search-filter-improvements
git merge main
# Git creates a merge commit that joins the two histories
# Your log will show a 'Merge branch main into feature/...' commit
# Safe to use when teammates are also on this branch

# --- Option B: REBASE (clean linear history, only for your own branches) ---
git checkout feature/search-filter-improvements
git rebase main
# Git temporarily removes your commits, fast-forwards to latest main,
# then replays your commits on top one by one
# Your commit hashes CHANGE — never do this on a shared branch

# --- If a conflict occurs during rebase, Git pauses and tells you ---
# CONFLICT (content): Merge conflict in src/search/filter_engine.py
# Step 1: Open the file, resolve the conflict markers manually
# Step 2: Stage the resolved file
git add src/search/filter_engine.py
# Step 3: Continue the rebase (NOT git commit — git rebase --continue)
git rebase --continue
# Step 4: If you want to abandon the whole rebase and go back to before
git rebase --abort

# --- After rebase, push requires --force-with-lease (NOT --force) ---
git push --force-with-lease origin feature/search-filter-improvements
# --force-with-lease is safer than --force:
# it refuses to overwrite if someone else has pushed to the branch since your last fetch

# --- View how clean the rebased log looks vs a merged log ---
git log --oneline --graph
# Rebased output (clean, linear):
# * d9f3e11 Add price range filter to search results
# * c7a2b04 Add category multi-select to search sidebar
# * 8e1f9a0 (origin/main, main) Add pagination to product listing
▶ Output
First, rewinding head to replay your work on top of it...
Applying: Add category multi-select to search sidebar
Applying: Add price range filter to search results

* d9f3e11 Add price range filter to search results
* c7a2b04 Add category multi-select to search sidebar
* 8e1f9a0 (origin/main, main) Add pagination to product listing
* 3b7c5d2 Fix checkout total display on mobile
* 1a4e8f6 Add order confirmation email template
🔥
Interview Gold: The golden rule of rebaseNever rebase commits that exist on a public or shared branch. If you rebase and force-push a branch that a teammate has checked out, their local copy will have completely different commit hashes for the same changes — causing confusion and lost work. The phrase to memorise: 'Rebase your local work, merge the public work.'

.gitignore, README, and the Habits That Make Teammates Love You

The practices covered so far — clean commits, short-lived branches, thoughtful merging — are the big ones. But there's a set of smaller habits that separate developers who 'know Git' from developers who 'use Git professionally'. These habits are often what interviewers probe for when they ask 'tell me about your version control workflow.'

First: every repository needs a .gitignore file before the first commit. This file tells Git which files to completely ignore — things like compiled binaries, log files, API keys stored in .env files, and IDE configuration folders like .idea/ or .vscode/. Committing these files is at best noise and at worst a security disaster. The website gitignore.io generates ready-made .gitignore files for any language or framework.

Second: never commit credentials. Not even for a second. Even if you delete them in the next commit, they are permanently in Git history and can be extracted. Use environment variables or secret management tools instead. If you accidentally commit a secret, rotate the credential immediately — assume it's compromised.

Third: write a meaningful README. It should answer four questions: what does this project do, how do I run it locally, how do I run the tests, and who do I contact if something is broken. A project with a clear README signals a professional codebase. A project without one signals chaos.

Finally: tag your releases. When code goes to production, run git tag -a v1.4.0 -m "Release 1.4.0 — adds avatar upload and search filters". Tags create permanent, named markers in history so you can always check out exactly what was running in production on any given day.

professional_repo_setup.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
# ============================================================
# Setting up a professional repository from scratch
# ============================================================

# --- 1. Initialise the repository ---
mkdir ecommerce-platform
cd ecommerce-platform
git init

# --- 2. Create a .gitignore BEFORE your first commit ---
cat > .gitignore << 'EOF'
# Python compiled files — not needed in version control
__pycache__/
*.pyc
*.pyo

# Virtual environment — each developer creates their own
venv/
.env/

# Environment variables — NEVER commit secrets
.env
.env.local
.env.production

# IDE configuration — personal to each developer's setup
.idea/
.vscode/
*.swp

# Build output — regenerated from source, not source itself
dist/
build/
*.egg-info/

# OS files
.DS_Store
Thumbs.db

# Log files — these grow forever and belong nowhere near git
logs/
*.log
EOF

# --- 3. Create a professional README ---
cat > README.md << 'EOF'
# Ecommerce Platform

A Python-based ecommerce backend with product search, cart management, and Stripe payments.

## Running Locally

```bash
python -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env       # Fill in your own values
python manage.py runserver
```

## Running Tests

```bash
pytest tests/ -v
```

## Contact

Owner: platform-team@yourcompany.com
EOF

# --- 4. First commit with both essential files ---
git add .gitignore README.md
git commit -m "Initial project setup with gitignore and README"

# --- 5. Tag a release when code hits production ---
git tag -a v1.0.0 -m "Release 1.0.0 — initial launch with product listing and cart"
git push origin v1.0.0
# Now this exact state of the code is permanently labelled

# --- 6. Verify the tag exists ---
git tag -l
# Output: v1.0.0

git show v1.0.0 --stat
# Shows the commit the tag points to, the tag message, and files changed
▶ Output
Initialized empty Git repository in /projects/ecommerce-platform/.git/

[main (root-commit) 2a9c4f1] Initial project setup with gitignore and README
2 files changed, 28 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md

Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 1.02 KiB | 1.02 MiB/s, done.
Total 3 (delta 0)
To github.com:yourteam/ecommerce-platform.git
* [new tag] v1.0.0 -> v1.0.0

v1.0.0

tag v1.0.0
Tagger: Your Name <you@yourcompany.com>
Date: Wed Nov 8 14:32:01 2023 +0000

Release 1.0.0 — initial launch with product listing and cart

commit 2a9c4f1
2 files changed, 28 insertions(+)
.gitignore | 22 ++++++++++++++++++++++
README.md | 6 +++++++++++++++++++
⚠️
Watch Out: Secrets in git history live foreverDeleting a committed API key in a follow-up commit does NOT remove it from history. Anyone with a clone of the repo can run `git log -p` and see every version of every file ever committed. If you commit a secret, treat it as fully compromised — rotate it immediately, then use a tool like `git filter-repo` to scrub the history if it's a private repo. If it's a public repo, assume the key has already been harvested by automated scanners.
Aspectgit mergegit rebase
History shapeNon-linear — shows branches diverging and joiningLinear — looks like one straight line of commits
Creates extra commitsYes — adds a merge commit to join branchesNo — replays your commits directly on top of the target
Safe on shared branchesYes — does not rewrite existing commitsNo — rewrites commit hashes, breaks others' local copies
Conflict resolutionResolve once in the merge commitResolve once per replayed commit (can be more work)
Readability of git logCan become complex with many branchesClean and easy to follow chronologically
Best used whenMerging a completed PR into mainUpdating your private feature branch before opening a PR
Force push needed afterNoYes — use --force-with-lease, never bare --force

🎯 Key Takeaways

  • One commit = one logical change. If you can't summarise your commit in one imperative-mood sentence under 50 characters, it's probably two commits.
  • Never commit directly to main. Always branch, always get a review — even if you're the only developer. Your future self is your reviewer.
  • Rebase rewrites history — so only rebase branches that exist only on your own machine. The moment a branch is shared or pushed to origin and reviewed by others, use merge.
  • Secrets committed to Git are compromised immediately — not when someone finds them, but the moment they're pushed. Rotate first, clean history second, always.

⚠ Common Mistakes to Avoid

  • Mistake 1: Committing directly to main — The symptom is that one broken commit takes down the entire team's workflow, or a feature half-finished gets deployed accidentally. The fix is a branch protection rule: in GitHub, go to Settings > Branches > Add rule > check 'Require a pull request before merging'. This physically prevents anyone pushing to main without a review.
  • Mistake 2: Writing vague commit messages like 'fix bug' or 'changes' — The symptom is that three months later, git log tells you nothing useful and git blame is equally useless. You have to open every commit to find the one that broke something. The fix is to adopt the imperative-mood rule immediately: every message completes the sentence 'If applied, this commit will ___'. 'Fix login validator rejecting emails with plus signs' is immediately clear. 'Fix bug' is noise.
  • Mistake 3: Committing node_modules, .env, or build artifacts — The symptom is a bloated repository that takes minutes to clone, and worse, potential API keys or passwords visible in the history forever. The fix is to create a comprehensive .gitignore before your very first commit. Use gitignore.io to generate one for your specific language and framework — paste it in as the very first file you add to any new repository.

Interview Questions on This Topic

  • QWalk me through your typical Git workflow when starting a new feature — from the moment you get the ticket to the moment the code is in production.
  • QWhat is the difference between git merge and git rebase, and when would you choose one over the other on a team project?
  • QIf a teammate accidentally committed AWS credentials to a public GitHub repository, what are the exact steps you'd take in the next five minutes?

Frequently Asked Questions

How often should I commit my code?

Commit every time you complete one logical, self-contained unit of work — not on a time schedule. That might mean three commits in an hour or one commit in an afternoon. The question to ask yourself is: 'Could this commit be reverted in isolation without breaking anything else?' If yes, it's a good commit boundary.

What is the difference between git pull and git fetch?

git fetch downloads the latest changes from the remote repository but does NOT apply them to your working files — it just updates your local knowledge of what the remote looks like. git pull does a fetch AND immediately merges those changes into your current branch. A safer habit is to run git fetch first, inspect what changed with git log origin/main, and then decide whether to merge — this avoids surprise conflicts landing in your code unannounced.

Should I use Git GUI tools or learn the command line?

Learn the command line first — without exception. GUI tools hide what's really happening, and when something goes wrong (and it will), you need to understand the underlying model to fix it. Once you're comfortable with the commands, a GUI like GitKraken or the GitHub Desktop app is a perfectly reasonable addition for visualising branch history. But Git from the terminal should always be your foundation.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousSoftware Testing TypesNext →Documentation Best Practices
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged