7.5 KiB
Dotfiles
This machine's dotfiles are a bare git repo at ~/.dotfiles, checked out with
$HOME as its work-tree. The dot fish function wraps that invocation
(git --git-dir=~/.dotfiles --work-tree=$HOME $argv, declared with
--wraps=git), so every git subcommand works through it: dot status,
dot add, dot commit, dot push, etc.
This directory (~/.config/dot) holds the dot CLI's custom subcommands,
tests, and package lists, but the repo tracks files across $HOME — fish
config, git identity, the dot function itself, and more. To see everything
tracked, run dot ls-tree -r --name-only HEAD from $HOME (paths are shown
relative to cwd, so running it from elsewhere silently truncates the list).
For an agent driving this through separate tool calls: cd ~ in one call does
not reliably carry over to the next, since each call may reset to the
project's working directory. Always cd "$HOME" and run the ls-tree (or any
other cwd-sensitive dot/git command) in that same call — e.g.
cd "$HOME" && dot ls-tree -r --name-only HEAD — rather than trusting a prior
cd to have stuck. Getting this wrong silently narrows the listing to
whatever the leftover cwd happens to be, which reads as "this file isn't
tracked" when it actually is.
Always add by explicit path
status.showUntrackedFiles=no is set locally (see dot init below), and
.gitignore only excludes .dotfiles itself plus OS/editor cruft — it is
not a whitelist. That
means virtually everything under $HOME reads as untracked, and git status
deliberately hides all of it.
Always run dot add <specific-path>. Never dot add -A, dot add ., or
any wildcard add — that would try to stage the entire home directory (caches,
secrets, everything).
Stage automatically after changes. Once a tracked file is edited, run
dot add <specific-path> for it right away rather than waiting to be asked —
one explicit path per changed file, still never a wildcard. This does not
extend to dot commit or dot push, which still require an explicit
request.
The dot CLI
Architecture
dot is defined in one file: ~/.config/fish/functions/dot.fish. It holds
three functions:
dot(--wraps=git) — dispatchesinit,help, and any file found under~/.config/dot/commands/, otherwise forwards everything togit --git-dir=~/.dotfiles --work-tree=$HOME $argv(full passthrough).__dot_init— the bootstrap logic, inlined in the same file rather than autoloaded separately, because it's the one subcommand that must work before the dotfiles repo has ever been cloned onto a machine.__dot_help— prints usage: the built-in commands plus whatever is currently found under~/.config/dot/commands/, generated by globbing that directory rather than a hardcoded list, so it can't drift from reality.
__dot_help's glob over ~/.config/dot/commands/*.fish is duplicated in
~/.config/fish/completions/dot.fish's __dot_custom_subcommands rather than
shared: fish only autoloads a function from a file named after that function,
so a helper defined inside dot.fish would be undefined if tab-completion
ran before dot had ever been sourced in the session. Keep both copies in
sync when the listing logic changes.
dot init:
- refuses to run if
~/.dotfilesalready exists (no re-init support) - clones the bare repo from
--url(default: the hardcoded Gitea remote) — if the clone fails, it errors out; it never falls back togit init - backs up any pre-existing file that checkout would clobber into
~/.dotfiles-backup/<timestamp>/, then retries the checkout - explicitly sets
status.showUntrackedFiles=noafter cloning — this is a local-only git setting, so a freshgit clonenever carries it over
Adding a subcommand
Beyond init, dot looks for ~/.config/dot/commands/<name>.fish, sources
it, and calls _dot_<name>. These files are deliberately kept out of
~/.config/fish/functions/ (fish's autoload path) so they never become
independently invokable top-level commands or clutter tab-completion outside
of dot itself.
- Create
~/.config/dot/commands/<name>.fishdefining a_dot_<name>function. - Confirm
dot <name>dispatches to it. No other wiring is needed —~/.config/fish/completions/dot.fishand__dot_helpboth discover new command files by globbing that directory, and--wraps=gitstill covers raw git subcommands. - Implement a
helpsubcommand: check forhelpas_dot_<name>'s first positional argument beforeargparse, and call a_dot_<name>_usagefunction that prints usage and every flag. If_dot_<name>itself dispatches to nested subcommands, apply this same check-then-dispatch pattern at that level too — there's no central--helphandling indot.fishto lean on; each level is responsible for its own._dot_<name>_usageshould print its text as a single multi-lineecho "..."string (fish preserves literal newlines inside double quotes) rather than oneechoper line. - Add a row to
~/.github/README.md's command table for it — one row per distinct use case, with paths written relative to$HOME(~/.config/dot/...), not relative to the README's own location. - Add a case to
~/.config/dot/tests/dot.fishcovering it, including itshelpoutput, and runfishtape ~/.config/dot/tests/dot.fishuntil it passes.
Testing
Tests live at ~/.config/dot/tests/dot.fish, run with
fishtape ~/.config/dot/tests/dot.fish. Fishtape is installed via Fisher
(fisher install jorgebucaran/fishtape) and tracked in
~/.config/fish/fish_plugins — a real, restorable dependency for developing
dot, but never required just to use it.
- Each scenario overrides
$HOME(set -gx HOME (mktemp -d)) before callingdot, so tests never touch the real~/.dotfiles. - Build a throwaway bare "remote" fixture with
git init --bareplus a seeded commit, and explicitly set itsHEAD(git --git-dir=$remote symbolic-ref HEAD refs/heads/main). Pushing withgit push origin HEAD:maindoes not update the bare repo'sHEADsymref — skip this and a clone of the fixture can end up "on a branch yet to be born." - Don't use
.gitconfigas a fake pre-existing "conflict" file in a fixture — git parses$HOME/.gitconfigas its own global config on every invocation, and garbage content there spams "key does not contain a section" errors that drown out the real assertion. Use a harmless file like.bashrcinstead. @test "description" <expr> <op> <expected>mirrors fish'stestbuiltin (-eq,-ne,=,-e,-f,-d,-n,-z);-a/-ocombinators aren't supported.
Gotchas
~/.claude/(Claude Code's own config: skills, agents, commands, etc.) is a plain directory, not a separate git repo of its own — plaingitcommands run from inside it report "not a git repository". It's tracked the same way as everything else under$HOME: through thedotbare repo. Usedot add/dot statuson paths under~/.claude/, not agitinvocation scoped to that directory, and don't assume an unrelated repo (e.g. a separate skills-source checkout elsewhere) is the tracked copy just because it also holds a copy of the same files.
Keybindings
Whenever a keybind is added, changed, or removed in any config on this
machine (tmux, KDE, neovim, fish, whatever), add or update its row in
~/.github/keybindings.md in the same
change. That file is the single reference for every keybind across tools —
it drifts the moment a bind changes somewhere without a matching edit there.