#!/usr/bin/env bash
#
# worktree-finalize.sh  -  salvage a finished task's artefacts, then remove its
# worktree. Called by Phase 6 once the PR is open.
#
# The problem it solves: a task's worktree is dead weight the moment its PR
# exists, but people forget to remove it, so `.worktrees/` accumulates full
# checkouts. Removing it at PR-open is safe ONLY if the files Phase 7 and
# `:resume` read are moved somewhere that outlives it  -  hence salvage first.
#
# What it deliberately does NOT do:
#   * NO `git checkout` of the task branch. `git worktree remove` leaves the
#     branch as an ordinary local branch, so nothing is lost and the user's own
#     HEAD and uncommitted work are untouched. Phase 5's flow checks the branch
#     out on purpose (it is a test handoff); doing that here would move the
#     user's HEAD out from under them mid-task, which is the one thing they
#     asked not to happen.
#   * NO `git branch -D`. The branch is the deliverable.
#   * NO `rm -rf`. `git worktree remove` refuses on a dirty tree, which is a
#     safety feature; `rm -rf` would silently destroy uncommitted work.
#
# Usage:
#   worktree-finalize.sh --worktree <path> --project-root <path>
#                        --task-id <id> [--project <slug>] [--branch <name>]
#                        [--logs-root <dir>] [--dry-run] [--json]
#
# Exit codes:
#   0  removed (or dry-run that would remove)
#   3  SKIPPED by a safety precondition  -  not an error, reason is reported
#   1  usage / hard error
set -uo pipefail

WORKTREE=""; PROJECT_ROOT=""; TASK_ID=""; PROJECT=""; BRANCH=""
LOGS_ROOT="${HOME}/.claude/logs/multi-agent"
DRY=0; JSON=0

while [ $# -gt 0 ]; do
  case "$1" in
    --worktree) WORKTREE="${2:-}"; shift 2 ;;
    --project-root) PROJECT_ROOT="${2:-}"; shift 2 ;;
    --task-id) TASK_ID="${2:-}"; shift 2 ;;
    --project) PROJECT="${2:-}"; shift 2 ;;
    --branch) BRANCH="${2:-}"; shift 2 ;;
    --logs-root) LOGS_ROOT="${2:-}"; shift 2 ;;
    --dry-run) DRY=1; shift ;;
    --json) JSON=1; shift ;;
    *) echo "usage: worktree-finalize.sh --worktree <p> --project-root <p> --task-id <id> [--project <s>] [--branch <b>] [--logs-root <d>] [--dry-run] [--json]" >&2; exit 1 ;;
  esac
done

[ -n "$WORKTREE" ] && [ -n "$PROJECT_ROOT" ] && [ -n "$TASK_ID" ] || {
  echo "ERR: --worktree, --project-root and --task-id are required" >&2; exit 1; }

SALVAGED=""
REMOVED=false
REASON=""

emit() {
  if [ "$JSON" = "1" ]; then
    # Built by node, not printf: a skip reason embeds a filename straight from
    # `git status --porcelain`, which quotes unusual names, so a file like
    # `"my file".swift` produced invalid JSON and the caller's jq step errored
    # instead of recording a safe skip.
    REMOVED="$REMOVED" REASON="$REASON" BRANCH="$BRANCH" DEST="${DEST:-}" \
    SALVAGED_RAW="$SALVAGED" DRYF="$([ "$DRY" = 1 ] && echo true || echo false)" \
    node -e '
      const s = process.env.SALVAGED_RAW || "";
      const salvaged = s ? s.split(",").map((x) => x.replace(/^"|"$/g, "")) : [];
      process.stdout.write(JSON.stringify({
        removed: process.env.REMOVED === "true",
        reason: process.env.REASON || "",
        salvaged,
        branch: process.env.BRANCH || "",
        artifactsPath: process.env.DEST || "",
        dryRun: process.env.DRYF === "true",
      }) + "\n");
    ' 2>/dev/null || printf '{"removed":false,"reason":"emit failed","salvaged":[],"branch":"","artifactsPath":"","dryRun":false}\n'
  else
    if [ "$REMOVED" = "true" ]; then
      echo "worktree-finalize: removed $WORKTREE"
      echo "  branch kept locally: ${BRANCH:-<unknown>} (no checkout performed)"
      [ -n "${DEST:-}" ] && echo "  artefacts: $DEST"
    else
      echo "worktree-finalize: SKIPPED  -  $REASON"
    fi
  fi
}

