Compare commits

...
Author SHA1 Message Date
Frank ecc089c6ef deploy.sh no longer starts units a live session already runs
`sync` on a box with a live session on it was unsafe in two independent ways, found by
preflighting the run rather than performing it. Refs #44.

**`enable --now` would have armed a screen lock mid-session.** The unit loop started every
`systemd-user/` unit whose `ExecStart` resolves. `swayidle-lock.service` on blankpad is
`enabled`/`inactive` — nothing started it, no swayidle process exists, `autostart.kdl` does
not launch one — so one `deploy.sh` would have begun blanking and locking the monitors while
the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer, and
`--now` forces a heartbeat commit at an arbitrary moment. A unit is now started only when
systemd reports it as nothing-yet (`""`/`disabled`), which is precisely a fresh box; anything
already installed is merely `enable`d. The state has to come from the **printed state**:
`is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot
distinguish "installed, timer-driven" from "never installed" — the first version of this fix
got that wrong and the test caught it.

**`--check` output was being read as more than it can mean.** 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`, `.zshrc` all did on blankpad),
because the walk visits the base package first and reports the intermediate owner change.
The sentence committed in #43 — RELINK noise is "the sound of a stale override waiting" — is
too strong and is corrected: the line proves a pending change only when `readlink` shows the
live target is not already the `zz-$HOST` file, and `actions=0` is the honest summary. The
stale-override hazard #43 removed was and is real; the signal for it is the target, not the line.

**The dconf dump carried six of its 24 keys from another box**, none of them preferences:
a Firefox file-chooser `last-folder-path` of `/home/dank/Downloads`, a nautilus
`window-state` section, and `picture-uri`/`picture-uri-dark` pointing at
`file:///home/dank/.config/background`. The last five showed no conflict because blankpad's
live tree *already* held those `/home/dank` values — an earlier deploy wrote them here — and
a foreign value that agrees with live is invisible to any diff while still being wrong. They
are dropped; `picture-options='zoom'` stays, because it is a preference. The live wallpaper
URIs are cleared on the box as part of landing this (niri draws the wallpaper, noctalia owns
it, nothing reads a dead path).

`tests/deploy.bats` grows by four, over a stub `systemctl` — the real one on this box is
exactly what must not be invoked from a test. Red before: an already-`enabled` unit and a
`linked` unit both get `--now`. Green after, including the two directions that keep the
change honest: a `disabled` unit is still `enable --now`'d so a fresh box comes up, and a
unit whose `ExecStart` is absent here is never touched at all.

