Version Control

Advanced Git Workflows for Solo Developers

Git workflow patterns for solo developers including trunk-based development, feature branches, release management, stashing strategies, and automation with aliases.

Advanced Git Workflows for Solo Developers

Most Git workflow guides assume a team. They describe pull request reviews, protected branches, and merge approval chains. As a solo developer, none of that applies. But working alone does not mean you should commit everything to main and hope for the best. A disciplined Git workflow protects you from your own mistakes, makes debugging easier, and creates a clean history that makes sense six months later.

I have used Git on solo projects for over a decade. The workflows in this guide are the patterns that survived real use — not theoretical models from blog posts, but systems I actually follow every day.

Prerequisites

  • Git installed (v2.30+)
  • Basic Git knowledge (commit, push, pull, branch)
  • A repository to work with
  • Terminal access

The Solo Developer's Branching Model

Teams use Git Flow or GitHub Flow because they need coordination. Solo developers need something simpler but still structured.

Trunk-Based Development for Solo Work

The simplest effective workflow:

main ─────●────●────●────●────●────●────●
           │              │
           └──feature─────┘

Rules:

  1. main is always deployable
  2. Short-lived feature branches for anything non-trivial
  3. No long-running branches
  4. Tag releases when you deploy
# Start a feature
git checkout -b add-user-auth

# Work, commit frequently
git add src/auth.js
git commit -m "Add JWT token generation"

git add src/middleware.js
git commit -m "Add auth middleware for protected routes"

git add tests/auth.test.js
git commit -m "Add auth integration tests"

# Merge back to main
git checkout main
git merge add-user-auth

# Clean up
git branch -d add-user-auth

# Tag the release
git tag -a v1.3.0 -m "Add user authentication"
git push origin main --tags

When to Branch

Not everything needs a branch. Here is the decision framework:

Commit directly to main:

  • Single-file fixes
  • Typo corrections
  • Config changes
  • Dependency updates
  • Documentation edits

Create a branch:

  • Features that touch multiple files
  • Refactoring that might break things
  • Experimental ideas you might abandon
  • Changes that take more than one session
# Quick fix — commit directly
git add config/database.js
git commit -m "Fix database connection timeout setting"
git push

# Multi-session feature — branch
git checkout -b refactor-api-layer
# ... work across multiple sessions ...
git checkout main
git merge refactor-api-layer

Commit Discipline

Writing Useful Commit Messages

Commit messages are notes to your future self. The diff shows what changed — the message explains why.

# Bad — describes the diff
git commit -m "Update app.js"

# Good — explains intent
git commit -m "Add rate limiting to API endpoints to prevent abuse"

# Bad — too vague
git commit -m "Fix bug"

# Good — identifies the bug
git commit -m "Fix off-by-one error in pagination that skipped last page"

Conventional Commits

A structured format that makes history scannable:

git commit -m "feat: add email notification for failed jobs"
git commit -m "fix: prevent duplicate entries in search index"
git commit -m "refactor: extract database queries into repository layer"
git commit -m "docs: add API endpoint documentation"
git commit -m "chore: update dependencies to latest versions"
git commit -m "test: add integration tests for payment flow"
git commit -m "perf: add database index for user lookup queries"

Prefixes at a glance:

  • feat: — new functionality
  • fix: — bug fix
  • refactor: — code restructuring without behavior change
  • docs: — documentation only
  • chore: — maintenance tasks
  • test: — test additions or changes
  • perf: — performance improvements
  • style: — formatting, no code change

Atomic Commits

Each commit should represent one logical change. If you need the word "and" in your commit message, you probably need two commits.

# Bad — two unrelated changes in one commit
git add .
git commit -m "Add user validation and fix CSS layout issue"

# Good — separate commits
git add src/validation.js tests/validation.test.js
git commit -m "Add input validation for user registration form"

git add static/css/layout.css
git commit -m "Fix sidebar overflow on mobile viewports"

Staging Partial Changes

Use git add -p to stage specific hunks within a file:

git add -p src/server.js

Git shows each changed section and asks what to do:

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
  q - quit

This lets you make multiple logical changes to a file and commit them separately.

Stashing Strategies

