#!/usr/bin/env bash
#
# shadow-git.sh  -  per-tool-call checkpoints in a separate git repo.
#
# A shadow git repo, separate from the project's real .git, records a snapshot
# of the working tree after each tool use so a sub-phase can be rolled back
# without polluting the user's semantic commit history.
#
# Why a SHADOW repo (not the real .git): the real git tree holds the user's
# semantic commits  -  clean history, intentional messages. Shadow snapshots
# fire per Edit/Write/MultiEdit/Bash-mutation and would pollute that tree.
# Shadow repos live under `.shadow-git/<task-id>/.git` and never touch the
# project's `.git/refs` or `.git/index`.
#
# Subcommands:
#   init       <task-id> <project-root>
#              Initialize a shadow repo for the task, snapshot the current
#              state as the baseline.
#   snapshot   <task-id> <project-root> "<step-label>"
#              Stage everything (respects .gitignore) + commit with the
#              step label. Idempotent  -  empty diffs produce no commit.
#   list       <task-id>
#              Show all snapshots: <sha>  <iso-date>  <label>
#   restore    <task-id> <project-root> <sha> [--files|--state|--both]
#              Restore to a prior snapshot. --files reverts the worktree;
#              --state would revert agent-state (TODO, out of scope for
#              this commit); --both does both.
#   prune      <task-id> [--older-than-days N]
#              Remove the shadow repo (or just snapshots older than N days).
#   doctor
#              Diagnostic dump: list known shadow repos + size on disk.
#
# Storage:
#   ~/.claude/state/shadow-git/<task-id>/.git/  (the bare repo's working dir
#                                                is the project root, but
#                                                we use --work-tree to keep
#                                                .git out of the project tree)
#
# Exit codes:
#   0 success
#   1 task-id / project-root missing
#   2 unsupported subcommand / bad args
#   3 git operation failed

set -euo pipefail

CMD="${1:-}"
shift || true

SHADOW_ROOT="$HOME/.claude/state/shadow-git"
mkdir -p "$SHADOW_ROOT"

err() { printf 'shadow-git: %s\n' "$1" >&2; }

usage() {
  sed -n '2,40p' "$0" >&2
  exit 2
}

[ -z "$CMD" ] && usage

shadow_dir() { printf '%s/%s' "$SHADOW_ROOT" "$1"; }

# Task ids become path components under $SHADOW_ROOT, so an unvalidated id
# like ".." would make prune's `rm -rf` walk out of the shadow tree and
# delete ~/.claude/state. Allow only [A-Za-z0-9._-]+ and explicitly reject
# ".", "..", empty, and anything containing a slash.
require_task_id() {
  case "${1:-}" in
    "" | . | ..)
      err "invalid task id: '${1:-}' (empty, '.' and '..' are rejected)"
      exit 2
      ;;
    *[!A-Za-z0-9._-]*)
      err "invalid task id: '$1' (allowed characters: A-Z a-z 0-9 . _ -)"
      exit 2
      ;;
  esac
}

# Wrap git so .git lives in $SHADOW_DIR but worktree is the project root.
sg() {
  local sd="$1" wt="$2"; shift 2
  git --git-dir="$sd/.git" --work-tree="$wt" "$@"
}

iso_now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }

# --- Subcommands ------------------------------------------------------------

do_init() {
  local task_id="${1:-}" project_root="${2:-}"
  [ -z "$task_id" ] || [ -z "$project_root" ] && { err "usage: init <task-id> <project-root>"; exit 1; }
  require_task_id "$task_id"
  [ ! -d "$project_root" ] && { err "project root not a directory: $project_root"; exit 1; }
  local sd; sd=$(shadow_dir "$task_id")
  if [ -d "$sd/.git" ]; then
    err "shadow already initialized for $task_id (use 'prune' to reset)"
    return 0
  fi
  mkdir -p "$sd"
  # Bare-ish: .git inside $sd, worktree is the project root.
  git init --quiet "$sd" >/dev/null
  # Ignore rules must live in $GIT_DIR/info/exclude: the work tree is the
  # project root, so a .gitignore placed next to the shadow .git is never
  # read by git. Seed the exclude file with the project's own .gitignore
  # (protects snapshots even if the project file is deleted mid-run) plus
  # forced excludes for build artifacts the project may not ignore.
  mkdir -p "$sd/.git/info"
  if [ -f "$project_root/.gitignore" ]; then
    cat "$project_root/.gitignore" >> "$sd/.git/info/exclude"
  fi
  # `.worktrees/` is forced-excluded too: worktrees live INSIDE the repo at
  # <repo>/.worktrees/<id>/ and each carries a `.git` gitlink, so a blanket
  # `add -A` would record every worktree as a "Subproject commit" and churn a
  # fresh gitlink into every per-tool-call snapshot. The real clone keeps this
  # out of its own index via .git/info/exclude; the shadow repo has its own
  # exclude file and must repeat the rule. This mirrors the traversal-prune
  # contract in phase-0-init.md (never descend into .worktrees on a tree walk).
  cat >> "$sd/.git/info/exclude" <<EOF
node_modules/
Pods/
.build/
DerivedData/
.next/
.gradle/
__pycache__/
.venv/
.worktrees/
*.log
EOF
  # Initial author setup  -  keep separate from user's global git identity to
  # signal "this is the shadow", not real commits.
  sg "$sd" "$project_root" config user.name "shadow-git"
  sg "$sd" "$project_root" config user.email "shadow@multi-agent.local"
  # Baseline snapshot.
  do_snapshot "$task_id" "$project_root" "baseline"
}