skip() { REASON="$1"; emit; exit 3; }

# --- preference gate -------------------------------------------------------
# Read here rather than in the caller so EVERY caller honours it. Declared in
# prefs.schema.json as settings.worktreeAutoRemoveOnPr; a preference that is
# declared, migrated and documented but never consulted is worse than no
# preference at all, because turning it off appears to work and does nothing.
PREFS="${PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
if [ -f "$PREFS" ] && command -v node >/dev/null 2>&1; then
  enabled="$(node -e '
    const fs=require("fs");
    try {
      const s=(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).global||{}).settings||{};
      process.stdout.write(s.worktreeAutoRemoveOnPr === false ? "false" : "true");
    } catch { process.stdout.write("true"); }
  ' "$PREFS" 2>/dev/null || echo true)"
  if [ "$enabled" = "false" ]; then
    REASON="disabled by prefs.global.settings.worktreeAutoRemoveOnPr"
    emit; exit 3
  fi
fi

# --- preconditions ---------------------------------------------------------

# `--local` mode has no worktree: worktreePath equals the project root. Removing
# it would delete the user's checkout.
rp_wt="$(cd "$WORKTREE" 2>/dev/null && pwd -P || echo "")"
rp_pr="$(cd "$PROJECT_ROOT" 2>/dev/null && pwd -P || echo "")"
[ -n "$rp_wt" ] || skip "worktree path does not exist: $WORKTREE"
[ -n "$rp_pr" ] || skip "project root does not exist: $PROJECT_ROOT"
[ "$rp_wt" != "$rp_pr" ] || skip "worktree equals project root (--local mode)  -  nothing to remove"

