Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
104 changes: 93 additions & 11 deletions claude-config-backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch> 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 <path>" 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"
Expand Down
159 changes: 159 additions & 0 deletions tests/claude-config-backup.bats
Original file line number Diff line number Diff line change
@@ -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"* ]]
}
Loading