Context: When Does a Git Workflow Actually Matter?
Back when I coded solo, I only ever used the main branch and committed with messages like “fix”, “update”, “try again” — nobody complained. But when I joined a 3-person team, things quickly fell apart: code was overwriting each other, nobody knew who changed what, and a deploy would break a feature someone else had just finished that morning.
The problem wasn’t that the team was working poorly — we just lacked shared conventions. Without a workflow, everyone uses Git their own way, and “accidents” are only a matter of time.
I once lost critical code because of a mistaken force push — pushed to main instead of a feature branch, wiping out 2 days of a colleague’s work. Since then, I’ve been very careful with git push --force and established some hard rules for the team. This article shares the approach I currently use — lightweight enough to not require constant maintenance, but structured enough to avoid trouble when working in a group.
Setup: Preparing Your Git Environment Before You Start
Configure Git User and Editor
The first step — and the most commonly skipped — is configuring your user information correctly. Without this, your commits will show a strange name or wrong email, making them very hard to trace later:
git config --global user.name "Your Name"
git config --global user.email "[email protected]"
git config --global core.editor "nano" # or vim, code --wait
git config --global init.defaultBranch main
If you work with multiple repos and multiple accounts (personal + work), use local config instead of global to avoid mix-ups:
# Run inside the work repo directory — applies only to that repo
git config user.email "[email protected]"
Aliases That Save You Time Every Day
These are the aliases I type every single day — they save quite a few keystrokes:
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.undo "reset HEAD~1 --mixed"
Once set up, git lg displays your commit history as a tree — very handy for seeing where your current branch stands relative to main.
Initialize a Repo with the Right .gitignore from the Start
git init
cat > .gitignore << EOF
node_modules/
.env
*.log
dist/
__pycache__/
.DS_Store
EOF
git add .gitignore
git commit -m "chore: init project structure"
In Practice: A Real-World Workflow for Individuals and Small Teams
Branching Strategy: Simple but Sufficient
For teams under 5 people, I’ve found this model works best — not as complex as full GitFlow, but not too loose either:
- main — production branch, only accepts code via Pull Requests, no direct commits
- develop — integration branch, tested before merging into main
- feature/feature-name — branch for developing individual features
- hotfix/bug-name — emergency bug fix branch cut from main
Working on a side project solo? Just main + feature/* is enough. No need for develop when there’s only one person.
# Create a feature branch from develop
git checkout develop
git pull origin develop
git checkout -b feature/add-login-feature
# Work, commit in small incremental steps...
git add src/login.py
git commit -m "feat: add login form with email validation"
git add tests/test_login.py
git commit -m "test: add unit tests for login validation"
# When done, push to remote to create a PR
git push origin feature/add-login-feature
Commit Convention: A Standard the Whole Team Must Follow Consistently
I use Conventional Commits — a simple format, easy to read in history, and it enables automatic changelog generation:
# Format: <type>(<scope>): <description>
git commit -m "feat: add user authentication via JWT"
git commit -m "fix(api): handle null response from payment gateway"
git commit -m "docs: update README with docker setup guide"
git commit -m "refactor: extract email validator to separate module"
git commit -m "chore: upgrade dependencies to latest stable"
The most commonly used types:
feat— new featurefix— bug fixdocs— documentation changes onlychore— maintenance tasks: update deps, CI config, etc.refactor— code restructure without adding features or fixing bugstest— adding or updating tests
Looking back at the history 3 months later, you’ll immediately know what each commit did — no need to open individual files and guess. This is the aspect of commit conventions I’ve found most valuable in practice. If you want to take this further, Git Hooks can automatically enforce these conventions so no one on the team accidentally skips them.
Pull Requests and Code Review in Small Teams
Even a 2-person team should use PRs — not because you distrust each other. Simply put: when reviewing someone else’s code, I regularly catch bugs the author missed, because they’ve been staring at that section for too long.
PR rules I’ve agreed on with my team:
- Every PR must have a short description: what it does, why, and how to test manually
- Keep PRs small — under 300 lines of changes is ideal and much easier to review
- At least 1 approval required before merging into
main - Never force push to
mainordevelopunder any circumstances
On that last point — I learned this lesson the hard way. Force pushing to main can wipe out the entire team’s commit history. I now always enable Branch Protection on GitHub for important branches. You should also consider signing your commits with GPG keys to add an extra layer of identity verification on top of branch protection:
# On GitHub: Settings → Branches → Add branch protection rule
# Branch name pattern: main
# ✅ Require a pull request before merging
# ✅ Require approvals: 1
# ✅ Do not allow bypassing the above settings
Rebase or Merge? Use the Right Tool for the Job
Rebase vs. merge — a familiar debate in every team. Here’s my simple rule for deciding:
- Use rebase when updating a feature branch with new code from develop — linear history, easier to read
- Use merge when merging a feature into develop/main — preserves context, easier to roll back an entire feature
# Update feature branch with the latest develop (rebase)
git checkout feature/add-login-feature
git fetch origin
git rebase origin/develop
# If there are conflicts, resolve them and continue
git rebase --continue
# Merge feature into develop after PR is approved
git checkout develop
git merge --no-ff feature/add-login-feature -m "merge: add login feature (#42)"
The --no-ff (no fast-forward) option creates a dedicated merge commit, making it easy to revert an entire feature if issues are discovered after merging. When rebase introduces conflicts, the techniques covered in resolving Git merge conflicts apply directly here as well.
Checking & Monitoring: Verifying Your Workflow Is Running Smoothly
View History as a Graph
git lg
# Sample output:
# * a3f2c1b (HEAD -> feature/login) feat: add form validation
# * 8d9e4f0 feat: add login form UI
# | * c4b1a2e (origin/develop) fix: resolve null pointer in API
# |/
# * 7f3d2c1 (develop) chore: update dependencies
At a glance you can see the feature branch is 1 commit behind develop — you need to rebase before creating a PR.
Periodic Stats and Audit
Before each release, I usually run these commands to do a final check:
# Who committed what in the past week
git shortlog -sn --since="1 week ago"
# View diff between two versions
git diff v1.0.0..v1.1.0 --stat
# Find which commit added or removed a specific piece of code
git log -S "function handleLogin" --oneline
# See which files are changed most frequently
git log --name-only --pretty=format: | sort | uniq -c | sort -rn | head -10
When you need to pinpoint exactly which commit introduced a regression, git bisect uses binary search to find the culprit commit fast — a lifesaver during pre-release audits.
Tag Every Release
Many teams skip this step — until they need an emergency rollback at 11 PM and have no idea which commit to point to:
# Create an annotated tag with a descriptive message
git tag -a v1.2.0 -m "Release v1.2.0 - add login feature, fix payment bug"
git push origin v1.2.0
# List all tags
git tag -l
# Roll back to an older tag in an emergency
git checkout v1.1.0
Protect Branches from Force Push on Self-Hosted Git
If you self-host Gitea or GitLab, you can use a server-side hook to block force pushes:
# File: /path/to/repo.git/hooks/pre-receive
#!/bin/bash
while read oldrev newrev refname; do
if [ "$refname" = "refs/heads/main" ]; then
# Check if this is a force push
if git rev-list $newrev..$oldrev | grep -q "."; then
echo "ERROR: Force push to main is not allowed!"
exit 1
fi
fi
done
chmod +x /path/to/repo.git/hooks/pre-receive
Summary
A good workflow doesn’t mean a complex one. For individuals or small teams under 5 people, staying consistent on these 4 points is all you need:
- A clear branch structure (main / develop / feature)
- Meaningful commit messages that follow a convention
- Code review via PRs — even with just 2 people
- Protect important branches from force pushes
I had to lose code once before these rules — which seemed tedious at the time — finally sank in. Starting with your next small project, setting up the workflow correctly from day one will save you a tremendous amount of headaches down the road.

