Personal dotfiles for macOS and WSL2 (Ubuntu). One entrypoint, symlinked configs, the same command on both platforms.
The platform is detected at runtime, so ./dot install does the right thing on either machine.
Configs are symlinked out of this repo into $HOME, which means editing ~/.zshrc edits the
repo — no copy step to forget, and git status shows exactly what drifted.
On a fresh machine, where not even git is installed:
curl -fsSL https://gitlab.stkn.org/felix/dotfiles/-/raw/main/bootstrap.sh | bashThat installs git (apt on Linux, Xcode CLI tools on macOS), clones the repo to ~/code/dotfiles
and stops — nothing else is touched. Then:
cd ~/code/dotfiles
./dot install
exec zshWith git and an SSH key already in place, clone it yourself instead and skip the bootstrap:
git clone git@github.com:cebor/dotfiles.git ~/code/dotfiles./dot install is idempotent, so re-running it is always safe. For the regular update there is
./dot update, which fetches, fast-forwards and re-links; ./dot install is what you want when a
commit also added a package or changed the configuration.
After the first exec zsh, dot is on your $PATH — sync links it into ~/.local/bin, so
dot doctor and dot update work from anywhere and the ./ is only needed before that.
The repo has to stay where you cloned it: the symlinks point back at it by absolute path.
| Command | What it does |
|---|---|
./dot install |
Everything: prerequisites → sync → packages → configure |
./dot sync |
Symlink home/ into $HOME — dotfiles and configs only |
./dot packages |
Install packages: Brewfile on macOS, apt sources + apt.txt on Linux |
./dot configure |
Apply configuration: locale, git, login shell, vim, macOS defaults |
./dot update |
Fast-forward the checkout to its upstream, then re-link |
./dot doctor |
Health check: links, tools, login shell, locale, git identity |
./dot test |
Syntax check, shellcheck and the bats suite under test/ |
./dot help |
Usage summary |
| Option | Effect |
|---|---|
-n, --dry-run |
Print what would happen; change nothing |
-y, --yes |
Never prompt — questions keep their default (hostname unchanged, git identity left unset with a warning). Destructive prompts are skipped, not auto-confirmed |
--linux |
Force the Linux path regardless of detection |
--status |
sync only: report link state, write nothing |
--unlink |
sync only: remove the symlinks again |
configure also takes a single step: ./dot configure locale|git|shell|vim|macos.
Three phases, each runnable on its own:
- sync — every file under
home/is symlinked to the same relative path in$HOME. Files are linked, directories are mirrored as real directories, so~/.configand~/.sshstay yours and other tools can keep writing into them. - packages —
brew bundleagainstpackages/Brewfileon macOS; on Linux the apt sources (WakeMeOps, the git PPA, NodeSource) first, then everything inpackages/apt.txtin one go, then helix, antidote and starship, which apt cannot provide. - configure — imperative settings that are not files: the locale,
git config --global, the login shell, vim-plug, anddefaults writeon macOS.
The repo is the source of truth. ~/.zshrc is a link into home/.zshrc, so:
hx ~/.zshrc # really edits ~/code/dotfiles/home/.zshrc
cd ~/code/dotfiles && git diffNothing is ever overwritten silently. When sync finds a real file where a link should go, it moves
it to ~/.dotfiles-backup/<timestamp>/ first and tells you if the content differed — and if that
backup cannot be written, the file is left alone instead of being linked over. Files removed or
renamed inside home/ have their stale links cleaned up on the next sync, tracked through a
manifest at ~/.local/state/dotfiles/manifest.
.
├── bootstrap.sh # curl-able: installs git and clones this repo, nothing more
├── dot # the only entrypoint
├── home/ # mirrored 1:1 into $HOME
│ ├── .zshrc # sources .exports/.aliases/.functions, then antidote + starship
│ ├── .exports # environment variables
│ ├── .aliases # command shortcuts
│ ├── .functions # shell functions + cross-platform pbcopy/pbpaste/open shims
│ ├── .zsh_plugins.txt # antidote plugin list
│ ├── .vimrc .tmux.conf .latexmkrc
│ ├── .ssh/config
│ └── .config/ # git/ignore (the global gitignore), helix, pycodestyle
├── lib/ # sourced helpers, never executed
│ ├── os.sh # platform detection, `has`, brew shellenv, arch mapping
│ ├── log.sh # section/info/ok/warn/err, prompts, dry-run `run`
│ └── link.sh # symlink engine: link, backup, prune, status
├── setup/ # one file per step, one function each
│ ├── prereqs.sh # Xcode CLI tools + Homebrew / the apt prerequisites
│ ├── packages.sh # dispatches to brew bundle or packages-linux.sh
│ ├── packages-linux.sh # apt sources, then the apt list, then the rest
│ ├── locale.sh # generate the LANG from home/.exports (Linux)
│ ├── git.sh shell.sh vim.sh
│ └── macos-defaults.sh # defaults write / scutil
├── packages/
│ ├── Brewfile # brew, cask, mas
│ └── apt.txt # apt list: name [@tag] [# comment]
└── test/ # bats suite, run by ./dot test
├── helper.bash # sandbox $HOME, command stubs, filesystem snapshots
├── *.bats # one file per unit under test, plus cli.bats end to end
├── ci-deps.sh # what a bare image needs; used by CI and the Dockerfile
└── Dockerfile # reproduce a CI job locally
Shell — zsh with antidote for plugins and
starship for the prompt. Plugins: oh-my-zsh lib/git/extract,
rupa/z, plus zsh-completions, zsh-autosuggestions and zsh-syntax-highlighting.
Editors — helix (hx) is the primary editor and git's core.editor;
vim is configured with vim-plug, Solarized and persistent undo.
Custom functions (home/.functions):
svenv # walk upward, find and activate venv/.venv
scpp report.pdf # scp to stkn.org, fix perms, copy the URL to the clipboard
server # python3 -m http.server + open the browser
tunnel host 3306 3307 # ssh forwarding: host's port 3306 -> localhost:3307
pwgen 32 # openssl rand -base64
f '*.conf' # find . -name
bump patch # npm version, with a "bump version to %s" message
dcl # docker: kill everything running, then remove it all
On Linux, pbcopy, pbpaste and open are defined as shims (win32yank / clip.exe / wl-copy /
xclip, and wslview / xdg-open) so the same functions work in WSL. Under WSL the open shim
deliberately overrides the /usr/bin/open that xdg-utils ships: xdg-open would look for a
Linux application, while wslview hands the file to Windows.
| macOS | Linux / WSL2 | |
|---|---|---|
| Packages | packages/Brewfile (brew, cask, mas) |
packages/apt.txt (+ WakeMeOps, git PPA, NodeSource), helix as a snap |
| Prerequisites | Xcode CLI tools + Homebrew | apt: git, curl, ca-certificates, gnupg, software-properties-common |
| Git credentials | osxkeychain |
libsecret, else 1 h cache |
| Clipboard | native pbcopy/pbpaste |
shims in home/.functions |
| Browser | native open |
wslview under WSL, overriding xdg-utils' open; xdg-open otherwise |
| bat | bat | bat from WakeMeOps; the distro's own ships as batcat, symlinked to ~/.local/bin/bat |
| SSH UseKeychain | honoured | ignored via IgnoreUnknown |
| Locale | every locale ships precompiled | LANG from home/.exports generated by setup/locale.sh |
| System settings | setup/macos-defaults.sh | n/a |
OS differences inside config files are handled inline ([[ "$OSTYPE" == darwin* ]]), so every
config exists exactly once.
| Task | Steps |
|---|---|
| Add an alias | edit home/.aliases → exec zsh (it is already linked) |
| Add a config file | put it at its $HOME path under home/ → ./dot sync |
| Add a package | edit packages/Brewfile or packages/apt.txt → ./dot packages |
| Add a zsh plugin | edit home/.zsh_plugins.txt → exec zsh |
| Change a macOS setting | edit setup/macos-defaults.sh → ./dot configure macos |
| Change the locale | edit LANG in home/.exports → ./dot configure locale |
Editing anything already linked needs no sync — ./dot sync is only for new, renamed or
deleted files.
./dot doctor # is anything missing or unlinked?
./dot sync --status # link state only, no writes
./dot sync --unlink # remove all links, then offer to restore the newest backup
./dot install -n # dry run the whole thingBackups of replaced files live in ~/.dotfiles-backup/<timestamp>/ and are never deleted
automatically.
Coming from the older rsync-based layout, $HOME still holds real copies rather than links — the
first ./dot sync backs each one up before linking. What it cannot clean up is what the old
layout wrote into ~/.gitconfig: 3a01b9b dropped the migration guards along with
core.excludesfile itself, both machines being past that point. On one that is not, clear the two
settings by hand — a core.excludesfile still naming ~/.gitignore_global hides
~/.config/git/ignore entirely, because git reads the file that setting names rather than both:
git config --global --unset core.excludesfile
git config --global --unset core.attributesfileThe files themselves, ~/.gitignore_global and ~/.gitattributes_global, are yours to delete.
macOS or Ubuntu (incl. WSL2), curl to fetch the bootstrap, and sudo rights for package
installation. Nothing else has to be installed by hand: bootstrap.sh brings the git that clones
the repo, and curl, gnupg and the rest are prerequisites ./dot install installs for itself, in
its own first phase, before it adds the apt sources that need them.
That first phase is why ./dot packages on its own expects an already-provisioned machine: it
checks for what it needs and points at ./dot install rather than installing it.
On a Mac without the Xcode CLI tools the bootstrap starts their installer and stops — finish it, then run the same command again.
./dot testruns three stages and stops at the first that fails: bash -n over every script, shellcheck -x,
then the bats suite in test/. Neither shellcheck nor
bats is needed to use these dotfiles — install them with brew install shellcheck bats-core or
apt-get install shellcheck bats. A missing shellcheck is reported and skipped; a missing bats
fails, because then nothing was tested.
The suite never touches your real home directory: test/helper.bash points $HOME,
$XDG_STATE_HOME and $TMPDIR at a temp sandbox before anything is sourced, and stubs any command
that would reach the system. It needs no root and no network.
To run it the way CI does:
docker build -f test/Dockerfile -t dot-test .
docker run --rm dot-testThe container runs the suite as an unprivileged user on purpose: several failure paths in
lib/link.sh are forced by making a directory unwritable, and root ignores that, so as root they
would skip and the run would go green for the wrong reason.
.gitlab-ci.yml runs the same ./dot test on the same image and expects a runner with the docker
executor.
What the tests are for is less "does bash work" than pinning the decisions this repo documents but
cannot otherwise enforce: the branch order in _link_state, that a dry run writes nothing at all,
the argument re-quoting in run, _apt_read_list filling a global rather than echoing, the
manifest carrying forward a link it failed to remove — in link_tree and in link_unlink — the
stdout/stderr split, that ~/.local/bin/dot never enters the manifest, that dot still works when
invoked through that link, that the last line of dot is a group ending in exit, the two literal
carriage returns in home/.config/git/ignore, and that
home/.zshrc defines has_brew before antidote load. CLAUDE.md
keeps the authoritative list; this one has to match it. Changing one of those on purpose means
changing its test; having one break by accident is the point.
MIT — see LICENSE.