diff --git a/README.md b/README.md index e977301..58efbd9 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. 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`. | +| `claude-config-backup.sh` | Snapshots machine-local Claude config files into a git repo and pushes. Each host writes only its own folder: `/cli/claude.json`, `/desktop/claude_desktop_config.json` and `/machine-settings.json`. The last is the `--settings` overlay that claude-wrapper passes; `CLAUDE_MACHINE_SETTINGS` overrides its path. `` is `CLAUDE_CONFIG_BACKUP_HOST` if set, else `hostname -s`, uppercased. It must match `^[A-Z0-9][A-Z0-9-]*$`, or the script logs `ERROR` and exits 1. It refuses to run on arich-mac (company machine), detected by host name or by the `arich` login. On its first run with this layout, it moves root-level `cli/` and `desktop/` into `/` with `git mv`, in a commit of its own. If both the old and new folders exist, it logs `ERROR`, changes nothing and exits 1. 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 f6398c4..2dbaf31 100755 --- a/claude-config-backup.sh +++ b/claude-config-backup.sh @@ -19,13 +19,6 @@ set -euo pipefail REPO="${XDG_DATA_HOME:-${HOME}/.local/share}/claude-config-backup" -# Tracked files: "|" -# Add a line here to track another file. -TRACKED=( - "desktop/claude_desktop_config.json|${HOME}/Library/Application Support/Claude/claude_desktop_config.json" - "cli/claude.json|${HOME}/.claude/.claude.json" -) - # Seconds to wait between the two stat samples used for the quiescence check. SETTLE_SECONDS="${SETTLE_SECONDS:-5}" @@ -35,6 +28,49 @@ log() { echo "[${ts}] $*" } +# Host label = this machine's top-level folder. hostname -s, not scutil (wrong inside the sandbox); the env var is for tests. +_host_short() { hostname -s 2>/dev/null || true; } +_current_user() { id -un 2>/dev/null || true; } +_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; } + +host_from_env="${CLAUDE_CONFIG_BACKUP_HOST:-}" +host_short="$(_host_short)" +host_short="$(_upper "${host_short}")" +host_env_upper="$(_upper "${host_from_env}")" +login="$(_current_user)" + +# arich-mac's company config must never reach this personal repo. Either signal refuses: host name or the "arich" login. +if [[ "${host_env_upper}" == ARICH-MAC* || "${host_short}" == ARICH-MAC* \ + || "${login}" == "arich" ]]; then + log "ERROR: this looks like arich-mac (company machine); refusing to back up company config to a personal repo" + exit 1 +fi + +if [[ -n "${host_from_env}" ]]; then + HOST_LABEL="${host_from_env}" +else + HOST_LABEL="${host_short}" +fi +if [[ -z "${HOST_LABEL}" ]]; then + log "ERROR: no host label; set CLAUDE_CONFIG_BACKUP_HOST (e.g. ASIAGO) in the LaunchAgent" + exit 1 +fi +# Uppercase letters, digits and '-' only: never empty, never '/' or '..'. +if [[ ! "${HOST_LABEL}" =~ ^[A-Z0-9][A-Z0-9-]*$ ]]; then + log "ERROR: invalid host label '${HOST_LABEL}' (allowed: A-Z, 0-9 and '-', not leading); set CLAUDE_CONFIG_BACKUP_HOST" + exit 1 +fi + +# "|", all under ${HOST_LABEL}/. machine-settings.json is claude-wrapper's --settings overlay, in no other repo. +TRACKED=( + "${HOST_LABEL}/desktop/claude_desktop_config.json|${HOME}/Library/Application Support/Claude/claude_desktop_config.json" + "${HOST_LABEL}/cli/claude.json|${HOME}/.claude/.claude.json" + "${HOST_LABEL}/machine-settings.json|${CLAUDE_MACHINE_SETTINGS:-${HOME}/.config/claude-code/machine-settings.json}" +) + +# Folders that lived at the repo root before the host-prefixed layout. +LEGACY_DIRS=(cli desktop) + if ! command -v git >/dev/null 2>&1; then log "ERROR: git not found on PATH" exit 1 @@ -181,6 +217,47 @@ if [[ ! -d "${REPO}/.git" ]]; then exit 1 fi +# One-time move of the root-level layout into ${HOST_LABEL}/: a git mv, so history follows. GIT_BIN lets tests stub it. +_git_mv() { "${GIT_BIN:-git}" -C "${REPO}" mv -- "$1" "$2"; } + +_migrate_legacy_layout() { + local d have_old=() clash=() + for d in "${LEGACY_DIRS[@]}"; do + [[ -d "${REPO}/${d}" ]] || continue + have_old+=("${d}") + [[ -e "${REPO}/${HOST_LABEL}/${d}" ]] && clash+=("${d}") + done + [[ "${#have_old[@]}" -eq 0 ]] && return 0 + + if [[ "${#clash[@]}" -gt 0 ]]; then + log "ERROR: both old and new layout present for: ${clash[*]} (root and ${HOST_LABEL}/); refusing to migrate or back up" + log " Resolve by hand in ${REPO}: keep one copy of each, commit, then re-run." + exit 1 + fi + if ! git -C "${REPO}" diff --cached --quiet; then + log "ERROR: index of ${REPO} has staged changes; refusing to migrate the layout on top of them" + exit 1 + fi + + mkdir -p "${REPO}/${HOST_LABEL}" + for d in "${have_old[@]}"; do + if ! _git_mv "${d}" "${HOST_LABEL}/${d}"; then + log "ERROR: git mv ${d} ${HOST_LABEL}/${d} failed; inspect ${REPO} by hand" + exit 1 + fi + done + if git -C "${REPO}" diff --cached --quiet; then + log "migrated layout: nothing tracked to move for ${have_old[*]}" + return 0 + fi + git -C "${REPO}" commit --quiet \ + -m "layout: move ${have_old[*]} into ${HOST_LABEL}/" \ + -m "One-time move to the host-prefixed layout by claude-config-backup.sh; each host now writes only its own folder." + log "migrated layout: moved ${have_old[*]} into ${HOST_LABEL}/" +} + +_migrate_legacy_layout + copied=0 skipped=0 matched_pattern="" diff --git a/tests/claude-config-backup.bats b/tests/claude-config-backup.bats index 991463b..bfee15f 100755 --- a/tests/claude-config-backup.bats +++ b/tests/claude-config-backup.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats -# bats tests for claude-config-backup.sh: the push step. +# bats tests for claude-config-backup.sh: host label, company-machine refusal, +# layout migration, tracked files and the push step. # # Run from repo root: bats tests/claude-config-backup.bats # @@ -7,12 +8,17 @@ # - 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. +# Claude config is read. CLAUDE_MACHINE_SETTINGS is cleared from the +# ambient environment for the same reason. # - 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. +# - hostname and id are stubs that print STUBS/hostname.out and +# STUBS/user.out, so the host label and the arich-mac check never depend +# on the machine running the tests. run_backup sets +# CLAUDE_CONFIG_BACKUP_HOST from BACKUP_HOST (default ASIAGO). # - 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. @@ -32,32 +38,59 @@ setup() { 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" + # hostname prints STUBS/hostname.out, and fails when it is absent. + printf '#!/usr/bin/env bash\ncat "%s/hostname.out" 2>/dev/null\n' "${STUBS}" >"${STUBS}/hostname" + # id prints STUBS/user.out (the script only calls `id -un`). + printf '#!/usr/bin/env bash\ncat "%s/user.out"\n' "${STUBS}" >"${STUBS}/id" + # A git stand-in for GIT_BIN: records its arguments and changes nothing. + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >>"%s/git-mv.calls"\nexit 1\n' "${STUBS}" >"${STUBS}/fake-git" + chmod +x "${STUBS}/gitleaks" "${STUBS}/stat" "${STUBS}/hostname" "${STUBS}/id" "${STUBS}/fake-git" + echo "testhost" >"${STUBS}/hostname.out" + echo "tester" >"${STUBS}/user.out" + BACKUP_HOST="ASIAGO" 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}" + SRC_MS="${T_HOME}/.config/claude-code/machine-settings.json" + mkdir -p "${T_HOME}/.claude" "${T_HOME}/.config/claude-code" "${T_DATA}" + + # origin with one commit that already holds ASIAGO/cli/claude.json. + use_origin "base" "ASIAGO/cli/claude.json" +} - # origin with one commit that already holds cli/claude.json. +# Points ORIGIN, ELSEWHERE and CLONE at a fresh set named : a bare +# origin with one commit holding each given path, and two clones of it. +# CLONE is always ${T_DATA}/claude-config-backup, the path the script uses. +use_origin() { + local name="$1" path + shift + ORIGIN="${TMPDIR_T}/${name}-origin.git" + ELSEWHERE="${TMPDIR_T}/${name}-elsewhere" + CLONE="${T_DATA}/claude-config-backup" + if [[ -e "${CLONE}" ]]; then + # Park the previous clone under the temp dir instead of deleting it. + mv "${CLONE}" "${TMPDIR_T}/parked-clone-before-${name}" + fi 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 + for path in "$@"; do + mkdir -p "$(dirname "${ELSEWHERE}/${path}")" + echo '{"v": 0}' >"${ELSEWHERE}/${path}" + git -C "${ELSEWHERE}" add -- "${path}" + done 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. +# Runs the script with every input pointed into the temp dir. Extra +# NAME=value arguments are passed to env before the script. run_backup() { - run env HOME="${T_HOME}" XDG_DATA_HOME="${T_DATA}" TMPDIR="${TMPDIR_T}" \ - SETTLE_SECONDS=0 PATH="${STUBS}:${PATH}" bash "${SCRIPT}" + run env -u CLAUDE_MACHINE_SETTINGS -u GIT_BIN \ + HOME="${T_HOME}" XDG_DATA_HOME="${T_DATA}" TMPDIR="${TMPDIR_T}" \ + CLAUDE_CONFIG_BACKUP_HOST="${BACKUP_HOST}" \ + SETTLE_SECONDS=0 PATH="${STUBS}:${PATH}" "$@" bash "${SCRIPT}" } # Adds a commit to origin from the other clone, as the fleet rollout did. @@ -70,6 +103,8 @@ remote_commit() { git -C "${ELSEWHERE}" push --quiet origin main } +# --- push step --------------------------------------------------------------- + @test "a normal push succeeds and says so" { echo '{"v": 1}' >"${SRC}" run_backup @@ -89,23 +124,23 @@ remote_commit() { # 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}" show main:ASIAGO/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_commit "ASIAGO/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}" == *"ERROR: rebase onto origin/main conflicted (CONFLICT"*"ASIAGO/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}" show HEAD:ASIAGO/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)" ] @@ -157,3 +192,187 @@ remote_commit() { [[ "${output}" != *"s3cr3t-token"* ]] [[ "${output}" != *"someuser"* ]] } + +# --- host-prefixed layout and tracked files ---------------------------------- + +@test "files are written under the host folder, never at the root" { + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [ "$(git -C "${ORIGIN}" show main:ASIAGO/cli/claude.json)" = '{"v": 1}' ] + [ -z "$(git -C "${ORIGIN}" ls-tree --name-only main -- cli desktop machine-settings.json)" ] +} + +@test "machine-settings.json is tracked and committed under the host folder" { + echo '{"autoMode": {"enabled": true}}' >"${SRC_MS}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"committed: ASIAGO/machine-settings.json"* ]] + [ "$(git -C "${ORIGIN}" show main:ASIAGO/machine-settings.json)" = '{"autoMode": {"enabled": true}}' ] +} + +@test "CLAUDE_MACHINE_SETTINGS names the overlay source, as in claude-wrapper" { + other="${TMPDIR_T}/elsewhere-overlay.json" + echo '{"v": "override"}' >"${other}" + run_backup CLAUDE_MACHINE_SETTINGS="${other}" + [ "${status}" -eq 0 ] + [ "$(git -C "${ORIGIN}" show main:ASIAGO/machine-settings.json)" = '{"v": "override"}' ] +} + +@test "an invalid-JSON machine-settings.json is not committed; the others still are" { + echo '{"sandbox": ' >"${SRC_MS}" + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"SKIP ASIAGO/machine-settings.json: source is not valid JSON, refusing to commit"* ]] + [ "$(git -C "${ORIGIN}" show main:ASIAGO/cli/claude.json)" = '{"v": 1}' ] + run git -C "${ORIGIN}" cat-file -e main:ASIAGO/machine-settings.json + [ "${status}" -ne 0 ] +} + +@test "a lowercase hostname is used, uppercased, when no override is set" { + BACKUP_HOST="" + echo "tilsit" >"${STUBS}/hostname.out" + echo '{"v": 1}' >"${SRC_MS}" + run_backup + [ "${status}" -eq 0 ] + [ "$(git -C "${ORIGIN}" show main:TILSIT/machine-settings.json)" = '{"v": 1}' ] +} + +# --- host label refusals ----------------------------------------------------- + +@test "no host label from either source exits non-zero and commits nothing" { + BACKUP_HOST="" + mv "${STUBS}/hostname.out" "${STUBS}/hostname.out.parked" + echo '{"v": 1}' >"${SRC}" + before="$(git -C "${CLONE}" rev-parse HEAD)" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: no host label; set CLAUDE_CONFIG_BACKUP_HOST"* ]] + [ "$(git -C "${CLONE}" rev-parse HEAD)" = "${before}" ] + [ -z "$(git -C "${CLONE}" status --porcelain)" ] +} + +@test "invalid host labels are refused and nothing is written" { + echo '{"v": 1}' >"${SRC}" + before="$(git -C "${CLONE}" rev-parse HEAD)" + for label in "../x" "a/b" ".." "-X" "AS IAGO" "ASIAGO/"; do + BACKUP_HOST="${label}" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: invalid host label '${label}'"* ]] + done + [ "$(git -C "${CLONE}" rev-parse HEAD)" = "${before}" ] + [ -z "$(git -C "${CLONE}" status --porcelain)" ] + [ ! -e "${T_DATA}/x" ] +} + +@test "an empty hostname with no override counts as no label" { + BACKUP_HOST="" + : >"${STUBS}/hostname.out" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: no host label"* ]] +} + +# --- company machine --------------------------------------------------------- + +@test "arich-mac by hostname is refused before anything is written" { + BACKUP_HOST="" + echo "arich-mac" >"${STUBS}/hostname.out" + echo '{"v": 1}' >"${SRC}" + before="$(git -C "${CLONE}" rev-parse HEAD)" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: this looks like arich-mac (company machine)"* ]] + [ "$(git -C "${CLONE}" rev-parse HEAD)" = "${before}" ] + [ -z "$(git -C "${CLONE}" status --porcelain)" ] +} + +@test "arich-mac is refused even when the override names another host" { + BACKUP_HOST="ASIAGO" + echo "arich-mac" >"${STUBS}/hostname.out" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"arich-mac (company machine)"* ]] +} + +@test "arich-mac by override label is refused" { + BACKUP_HOST="ARICH-MAC" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"arich-mac (company machine)"* ]] +} + +@test "the arich login (arich-mac's user) is refused whatever the host name" { + echo "arich" >"${STUBS}/user.out" + echo '{"v": 1}' >"${SRC}" + before="$(git -C "${CLONE}" rev-parse HEAD)" + run_backup + [ "${status}" -ne 0 ] + [[ "${output}" == *"arich-mac (company machine)"* ]] + [ "$(git -C "${CLONE}" rev-parse HEAD)" = "${before}" ] +} + +# --- one-time layout migration ----------------------------------------------- + +@test "the old root layout is moved into the host folder in one commit, then backed up" { + use_origin "legacy" "cli/claude.json" "desktop/claude_desktop_config.json" + before="$(git -C "${CLONE}" rev-parse HEAD)" + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"migrated layout: moved cli desktop into ASIAGO/"* ]] + [[ "${output}" == *"pushed to origin"* ]] + # initial + migration + backup + [ "$(git -C "${ORIGIN}" rev-list --count main)" -eq 3 ] + migration="$(git -C "${ORIGIN}" rev-parse main~1)" + [ "$(git -C "${ORIGIN}" rev-parse main~2)" = "${before}" ] + [ "$(git -C "${ORIGIN}" log -1 --format=%s "${migration}")" = "layout: move cli desktop into ASIAGO/" ] + # The migration commit is renames only, with the content unchanged. + run git -C "${ORIGIN}" diff --name-status -M "${before}" "${migration}" + [[ "${output}" == *"R100"*"cli/claude.json"*"ASIAGO/cli/claude.json"* ]] + [[ "${output}" == *"R100"*"desktop/claude_desktop_config.json"*"ASIAGO/desktop/claude_desktop_config.json"* ]] + [ "$(printf '%s\n' "${output}" | wc -l | tr -d ' ')" -eq 2 ] + # History follows the file across the move. + [ "$(git -C "${CLONE}" log --follow --format=%H -- ASIAGO/cli/claude.json | tail -1)" = "${before}" ] + [ -z "$(git -C "${ORIGIN}" ls-tree --name-only main -- cli desktop)" ] + [ "$(git -C "${ORIGIN}" show main:ASIAGO/cli/claude.json)" = '{"v": 1}' ] +} + +@test "a second run after migration does not migrate again" { + use_origin "legacy" "cli/claude.json" + echo '{"v": 1}' >"${SRC}" + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" == *"migrated layout: moved cli into ASIAGO/"* ]] + run_backup + [ "${status}" -eq 0 ] + [[ "${output}" != *"migrated layout"* ]] + [[ "${output}" == *"no content change; nothing to commit"* ]] +} + +@test "old and new layout both present: ERROR, no move, no commit" { + use_origin "both" "cli/claude.json" "ASIAGO/cli/claude.json" + before="$(git -C "${CLONE}" rev-parse HEAD)" + echo '{"v": 1}' >"${SRC}" + run_backup GIT_BIN="${STUBS}/fake-git" + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: both old and new layout present for: cli"* ]] + [ ! -e "${STUBS}/git-mv.calls" ] + [ "$(git -C "${CLONE}" rev-parse HEAD)" = "${before}" ] + [ -z "$(git -C "${CLONE}" status --porcelain)" ] + [ "$(cat "${CLONE}/ASIAGO/cli/claude.json")" = '{"v": 0}' ] +} + +@test "the move goes through GIT_BIN, and its failure stops the run" { + use_origin "legacy" "cli/claude.json" + before="$(git -C "${CLONE}" rev-parse HEAD)" + echo '{"v": 1}' >"${SRC}" + run_backup GIT_BIN="${STUBS}/fake-git" + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: git mv cli ASIAGO/cli failed"* ]] + [ "$(cat "${STUBS}/git-mv.calls")" = "-C ${CLONE} mv -- cli ASIAGO/cli" ] + [ "$(git -C "${CLONE}" rev-parse HEAD)" = "${before}" ] + [ -f "${CLONE}/cli/claude.json" ] +}