Articles › Programming

Git for solo developers: small habits that save you when things break

Small commits with clear messages, trunk plus short branches, tags for releases, git bisect to find the bad commit in 7 tries out of 100, git reflog to recover lost work.

Beginner 7 min read October 22, 2026

What you will learn
  • Small commits, one change each, make history easy to revert, search and read
  • Work on main and branch off only for short-lived work that merges back within a day or two
  • git bisect finds the bad commit among 100 in 7 tries, because it halves the suspects every time
  • git reflog remembers everywhere HEAD has been, so a wrong reset --hard is recoverable; a committed secret needs a new key first

On a solo project nobody reviews your commits and nobody complains about ten commits called “fix”. None of it seems to matter until production breaks and you need to know which commit did it, or you reset --hard away an afternoon of work. These are a few Git habits that cost little today and save you six months from now.

Tested on Git 2.45.2 (Windows, Git Bash). All commands are POSIX shell and work the same on Linux and macOS.

Small commits, messages that say why

One commit, one change: one bug fix, one feature, one refactor, not mixed. Not for neatness: every recovery tool works in units of commits. revert undoes a commit; bisect points at a commit. If one commit holds five changes, the best the tools can say is “it is somewhere in these five”.

Instead ofWrite
fixfix: round tax before summing the total, not after
updatefeat: send a confirmation email when an order succeeds
wiprefactor: move price calculation out of the controller

Keep the first line short and about what changed. If the reason is not obvious from the code, add it on the following lines. The reader is you, six months from now.

Look at what is going in before every commit:

git status
git diff --staged
git commit -m "fix: round tax before summing the total"

Trunk plus short branches

You do not need develop, release and hotfix like a large team. Use main as the trunk, always deployable. For work that takes several days or that you are unsure about, branch off and merge back quickly.

git switch -c feat/export-csv     # create a branch and switch to it
# ... work, one commit per change ...
git switch main
git merge feat/export-csv
git branch -d feat/export-csv     # delete it once merged

A branch that lives for weeks drifts away from main, and the day you merge you get one giant conflict. If the experiment fails, drop the branch; main stays clean.

Tag every release

A tag is a label on a commit that says “this is what went to production”. Use annotated tags (-a): they store a date, the tagger and a message. The Git docs say annotated tags are meant for releases and lightweight tags for private or temporary labels, and git describe ignores lightweight tags by default.

git tag -a v1.4.0 -m "release 1.4.0: export CSV"
git describe            # e.g. v1.4.0-12-g6219891 = 12 commits past v1.4.0
git push --follow-tags  # push commits plus the annotated tags that point at them

Now when someone says “yesterday’s version was fine”, you know exactly which commit yesterday was, and you have a starting point for bisect. If you deploy by swapping folders as in atomic deploys with rollback, tags tell you which version each folder holds.

git bisect: halve your way to the bad commit

v1.0 was good, today is broken, and there are 99 commits in between. Checking them one by one is up to 99 tries. git bisect halves the range instead: test the middle; if it is good the bug is in the second half, if it is bad it is in the first. Repeat until one commit is left.

Worked example: how many tries for 99 commits?

Each try cuts the suspects in half, so the number of tries is about log₂(number of commits).

  1. 99 suspect commits
  2. 2⁶ = 64 is less than 99, so 6 halvings are not enough
  3. 2⁷ = 128 is more than 99, so 7 halvings always are
  4. log₂(99) ≈ 6.6, round up = 7 tries instead of 99
Commits to searchOne by one (worst case)bisect (about)
1001007
1,0001,00010
10,00010,00014

We ran it on a test repo of 100 commits with a bug planted in commit 73. At the start Git reported “49 revisions left to test after this (roughly 6 steps)”, then ran the test script 7 times in total and named commit 73.

Let a script decide

If you can turn the check into a script, git bisect run does the whole thing. The script exits 0 for good, 1-127 (except 125) for bad, and 125 when a commit cannot be tested (for example the build fails), which tells bisect to skip it.

