#!/usr/bin/env bash
# repo-archive-wip.sh — preserve every work carrier in a Git repo, then reclaim disk.
#
# Implements the `wip-triage` skill's preservation contract and deletion gate for
# ANY repository. Preservation is content-addressed: a carrier is only deleted
# once its bytes are proven to exist in the object store (or archived out).
#
# Modes:
#   audit    read-only inventory
#   preserve create durable archive/* refs + snapshot uncommitted state
#   junk     delete regenerable build output (no preservation needed)
#   retire   remove registered worktrees, after verifying archive refs resolve
#   orphans  hash unregistered dirs; archive only content git lacks; delete rest
#
# Safety: never deletes the primary checkout, never uses --force, never removes
# a carrier whose content is not provably preserved.
set -uo pipefail

MODE="${1:-audit}"
REPO="${2:-$PWD}"

JUNK_NAMES=(node_modules dist .astro .wrangler .turbo .next .vite .nuxt build
            playwright-report test-results coverage .shots .pnpm-store
            __pycache__ .pytest_cache .mypy_cache target .gradle)
SECRET_GLOBS=('.dev.vars' '.env' '.env.*' '*.pem' '*.key' '*token*.lock' '*.p12')

die() { echo "FATAL: $*" >&2; exit 3; }

REPO=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null) || die "not a git repo: ${2:-$PWD}"
GD=$(git -C "$REPO" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || die "no git dir"
PRIMARY=$(git --git-dir="$GD" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')
STAMP=$(date +%Y%m%d-%H%M%S)
ARCHIVE_DIR="${ARCHIVE_DIR:-$HOME/wip-archives/$(basename "$REPO")}"
# Worktree copies also live outside the repo; override for non-default layouts.
IFS=':' read -r -a ORPHAN_ROOTS <<< "${ORPHAN_ROOTS:-$REPO/.worktrees:$REPO/.claude/worktrees:$REPO/.opencode/worktrees:$HOME/wt:$HOME/.wt:$HOME/.harness/v2/worktrees}"
WORK=$(mktemp -d -t repo-archive-wip.XXXXXX) || die "cannot create work dir"
trap 'rm -rf "$WORK"' EXIT

g() { git --git-dir="$GD" "$@"; }
slug() { printf '%s' "$1" | sed "s|^$HOME/||; s|^/||; s|[/ ]|-|g; s|[^A-Za-z0-9._-]||g"; }
worktrees() { g worktree list --porcelain | awk '/^worktree /{print substr($0,10)}'; }

is_secret() {
  local b; b=$(basename "$1"); local p
  for p in "${SECRET_GLOBS[@]}"; do [[ "$b" == $p ]] && return 0; done
  return 1
}

find_junk() {  # $1 = root
  local args=() n
  for n in "${JUNK_NAMES[@]}"; do args+=(-name "$n" -o); done
  unset 'args[${#args[@]}-1]'
  find "$1" \( "${args[@]}" \) -type d -prune -print0 2>/dev/null
}

baseline() {
  local url head
  url=$(g config --get remote.origin.url 2>/dev/null || echo "(no remote)")
  head=$(g symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null || echo "(unset)")
  echo "repo=$REPO"; echo "remote=$url"; echo "default=$head"
  if [ "$url" != "(no remote)" ] && g fetch --prune origin >/dev/null 2>&1; then
    echo "fetch=ok"
  else
    echo "fetch=unavailable (offline or no remote)"
  fi
}

# --- preservation -------------------------------------------------------------
# A detached HEAD is the one carrier whose commits nothing else references.
preserve_detached() {
  local w sha ref n=0
  while read -r w; do
    [ -d "$w" ] || continue
    git -C "$w" symbolic-ref -q HEAD >/dev/null 2>&1 && continue
    sha=$(git -C "$w" rev-parse HEAD 2>/dev/null) || continue
    [ -n "$sha" ] || { echo "HOLD	unreadable-head	$w"; continue; }
    ref="refs/heads/archive/wt-$(slug "$w")"
    g show-ref --verify --quiet "$ref" || g update-ref "$ref" "$sha"
    echo "detached	$w	$sha	${ref#refs/heads/}"; n=$((n+1))
  done < <(worktrees)
  echo "detached_pinned=$n" >&2
}

# Tags preserve commits only, so staged/dirty/untracked state needs its own tree.
preserve_dirty() {
  local w n idx tree snap ref f
  while read -r w; do
    [ -d "$w" ] || continue
    n=$(git -C "$w" status --porcelain 2>/dev/null | grep -c .)
    [ "${n:-0}" -gt 0 ] || continue
    idx="$WORK/idx"; rm -f "$idx"
    GIT_INDEX_FILE="$idx" git -C "$w" read-tree HEAD 2>/dev/null || { rm -f "$idx"; echo "HOLD	no-head	$w"; continue; }
    while IFS= read -r f; do
      is_secret "$f" && { echo "skip-secret	$w	$f"; continue; }
      GIT_INDEX_FILE="$idx" git -C "$w" add --force -- "$f" 2>/dev/null \
        || GIT_INDEX_FILE="$idx" git -C "$w" rm --cached --ignore-unmatch -q -- "$f" 2>/dev/null
    done < <(git -C "$w" status --porcelain | sed 's/^...//; s/^"//; s/"$//')
    tree=$(GIT_INDEX_FILE="$idx" git -C "$w" write-tree 2>/dev/null); rm -f "$idx"
    [ -n "$tree" ] || { echo "HOLD	write-tree	$w"; continue; }
    snap=$(git -C "$w" commit-tree "$tree" -p HEAD -m "wip snapshot: $w" 2>/dev/null) || { echo "HOLD	commit-tree	$w"; continue; }
    ref="refs/tags/archive/wip-$(slug "$w")"
    g update-ref "$ref" "$snap"
    echo "dirty	$w	$snap	${ref#refs/tags/}"
  done < <(worktrees)
}

# Stash entries live in an expiring reflog — copy each to a permanent ref.
preserve_stashes() {
  local i=0 sha
  while read -r sha; do
    g update-ref "refs/tags/archive/stash-$(printf '%03d' "$i")" "$sha"
    echo "stash	stash@{$i}	$sha	archive/stash-$(printf '%03d' "$i")"
    i=$((i+1))
  done < <(g rev-list -g refs/stash 2>/dev/null)
  echo "stashes_pinned=$i" >&2
}

verify_refs() {
  local bad=0 r total=0
  while read -r r; do
    total=$((total+1))
    g rev-parse --verify --quiet "$r^{commit}" >/dev/null || { echo "UNRESOLVED $r" >&2; bad=$((bad+1)); }
  done < <(g for-each-ref --format='%(refname)' refs/heads/archive refs/tags/archive)
  echo "archive_refs=$total unresolved=$bad"
  [ "$bad" -eq 0 ]
}

# --- orphans ------------------------------------------------------------------
# Directories git no longer registers. Their bytes are usually duplicates of
# objects already stored; only genuinely unique content is worth keeping.
orphan_dirs() {
  local d root reg
  reg="$WORK/reg"; worktrees > "$reg"
  for root in "${ORPHAN_ROOTS[@]}"; do
    [ -d "$root" ] || continue
    for d in "$root"/*/; do
      [ -d "$d" ] || continue
      d="${d%/}"
      # Only claim a directory that belongs to THIS repo's object store.
      [ "$(git -C "$d" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" = "$GD" ] \
        || [ ! -e "$d/.git" ] || continue
      grep -qxF "$d" "$reg" || echo "$d"
    done
  done
  for d in "$REPO"/.wt-*/; do
    [ -d "$d" ] || continue
    d="${d%/}"
    grep -qxF "$d" "$reg" || echo "$d"
  done
}

do_orphans() {
  local dirs; dirs="$WORK/odirs"; orphan_dirs > "$dirs"
  local count; count=$(grep -c . "$dirs" || true)
  echo "orphan_dirs=$count"
  [ "${count:-0}" -gt 0 ] || return 0
  local files="$WORK/ofiles"
  # shellcheck disable=SC2046
  find $(tr '\n' ' ' < "$dirs") -type f ! -path '*/node_modules/*' ! -path '*/.git/*' 2>/dev/null > "$files"
  echo "orphan_files=$(grep -c . "$files" || true)"
  local uniq="$WORK/uniq"
  python3 - "$REPO" "$files" "$uniq" <<'PY'
import hashlib,subprocess,sys,os
repo,flist,out=sys.argv[1],sys.argv[2],sys.argv[3]
files=[l.rstrip("\n") for l in open(flist) if l.strip()]
shas={}
for p in files:
    try: d=open(p,'rb').read()
    except Exception: continue
    h=hashlib.sha1(); h.update(b"blob %d\0"%len(d)); h.update(d)
    shas.setdefault(h.hexdigest(),[]).append(p)
r=subprocess.run(["git","-C",repo,"cat-file","--batch-check","--buffer"],
                 input="\n".join(shas)+"\n",capture_output=True,text=True)
missing=[l.split()[0] for l in r.stdout.splitlines() if "missing" in l]
u=sorted({p for s in missing for p in shas[s]})
open(out,"w").write("\n".join(u)+("\n" if u else ""))
print(f"distinct_blobs={len(shas)} missing_blobs={len(missing)} unique_files={len(u)}")
PY
  local n; n=$(grep -c . "$uniq" 2>/dev/null || echo 0)
  if [ "$n" -gt 0 ]; then
    mkdir -p "$ARCHIVE_DIR"
    : > "$uniq.safe"
    while IFS= read -r f; do
      is_secret "$f" && { echo "skip-secret	$f"; continue; }
      printf '%s\n' "$f" >> "$uniq.safe"
    done < "$uniq"
    tar -czf "$ARCHIVE_DIR/unique-orphan-files-$STAMP.tar.gz" -T "$uniq.safe" 2>/dev/null
    echo "archived=$(tar -tzf "$ARCHIVE_DIR/unique-orphan-files-$STAMP.tar.gz" | grep -c .) -> $ARCHIVE_DIR/unique-orphan-files-$STAMP.tar.gz"
  else
    echo "archived=0 (every orphan byte already in the object store)"
  fi
  [ "$MODE" = orphans ] || return 0
  xargs -a "$dirs" -d '\n' -P 8 rm -rf 2>/dev/null
  echo "orphan_dirs_removed=$count"
}

# --- retire -------------------------------------------------------------------
retire() {
  local w removed=0 held=0
  while read -r w; do
    [ "$w" = "$PRIMARY" ] && { held=$((held+1)); continue; }
    [ -d "$w" ] || continue
    [ -f "$GD/worktrees/$(basename "$w")/locked" ] && { echo "HELD	locked	$w"; held=$((held+1)); continue; }
    if ! git -C "$w" symbolic-ref -q HEAD >/dev/null 2>&1 \
       && ! git -C "$w" rev-parse HEAD >/dev/null 2>&1; then
      echo "HELD	unreadable-head	$w"; held=$((held+1)); continue
    fi
    rm -rf "$w" && removed=$((removed+1)) || echo "FAILED-REMOVE	$w"
  done < <(worktrees)
  g worktree prune
  echo "removed=$removed held=$held"
}

# --- main ---------------------------------------------------------------------
baseline
case "$MODE" in
  audit)
    echo "worktrees=$(worktrees | grep -c .)"
    echo "branches=$(g for-each-ref --format=x refs/heads | grep -c .)"
    echo "stashes=$(g rev-list -g refs/stash 2>/dev/null | grep -c .)"
    echo "dirty_worktrees=$(while read -r w; do [ -d "$w" ] && [ -n "$(git -C "$w" status --porcelain 2>/dev/null)" ] && echo x; done < <(worktrees) | grep -c .)"
    echo "orphan_dirs=$(orphan_dirs | grep -c .)"
    echo "junk_dirs=$(find_junk "$REPO" | tr -cd '\0' | wc -c)"
    ;;
  preserve)
    mkdir -p "$ARCHIVE_DIR"
    { preserve_detached; preserve_dirty; preserve_stashes; } | tee "$ARCHIVE_DIR/manifest-$STAMP.tsv"
    verify_refs || die "preservation incomplete — do not retire"
    echo "manifest=$ARCHIVE_DIR/manifest-$STAMP.tsv"
    ;;
  junk)
    find_junk "$REPO" | xargs -0 -P 8 rm -rf 2>/dev/null
    echo "junk_removed=ok"
    ;;
  orphans) do_orphans ;;
  retire)
    verify_refs || die "archive refs unresolved — refusing to retire"
    retire
    echo "--- post-removal verification ---"
    verify_refs || die "refs broke during removal"
    ;;
  *) die "usage: $0 {audit|preserve|junk|orphans|retire} [repo]" ;;
esac
