diff --git a/AGENTS.md b/AGENTS.md index 05add1cc..c50c1630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,6 +172,7 @@ number of error codes, so it is weaker than the `strict = true` in the config. ## Commands - run: n/a: monorepo of independent scripts and apps, no single entry point (see Repository Layout) +- deploy: `scripts/deploy.sh` (fast-forwards the live `~/src/testsAndMisc` checkout to origin/main, keeping unrelated local edits; exits non-zero unless HEAD = origin/main and the tracked hooks are wired) - test: `python3 -m pytest -q -o addopts="--strict-markers --strict-config"` - test-changed: `scripts/test_changed.sh` - lint: `pre-commit run --hook-stage pre-commit` diff --git a/docs/superpowers/evidence/shutdown-countdown-minutes-2026-10-04.json b/docs/superpowers/evidence/shutdown-countdown-minutes-2026-10-04.json deleted file mode 100644 index d3100b21..00000000 --- a/docs/superpowers/evidence/shutdown-countdown-minutes-2026-10-04.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "intent": "The i3blocks shutdown countdown shows and enforces minute-precision shutdown times (e.g. 18:30) now that /etc/shutdown-schedule.conf carries *_MINUTES keys, while still reading pre-migration hour-only files.", - "scope": [ - "linux_configuration/i3blocks/shutdown_countdown.sh", - "linux_configuration/tests/test_i3blocks_efficiency.sh", - "Non-goal: the writers (screen-locker helper, digital-wellbeing installer) live in their own repos" - ], - "changes": [ - "Parse MON_WED_MINUTES/THU_SUN_MINUTES/MORNING_END_MINUTES, prefer them, fall back to *_HOUR * 60; reject values outside 0-1440", - "Shutdown epoch and window thresholds computed from minutes; still builtins only (no forks)", - "Regression test: 18:30 minutes config shows '⏻ 18:30' at 18:15 and '⏻ SHUTDOWN' at 18:31 while the legacy HOUR copy says 18" - ], - "verification": [ - { - "command": "bash linux_configuration/tests/test_i3blocks_efficiency.sh", - "result": "pass", - "evidence": "All i3blocks efficiency regression tests passed (fork count <= 1 for the countdown)" - }, - { - "command": "SHUTDOWN_CONFIG= NOW_EPOCH= bash shutdown_countdown.sh", - "result": "pass", - "evidence": "dual: 18:30, 18:30, SHUTDOWN, SHUTDOWN; legacy (19/23): 19:00, 19:00, 19:00, 23:00" - }, - { - "command": "bash ~/.config/i3blocks/shutdown_countdown.sh (live, symlinked)", - "result": "pass", - "evidence": "⏻ 23:00 against the host's current hour-only /etc/shutdown-schedule.conf" - } - ], - "risks": [ - "A config with only *_MINUTES and malformed values shows INVALID TIME instead of a countdown (display only; enforcement is the check script)" - ], - "rollback": [ - "git revert the commit; the old script reads the *_HOUR keys, which every writer still emits", - "Re-run the live block and confirm it shows the hour-rounded time" - ] -} diff --git a/docs/superpowers/evidence/testsandmisc-deploy-command-2026-11-12.json b/docs/superpowers/evidence/testsandmisc-deploy-command-2026-11-12.json new file mode 100644 index 00000000..cb78993e --- /dev/null +++ b/docs/superpowers/evidence/testsandmisc-deploy-command-2026-11-12.json @@ -0,0 +1,30 @@ +{ + "intent": "testsAndMisc declares a deploy command, so Ship can deploy a pushed change instead of reporting 'pushed, not deployed'.", + "scope": [ + "scripts/deploy.sh (new), CLAUDE.md Commands, linux_configuration/tests/test_deploy.bats (new)", + "Non-goal: refreshing copies the sudo installers put under /usr/local or /etc" + ], + "changes": [ + "scripts/deploy.sh fast-forwards ~/src/testsAndMisc to origin/main (pull --ff-only), keeps unrelated local edits, refuses on collision", + "Health check: on main, HEAD = origin/main, core.hooksPath = linux_configuration/.githooks; exits non-zero otherwise", + "CLAUDE.md Commands gains '- deploy: `scripts/deploy.sh`'" + ], + "verification": [ + { + "command": "bats linux_configuration/tests/test_deploy.bats", + "result": "pass", + "evidence": "red first (deploy.sh missing, exit 127); then 7/7 ok: ff, idempotent, keeps WIP, refuses collision, hooks unwired fails, off-main fails, CLAUDE.md line" + }, + { + "command": "shellcheck scripts/deploy.sh", + "result": "pass", + "evidence": "no findings" + } + ], + "risks": [ + "Live checkout carries kuhy's uncommitted edits; an incoming commit touching them makes deploy fail rather than overwrite" + ], + "rollback": [ + "git revert this commit; the live checkout stays where the last deploy left it" + ] +} diff --git a/linux_configuration/tests/test_deploy.bats b/linux_configuration/tests/test_deploy.bats new file mode 100755 index 00000000..785f1b6c --- /dev/null +++ b/linux_configuration/tests/test_deploy.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# Tests for scripts/deploy.sh against a scratch origin + "live" clone, so the +# real ~/src/testsAndMisc is never touched. +# +# bats linux_configuration/tests/test_deploy.bats + +bats_require_minimum_version 1.5.0 + +setup() { + ROOT="$(cd -- "$(dirname -- "$BATS_TEST_FILENAME")/../.." && pwd)" + SCRIPT="$ROOT/scripts/deploy.sh" + T="$BATS_TEST_TMPDIR" + export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com + export GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com + git init -q --bare -b main "$T/origin.git" + git clone -q "$T/origin.git" "$T/dev" 2>/dev/null + mkdir -p "$T/dev/linux_configuration/.githooks" + echo a >"$T/dev/file.txt" + touch "$T/dev/linux_configuration/.githooks/pre-commit" + git -C "$T/dev" add -A + git -C "$T/dev" commit -q -m base + git -C "$T/dev" push -q origin HEAD:main + git clone -q "$T/origin.git" "$T/live" + git -C "$T/live" config core.hooksPath linux_configuration/.githooks + export TESTSANDMISC_DIR="$T/live" +} + +push_change() { + echo "$1" >"$T/dev/${2:-file.txt}" + git -C "$T/dev" commit -qam "$1" + git -C "$T/dev" push -q origin HEAD:main +} + +@test "CLAUDE.md declares scripts/deploy.sh as the deploy command" { + local tick="\`" n=0 line + while IFS= read -r line; do + [[ "$line" == "- deploy: ${tick}scripts/deploy.sh${tick}"* ]] && n=$((n + 1)) + done <"$ROOT/CLAUDE.md" + [ "$n" -eq 1 ] + [ -x "$SCRIPT" ] +} + +@test "fast-forwards the live checkout to origin/main" { + push_change b + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(git -C "$T/live" rev-parse HEAD)" = "$(git -C "$T/origin.git" rev-parse main)" ] +} + +@test "is idempotent: a second run is a no-op that still passes" { + push_change b + "$SCRIPT" + run "$SCRIPT" + [ "$status" -eq 0 ] +} + +@test "keeps unrelated uncommitted work and still deploys" { + echo wip >"$T/live/other.txt" + git -C "$T/live" add other.txt + push_change b + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(cat "$T/live/other.txt")" = wip ] +} + +@test "refuses when uncommitted work collides with the update" { + echo mine >"$T/live/file.txt" + push_change theirs + run "$SCRIPT" + [ "$status" -ne 0 ] + [ "$(cat "$T/live/file.txt")" = mine ] +} + +@test "fails the health check when hooks are not wired" { + git -C "$T/live" config --unset core.hooksPath + run "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *hooksPath* ]] +} + +@test "fails when the live checkout is not on main" { + git -C "$T/live" switch -q -c feature + run "$SCRIPT" + [ "$status" -ne 0 ] +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 00000000..c2b34f8b --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# ============================================================================ +# Deploy testsAndMisc: fast-forward the live checkout (~/src/testsAndMisc) to +# origin/main. Everything this repo runs on the machine (i3 config, systemd +# units, symlinked scripts, git hooks) executes straight from that checkout, +# so updating it is the deploy. +# +# scripts/deploy.sh +# +# Idempotent. Uncommitted work in the live checkout is kept: git refuses the +# fast-forward (and this exits non-zero) only when an incoming commit touches +# a file with local edits. Copies installed under /usr/local or /etc by the +# sudo installers are not refreshed here; rerun that installer by hand. +# +# Exits non-zero unless the health check passes: the checkout is on main, its +# HEAD equals origin/main, and core.hooksPath points at the tracked hooks. +# Env: TESTSANDMISC_DIR (default ~/src/testsAndMisc). +# ============================================================================ + +set -euo pipefail + +readonly REPO="${TESTSANDMISC_DIR:-${HOME}/src/testsAndMisc}" +readonly HOOKS_PATH=linux_configuration/.githooks + +die() { + echo "deploy: $1" >&2 + exit 1 +} + +update_checkout() { + [[ -d "${REPO}/.git" ]] || die "${REPO} is not a git checkout" + local branch + branch="$(git -C "$REPO" rev-parse --abbrev-ref HEAD)" + [[ "$branch" == main ]] || die "${REPO} is on '${branch}', not main" + git -C "$REPO" pull --ff-only --quiet origin main || + die "git pull --ff-only failed in ${REPO} (diverged, or local edits collide)" +} + +health_check() { + local head remote hooks + head="$(git -C "$REPO" rev-parse HEAD)" + remote="$(git -C "$REPO" rev-parse origin/main)" + [[ "$head" == "$remote" ]] || die "HEAD ${head:0:8} != origin/main ${remote:0:8}" + hooks="$(git -C "$REPO" config --get core.hooksPath || true)" + [[ "$hooks" == "$HOOKS_PATH" && -d "${REPO}/${HOOKS_PATH}" ]] || + die "core.hooksPath is '${hooks}', want ${HOOKS_PATH} (run meta/scripts/install_hooks.sh)" + echo "deploy: ${REPO} at ${head:0:8} = origin/main, hooks wired" +} + +main() { + update_checkout + health_check +} + +main "$@"