git bisect start HEAD v1.0      # bad = HEAD, good = v1.0
git bisect run ./test.sh        # Git runs the script on each midpoint
git bisect reset                # done, back to your branch
#!/bin/sh
# test.sh: build fails = skip, tests fail = this commit is bad
npm run build || exit 125
npm test

Testing by hand instead, answer each step with git bisect good or git bisect bad. Bisect works best when commits are small and every commit builds. If half your commits do not build, you skip a lot, and the commit it finally names is too big to find the mistake in.

How useful bisect is depends on how small your commits are. The first habit is what makes this one work.

Start99After try 149After try 224After try 312After try 46After try 53After try 61suspect commits left, halved each try
bisect halves the suspects each time: 99 commits down to one within 7 tries

git reflog: get back work you thought was gone

A reset --hard to the wrong commit, a deleted unmerged branch, a rebase gone wrong: the work looks lost, but Git records every place HEAD has pointed in the reflog.

git reset --hard HEAD~3      # oops, threw away the last 3 commits
git reflog -3
# 4a7f3a8 HEAD@{0}: reset: moving to HEAD~3
# 6219891 HEAD@{1}: checkout: moving from ... to main
# ...
git reset --hard 'HEAD@{1}'  # back to where you were before the reset

If you would rather not move your current branch, create a new branch at that point first and look around:

git branch rescue 'HEAD@{1}'

Limits to know:

  • The reflog is local only. It is not pushed; delete the project folder and it goes too
  • Default retention is 90 days, and 30 days for entries no branch can reach any more; after that git gc may clean them up
  • It only recovers what was committed. Uncommitted edits wiped by reset --hard are not in the reflog, another reason to commit often

Always quote 'HEAD@{1}'; some shells (PowerShell, for one) treat the braces specially.

.gitignore your secrets from the first commit

Add .gitignore before your first git add, not after the accident.

# secrets and machine-specific values
.env
.env.*
!.env.example
*.pem
*.key

# things you can rebuild
node_modules/
dist/
__pycache__/

Check that a file really is ignored:

git check-ignore -v .env
# .gitignore:2:.env	.env

The common trap: .gitignore does not affect files that are already tracked, as the Git docs say. Remove the file from the index first:

git rm --cached .env
git commit -m "chore: stop tracking .env"

The file stays on disk, but it is still in every earlier commit. If you ever pushed it, treat the key as leaked. GitHub’s advice is to revoke or rotate the secret first; rewriting history with git filter-repo comes second, changes every commit hash after that point, and may not be needed once the key is dead. Keeping secrets out of code in the first place is covered in separate credentials.

A pushed secret is fixed by changing the key, not by deleting the commit.

Common mistakes

  • One giant commit at the end of each day. It makes revert and bisect useless
  • git add . before there is a .gitignore, and .env or node_modules goes in
  • Long-lived branches merged once at the end, with every conflict at once
  • Lightweight tags for releases. git describe will not see them; use -a
  • Panicking and re-cloning when one line of git reflog would have brought it back
  • push --force over main. Even solo, if you work from more than one machine the others get confused. Use --force-with-lease if you really must

Sources

FAQ

Working alone, do I need branches at all?

Not always. A small change finished in one sitting can go straight onto main. For work that takes days or is an experiment, branch off; main stays deployable and you can throw the branch away if it does not work out.

How long does the reflog keep entries?

By default 90 days for normal entries (gc.reflogExpire) and 30 days for entries no longer reachable from any branch (gc.reflogExpireUnreachable). The reflog lives only in your local repository and is never pushed.

I pushed my .env to GitHub. Is deleting the commit enough?

No. Treat the key as leaked. GitHub's first step is to revoke or rotate the secret; removing it from history with git filter-repo comes after, if at all.

Commands and settings were tested on sample systems. Try them on a test machine before production.