diff --git a/.config/raycast-extensions/toolbox/README.md b/.config/raycast-extensions/toolbox/README.md index 822b515..2cf5b96 100644 --- a/.config/raycast-extensions/toolbox/README.md +++ b/.config/raycast-extensions/toolbox/README.md @@ -40,7 +40,7 @@ Set in Raycast under Extensions → Toolbox: Browse Meetings needs the enriched `mtg list --json` output shipped by this repo's `bin/mtg`. Apply it to `~/bin/mtg` by running `yadm -merge working-branch` from the home directory. +fetch && yadm merge origin/main` from the home directory. ## Install / development diff --git a/Justfile b/Justfile index 3de376a..799169b 100644 --- a/Justfile +++ b/Justfile @@ -9,9 +9,60 @@ default: check-env: ~/bin/check-env +# Start a task: new worktree on its own branch off origin/main, ready to use +worktree NAME: + #!/usr/bin/env bash + set -euo pipefail + root="$(git rev-parse --show-toplevel)" + parent="$(dirname "$root")" + # Works whether this is run from the launcher checkout or from another task + # worktree, which already sits inside dotfiles-worktrees/. + case "$(basename "$parent")" in + dotfiles-worktrees) dest="$parent/{{ NAME }}" ;; + *) dest="$parent/dotfiles-worktrees/{{ NAME }}" ;; + esac + if [ -e "$dest" ]; then echo "already exists: $dest" >&2; exit 1; fi + git fetch -q origin + git worktree add "$dest" -b "{{ NAME }}" origin/main + # Without this every mise-shimmed binary refuses to run in the new checkout. + if command -v mise >/dev/null; then (cd "$dest" && mise trust >/dev/null); fi + echo + echo " cd $dest" + +# Finish a task: remove its worktree and branch, re-park this checkout at origin/main +worktree-done NAME: + #!/usr/bin/env bash + set -euo pipefail + root="$(git rev-parse --show-toplevel)" + parent="$(dirname "$root")" + # Works whether this is run from the launcher checkout or from another task + # worktree, which already sits inside dotfiles-worktrees/. + case "$(basename "$parent")" in + dotfiles-worktrees) dest="$parent/{{ NAME }}" ;; + *) dest="$parent/dotfiles-worktrees/{{ NAME }}" ;; + esac + [ -d "$dest" ] && git worktree remove "$dest" + git fetch -q origin + # -d, never -D: refuses if the branch was not merged, which is the point. + git branch -d "{{ NAME }}" 2>/dev/null || echo "branch {{ NAME }} not deleted (unmerged?)" + # Re-park only the launcher checkout, and only when it has nothing to lose. + # Run from another task worktree this would detach that worktree instead, + # which is not what anyone means by "done with {{ NAME }}". + if [ "$(basename "$parent")" = "dotfiles-worktrees" ]; then + echo "not re-parking: this is a task worktree, not the launcher" + elif ! git diff --quiet || ! git diff --cached --quiet; then + echo "not re-parking: uncommitted changes in $root" + else + git checkout -q --detach origin/main + echo "re-parked at origin/main ($(git rev-parse --short HEAD))" + fi + # Run the same checks CI runs (syntax, config parsing, shell startup, emacs, ansible) +# Deliberately ./bin, not ~/bin: this validates the checkout you are standing +# in, so it does the right thing from a worktree. The other recipes below use +# ~/bin because they act on the live system instead. check *SECTION: - ~/bin/check-dotfiles {{ SECTION }} + ./bin/check-dotfiles {{ SECTION }} # Lint all shell scripts with shellcheck (advisory; not run in CI) lint: diff --git a/README.md b/README.md index 3755973..ae464ba 100644 --- a/README.md +++ b/README.md @@ -111,14 +111,66 @@ done ### Development Workflow -This repo uses a staging workflow to safely iterate on dotfiles before applying them to the home directory. +Each piece of work gets its own worktree on its own branch. +`~/src/personal/dotfiles` is the launcher: a checkout kept detached at +`origin/main`, used to read the tree and start tasks from, never to edit in. +Detached rather than on a branch for two reasons — a linked worktree cannot +check out `main`, because `$HOME` already has it, and having no branch there +means there is nowhere for a stray edit to accumulate. -1. **Edit on `working-branch`** — All changes are made in this checkout (e.g., `~/src/personal/dotfiles`) on the `working-branch` branch. -2. **Commit and push** — Use standard `git` CLI commands to commit and push changes, keeping a history of iterations. -3. **Merge into home directory** — When ready, switch to the home directory and run `yadm merge working-branch` to apply changes to the live dotfiles. -4. **Revert if needed** — If something breaks, yadm's git history makes it straightforward to revert to a known good state (e.g., `yadm revert` or `yadm checkout` specific files). +```sh +# From ~/src/personal/dotfiles — start a task +just worktree +cd ../dotfiles-worktrees/ + +# ... edit, then validate before anything reaches $HOME ... +just check + +git add -- +git commit +git push -u origin +gh pr create --base main +``` + +After the PR merges, apply it to the live dotfiles and clean up: + +```sh +# From $HOME — this is the yadm main worktree, checked out on main +yadm fetch && yadm merge origin/main + +# From ~/src/personal/dotfiles +just worktree-done # removes the worktree, deletes the branch, + # and re-parks this checkout at origin/main +``` -This keeps experimental changes isolated until they're validated, while still tracking everything in version control. +`just worktree` handles the `mise trust` step for you, which matters because +**it is not optional**. This repo ships `.config/mise/config.toml`, so mise +treats any checkout of it as a project config — and refuses to run until that +specific path is trusted. A newly created worktree is untrusted by default, +and until you trust it every mise-shimmed binary (`python3`, `node`, `uvx`, …) +exits 1 with a trust error instead of running. The symptom is confusing, +because the failure surfaces wherever the shim was called rather than as +anything about mise. + +Conventions worth keeping to: + +- **Branch from `origin/main`, not from whatever is checked out.** Worktrees + are cheap precisely because they don't inherit each other's state. +- **Stage with explicit paths.** `git add -- ` then `git commit`, never a + bare `git commit` after a `git rm` — a bare commit takes the whole index and + will quietly sweep unrelated staged changes into your commit. +- **Never `git checkout main` in a linked worktree.** `main` is checked out by + the yadm main worktree at `$HOME`; git will refuse, and forcing it is how you + end up with `$HOME` on a detached HEAD. +- **`just check` before pushing.** It runs exactly what CI runs, against the + checkout you are standing in, so a shell that would fail to start is caught + before `yadm merge` rather than after. +- **One worktree per task, removed when merged.** `git worktree list` should be + short; stale worktrees hold branch checkouts and get stale silently. + +Why not edit directly in `$HOME`? It is the yadm main worktree on `main`, so +there is no staging step between an edit and your live environment — a broken +`.zshrc` locks you out of new terminals immediately. ### Tool Management @@ -154,14 +206,34 @@ provision --extra-vars "is_desktop=false" To add new tools, edit `.config/dotfiles/playbook.yml`. To change runtime versions (node, python, etc.), edit `.mise.toml`. -### Shell Script Linting +### Checks -All shell scripts should pass shellcheck: +`just check` runs the same checks CI runs, against the checkout you are +standing in. Run it before pushing. ```sh -~/bin/lint-shell +just check # everything +just check shell # one section: syntax | configs | shell | emacs | ansible ``` +| Section | What it does | +|---|---| +| `syntax` | `zsh -n` over the zsh files, `bash -n` over the bash and sh files | +| `configs` | TOML, YAML, JSON and Lua parse checks (VS Code's JSONC included) | +| `shell` | Stages the tracked shell config into an empty `HOME` and actually starts `zsh -i`, `zsh -l` and `bash -l` there | +| `emacs` | Boots `init.el` against an empty init-directory, the way a new machine does | +| `ansible` | `--syntax-check` on the provisioning playbook | + +The `shell` section is the one that earns its keep: it catches an unguarded +`source` of a file that happens to exist on this machine but not on a fresh +one, which is a class of bug no syntax-level tool can see. A check whose tool +is missing reports `SKIP` rather than passing, and the script refuses to +report success if it matched no files at all. + +`~/bin/lint-shell` still runs shellcheck over `bin/`, as an advisory local +tool. It is not part of CI: shellcheck cannot parse zsh, which is what most of +this configuration is written in. + ## Repository Structure ### Zsh Configuration @@ -207,7 +279,11 @@ Scripts should handle both macOS and Linux: Since yadm places all the files in situ, it's unlikely going to be a good idea to run `claude` in your home directory. Instead, run it in this checkout and follow the standard development workflow: 1. `cd ~/src/personal/dotfiles` (or wherever this repo is checked out) -2. Make sure you're on `working-branch`: `git checkout working-branch` -3. Run `claude` and make whatever changes you need. -4. Commit and push with standard `git` commands. -5. When ready, apply to your home directory: `yadm merge working-branch` +2. Start a task worktree: `just worktree `, then `cd` into the path it prints. +3. Run `claude` there and make whatever changes you need. +4. Run `just check`, then commit and push with standard `git` commands and open a PR. +5. After it merges, apply to your home directory: `yadm fetch && yadm merge origin/main` + +Run `claude` in the task worktree, not in `~/src/personal/dotfiles` itself — +that checkout is detached at `origin/main` and is only there to read from and +launch tasks from. diff --git a/bin/check-dotfiles b/bin/check-dotfiles index 528438a..a11ac4f 100755 --- a/bin/check-dotfiles +++ b/bin/check-dotfiles @@ -23,6 +23,27 @@ source "${SCRIPT_DIR}/lib/common.sh" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "${REPO_ROOT}" || exit 1 +# Checks run against the checkout this script lives in, which is what makes it +# usable from a worktree. Refuse to run if that is not a git checkout at all -- +# otherwise `git ls-files` returns nothing and every section trivially +# "passes", which is the most dangerous result this script could produce. +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + error "${REPO_ROOT} is not a git checkout; refusing to report a pass over nothing" +fi + +# This repo ships .config/mise/config.toml, so mise picks it up as a project +# config whenever the cwd is inside a checkout. Until that path is trusted, +# every mise-shimmed binary -- python3, node, uvx -- exits 1 instead of +# running, which is the default state of a freshly created worktree. Probe for +# it up front: otherwise the failure arrives as dozens of bogus "parse error"s +# pointing at perfectly valid config files. +if has_command python3 && ! python3 -c 'pass' >/dev/null 2>&1; then + probe=$(python3 -c 'pass' 2>&1 | head -2) + error "python3 will not run in ${REPO_ROOT}: +${probe} + If that mentions mise, this checkout is untrusted. Run: mise trust" +fi + FAILURES=0 CHECKED=0 @@ -262,4 +283,12 @@ if [[ $FAILURES -gt 0 ]]; then echo "${FAILURES} check(s) failed, ${CHECKED} passed" >&2 exit 1 fi + +# "All 0 checks passed" is not a pass. If a section matched no files at all, +# something is wrong with where or how this ran, and reporting success would +# hide it. +if [[ $CHECKED -eq 0 ]]; then + error "no files were checked -- expected at least one match in ${REPO_ROOT}" +fi + success "All ${CHECKED} checks passed"