Personal dotfiles. GNU stow-managed — source tree is organized into packages (common/, darwin/, codespaces/) that mirror $HOME. Files live at their real names (no dot_ prefix); stow creates symlinks from $HOME into the repo. Repo-management files (README.md, AGENTS.md, LICENSE, script/, Makefile, templates/) sit at the source root, outside every package, so stow never touches them. Per-OS gating is handled by which packages you stow.
make test is the repo's test target — runs shellcheck -x on every script plus the functional suite in script/test. Run it after any script edit. script/doctor is a separate health check for the installed environment on the current host, not for the repo itself.
| Command | Purpose |
|---|---|
script/setup |
First-time entry point. Installs stow via Homebrew/apt if missing, then runs make install. Auto-run by Codespaces. |
script/doctor |
Health check — core tools, shell, git, mise, Neovim, tmux/TPM. Also checks that ~/.config/git/config matches the template (warns on drift). |
make test |
Run shellcheck -x on every script under script/ and then execute script/test (functional tests for stow-package and setup-git-config). |
make install |
Generate ~/.config/git/config from the template (prompting for identity on first run), stow common. On macOS also stow darwin, install brew bundle, update fisher, chsh to fish. |
make regen-git-config |
Re-render ~/.config/git/config from templates/git-config.tmpl + ~/.config/dotfiles/identity.env. Run this after editing the template. |
make brew |
Re-run brew bundle --global after editing darwin/.Brewfile. |
make fisher |
Bootstrap fisher if needed and run fisher update after editing common/.config/fish/fish_plugins. |
make mise |
mise trust && mise install after editing common/.config/mise/config.toml. |
make clean |
stow -D every package — cleanly unlinks everything from $HOME. Does NOT delete ~/.config/git/config or ~/.config/dotfiles/identity.env. |
:Lazy sync |
Update/install Neovim plugins via lazy.nvim; commit lazy-lock.json. |
make install is also the recovery incantation — re-running it after a broken state is safe because script/stow-package handles pre-existing files by moving them aside, and every other step is idempotent.
Stow symlinks files from the package into $HOME, so editing ~/.config/fish/config.fish edits common/.config/fish/config.fish transparently. git diff in the repo shows your change immediately — no apply, no re-add, no drift. When you add a new tracked file inside a package, run make install so stow creates its symlink in $HOME.
If you ever need to invoke stow manually (e.g. for dry-run debugging), always include --no-folding — see the paragraph below for why. Dry-run example: stow -n -v --no-folding -t $HOME common.
Why --no-folding: every stow invocation in this repo passes --no-folding (see the STOW variable in the Makefile). With folding enabled, stow creates a single directory-level symlink when a package subtree is new in $HOME — e.g. ~/.config/fish would become a symlink pointing at common/.config/fish. That's minimal-symlink-count, but it means any runtime write a tool does inside ~/.config/fish/ (fish writing fish_variables, fisher writing conf.d/ and completions/) silently propagates through the symlink into the repo source, polluting the stow package with untracked files. --no-folding makes every directory in $HOME a real directory containing individual per-file symlinks, so:
- Edits to tracked files (
config.fish,lazy-lock.json, etc.) still go through their per-file symlinks into the repo source, exactly as before. - New files written at runtime (
fish_variables,conf.d/foo.fish,completions/bar.fish, whatever) land in real$HOMEpaths and never enter the repo. They're the same as any other per-machine file. - Adding a new tracked file is deliberate: create it in the package, run
stow -R(ormake install), and the per-file symlink appears in$HOME.
Defense-in-depth: the root .gitignore also lists a handful of known runtime-state paths inside packages, so if --no-folding is ever accidentally dropped, those files still won't get committed.
The one exception is git config, which is generated at install time rather than stow-symlinked. See "Git identity layout" below.
The Makefile is a thin dispatcher; real logic lives in script/ so each piece is independently testable and shellcheck-clean:
| Script | Purpose |
|---|---|
script/setup |
Bootstrap — installs stow if missing, then runs make install. #!/bin/sh (POSIX). |
script/setup-git-config |
Renders templates/git-config.tmpl → ~/.config/git/config using ~/.config/dotfiles/identity.env. Prompts on first run (TTY-guarded), atomic temp+mv write, sed-escaped substitution. Bash. |
script/stow-package |
stow --no-folding wrapper. Dry-runs first, moves any conflicting real files in $HOME to ~/.dotfiles-backup/<timestamp>/<relative-path>/, then stows. Nothing is ever deleted. Used by make stow-common, make install-codespaces, and script/install-darwin so first-time migrations off a prior dotfile manager don't abort on pre-existing files. Bash. |
script/install-darwin |
macOS bootstrap: installs Homebrew if missing + sources brew shellenv, stows darwin (via script/stow-package), runs make brew fisher, registers fish in /etc/shells (line-exact match), chsh to fish. Bash. |
script/install-codespace-tools |
Codespaces bootstrap: downloads nvim/rg/bat/fzf/lazygit/diff-so-fancy/tree-sitter via gh release download. Bash, set -euo pipefail. |
script/doctor |
Health check. Includes a template-drift check that re-renders templates/git-config.tmpl against identity.env and cmp -s's against the live file; warns if they differ. Bash. |
script/test |
Functional test suite for the scripts with non-trivial logic (stow-package conflict parsing + backup moves, setup-git-config template substitution + sed escaping). Each test runs inside its own mktemp sandbox with a stubbed $HOME; nothing touches the real system. Bash. |
All pass shellcheck -x and are exercised by make test, which runs both the linter and script/test:
make test
# equivalent to:
shellcheck -x script/setup script/setup-git-config script/stow-package script/install-darwin script/doctor script/install-codespace-tools script/test
./script/test.github/workflows/ci.yml has three jobs, all on ubuntu-latest:
test— installsstowvia apt, runsmake test. Mirrors the localmake testexactly.fish—git ls-files -z '*.fish' | xargs -0 -r -n1 fish -n.-n1so a syntax error in any single file propagates through xargs as a nonzero exit (without it, xargs batches all files into onefish -ncall and you only see the last file's status).lua— discovers the directory containingstylua.tomlviagit ls-filesand passes it toJohnnyMorganz/stylua-action. stylua walks upward from each target file to find its own config, so no-fpath is hardcoded.
Design constraint: every job discovers files via git ls-files, never via hardcoded paths. Moving common/ → whatever/ should not require a CI edit. If you add a new validator, follow the same pattern. The one exception is the stylua version pin (version: v2.4.1 in the stylua-action step) — bumping it may require reformatting the tracked .lua files, because stylua is not backwards-compatible on formatting across major versions. When bumping, run mise exec stylua@<new-version> -- stylua common/.config/nvim locally first and commit the reflow in the same change.
.github/workflows/license-year.yml is a separate scheduled workflow that bumps the copyright year in LICENSE each January and opens a PR via peter-evans/create-pull-request. Unrelated to the test pipeline.
Git config is the one dotfile that isn't stow-symlinked. Instead, it's rendered from a template at install time, because VS Code Dev Containers copies ~/.config/git/config verbatim but does NOT follow [include] directives (vscode-remote-release#3331). To give devcontainers a working git config with aliases, colors, filters, AND identity all inlined in one file, the template has everything inlined and identity gets substituted at install time.
templates/git-config.tmpl— committed plain text, the full static git config (aliases, colors, filters, core, pull, push, etc.) plus hardcoded[user] name = Mark Tareshawty,[commit] gpgsign = true,[github] user = tarebyte, with two placeholders{{EMAIL}}and{{SIGNINGKEY}}in the[user]section. Ends with[include] path = ~/.config/git/config.darwin. Not in any stow package — it's a source file for code generation.~/.config/dotfiles/identity.env— per-host, not in the repo. Plain shell-sourceable file withEMAIL=...andSIGNINGKEY=.... Created on firstmake installby prompts; mode 600. Edit this file to rotate keys or change emails, then runmake regen-git-config.~/.config/git/config— generated at install time, real file (NOT a symlink). Rendered by substituting the two placeholders fromidentity.envinto the template. This is the file devcontainers copy.darwin/.config/git/config.darwin— macOS-only credential helper (osxkeychain) andgpg.programpath. Still stow-symlinked from thedarwinpackage,[include]d from the generated~/.config/git/config. Inside Linux devcontainers the include silently no-ops (correct — osxkeychain doesn't exist there).common/.config/git/ignore— global gitignore, regular stow symlink.
- Add an alias / change a color / edit a filter: edit
templates/git-config.tmpl, runmake regen-git-configto propagate to~/.config/git/configon this host. To propagate to another host, pull and runmake regen-git-configthere. - Rotate a GPG signing key or change email: edit
~/.config/dotfiles/identity.env, runmake regen-git-config. The template is unchanged; only the generated output picks up the new values. - First-time install on a fresh host:
make installsees the missingidentity.env, prompts once for email and GPG key, writesidentity.env(mode 600), renders the config, continues with stow + brew bundle. Subsequentmake installruns are silent becauseidentity.envalready exists. - Inspect live config:
cat ~/.config/git/configorgit config --list --show-origin. The file is regular and readable — just don't edit it directly, or your edits will be clobbered by the nextmake regen-git-config.script/doctorcatches this: it re-renders the template againstidentity.envand warns if it differs from the live file.
Because the template has placeholders ({{EMAIL}}, {{SIGNINGKEY}}) that need to become real values before git can read the file. Stow would symlink the placeholder-bearing file straight into $HOME, and git would break on the invalid user.email. An intermediate substitution step is required, and the cleanest place to do it is at install time.
Because devcontainers don't follow [include]. If static content lived in a separate file referenced via [include], the container would only see identity — no aliases, no colors, no filters. Inlining the static content into the generated file means devcontainers get the full experience.
The one exception is darwin/.config/git/config.darwin which IS [include]d, and that's intentional: the [include] silently fails inside Linux containers, which is correct because osxkeychain doesn't exist there.
It could be — name, email, signing key ID, and GitHub username are all already public via git log and git log --show-signature on github.com, so committing them leaks nothing new. But keeping them in a per-host identity.env outside the repo means:
- Different hosts can use different values (work mac with work GPG key + work email, personal mac with personal values) without committing both to the repo.
- A forker of this repo gets prompted for their own values on first
make installinstead of inheriting Mark's.
The tradeoff is the one "generated file with a source of truth elsewhere" in the setup. Everything else is stow-symlinked and edit-in-place.
common/— shared on every host: fish, nvim, mise, starship, tmux, ctags, dircolors, gemrc, pryrc, rubocop, terminfo,git/ignore(global gitignore).darwin/— any macOS host:.Brewfile, macOS-only scripts (pb-pem,ssh-copy-id,touchid-enable-pam-sudo),git/config.darwin(osxkeychain + gpg.program).codespaces/— Codespaces only:.bash_aliases,.oh-my-zsh/, Linux-only scripts (gh-prepare).
Git config isn't in any stow package — it's rendered from templates/git-config.tmpl + ~/.config/dotfiles/identity.env at make install time. See the "Git identity layout" section above.
make install picks the right packages based on uname -s (darwin gates install-darwin) and $CODESPACES (gates install-codespaces). No CONTEXT env var — each host's identity file is what makes it "personal" or "work".
- macOS → Fish (
common/.config/fish/config.fish) + Starship + Homebrew (darwin/.Brewfile).make install-darwininstalls Homebrew, stowsdarwin, runsbrew bundle, bootstraps fisher, adds fish to/etc/shells,chshs to fish. - Codespaces →
codespaces/.bash_aliases(no Fish, no Starship).make install-codespacescallsscript/install-codespace-toolswhich pulls nvim/rg/bat/fzf/lazygit/diff-so-fancy/tree-sitter viagh release download(arch-aware amd64/arm64). No Homebrew, no mise. Stays on bash.
Shared: common/.config/mise/config.toml, EDITOR=nvim, core aliases (lg, gp, vi, vim). Shell-level features usually need both config.fish and codespaces/.bash_aliases.
Machine-local secrets on Codespaces come from user-defined Codespaces secrets. On Darwin, add them yourself — there is currently no machine-local env file in the tracked layout.
Built on LazyVim (lazy.nvim under the hood). Mason is disabled — LSP servers are installed by mise/system and configured inline in lua/plugins/lsp.lua via opts.servers. No Mason, no ensure_installed for language servers.
Layout under common/.config/nvim/:
init.lua— 3-line stub: sets leader andrequire("config.lazy").lua/config/lazy.lua— bootstraps lazy.nvim and importslazyvim.plugins+ localplugins/. The enabled LazyVim extras live inlazyvim.json(managed by:LazyExtras), not in this file.lazyvim.json— tracks the enabled extras and news-read state. Commit changes after toggling extras.lua/config/options.lua— only the deltas from LazyVim defaults (gdefault,cmdheight=0,listchars,updatetime,relativenumber=false,pumblend=0).lua/config/keymaps.lua— only our custom keymaps on top of LazyVim's.lua/config/autocmds.lua— custom autocmds (CursorHold diagnostic float, gotmpl filetype + parser aliases).lua/plugins/colorscheme.lua— LazyVimcolorschemeopt + catppuccin flavour, highlights, lualine palette integration.lua/plugins/ui.lua— lualine sections, snacks dashboard + picker/notifier/statuscolumn/explorer, nvim-web-devicons.lua/plugins/disabled.lua— single place for LazyVim plugins we turn off:bufferline.nvim,mason.nvim,mason-lspconfig.nvim,neo-tree.nvim.lua/plugins/coding.lua— blink.cmp Tab/S-Tab cycling + sources, disable for mini.surround.lua/plugins/gitsigns.lua— gitsignsnumhlonly (no current-line blame).lua/plugins/noice.lua— noice routes/presets.lua/plugins/lsp.lua— registers the Ruby servers (ruby_lsp,vscode_sorbet,vscode_sorbet_rubocop) viaopts.servers, and setsopts.diagnostics. Mason itself is disabled indisabled.lua.lua/plugins/editing.lua— vim-surround, vim-repeat, vim-eunuch, vim-projectionist, vim-rails, vim-better-whitespace, vim-dirvish + dirvish-git.lua/plugins/treesitter.lua— parser list additions, treesitter-context, treesitter-textobjects, endwise.ftplugin/,ftdetect/,after/— standard.lazy-lock.json— pinned plugin versions; commit on update.
Conventions (intentional — don't "fix"):
- Leader:
,(not space — set ininit.luabeforerequire("config.lazy")). gdefault = true, absolute line numbers,cmdheight = 0,clipboard = unnamedplus.- Custom focusless diagnostic float on
CursorHold(paired withupdatetime = 250). - Autocmds wrapped in a named
augroupwithclear = trueso re-sourcing is idempotent. - Theme: Catppuccin Mocha (matches tmux).
- File explorer: vim-dirvish (neo-tree disabled).
- Surround: vim-surround (mini.surround disabled).
Adding a plugin: create/edit a spec file under lua/plugins/ returning a lazy.nvim spec table. Run :Lazy sync, commit lazy-lock.json.
Adding an LSP: add <name> = { ... } to opts.servers in lua/plugins/lsp.lua with the full vim.lsp.Config table inline.
Formatting: LazyVim's default conform.nvim wiring, untouched. Format-on-save runs through LazyVim.format, Lua uses stylua, Go uses goimports + gofumpt, everything else falls back to the LSP formatter.
common/.config/fish/ = config.fish, fish_plugins (Fisher), functions/ (one fn per file, filename must match function name). Non-obvious helpers: gloan (clones into ~/src/{owner}/{repo}), github_token (1Password).
- Git: see the "Git identity layout" section above for the template-based setup. All static config (aliases:
cofzf checkout,cssigned commit,uppull --rebase; GPG signing on; diff-so-fancy pager;pull.rebase; default branchmain) lives intemplates/git-config.tmpl. macOS credential helper indarwin/.config/git/config.darwin. - Tmux: prefix
Ctrl-f, base index 1, vim copy-mode, Catppuccin Mocha, plugins via TPM (prefix + I). - iTerm2: not tracked — changes too often. Don't edit here.
- Put it in the right package:
- Cross-platform →
common/ - macOS-only →
darwin/ - Codespaces-only →
codespaces/
- Cross-platform →
- Use real filenames (no
dot_prefix). Ensure executables arechmod +xin git (git update-index --chmod=+x path). - Run
make installto create the symlink in$HOME. (Because--no-foldingis on, every file needs its own symlink —make installis idempotent and will create just the new one.)
If a tool wrote a file into a config directory at runtime (e.g. funced created ~/.config/fish/functions/newfunc.fish as a real file in $HOME, not through a symlink), and you decide you do want it tracked:
mv ~/.config/fish/functions/newfunc.fish common/.config/fish/functions/
make install(Git config is the one exception — don't add it to a stow package. Edit templates/git-config.tmpl and run make regen-git-config.)
Ruby/Rails primary — Sorbet (vscode_sorbet, vscode_sorbet_rubocop), ruby-lsp, Rubocop (common/.rubocop.yml), vim-rails, common/.pryrc. Also Go (gopls + gofumpt + goimports via the lang.go LazyVim extra, binaries from mise), Lua (lua_ls, stylua), Node, Rust, Python, and .NET via mise. Because Mason is disabled, the ensure_installed entries inside LazyVim language extras are silent no-ops — any tool they want must be provided by mise or the system.