6.1 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).
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
~/.gitea/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.