#!/usr/bin/env bash # Back up Claude Code auto-memory (~/.claude/projects/*/memory) to its own private # repo, gitea vh/claude-memory. The git dir lives at ~/claude-memory.git with # ~/.claude/projects as its work tree, so nothing is added inside ~/.claude and # Claude Code keeps writing memory exactly as before. The exclude rules below # admit only the memory/ folders; transcripts never leave the machine. # # claude-memory-sync commit local changes, rebase onto the remote, push # claude-memory-sync --status last result, and what is waiting to be committed # claude-memory-sync --init set up this machine from the remote (fresh box) # # Run hourly by the claude-memory-sync.timer systemd user unit. The result of every # run goes to $state; `link-dotfiles --check` reports it when it is stale or failed. # # Project folders are named after absolute paths (-home-lkraven-development-X), so # memory lines up across Linux boxes; macOS (/Users/...) gets its own folders. set -euo pipefail repo="$HOME/claude-memory.git" worktree="$HOME/.claude/projects" remote="git@gitea.phasefinal.com:vh/claude-memory.git" state="$HOME/.claude/state/claude-memory-sync.last" g() { git --git-dir="$repo" "$@"; } write_exclude() { cat > "$repo/info/exclude" <<'EOF' # Work tree is ~/.claude/projects. Track ONLY each project's memory/ folder; # transcripts (*.jsonl), tool results and everything else stay local. # Written by claude-memory-sync; edit the script, not this file. /* !/*/ /*/* !/*/memory/ .pytest_cache/ *.tmp .DS_Store EOF } record() { # $1 = ok|fail, $2 = detail mkdir -p "$(dirname "$state")" printf '%s %s %s\n' "$(date +%s)" "$1" "$2" > "$state.$$" && mv -f "$state.$$" "$state" } case "${1:-}" in --status) if [ -f "$state" ]; then read -r ts result detail < "$state" when="$(date -d "@$ts" '+%Y-%m-%d %H%M' 2>/dev/null || date -r "$ts" '+%Y-%m-%d %H%M')" echo "last run: $when — $result${detail:+ ($detail)}" else echo "last run: never" fi [ -d "$repo" ] && echo "uncommitted memory changes: $(g status --porcelain | wc -l | tr -d ' ')" exit 0 ;; --init) [ -e "$repo" ] && { echo "already set up: $repo" >&2; exit 1; } git clone -q --bare "$remote" "$repo" g config core.bare false g config core.worktree "$worktree" g config status.showUntrackedFiles all g config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' g fetch -q origin write_exclude mkdir -p "$worktree" # Index = remote HEAD, work tree untouched: memory already on this box wins on # the first sync, and files only the remote has are restored below. g reset -q g ls-files --deleted -z | xargs -0 -r git --git-dir="$repo" checkout -- echo "set up. Local differences (if any) will be committed on the first sync:" g status --short | head -20 exit 0 ;; "") ;; *) echo "usage: claude-memory-sync [--status|--init]" >&2; exit 2 ;; esac [ -d "$repo" ] || { echo "no memory repo at $repo (run --init)" >&2; exit 2; } write_exclude # One run at a time: a slow push must not overlap the next timer tick. exec 9>"$repo/sync.lock" flock -n 9 || { echo "another sync is running" >&2; exit 0; } g add -A if ! g diff --cached --quiet; then n="$(g diff --cached --name-only | wc -l | tr -d ' ')" g commit -q -m "memory: sync from $(hostname -s) ($n file(s))" fi if ! g fetch -q origin 2>/dev/null; then record fail "fetch failed — remote unreachable or repo missing" exit 1 fi if g rev-parse -q --verify origin/main >/dev/null; then if ! g rebase -q origin/main 2>/dev/null; then g rebase --abort 2>/dev/null || true record fail "rebase conflict with origin/main — resolve by hand" exit 1 fi fi if ! g push -q origin main 2>/dev/null; then record fail "push failed" exit 1 fi record ok "$(g rev-parse --short HEAD)"