From bf55951134faa0b63bc1ff5aba0551fe920b2361 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Thu, 13 Aug 2026 12:28:24 +0200 Subject: [PATCH] hold every laptop-to-server write behind one .sync-off file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scripts write to the server — bootstrap.sh, laptop/sync-config.sh and laptop/push-project.sh — and all three overwrite rather than merge, so all three can land on work that exists only in the copy they replace. check_sync_hold() in lib/common.sh refuses all of them while .sync-off exists at the repository root, printing that file's first five lines as the reason. Checked in one function rather than three times, so the hold cannot be lifted halfway. A file rather than a variable in local.conf: load_laptop_conf() sources local.conf after the environment is set, so a command-line FLIT_SYNC_OFF=0 would be overwritten silently and the hold would read as lifted while still in force. A file also carries its own reason, which is what is needed when it is hit weeks later. No override flag. push-project.sh --force already overrides the dirty-tree check, and this exists for the case that check cannot see, so a flag here would defeat it. Deleting the file lifts the hold. SYNC_HOLD_FILE names the file rather than hard-coding it so that lib/common.test.sh can invoke the three scripts for real without the suite's result depending on whether syncing is currently held off. It is a seam for that, not a bypass. --- .gitignore | 1 + CLAUDE.md | 6 ++++++ README.md | 25 +++++++++++++++++++++++++ bootstrap.sh | 5 +++++ laptop/push-project.sh | 1 + laptop/sync-config.sh | 1 + lib/common.sh | 42 ++++++++++++++++++++++++++++++++++++++++++ lib/common.test.sh | 35 ++++++++++++++++++++++++++++++++--- 8 files changed, 113 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f4d0cbe..c36f68e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ handoff.md deployment.md claude-config-migration.md local.conf +.sync-off .claude/settings.local.json .worktrees/ *.bak diff --git a/CLAUDE.md b/CLAUDE.md index 9222347..813ca51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,12 @@ Each of these was a deliberate decision; changing it re-breaks something. reaches it; the weekly timer gets the token from `secrets.env` via `EnvironmentFile=` and redeems it there. A SKIP from `bootstrap.sh` is not evidence of an expired or missing token (§5.3). +- **`.sync-off` has no override flag** — `check_sync_hold()` in + `lib/common.sh` exists for the case `push-project.sh --force` cannot see, so + giving it its own override would defeat the point; deleting the file is the + only way to lift the hold. `SYNC_HOLD_FILE` is a seam so `lib/common.test.sh` + can invoke the three writing scripts without the suite depending on whether + the operator is currently holding syncing off, not a supported bypass. ## Before you commit diff --git a/README.md b/README.md index 223db8d..4eccbc0 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,31 @@ Then open it: $ flit myproject +### Holding syncing off entirely + +**A `.sync-off` file at the repository root refuses every laptop-to-server +write, not just one project's.** `bootstrap.sh`, `laptop/sync-config.sh` and +`laptop/push-project.sh` all overwrite rather than merge, so one switch covers +all three; `check_sync_hold()` in `lib/common.sh` checks it once, at startup, +rather than in each script, so it cannot be lifted halfway. Create it with the +reason as its content: + + $ echo "rebasing the server's dotfiles by hand, do not overwrite" > .sync-off + +The refusing script prints the file's first five lines back, which is what +makes the reason worth writing down — it is what shows up weeks later when +something else hits the hold. Delete the file to resume: + + $ rm .sync-off + +This is coarser than `.flit-no-push`: that file holds off pushes to one +project, this one stops provisioning and the configuration sync too. There is +no flag to override it — `push-project.sh --force` already overrides the +dirty-tree check, and `.sync-off` exists for exactly the case that check +cannot see, so overriding it the same way would defeat the point. Deleting the +file is the only way to lift the hold, and doing so is a decision worth +leaving a trace of, unlike a flag on a command line. + ### Secrets in a project Commit `.env` files holding `pass://` references, never values: diff --git a/bootstrap.sh b/bootstrap.sh index a9586c4..149735e 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -14,6 +14,11 @@ source lib/common.sh # Before preflight: resolve_operator_key() reads HCLOUD_SSH_PUBKEY, and it # must see whatever local.conf sets before it runs. load_laptop_conf +# Ahead of argument parsing, so that no flag combination reaches a phase that +# writes to the server. A provisioning run is the largest write of the three +# this holds off — it redelivers the repository, the dotfiles and the whole +# ~/.claude payload, and re-runs the configuration steps on top. +check_sync_hold TARGET="" # --target HOST: skip phase 1, use an existing host SKIP_LAPTOP=0 diff --git a/laptop/push-project.sh b/laptop/push-project.sh index c26a901..0b220e6 100755 --- a/laptop/push-project.sh +++ b/laptop/push-project.sh @@ -45,6 +45,7 @@ cd "$(dirname "$0")/.." || exit 1 # shellcheck source=lib/common.sh source lib/common.sh load_laptop_conf +check_sync_hold usage() { cat >&2 <<'EOF' diff --git a/laptop/sync-config.sh b/laptop/sync-config.sh index d22baed..e87fa7c 100755 --- a/laptop/sync-config.sh +++ b/laptop/sync-config.sh @@ -20,6 +20,7 @@ cd "$(dirname "$0")/.." || exit 1 # shellcheck source=lib/common.sh source lib/common.sh load_laptop_conf +check_sync_hold HOST=${1:-$FLIT_SERVER} diff --git a/lib/common.sh b/lib/common.sh index e114e34..b15fc70 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -437,6 +437,48 @@ load_laptop_conf() { source local.conf } +# check_sync_hold — refuse every laptop-to-server write while `.sync-off` exists +# at the repository root. +# +# Three scripts write to the server: bootstrap.sh (the repository, dotfiles and +# the ~/.claude payload), laptop/sync-config.sh (the ~/.claude working tree and +# a re-render on top of it) and laptop/push-project.sh (a project directory). +# All three overwrite, none of them merges, and all three can land while someone +# is working in what they overwrite. One switch covers all three, checked here +# rather than three times, so lifting it is one deletion and cannot be lifted +# halfway. +# +# A file rather than a variable in local.conf, for two reasons. local.conf is +# sourced by load_laptop_conf() after the environment is already set, so an +# `FLIT_SYNC_OFF=0` on the command line would be silently overwritten by the +# file and the hold would look lifted while still in force. And a file holds its +# own reason, which is what the operator needs when they hit this weeks later. +# +# No override flag, deliberately. A flag would make this the guard it exists to +# outrank — laptop/push-project.sh's --force already overrides the dirty-tree +# check, and this exists precisely because that check cannot see the case. The +# way to push is to delete the file, which is a decision rather than a keystroke. +# +# Called after the caller has cd'd to the repository root, which all three do. +# +# SYNC_HOLD_FILE names the file rather than hard-coding it, because these three +# scripts are run by lib/common.test.sh against this very repository: with the +# path fixed, every test that invokes one of them would pass or fail according +# to whether the operator happens to be holding syncing off, which is a suite +# that tests the machine it runs on rather than the code. It is a seam for that, +# not the override this deliberately does not have — pointing it elsewhere to +# get a push through leaves no record of the decision, where deleting the file +# does. +check_sync_hold() { + local hold=${SYNC_HOLD_FILE:-.sync-off} + [[ -r "$hold" ]] || return 0 + local reason; reason=$(head -5 "$hold") + die "syncing to the server is held off by $hold${reason:+: +$reason} +Every laptop-to-server write is refused while that file exists: provisioning, +the configuration sync, and project pushes alike. Delete $PWD/$hold to resume." +} + # flit_remote_env — the assignment list every ssh call that runs on a stripped # environment (`sudo` strips it; so does a non-login remote command) must carry # explicitly. ONE function rather than a copy per caller, so a variable added diff --git a/lib/common.test.sh b/lib/common.test.sh index 8a4c5d6..632cfb0 100755 --- a/lib/common.test.sh +++ b/lib/common.test.sh @@ -701,6 +701,35 @@ t "push-project.sh orders the filters project, then git, then general" \ PIPED=$(grep -c '"\${RSYNC\[@\]}".*|' laptop/push-project.sh) t "push-project.sh never pipes rsync straight into a filter" "0" "$PIPED" +# ---- check_sync_hold stops every laptop-to-server write at once +# +# The three scripts are invoked for real rather than the function called +# directly, because what this guards against is one of them growing a path that +# reaches a write before the check — a direct call would still pass on the day +# that happened. --print-only is used throughout: if the hold ever failed to +# fire, a dry run is the only shape of these three that would not then write to +# a server. +HOLD="$TD/hold-file" +printf 'held for a reason\n' >"$HOLD" + +for s in bootstrap.sh laptop/sync-config.sh; do + OUT=$(SYNC_HOLD_FILE=$HOLD bash "$s" --print-only 2>&1); RC=$? + t "sync hold: $s refuses" "1" "$RC" + t "sync hold: $s gives the file's reason" "1" "$(grep -c 'held for a reason' <<<"$OUT")" +done + +# push-project.sh needs a project argument to get as far as anything else, so +# the hold must fire ahead of that rather than on the way past it. +OUT=$(SYNC_HOLD_FILE=$HOLD bash laptop/push-project.sh --print-only "$TD" 2>&1); RC=$? +t "sync hold: push-project.sh refuses" "1" "$RC" +t "sync hold: push-project.sh gives the file's reason" "1" \ + "$(grep -c 'held for a reason' <<<"$OUT")" + +# An unreadable-because-absent hold file is the normal case, and a guard that +# fired on it would stop every push on every machine that never held one. +t "sync hold: absent file holds nothing" "0" \ + "$(SYNC_HOLD_FILE=$TD/no-such-hold bash -c 'source lib/common.sh; check_sync_hold' >/dev/null 2>&1; echo $?)" + # ---- .flit-no-push holds the push off, and no flag lifts it # # The hold exists for work that lives only in the server's copy, so a flag that @@ -713,18 +742,18 @@ NP="$TD/nopush" mkdir -p "$NP" printf 'work lives on the server\n' >"$NP/.flit-no-push" -NP_OUT=$(bash laptop/push-project.sh --print-only "$NP" 2>&1); NP_RC=$? +NP_OUT=$(SYNC_HOLD_FILE=$TD/no-such-hold bash laptop/push-project.sh --print-only "$NP" 2>&1); NP_RC=$? t "no-push: refused under --print-only" "1" "$NP_RC" t "no-push: the file's content is the reason" "1" \ "$(grep -c 'work lives on the server' <<<"$NP_OUT")" -NP_OUT=$(bash laptop/push-project.sh --print-only --force "$NP" 2>&1); NP_RC=$? +NP_OUT=$(SYNC_HOLD_FILE=$TD/no-such-hold bash laptop/push-project.sh --print-only --force "$NP" 2>&1); NP_RC=$? t "no-push: --force does not lift it" "1" "$NP_RC" # And a project without the file is not caught by it — a guard that refused # everything would pass the two assertions above while breaking every push. mkdir -p "$TD/yespush" -YP_OUT=$(bash laptop/push-project.sh --print-only "$TD/yespush" 2>&1) +YP_OUT=$(SYNC_HOLD_FILE=$TD/no-such-hold bash laptop/push-project.sh --print-only "$TD/yespush" 2>&1) t "no-push: a project without the file is not held" "0" \ "$(grep -c 'flit-no-push' <<<"$YP_OUT")" -- 2.51.2