Verification: `bats tests/` 70/70, `kb-lint.sh omp-docs` 0, `kb-coverage.py` 0. The dump now
reconciles against live state with **0 conflicts and no `/home/` values in 18 keys**, proven
by key-by-key comparison rather than `diff` — `dconf load` writes only the keys present, so a
diff overstates the damage one way and hides it the other.
2026-10-20 17:13:55 -07:00
dank 7523ca5c19 Merge pull request 'Stop the deploy tier from reverting live theme state' (#43) from fix/42-deploy-generated into main 2026-10-20 17:10:03 -07:00
Frank 3022277167 Stop the deploy tier from reverting live theme state
Three findings from verifying the #41 merge against the live box with
`deploy.sh --check`. One commit because they share the deploy tier, its
documentation, and the first test file that tier ever had.

**1. Generated palette output was tracked, and sync would have restored it over a
live theme.** `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl` (`b3d3df4`,
2026-10-18) sat byte-identical to the live file — harmless while the palette held, and
`--check` was already proposing `ADOPT … noctalia.kdl`. The moment the theme changed, the
next sync would have quarantined the live file and reinstated 2026-10-18's colours.
`deploy.sh` now refuses the class outright (`SKIP … (generated theme output)`), `.gitignore`
carries the matching patterns so it cannot be re-added, and the file is untracked.

**2. A `zz` override older than the file it overrides.** `dotfiles/zz-blankpad/niri/…/
layout.kdl` held focus ring `width 2` / `#e01b24` (`7b7dbf5`, 2026-10-17) while base
absorbed `b3d3df4` (2026-10-18): `width 4`, `#ff8c00`. Stow order makes `zz-*` win, and a
cross-package owner change relinks *silently* — no quarantine, no warning. Live was linked
to base, the newer intent, so `--check` printed `RELINK … layout.kdl` every run and one
unattended `deploy.sh` would have reverted the amber focus ring. The duplicate is deleted;
base owns the file.

**3. Two topologies I had asserted and not measured.** The media law and the `.gitignore`
comment both said `~/.local/share/noctalia/wallpapers` symlinks into the repo, so deleting
the tracked copies would blank the desktop. Measured: it is a real directory of real files
that Noctalia owns; the repo held a duplicate, and the #41 merge deleted that duplicate
without the desktop flickering. The correct general law replaces the false one — untracking
a path deletes the file in every other checkout, `git rm --cached` on the branch
notwithstanding — and the restore procedure now points at the app's directory, which
`deploy.sh` does not manage.

The guard is `tests/deploy.bats`, black-box over exit status, stdout, the symlink shape
under `$HOME`, and the quarantine directory. Before the change: test 1 and test 2 fail,
test 2 because the live palette really is quarantined and reverted. After: 4/4, including
the control that an ordinary identical config is still adopted — an exclusion nobody can
turn off is just a broken tool.

Verification: `bats tests/` 66/66, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, and live `deploy.sh --check` on blankpad reports `SKIP … (generated theme output)`
with `actions=0` instead of `ADOPT … noctalia.kdl`. The real `deploy.sh` run that clears the
remaining `RELINK` lines is deliberately left until this is in main — running it from a
worktree would repoint live symlinks into a checkout the heartbeat reaps.

Refs #42
2026-10-20 17:08:06 -07:00
dank 689ada428f Merge pull request 'Quarantine what the gate could not see; delete what the repo only pretended to own' (#41) from fix/40-dotfiles-hygiene into main 2026-10-20 17:02:53 -07:00
8 changed files with 286 additions and 94 deletions
+17 -6
View File
@@ -80,10 +80,21 @@ wt/
agent/memories/--home-blank-.omp-wt-*
# --- theme media: content the pipeline consumes, not configuration ---------
# 23 wallpapers, 80 MiB. Tracked until 2026-10-20; every clone of a config repo
# was carrying a library no diff can review. The files stay on disk because
# ~/.local/share/noctalia/wallpapers symlinks into here, and every filename
# carries its wallhaven ID, so a fresh box restores them with
# `curl -L -o <name> https://wallhaven.cc/w/<id>` + deploy.sh.
# Law: omp-docs/tools/dotfiles-deploy.md
# 23 wallpapers, 80 MiB. Tracked until 2026-10-20: every clone of a config repo was
# carrying a library no diff can review. The live library at
# ~/.local/share/noctalia/wallpapers is a real directory of real files (measured
# 2026-10-20), so this path in the repo was a duplicate of it, not its source — and
# note that untracking deletes the copy in every other checkout regardless of
# `git rm --cached` on the branch. A fresh box starts with an empty library; every
# filename carries its wallhaven ID, so `curl -L -o <name> https://wallhaven.cc/w/<id>`
# puts it back. Law: omp-docs/tools/dotfiles-deploy.md
dotfiles/noctalia/.local/share/noctalia/wallpapers/
# --- generated theme output: machine-written, never reviewable, never deployed ---
# noctalia/matugen rewrite these in place; a tracked copy is a stale snapshot that
# deploy.sh would push back over a live palette. Patterns match the exclusion list in
# scripts/deploy.sh. Law: omp-docs/inventory/home-config-census.md, Refs #42
dotfiles/**/themes/noctalia*
dotfiles/**/colors/noctalia*
dotfiles/**/noctalia.*
dotfiles/**/noctalia-*
-10
View File
@@ -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
@@ -1,29 +0,0 @@
layout {
gaps 16 // Gap between windows
center-focused-column "never" // Don’t auto-center focused column
background-color "transparent" // <- needed for noctalia-shell to set wallpaper
preset-column-widths {
proportion 0.25 // 1/4
proportion 0.3333 // 1/3
proportion 0.5 // 1/2
}
preset-window-heights {
proportion 0.25 // 1/4
proportion 0.5 // 1/2
}
struts {
}
shadow
focus-ring {
width 2
active-color "#e01b24"
}
border {
off
}
}
@@ -1,32 +0,0 @@
layout {
focus-ring {
active-color "#ff8c00"
inactive-color "#181818"
urgent-color "#ff8c00"
}
border {
active-color "#f43841"
inactive-color "#181818"
urgent-color "#f43841"
}
tab-indicator {
active-color "#f43841"
inactive-color "#74050b"
urgent-color "#f43841"
}
insert-hint {
color "#f4384180"
}
}
recent-windows {
highlight {
active-color "#f43841"
urgent-color "#f43841"
}
}
+3 -3
View File
@@ -83,8 +83,8 @@ by category, not file-by-file.
| micro | 0 | 154 | adopt: `settings.json`, `bindings.json`; generated: `colorschemes/noctalia.micro`; bundled/state: `syntax/*` (146), `buffers/history` |
| mpv | 0 | 0 | — (empty) |
| nautilus | 0 | 0 | — (empty) |
| niri | 9 | 1 | 8 live KDL includes (5 from `dotfiles/niri`, 3 overridden by the `zz-blankpad` tier, whose own 9 niri files are counted in that tier) + 1 tracked relic, see *Never track*. Box-only: `noctalia.kdl`, a generated include — [tools/dotfiles-deploy](/tools/dotfiles-deploy.md) |
| noctalia | 3 | 2672 | Tracked and live-linked: `config.toml`, `palettes/Gruber Red.json`, `state/noctalia/settings.toml`. Everything else is app state plus noctalia's downloaded catalog: `community-palettes/` 66, `community-templates/` 80, `wallhaven/thumbs`, `clipboard/`, `plugins/`, `state.toml`, `usage_counts.json`. The 23 wallpapers #41 untracks stay on disk — the theme reads them |
| niri | 8 | 2 | 8 live KDL includes (5 from `dotfiles/niri`, 3 overridden by the `zz-blankpad` tier, whose own 7 niri files are counted in that tier — the stale `layout.kdl` duplicate and generated `noctalia.kdl` were removed by #43) + see *Never track* for the relic. Box-only: `noctalia.kdl` (generated include, refused by `deploy.sh`) and the retired copy |
| noctalia | 3 | 2672 | Tracked and live-linked: `config.toml`, `palettes/Gruber Red.json`, `state/noctalia/settings.toml`. Everything else is app state plus noctalia's downloaded catalog: `community-palettes/` 66, `community-templates/` 80, `wallhaven/thumbs`, `clipboard/`, `plugins/`, `state.toml`, `usage_counts.json`. The app's wallpaper library is `~/.local/share/noctalia/wallpapers` — real files it owns; the repo's duplicate copy of 23 JPEGs was untracked by #41 and deleted from this checkout by the merge, which is what untracking does in every checkout |
| nvim | 0 | 0 | — (empty) |
| pywalfox | 0 | 1 | `config.json` — program absent, prune |
| qt5ct | 0 | 1 | `colors/noctalia.conf` (generated) — program absent, prune |
@@ -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. |
+106 -12
View File
@@ -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).
@@ -42,14 +44,18 @@ 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. [Noctalia](noctalia-shell.md) reads
`~/.local/share/noctalia/wallpapers/`, which is a set of symlinks into that directory, so
the files stay on disk and untracking them broke nothing on this box.
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: every filename is `wallhaven_<id>.<ext>` and the ID is the whole
address — `curl -L -o <name> "https://wallhaven.cc/w/<id>"` puts the library back, then
`deploy.sh` relinks it. Losing the set is an acceptable outcome; 80 MB per clone forever
is not.
Restore on a fresh box: the library lives in `~/.local/share/noctalia/wallpapers/`, a path
`deploy.sh` does not manage. Every filename is `wallhaven_<id>.<ext>` and the ID is the
whole address — `curl -L -o <name> "https://wallhaven.cc/w/<id>"` 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
@@ -90,3 +96,91 @@ tracked in `dotfiles/zz-blankpad/`; the pointer is not, because which desk the b
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: 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.
+22 -2
View File
@@ -44,6 +44,14 @@ actions=0; quarantined=0; skipped=0
deploy_file() { # $1 = repo file path, $2 = home-relative target path
local src="$1" tgt="$HOME/$2"
# Generated theme output is never deployment state: noctalia rewrites these files in
# place, so a tracked copy is a stale snapshot and syncing one reverts a live palette to
# the colours of the day it was committed. Classes are those the census measures by
# mtime — see omp-docs/inventory/home-config-census.md, "Generated palette expansion".
case "$2" in
*/themes/noctalia*|*/colors/noctalia*|*/noctalia.*|*/noctalia-*|*/Noctalia*)
echo "SKIP $2 (generated theme output)"; skipped=$((skipped+1)); return 0 ;;
esac
if [ -L "$tgt" ]; then
if [ "$(realpath "$tgt")" = "$(realpath "$src")" ]; then return 0; fi # already deployed
case "$MODE" in
@@ -116,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
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bats
# tests/deploy.bats — black-box tests for scripts/deploy.sh (Refs #42)
#
# Contract (see script header): the repo checkout IS the deployment; sync adopts
# identicals, links new files, quarantines-and-replaces divergents, and stows the
# zz-$(hostname) package last. One thing it must NEVER do is deploy generated theme
# output: noctalia rewrites `noctalia.*` / `themes/noctalia*` in place, so a tracked
# copy is a stale snapshot, and a sync would revert a live palette to whatever colour
# scheme was current on the day it was committed.
#
# Nothing is observed except exit status, stdout, the file/symlink shape under $HOME,
# and the quarantine directory.
setup() {
TEST_TMP="$(mktemp -d)"
REPO="$TEST_TMP/repo"
DEPLOY="$BATS_TEST_DIRNAME/../scripts/deploy.sh"
mkdir -p "$REPO/scripts" "$REPO/dotfiles"
# A git repo, because the deployment checkout IS one and deploy.sh arms hooksPath.
git init -q -b main "$REPO"
cp "$DEPLOY" "$REPO/scripts/deploy.sh"
chmod +x "$REPO/scripts/deploy.sh"
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"; }
# repo_file <pkg-relative path> <content>
repo_file() { mkdir -p "$(dirname "$REPO/dotfiles/$1")"; printf '%s\n' "$2" > "$REPO/dotfiles/$1"; }
# live_file <absolute path under $HOME> <content>
live_file() { mkdir -p "$(dirname "$1")"; printf '%s\n' "$2" > "$1"; }
# systemd_unit <name> <ExecStart> — 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"'
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"SKIP"*".config/ghostty/themes/noctalia"* ]]
[ -f "$HOME/.config/ghostty/themes/noctalia" ]
[ ! -L "$HOME/.config/ghostty/themes/noctalia" ]
}
@test "a diverged live palette is never quarantined and reverted to a stale copy" {
# The dangerous shape: the tracked file matches today's palette, the theme changes
# tomorrow, and sync would quarantine the live file and restore the stale one.
repo_file "niri/.config/niri/noctalia.kdl" 'active-color "#e01b24"'
live_file "$HOME/.config/niri/noctalia.kdl" 'active-color "#ff8c00"'
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
grep -q 'active-color "#ff8c00"' "$HOME/.config/niri/noctalia.kdl"
[ ! -L "$HOME/.config/niri/noctalia.kdl" ]
[ ! -d "$REPO/.quarantine" ]
}
@test "an ordinary identical config is still adopted" {
# Control against an exclusion so broad the tool stops deploying anything.
repo_file "ghostty/.config/ghostty/config.ghostty" 'theme = "gruber"'
live_file "$HOME/.config/ghostty/config.ghostty" 'theme = "gruber"'
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[ -L "$HOME/.config/ghostty/config.ghostty" ]
[ "$(realpath "$HOME/.config/ghostty/config.ghostty")" = "$(realpath "$REPO/dotfiles/ghostty/.config/ghostty/config.ghostty")" ]
}
@test "--check reports and writes nothing" {
repo_file "ghostty/.config/ghostty/config.ghostty" 'theme = "gruber"'
live_file "$HOME/.config/ghostty/config.ghostty" 'theme = "tokyo-night"'
run "$REPO/scripts/deploy.sh" --check
[ "$status" -eq 0 ]
[[ "$output" == *"DRIFT"* || "$output" == *"QUAR"* ]]
[ ! -L "$HOME/.config/ghostty/config.ghostty" ]
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"
}