diff --git a/README.md b/README.md index 91aa04d..e977301 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Internal repo; not intended for public consumption. | --- | --- | | `1password-mcp-launcher.sh` | Reads `OP_SERVICE_ACCOUNT_TOKEN` from the macOS Keychain and launches the 1Password MCP server at a pinned version — avoids handing the keychain-held token to whatever `npx -y` happens to resolve. | | `instapaper-mcp-launcher.sh` | Reads the four Instapaper API credentials from the macOS Keychain and launches the Instapaper MCP server. The server calls bare `dotenv.config()`, which resolves `.env` against the process working directory — a Desktop-spawned server never runs from the repo, so exporting here is what actually reaches it. | -| `claude-config-backup.sh` | Snapshots machine-local Claude config files into a git repo and pushes. Gates each file on quiescence (unchanged size+mtime across a settle window), valid JSON, and a `gitleaks`-first secret scan before committing. Driven by `com.asiago.claude-config-backup` every 15 min. | +| `claude-config-backup.sh` | Snapshots machine-local Claude config files into a git repo and pushes. Gates each file on quiescence (unchanged size+mtime across a settle window), valid JSON, and a `gitleaks`-first secret scan before committing. If the remote has commits the clone lacks, it runs `git pull --rebase` and pushes once more. A rebase conflict is aborted and logged as `ERROR` with the manual fix; the script exits 1 and keeps the local commits. Other push failures log `WARN` with git's first error line. A failed push is retried next run, even when nothing new was committed. It never force-pushes. Driven by `com.asiago.claude-config-backup` every 15 min. Tests are in `tests/claude-config-backup.bats`. | | `handoff-sync.sh` | Two-way `rclone bisync` of `~/.local/state/claude-handoffs` with the Google Drive folder `Claude Handoff`, for hosts with no Drive app (TILSIT). Runs from `andrewrich`'s crontab every 5 minutes, not a LaunchAgent, because that user has no GUI session there. Log: `~/.local/state/rclone/handoff-sync.log`. The `gdrive:` remote uses rclone's shared client ID, which Google may retire. Tests in `tests/handoff-sync.bats`. | | `refresh-claude-secrets.sh` | Resolves `op://` references from `secrets.op` and writes plaintext values into `claude-secrets.md` for Claude via the Filesystem MCP. Re-run after rotating any referenced credential. Must run from `~/.config/claude-code` (the symlink target). | | `ssh` | Wrapper over the real `ssh` that intercepts `# op: VAR="op://..."` annotations in `~/.ssh/config`, resolves them via 1Password, exports, then `exec`s real `ssh`. Unannotated hosts pass straight through. Spec: [`docs/plans/2026-04-16-ssh-wrapper-spec.md`](docs/plans/2026-04-16-ssh-wrapper-spec.md). Tests in `tests/ssh-wrapper.bats`. | diff --git a/claude-config-backup.sh b/claude-config-backup.sh index 64e8340..f6398c4 100755 --- a/claude-config-backup.sh +++ b/claude-config-backup.sh @@ -242,25 +242,107 @@ for entry in "${TRACKED[@]}"; do [[ -f "${REPO}/${dest_rel}" ]] && git -C "${REPO}" add -- "${dest_rel}" done +# Commits HEAD has that origin/ lacks. Local refs only, no fetch. +# Prints 0 if there is nothing to compare. +_unpushed_count() { + local b + b="$(git -C "${REPO}" symbolic-ref --quiet --short HEAD)" || { echo 0; return; } + git -C "${REPO}" rev-parse --verify --quiet "refs/remotes/origin/${b}" >/dev/null || { echo 0; return; } + git -C "${REPO}" rev-list --count "origin/${b}..HEAD" +} + if git -C "${REPO}" diff --cached --quiet; then - log "no content change; nothing to commit" - exit 0 + # A failed push used to wait until a config file changed again; retry it. + unpushed="$(_unpushed_count)" + if [[ "${unpushed}" -eq 0 ]]; then + log "no content change; nothing to commit" + exit 0 + fi + log "no content change; ${unpushed} unpushed commit(s), retrying push" +else + changed="$(git -C "${REPO}" diff --cached --name-only | tr '\n' ' ' | sed 's/ $//')" + stat_line="$(git -C "${REPO}" diff --cached --shortstat | sed 's/^ *//')" + + captured_at="$(date '+%Y-%m-%d %H:%M:%S %Z')" + git -C "${REPO}" commit --quiet \ + -m "auto: ${changed} (${stat_line})" \ + -m "Captured ${captured_at} by claude-config-backup.sh" + log "committed: ${changed} — ${stat_line}" fi -changed="$(git -C "${REPO}" diff --cached --name-only | tr '\n' ' ' | sed 's/ $//')" -stat_line="$(git -C "${REPO}" diff --cached --shortstat | sed 's/^ *//')" +# Push. Unattended, so no prompts. Errors are logged, not discarded. +# A "behind" rejection rebases once. Never force-push or reset. + +# First line of git's stderr that says what went wrong, for the log. URL +# userinfo (user:token@) is stripped in case a remote URL carries it. +_git_error_excerpt() { + local err="$1" line + line="$(printf '%s\n' "${err}" | grep -m1 -E '^[[:space:]]*(!|error:|fatal:)' || true)" + if [[ -z "${line}" ]]; then + line="$(printf '%s\n' "${err}" | grep -m1 -v '^[[:space:]]*$' || true)" + fi + line="$(printf '%s' "${line}" | sed -E 's#(://)[^/@[:space:]]+@#\1#g; s/^[[:space:]]+//')" + printf '%s' "${line:-no error output from git}" +} + +# True if the remote has commits we lack. Matches the [rejected] line; +# advice.* config can hide the hint lines. +_push_was_rejected_behind() { + printf '%s\n' "$1" | grep -qE '\[rejected\].*\((fetch first|non-fast-forward)\)' +} + +# --absolute-git-dir: --git-path is relative to REPO, not to our cwd. +_rebase_in_progress() { + local git_dir + git_dir="$(git -C "${REPO}" rev-parse --absolute-git-dir)" || return 1 + [[ -d "${git_dir}/rebase-merge" || -d "${git_dir}/rebase-apply" ]] +} + +# Prints git's stderr (stdout is discarded) and returns git's status. +_push_head() { + { GIT_TERMINAL_PROMPT=0 git -C "${REPO}" push --quiet origin HEAD >/dev/null; } 2>&1 +} -captured_at="$(date '+%Y-%m-%d %H:%M:%S %Z')" -git -C "${REPO}" commit --quiet \ - -m "auto: ${changed} (${stat_line})" \ - -m "Captured ${captured_at} by claude-config-backup.sh" -log "committed: ${changed} — ${stat_line}" +# Prints stdout and stderr both: git reports "CONFLICT (content): Merge +# conflict in " on stdout, and that line names the file to fix. +_pull_rebase() { + GIT_TERMINAL_PROMPT=0 git -C "${REPO}" pull --rebase --quiet origin "$1" 2>&1 +} if git -C "${REPO}" remote get-url origin >/dev/null 2>&1; then - if git -C "${REPO}" push --quiet origin HEAD 2>/dev/null; then + if push_err="$(_push_head)"; then log "pushed to origin" + elif ! _push_was_rejected_behind "${push_err}"; then + excerpt="$(_git_error_excerpt "${push_err}")" + log "WARN: push failed (${excerpt}); commit is safe locally, will retry next run" + elif ! branch="$(git -C "${REPO}" symbolic-ref --quiet --short HEAD)"; then + log "WARN: push rejected, remote has commits we lack, but HEAD is detached so there is no branch to rebase; commit is safe locally" else - log "WARN: push failed; commit is safe locally, will retry next run" + log "push rejected, remote has commits we lack; rebasing onto origin/${branch}" + if pull_err="$(_pull_rebase "${branch}")"; then + if push_err="$(_push_head)"; then + log "pushed to origin after rebase" + else + excerpt="$(_git_error_excerpt "${push_err}")" + log "WARN: push after rebase failed (${excerpt}); commit is safe locally, will retry next run" + fi + elif _rebase_in_progress; then + # Name the conflicting file if git said which; else git's first error. + excerpt="$(printf '%s\n' "${pull_err}" | grep -m1 '^CONFLICT' || true)" + if [[ -z "${excerpt}" ]]; then + excerpt="$(_git_error_excerpt "${pull_err}")" + fi + if git -C "${REPO}" rebase --abort >/dev/null 2>&1; then + log "ERROR: rebase onto origin/${branch} conflicted (${excerpt}); rebase aborted, local commits are intact and unpushed" + else + log "ERROR: rebase onto origin/${branch} conflicted (${excerpt}) and 'git rebase --abort' also failed; inspect ${REPO} by hand" + fi + log " Fix by hand: git -C '${REPO}' pull --rebase origin ${branch}, resolve the conflict, git rebase --continue, then git push origin ${branch}" + exit 1 + else + excerpt="$(_git_error_excerpt "${pull_err}")" + log "WARN: pull --rebase failed (${excerpt}); commit is safe locally, will retry next run" + fi fi else log "no origin remote configured; commit is local only" diff --git a/tests/claude-config-backup.bats b/tests/claude-config-backup.bats new file mode 100755 index 0000000..991463b --- /dev/null +++ b/tests/claude-config-backup.bats @@ -0,0 +1,159 @@ +#!/usr/bin/env bats +# bats tests for claude-config-backup.sh: the push step. +# +# Run from repo root: bats tests/claude-config-backup.bats +# +# Hermetic. Every path the script touches is under BATS_TEST_TMPDIR: +# - REPO is ${XDG_DATA_HOME}/claude-config-backup, and XDG_DATA_HOME is set +# to the temp dir, so the real backup clone is never reached. +# - TRACKED sources are under ${HOME}, and HOME is the temp dir, so no real +# Claude config is read. +# - origin is a temp bare repo; "elsewhere" is a second temp clone that +# plays the part of the fleet rollout that pushed to the remote. +# - gitleaks is a stub that finds nothing. stat is a stub that prints a +# constant, because the script uses BSD `stat -f` and CI runs on Linux, +# where `stat -f` means filesystem status and drifts between samples. +# - GIT_CONFIG_GLOBAL points at a temp file and GIT_CONFIG_NOSYSTEM=1, so +# no global hooksPath, template dir, or identity leaks into the fixtures. + +setup() { + REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)" + TMPDIR_T="${BATS_TEST_TMPDIR:?bats 1.4 or later is required}" + SCRIPT="${REPO_ROOT}/claude-config-backup.sh" + + export GIT_CONFIG_GLOBAL="${TMPDIR_T}/gitconfig" + export GIT_CONFIG_NOSYSTEM=1 + git config --file "${GIT_CONFIG_GLOBAL}" user.name "Backup Test" + git config --file "${GIT_CONFIG_GLOBAL}" user.email "backup-test@example.invalid" + git config --file "${GIT_CONFIG_GLOBAL}" init.defaultBranch main + git config --file "${GIT_CONFIG_GLOBAL}" advice.detachedHead false + + STUBS="${TMPDIR_T}/stubs" + mkdir -p "${STUBS}" + printf '#!/usr/bin/env bash\nexit 0\n' >"${STUBS}/gitleaks" + printf '#!/usr/bin/env bash\necho 1:1\n' >"${STUBS}/stat" + chmod +x "${STUBS}/gitleaks" "${STUBS}/stat" + + T_HOME="${TMPDIR_T}/home" + T_DATA="${TMPDIR_T}/data" + ORIGIN="${TMPDIR_T}/origin.git" + CLONE="${T_DATA}/claude-config-backup" + ELSEWHERE="${TMPDIR_T}/elsewhere" + SRC="${T_HOME}/.claude/.claude.json" + mkdir -p "${T_HOME}/.claude" "${T_DATA}" + + # origin with one commit that already holds cli/claude.json. + git init --quiet --bare -b main "${ORIGIN}" + git clone --quiet "${ORIGIN}" "${ELSEWHERE}" 2>/dev/null + mkdir -p "${ELSEWHERE}/cli" + echo '{"v": 0}' >"${ELSEWHERE}/cli/claude.json" + git -C "${ELSEWHERE}" add cli/claude.json + git -C "${ELSEWHERE}" commit --quiet -m "initial" + git -C "${ELSEWHERE}" push --quiet origin main + + git clone --quiet "${ORIGIN}" "${CLONE}" +} + +# Runs the script with every input pointed into the temp dir. +run_backup() { + run env HOME="${T_HOME}" XDG_DATA_HOME="${T_DATA}" TMPDIR="${TMPDIR_T}" \ + SETTLE_SECONDS=0 PATH="${STUBS}:${PATH}" bash "${SCRIPT}" +} + +# Adds a commit to origin from the other clone, as the fleet rollout did. +remote_commit() { + local path="$1" content="$2" + mkdir -p "$(dirname "${ELSEWHERE}/${path}")" + printf '%s\n' "${content}" >"${ELSEWHERE}/${path}" + git -C "${ELSEWHERE}" add -- "${path}" + git -C "${ELSEWHERE}" commit --quiet -m "elsewhere: ${path}" + git -C "${ELSEWHERE}" push --quiet origin main +} + +@test "a normal push succeeds and says so" { + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"pushed to origin"* ]] + [[ "${output}" != *"rebas"* ]] + [ "$(git -C "${ORIGIN}" rev-parse main)" = "$(git -C "${CLONE}" rev-parse HEAD)" ] +} + +@test "a remote commit we lack is rebased onto, then the push succeeds" { + remote_commit ".github/workflows/standards-check.yml" "name: stub" + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"push rejected, remote has commits we lack; rebasing onto origin/main"* ]] + [[ "${output}" == *"pushed to origin after rebase"* ]] + # initial + elsewhere + ours + [ "$(git -C "${ORIGIN}" rev-list --count main)" -eq 3 ] + git -C "${ORIGIN}" cat-file -e main:.github/workflows/standards-check.yml + [ "$(git -C "${ORIGIN}" show main:cli/claude.json)" = '{"v": 1}' ] + [ "$(git -C "${ORIGIN}" rev-parse main)" = "$(git -C "${CLONE}" rev-parse HEAD)" ] +} + +@test "a conflicting remote commit aborts the rebase and keeps local commits" { + remote_commit "cli/claude.json" '{"v": "remote"}' + remote_head="$(git -C "${ORIGIN}" rev-parse main)" + before="$(git -C "${CLONE}" rev-list --count HEAD)" + echo '{"v": "local"}' >"${SRC}" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: rebase onto origin/main conflicted (CONFLICT"*"cli/claude.json"* ]] + [[ "${output}" == *"local commits are intact"* ]] + [[ "${output}" == *"Fix by hand: git -C '${CLONE}' pull --rebase origin main"* ]] + # Our commit is still there, on top of what we had; nothing was dropped. + [ "$(git -C "${CLONE}" rev-list --count HEAD)" -eq $((before + 1)) ] + [ "$(git -C "${CLONE}" show HEAD:cli/claude.json)" = '{"v": "local"}' ] + [ "$(git -C "${CLONE}" symbolic-ref --short HEAD)" = "main" ] + [ ! -d "$(git -C "${CLONE}" rev-parse --git-path rebase-merge)" ] + [ ! -d "$(git -C "${CLONE}" rev-parse --git-path rebase-apply)" ] + [ -z "$(git -C "${CLONE}" status --porcelain)" ] + # No force-push: the remote is exactly where it was. + [ "$(git -C "${ORIGIN}" rev-parse main)" = "${remote_head}" ] +} + +@test "any other push failure warns with git's error and does not rebase" { + git -C "${CLONE}" remote set-url origin "${TMPDIR_T}/does-not-exist.git" + before="$(git -C "${CLONE}" rev-parse HEAD)" + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"WARN: push failed (fatal: "*"); commit is safe locally, will retry next run"* ]] + [[ "${output}" != *"rebas"* ]] + [ "$(git -C "${CLONE}" rev-list --count HEAD)" -eq 2 ] + [ "$(git -C "${CLONE}" rev-parse HEAD~1)" = "${before}" ] +} + +@test "an earlier failed push is retried on a run with no content change" { + echo '{"v": 1}' >"${SRC}" + git -C "${CLONE}" remote set-url origin "${TMPDIR_T}/does-not-exist.git" + run_backup + [[ "${output}" == *"WARN: push failed"* ]] + git -C "${CLONE}" remote set-url origin "${ORIGIN}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"no content change; 1 unpushed commit(s), retrying push"* ]] + [[ "${output}" == *"pushed to origin"* ]] + [ "$(git -C "${ORIGIN}" rev-parse main)" = "$(git -C "${CLONE}" rev-parse HEAD)" ] +} + +@test "a run with no content change and nothing unpushed does not push" { + echo '{"v": 1}' >"${SRC}" + run_backup + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"no content change; nothing to commit"* ]] + [[ "${output}" != *"push"* ]] +} + +@test "URL userinfo is stripped from the logged error" { + git -C "${CLONE}" remote set-url origin "https://someuser:s3cr3t-token@127.0.0.1:1/nope.git" + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"WARN: push failed ("* ]] + [[ "${output}" != *"s3cr3t-token"* ]] + [[ "${output}" != *"someuser"* ]] +}