Version Control

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.editor set 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:

  1. main is always deployable
  2. New work happens on feature branches named feature/description
  3. Bug fixes happen on fix/description branches
  4. Before merging, rebase onto main and squash if needed
  5. Merge with --no-ff to preserve the branch context in history
  6. Delete the branch after merge
  7. 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 main tells a coherent story.

  • Never force-push to main. Use --force-with-lease on feature branches if you must, but main should only move forward. If you need to undo something on main, use git revert to create a new commit that undoes the change.

  • Use .gitignore aggressively 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 = true globally. 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 with git 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 -p instead of git 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/webhooks takes two seconds and could save days of work.

References

Powered by Contentful