do_snapshot() {
  local task_id="${1:-}" project_root="${2:-}" label="${3:-}"
  [ -z "$task_id" ] || [ -z "$project_root" ] || [ -z "$label" ] && {
    err "usage: snapshot <task-id> <project-root> <label>"; exit 1; }
  require_task_id "$task_id"
  local sd; sd=$(shadow_dir "$task_id")
  [ ! -d "$sd/.git" ] && { err "shadow not initialized for $task_id  -  run 'init' first"; exit 1; }
  sg "$sd" "$project_root" add -A >/dev/null 2>&1 || true
  # Skip empty diffs (no-op snapshots).
  if sg "$sd" "$project_root" diff --cached --quiet 2>/dev/null; then
    printf 'shadow-git: no changes to snapshot (%s)\n' "$label" >&2
    return 0
  fi
  local msg="$label"
  sg "$sd" "$project_root" commit --quiet -m "$msg" -m "iso: $(iso_now)" >/dev/null
  local sha
  sha=$(sg "$sd" "$project_root" rev-parse --short HEAD)
  printf '%s\n' "$sha"
}

do_list() {
  local task_id="${1:-}"
  [ -z "$task_id" ] && { err "usage: list <task-id>"; exit 1; }
  require_task_id "$task_id"
  local sd; sd=$(shadow_dir "$task_id")
  [ ! -d "$sd/.git" ] && { err "shadow not initialized for $task_id"; exit 1; }
  # Use the embedded `iso:` line from the commit message body so the listing
  # carries the snapshot timestamp without relying on committer date (which is
  # influenced by git env vars).
  sg "$sd" "$(pwd)" log --pretty=format:'%h  %s' --no-decorate
}

do_restore() {
  local task_id="${1:-}" project_root="${2:-}" sha="${3:-}" mode="${4:---files}"
  [ -z "$task_id" ] || [ -z "$project_root" ] || [ -z "$sha" ] && {
    err "usage: restore <task-id> <project-root> <sha> [--files|--state|--both]"; exit 1; }
  require_task_id "$task_id"
  local sd; sd=$(shadow_dir "$task_id")
  [ ! -d "$sd/.git" ] && { err "shadow not initialized for $task_id"; exit 1; }
  case "$mode" in
    --files|--both)
      sg "$sd" "$project_root" checkout --quiet "$sha" -- . \
        || { err "git checkout failed"; exit 3; }
      # `checkout <sha> -- .` restores tracked content but leaves files that
      # did not exist in the snapshot. Remove both classes of leftovers so a
      # restore is a full rollback, staying strictly inside the work tree:
      #   a) files committed in snapshots AFTER $sha (added between sha..HEAD)
      #   b) files created since the last snapshot (still untracked; `clean`
      #      respects the info/exclude rules, so ignored trees are untouched)
      local extra
      extra=$(sg "$sd" "$project_root" diff --name-only --diff-filter=A "$sha" HEAD 2>/dev/null || true)
      if [ -n "$extra" ]; then
        printf '%s\n' "$extra" | while IFS= read -r f; do
          [ -n "$f" ] && rm -f "$project_root/$f"
        done
      fi
      ( cd "$project_root" && \
        git --git-dir="$sd/.git" --work-tree="$project_root" clean -fdq ) || true
      printf 'shadow-git: restored files to %s\n' "$sha" >&2
      ;;
    --state)
      err "--state mode reserved for agent-state.json snapshot (not yet implemented in this surface)"
      exit 2
      ;;
    *) err "unknown mode: $mode"; exit 2 ;;
  esac
  # When --both, also restore state would happen here in a future commit.
}

