Dotfiles & Git
Everything you configured in the last three chapters lives in text files. Put them in a Git repository and a new Mac — or a reset one — goes from Setup Assistant to your environment in twenty minutes. This chapter also sets up Git itself the way a working engineer has it, because the defaults are from 2005.
What counts as dotfiles#
Anything under ~ that starts with a ., plus the modern ~/.config/* tree:
| Config | Path |
|---|---|
| zsh | ~/.zshenv, ~/.zprofile, ~/.zshrc |
| Git | ~/.config/git/config, ~/.config/git/ignore (XDG paths — no more ~/.gitconfig) |
| Ghostty | ~/.config/ghostty/config |
| Starship | ~/.config/starship.toml |
| mise | ~/.config/mise/config.toml |
| tmux | ~/.config/tmux/tmux.conf |
| Neovim | ~/.config/nvim/ |
| Karabiner | ~/.config/karabiner/karabiner.json |
| AeroSpace | ~/.aerospace.toml (or ~/.config/aerospace/aerospace.toml) |
| Homebrew | ~/.config/homebrew/Brewfile |
| SSH | ~/.ssh/config (not the keys) |
| VS Code | ~/Library/Application Support/Code/User/settings.json, keybindings.json (or use Settings Sync) |
| Zed | ~/.config/zed/settings.json, keymap.json |
| macOS defaults | your macos-defaults.sh script (Chapter 3 / Appendix A) |
Never commit: ~/.ssh/id_*, ~/.gnupg/, ~/.aws/credentials, ~/.npmrc with tokens, ~/.netrc, .env files, anything from ~/Library/Keychains. Chapter 16 covers where secrets go instead.
Three ways to manage them#
1. The bare-repo trick (zero tools)#
Your home directory is the working tree; the .git directory lives elsewhere so ~ doesn't look like a repo.
git init --bare "$HOME/.dotfiles"
alias dot='git --git-dir="$HOME/.dotfiles" --work-tree="$HOME"' # put this in .zshrc
dot config status.showUntrackedFiles no
dot add ~/.zshrc ~/.config/ghostty/config ~/.config/starship.toml ~/.config/git/config
dot commit -m "initial dotfiles"
dot remote add origin git@github.com:you/dotfiles.git
dot push -u origin mainOn a new machine:
git clone --bare git@github.com:you/dotfiles.git "$HOME/.dotfiles"
alias dot='git --git-dir="$HOME/.dotfiles" --work-tree="$HOME"'
dot checkout # fails if files exist — move them aside, retry
dot config status.showUntrackedFiles noPros: no dependencies, files live in place, dot diff works. Cons: no templating (same file on every machine), no secrets handling, easy to accidentally dot add something large.
2. chezmoi (recommended for more than one machine)#
chezmoi (brew install chezmoi) is a single Go binary that keeps the source of truth in ~/.local/share/chezmoi and applies it to ~. Its strengths: templates (one .gitconfig with {{ if eq .chezmoi.hostname "work-mbp" }} blocks), secrets pulled from 1Password/Bitwarden/Keychain at apply time (never stored in the repo), run_once_ scripts (install Homebrew, run brew bundle, apply defaults), and first-class support for macOS/Linux differences.
chezmoi init # creates the source repo
chezmoi add ~/.zshrc ~/.config/ghostty/config # copies into the source dir (as dot_zshrc, dot_config/ghostty/config)
chezmoi edit ~/.zshrc # edit the source version
chezmoi diff # what would change
chezmoi apply # write to ~
chezmoi cd && git add -A && git commit -m "…" && git push
# new machine — one line, including running your install scripts:
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply youA secrets template line: token = {{ onepasswordRead "op://Private/GitHub/token" }} — chezmoi calls the 1Password CLI at apply time. Machine-specific data lives in ~/.config/chezmoi/chezmoi.toml ([data] email = "…", work = true).
3. GNU Stow / yadm / Nix Home Manager#
- Stow (
brew install stow): keep~/dotfiles/zsh/.zshrc,~/dotfiles/ghostty/.config/ghostty/config, runstow zsh ghosttyto symlink them into~. Simple, transparent, no templating. Very popular. - yadm: the bare-repo trick with templating and encryption bolted on.
- Home Manager (Nix): declarative, reproducible, manages packages and config, steep learning curve; the right answer if you're already Nix-curious and wrong for a first-year student.
Pick chezmoi if you have (or will have) a work Mac and a personal Mac; the bare repo or Stow if you have one machine and like simplicity.
Repository layout and bootstrap#
Whatever the tool, include an install.sh (idempotent) at the repo root so the sequence on a fresh Mac is:
xcode-select --install
git clone git@github.com:you/dotfiles.git ~/.dotfiles # or the chezmoi one-liner
~/.dotfiles/install.shThe script (Appendix A is a complete one) should: install Homebrew if missing → brew bundle → link/apply dotfiles → run macos-defaults.sh → mise install → set up Touch ID sudo → remind you of the manual steps (sign in to 1Password, add SSH key to GitHub, grant Accessibility to Raycast/Karabiner). Keep a README.md with those manual steps; you'll thank yourself in two years.
Structure that scales:
dotfiles/
├── install.sh
├── Brewfile
├── macos-defaults.sh
├── zsh/ .zshenv .zprofile .zshrc
├── git/ config ignore
├── ghostty/ config
├── starship.toml
├── mise/ config.toml
├── nvim/ …
├── karabiner/ karabiner.json
├── aerospace/ aerospace.toml
├── vscode/ settings.json keybindings.json extensions.txt
└── README.md manual steps, hardware notesInclude .zshrc.local in .gitignore and source it from .zshrc for machine-specific bits (work proxies, secrets, aliases you'd rather not publish).
Git, configured properly#
Homebrew's git (brew install git) is newer than Apple's. Use the XDG location so ~ stays clean:
mkdir -p ~/.config/git~/.config/git/config:
[user]
name = Alex Kowalski
email = alex@example.com
signingkey = ~/.ssh/id_ed25519.pub # sign commits with your SSH key (no GPG needed)
[gpg]
format = ssh
[gpg "ssh"]
allowedSignersFile = ~/.config/git/allowed_signers
# 1Password users: program = /Applications/1Password.app/Contents/MacOS/op-ssh-sign
[commit]
gpgsign = true
verbose = true # show the diff in the commit message editor
[tag]
gpgsign = true
[init]
defaultBranch = main
[core]
editor = nvim # or: code --wait / zed --wait
pager = delta
autocrlf = input
excludesfile = ~/.config/git/ignore
fsmonitor = true # faster status in big repos (uses FSEvents)
untrackedCache = true
[color]
ui = auto
[column]
ui = auto
[branch]
sort = -committerdate
[tag]
sort = version:refname
[fetch]
prune = true
pruneTags = true
all = true
[pull]
rebase = true
[push]
default = simple
autoSetupRemote = true # first `git push` just works
followTags = true
[rebase]
autoSquash = true
autoStash = true
updateRefs = true
[merge]
conflictstyle = zdiff3
[diff]
algorithm = histogram
colorMoved = plain
mnemonicPrefix = true
renames = true
[rerere]
enabled = true
autoupdate = true
[help]
autocorrect = prompt
[interactive]
diffFilter = delta --color-only
[delta]
navigate = true
side-by-side = false
line-numbers = true
hyperlinks = true
[credential]
helper = osxkeychain # HTTPS tokens in the Keychain; `gh auth login` sets this up too
[url "git@github.com:"]
insteadOf = https://github.com/ # always push over SSH even if you cloned via HTTPS
[alias]
st = status -sb
co = checkout
sw = switch
br = branch
ci = commit
ca = commit --amend --no-edit
cm = commit -m
lg = log --oneline --graph --decorate --all -30
last = log -1 HEAD --stat
unstage = reset HEAD --
undo = reset --soft HEAD~1
wip = !git add -A && git commit -m "wip"
fixup = commit --fixup
please = push --force-with-lease
aliases = config --get-regexp ^alias\\.
ignored = ls-files --others --ignored --exclude-standard
root = rev-parse --show-toplevel
cleanup = "!git branch --merged | grep -vE '(^\\*|main|master|develop)' | xargs -r git branch -d"
[includeIf "gitdir:~/Developer/work/"]
path = ~/.config/git/config-work # different email/signing key for work reposNotes on the choices:
- SSH commit signing (Git ≥ 2.34) reuses your SSH key — no GPG keyring to manage. Add the same public key to GitHub as a Signing key (
gh ssh-key add ~/.ssh/id_ed25519.pub --type signing), and putalex@example.com ssh-ed25519 AAAA…in~/.config/git/allowed_signerssogit log --show-signatureverifies locally. With 1Password as SSH agent, setgpg.ssh.programtoop-ssh-signand Touch ID approves each signature. fsmonitor+untrackedCachemakegit statusinstant in monorepos on macOS.pull.rebase = trueandrebase.autoStashkeep history linear for the solo-dev workflow; teams that merge can flip it.includeIflets your work identity kick in automatically for anything under~/Developer/work/.delta(brew install git-delta) gives syntax-highlighted, line-numbered diffs;difftastic(git difftool -t difftasticafter[difftool "difftastic"] cmd = difft "$LOCAL" "$REMOTE") understands syntax trees.
Global ignore, ~/.config/git/ignore:
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
Icon?
# editors
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.swp
*~
.zed/
# envs & secrets (belt and braces — repos should ignore these too)
.env
.env.*
!.env.example
.envrc
.direnv/
# tooling
.mise.local.toml
.tool-versions.local
node_modules/
__pycache__/
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
target/
dist/
build/GitHub CLI#
brew install gh, then gh auth login (choose SSH, upload your key, authenticate in the browser). It configures the Keychain credential helper too. Daily use:
gh repo create my-project --private --clone # new repo + clone
gh repo clone owner/repo
gh pr create --fill # PR from the current branch
gh pr checkout 123 # review someone's PR locally
gh pr view --web
gh pr checks --watch
gh issue list --assignee @me
gh run watch # tail a GitHub Actions run
gh api repos/{owner}/{repo}/releases # any REST call
gh extension install dlvhdr/gh-dash # a TUI dashboard of PRs/issues
gh copilot suggest "undo last commit but keep changes" # gh-copilot extensionGitLab has glab; Bitbucket has nothing good. Git GUI clients if you want one: Fork ($50, fast, best interactive rebase UI), Tower ($69/yr), GitKraken, Sublime Merge, GitHub Desktop (free, basic), or the Git panels in VS Code/Zed/JetBrains — plus lazygit in the terminal, which many people end up preferring to all of them.
direnv: per-directory environment#
brew install direnv, hook it in .zshrc (eval "$(direnv hook zsh)"). Drop a .envrc in a project:
# .envrc
dotenv_if_exists .env # load KEY=value pairs from .env (which is gitignored)
export DATABASE_URL=postgres://localhost/myapp_dev
PATH_add ./bin ./node_modules/.bin
layout python # or `use mise` / `use flake` (nix) — activates a venv automaticallydirenv allow once; from then on, cd into the directory loads the environment and leaving it unloads it. mise (Chapter 11) can do the env part too ([env] in .mise.toml), so many people use only mise; direnv remains the standard for teams with mixed tooling and for Nix flakes.
Secrets hygiene for repos#
- Commit
.env.examplewith placeholder keys; never.env. Add.env*to the global ignore (above) and each repo's.gitignore. - Install gitleaks or trufflehog (
brew install gitleaks) and run it as a pre-commit hook:pre-commit(brew install pre-commit) with a.pre-commit-config.yamlincludinggitleaks. GitHub's push protection also blocks known token formats on push for public repos and for orgs with Advanced Security. - If you do commit a secret: rotate it immediately (assume it's compromised the moment it hits a remote), then rewrite history with
git filter-repoand force-push. Rotating is the important part; the rewrite is hygiene. - Store personal API keys in the macOS Keychain (
security add-generic-password -a "$USER" -s openai_api_key -w) and read them in.zshrclazily, or in 1Password and inject withop run --env-file=.env.tpl -- npm start. Both beat a plaintextexport OPENAI_API_KEY=in your dotfiles. - SSH keys: one per machine, passphrase-protected, in the Keychain or a hardware token. Revoke a machine's key on GitHub when you sell it.