Version Control Best Practices — Commits, Branches and Workflows Explained
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.
# --- 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
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
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.
# --- 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
[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).
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.
# ============================================================ # 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
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
.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.
# ============================================================ # 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
[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 +++++++++++++++++++
| Aspect | git merge | git rebase |
|---|---|---|
| History shape | Non-linear — shows branches diverging and joining | Linear — looks like one straight line of commits |
| Creates extra commits | Yes — adds a merge commit to join branches | No — replays your commits directly on top of the target |
| Safe on shared branches | Yes — does not rewrite existing commits | No — rewrites commit hashes, breaks others' local copies |
| Conflict resolution | Resolve once in the merge commit | Resolve once per replayed commit (can be more work) |
| Readability of git log | Can become complex with many branches | Clean and easy to follow chronologically |
| Best used when | Merging a completed PR into main | Updating your private feature branch before opening a PR |
| Force push needed after | No | Yes — 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.
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.