diff --git a/.claude/skills/dotfiles/DOT-CLI.md b/.claude/skills/dotfiles/DOT-CLI.md new file mode 100644 index 0000000..018b75f --- /dev/null +++ b/.claude/skills/dotfiles/DOT-CLI.md @@ -0,0 +1,65 @@ +# The dot CLI + +## Architecture + +`dot` is defined in one file: `~/.config/fish/functions/dot.fish`. It holds +two functions: + +- `dot` (`--wraps=git`) — dispatches `init` and any file found under + `~/.config/dot/commands/`, otherwise forwards everything to + `git --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 init`: + +- refuses to run if `~/.dotfiles` already 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 to `git init` +- backs up any pre-existing file that checkout would clobber into + `~/.dotfiles-backup//`, then retries the checkout +- explicitly sets `status.showUntrackedFiles=no` after cloning — this is a + local-only git setting, so a fresh `git clone` never carries it over + +## Adding a subcommand + +Beyond `init`, `dot` looks for `~/.config/dot/commands/.fish`, sources +it, and calls `_dot_`. 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. + +1. Create `~/.config/dot/commands/.fish` defining a `_dot_` + function. +2. Confirm `dot ` dispatches to it. No other wiring is needed — + `~/.config/fish/completions/dot.fish` discovers new command files by + globbing that directory, and `--wraps=git` still covers raw git + subcommands. +3. Add a case to `~/.config/dot/tests/dot.fish` covering it and run + `fishtape ~/.config/dot/tests/dot.fish` until 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 calling + `dot`, so tests never touch the real `~/.dotfiles`. +- Build a throwaway bare "remote" fixture with `git init --bare` plus a + seeded commit, and explicitly set its `HEAD` + (`git --git-dir=$remote symbolic-ref HEAD refs/heads/main`). Pushing with + `git push origin HEAD:main` does **not** update the bare repo's `HEAD` + symref — skip this and a clone of the fixture can end up "on a branch yet + to be born." +- Don't use `.gitconfig` as a fake pre-existing "conflict" file in a + fixture — git parses `$HOME/.gitconfig` as 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 `.bashrc` instead. +- `@test "description" ` mirrors fish's `test` builtin + (`-eq`, `-ne`, `=`, `-e`, `-f`, `-d`, `-n`, `-z`); `-a`/`-o` combinators + aren't supported. diff --git a/.claude/skills/dotfiles/SKILL.md b/.claude/skills/dotfiles/SKILL.md new file mode 100644 index 0000000..f226ca0 --- /dev/null +++ b/.claude/skills/dotfiles/SKILL.md @@ -0,0 +1,26 @@ +--- +name: dotfiles +description: Conventions for this machine's dotfiles bare-repo setup. Use when editing any file under $HOME managed by this repo, or when extending the dot CLI (subcommands, completions, bootstrap, tests). +--- + +# 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. + +## Always add by explicit path + +`status.showUntrackedFiles=no` is set locally, 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 `.** Never `dot add -A`, `dot add .`, or +any wildcard add — that would try to stage the entire home directory (caches, +secrets, everything). + +See [DOT-CLI.md](DOT-CLI.md) for the `dot` command's own architecture, +bootstrap logic, subcommand dispatch, and test suite. diff --git a/.config/dot/commands/.gitkeep b/.config/dot/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.config/dot/tests/dot.fish b/.config/dot/tests/dot.fish new file mode 100644 index 0000000..8efb355 --- /dev/null +++ b/.config/dot/tests/dot.fish @@ -0,0 +1,72 @@ +# Fixture: a fake bare "remote" repo with tracked dotfiles, shared read-only +# across every case below. dot init only ever clones from it, never mutates it. +set -l remote (mktemp -d)/dotfiles.git +git init -q --bare $remote + +set -l seed (mktemp -d) +pushd $seed +git init -q -b main +git config user.email test@dot.fish +git config user.name dot-tests +mkdir -p .config/fish/functions +echo 'echo tracked-bashrc' >.bashrc +echo 'echo hi' >.config/fish/functions/greet.fish +git add -A +git commit -qm seed >/dev/null +git remote add origin $remote +git push -q origin HEAD:main >/dev/null 2>&1 +popd +git --git-dir=$remote symbolic-ref HEAD refs/heads/main + +# --- fresh bootstrap, no conflicts --- +set -gx HOME (mktemp -d) +dot init --url $remote >/dev/null 2>&1 +set -l fresh_status $status + +@test "dot init succeeds against a clean HOME" $fresh_status -eq 0 +@test "clones the bare repo to ~/.dotfiles" -e $HOME/.dotfiles +@test "checks out tracked files onto HOME" -e $HOME/.bashrc +@test "checked-out file has the repo's content" (cat $HOME/.bashrc) = "echo tracked-bashrc" +@test "disables status.showUntrackedFiles" (git --git-dir=$HOME/.dotfiles config --local status.showuntrackedfiles) = no + +dot init --url $remote >/dev/null 2>&1 +set -l repeat_status $status +@test "re-running dot init refuses when already initialized" $repeat_status -eq 1 + +set -l passthrough_status (dot status >/dev/null 2>&1; echo $status) +@test "git passthrough still works (dot status)" $passthrough_status -eq 0 + +# --- a pre-existing conflicting file gets backed up, not clobbered --- +set -gx HOME (mktemp -d) +echo 'pre-existing-content' >$HOME/.bashrc +dot init --url $remote >/dev/null 2>&1 +set -l conflict_status $status + +@test "dot init still succeeds with a conflicting file present" $conflict_status -eq 0 +@test "conflicting file ends up with the tracked content" (cat $HOME/.bashrc) = "echo tracked-bashrc" +@test "a backup directory was created" -d $HOME/.dotfiles-backup +@test "the pre-existing content was preserved in the backup" (cat $HOME/.dotfiles-backup/*/.bashrc) = "pre-existing-content" + +# --- an unreachable URL never falls back to creating an empty repo --- +set -gx HOME (mktemp -d) +dot init --url /nonexistent/path.git >/dev/null 2>&1 +set -l bad_url_status $status +set -l dotfiles_exists (test -e $HOME/.dotfiles; and echo yes; or echo no) + +@test "dot init fails on an unreachable URL" $bad_url_status -eq 1 +@test "no .dotfiles directory is left behind on failure" $dotfiles_exists = no + +# --- dispatches to files under ~/.config/dot/commands/ without polluting +# the fish function namespace: the file only defines _dot_, which +# only becomes known to fish once dot sources it on demand. +set -gx HOME (mktemp -d) +dot init --url $remote >/dev/null 2>&1 + +mkdir -p $HOME/.config/dot/commands +set -l marker (mktemp) +echo "function _dot_mark + echo marked >$marker +end" >$HOME/.config/dot/commands/mark.fish + +dot mark >/dev/null 2>&1 +@test "dispatches to a command file under ~/.config/dot/commands/" (cat $marker) = marked diff --git a/.config/fish/completions/dot.fish b/.config/fish/completions/dot.fish new file mode 100644 index 0000000..02ff615 --- /dev/null +++ b/.config/fish/completions/dot.fish @@ -0,0 +1,6 @@ +function __dot_custom_subcommands + echo init + path basename $HOME/.config/dot/commands/*.fish 2>/dev/null | path change-extension '' +end + +complete -c dot -n __fish_use_subcommand -a "(__dot_custom_subcommands)" diff --git a/.config/fish/completions/fishtape.fish b/.config/fish/completions/fishtape.fish new file mode 100644 index 0000000..ad81efe --- /dev/null +++ b/.config/fish/completions/fishtape.fish @@ -0,0 +1,2 @@ +complete --command fishtape --short v --long version --description "Print version" +complete --command fishtape --short h --long help --description "Print help" diff --git a/.config/fish/fish_plugins b/.config/fish/fish_plugins new file mode 100644 index 0000000..32526b8 --- /dev/null +++ b/.config/fish/fish_plugins @@ -0,0 +1 @@ +jorgebucaran/fishtape diff --git a/.config/fish/functions/dot.fish b/.config/fish/functions/dot.fish index 453f6ef..85823a0 100644 --- a/.config/fish/functions/dot.fish +++ b/.config/fish/functions/dot.fish @@ -1,4 +1,89 @@ function dot --wraps=git --description 'Manage dotfiles via a bare repo checked out over $HOME' - git --git-dir=$HOME/.dotfiles --work-tree=$HOME $argv + set -l dotfiles_dir $HOME/.dotfiles + + if test "$argv[1]" = init + set -e argv[1] + __dot_init $dotfiles_dir $argv + return $status + end + + set -l commands_dir $HOME/.config/dot/commands + set -l command_file $commands_dir/$argv[1].fish + + if test -n "$argv[1]" -a -f "$command_file" + source $command_file + _dot_$argv[1] $argv[2..-1] + return $status + end + + git --git-dir=$dotfiles_dir --work-tree=$HOME $argv +end + +# Kept inline (not a separate autoloaded function file) because this is the +# only subcommand that must work before the dotfiles repo has been cloned. +function __dot_init + set -l dotfiles_dir $argv[1] + set -e argv[1] + + argparse 'url=' -- $argv + or return 1 + + set -l url $_flag_url + test -n "$url"; or set url ssh://gitea@git.alexion.dev:2022/alexion/dotfiles.git + + if test -e $dotfiles_dir + echo "dot init: $dotfiles_dir already exists, refusing to re-initialize" >&2 + return 1 + end + + git clone --bare $url $dotfiles_dir + or begin + echo "dot init: failed to clone $url" >&2 + return 1 + end + + git --git-dir=$dotfiles_dir config status.showUntrackedFiles no + + set -l checkout_output (git --git-dir=$dotfiles_dir --work-tree=$HOME checkout 2>&1) + set -l checkout_status $status + + if test $checkout_status -ne 0 + set -l conflicts + set -l in_block 0 + + for line in $checkout_output + if test $in_block -eq 1 + if string match -rq '^\s' -- $line + set -a conflicts (string trim -- $line) + continue + else + set in_block 0 + end + end + + string match -q '*would be overwritten by checkout:*' -- $line + and set in_block 1 + end + + if test (count $conflicts) -eq 0 + echo "dot init: checkout failed and no recoverable conflicts were found:" >&2 + printf '%s\n' $checkout_output >&2 + return 1 + end + + set -l backup_dir $HOME/.dotfiles-backup/(date +%Y%m%dT%H%M%S) + for f in $conflicts + mkdir -p (path dirname $backup_dir/$f) + mv $HOME/$f $backup_dir/$f + echo "dot init: backed up ~/$f to $backup_dir/$f" + end + + git --git-dir=$dotfiles_dir --work-tree=$HOME checkout + or begin + echo "dot init: checkout still failing after backing up conflicts, aborting" >&2 + return 1 + end + end + + echo "dot init: bootstrapped $dotfiles_dir from $url" end -complete -c dot -w git diff --git a/.config/fish/functions/fishtape.fish b/.config/fish/functions/fishtape.fish new file mode 100644 index 0000000..7759f92 --- /dev/null +++ b/.config/fish/functions/fishtape.fish @@ -0,0 +1,116 @@ +function fishtape --description "Test scripts, functions, and plugins in Fish" + switch "$argv" + case -v --version + echo "fishtape, version 3.0.1" + case "" -h --help + echo "Usage: fishtape Run test files" + echo "Options:" + echo " -v or --version Print version" + echo " -h or --help Print this help message" + case \* + set --local files (realpath $argv) + + for file in $files + if test ! -f $file + echo "fishtape: Invalid file or file not found: \"$file\"" >&2 + return 1 + end + end + + set --local operators -{n,z,b,c,d,e,f,g,G,k,L,O,p,r,s,S,t,u,w,x} + set --local expectations \ + "a non-zero length string" \ + "a zero length string" \ + "a block device" \ + "a character device" \ + "a directory" \ + "an existing file" \ + "a regular file" \ + "a file with the set-group-ID bit set" \ + "a file with same group ID as the current user" \ + "a file with the sticky bit set" \ + "a symbolic link" \ + "a file owned by the current user" \ + "a named pipe" \ + "a file marked as readable" \ + "a file of size greater than zero" \ + "a socket" \ + "a terminal tty file descriptor" \ + "a file with the set-user-ID bit set" \ + "a file marked as writable" \ + "a file marked as executable" + + set --universal _fishtape_test_number 0 + set --universal _fishtape_test_passed 0 + set --universal _fishtape_test_failed 0 + + function @echo + echo "# $argv" + end + + function @test --argument-names name --inherit-variable operators --inherit-variable expectations + set --erase argv[1] + set --query argv[2] || set --append argv "" + + set _fishtape_test_number (math $_fishtape_test_number + 1) + + if test $argv + set _fishtape_test_passed (math $_fishtape_test_passed + 1) + + echo "ok $_fishtape_test_number $name" + else + if test $argv[1] = "!" + set operator "! " + set expected "not " + set --erase argv[1] + end + + if set --query argv[3] + set operator "$operator"$argv[2] + set expected (string escape -- $argv[3]) + set actual (string escape -- $argv[1]) + else + set operator "$operator"$argv[1] + set expected "$expected"$expectations[(contains --index -- $argv[1] $operators)] + set actual (string escape -- $argv[2]) + end + + set _fishtape_test_failed (math $_fishtape_test_failed + 1) + + status print-stack-trace | + string replace --filter --regex -- "\s+called on line (\d+) of file (.+)" '$2:$1' | + read --local at + + echo "not ok $_fishtape_test_number $name" + echo " ---" + echo " operator: $operator" + echo " expected: $expected" + echo " actual: $actual" + echo " at: $at" + echo " ..." + end + end + + echo TAP version 13 + + for file in $files + fish --init-command=(functions @echo | string collect) --init-command=(functions @test | string collect) $file + end + + echo + echo "1..$_fishtape_test_number" + echo "# pass $_fishtape_test_passed" + test $_fishtape_test_failed -eq 0 && + echo "# ok" || + echo "# fail $_fishtape_test_failed" + + functions --erase @echo @test + + set --local failed $_fishtape_test_failed + set --erase _fishtape_test_number + set --erase _fishtape_test_passed + set --erase _fishtape_test_failed + + test $failed -eq 0 + end +end diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..69f6537 --- /dev/null +++ b/.gitea/README.md @@ -0,0 +1,53 @@ +# dotfiles + +Managed as a bare git repo checked out over `$HOME`. Not cloned in the usual +sense — `git --git-dir=~/.dotfiles --work-tree=$HOME` treats `$HOME` itself as +the working tree. + +## Everyday use + +A fish function named `dot` wraps that invocation: + +```fish +dot status +dot add .bashrc +dot commit -m 'update bashrc' +dot push +``` + +Any git subcommand works — `dot` forwards whatever you type straight to git. + +## Bootstrapping a new machine + +Before `dot` exists there's nothing to autoload it from, so the very first +step is fetching that one file by hand: + +```sh +mkdir -p ~/.config/fish/functions +curl -fsSL https://git.alexion.dev/alexion/dotfiles/raw/branch/main/.config/fish/functions/dot.fish \ + -o ~/.config/fish/functions/dot.fish +fish -c 'dot init' +``` + +`dot init`: + +- clones the bare repo to `~/.dotfiles` +- backs up any pre-existing files that would be overwritten by checkout into + `~/.dotfiles-backup//` +- checks out the tracked files onto `$HOME` +- sets `status.showUntrackedFiles=no` so `dot status` doesn't list all of + `$HOME` + +It refuses to run if `~/.dotfiles` already exists, and it never falls back to +creating a fresh empty repo if the clone fails. + +## Adding new subcommands + +Beyond `init`, `dot` looks for `~/.config/dot/commands/.fish`. Each file +should define a `_dot_` function; `dot args...` sources the file +and calls it. These files are deliberately *not* under +`~/.config/fish/functions/`, so they never become independently invokable +commands or clutter tab-completion outside of `dot` itself. + +`~/.config/fish/completions/dot.fish` discovers them automatically by +scanning that directory, so a new command file gets tab-completion for free.