Basic Stash

# Save work in progress
git stash

# List stashes
git stash list
# stash@{0}: WIP on main: abc1234 Last commit message
# stash@{1}: WIP on feature: def5678 Another message

# Restore latest stash
git stash pop

# Restore without removing from stash list
git stash apply

# Restore a specific stash
git stash apply stash@{1}

Named Stashes

# Save with a descriptive name
git stash push -m "Half-done API refactor"
git stash push -m "Experimental caching approach"

# List shows the names
git stash list
# stash@{0}: On main: Experimental caching approach
# stash@{1}: On main: Half-done API refactor

Partial Stashing

Stash only specific files:

# Stash only tracked files that changed
git stash push -m "WIP auth" src/auth.js src/middleware.js

# Stash everything except staged changes
git stash push --keep-index -m "Stash unstaged changes only"

# Stash including untracked files
git stash push -u -m "Include new files too"

Stash Workflows

Context switching:

# Working on feature, need to fix urgent bug
git stash push -m "In-progress: user dashboard"

# Fix the bug
git checkout main
git add src/api.js
git commit -m "fix: prevent null pointer in API response handler"
git push

# Return to feature work
git checkout feature-dashboard
git stash pop

Trying an approach:

# Save current approach
git stash push -m "Approach A: recursive solution"

# Try a different approach
# ... write different code ...

# Compare approaches
git diff stash@{0}

# Keep current work or restore the stash
git stash drop stash@{0}  # Keep current, discard stash
# or
git stash pop              # Restore stash, discard current

Release Management

Semantic Versioning with Tags

# Tag a release
git tag -a v2.1.0 -m "Release 2.1.0: Add payment processing"

# List tags
git tag -l "v2.*"

# Push tags
git push origin --tags

# Tag a past commit
git tag -a v2.0.1 -m "Hotfix: Fix payment amount rounding" abc1234

# Delete a tag
git tag -d v2.1.0-beta
git push origin --delete v2.1.0-beta

Release Branches for Hotfixes

When you need to fix a released version while main has moved on:

# Create a hotfix branch from the release tag
git checkout -b hotfix/2.1.1 v2.1.0

# Make the fix
git add src/payment.js
git commit -m "fix: correct rounding error in payment calculation"

# Tag the hotfix
git tag -a v2.1.1 -m "Hotfix: Payment rounding correction"

# Merge back to main
git checkout main
git merge hotfix/2.1.1

# Push everything
git push origin main --tags

# Clean up
git branch -d hotfix/2.1.1

Changelog Generation

Use Git log to generate changelogs from conventional commits:

# Changes since last tag
git log v2.0.0..HEAD --oneline

# Only features
git log v2.0.0..HEAD --oneline --grep="^feat:"

# Formatted changelog
git log v2.0.0..HEAD --pretty=format:"- %s (%h)" --reverse

A script to automate this:

// scripts/changelog.js
var childProcess = require("child_process");

function getLatestTag() {
  try {
    return childProcess.execSync("git describe --tags --abbrev=0", {
      encoding: "utf-8"
    }).trim();
  } catch (err) {
    return null;
  }
}

function getCommitsSince(tag) {
  var range = tag ? tag + "..HEAD" : "HEAD";
  var output = childProcess.execSync(
    'git log ' + range + ' --pretty=format:"%s|||%h|||%an|||%ad" --date=short',
    { encoding: "utf-8" }
  );

  return output.trim().split("\n").filter(Boolean).map(function(line) {
    var parts = line.split("|||");
    return {
      message: parts[0],
      hash: parts[1],
      author: parts[2],
      date: parts[3]
    };
  });
}

function categorize(commits) {
  var categories = {
    features: [],
    fixes: [],
    refactors: [],
    other: []
  };

  commits.forEach(function(commit) {
    if (commit.message.indexOf("feat:") === 0) {
      categories.features.push(commit);
    } else if (commit.message.indexOf("fix:") === 0) {
      categories.fixes.push(commit);
    } else if (commit.message.indexOf("refactor:") === 0) {
      categories.refactors.push(commit);
    } else {
      categories.other.push(commit);
    }
  });

  return categories;
}

