Git commands you actually use in a day of Mac work

Git ships well over a hundred subcommands. A cheat sheet lists most of them, which is why cheat sheets are read once and never again: the list is sorted by what Git can do, not by what a day of work needs. A more useful sort is by frequency and by risk. Roughly a dozen commands carry almost every working day. A second small group is for reading history when the next step is unclear. A third group undoes things, and that group is the one worth understanding precisely, because a few of its members can destroy work that has never been recorded anywhere.

The loop that covers most of a working day

Five commands, in a fixed order, account for the majority of Git usage in normal work.

git status
git diff
git add -p src/checkout.ts
git commit -m "Reject expired coupons at checkout"
git push

git status names which files changed and which are staged. git diff with no arguments shows the changes that are not staged yet, which is a different set from everything changed since the last commit. That distinction catches people out often enough to be worth stating plainly: once everything is staged, plain git diff prints nothing.

git add -p is the one entry in this list that changes the quality of the result rather than the speed of getting there. The documentation describes it as choosing hunks of patch between the index and the work tree interactively, which gives a chance to review the difference before adding it. In practice it is how one working tree containing a fix, a rename, and a stray debug line becomes two clean commits and a discarded line. Commits built this way are the ones that can be reverted or cherry-picked later without dragging unrelated changes along.

git stash belongs beside these, as the answer to an interruption. git stash push -m "wip coupon validation" sets the current changes aside, git stash list shows what is waiting, and git stash pop brings the most recent set back. -u includes untracked files, which is the flag people wish they had used after a stash that appeared to lose a new file. git stash branch <name> turns a stash into a branch, which is the graceful exit from a stash that has aged.

Reading history before changing it

The commands in this group produce no changes at all, which makes them the cheapest way out of uncertainty.

git log --oneline --graph --decorate -20
git log -p -- src/checkout.ts
git show 9f2c1ab --stat
git blame -L 40,80 src/checkout.ts

git log --oneline --graph answers the question that comes up before any merge or rebase, which is what shape the history currently has. Adding -p restricts attention to one path and shows the patch for each commit touching it, which is how a behaviour change gets traced to a commit rather than to a person.

git blame -L <start>,<end> annotates a line range rather than a whole file, and it also accepts -L :<funcname> to follow a function by name. On a long file, this is the difference between a readable answer and several hundred lines of output.

Two search options are worth separating because they look similar and answer different questions. -S<string> looks for differences that change the number of occurrences of a string, so it finds where something was introduced or removed. -G<regex> looks for differences whose patch text contains added or removed lines matching a pattern, so it finds every commit that touched a line mentioning it. The documentation illustrates the gap with a commit that changes a call site: git log -G"frotz\(nitfol" shows it, while git log -S"frotz\(nitfol" --pickaxe-regex does not, because the number of occurrences did not change.

Finding which commit did it

When reading history is not enough, the next command is the one most people know by name and never reach for. git bisect runs a binary search over the range between a commit known to be broken and a commit known to be fine, checking out a commit in the middle and asking which side it falls on.

git bisect start
git bisect bad
git bisect good v3.2.0
git bisect run npm test -- src/checkout.test.ts
git bisect reset

The manual describes it as a binary search for the commit that introduced a bug, and it also notes the wider use: the terms old and new can stand in for good and bad, which means the same search finds the commit that fixed something or the commit where a benchmark changed. A range of a thousand commits resolves in about ten steps.

git bisect run is the part that turns this from a chore into a background task, and its contract is worth getting right. The script must exit 0 when the source is good, and with a code between 1 and 127 when it is bad. 125 is reserved, and means the current commit cannot be tested, so bisect skips it instead of drawing a conclusion. Any other exit code stops the session. Note that a script ending in exit(-1) leaves 255, which is inside the bad range rather than outside it. git bisect reset returns the working tree to where it started, and forgetting it is the usual reason a repository appears to be stuck on an old commit.

Two search commands sit beside it. git grep looks for a pattern in the tracked files in the working tree, in the blobs registered in the index, or in the blobs of a given tree object, which means a tag or an old commit can be searched without checking it out. git ls-files merges the index listing with the actual directory listing, and its flags decide which combination is shown, so it settles arguments about whether a file is tracked, ignored, or merely sitting on disk.

checkout, switch, restore, and the experimental label

git checkout does two unrelated jobs, which is why git switch and git restore exist to split them. Both newer commands are useful, and both still carry a notice in their manual pages, in capitals, stating that the command is experimental and the behaviour may change. That notice is still present in the pages shipped with Git 2.48, several years after the commands were introduced, which is a fair reason to know all three rather than replacing one with the others.

git switch main
git switch -c fix/expired-coupons
git switch -
git restore src/checkout.ts
git restore --staged src/checkout.ts

git switch moves between branches and nothing else. It does not require a clean index and working tree, and it aborts if switching would lose local changes, unless told otherwise with --discard-changes or --merge. -c creates a branch and switches to it, and a bare - returns to the previously checked out branch, which is the same as @{-1}.

git restore restores paths, and its two destinations are worth committing to memory. With no flags it restores working tree files from the index. With --staged it restores the index from HEAD, which is the unstaging operation. With both --staged and --worktree it does both. --source=<tree> takes the content from a named commit instead, and -p selects hunks interactively.

The older spellings remain in every existing script and answer: git checkout <branch> to switch, git checkout -- <file> to discard a file's changes, git reset HEAD <file> to unstage. None of them are deprecated.

