--- type: reference title: Dotfiles deployment tier description: How dotfiles/ packages become live configuration — stow-equivalent symlinks, host override packages, and the two laws about theme media and systemd enable symlinks. tags: - dotfiles - deployment - systemd - noctalia timestamp: 2026-10-20 verified: 2026-10-20 --- # Dotfiles deployment tier `dotfiles/` holds home-relative trees and `scripts/deploy.sh` is a stow-equivalent: the repo checkout **is** the deployment, and deploying replaces live files with symlinks into the repo, so `git pull` + `deploy.sh` is the whole propagation story. One directory per tool (`niri`, `ghostty`, `gtk-3.0`, `systemd-user`, …); a package named `zz-$(hostname)` is stowed last and its files replace symlinks coming from base packages. | Mode | Effect | |---|---| | `deploy.sh --check` | dry run — adopts identicals, reports what would be linked, lists divergents, writes nothing. Run this first, always | | `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`; 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 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). ## Law: media the theme pipeline consumes is content, not config Wallpapers live at `dotfiles/noctalia/.local/share/noctalia/wallpapers/` and are gitignored. They were tracked until 2026-10-20 — 23 files, 80 MiB — which meant every clone of a *configuration* repo carried a media library no diff can review, and the payload only ever grows because deleting a tracked blob from the tree does not delete it from history. The topology is not what one would guess: [Noctalia](noctalia-shell.md) keeps its own library at `~/.local/share/noctalia/wallpapers/` as a **real directory of real files**, so the tracked tree was a duplicate of it and never its source — measured 2026-10-20, when the merge of #41 deleted the repo copies and the desktop did not flicker. Two consequences to know before untracking media: the copy that survives belongs to the application, not to the repo; and untracking deletes the file in every other checkout, `git rm --cached` on the branch notwithstanding. Restore on a fresh box: the library lives in `~/.local/share/noctalia/wallpapers/`, a path `deploy.sh` does not manage. Every filename is `wallhaven_.` and the ID is the whole address — `curl -L -o "https://wallhaven.cc/w/"` into that directory puts the set back. Losing it is an acceptable outcome; 80 MB per clone forever is not. ## Law: never `systemctl --user enable` a unit from a worktree `enable` writes `~/.config/systemd/user/default.target.wants/` as a symlink to the unit path it was handed, and that path is absolute. The heartbeat reaps `wt/*` worktrees older than seven days ([live-sync](live-sync.md)), so a unit enabled from a worktree turns into a dangling symlink while `systemctl --user is-enabled` still answers `enabled` — the unit is dead and the status lies about it. Found on 2026-10-20: `blankpad-desk-autoswitch.service` pointed into the reaped `wt/de-redesign-20261009`, so desk auto-switching had been silently off since the reap. Check the link target before the unit file: ```bash ls -l ~/.config/systemd/user/default.target.wants/ # every target must resolve; a dangling one is a reaped worktree, not a config bug ``` Fix is to relink into the deployment checkout (`~/.omp/dotfiles/…`), `systemctl --user daemon-reload`, then confirm with `is-active` — `is-enabled` proves nothing. ## Law: a generated file must never be a required include `~/.config/niri/config.kdl` ends with `include "noctalia.kdl"` — no `optional=true`. That file is noctalia's palette output (`#ff8c00`, `#f43841`, one re-stamp `2026-10-18 17:36:27`), so it is machine state and correctly untracked. But on a fresh box `deploy.sh` links `config.kdl` first, and niri then refuses to start until noctalia has run once and written the file — the compositor is bricked by the order of operations, not by a bad config. Close it at the include, which survives someone skipping the theme pipeline: mark generated includes `optional=true`, as the `display.kdl` and `switch-events.kdl` lines in the same file already are. ## Desk choice is live state, never config `~/.config/niri/cfg/display.kdl` is a relative symlink to whichever profile is active (`desk-1-home.kdl`, `desk-2-portable.kdl`), written by `desk.sh`. Both profiles are tracked in `dotfiles/zz-blankpad/`; the pointer is not, because which desk the box sits on is not a fact about the repo. A fresh box therefore starts with no output configuration — `config.kdl` includes `display.kdl` with `optional=true`, so niri comes up usable but with default monitors until `desk.sh` is run once. ## Law: generated theme output is never deployed noctalia rewrites palette output in place — `noctalia.*`, `themes/noctalia*`, `colors/noctalia*`, `noctalia-*` (the classes the [census](/inventory/home-config-census.md) proves by same-millisecond mtime). A tracked copy of one is a stale snapshot of a colour scheme, and the failure is quiet: `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl` (commit `b3d3df4`, 2026-10-18) sat byte-identical to the live file for two days, harmless, while `--check` cheerfully proposed `ADOPT … noctalia.kdl`. The moment the palette changed, the next sync would have quarantined the live file and restored 2026-10-18's colours — a tool silently reverting a user-visible theme choice. `deploy.sh` now refuses the class (`SKIP … (generated theme output)`), `.gitignore` carries the matching patterns so it cannot be re-added, and `tests/deploy.bats` pins both directions: generated output is never adopted, and an ordinary identical config still is. Untracked in #43, Refs #42. ## Law: a `zz` override older than the file it overrides is a time bomb Stow order means `zz-$HOST` wins, and when both packages carry the same path, sync relinks it *silently* — the script's own comment: "managed file changing owners between packages: silent relink, nothing to quarantine". `dotfiles/zz-blankpad/niri/.config/niri/cfg/layout.kdl` held focus ring `width 2` / `active-color "#e01b24"` from `7b7dbf5` (2026-10-17) while the base package absorbed `b3d3df4` (2026-10-18): `width 4`, `"#ff8c00"`. Live was linked to base, the newer intent, so `--check` printed `RELINK .config/niri/cfg/layout.kdl` on every run and one unattended `deploy.sh` would have reverted the amber focus ring with no 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. 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: expand systemd specifiers before calling a unit foreign `ExecStart=%h/.omp/scripts/live-sync.sh` is valid systemd and meaningless to a shell. The resolution check handed that literal string to `command -v`, which fails everywhere, so the deployment concluded the unit belonged to another machine and skipped it — silently, through the same code path that is supposed to keep a *foreign* unit inert. The unit it kept skipping was this repo's own heartbeat, and `%h` is precisely where this checkout lives, so the false verdict was the same on every box that deployed it. Measured on blankpad: the first real `deploy.sh` run printed `SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh' absent on this host)` while `/home/blank/.omp/scripts/live-sync.sh` sits right there. Expand the specifiers a user unit can carry — `%h`, `%u`, `%U` — before resolving, and let an unknown specifier fail rather than guess at it. The probe needs no privileges and writes nothing: ```bash bin=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' \ ~/.omp/dotfiles/systemd-user/.config/systemd/user/live-sync.service | head -1) command -v "${bin//%h/$HOME}" # resolves → the unit is this box's; the SKIP was the lie ``` ## 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.