The Git Workflow That Saved My Sanity as a Solo Developer
I used to commit directly to main and regret it every time. Here's the system that fixed everything.
The Problem With "Just Commit to Main"
We've all done it:
# Make a quick fix
git add -A
git commit -m "fixed bug"
git push origin main
# ... 2 hours later ...
# Oh no, that broke something else
# And now production is broken
# And I can't remember what I changed
When you're a solo developer, there's no code review partner to catch your mistakes. You need your own process to be your safety net.
My Current Workflow
Here's the exact system I use for every project:
Branch Strategy
main (protected)
├── feature/xxx (new features)
├── fix/xxx (bug fixes)
└── refactor/xxx (code cleanup)
release/v*.*.* (for versioned releases, optional)
Rule: Never push directly to main. Ever.
The Complete Flow
# 1. Start work
git checkout -b feature/user-authentication
# 2. Work normally (commit often, messy commits are OK)
git add -A
git commit -m "wip: auth controller skeleton"
git add -A
git commit -m "wip: login endpoint"
git add -A
git commit -m "wip: jwt token generation"
# 3. Before merging: clean up history
git rebase -i main
# Pick, squash, reword into clean commits:
# "feat: add user authentication with JWT"
# "fix: handle expired tokens gracefully"
# 4. Run checks (automated)
npm test
npm run lint
# 5. Merge to main
git checkout main
git merge --no-ff feature/user-authentication
git push origin main
# 6. Clean up
git branch -d feature/user-authentication
Why --no-ff?
# --no-ff (no fast-forward) creates a merge commit
# This preserves branch history in git log:
git log --oneline --graph
* a1b2c3d (HEAD -> main) Merge pull request #42 from feature/user-auth
|\
| * e4f5g6h feat: add user authentication with JWT
| * f6g7h8i fix: handle expired tokens gracefully
|/
* j8k9l0m Previous main commit
# vs fast-forward (loses context):
* e4f5g6h (HEAD -> main) feat: add user authentication with JWT
* f6g7h8i fix: handle expired tokens gracefully
* j8k9l0m Previous main commit
The merge commit tells you why these changes were made. Six months later, you'll thank yourself.
Pre-Push Hook (My Safety Net)
This is the single most valuable thing in my setup:
#!/bin/sh
# .git/hooks/pre-push
# Runs before every push. Blocks push if any check fails.
echo "🔍 Running pre-push checks..."
# 1. Don't allow pushing to main directly
PROTECTED_BRANCHES="main master develop"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
for BRANCH in $PROTECTED_BRANCHES; do
if [ "$CURRENT_BRANCH" = "$BRANCH" ]; then
echo "❌ ERROR: Direct pushes to '$BRANCH' are not allowed!"
echo " Create a feature branch and merge instead."
exit 1
fi
done
# 2. Check for secrets (basic pattern match)
if git diff --cached --name-only | grep -qE '\.env$|credentials|secret'; then
echo "⚠️ WARNING: Possible secret file detected!"
read -p " Continue anyway? (y/N) " confirm
[ "$confirm" != "y" ] && exit 1
fi
# 3. Run tests if available
if [ -f "package.json" ] && grep -q '"test"' package.json; then
echo "🧪 Running tests..."
npm test
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Push aborted."
exit 1
fi
fi
# 4. Run linter if available
if [ -f "package.json" ] && grep -q '"lint"' package.json; then
echo "✨ Running linter..."
npm run lint
if [ $? -ne 0 ]; then
echo "❌ Lint errors found. Fix them or --no-verify (not recommended)."
exit 1
fi
fi
echo "✅ All checks passed. Pushing..."
exit 0
Make it executable: chmod +x .git/hooks/pre-push
This has saved me from pushing broken code more times than I can count.
Pre-Commit Hook (Automatic Cleanup)
#!/bin/sh
# .git/hooks/pre-commit
# Runs before every commit.
# Auto-fix formatting issues
if command -v npx &> /dev/null && [ -f "package.json" ]; then
# Prettier (if installed)
if npx prettier --version &> /dev/null; then
npx prettier --write . 2>/dev/null
git update-index -a
fi
# ESLint auto-fix (if configured)
if npx eslint --version &> /dev/null; then
npx eslint --fix . 2>/dev/null
git update-index -a
fi
fi
# Don't commit files larger than 1MB
MAX_SIZE=$((1024 * 1024))
FILES=$(git diff --cached --name-only | while read file; do
SIZE=$(stat -c%s "$file" 2>/dev/null || echo 0)
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
echo "$file ($((SIZE / 1024))KB)"
fi
done)
if [ -n "$FILES" ]; then
echo "⚠️ Large files detected:"
echo "$FILES"
echo "Consider using Git LFS for binary files."
fi
exit 0
Useful Git Aliases
# Add these to ~/.gitconfig
[alias]
# Quick status
s = status -sb
# Nice log view
lg = log --oneline --graph --decorate
# Better diff (with stats)
d = diff --stat -p
# Undo last commit (keep changes staged)
undo = reset --soft HEAD~1
# Delete all merged branches
clean-branches = "!git branch --merged | grep -v '\\*\\|main\\|master\\|develop' | xargs git branch -d"
# Show files changed in a branch
branch-changes = "!git log --oneline ..main --name-only | sort | uniq"
# Find a string in all commits
find-text = "!git rev-list --all | xargs git grep -F"
# Quick amend (for fixing last commit)
amend = commit --amend --no-edit
# Save uncommitted work temporarily
save = stash push -u -m 'WIP'
pop = stash pop
Handling "Oh No" Moments
"I pushed something broken to main"
# Option 1: Revert (safe, preserves history)
git revert HEAD
git push origin main
# Option 2: If you MUST rewrite history (dangerous with shared repos)
git reset --hard HEAD~1
git push --force-with-lease origin main
# Note: --force-with-lease is safer than --force
"I committed to the wrong branch"
# Move the last N commits to a new branch
git branch new-branch # Create branch from current state
git reset --hard HEAD~N # Move main back N commits
git checkout new-branch # Switch to branch with your work
"I need to get one file from another branch"
# Get config.js from staging branch
git checkout staging -- path/to/config.js
git commit -m "chore: sync config from staging"
"I want to see what changed between two points"
# Since last release tag
git log v1.0.0..HEAD --oneline
# Stats since last week
git diff --stat "@{1 week ago}" HEAD
# Who changed this file?
git log --follow -p -- src/server.js
Release Workflow
For projects with versions:
#!/bin/bash
# release.sh — creates a new versioned release
# Bump version in package.json
VERSION=$(node -p "require('./package.json').version")
read -p "Current version: $VERSION. New version: " NEW_VERSION
npm version $NEW_VERSION --no-git-tag-version
git add package.json package-lock.json
git commit -m "chore: bump version to $NEW_VERSION"
# Create annotated tag
git tag -a v$NEW_VERSION -m "Release v$NEW_VERSION"
# Push tag (triggers CI/CD deployment)
git push origin main --tags
echo "✅ Released v$NEW_VERSION"
What This Gives Me
| Before | After |
|---|---|
| Broken main every other day | Clean main, always deployable |
| "What did I change?" confusion | Clear git log tells the story |
| Messy commit messages | Consistent conventional commits |
| Secrets accidentally committed | Pre-push hook blocks it |
| Spent hours finding bugs | Tests catch them before push |
The Minimum Viable Setup
If you take nothing else from this article, do these three things:
- Protect main branch — use pre-push hook or GitHub branch rules
- Commit on branches — never work directly on main
- Clean up before merge — rebase -i to make history readable
Everything else is optimization. These three habits alone will prevent 90% of "oh crap" moments.
Git is a tool. Like any tool, it's only as good as your workflow.
Follow @armorbreak for more developer productivity tips.
Top comments (0)