From a52bf0aa68c2922d34f6d40a52cad5232b256799 Mon Sep 17 00:00:00 2001 From: Thomas Lockney Date: Mon, 27 Jul 2026 14:28:03 -0700 Subject: [PATCH 1/2] check-dotfiles: validate the checkout it runs from, and document worktrees Running the checks from a second worktree turned up three problems, all of which made a broken setup look fine. `just check` invoked ~/bin/check-dotfiles, which resolves its repo root from its own location, so from a worktree it scanned $HOME instead. $HOME is the yadm main worktree and has no .git directory of its own, so git ls-files matched nothing and the run reported "All 0 checks passed" -- a green result over an empty file list. The recipe now uses ./bin/check-dotfiles so it validates the checkout you are standing in. The other recipes keep using ~/bin because they act on the live system rather than a checkout. The script now refuses to run outside a git checkout, and refuses to report success when it matched no files at all, so that failure cannot recur silently. It also now probes python3 before using it. This repo ships .config/mise/config.toml, which mise picks up as a project config whenever the cwd is inside a checkout, and a freshly created worktree is untrusted by default -- so every mise-shimmed binary exits 1 rather than running. That surfaced as 73 "parse error" failures against config files that are perfectly valid. The probe turns it into one message naming `mise trust`. README documents the worktree-per-task workflow this exercised, including the mise trust step and the reason a bare `git commit` after `git rm` is a trap. --- Justfile | 5 ++- README.md | 90 +++++++++++++++++++++++++++++++++++++++++----- bin/check-dotfiles | 29 +++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/Justfile b/Justfile index 3de376a..d654323 100644 --- a/Justfile +++ b/Justfile @@ -10,8 +10,11 @@ check-env: ~/bin/check-env # 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..14412e1 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` +stays parked on `working-branch` as a stable, up-to-date checkout, and is not +where edits happen — that way a half-finished change never blocks switching to +something else, and two tasks never share a working tree. -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 +git fetch origin +git worktree add ../dotfiles-worktrees/ -b origin/main +cd ../dotfiles-worktrees/ +mise trust # required: see note below + +# ... 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 +git fetch origin && git merge --ff-only origin/main # keep the parked checkout current +git worktree remove ../dotfiles-worktrees/ +git branch -d +``` -This keeps experimental changes isolated until they're validated, while still tracking everything in version control. +**`mise trust` 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 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" From 67705e59d01746bc4655d8aa5b7fa6d6efc3611c Mon Sep 17 00:00:00 2001 From: Thomas Lockney Date: Mon, 27 Jul 2026 14:39:17 -0700 Subject: [PATCH 2/2] retire working-branch in favour of one worktree per task working-branch existed to give quick, exploratory edits somewhere to live without the overhead of a branch and a checkout. The worktree convention covers that case now, so keeping it around only preserves a second, softer path where changes reach $HOME without review. ~/src/personal/dotfiles becomes the launcher: detached at origin/main, used to read the tree and start tasks from, never edited in. Detached rather than on a branch because a linked worktree cannot check out main -- $HOME already has it -- and because with no branch there is nowhere for a stray edit to accumulate. To keep starting a task as cheap as working-branch was, `just worktree ` creates the worktree off origin/main, puts it on its own branch, and runs mise trust so shimmed binaries work there. `just worktree-done ` removes it again, deletes the branch with -d so unmerged work is refused rather than discarded, and re-parks the launcher at origin/main. Both resolve the worktrees directory whether run from the launcher or from another task worktree, and worktree-done declines to re-park when it is not the launcher or when the tree is dirty. The raycast toolbox README pointed at `yadm merge working-branch`; it now points at `yadm fetch && yadm merge origin/main`. The dated plan under .config/dotfiles/plans/ still mentions working-branch and is left alone: it records what was true when it was written. --- .config/raycast-extensions/toolbox/README.md | 2 +- Justfile | 48 ++++++++++++++++++++ README.md | 46 ++++++++++--------- 3 files changed, 74 insertions(+), 22 deletions(-) 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 d654323..799169b 100644 --- a/Justfile +++ b/Justfile @@ -9,6 +9,54 @@ 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 diff --git a/README.md b/README.md index 14412e1..ae464ba 100644 --- a/README.md +++ b/README.md @@ -111,17 +111,17 @@ done ### Development Workflow -Each piece of work gets its own worktree on its own branch. `~/src/personal/dotfiles` -stays parked on `working-branch` as a stable, up-to-date checkout, and is not -where edits happen — that way a half-finished change never blocks switching to -something else, and two tasks never share a working tree. +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. ```sh # From ~/src/personal/dotfiles — start a task -git fetch origin -git worktree add ../dotfiles-worktrees/ -b origin/main +just worktree cd ../dotfiles-worktrees/ -mise trust # required: see note below # ... edit, then validate before anything reaches $HOME ... just check @@ -139,18 +139,18 @@ After the PR merges, apply it to the live dotfiles and clean up: yadm fetch && yadm merge origin/main # From ~/src/personal/dotfiles -git fetch origin && git merge --ff-only origin/main # keep the parked checkout current -git worktree remove ../dotfiles-worktrees/ -git branch -d +just worktree-done # removes the worktree, deletes the branch, + # and re-parks this checkout at origin/main ``` -**`mise trust` 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. +`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: @@ -279,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.