# Refuse if the caller is standing inside the tree being removed. A shell left on
# a deleted inode is worse than a leftover directory, and Phase 6 legitimately
# cd's into the worktree earlier, so this is a real case rather than a theoretical
# one. The caller must cd out first.
cwd_rp="$(pwd -P)"
case "$cwd_rp/" in
  "$rp_wt"/*) skip "current directory is inside the worktree  -  cd to the project root first" ;;
esac

# Must be a worktree git actually knows about, so a mistyped path cannot delete an
# unrelated directory.
if ! git -C "$rp_pr" worktree list --porcelain 2>/dev/null \
     | awk '/^worktree /{print substr($0,10)}' \
     | while IFS= read -r w; do (cd "$w" 2>/dev/null && pwd -P); done \
     | grep -qxF "$rp_wt"; then
  skip "not a registered worktree of $rp_pr"
fi

[ -n "$BRANCH" ] || BRANCH="$(git -C "$rp_wt" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"

# A detached worktree resolves BRANCH to the literal "HEAD", which then builds
# refs/remotes/origin/HEAD  -  the remote's default-branch symref, unrelated to this
# work. The pushed-HEAD check would pass against the wrong ref and the
# "branch is kept" guarantee would be vacuous, because no branch ref exists.
[ "$BRANCH" != "HEAD" ] || skip "detached HEAD  -  no branch would survive the removal, refusing to finalize"

# Uncommitted work is never discarded. Phase 5 states the same rule; this is the
# second place it has to hold, because here there is no WIP-commit step to fall
# back on  -  the PR is already open and a surprise WIP commit would not be in it.
#
# The pipeline's OWN artefacts are excluded from that judgement. They live inside
# the worktree and are untracked, so a raw `status --porcelain` is never empty at
# PR-open and the removal would never fire  -  the feature would look implemented
# and do nothing. They are also exactly the files salvaged below, so losing them
# from the worktree is the intent rather than a risk.
#
# Only these specific paths are forgiven. `--untracked-files=no` would have been
# one character cheaper and wrong: a source file the dev created but never
# `git add`ed is invisible to it, and that file would be destroyed silently.
# Matched on NAME ALONE this forgave a project's own tracked files: a repo that
# tracks `.build.log` or `.pipeline/` had its uncommitted modification forgiven
# here and the file deleted below, leaving a kept worktree with ` D .build.log`
# and the only copy of the change in a salvage dir nobody was told about.
# So the carve-out applies only to paths git reports as UNTRACKED (`??`), and the
# alternatives are end-anchored  -  unanchored, `.build.log.old` was forgiven here
# but not deletable below, which made every removal refuse forever.
ARTIFACTS_RE='^(agent-state\.json|phase-tracker\.json|triage-output\.json|\.review-diff\.txt|\.build\.log|\.test\.log)$|^\.pipeline/'
dirty="$(git -C "$rp_wt" status --porcelain 2>/dev/null | awk '
  {
    st = substr($0, 1, 2); path = substr($0, 4);
    if (st == "??") { untracked = 1 } else { untracked = 0 }
    print (untracked ? "U" : "T") "\t" path;
  }' | while IFS="$(printf '\t')" read -r kind path; do
    if [ "$kind" = "U" ] && printf '%s' "$path" | grep -Eq "$ARTIFACTS_RE"; then
      continue   # our own untracked artefact
    fi
    printf '%s\n' "$path"
  done)"
if [ -n "$dirty" ]; then
  first="$(printf '%s\n' "$dirty" | head -1)"
  skip "worktree has uncommitted changes (e.g. $first)  -  refusing to remove"
fi

# HEAD must be on the remote. Removing a worktree whose commits exist nowhere else
# is not cleanup, it is data loss: the branch ref survives locally, but a later
# `branch -D` or a fresh clone loses the work.
# --verify --quiet, not a bare rev-parse: on a missing ref a bare rev-parse
# prints the ref NAME to stdout and errors, so `2>/dev/null` leaves the name in
# the variable and it gets compared against a sha  -  producing the nonsense
# "HEAD differs from remote (refs/rem...)" instead of "no remote counterpart".
head_sha="$(git -C "$rp_wt" rev-parse --verify --quiet HEAD || echo "")"
upstream_sha="$(git -C "$rp_wt" rev-parse --verify --quiet '@{u}' || echo "")"
if [ -z "$upstream_sha" ] && [ -n "$BRANCH" ]; then
  # No upstream configured: accept a remote ref that already points at HEAD.
  upstream_sha="$(git -C "$rp_wt" rev-parse --verify --quiet "refs/remotes/origin/$BRANCH" || echo "")"
fi
[ -n "$upstream_sha" ] || skip "branch has no remote counterpart  -  push before finalizing"
[ "$head_sha" = "$upstream_sha" ] || skip "HEAD ($(echo "$head_sha" | cut -c1-8)) differs from remote ($(echo "$upstream_sha" | cut -c1-8))  -  push before finalizing"

# --- salvage ---------------------------------------------------------------
# Everything Phase 7, :resume, :status and :log read out of the worktree. Each is
# copied only if present; a task that never reached Phase 4 has no triage output
# and that is not an error.
DEST="$LOGS_ROOT/${PROJECT:+$PROJECT/}$TASK_ID/artifacts"
LOGS_TASK_DIR="$LOGS_ROOT/${PROJECT:+$PROJECT/}$TASK_ID"

copy_one() {
  src="$rp_wt/$1"
  [ -e "$src" ] || return 0
  if [ "$DRY" = "1" ]; then
    SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
    return 0
  fi
  mkdir -p "$DEST/$(dirname "$1")" 2>/dev/null
  cp -R "$src" "$DEST/$1" 2>/dev/null && SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
}

# agent-state.json and the tracker state file are never written INSIDE the
# worktree checkout - multi-repo-pipeline.sh/phase-tracker.sh write both
# straight to $LOGS_TASK_DIR. copy_one() sourcing from $rp_wt always missed
# them (src never existed → silent no-op), so this salvage - and the
# worktreeRemovedAt stamp below, gated on the salvaged copy existing - never
# actually fired for either file.
copy_from_logs() {
  src="$LOGS_TASK_DIR/$1"
  [ -e "$src" ] || return 0
  if [ "$DRY" = "1" ]; then
    SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
    return 0
  fi
  mkdir -p "$DEST/$(dirname "$1")" 2>/dev/null
  cp -R "$src" "$DEST/$1" 2>/dev/null && SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
}

copy_from_logs agent-state.json
copy_from_logs tracker-state.json

for a in triage-output.json \
         .review-diff.txt .build.log .test.log .pipeline; do
  copy_one "$a"
done

# --- remove ----------------------------------------------------------------
if [ "$DRY" = "1" ]; then
  REMOVED=true; REASON="dry-run"; emit; exit 0
fi

# The artefacts are now safely in the log dir, and `git worktree remove` refuses
# on untracked files just as it does on modified ones. Drop exactly the paths that
# were salvaged  -  nothing else  -  so the removal can proceed on a genuinely clean
# tree. This is what keeps `--force` off the table: a refusal after this point
# means unexpected dirt, and forcing past unexpected dirt is how work gets lost.
DROPPED=""
for a in triage-output.json \
         .review-diff.txt .build.log .test.log .pipeline; do
  [ -e "$rp_wt/$a" ] || continue
  case "$a" in
    */*|"") continue ;;   # refuse anything that could escape the worktree
  esac
  # Tracked means it belongs to the project, not to us  -  even when the name
  # collides with one of ours. Deleting it destroyed a real file in testing.
  if git -C "$rp_wt" ls-files --error-unmatch -- "$a" >/dev/null 2>&1; then
    continue
  fi
  # ${var:?} so an empty rp_wt can never expand to `rm -rf /<name>`.
  rm -rf -- "${rp_wt:?}/$a"
  DROPPED="${DROPPED:+$DROPPED }$a"
