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:
mainis always deployable- Short-lived feature branches for anything non-trivial
- No long-running branches
- 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 functionalityfix:— bug fixrefactor:— code restructuring without behavior changedocs:— documentation onlychore:— maintenance taskstest:— test additions or changesperf:— performance improvementsstyle:— 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-issquash— merge into previous commit, combine messagesfixup— merge into previous commit, discard messagereword— change commit messagedrop— 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.0shows 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 cmsave 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-leaseinstead of--forceto prevent overwriting remote changes you forgot about.