do_prune() {
  local task_id="${1:-}"; shift || true
  [ -z "$task_id" ] && { err "usage: prune <task-id> [--older-than-days N]"; exit 1; }
  require_task_id "$task_id"
  local older=""
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --older-than-days) older="$2"; shift 2 ;;
      *) err "unknown flag: $1"; exit 2 ;;
    esac
  done
  local sd; sd=$(shadow_dir "$task_id")
  if [ -z "$older" ]; then
    rm -rf "$sd"
    printf 'shadow-git: pruned shadow repo for %s\n' "$task_id" >&2
  else
    [ ! -d "$sd/.git" ] && { err "no shadow for $task_id"; exit 1; }
    # Retention never touches a real work-tree: this used to run
    # `reset --hard` with --work-tree=$(pwd), silently rewriting whatever
    # directory prune happened to be invoked from, and it kept the OLDEST
    # commit inside the window while discarding every newer one - backwards
    # for a "keep the last N days" policy. Every git call below is
    # git-dir-only plumbing (log/rev-parse/commit-tree/update-ref/gc - no
    # checkout, no reset, no clean); the one --work-tree `sg` still wants
    # points at $sd itself, which prune already owns.
    local branch
    branch=$(sg "$sd" "$sd" symbolic-ref --short HEAD)
    local cutoff_epoch
    cutoff_epoch=$(($(date +%s) - older * 86400))
    # Commits strictly within the window, oldest first: what survives.
    local keep_shas
    keep_shas=$(sg "$sd" "$sd" log --reverse --pretty='%H %ct' "$branch" \
      | awk -v c="$cutoff_epoch" '$2 >= c {print $1}')
    if [ -z "$keep_shas" ]; then
      printf 'shadow-git: no snapshot for %s is within %s day(s); nothing pruned\n' "$task_id" "$older" >&2
      return 0
    fi
    # Replay the surviving commits onto a fresh chain (oldest becomes the new
    # root) so everything older than the cutoff becomes unreachable and
    # gc-able, while every kept snapshot's tree/message/author/committer date
    # is preserved exactly (committer date matters: it's what the NEXT prune
    # run's cutoff math reads, so it must stay the original snapshot time,
    # not "now").
    local prev="" sha tree msg adate cdate aname aemail cname cemail
    while IFS= read -r sha; do
      [ -z "$sha" ] && continue
      tree=$(sg "$sd" "$sd" rev-parse "$sha^{tree}")
      msg=$(sg "$sd" "$sd" log -1 --format=%B "$sha")
      adate=$(sg "$sd" "$sd" log -1 --format=%ad --date=raw "$sha")
      cdate=$(sg "$sd" "$sd" log -1 --format=%cd --date=raw "$sha")
      aname=$(sg "$sd" "$sd" log -1 --format=%an "$sha")
      aemail=$(sg "$sd" "$sd" log -1 --format=%ae "$sha")
      cname=$(sg "$sd" "$sd" log -1 --format=%cn "$sha")
      cemail=$(sg "$sd" "$sd" log -1 --format=%ce "$sha")
      if [ -n "$prev" ]; then
        prev=$(printf '%s' "$msg" \
          | GIT_AUTHOR_NAME="$aname" GIT_AUTHOR_EMAIL="$aemail" GIT_AUTHOR_DATE="$adate" \
            GIT_COMMITTER_NAME="$cname" GIT_COMMITTER_EMAIL="$cemail" GIT_COMMITTER_DATE="$cdate" \
            git --git-dir="$sd/.git" commit-tree "$tree" -p "$prev")
      else
        prev=$(printf '%s' "$msg" \
          | GIT_AUTHOR_NAME="$aname" GIT_AUTHOR_EMAIL="$aemail" GIT_AUTHOR_DATE="$adate" \
            GIT_COMMITTER_NAME="$cname" GIT_COMMITTER_EMAIL="$cemail" GIT_COMMITTER_DATE="$cdate" \
            git --git-dir="$sd/.git" commit-tree "$tree")
      fi
    done <<<"$keep_shas"
    git --git-dir="$sd/.git" update-ref "refs/heads/$branch" "$prev"
    sg "$sd" "$sd" gc --prune=now --quiet 2>/dev/null || true
    printf 'shadow-git: pruned snapshots older than %s day(s) for %s (kept %s)\n' \
      "$older" "$task_id" "$(printf '%s\n' "$keep_shas" | wc -l | tr -d ' ')" >&2
  fi
}

do_doctor() {
  printf 'shadow-git doctor\n'
  printf '  storage:  %s\n' "$SHADOW_ROOT"
  if [ ! -d "$SHADOW_ROOT" ]; then
    printf '  (empty)\n'
    return 0
  fi
  for d in "$SHADOW_ROOT"/*/; do
    [ -d "$d" ] || continue
    local task_id; task_id=$(basename "$d")
    local size; size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
    local count
    count=$(git --git-dir="$d/.git" log --oneline 2>/dev/null | wc -l | tr -d ' ' || echo 0)
    printf '    %-20s %s  %s snapshots\n' "$task_id" "$size" "$count"
  done
}

# --- Dispatch ---------------------------------------------------------------

case "$CMD" in
  init)     do_init     "$@" ;;
  snapshot) do_snapshot "$@" ;;
  list)     do_list     "$@" ;;
  restore)  do_restore  "$@" ;;
  prune)    do_prune    "$@" ;;
  doctor)   do_doctor   "$@" ;;
  --help|-h) usage ;;
  *) err "unknown subcommand '$CMD'"; usage ;;
esac