done

# Put them back if the removal is refused. The artefacts were dropped BEFORE
# knowing `git worktree remove` would succeed, so on the refusal path (submodule,
# lock, permissions) a still-live run lost its agent-state.json and
# phase-tracker.json from the location every consumer prefers  -  and a later
# write-state.mjs merge against that path would recreate the file holding only the
# patch, corrupting the run record.
restore_dropped() {
  [ -n "$DROPPED" ] || return 0
  for a in $DROPPED; do
    [ -e "$DEST/$a" ] || continue
    cp -R "$DEST/$a" "$rp_wt/$a" 2>/dev/null || true
  done
}

if git -C "$rp_pr" worktree remove "$rp_wt" 2>/dev/null; then
  git -C "$rp_pr" worktree prune 2>/dev/null || true
  REMOVED=true; REASON="removed after PR"

  # Stamp the outcome into the SALVAGED agent-state.json here, rather than leaving
  # the caller to write it. In single-repo worktree mode the caller's $STATE_FILE
  # is the copy that just went away with the worktree, so a write there fails and
  # `worktreeRemovedAt` / `artifactsPath` land nowhere  -  which then makes Phase 7
  # read a dead path, fall back to the removed worktree, and silently skip the
  # triage-memory ingest. Exactly the [ -f ]-guarded degradation this step exists
  # to prevent.
  if [ -f "$DEST/agent-state.json" ] && command -v node >/dev/null 2>&1; then
    DEST="$DEST" BRANCH="$BRANCH" node -e '
      const fs = require("fs");
      const p = process.env.DEST + "/agent-state.json";
      try {
        const s = JSON.parse(fs.readFileSync(p, "utf8"));
        s.worktreeRemovedAt = new Date().toISOString();
        s.artifactsPath = process.env.DEST;
        s.worktreePath = null;
        fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
      } catch { /* leave the salvaged copy untouched rather than corrupt it */ }
    ' 2>/dev/null || true
  fi

  emit
  exit 0
fi

restore_dropped

# `worktree remove` refused. Do NOT escalate to --force: the clean-tree check
# above already passed, so a refusal here means something unexpected (a lock, a
# submodule, a permission problem) and forcing would destroy whatever it is.
REASON="git worktree remove refused  -  left in place (artefacts already salvaged)"
emit
exit 3