Undoing, ranked by what can be lost

This is the group where precision pays, so it is worth laying out by consequence rather than by name.

Command What it changes What can be lost
git restore --staged <file> the index only nothing
git reset <commit> the index and branch pointer nothing in the working tree
git commit --amend replaces the last commit the previous commit message
git revert <commit> adds a new commit nothing
git restore <file> the working tree file uncommitted changes to it
git checkout -- <file> the working tree file uncommitted changes to it
git reset --hard index and working tree all uncommitted changes

The official book is direct about where the cliff edge is. Almost anything committed can be recovered, including commits on a deleted branch and commits overwritten by an amend, while uncommitted content that is lost is gone for good. That is the line the table above is sorted around. Everything above git restore <file> touches recorded state and can be walked back. Everything below it can erase work that was never recorded anywhere.

git reflog is what makes the first half of that claim true. It lists where HEAD has been, including positions that no branch points at any more, so a branch deleted an hour ago or a commit replaced by an amend is still reachable by its hash. git log -g walks the same entries in log form.

Updating a shared branch without overwriting someone else's work

git push normally refuses to update a remote ref that is not an ancestor of the local ref, which is the protection that stops one person's history replacing another's. After a rebase, that refusal has to be bypassed, and there are two ways to do it.

git fetch origin
git log --oneline origin/main..HEAD
git push --force-with-lease

--force bypasses the check unconditionally. --force-with-lease bypasses it only when the remote ref is at the value the local repository expects. The documentation spells out the scenario it protects against: if somebody else built on top of the original history while the rebase was happening, the remote tip has advanced, and forcing unconditionally loses their work. With --force-with-lease the operation fails instead.

Fetching first is the other half. git fetch updates remote-tracking refs without touching any local branch, which makes git log --oneline origin/main..HEAD a safe way to see exactly what would be added. git pull --rebase replays local commits on top of the fetched ones rather than recording a merge, which keeps the history of a shared branch readable.

The macOS settings worth checking once

Four configuration values behave differently on a Mac than elsewhere, and reading them once explains a category of confusing behaviour.

git config --get core.ignoreCase
git config --get core.precomposeUnicode
git fsmonitor--daemon status

core.ignoreCase is documented as an internal variable enabling workarounds for filesystems that are not case sensitive, naming APFS and HFS+ among others. It defaults to false, except that git clone and git init probe the filesystem and set it to true when appropriate. The documentation warns that Git relies on the value being correct for the filesystem and that changing it may cause unexpected behaviour, so this is a value to read rather than to edit. It is also the reason a rename that only changes capitalisation needs an intermediate name to work reliably.

core.precomposeUnicode is documented as used only by the macOS implementation of Git, and when true it reverts the Unicode decomposition of filenames that macOS performs. This is what makes filenames containing accents or Japanese characters compare equal between a Mac and a Linux build server.

core.protectHFS defaults to true on macOS and refuses to check out paths that would be treated as equivalent to .git on HFS+. It is a security measure and belongs left alone.

core.fsmonitor enables a filesystem monitor daemon that is built into Git, so no third-party tool is needed. It communicates directly with commands such as git status instead of going through hooks, which is what makes git status fast in a repository with a very large working tree.

Credential storage is the last item. Git on macOS ships a keychain helper, git-credential-osxkeychain, in its exec path, and naming it as the credential helper is how HTTPS remotes stop asking for a token on every fetch.

Where the day actually goes

Counting the commands in a working day gives a small number. Counting the context switches around them gives a much larger one: read a status in a terminal, find the file in a list, open it in an editor pointed at a different directory, run the tests, read the diff, go back.

That cost is in the arrangement of the windows rather than in Git. When a file list, a terminal, and an assistant share one working directory in a single window, the output of git status and the files it names are in view together, and the switching stops being part of the task. What the arrangement covers is described on the Features page, and how it differs from dual pane browsers and terminal-first tools is set out on Compared with other file managers.

What to change first

Replace bare git add . with git add -p for one week, and read git log --oneline origin/main..HEAD before every update to a shared branch. Those two habits improve every later operation, because clean commits are what make revert, cherry-pick, and bisect usable. If the window switching around them is the real cost, that is a layout problem, and Atriens is one example of the shape of tool that removes it.

Frequently asked questions

Should git switch and git restore replace git checkout?

They cover the two jobs git checkout mixes together, so they are clearer to read, but both still carry an explicit experimental notice in their manual pages. Knowing all three is the practical answer, since git checkout appears in every older script and answer and is not deprecated.

How do you recover a commit after git reset --hard?

If the commit existed, git reflog still lists the position HEAD held before the reset, and git checkout or git branch against that hash brings it back. Changes that were only in the working tree and never committed or stashed are not recorded anywhere and cannot be recovered.

What is the difference between git fetch and git pull?

git fetch updates remote-tracking refs and changes no local branch, so it is always safe to run. git pull fetches and then integrates, by merge with default settings or by replay with --rebase. Fetching first and reading git log origin/main..HEAD shows what a pull would bring before it happens.

Why does a filename rename that only changes capitalisation fail on a Mac?

The default macOS filesystem is case insensitive, so Readme.md and README.md are the same path to the operating system, and Git sets core.ignoreCase accordingly at clone time. Renaming through an intermediate name, such as git mv README.md tmp followed by git mv tmp Readme.md, records the change the repository needs.

Back to all posts