function formatChangelog(version, categories) {
  var lines = [];
  lines.push("## " + version + " (" + new Date().toISOString().split("T")[0] + ")");
  lines.push("");

  if (categories.features.length > 0) {
    lines.push("### Features");
    categories.features.forEach(function(c) {
      lines.push("- " + c.message.replace("feat: ", "") + " (" + c.hash + ")");
    });
    lines.push("");
  }

  if (categories.fixes.length > 0) {
    lines.push("### Bug Fixes");
    categories.fixes.forEach(function(c) {
      lines.push("- " + c.message.replace("fix: ", "") + " (" + c.hash + ")");
    });
    lines.push("");
  }

  if (categories.refactors.length > 0) {
    lines.push("### Refactoring");
    categories.refactors.forEach(function(c) {
      lines.push("- " + c.message.replace("refactor: ", "") + " (" + c.hash + ")");
    });
    lines.push("");
  }

  return lines.join("\n");
}

var tag = getLatestTag();
var newVersion = process.argv[2] || "Unreleased";
var commits = getCommitsSince(tag);
var categories = categorize(commits);
console.log(formatChangelog(newVersion, categories));
node scripts/changelog.js v2.2.0

Git Aliases for Speed

Essential Aliases

git config --global alias.s "status --short --branch"
git config --global alias.l "log --oneline --graph --decorate -20"
git config --global alias.la "log --oneline --graph --decorate --all -30"
git config --global alias.d "diff"
git config --global alias.ds "diff --staged"
git config --global alias.co "checkout"
git config --global alias.cb "checkout -b"
git config --global alias.cm "commit -m"
git config --global alias.ca "commit --amend --no-edit"
git config --global alias.unstage "reset HEAD --"
git config --global alias.last "log -1 HEAD --stat"
git config --global alias.branches "branch -a --sort=-committerdate"
git config --global alias.tags "tag -l --sort=-version:refname"
git config --global alias.stashes "stash list"

Advanced Aliases

# Show what changed in the last commit
git config --global alias.what "show --stat"

# Quick amend with new files
git config --global alias.oops "!git add -A && git commit --amend --no-edit"

# Undo last commit (keep changes staged)
git config --global alias.undo "reset --soft HEAD~1"

# Show all files changed between two refs
git config --global alias.changed "diff --name-only"

# Interactive rebase on last N commits
git config --global alias.ri "!f() { git rebase -i HEAD~\${1:-5}; }; f"

# Delete merged branches
git config --global alias.cleanup "!git branch --merged main | grep -v main | xargs -r git branch -d"

# Today's commits
git config --global alias.today "log --since='midnight' --oneline"

# Create a save point (lightweight tag)
git config --global alias.save "!f() { git tag save/\$(date +%Y%m%d-%H%M%S); }; f"

Usage:

git s              # Short status with branch info
git l              # Last 20 commits as graph
git cb new-feature # Create and checkout branch
git cm "Add auth"  # Commit with message
git ca             # Amend last commit silently
git undo           # Undo last commit, keep changes
git today          # Show today's work
git save           # Create a timestamped save point

History Management

Interactive Rebase for Cleanup

Before merging a feature branch, clean up the commit history:

# Rebase last 5 commits
git rebase -i HEAD~5

The editor opens with:

pick abc1234 WIP: start auth feature
pick def5678 Fix typo
pick ghi9012 WIP: more auth work
pick jkl3456 Add auth tests
pick mno7890 Fix test assertion

Change to:

pick abc1234 WIP: start auth feature
fixup def5678 Fix typo
squash ghi9012 WIP: more auth work
pick jkl3456 Add auth tests
fixup mno7890 Fix test assertion

Commands:

  • pick — keep the commit as-is
  • squash — merge into previous commit, combine messages
  • fixup — merge into previous commit, discard message
  • reword — change commit message
  • drop — remove the commit entirely

The result is a clean history:

abc1234 feat: Add user authentication
jkl3456 test: Add auth integration tests

Fixup Commits

When you find a bug in a previous commit:

# Make the fix
git add src/auth.js

# Create a fixup commit targeting a specific commit
git commit --fixup abc1234

