diff --git a/dotfiles/dconf/dump.txt b/dotfiles/dconf/dump.txt index 4c04457..fe6b6e1 100644 --- a/dotfiles/dconf/dump.txt +++ b/dotfiles/dconf/dump.txt @@ -9,8 +9,6 @@ window-size=(360, -1) [org/gnome/desktop/background] picture-options='zoom' -picture-uri='file:///home/dank/.config/background' -picture-uri-dark='file:///home/dank/.config/background' [org/gnome/desktop/interface] color-scheme='prefer-dark' @@ -26,14 +24,6 @@ default-visible-columns=['name', 'size', 'type', 'date_modified'] default-folder-viewer='list-view' migrated-gtk-settings=true -[org/gnome/nautilus/window-state] -initial-size=(890, 550) -initial-size-file-chooser=(890, 550) -maximized=false - -[org/gnome/portal/filechooser/firefox] -last-folder-path='/home/dank/Downloads' - [org/gtk/gtk4/settings/file-chooser] show-hidden=false sort-directories-first=false diff --git a/omp-docs/inventory/home-config-census.md b/omp-docs/inventory/home-config-census.md index 5ae59e6..9ac9fc2 100644 --- a/omp-docs/inventory/home-config-census.md +++ b/omp-docs/inventory/home-config-census.md @@ -135,7 +135,7 @@ configs). | Reason | Contents | |---|---| | **Generated palette expansion** | Every `themes/noctalia*`, `*/noctalia.*`, `*/colors/noctalia*` / `Noctalia*` (24 files outside app-profile dirs). Machine-written by matugen/noctalia, not hand-authored: the `stat -c '%y %n'` cluster `2026-10-14 19:05:40.330…40.813 -0700` writes 24 files in one second (`alacritty/themes/noctalia.toml` at `.330212397`, `btop/themes/noctalia.theme` at `.334212456`, `tmux/themes/noctalia.conf` at `.813219586`, …); later re-runs re-stamp singles — `bat/themes/noctalia.tmTheme` `2026-10-18 17:11:22`, `niri/noctalia.kdl` `2026-10-18 17:36:27`, `mango/noctalia.conf` `2026-10-17 18:23:37`. A same-second mass-write is generation, never an edit. | -| **App state / profiles** | `mozilla` (5471), `unity3d` RimWorld state (235), `nirimod` (116), `obsidian` (99), `FreeTube` (56), `google-chrome-for-testing` (5); plus per-program state inside tracked dirs: `dconf/user`, `fish/fish_variables`, `micro/syntax/*`+`micro/buffers/history`, `gtk-4.0/servers`, `cachyos/cachyos-hello.log`. | +| **App state / profiles** | `mozilla` (5471), `unity3d` RimWorld state (235), `nirimod` (116), `obsidian` (99), `FreeTube` (56), `google-chrome-for-testing` (5); plus per-program state inside tracked dirs: `dconf/user`, `fish/fish_variables`, `micro/syntax/*`+`micro/buffers/history`, `gtk-4.0/servers`, `cachyos/cachyos-hello.log`. State hides inside a tracked file too: `dconf/dump.txt` carried `last-folder-path`, a nautilus `window-state` section and `picture-uri`/`picture-uri-dark` — all `/home/dank` values, and the wallpaper URIs were live on blankpad, i.e. already deployed here — dropped by #44, see [tools/dotfiles-deploy](/tools/dotfiles-deploy.md). | | **Skills-CLI installs** | `crush` (37), `devin` (37), `goose` (37), `kimchi/harness` (37) skill trees — provisioned by the skills CLI, owned by the store, not this repo. See [skills/store-wiring](/skills/store-wiring.md). | | **Secret tier** | `~/.config/secrets/` (`gitea`, `hf_token`, `hf_token_write`, `kagi_api_key` — names only), top-level `frank-job-hunt.env`, `containers/systemd/postgres.env`. Never tracked, values never printed. | | **Trash committed inside the tree** | `dotfiles/niri/.config/niri/cfg/.AGENTTRASH/display.kdl.superseded` — a retired output config committed 2026-10-09, five days before `.gitignore:54` gained `.AGENTTRASH/` (2026-10-14). Tracked-then-ignored: an ignore rule never reaches an already-tracked path, so the only repair is `git rm --cached`, done by #41. The rule does match at depth — `git check-ignore --no-index -v dotfiles/niri/.config/niri/cfg/.AGENTTRASH/x` resolves to `.gitignore:54` — so a fresh in-tree trash dir is ignored from birth and this does not recur on its own. | diff --git a/omp-docs/tools/dotfiles-deploy.md b/omp-docs/tools/dotfiles-deploy.md index 87d0fff..38aec42 100644 --- a/omp-docs/tools/dotfiles-deploy.md +++ b/omp-docs/tools/dotfiles-deploy.md @@ -25,14 +25,16 @@ is stowed last and its files replace symlinks coming from base packages. | `deploy.sh` (sync) | adopt identicals, link new, quarantine divergents into `.quarantine/` and replace them | | `deploy.sh --adopt` | like sync, but divergents are reported and skipped rather than quarantined | -Two package shapes are not symlinks: `dconf/dump.txt` is loaded with `dconf load /`, and -`systemd-user` units get `daemon-reload` plus `enable --now` only when their `ExecStart` -binary resolves on the current machine, so a unit belonging to another box stays inert -instead of crash-looping. +Two package shapes are not symlinks. `dconf/dump.txt` is loaded with `dconf load /`, and +`systemd-user` units get `daemon-reload` plus `enable`; a unit is **started** only when +systemd reports it as nothing-yet (`""`/`disabled`), and never when its `ExecStart` binary +is absent here, so a unit belonging to another box stays inert instead of crash-looping. +Starting something already installed is what makes `deploy.sh` unsafe to run beside a live +session — see *Never start a unit a session may already be running*. **The tracked unit is the file, not the directory.** A live directory can be a real directory and still be fully tracked, because every file inside it is a symlink into the -repo (`niri` is 9 tracked / 1 box-only; `gtk-3.0` is 3/0). Any audit that classifies by +repo (`niri` is 8 tracked / 2 box-only; `gtk-3.0` is 3/0). Any audit that classifies by directory mislabels the tree — classify per file with `readlink -f`. The live census and its classifier command are in [home-config-census](/inventory/home-config-census.md). @@ -123,5 +125,62 @@ run and one unattended `deploy.sh` would have reverted the amber focus ring with quarantine and no warning. The duplicate is deleted in #43. Rule: a `zz` file exists only to differ. When base absorbs the change, delete the `zz` copy -in the same commit — and never read `RELINK` lines from `--check` as cosmetic noise; they -are the sound of a stale override waiting for someone to run the tool for real. +in the same commit. + +How to read `--check`, measured on blankpad: it prints `RELINK` for **every** path the `zz` +tier carries, even when the live link already points at the override — `rules.kdl`, +`keybinds.kdl`, `misc.kdl`, `autostart.kdl` and `.zshrc` all did — because the walk +processes the base package first and reports the intermediate owner change. A `RELINK` line +therefore proves a pending change only when `readlink` shows the live target is *not* +already the `zz-$HOST` file. `actions=0` is the only honest summary. +## Law: never start a unit a session may already be running + +`sync` used to end with `systemctl --user enable --now` for every unit in `systemd-user/` +whose `ExecStart` resolves. Measured on blankpad before one such run: + +|unit|`UnitFileState`|`ActiveState`| +|---|---|---| +|`live-sync.service`|`linked`|`inactive`| +|`shelly-notifications.service`|`enabled`|`active`| +|`swayidle-lock.service`|`enabled`|`inactive`| + +`swayidle-lock.service` was installed and **not running** — nothing started it, no swayidle +process existed (`pgrep -c swayidle` = 0), and `autostart.kdl` does not launch one. A +`deploy.sh` run would have armed it: monitors blanking and locking on the idle timer while +the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer; +`--now` forces a heartbeat commit at an arbitrary moment. + +Two rules follow. Install state must be read from the **printed state**, not the exit +status — `is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot +tell "installed, driven by a timer" apart from "never installed". And a unit is started only +in the latter case: a machine with a session on it has already decided what runs, while a +fresh box — where everything reports `disabled` — still gets its units up. Both directions +are pinned in `tests/deploy.bats` over a stub `systemctl`, because the real one on this box +is exactly what must not be invoked from a test. Refs #44. + +## Law: the dconf dump holds preferences, not per-box state + +`dconf load /` writes every key in `dotfiles/dconf/dump.txt`, so the dump carries whatever +box it was taken from. Comparing key by key against live state on blankpad found **six of +24** keys that were not this box's, and not one of them was a preference: + + [org/gnome/portal/filechooser/firefox] last-folder-path + repo: '/home/dank/Downloads' live: '/home/blank/Downloads' + [org/gnome/nautilus/window-state] initial-size, initial-size-file-chooser, maximized + [org/gnome/desktop/background] picture-uri, picture-uri-dark + both: 'file:///home/dank/.config/background' + +A file-chooser memory, a window geometry, a wallpaper URI: application state that arrived +with the dotfiles import. The last five did not appear as conflicts at all, because +blankpad's **live** tree already held the same `/home/dank` values — an earlier deploy had +written them here. A foreign value that agrees with live is invisible to any diff and still +wrong. All six come out in #44; the two wallpaper URIs are also cleared on this box +(`gsettings set org.gnome.desktop.background picture-uri ''`, and its `-dark` twin), because +niri draws the wallpaper, noctalia owns it, and nothing reads a dead path. + +Compare **key by key**, not with a plain `diff`: `dconf load` writes only the keys present, +so live-only keys survive and a diff overstates the damage in one direction and understates +it in the other. Re-dump on the box whose settings are canonical (`dconf dump / > +dotfiles/dconf/dump.txt`), then delete the state sections before committing — the census's +*Never track: app state* class applies inside a tracked file, not just to whole directories. +Refs #44. diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 2dfa535..2942208 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -124,8 +124,20 @@ if [ "$MODE" != "check" ] && [ -d "$PKGS_DIR/systemd-user/.config/systemd/user" [ -e "$unit" ] || continue bin="$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$unit" | head -1)" if [ -n "$bin" ] && command -v "$bin" >/dev/null 2>&1; then - systemctl --user enable --now "$(basename "$unit")" || true - echo "ENABLE $(basename "$unit") (ExecStart resolves here)" + name="$(basename "$unit")" + # Read the state, not the exit status: `is-enabled` exits 1 for a `linked` unit exactly + # as for a `disabled` one. Starting something already installed is how a deploy locks a + # live session — swayidle-lock sits enabled/inactive on blankpad and `--now` arms it + # mid-conversation. Only a unit nothing has installed yet gets started. Refs #44. + state="$(systemctl --user is-enabled "$name" 2>/dev/null || true)" + case "$state" in + "" | disabled) + systemctl --user enable --now "$name" || true + echo "ENABLE $name (ExecStart resolves here; nothing running it yet)" ;; + *) + systemctl --user enable "$name" || true + echo "ENABLE $name (already $state; not started — a live session decides what runs)" ;; + esac else echo "SKIP $(basename "$unit") (ExecStart '$bin' absent on this host)" fi diff --git a/tests/deploy.bats b/tests/deploy.bats index d47fdd8..f5f8838 100755 --- a/tests/deploy.bats +++ b/tests/deploy.bats @@ -23,6 +23,24 @@ setup() { export HOME="$TEST_TMP/home" mkdir -p "$HOME" export DEPLOY_REPO="$REPO" DEPLOY_HOST=box + + # systemctl stub: records every call and answers is-enabled from a file. The real + # systemctl must never run here — on this box it would start a screen lock. + mkdir -p "$TEST_TMP/bin" + export STUB_LOG="$TEST_TMP/systemctl.log" UNIT_STATE="$TEST_TMP/unit-state" + : > "$STUB_LOG" + cat > "$TEST_TMP/bin/systemctl" <<'STUB' +#!/usr/bin/env bash +echo "systemctl $*" >> "$STUB_LOG" +if [ "$2" = is-enabled ]; then + state="$(cat "$UNIT_STATE" 2>/dev/null || echo disabled)" + printf '%s\n' "$state" + case "$state" in disabled | "") exit 1 ;; *) exit 0 ;; esac +fi +exit 0 +STUB + chmod +x "$TEST_TMP/bin/systemctl" + export PATH="$TEST_TMP/bin:$PATH" } teardown() { rm -rf "$TEST_TMP"; } @@ -32,6 +50,13 @@ repo_file() { mkdir -p "$(dirname "$REPO/dotfiles/$1")"; printf '%s\n' "$2" > "$ # live_file live_file() { mkdir -p "$(dirname "$1")"; printf '%s\n' "$2" > "$1"; } +# systemd_unit — a unit in the systemd-user package +systemd_unit() { + mkdir -p "$REPO/dotfiles/systemd-user/.config/systemd/user" + printf '[Unit]\nDescription=%s\n[Service]\nExecStart=%s\n[Install]\nWantedBy=default.target\n' "$1" "$2" \ + > "$REPO/dotfiles/systemd-user/.config/systemd/user/$1" +} + @test "generated theme output is never adopted into a symlink" { repo_file "ghostty/.config/ghostty/themes/noctalia" 'background = "#f43841"' live_file "$HOME/.config/ghostty/themes/noctalia" 'background = "#f43841"' @@ -74,3 +99,40 @@ live_file() { mkdir -p "$(dirname "$1")"; printf '%s\n' "$2" > "$1"; } grep -q 'theme = "tokyo-night"' "$HOME/.config/ghostty/config.ghostty" [ ! -d "$REPO/.quarantine" ] } + +@test "a unit already enabled is enabled but not started" { + systemd_unit lock.service /bin/sh + printf 'enabled\n' > "$UNIT_STATE" + run "$REPO/scripts/deploy.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"not started"* ]] + grep -q 'systemctl --user enable lock.service' "$STUB_LOG" + ! grep -q -- '--now' "$STUB_LOG" +} + +@test "a linked unit is not started either" { + # `is-enabled` exits 1 for a linked unit, exactly as it does for a disabled one, so the + # printed state is the signal — exit status cannot tell them apart. + systemd_unit timer-driven.service /bin/sh + printf 'linked\n' > "$UNIT_STATE" + run "$REPO/scripts/deploy.sh" + [ "$status" -eq 0 ] + ! grep -q -- '--now' "$STUB_LOG" +} + +@test "a disabled unit is enabled and started, so a fresh box still comes up" { + systemd_unit fresh.service /bin/sh + printf 'disabled\n' > "$UNIT_STATE" + run "$REPO/scripts/deploy.sh" + [ "$status" -eq 0 ] + grep -q 'systemctl --user enable --now fresh.service' "$STUB_LOG" +} + +@test "a unit whose ExecStart is absent here stays inert" { + systemd_unit otherbox.service /nonexistent/daemon + printf 'disabled\n' > "$UNIT_STATE" + run "$REPO/scripts/deploy.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*otherbox.service* ]] + ! grep -q enable "$STUB_LOG" +}