From 18d3c4d1c184146a1442dc2a13c4211e52aa56a0 Mon Sep 17 00:00:00 2001 From: Alex Ivantsov Date: Fri, 6 Nov 2026 09:38:30 -0500 Subject: [PATCH] fix(backup): keep the nightly job on copies with no origin remote The 2026-11-05 change (#26) stopped deploy from scheduling the nightly job on a copy with no origin remote. That silenced the nightly "'origin' does not appear to be a git repository" error, but it also cost such a copy the job's nightly project pull and branch-gc --apply. daily-sync.sh is now the one place that decides what a copy with no origin does. It commits the day's work as always, then logs one line, "OPS: INFO no origin remote: the day's commit is kept locally; nothing pulled or pushed" (or "no local changes to commit" on a clean day), returns success, and the project and branch-gc stages run as on any copy. "No origin" is read from the local config (`git remote` lists no origin, matched in bash so pipefail cannot flip it), so an origin that exists but cannot be reached, or has only a push URL, is still a failed pull and an ERROR; a `git remote` that fails is an ERROR too. deploy.sh and deploy.ps1 schedule the job on every copy again and no longer look at the remotes. The session briefing reads the INFO line as "Backup: local only (no origin remote)", neither a failure nor quiet; an ERROR or WARN in the same run still wins. Kept from #26: its deploy test harnesses (now asserting the job is scheduled with and without an origin, and that the cron and task blocks run no git) and the deploy.sh --help fix. branch-gc-safety-test's daily-sync order case gets an origin in its fixture so it still exercises the pull. daily-sync-test.sh now names branch-gc (its fixture stub), so it starts with the sandbox guard. Docs: CHANGELOG, README ("The daily backup"), DEPLOYMENT.md (step 8, the Windows summary, the verification commands) and CONTEXT/how-i-work.md. --- .claude-config/backup/daily-sync.sh | 32 +++++ .../bin/tests/branch-gc-safety-test.py | 3 + .claude-config/bin/tests/daily-sync-test.sh | 114 +++++++++++++++++- .claude-config/bin/tests/deploy-cron-test.sh | 110 +++++++++-------- .../bin/tests/deploy-windows-test.ps1 | 47 ++++---- .../bin/tests/session-briefing-backup-test.sh | 31 +++++ .claude-config/deploy.ps1 | 34 +----- .claude-config/deploy.sh | 27 ++--- .claude-config/hooks/session-briefing.sh | 21 +++- CHANGELOG.md | 23 ++++ CONTEXT/how-i-work.md | 3 +- DEPLOYMENT.md | 43 ++++--- README.md | 30 +++-- 13 files changed, 350 insertions(+), 168 deletions(-) diff --git a/.claude-config/backup/daily-sync.sh b/.claude-config/backup/daily-sync.sh index 2161aeb..7f6ac55 100755 --- a/.claude-config/backup/daily-sync.sh +++ b/.claude-config/backup/daily-sync.sh @@ -32,6 +32,17 @@ # pull failed on every run, so nothing was ever backed up. When the pull # fails and `git ls-remote --exit-code` says the branch is absent (exit 2, # not a network or auth error), the run pushes through the gate instead. +# - No origin remote: a copy with no remote named `origin` is a supported +# setup (OPS works without GitHub). The day's work is still committed, then +# the OPS stage logs one INFO line ("no origin remote ...: nothing pulled or +# pushed") and returns success; the project and branch-gc stages run as on +# any other copy, and the briefing reads the INFO line as "local only". The +# test is `git remote` listing no `origin`, which reads only the local +# config: an origin that exists but cannot be reached (offline, a dead URL, +# no credentials) is never taken for "no origin" and stays a failed pull, +# an ERROR. deploy schedules this job on every copy and leaves this choice +# to this script (an earlier deploy skipped the schedule on such copies, so +# they lost the nightly project pull and branch cleanup too). set -uo pipefail @@ -166,6 +177,27 @@ backup_ops() { committed=1 fi + # No origin remote: the commit above is the whole backup (see Design). + # `git remote` reads only the local config, so a network or auth failure + # can never land here, and an origin that is configured but broken (a bad + # URL, a pushurl with no url) falls through to the pull and fails loudly. + # A `git remote` that itself fails is an ERROR, not a no-origin copy. The + # match is in bash, not `printf | grep -q`: under pipefail an early grep + # exit can fail the pipe and read as "no origin" on a copy that has one. + local remotes + if ! remotes="$(git remote 2>&1)"; then + log "OPS: ERROR could not list the git remotes; nothing pulled or pushed: $(printf '%s' "$remotes" | tail -1)" + return 1 + fi + if [[ $'\n'"$remotes"$'\n' != *$'\n'origin$'\n'* ]]; then + if [ "$committed" = 1 ]; then + log "OPS: INFO no origin remote: the day's commit is kept locally; nothing pulled or pushed" + else + log "OPS: INFO no origin remote: no local changes to commit; nothing pulled or pushed" + fi + return 0 + fi + # Then pull, so the tree stays current across hosts. --no-autostash: the # tree is committed, and a stash is exactly what this order avoids. A # failed pull (offline, no credentials under cron, a conflicting rebase) diff --git a/.claude-config/bin/tests/branch-gc-safety-test.py b/.claude-config/bin/tests/branch-gc-safety-test.py index f8dffbc..3b228c2 100755 --- a/.claude-config/bin/tests/branch-gc-safety-test.py +++ b/.claude-config/bin/tests/branch-gc-safety-test.py @@ -493,6 +493,9 @@ def test_daily_sync_runs_gc_last_and_logs_auth_failure_nonfatally(self): (ops / "README.md").write_text("ops\n") self.git("add", "README.md", at=ops) self.git("commit", "-q", "-m", "seed", at=ops) + # An origin, so the backup stage pulls (a copy with no origin skips the + # pull by design). It is never contacted: the interceptor fakes the pull. + self.git("remote", "add", "origin", "git@example.invalid:acme/ops.git", at=ops) self.git_interceptor(''' if "pull" in args: with (root / "order").open("a") as f: f.write("pull\\n") diff --git a/.claude-config/bin/tests/daily-sync-test.sh b/.claude-config/bin/tests/daily-sync-test.sh index d4e47d7..f2047f0 100755 --- a/.claude-config/bin/tests/daily-sync-test.sh +++ b/.claude-config/bin/tests/daily-sync-test.sh @@ -26,8 +26,18 @@ # gate: pushed to a private origin, held for a public one; an unreachable # origin is still a failed pull, never a first push # -# Run: bash ~/OPS/.claude-config/bin/tests/daily-sync-test.sh +# - no origin remote: the day's commit is kept, the OPS stage logs one INFO +# line and no ERROR or WARN, nothing is pulled or pushed, and the project +# and branch-gc stages still run; a remote under another name is no origin; +# an origin that exists but cannot be reached, or has only a push URL, is +# still a failed pull (ERROR), and so is a `git remote` that fails +# +# It names branch-gc (a stub in its fixture, never the real tool), so like the +# other suites that do it starts with the sandbox guard (verify-ops check 24): +# ~/OPS/.claude-config/bin/sandbox-run --no-bind --no-net --ro-bind ~/OPS -- \ +# bash ~/OPS/.claude-config/bin/tests/daily-sync-test.sh set -uo pipefail +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/../sandbox-guard.sh" && sandbox_guard || exit 97 CFG="$(cd "$(dirname "$0")/../.." && pwd)" SYNC="$CFG/backup/daily-sync.sh" @@ -57,11 +67,17 @@ case "${*: -1}" in esac exit 1 STUB -# Every git call passes through this wrapper, which records any stash. +# Every git call passes through this wrapper, which records any stash and any +# call that talks to a remote (the no-origin cases assert there are none). +# FAIL_GIT_REMOTE=1 makes a bare `git remote` fail, as a broken config would. REAL_GIT="$(command -v git)" cat > "$WORK/bin/git" <> "$WORK/stash-calls"; done +for a in "\$@"; do + [ "\$a" = stash ] && printf '%s\n' "\$*" >> "$WORK/stash-calls" + case "\$a" in pull|push|fetch|ls-remote) printf '%s\n' "\$*" >> "$WORK/remote-calls" ;; esac +done +if [ "\${FAIL_GIT_REMOTE:-}" = 1 ] && [ "\$*" = remote ]; then echo "fatal: bad config line 1 in file .git/config" >&2; exit 128; fi exec "$REAL_GIT" "\$@" STUB chmod +x "$WORK/bin/gh" "$WORK/fake-ssh" "$WORK/bin/git" @@ -314,4 +330,96 @@ grep -q 'OPS: backup pushed successfully' "$LOG" || fail "missing remote branch, if grep -q 'OPS: ERROR' "$LOG"; then fail "missing remote branch, private origin: logged as an error"; fi pass "an origin with no branch yet is a first push through the gate: held for a public origin, pushed to a private one" +# --- No origin remote: commit locally, one INFO line, every stage still runs ----- +# (2026-11-06: deploy stopped scheduling the job on a copy with no origin, so +# such a copy also lost its nightly project pull and branch-gc. The job is +# scheduled again everywhere, and this stage is what tells "no origin" apart.) +# A fresh copy made with `git init` (the template copied, never cloned), with a +# branch-gc stub so the third stage leaves a trace too. +export HOME="$WORK/local-home" +OPS="$HOME/OPS"; LOG="$OPS/.claude-config/backup/sync.log" +git init -q -b main "$OPS" || fail "fixture: git init the local-only copy" +mkdir -p "$OPS/.claude-config/bin" "$OPS/PROJECTS" +cp "$CHECK" "$OPS/.claude-config/bin/origin-private-check" +printf '.claude-config/backup/sync.log\n' > "$OPS/.gitignore" +printf '#!/usr/bin/env bash\necho "projects stage ran ($1)"\n' > "$OPS/PROJECTS/sync-check.sh" +printf '#!/usr/bin/env bash\necho "branch-gc stage ran ($1)"\n' > "$OPS/.claude-config/bin/branch-gc" +chmod +x "$OPS/.claude-config/bin/branch-gc" +git -C "$OPS" add -A && git -C "$OPS" commit -q -m seed || fail "fixture: seed the local-only copy" +NO_ORIGIN_DIRTY="OPS: INFO no origin remote: the day's commit is kept locally; nothing pulled or pushed" +NO_ORIGIN_CLEAN="OPS: INFO no origin remote: no local changes to commit; nothing pulled or pushed" +ops_lines() { grep -E '^\[[^]]*\] OPS: ' "$LOG" | sed 's/^\[[^]]*\] //'; } +all_stages_ran() { + grep -q 'projects stage ran (--auto)' "$LOG" && grep -q 'PROJECTS: sync-check.sh --auto completed' "$LOG" \ + || fail "$1: the project stage did not run" + grep -q 'branch-gc stage ran (--apply)' "$LOG" && grep -q 'BRANCH-GC: branch-gc --apply completed' "$LOG" \ + || fail "$1: the branch-gc stage did not run" + grep -q '===== daily-sync complete =====' "$LOG" || fail "$1: the run did not complete" +} + +dirty_tree local +head_before="$(local_head)"; rm -f "$WORK/remote-calls" +run_sync +[ "$(ops_lines)" = "$NO_ORIGIN_DIRTY" ] || fail "no origin: the OPS stage did not log exactly the one INFO line: $(ops_lines)" +[ "$(git -C "$OPS" log -1 --format=%s)" = "Daily Backup $(date '+%Y-%m-%d')" ] && [ "$(git -C "$OPS" rev-parse HEAD^)" = "$head_before" ] \ + || fail "no origin: the day's commit was not made" +[ -z "$(git -C "$OPS" status --porcelain)" ] && git -C "$OPS" cat-file -e HEAD:untracked-local.md \ + || fail "no origin: the day's work is not all in the day's commit" +[ ! -s "$WORK/remote-calls" ] || fail "no origin: git talked to a remote: $(cat "$WORK/remote-calls")" +all_stages_ran "no origin" +no_stash "no origin" +pass "no origin: the day's commit is kept, one INFO line and no ERROR or WARN, nothing pulled or pushed, every stage ran" + +head_before="$(local_head)" +run_sync +[ "$(ops_lines)" = "$NO_ORIGIN_CLEAN" ] || fail "no origin, clean tree: not the one INFO line: $(ops_lines)" +[ "$(local_head)" = "$head_before" ] || fail "no origin, clean tree: a commit was made" +[ ! -s "$WORK/remote-calls" ] || fail "no origin, clean tree: git talked to a remote: $(cat "$WORK/remote-calls")" +all_stages_ran "no origin, clean tree" +pass "no origin, clean tree: nothing committed, the INFO line says so, every stage ran" + +# A remote under another name is not an origin: the backup only ever pulls and +# pushes origin, so the copy is local only (deploy used the same rule). +git -C "$OPS" remote add upstream "$WORK/never-contacted.git" +printf 'upstream day\n' > "$OPS/upstream-day.md" +run_sync +[ "$(ops_lines)" = "$NO_ORIGIN_DIRTY" ] || fail "remote named upstream: not read as no origin: $(ops_lines)" +[ ! -s "$WORK/remote-calls" ] || fail "remote named upstream: git talked to a remote: $(cat "$WORK/remote-calls")" +git -C "$OPS" remote remove upstream +pass "a remote under another name does not count as an origin" + +# An origin that exists but cannot be reached is a failed pull, an ERROR, as +# on any copy: never "no origin". A local path that does not exist fails +# without any network, so the case is deterministic. +git -C "$OPS" remote add origin "$WORK/no-such-dir/ops.git" +printf 'dead origin day\n' > "$OPS/dead-origin.md" +head_before="$(local_head)" +run_sync +grep -q "OPS: ERROR pull --rebase failed; the day's commit is kept locally, nothing pushed" "$LOG" \ + || fail "unreachable origin on a local copy: not an ERROR: $(ops_lines)" +if grep -q 'OPS: INFO no origin remote' "$LOG"; then fail "unreachable origin: read as no origin"; fi +[ "$(git -C "$OPS" rev-parse HEAD^)" = "$head_before" ] && git -C "$OPS" cat-file -e HEAD:dead-origin.md \ + || fail "unreachable origin on a local copy: the day's commit was not kept" +all_stages_ran "unreachable origin on a local copy" +pass "an origin that exists but cannot be reached is still an ERROR, never 'no origin'" + +# An origin with a push URL and no fetch URL is still an origin (git lists it): +# the pull fails loudly instead of the copy passing as local only. +git -C "$OPS" config --unset remote.origin.url +git -C "$OPS" config remote.origin.pushurl "$WORK/no-such-dir/ops.git" +run_sync +grep -q 'OPS: ERROR pull --rebase failed' "$LOG" || fail "origin with only a push URL: not an ERROR: $(ops_lines)" +if grep -q 'OPS: INFO no origin remote' "$LOG"; then fail "origin with only a push URL: read as no origin"; fi +git -C "$OPS" remote remove origin +pass "an origin with only a push URL is still an origin: the failed pull is an ERROR" + +# When git cannot list the remotes at all, that is an ERROR, not a local copy. +printf 'broken config day\n' > "$OPS/broken-config.md" +FAIL_GIT_REMOTE=1 run_sync +grep -q 'OPS: ERROR could not list the git remotes; nothing pulled or pushed: fatal: bad config line 1' "$LOG" \ + || fail "git remote failing: not an ERROR: $(ops_lines)" +if grep -q 'OPS: INFO no origin remote' "$LOG"; then fail "git remote failing: read as no origin"; fi +all_stages_ran "git remote failing" +pass "a git remote that cannot list the remotes is an ERROR, not 'no origin'" + echo "ALL PASS" diff --git a/.claude-config/bin/tests/deploy-cron-test.sh b/.claude-config/bin/tests/deploy-cron-test.sh index 2f894f5..886b44a 100755 --- a/.claude-config/bin/tests/deploy-cron-test.sh +++ b/.claude-config/bin/tests/deploy-cron-test.sh @@ -1,24 +1,24 @@ #!/usr/bin/env bash -# deploy-cron-test.sh: deploy.sh schedules the nightly backup only when the OPS -# repo has an `origin` remote. +# deploy-cron-test.sh: deploy.sh schedules the nightly backup on every copy, +# with or without an `origin` remote. # -# A copy with no remote is a supported setup (OPS works without GitHub). There -# the backup's OPS stage logs "'origin' does not appear to be a git repository" -# on every run and the session briefing reports a failed backup in every -# session, so deploy must not schedule the job. daily-sync.sh is unchanged: a -# copy that has a remote and cannot push still logs and reports loudly (that is -# daily-sync-test.sh's ground). +# A copy with no remote is a supported setup (OPS works without GitHub). An +# earlier deploy did not schedule the job there, so such a copy also lost the +# job's nightly project pull and branch-gc. Now daily-sync.sh is the one place +# that decides what a copy with no origin does (it commits locally and logs one +# INFO line; daily-sync-test.sh covers that), and deploy does not look at the +# remotes at all. # # This runs the real cron block sliced out of deploy.sh, against a fake # `crontab` kept in a file and throwaway git repos as the OPS root, and asserts: # - origin present: the cron line is installed once, exactly as before, and a # second run installs nothing more # - no origin (a fresh `git init`, a remote under another name, a directory -# that is not a repository): nothing is written to the crontab, the one -# "No git remote" line is printed, and the deploy does not count it a failure +# that is not a repository): the same line is installed once, nothing says +# "No git remote", and a rerun adds nothing +# - the cron block never runs git: the remotes are not deploy's decision # - an existing cron line is left alone, with and without an origin: the -# crontab is byte-identical and written zero times; without an origin the -# deploy says so and names the fix, and still removes nothing +# crontab is byte-identical and written zero times, and no warning is printed # - other crontab entries survive a run in every case # - --no-system touches no crontab either way # @@ -50,7 +50,14 @@ case "${1:-}" in *) echo "fake crontab: unexpected arguments: $*" >&2; exit 2 ;; esac STUB -chmod +x "$WORK/bin/crontab" +# git, on the block's PATH only, records every call: the block must make none. +REAL_GIT="$(command -v git)" +cat > "$WORK/bin/git" <> "$WORK/git-calls" +exec "$REAL_GIT" "\$@" +STUB +chmod +x "$WORK/bin/crontab" "$WORK/bin/git" BLOCK="$(awk '/^# --- Daily backup cron job ---/ {p=1} p && /^# --- Claude Code plugins/ {exit} p {print}' "$DEPLOY")" printf '%s\n' "$BLOCK" | grep -q 'crontab' && printf '%s\n' "$BLOCK" | grep -q 'CRON_LINE' \ @@ -86,59 +93,61 @@ run_block() { new_crontab() { export CRONTAB_FILE="$WORK/crontab.$1"; rm -f "$CRONTAB_FILE" "$CRONTAB_FILE.writes"; } cron_count() { grep -cF '# OPS Daily Backup' "$CRONTAB_FILE" 2>/dev/null || true; } cron_writes() { if [ -f "$CRONTAB_FILE.writes" ]; then wc -l < "$CRONTAB_FILE.writes" | tr -d ' '; else echo 0; fi; } -NOREMOTE_MSG='No git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then rerun deploy.' +cron_line() { echo "0 9 * * * $1/.claude-config/backup/daily-sync.sh # OPS Daily Backup"; } + +# installs_once