中文 | English
After reading webpro's article, I discovered many useful dotfiles. I think they are great, but I don't want to copy them directly (I believe everyone's computer setup is unique). I want a configuration that fits me, just like mathiasbynens.
This repository is a personal, macOS-first setup. It is written to be readable and reproducible rather than to be a general-purpose framework: every file that is installed is declared in one place (
install.sh), and./bootstrap.shchecks that what is on disk matches that declaration.
| Path | What it is | Installed by install.sh |
|---|---|---|
install.sh |
Installer: links files, optionally installs tooling | — |
bootstrap.sh |
Verifier: read-only check of the install (and --fix) |
— |
tests/test-install.sh |
Regression tests for both scripts | — |
zsh/ |
Shell startup files and zshrc.d/ modules |
yes |
p10k/ |
powerlevel10k flavour (zshrc overlay + p10k.zsh) |
yes, with --prompt p10k |
git/ |
gitconfig, gitignore_global, gitconfig.local |
yes |
editorconfig |
EditorConfig rules | yes → ~/.editorconfig |
tmux/ |
oh-my-tmux tmux.conf + personal tmux.conf.local |
yes → ~/.config/tmux |
nvim/ |
Neovim (NvChad) configuration | yes → ~/.config/nvim |
bin/ |
Personal commands; executables land in ~/.local/bin |
yes |
config/ |
starship.toml, thefuck/, aria2/ |
yes |
Brewfile |
Homebrew formulae, casks and VS Code extensions | yes (macOS) |
macos |
Script that changes system-wide macOS preferences | no — run manually |
defaults |
Read-only dump of this machine's defaults state |
no — reference only |
macos_other.md |
Notes and snippets not automated | no — documentation |
launchd/ |
Documentation about launchd |
no — documentation |
| Platform | Support |
|---|---|
| macOS (Apple Silicon and Intel) | Full: dotfiles, Homebrew + Brewfile, zsh, prompt, tmux, nvim |
| Linux | Partial: dotfiles + zsh only. Homebrew/Brewfile/macOS steps are skipped |
| Windows / WSL | Not supported by install.sh. Use WSL and run the Linux path, or link files manually |
Requirements:
bash>= 3.2 — the version macOS ships at/bin/bash. The scripts avoid bash 4+ features on purpose, so no Homebrew bash is needed.zsh>= 5.0 for the shell configuration.gitfor cloning zsh plugins (and forBrewfile'sgo/uventries).
git clone https://github.com/genwilliam/dotfiles.git ~/dotfiles
cd ~/dotfiles
./install.sh| Option | Effect |
|---|---|
-n, --dry-run |
Print what would happen; change nothing |
--link-only |
Only create symlinks. Skip Homebrew, Brewfile, zsh, Oh My Zsh and plugins |
--prompt FLAVOUR |
starship (default) or p10k. Skips the interactive question |
--print-map |
Print source<TAB>target<TAB>required|optional for every managed path and exit |
--uninstall |
Remove the symlinks that point into this repository |
--remove-readmes |
Delete README* files from $HOME after installing |
-y, --yes |
Answer "yes" to every confirmation |
-h, --help |
Usage |
Useful combinations:
./install.sh --dry-run # preview a full install
./install.sh --link-only --prompt p10k # only link files, powerlevel10k flavour
./install.sh -y # unattendedinstall.sh needs to run on macOS or Linux; it stops with a clear message
otherwise. It never uses sudo except when it has to register a shell in
/etc/shells for chsh, and it tells you the manual command when it cannot.
install.sh has a single mapping table (link_dotfiles). This is the complete
list for the default (starship) flavour:
| Source in this repo | Target |
|---|---|
git/gitconfig |
~/.gitconfig |
git/gitignore_global |
~/.gitignore_global |
git/gitconfig.local |
~/.gitconfig.local (optional, see below) |
editorconfig |
~/.editorconfig |
zsh/zshenv |
~/.zshenv |
zsh/zprofile |
~/.zprofile |
zsh/zlogin |
~/.zlogin |
zsh/zlogout |
~/.zlogout |
zsh/zshrc.d |
~/.zshrc.d |
zsh/zshrc |
~/.zshrc |
config/starship.toml |
~/.config/starship.toml |
tmux |
~/.config/tmux |
config/thefuck |
~/.config/thefuck |
config/aria2 |
~/.config/aria2 |
nvim |
~/.config/nvim |
each executable in bin/ |
~/.local/bin/<name> |
With --prompt p10k, p10k/zshrc is linked to ~/.zshrc, p10k/p10k.zsh to
~/.p10k.zsh, and config/starship.toml is not linked. Switching flavour also
removes the other flavour's link.
git/gitconfig.local is optional: it is gitignored, so a fresh clone does not
contain it. install.sh skips it silently and bootstrap.sh reports it as a note.
Create it yourself for machine-local identity:
cat > ~/dotfiles/git/gitconfig.local <<'EOF'
[user]
name = Your Name
email = you@example.com
EOF
./install.sh --link-onlyOnly executable files under bin/ are linked, so the bin/README.md files never
end up in ~/.local/bin. Files in subdirectories (such as bin/local/adminrun) are
included.
Linking is idempotent and non-destructive:
- A target that is already a symlink to the correct source is left untouched.
- A target that is a symlink to something else is replaced by the correct symlink.
- A target that is a real file or directory is first moved to
<target>.bak.<YYYYmmddHHMMSS>and never deleted. If that name is taken, a numeric suffix is appended. - A missing source is reported: a required mapping that points at a file which no longer exists is a failure, not a silent skip.
The script summarises what it did (linked / already correct / skipped / backed up / removed) and exits non-zero if any step failed.
./bootstrap.sh # read-only check
./bootstrap.sh --fix # repair missing or wrong links
./bootstrap.sh --no-warm # skip the interactive zsh smoke test
./bootstrap.sh --prompt p10kbootstrap.sh reads the mapping from install.sh --print-map, so the two can never
disagree. It checks that:
- every mapping points at a source that still exists (this is what catches a
configuration file that was renamed but not updated in
install.sh); - every target in
$HOMEis a symlink to that exact source; - no stale symlink points into this repository that is no longer part of the
mapping (these silently change shell behaviour — for example a leftover
~/.tmux.confor~/.p10k.zsh); - the managed zsh files pass
zsh -n; - an interactive zsh starts cleanly (skip with
--no-warm).
It exits 0 when everything verifies and 1 when it finds a problem. Repair only
happens with --fix, which delegates to ./install.sh --link-only — there is only
one implementation of the linking logic, so there is nothing to drift.
Re-running is safe; that is the normal update path:
cd ~/dotfiles
git pull
./install.sh --link-only # or ./install.sh to also run the tooling steps
./bootstrap.shTo remove the configuration without touching anything else:
./install.sh --uninstall --dry-run # preview
./install.sh --uninstall # remove only repo-owned symlinks--uninstall removes a path only if it is a symlink that resolves to this
repository. Regular files, directories, and symlinks pointing elsewhere are left
alone, and backups are kept. There is no separate uninstall script by design:
uninstalling is just removing the links, which the installer can do precisely.
To restore a configuration that was replaced during an install:
ls -d ~/.zshrc.bak.* # find the backups
mv ~/.zshrc.bak.20260101120000 ~/.zshrc # restore one| Step | Command / action | Why it is manual |
|---|---|---|
| macOS system preferences | ./macos |
Changes system-wide settings, needs sudo and a re-login |
| Reviewing your defaults | ./defaults is a dump, not a script |
See the warning below |
| Meslo Nerd Font | brew install --cask font-meslo-lg-nerd-font |
Also in the Brewfile |
| powerlevel10k wizard | p10k configure |
Regenerates ~/.p10k.zsh |
| tmux plugins | start tmux, then Prefix + I |
TPM installs/updates plugins itself |
| Neovim plugins | start nvim once |
lazy.nvim installs plugins on first start |
| GitHub Copilot auth | :Copilot setup in Neovim |
Interactive authentication |
| Homebrew itself | prompted by ./install.sh |
Runs the upstream installer, which needs your consent |
defaults is a 195 KB, automatically generated dump of this machine's current
macOS defaults (defaults read output), kept for reference. It is not a script and
is not executed by anything.
It contains binary plist data and machine-specific personal information: absolute paths under a home directory, recent Finder locations, Dock entries, and similar. Treat it as a private backup. If you fork this repository, delete that file (or keep it out of version control) before publishing.
Before you begin, please read zsh/bash loading order.
| File | Load order | When it is loaded | Runs every shell start? | Responsibility here |
|---|---|---|---|---|
.zshenv |
1 | Every zsh invocation | yes | Locale only, kept minimal so scripts/scp are unaffected |
.zprofile |
2 | Login shells | no | Owns PATH: user bins, Homebrew shellenv |
.zshrc |
3 | Interactive shells | no | Orchestrator: cache paths, module order, Oh My Zsh |
.zlogin |
4 | After .zshrc in login shells |
no | Terminal title |
.zlogout |
5 | When a login shell exits | no | Cleanup hook (currently a no-op, documented) |
~/.zshrc sources $ZSH/oh-my-zsh.sh, and install.sh runs the official Oh My
Zsh installer plus clones the plugins that are not bundled with it. The plugin list
in zsh/zshrc.d/plugins.zsh is the source of truth:
- bundled with Oh My Zsh, nothing to install:
git,fzf,aliases,colored-man-pages,zoxide,extract - installed by
install.sh:zsh-autosuggestions,fast-syntax-highlighting,you-should-use
fzf-tab is intentionally disabled (commented out in plugins.zsh, and no
fzf-tab keybinding is configured). Tab uses menu completion.
zsh/zshrc.d/ is split by responsibility, and the load order matters:
~/.zshrc
├─ pre-Oh-My-Zsh : env.zsh → env.local.zsh → plugins.zsh
├─ Oh My Zsh : $ZSH/oh-my-zsh.sh
└─ post-Oh-My-Zsh: aliases.zsh → completion.zsh → tools.zsh
env.zsh— interactive environment variables and extraPATHentries (Rust, JDK, Maven).env.local.zsh— gitignored, empty by default; put machine-specific settings here. It is sourced even when absent.plugins.zsh— only theplugins=(...)array.completion.zsh— completion policy andcompinit.tools.zsh— tool initialisation: starship, lazythefuck, lazynvm, lazymise.
See zsh/zshrc.d/README.md for the full conventions.
PATH is composed in .zprofile in this order (last prepended wins):
~/.local/bin,~/Library/pnpm/opt/homebrew/bin,/usr/local/bin(then Homebrew'sshellenvprepends/opt/homebrew/binand/opt/homebrew/sbinagain)~/.openclaw/binand other per-user tool bins, guarded by an existence check
zsh/zshrc.d/env.zsh prepends Rust/JDK/Maven paths afterwards. Nothing hard-codes a
user name or a home directory: paths are built from $HOME and existence-checked, so
the same files work on another machine or another architecture (the JDK check covers
both /opt/homebrew and /usr/local). The Maven path is resolved with a glob, so
upgrading Maven does not require editing the config.
path/PATH are kept unique (typeset -gU path), so re-sourcing ~/.zshrc never
grows or duplicates PATH.
Both flavours share ~/.zshrc.d, so aliases, completion and tool initialisation are
identical. Only the prompt differs:
- starship (default, currently used):
zsh/zshrc→~/.zshrc, config inconfig/starship.toml. - powerlevel10k:
p10k/zshrc→~/.zshrc. That file is a thin overlay — it initialises the instant prompt, setsZSH_THEMEandZSH_PROMPT_FLAVOR=p10k, then sources the shared orchestrator found relative to its own resolved symlink. It no longer duplicates the module list.
./install.sh --link-only --prompt p10kInstalling both at once is not supported; install.sh removes the other flavour's
link so that exactly one prompt is active. Changing flavour takes effect in a new
shell.
To configure starship, edit config/starship.toml — it is linked into
~/.config/starship.toml (the README used to show a wrong path for this).
In large plugin setups, repeated source ~/.zshrc can become unexpectedly slow when
compinit keeps rebuilding completion metadata (compdump).
- Re-sourcing
~/.zshrcin the same shell can cause completion metadata drift if path arrays are not deduplicated. - Once metadata differs,
compinitmay regenerate the completion cache, which can make reload time jump from milliseconds to hundreds of milliseconds.
Current fix in this repo:
typeset -gU path fpathprevents duplication during repeated sourcing.ZSH_CACHE_DIRandZSH_COMPDUMPpin the cache under~/.cachefor deterministic behaviour.completion.zshusescompinit -C -d "$ZSH_COMPDUMP"to prefer cache reuse.- Completion policy is explicit: Tab uses menu selection;
fzf-tabis available but disabled.
This actually works now:
ZSH_STARTUP_PROFILE=1 zsh -i -c exit 2>&1 | grep '^zsh-startup'Each sourced module prints one zsh-startup <ms> <module> line on stderr. With the
variable unset there is no overhead (the timing code is guarded behind a single
variable check). Baseline stays available with:
/usr/bin/time -lp zsh -i -c exitThe tmux configuration is based on oh-my-tmux.
Unlike the instructions in older versions of this README, do not run the upstream
install.sh: this repository already tracks oh-my-tmux's tmux.conf, and the
upstream installer would write through the symlink and modify the tracked file.
The layout is XDG-based:
~/.config/tmux -> <repo>/tmux
~/.config/tmux/tmux.conf -> oh-my-tmux base config (tracked, do not edit)
~/.config/tmux/tmux.conf.local -> personal overrides (edit this one)
~/.config/tmux/plugins/ -> TPM's plugin directory (gitignored)
tmux >= 3.1 reads ~/.config/tmux/tmux.conf by itself, and oh-my-tmux derives its
plugin directory from the config location, which is why the whole tmux directory is
linked at once. Older installs that created ~/.tmux.conf are migrated
automatically: install.sh removes that symlink when it points into this repository
and prints a note (otherwise a stale ~/.tmux.conf would keep taking precedence).
Declared in tmux/tmux.conf.local and installed by TPM:
tmux-plugins/tmux-copycat— regex search in scrollback/copy modetmux-plugins/tmux-resurrect— save and restore sessionstmux-plugins/tmux-open— open the highlighted path/URL
Commented out (available, not enabled):
tmux-plugins/tmux-cpu,tmux-plugins/tmux-continuum,27medkamal/tmux-session-wizard,tmux-plugins/tmux-sensible,tmux-plugins/tmux-yank
tmux_conf_uninstall_plugins_on_reload=true means plugins that are removed from the
list above are uninstalled the next time the configuration is reloaded. That is
intended.
TPM keys (oh-my-tmux's integration, slightly different from upstream TPM):
| Keys | Action |
|---|---|
Prefix + I |
install plugins |
Prefix + u |
update plugins |
Prefix + Alt + u |
uninstall removed plugins |
| Binding | Action |
|---|---|
Prefix + - |
split top/bottom |
Prefix + _ |
split left/right |
Prefix + H/J/K/L |
resize pane (repeatable) |
Prefix + r |
reload configuration |
Prefix + e |
edit tmux.conf.local |
Prefix + m |
toggle mouse mode (personal override) |
Prefix + + |
maximise/restore a pane |
Prefix + s |
tmux's built-in choose-tree |
Prefix + c / w / x / z / q |
new window, window list, kill pane, zoom, pane numbers |
Prefix + [ then v / y |
copy mode: select, copy to the OS clipboard |
Earlier versions of this README documented a
Prefix + =split and adddelete flow inPrefix + s. Neither exists in the tracked configuration, so they have been removed from this document rather than left as wishful thinking.
Personal settings from tmux.conf.local: mouse mode on, history-limit 10000,
copy-to-OS-clipboard enabled, and the dark theme colours.
Based on NvChad v2.5. nvim/init.lua bootstraps
lazy.nvim, which installs and loads every
plugin, including github/copilot.vim declared in nvim/lua/plugins/init.lua. The
pinned versions live in nvim/lazy-lock.json.
Because lazy.nvim is the plugin manager, nvim/pack/ is only a placeholder (kept by
nvim/pack/.gitkeep). If you do not have Neovim yet:
brew install nvim # macOS
# or download from https://neovim.io/First start installs the plugins; run :Copilot setup once to authenticate Copilot.
git/gitconfig holds global settings. Private per-machine values live in
git/gitconfig.local, which gitconfig includes when it exists:
[include]
path = ~/.gitconfig.localgit/gitconfig.local is gitignored and therefore absent from a fresh clone; Git
ignores a missing include path, so everything still works. Create it as shown in
Install.
One important note: if your email is written directly in config, it may expose your
address. GitHub can hide your email address — see
this article.
Enable Keep my email addresses private in GitHub to get a noreply address; CLI
Git will not use it automatically, but you can put it in gitconfig.local to avoid
exposing your real email.
git/gitignore_global holds global ignore rules (OS junk, editor files, build
output, node_modules, Python caches, Go binaries). Its path must match
core.excludesFile in gitconfig, which it does (~/.gitignore_global).
It contains the Homebrew packages installed via
brew bundle. Inspect it with a text editor first and adjust it to your needs —
it installs a lot of large casks. Then:
brew bundle --file=./Brewfileinstall.sh runs the equivalent of brew bundle --file=<repo>/Brewfile --no-upgrade,
after asking for confirmation. It runs that step synchronously and before the shell
setup, because the tools it installs (starship, zoxide, nvm, git) are used by
the following steps.
appdir is a Homebrew Brewfile option that specifies where cask apps are installed.
By default, Homebrew installs apps into /Applications. This repo sets a global
default through HOMEBREW_CASK_OPTS in zsh/zshrc.d/env.zsh:
export HOMEBREW_CASK_OPTS="--appdir=$HOME/Applications --require-sha"Individual casks in the Brewfile also pass args: { appdir: "~/Applications" }.
On macOS, the first user account is always an administrator account. Administrator
accounts belong to the admin group and have sudo privileges. This means they can
effectively control the system, including privileged operations, which increases risk.
Tools like sudo can have weaknesses
that may be exploited by concurrently running programs.
Apple recommends this best practice: use a separate standard account for daily work, and use an administrator account only for installation and system configuration.
You don't have to log in through the macOS login screen as the admin account all the time. When terminal commands require admin privileges, the system prompts for authentication, then the terminal continues with those privileges. For this, Apple provides guidance on hiding the admin account and its home directory here. It's an elegant approach to avoid having a visible "ghost" account.
Quoted from https://github.com/drduh/macOS-Security-and-Privacy-Guide
bin/ holds personal commands. Every executable file in it is linked into
~/.local/bin, which is on PATH via .zprofile, so bin/local/adminrun becomes
adminrun:
adminrun <command> [args...] # run a command with macOS admin privilegesNon-executable files such as bin/README.md are skipped, so documentation never ends
up on PATH. To add a command:
chmod +x bin/my-tool
./install.sh --link-only # or ./bootstrap.sh --fixmacos is a single script (not a directory). It applies system preference changes
with defaults, plus a few commands that need sudo. It is not run by install.sh,
because it changes system-wide state and typically needs a log out/in.
./macos --dry-run # preview every command; changes nothing
./macos # ask for confirmation, then apply
./macos --yes # apply without asking (for example after a dry run)What it changes:
defaults writepreferences: hot corners, tap-to-click, three-finger drag, keyboard UI mode, Finder path bar / search scope / extension warnings, menu bar clock, Mail attachments, Mission Control window grouping, screenshot, font and login-window keyschflags hidden ~/Library— hides your Library folder in Finder- a
hidutilCapsLock delay override (cleared again on the next reboot) - flushes the DNS cache and switches the application firewall on (
sudo) - removes Google's software updater if it is installed — this deletes files
- restarts Finder, Dock and SystemUIServer (closes any open Finder windows)
--dry-run shadows defaults/sudo/hidutil/chflags with printers, so you can read
exactly what would happen without touching anything. Individual defaults write
failures are not fatal by design: the script applies the whole list rather than stopping
at the first key the current macOS version no longer supports. Read it first — it is a
personal list, not a supported configuration profile.
See the warning above. Reference material only.
Notes and snippets that are deliberately not automated (for example the Mail.app index vacuum). Documentation only.
launchd/README.md and launchd/README-zh.md explain launchd and provide reference
material. They are documentation — no LaunchAgent/LaunchDaemon plists are
installed by this repository. Never put secrets in a plist, and note that
~/Library/LaunchAgents files are readable only by you while most of those paths are
below /Library/LaunchAgents and need admin rights.
-
Add the file to the repository, in the directory that matches its purpose.
-
Add it to the mapping table in
link_dotfiles()ininstall.sh:link_dotfile "$DOTFILES/config/my-tool.toml" "$HOME/.config/my-tool.toml"
Use the third argument
optionalif the source is machine-local and gitignored. -
Document it in this README (the layout table above).
-
Verify:
./install.sh --dry-run # the new file should be listed ./bootstrap.sh --no-warm # it should verify ./tests/test-install.sh # links and mappings are exercised
bootstrap.sh gets its expectations from install.sh, so as long as you add the
mapping, the verifier starts checking it immediately. Nothing else needs updating.
./tests/test-install.sh # full suite: isolated temp HOME, no network
shellcheck --shell=bash --severity=style install.sh bootstrap.sh tests/test-install.shThe test suite simulates a fresh clone (no gitignored files, no vendored plugins) and
checks: the mapping is well-formed and complete, a fresh install links everything
correctly, no documentation leaks into ~/.local/bin, pre-existing files are backed up
with their content intact, a second run is a no-op, --dry-run changes nothing,
legacy ~/.tmux.conf symlinks are migrated while foreign files are left alone, prompt
flavours switch cleanly, bootstrap.sh detects damage and repairs it, --uninstall
removes only repo-owned links, and a renamed source is reported as an error by both
scripts.
CI (.github/workflows/ci.yml) runs shellcheck, bash -n, zsh -n, the test suite,
and a real Linux install-and-verify — it needs no network and no Homebrew.
- macOS-only parts are not covered by CI.
install.sh's Homebrew,chshand~/.config/tmuxbehaviour and themacosscript are only exercised on macOS by hand. CI covers the linking logic on Linux. defaultscontains personal data and is committed on purpose (see the warning above). It is not a credential, but it does describe this machine. Removing it from future commits does not remove it from Git history; that would need a history rewrite, which is intentionally not done here.- Linux support is partial and best-effort.
zsh/zprofilehandles Linux paths andinstall.shcan useapt-get/dnf/yum/pacman/zypper/apk, but theBrewfile, themacosscript,launchdand the pnpm/Homebrew paths are macOS-oriented. *.txtis gitignored in this repository on purpose. If you ever need to track a.txtfile (for examplerequirements.txt), add an explicit!path/to/file.txt.fzf-tabis installed by nobody and enabled by nobody; the clone step was removed. Its keybindings were also removed from the p10k overlay, which previously referenced it while it was disabled.
As Joshua Clayton said in a recent episode of The Changelog, if you're unhappy with your dotfiles, you're doing it wrong™. Pick and choose what you like from other people's dotfiles. Learn from everyone, follow no one. Build your own lightsaber. Rebuild it from scratch with confidence. Original: https://wynnnetherland.com/journal/dotfiles-discovery
If you don't know what dotfiles are, this article is helpful.
If you don't want to read it, here's a short explanation:
Dotfiles are configuration files in Unix-like systems (such as macOS and Linux) whose names start with
.. They are usually used to store user-level application settings.They are called dotfiles because each filename starts with a dot.
Important
My shell setup is based on zsh. If you use another shell (such as bash or fish), this
setup will not work as-is. This repository
may help you.
See LICENSE.