# Later, auto-squash during rebase
git rebase -i --autosquash HEAD~10

Git automatically arranges the fixup commit right after its target.

Recovering from Mistakes

# See everything Git has recorded (even deleted branches)
git reflog

# Output:
# abc1234 HEAD@{0}: merge add-auth: Fast-forward
# def5678 HEAD@{1}: checkout: moving from add-auth to main
# ghi9012 HEAD@{2}: commit: Add JWT validation
# ...

# Restore a lost commit
git checkout -b recovered-branch HEAD@{2}

# Undo a merge
git reset --hard HEAD@{1}

# Recover a deleted branch
git checkout -b restored-feature HEAD@{5}

The reflog is your safety net. Git keeps everything for at least 30 days.

Complete Working Example: Daily Solo Workflow

#!/bin/bash
# daily-workflow.sh — Example of a typical solo developer day

# Morning: check what you were doing
git s
git stashes
git l

# Start a new feature
git cb feature/search-api

# Work session 1: build the search endpoint
git add src/routes/search.js src/services/searchService.js
git cm "feat: add full-text search endpoint for articles"

# Work session 2: add tests
git add tests/search.test.js
git cm "test: add search endpoint integration tests"

# Quick fix needed on main
git stash push -m "WIP: search pagination"
git co main
git add src/routes/api.js
git cm "fix: return 404 instead of 500 for missing resources"
git push
git co feature/search-api
git stash pop

# Work session 3: finish pagination
git add src/routes/search.js src/utils/pagination.js
git cm "feat: add pagination to search results"

# Clean up history before merging
git rebase -i main
# Squash WIP commits, reword as needed

# Merge to main
git co main
git merge feature/search-api
git branch -d feature/search-api

# Tag if this is a release
git tag -a v1.4.0 -m "Release 1.4.0: Full-text search"
git push origin main --tags

Common Issues and Troubleshooting

Accidentally committed to main instead of a branch

You committed directly to main when you meant to create a feature branch:

Fix: Create the branch from the current position, then reset main:

git branch feature/accidental-work
git reset --hard HEAD~3  # Move main back 3 commits
git checkout feature/accidental-work

Committed sensitive data (API keys, passwords)

A secret was committed and needs to be removed from history:

Fix: For the most recent commit, amend it. For older commits, use filter-branch or BFG Repo Cleaner. Change the secret immediately — assume it is compromised:

# Remove from last commit
git rm --cached .env
echo ".env" >> .gitignore
git commit --amend --no-edit

# Force push if already pushed (destructive — you are solo, so this is safe)
git push --force-with-lease

Merge created unexpected conflicts

You merged a branch and got conflicts you did not expect:

Fix: Use git diff main...feature-branch before merging to preview what will change. If you are already in a merge conflict, git merge --abort cancels it cleanly.

Lost commits after a bad rebase

An interactive rebase went wrong and commits are missing:

Fix: Use git reflog to find the commit hash before the rebase started, then reset to it:

git reflog
# Find the entry before "rebase started"
git reset --hard HEAD@{5}

Best Practices

  • Commit frequently with atomic changes. Small commits are easier to understand, revert, and bisect. Commit after each logical step, not at the end of the day.
  • Branch for anything that takes more than one commit. Even solo, branches protect main from half-finished work. They cost nothing and provide a clean rollback point.
  • Use conventional commit prefixes. feat:, fix:, refactor: make history scannable. You will thank yourself when debugging a regression six months later.
  • Tag every deployment. Tags create named checkpoints. When production breaks, git diff v1.3.0..v1.4.0 shows exactly what changed.
  • Clean up history before merging. Interactive rebase turns a mess of WIP commits into a coherent story. The feature branch is your draft — main should be the published version.
  • Set up aliases for your most common operations. git s, git l, git cm save hundreds of keystrokes per day. Customize them for your workflow.
  • Use the reflog as your safety net. Before any destructive operation, note the current HEAD. The reflog keeps everything for 30 days. You can always get back.
  • Never force push shared branches. As a solo developer, main is still "shared" if you deploy from it. Use --force-with-lease instead of --force to prevent overwriting remote changes you forgot about.

References

Powered by Contentful