Advanced Git Workflows for Solo Developers
A practical guide to Git workflows optimized for solo developers, covering branching strategies, interactive rebase, git hooks automation, aliases, and maintaining clean commit history.
Advanced Git Workflows for Solo Developers
Most Git workflow advice is written for teams. Gitflow, trunk-based development, pull request reviews -- they assume you have collaborators. But if you are a solo developer shipping side projects, freelance work, or maintaining open source libraries, you still need a disciplined Git workflow. The difference is that you optimize for speed, clean history, and the ability to context-switch ruthlessly without losing work.
This article covers the Git techniques I use daily as a solo developer. These are not theoretical -- they are the exact workflows behind dozens of shipped Node.js projects, and every one of them has saved me from losing work or shipping bugs.
Prerequisites
- Git 2.30 or later (for worktree improvements and newer rebase options)
- Node.js v18+ installed for the hook examples
- A terminal you are comfortable with (bash, zsh, PowerShell, or Git Bash on Windows)
- Basic Git knowledge: commits, branches, push, pull, merge
- A text editor configured as your Git editor (
core.editorset in.gitconfig)
Why Solo Developers Still Need Branching Strategies
When you are the only person committing to a repository, it is tempting to do everything on main. No merge conflicts, no pull requests, no ceremony. And for tiny scripts or throwaway prototypes, that works.
But the moment your project has users, a deployment pipeline, or a production environment, committing directly to main becomes a liability. Here is why:
You need a stable reference point. If you deploy from main and your latest commit introduces a bug, you need to know which commit was the last known good state. If you have been committing a stream of half-finished work directly to main, there is no clean rollback point.
You need to experiment safely. Trying a new database driver? Refactoring your authentication layer? Rewriting your build pipeline? These changes can take hours or days. If they live on main, your project is in a broken state the entire time. A branch lets you experiment without consequences.
You need to context-switch. A client reports a critical bug while you are mid-way through a feature. If everything is on main, you either stash frantically or commit broken code. With branches, you switch to main, fix the bug, deploy, and switch back to your feature branch. Clean.
You need clean history for future you. Six months from now, when you are debugging a regression, git log should tell a coherent story. Not "WIP", "fix", "actually fix", "ok now it works", "revert that". A branching strategy with rebasing gives you a readable history.
A Practical Trunk-Based Workflow for One Person
For solo work, I use a simplified trunk-based development model. There is one long-lived branch: main. Everything else is a short-lived branch that gets rebased and merged back.
The rules are simple:
mainis always deployable- New work happens on feature branches named
feature/description - Bug fixes happen on
fix/descriptionbranches - Before merging, rebase onto
mainand squash if needed - Merge with
--no-ffto preserve the branch context in history - Delete the branch after merge
- Tag releases
Here is the typical flow:
# Start new work
git checkout main
git pull origin main
git checkout -b feature/add-webhook-support
# Do your work, commit often
git add src/webhooks.js
git commit -m "add webhook handler skeleton"
# ... more commits ...
git add src/webhooks.js test/webhooks.test.js
git commit -m "add webhook signature verification"
# Ready to merge - first rebase onto main
git checkout main
git pull origin main
git checkout feature/add-webhook-support
git rebase main
# If you want to squash commits, interactive rebase
git rebase -i main
# Merge back with no-fast-forward
git checkout main
git merge --no-ff feature/add-webhook-support
# Clean up
git branch -d feature/add-webhook-support
git push origin main
# Tag if it is a release
git tag -a v1.3.0 -m "Add webhook support"
git push origin v1.3.0
The --no-ff flag is important. Without it, Git does a fast-forward merge and your branch history disappears. With --no-ff, you get a merge commit that clearly shows "this group of commits was the webhook feature." Six months later, that context is invaluable.
Using Feature Branches to Experiment Safely
Feature branches are your laboratory. The key insight for solo developers is that branches are free. They cost nothing. Create them liberally, delete them without guilt.
I use a naming convention that tells me at a glance what each branch is for:
feature/user-auth # New functionality
fix/memory-leak-parser # Bug fix
experiment/redis-caching # Might not keep this
chore/upgrade-express-5 # Maintenance work
release/v2.0 # Release preparation
When I want to try something risky, I create an experiment branch:
git checkout -b experiment/replace-mongoose-with-prisma
# Spend a few hours exploring
# If it works out:
git checkout main
git merge --no-ff experiment/replace-mongoose-with-prisma
git branch -d experiment/replace-mongoose-with-prisma
# If it does not work out:
git checkout main
git branch -D experiment/replace-mongoose-with-prisma
# -D force-deletes without checking merge status
The capital -D is the "I know what I am doing" flag. Use it for branches you are intentionally discarding.
One pattern I rely on: before starting a risky refactor, I create a branch and immediately make a commit with the message "CHECKPOINT: before refactor". That commit is my safety net. If everything goes wrong, I know exactly where to reset to.
git checkout -b feature/refactor-routing
git commit --allow-empty -m "CHECKPOINT: before routing refactor"
# Now go wild with changes
Git Stash Techniques for Context Switching
Stashing is the solo developer's best friend. You are deep in a feature, a production alert fires, and you need to switch context immediately. Git stash saves your uncommitted work without creating a commit.
The basics:
# Stash everything (tracked files only)
git stash
# Stash with a message so you remember what it was
git stash push -m "webhook handler half-done, need to fix auth bug"
# Stash including untracked files
git stash push -u -m "new webhook files not yet tracked"
# Stash including ignored files too (nuclear option)
git stash push -a -m "everything including node_modules changes"
Retrieving stashes:
# List all stashes
git stash list
# stash@{0}: On feature/webhooks: webhook handler half-done
# stash@{1}: On main: quick experiment with caching
# Apply most recent stash (keeps it in stash list)
git stash apply
# Apply and remove from stash list
git stash pop
# Apply a specific stash
git stash apply stash@{1}
# See what is in a stash without applying
git stash show stash@{0}
git stash show -p stash@{0} # full diff
A technique I use constantly: stashing specific files. When you have changes across multiple files but only want to stash some of them:
# Stash only specific files
git stash push -m "just the config changes" config/database.js config/redis.js
# Stash everything EXCEPT certain files (stage what you want to keep, stash the rest)
git add src/webhooks.js
git stash push -m "stash everything except webhooks" --keep-index
git reset HEAD src/webhooks.js
Warning: Do not let stashes pile up. I have seen developers with 30+ stashes who have no idea what any of them contain. Treat stashes as temporary. If you have not applied a stash within a day or two, either apply it or drop it. If the work is important enough to keep, it deserves a branch, not a stash.
# Clean up old stashes
git stash drop stash@{3}
# Nuclear option: drop all stashes
git stash clear
Interactive Rebase to Clean Up History Before Pushing
Interactive rebase is the single most powerful Git feature for solo developers. It lets you rewrite your commit history before anyone else sees it. Squash five "WIP" commits into one meaningful commit. Reword a message. Reorder commits. Drop a commit entirely.
The golden rule: never rebase commits that have been pushed to a shared remote. For solo developers, this means only rebase commits on your feature branches before merging into main.
# Rebase the last 5 commits interactively
git rebase -i HEAD~5
This opens your editor with something like:
pick a1b2c3d add webhook endpoint
pick e4f5g6h WIP: handler logic
pick i7j8k9l fix typo in handler
pick m0n1o2p add tests for webhook
pick q3r4s5t fix test assertion
Change it to:
pick a1b2c3d add webhook endpoint
squash e4f5g6h WIP: handler logic
squash i7j8k9l fix typo in handler
pick m0n1o2p add tests for webhook
squash q3r4s5t fix test assertion
The commands available during interactive rebase:
| Command | Short | Effect |
|---|---|---|
| pick | p | Use commit as-is |
| reword | r | Use commit, but edit the message |
| edit | e | Pause rebase to amend the commit |
| squash | s | Meld into previous commit, combine messages |
| fixup | f | Meld into previous commit, discard this message |
| drop | d | Remove commit entirely |
After saving, Git replays the commits with your changes. For squash, it opens another editor to combine the commit messages.
My typical workflow: commit frequently while working (every logical change), then rebase before merging to main. The result is a clean history where each commit represents a complete, working change.
# On feature branch, ready to merge
git rebase -i main
# This rebases all commits since you branched off main
# Squash WIP commits, reword messages, reorder as needed
# Then merge
git checkout main
git merge --no-ff feature/add-webhook-support
A handy shortcut: if you just want to fixup the last commit without opening the interactive editor:
# Amend the last commit with staged changes
git add src/webhooks.js
git commit --amend --no-edit
# Amend with a new message
git commit --amend -m "add webhook handler with signature verification"
Git Bisect for Finding When Bugs Were Introduced
Git bisect is chronically underused. It performs a binary search through your commit history to find exactly which commit introduced a bug. Instead of manually checking out commits one by one, bisect cuts the search space in half each step.
Manual bisect:
# Start bisect
git bisect start
# Current commit is broken
git bisect bad
# This commit from last week was working
git bisect good v1.2.0
# Git checks out a commit in the middle
# Test it, then tell Git:
git bisect good # if this commit works
# or
git bisect bad # if this commit is broken
# Repeat until Git finds the exact commit
# Bisecting: 3 revisions left to test after this (roughly 2 steps)
# [abc1234] refactor: change database connection pooling
But the real power of bisect for Node.js developers is automated bisect. Write a script that returns exit code 0 for good and non-zero for bad, and let Git do the work:
# Create a test script
cat > /tmp/test-bug.sh << 'EOF'
#!/bin/bash
npm install --silent 2>/dev/null
npm test 2>/dev/null
EOF
chmod +x /tmp/test-bug.sh
# Run automated bisect
git bisect start
git bisect bad HEAD
git bisect good v1.2.0
git bisect run /tmp/test-bug.sh
Git will automatically check out commits, run your script, and find the first bad commit without any manual intervention. For a history with 1,000 commits between good and bad, bisect finds the culprit in about 10 steps.
When you are done:
# Reset to where you were before bisect
git bisect reset
A Node.js-specific bisect script that checks for a specific failing test:
// bisect-check.js
var exec = require("child_process").execSync;
try {
exec("npm install --silent", { stdio: "ignore" });
exec("npx mocha test/webhooks.test.js --timeout 10000", { stdio: "ignore" });
process.exit(0); // Good commit
} catch (err) {
process.exit(1); // Bad commit
}
git bisect run node bisect-check.js
Automating with Git Hooks
Git hooks are scripts that run automatically at specific points in the Git workflow. For solo developers, they replace the code review process. No one is reviewing your pull requests, so hooks are your automated quality gate.
Hooks live in .git/hooks/ by default. Each hook is an executable file named after the event it handles. The most useful hooks for solo development:
pre-commit
Runs before every commit. Use it to lint code, run formatters, and check for common mistakes.
#!/bin/bash
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# Get list of staged JS files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -z "$STAGED_FILES" ]; then
echo "No JavaScript files staged, skipping lint."
exit 0
fi
# Run ESLint on staged files only
echo "Linting staged files..."
npx eslint $STAGED_FILES
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix errors before committing."
exit 1
fi
# Check for console.log statements
echo "Checking for console.log..."
if grep -n "console\.log" $STAGED_FILES; then
echo ""
echo "WARNING: console.log found in staged files."
echo "Remove debug logging before committing."
exit 1
fi
# Check for TODO/FIXME/HACK markers
echo "Checking for TODO markers..."
TODOS=$(grep -rn "TODO\|FIXME\|HACK\|XXX" $STAGED_FILES)
if [ -n "$TODOS" ]; then
echo ""
echo "WARNING: Found TODO markers in staged files:"
echo "$TODOS"
echo ""
echo "Consider addressing these before committing."
# Note: this is a warning, not a block (no exit 1)
fi
# Check for .env files accidentally staged
if git diff --cached --name-only | grep -q '\.env'; then
echo "ERROR: .env file is staged. Never commit secrets."
exit 1
fi
echo "Pre-commit checks passed."
exit 0
commit-msg
Validates or modifies the commit message. Enforce a consistent format:
#!/bin/bash
# .git/hooks/commit-msg
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Enforce conventional commit format
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{3,}"
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo ""
echo "ERROR: Commit message does not follow conventional format."
echo ""
echo "Expected: <type>(<scope>): <description>"
echo ""
echo "Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
echo ""
echo "Examples:"
echo " feat(auth): add JWT token refresh"
echo " fix(api): handle null response from webhook"
echo " chore: upgrade dependencies"
echo ""
echo "Your message: $COMMIT_MSG"
echo ""
exit 1
fi
# Enforce minimum message length (excluding type prefix)
MSG_LENGTH=${#COMMIT_MSG}
if [ $MSG_LENGTH -lt 15 ]; then
echo "ERROR: Commit message too short (${MSG_LENGTH} chars). Be descriptive."
exit 1
fi
exit 0
pre-push
Runs before pushing. This is your last line of defense. Run the full test suite here:
#!/bin/bash
# .git/hooks/pre-push
echo "Running pre-push checks..."
# Run full test suite
echo "Running tests..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Fix before pushing."
exit 1
fi
# Optional: run build to catch compilation errors
echo "Running build..."
npm run build 2>/dev/null
if [ $? -ne 0 ]; then
echo "Build failed. Fix before pushing."
exit 1
fi
echo "Pre-push checks passed. Pushing..."
exit 0
Sharing Hooks Across Machines
The .git/hooks/ directory is not tracked by Git. To share hooks across your machines (or make them part of the project), store them in a tracked directory and configure Git to use it:
# Create a hooks directory in your project
mkdir -p .githooks
# Tell Git to use it
git config core.hooksPath .githooks
# Now commit your hooks as part of the project
cp .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git add .githooks/
git commit -m "chore: add git hooks for automated quality checks"
For Node.js projects, you can also use the husky package to manage hooks via package.json:
npm install --save-dev husky
npx husky init
// package.json
{
"scripts": {
"prepare": "husky",
"lint": "eslint .",
"test": "mocha --recursive"
}
}
Git Aliases for Common Operations
Aliases eliminate repetitive typing and encode your workflows into short commands. These go in your ~/.gitconfig file under the [alias] section.
Here are the aliases I actually use daily:
[alias]
# Status and log
s = status -sb
lg = log --oneline --graph --decorate --all -20
ll = log --oneline --graph --decorate -10
last = log -1 HEAD --stat
# Branching
co = checkout
cb = checkout -b
br = branch -vv
brd = branch -d
brD = branch -D
# Committing
cm = commit -m
ca = commit --amend --no-edit
cam = commit --amend -m
# Diffing
df = diff
dfs = diff --staged
dfw = diff --word-diff
# Stashing
sl = stash list
sp = stash push -m
sa = stash apply
sd = stash drop
# Common workflows
sync = !git checkout main && git pull origin main
done = !git checkout main && git merge --no-ff @{-1} && git branch -d @{-1}
undo = reset --soft HEAD~1
unstage = reset HEAD --
wip = !git add -A && git commit -m 'chore: WIP - work in progress'
# Cleanup
cleanup = !git branch --merged main | grep -v 'main' | xargs -r git branch -d
prune-remote = fetch --prune
# Find stuff
find = log --all --pretty=format:'%C(auto)%h %s' --grep
changed = diff --name-only HEAD~1
# Tags
tags = tag -l --sort=-v:refname
latest-tag = describe --tags --abbrev=0
Usage examples:
# Quick status
git s
# ## feature/webhooks...origin/feature/webhooks
# M src/webhooks.js
# ?? src/webhooks.test.js
# Beautiful log
git lg
# * a1b2c3d (HEAD -> feature/webhooks) add webhook tests
# * e4f5g6h add webhook handler
# | * i7j8k9l (main) fix auth token expiry
# |/
# * m0n1o2p (tag: v1.2.0) release v1.2.0
# Create and switch to new branch
git cb feature/notifications
# Quick commit
git cm "feat(webhook): add retry logic for failed deliveries"
# Finish a feature (merges current branch into main)
git done
# Undo last commit but keep changes staged
git undo
# WIP commit when you need to switch context fast
git wip
# Clean up merged branches
git cleanup
The done alias is particularly useful. It switches to main, merges whatever branch you were just on (the @{-1} reference means "the branch I was on before"), and deletes that branch. One command to finish a feature.
Tagging Release Versions
Tags mark specific commits as release points. For solo projects, I use semantic versioning tags and annotated tags (which store the tagger, date, and a message).
# Create an annotated tag
git tag -a v1.3.0 -m "Add webhook support and retry logic"
# Push tags to remote
git push origin v1.3.0
# Push all tags
git push origin --tags
# List tags sorted by version
git tag -l --sort=-v:refname
# v1.3.0
# v1.2.1
# v1.2.0
# v1.1.0
# v1.0.0
# See tag details
git show v1.3.0
# tag v1.3.0
# Tagger: Shane <[email protected]>
# Date: Sat Feb 8 10:30:00 2026 -0800
#
# Add webhook support and retry logic
#
# commit a1b2c3d...
# Tag a past commit
git tag -a v1.2.1 abc1234 -m "Hotfix: fix auth token expiry"
# Delete a tag (local and remote)
git tag -d v1.3.0-beta
git push origin :refs/tags/v1.3.0-beta
For Node.js projects, I tie tagging into the release workflow:
# Bump version in package.json, commit, and tag in one step
npm version patch -m "release: %s - fix webhook retry timing"
# This runs: git commit -m "release: v1.3.1 - fix webhook retry timing"
# And: git tag v1.3.1
npm version minor -m "release: %s - add notification system"
npm version major -m "release: %s - breaking API changes"
# Push commit and tags together
git push origin main --tags
The npm version command updates package.json, creates a commit, and creates a tag all at once. It also runs preversion, version, and postversion scripts if defined in package.json, so you can automate testing and building as part of the release:
{
"scripts": {
"preversion": "npm test",
"version": "npm run build && git add -A dist",
"postversion": "git push origin main --tags"
}
}
Maintaining a Clean Commit History
A clean commit history is documentation. When you run git log six months from now, you should be able to understand what changed, why it changed, and in what order, without reading a single line of code.
Rules I follow:
Each commit should represent one logical change. Not "update files" or "various fixes". One commit for adding the webhook endpoint. One commit for adding the tests. One commit for updating the documentation. If you need to use "and" in your commit message, you probably need two commits.
Use conventional commits format. The type(scope): description format is not just convention -- it enables automated changelog generation and makes history scannable:
feat(api): add webhook delivery endpoint
feat(api): add webhook signature verification
test(api): add webhook handler test suite
fix(api): handle null payload in webhook handler
docs: add webhook API documentation
chore: upgrade express to 4.21
Rebase before merging. Never merge main into your feature branch. Always rebase your feature branch onto main. Merge commits from pulling main into your branch pollute the history with noise.
# Wrong - creates unnecessary merge commits
git checkout feature/webhooks
git merge main
# Right - replays your commits on top of main
git checkout feature/webhooks
git rebase main
Write commit messages in the imperative mood. "Add webhook handler", not "Added webhook handler" or "Adds webhook handler". The convention is that a commit message completes the sentence "If applied, this commit will _____."
Use git add -p for partial staging. When a single file contains changes for two different logical concerns, stage them separately:
# Stage changes interactively, hunk by hunk
git add -p src/server.js
# Git shows each change and asks:
# Stage this hunk [y,n,q,a,d,s,e,?]?
# y = stage this hunk
# n = skip this hunk
# s = split into smaller hunks
# e = manually edit the hunk
Using Git Worktrees for Parallel Work
Worktrees let you check out multiple branches simultaneously in different directories. Instead of stashing or committing WIP to switch branches, you just open another directory.
This is especially powerful for solo developers who need to context-switch frequently:
# You are on feature/webhooks and need to fix a production bug
# Instead of stashing, create a worktree
# Create a worktree for the hotfix
git worktree add ../myproject-hotfix main
# Preparing worktree (checking out 'main')
# HEAD is now at abc1234 release: v1.3.0
# Work on the fix in the new directory
cd ../myproject-hotfix
git checkout -b fix/auth-token-expiry
# ... make changes, commit, push ...
git checkout main
git merge --no-ff fix/auth-token-expiry
git push origin main
# Go back to your feature work (nothing was disturbed)
cd ../myproject
# Still on feature/webhooks, all changes intact
# Clean up the worktree when done
git worktree remove ../myproject-hotfix
# List active worktrees
git worktree list
# /home/shane/myproject abc1234 [feature/webhooks]
# /home/shane/myproject-hotfix def5678 [main]
Worktrees share the same .git directory, so they share branches, stashes, and history. But each worktree has its own working directory and index, so they are completely independent in terms of file state.
I keep a permanent worktree for main so I can always test against the latest stable code without leaving my feature branch:
git worktree add ../myproject-stable main
Important caveat: you cannot have two worktrees on the same branch. Each branch can only be checked out in one worktree at a time.
Complete Working Example
Here is a full .gitconfig with all the aliases, plus hook scripts for a Node.js project.
~/.gitconfig
[user]
name = Shane
email = [email protected]
[core]
editor = code --wait
autocrlf = input
pager = less -FRX
hooksPath = .githooks
[init]
defaultBranch = main
[pull]
rebase = true
[push]
default = current
followTags = true
[merge]
ff = false
conflictstyle = diff3
[diff]
algorithm = histogram
colorMoved = default
[rebase]
autoSquash = true
autoStash = true
[rerere]
enabled = true
[alias]
# Status and log
s = status -sb
lg = log --oneline --graph --decorate --all -20
ll = log --oneline --graph --decorate -10
last = log -1 HEAD --stat
# Branching
co = checkout
cb = checkout -b
br = branch -vv
brd = branch -d
brD = branch -D
# Committing
cm = commit -m
ca = commit --amend --no-edit
cam = commit --amend -m
# Diffing
df = diff
dfs = diff --staged
dfw = diff --word-diff
# Stashing
sl = stash list
sp = stash push -m
sa = stash apply
sd = stash drop
# Workflows
sync = !git checkout main && git pull origin main
done = !git checkout main && git merge --no-ff @{-1} && git branch -d @{-1}
undo = reset --soft HEAD~1
unstage = reset HEAD --
wip = !git add -A && git commit -m 'chore: WIP'
# Cleanup
cleanup = !git branch --merged main | grep -v 'main' | xargs -r git branch -d
prune-remote = fetch --prune
# Find
find = log --all --pretty=format:'%C(auto)%h %s' --grep
changed = diff --name-only HEAD~1
# Tags
tags = tag -l --sort=-v:refname
latest-tag = describe --tags --abbrev=0
.githooks/pre-commit (Node.js Project)
#!/bin/bash
# Pre-commit hook for Node.js projects
# Runs lint, checks for secrets, validates JSON
set -e
echo "=== Pre-commit Hook ==="
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Get staged files
STAGED_JS=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$' || true)
STAGED_JSON=$(git diff --cached --name-only --diff-filter=ACM | grep '\.json$' || true)
STAGED_ALL=$(git diff --cached --name-only --diff-filter=ACM)
# 1. Check for secrets
echo "Checking for secrets..."
SECRETS_PATTERN="(password|secret|api_key|apikey|token|private_key)\s*[:=]\s*['\"][^'\"]{8,}"
if echo "$STAGED_ALL" | xargs grep -ilE "$SECRETS_PATTERN" 2>/dev/null; then
echo -e "${RED}ERROR: Possible secrets detected in staged files.${NC}"
echo "Review the files above and remove any credentials."
exit 1
fi
# 2. Check for .env files
if echo "$STAGED_ALL" | grep -q '\.env'; then
echo -e "${RED}ERROR: .env file staged. Never commit environment files.${NC}"
exit 1
fi
# 3. Lint JavaScript files
if [ -n "$STAGED_JS" ]; then
echo "Linting JavaScript files..."
npx eslint $STAGED_JS --quiet
if [ $? -ne 0 ]; then
echo -e "${RED}ESLint failed. Fix errors before committing.${NC}"
exit 1
fi
echo -e "${GREEN}Lint passed.${NC}"
fi
# 4. Validate JSON files
if [ -n "$STAGED_JSON" ]; then
echo "Validating JSON files..."
for file in $STAGED_JSON; do
node -e "JSON.parse(require('fs').readFileSync('$file', 'utf8'))" 2>/dev/null
if [ $? -ne 0 ]; then
echo -e "${RED}Invalid JSON: $file${NC}"
exit 1
fi
done
echo -e "${GREEN}JSON validation passed.${NC}"
fi
# 5. Check for large files
echo "Checking file sizes..."
LARGE_FILES=$(git diff --cached --name-only --diff-filter=ACM | while read file; do
SIZE=$(wc -c < "$file" 2>/dev/null || echo 0)
if [ "$SIZE" -gt 1048576 ]; then
echo "$file ($(($SIZE / 1024))KB)"
fi
done)
if [ -n "$LARGE_FILES" ]; then
echo -e "${YELLOW}WARNING: Large files detected:${NC}"
echo "$LARGE_FILES"
echo "Consider using Git LFS for files over 1MB."
fi
# 6. Check for console.log in production code (warn only)
if [ -n "$STAGED_JS" ]; then
CONSOLE_LOGS=$(echo "$STAGED_JS" | grep -v 'test/' | grep -v 'spec/' | xargs grep -n 'console\.log' 2>/dev/null || true)
if [ -n "$CONSOLE_LOGS" ]; then
echo -e "${YELLOW}WARNING: console.log found in non-test files:${NC}"
echo "$CONSOLE_LOGS"
fi
fi
echo -e "${GREEN}=== Pre-commit checks passed ===${NC}"
exit 0
.githooks/commit-msg (Conventional Commits)
#!/bin/bash
# Enforce conventional commit format
COMMIT_MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{3,}"
if [[ "$COMMIT_MSG" =~ ^Merge ]]; then
exit 0
fi
if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
echo ""
echo "ERROR: Invalid commit message format."
echo ""
echo " Expected: <type>(<scope>): <description>"
echo ""
echo " Types: feat fix docs style refactor test chore perf ci build revert"
echo ""
echo " Your message: $(head -1 <<< "$COMMIT_MSG")"
echo ""
exit 1
fi
# Check subject line length
SUBJECT=$(head -1 <<< "$COMMIT_MSG")
if [ ${#SUBJECT} -gt 72 ]; then
echo "ERROR: Subject line too long (${#SUBJECT} chars). Max is 72."
exit 1
fi
exit 0
Setup Script
A script to set up the entire workflow for a new project:
#!/bin/bash
# setup-git-workflow.sh
# Run this in the root of a new Node.js project
set -e
echo "Setting up Git workflow..."
# Create hooks directory
mkdir -p .githooks
# Set hooks path
git config core.hooksPath .githooks
# Create pre-commit hook
cat > .githooks/pre-commit << 'HOOK'
#!/bin/bash
STAGED_JS=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$' || true)
if [ -n "$STAGED_JS" ]; then
npx eslint $STAGED_JS --quiet || exit 1
fi
if git diff --cached --name-only | grep -q '\.env'; then
echo "ERROR: .env file staged."
exit 1
fi
exit 0
HOOK
# Create commit-msg hook
cat > .githooks/commit-msg << 'HOOK'
#!/bin/bash
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{3,}"
MSG=$(cat "$1")
if [[ "$MSG" =~ ^Merge ]]; then exit 0; fi
if ! echo "$MSG" | head -1 | grep -qE "$PATTERN"; then
echo "ERROR: Use conventional commits. Example: feat(api): add endpoint"
exit 1
fi
exit 0
HOOK
# Make hooks executable
chmod +x .githooks/*
# Configure Git settings
git config pull.rebase true
git config push.default current
git config push.followTags true
git config merge.ff false
git config rebase.autoSquash true
git config rerere.enabled true
echo "Git workflow configured."
echo "Hooks installed to .githooks/"
echo "Run 'git config --list --local' to verify settings."
# Run the setup
chmod +x setup-git-workflow.sh
./setup-git-workflow.sh
# Setting up Git workflow...
# Git workflow configured.
# Hooks installed to .githooks/
# Run 'git config --list --local' to verify settings.
Common Issues and Troubleshooting
1. Rebase Conflict Hell
CONFLICT (content): Merge conflict in src/server.js
error: could not apply a1b2c3d... feat: add webhook endpoint
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add <pathspec>..." then run "git rebase --continue".
Cause: Your feature branch has diverged significantly from main. Each conflicting commit requires manual resolution during rebase.
Fix: Rebase frequently. Do not let your feature branch live for weeks without rebasing onto main. If you are already in conflict:
# Resolve conflicts in the file
# Then:
git add src/server.js
git rebase --continue
# If it is truly hopeless, abort and try a different strategy
git rebase --abort
# Alternative: squash all your commits first, then rebase
# Fewer commits = fewer potential conflicts
git rebase -i HEAD~10 # squash everything
git rebase main # now rebase the single commit
2. Detached HEAD State
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
Cause: You checked out a specific commit, tag, or remote branch directly instead of a local branch. Common after git checkout v1.2.0 or during bisect.
Fix:
# If you made commits you want to keep:
git branch rescue-branch
git checkout rescue-branch
# If you just want to get back to a branch:
git checkout main
# If you are in the middle of bisect:
git bisect reset
3. Hook Permission Denied
hint: the '.githooks/pre-commit' hook was ignored because it's not set as executable.
hint: you can disable this warning with `git config advice.ignoredHook false`.
Cause: On Unix/Mac/WSL, hook files need executable permission. On Windows with Git Bash, this can also happen after cloning a repo.
Fix:
chmod +x .githooks/*
# On Windows, if chmod does not work:
git update-index --chmod=+x .githooks/pre-commit
git update-index --chmod=+x .githooks/commit-msg
4. Accidentally Committed to Wrong Branch
# You just committed to main instead of your feature branch
git log --oneline -3
# a1b2c3d (HEAD -> main) feat: add webhook handler <-- oops
# e4f5g6h release: v1.3.0
# i7j8k9l chore: update dependencies
Fix:
# Move the commit to the correct branch
git checkout -b feature/webhooks # creates branch at current commit
git checkout main # go back to main
git reset --hard HEAD~1 # remove the commit from main
git checkout feature/webhooks # continue working on the right branch
If you already pushed to main, you will need a force push (git push --force-with-lease origin main), which is why having hooks that prevent accidental pushes to main is valuable.
5. Stash Apply Conflicts
error: Your local changes to the following files would be overwritten by merge:
src/server.js
Please commit your changes or stash them before you can merge.
Cause: The file has changed since you stashed, and Git cannot cleanly apply the stash.
Fix:
# Commit or stash your current changes first
git stash push -m "current work"
# Apply the conflicting stash
git stash apply stash@{1}
# Resolve conflicts, then drop the stash manually
git stash drop stash@{1}
# Apply your original work back
git stash pop
6. Lost Commits After Rebase
# You rebased and now commits seem to be gone
git log --oneline -5
# Fewer commits than expected...
Fix: Git never truly deletes commits immediately. Use the reflog:
git reflog
# a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature/webhooks
# e4f5g6h HEAD@{1}: rebase (squash): add webhook support
# i7j8k9l HEAD@{2}: rebase (start): checkout main
# m0n1o2p HEAD@{3}: commit: add webhook tests <-- your lost commit
# q3r4s5t HEAD@{4}: commit: add webhook handler
# Reset to the state before rebase
git reset --hard HEAD@{3}
# Or cherry-pick specific lost commits
git cherry-pick m0n1o2p
The reflog is your safety net. It records every HEAD movement for 90 days by default. Even after a bad rebase, your commits are recoverable.
Best Practices
Commit early, commit often, rebase before merging. Small, frequent commits on your feature branch give you granular undo points. Interactive rebase before merging cleans up the noise so
maintells a coherent story.Never force-push to
main. Use--force-with-leaseon feature branches if you must, butmainshould only move forward. If you need to undo something onmain, usegit revertto create a new commit that undoes the change.Use
.gitignoreaggressively from day one.node_modules/,.env,dist/,.DS_Store,*.log-- add these before your first commit. Removing tracked files later is always more painful than ignoring them up front.Set
pull.rebase = trueglobally. When you pull and there are upstream changes, rebase your local commits on top instead of creating a merge commit. This keeps the history linear and clean. Set it once:git config --global pull.rebase true.Enable
rerere(reuse recorded resolution). When you resolve a merge conflict, Git records the resolution. If the same conflict appears again (common during repeated rebases), Git resolves it automatically. Enable withgit config --global rerere.enabled true.Tag every release, no exceptions. Tags are cheap and immensely valuable for debugging. When a user reports a bug in "version 1.3", you can instantly check out that exact code with
git checkout v1.3.0. Without tags, you are grep-ing through commit messages trying to find the release point.Use
git add -pinstead ofgit add .for anything non-trivial. Reviewing each hunk before staging catches accidental debug code, unrelated changes, and secrets. It takes 30 extra seconds and prevents countless "oops" commits.Keep branches short-lived. A feature branch that lives for three weeks is a branch that will have painful merge conflicts. Aim for branches that last hours to days, not weeks. If a feature takes weeks, break it into smaller incremental changes that can each be merged independently.
Back up your local branches to the remote. Even as a solo developer, push your feature branches. Your laptop can fail, get stolen, or corrupt its disk. A quick
git push -u origin feature/webhookstakes two seconds and could save days of work.
References
- Git Documentation - git-rebase -- Official reference for interactive rebase, including all commands and options.
- Git Documentation - git-bisect -- Full bisect documentation including automated bisect with scripts.
- Git Documentation - git-worktree -- Worktree management for parallel checkouts.
- Git Documentation - githooks -- Complete list of available hooks and their interfaces.
- Conventional Commits Specification -- The standard for structured commit messages used in this article.
- Semantic Versioning -- Versioning standard referenced in the tagging section.
- Pro Git Book - Chapter 7: Git Tools -- In-depth coverage of advanced Git features including reflog, stashing, and searching.
