#!/usr/bin/env bash
# gstack-ff-update <dir> <rev> — fast-forward the gstack install at <dir> to
# <rev> without ever leaving conflict markers in it and without losing a
# local edit. The only code that moves an install's tree: the session-start
# auto-updater, /gstack-upgrade Step 4 and the held-install recovery commands
# (docs/troubleshooting.md#auto-update-held-install-recovery) call it.
#
# Self-contained on purpose: the recovery commands fetch this one file from
# the new revision (git show FETCH_HEAD:bin/gstack-ff-update) and run it in a
# private temp directory, so it sources nothing. The lock and the
# flatten-and-strip sanitizer are inlined; test/gstack-ff-update.test.ts pins
# them to their twins in bin/gstack-session-update.
#
# Inside an install-scoped lock (<git-common-dir>/gstack-ff.lock):
#   1. The working tree is captured as-is through a private index (tracked
#      edits, unmerged and marker-laden files), and the real index's staged
#      state separately; the real index is never touched while building them.
#   2. Edits to files the update does not change ride over it, as
#      `merge --ff-only --autostash` did. Regenerable footprint files
#      (SKILL.md, */SKILL.md, */sections/*.md, rewritten by prefix mode and
#      gbrain blocks) ride over when the update leaves them alone and are
#      replaced by the update's version otherwise; callers re-render them.
#   3. Any other edit that conflicts with the update, any staged change and
#      any unmerged file is shelved into a rescue commit
#      refs/gstack/rescue/<oid>: tree = the working tree as captured, first
#      parent = the pre-update HEAD (so `diff <ref>^ <ref>` is only the
#      person's delta), second parent = the staged state. It is stash-shaped,
#      so `git stash show -p <oid>` reads it too.
#   4. The tree moves with a two-way `read-tree -m -u <captured> <result>` on
#      the private index, so git refuses to overwrite a file changed after the
#      capture or an untracked file in the way; HEAD moves compare-and-swap.
#
# stdout, one line:   already <head> | ok <old> <new> | rescued <old> <new> <ref> unmerged=<0|1>
# refusals (stdout):  <reason>: <detail>, and nothing in the install changed
#   exit 2  usage: / target-unresolvable: / not-a-checkout:
#   exit 3  autostash-unrecoverable: (edits kept where they are)
#   exit 4  not-fast-forward:
#   exit 5  index-unreadable:
#   exit 75 ff-busy: (another update holds this install's lock)
# A rescue also prints the inspect, export and delete commands on stderr.

set +e
export LC_ALL=C GIT_TERMINAL_PROMPT=0

# Twin: ff_flatten in bin/gstack-session-update (test-pinned). One line, no
# control characters or escape sequences, at most <max> bytes.
ff_flatten() {
  _ff_esc=$(printf '\033')
  printf '%s' "$1" | tr '\n\r\t' '   ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}"
}
ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; }

[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision>"
DIR=$1 REV=$2
cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR"
DIR=$(pwd -P)
g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
[ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \
  || refuse 2 not-a-checkout "$DIR is not the top of a git checkout"
TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR"
COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
  || COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \
  || refuse 2 not-a-checkout "cannot locate the git directory of $DIR"
INDEX=$(g rev-parse --path-format=absolute --git-path index 2>/dev/null) \
  || INDEX="$(cd -- "$(dirname "$(g rev-parse --git-path index)")" && pwd -P)/index"

# ── Install-scoped lock (twin of bin/gstack-session-update's lock) ──
# Staleness: a dead holder PID, or a heartbeat older than the TTL (a live
# holder freshens it every GSTACK_FF_HEARTBEAT_SECONDS, default 300 s).
LOCK_DIR="$COMMON/gstack-ff.lock"
LOCK_TTL_MINUTES=30
MYPID=$$
OWN_INDEX_LOCK="" TMP="" HB_PID=""
lock_is_expired() {
  _hb="$LOCK_DIR/pid"
  [ -f "$_hb" ] || _hb="$LOCK_DIR"
  [ -n "$(find "$_hb" -maxdepth 0 -mmin +$LOCK_TTL_MINUTES 2>/dev/null)" ]
}
lock_try() {
  mkdir "$LOCK_DIR" 2>/dev/null && return 0
  _lp=$(cat "$LOCK_DIR/pid" 2>/dev/null)
  if lock_is_expired || { [ "${_lp:-0}" -gt 0 ] 2>/dev/null && ! kill -0 "$_lp" 2>/dev/null; }; then
    mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || return 1
    rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null
    mkdir "$LOCK_DIR" 2>/dev/null && return 0
  fi
  return 1
}
cleanup_ff_update() {
  for heartbeat in ${HB_PID:-$(jobs -p)}; do
    kill "$heartbeat" 2>/dev/null
    wait "$heartbeat" 2>/dev/null
  done
  [ -n "$OWN_INDEX_LOCK" ] && rm -f "$OWN_INDEX_LOCK" 2>/dev/null
  [ -n "$TMP" ] && rm -rf "$TMP" 2>/dev/null
  [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null
}
_wait=${GSTACK_FF_LOCK_WAIT:-15}
case "$_wait" in ''|*[!0-9]*) _wait=15 ;; esac
until lock_try; do
  [ "$_wait" -gt 0 ] || refuse 75 ff-busy "another gstack update is moving $DIR (lock $LOCK_DIR held by pid $(cat "$LOCK_DIR/pid" 2>/dev/null || echo unknown)); nothing was changed, retry in a minute"
  sleep 1; _wait=$((_wait - 1))
done
echo "$MYPID" > "$LOCK_DIR/pid" 2>/dev/null
trap cleanup_ff_update EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
_beat=${GSTACK_FF_HEARTBEAT_SECONDS:-300}
case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac
(
  cleanup_heartbeat() {
    for sleeper in $(jobs -p); do
      kill "$sleeper" 2>/dev/null
      wait "$sleeper" 2>/dev/null
    done
  }
  trap cleanup_heartbeat EXIT
  trap 'exit 0' HUP INT TERM
  while :; do
    sleep "$_beat" &
    wait "$!" || exit 0
    [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0
    touch "$LOCK_DIR/pid" 2>/dev/null
  done
) &
HB_PID=$!

# ── Inside the lock ──
HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed"
g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed"
UNMERGED=$(tr '\0' '\n' < "$TMP/unmerged.raw")
# Nothing to move: an up-to-date install keeps its edits and staged state as they are.
if [ "$HEAD_OID" = "$TARGET" ] && [ -z "$UNMERGED" ]; then echo "already $TARGET"; exit 0; fi
g merge-base --is-ancestor "$HEAD_OID" "$TARGET" 2>/dev/null \
  || refuse 4 not-fast-forward "$TARGET is not a fast-forward of HEAD $HEAD_OID in $DIR (local commits or a diverged history); nothing was changed"

# Exclusive real-index lock for the whole move: git commands wait or fail
# instead of racing it, and the result is installed by rename.
( set -C; : > "$INDEX.lock" ) 2>/dev/null \
  || refuse 3 autostash-unrecoverable "$INDEX.lock exists (another git command is running in $DIR); nothing was changed"
OWN_INDEX_LOCK="$INDEX.lock"

is_footprint() { case "$1" in SKILL.md|*/SKILL.md|*/sections/*.md) return 0 ;; esac; return 1; }
# capture <index> — the working tree as-is into a private index; prints its tree.
capture() {
  if [ -f "$INDEX" ]; then cp "$INDEX" "$1" || return 1; else GIT_INDEX_FILE=$1 g read-tree "$HEAD_OID" || return 1; fi
  GIT_INDEX_FILE=$1 g add -u -- . 2>>"$TMP/err" || return 1
  GIT_INDEX_FILE=$1 g update-index -q --refresh >/dev/null 2>&1
  GIT_INDEX_FILE=$1 g write-tree 2>>"$TMP/err"
}
# paths <a> <b> <fileprefix> — changed paths between two trees, split into
# <fileprefix>.user and <fileprefix>.foot (NUL-separated).
paths() {
  : > "$3.user"; : > "$3.foot"
  g diff-tree -r -z --no-renames --name-only "$1" "$2" 2>/dev/null | while IFS= read -r -d '' _p; do
    if is_footprint "$_p"; then printf '%s\0' "$_p" >> "$3.foot"; else printf '%s\0' "$_p" >> "$3.user"; fi
  done
}
# entries <from> <to> <pathsfile> — index-info lines giving <to>'s entry for
# each listed path that differs (mode 0 removes it).
entries() {
  [ -s "$3" ] || return 0
  xargs -0 git --literal-pathspecs diff-tree -r -z --no-renames "$1" "$2" -- < "$3" 2>/dev/null \
    | while IFS= read -r -d '' _meta && IFS= read -r -d '' _p; do
        set -- $_meta
        printf '%s %s\t%s\0' "$2" "$4" "$_p"
      done
}
apply_entries() { [ -s "$2" ] || return 0; GIT_INDEX_FILE=$1 g update-index -z --index-info < "$2"; }
tree_without_footprint() {
  paths "$HEAD_OID" "$2" "$TMP/fp-$1"
  cp "$TMP/$1.idx" "$TMP/$1-user.idx" && entries "$2" "$HEAD_OID" "$TMP/fp-$1.foot" > "$TMP/fp-$1.info" \
    && apply_entries "$TMP/$1-user.idx" "$TMP/fp-$1.info" && GIT_INDEX_FILE="$TMP/$1-user.idx" g write-tree
}

: > "$TMP/err"
WTREE=$(capture "$TMP/w.idx") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR ($(head -c 200 "$TMP/err")); nothing was changed"
# The staged state: stage 0 entries as they are; an unmerged path keeps its
# "ours" stage (else theirs, else base) so the tree can be written.
g ls-files -s -z > "$TMP/s.raw" 2>>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR; nothing was changed"
TAB=$'\t'
: > "$TMP/s.info"
_cp="" _cl="" _cr=9
while IFS= read -r -d '' _e; do
  _p=${_e#*"$TAB"}
  set -- ${_e%%"$TAB"*}
  case "$3" in 0) _r=0 ;; 2) _r=1 ;; 3) _r=2 ;; *) _r=3 ;; esac
  if [ "$_p" != "$_cp" ]; then [ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info"; _cp=$_p _cr=9; fi
  [ "$_r" -lt "$_cr" ] && _cr=$_r _cl="$1 $2$TAB$_p"
done < "$TMP/s.raw"
[ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info"
GIT_INDEX_FILE="$TMP/s.idx" g read-tree --empty 2>/dev/null
apply_entries "$TMP/s.idx" "$TMP/s.info" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed"
STREE=$(GIT_INDEX_FILE="$TMP/s.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed"
cp "$TMP/w.idx" "$TMP/wu.idx"; WUSER=$(tree_without_footprint wu "$WTREE") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR; nothing was changed"
SUSER=$(tree_without_footprint s "$STREE") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed"
HTREE=$(g rev-parse "$HEAD_OID^{tree}")

UNMERGED_USER=0 UNMERGED_FOOT=""
while IFS= read -r _u; do
  [ -n "$_u" ] || continue
  _p=$(printf '%s' "$_u" | cut -f2-)
  if is_footprint "$_p"; then UNMERGED_FOOT=1; else UNMERGED_USER=1; fi
done <<EOF
$UNMERGED
EOF

ZERO=$(g hash-object --stdin < /dev/null | tr '0-9a-f' '0')
# lsentry <tree> <path> — "<mode> <oid>" of <path> in <tree>, empty when absent.
lsentry() { git --literal-pathspecs ls-tree -r -z "$1" -- "$2" 2>/dev/null | tr '\0' '\n' | awk -F '\t' 'NR == 1 { split($1, a, " "); print a[1] " " a[3] }'; }
# carry_line <path> — the index-info line that carries <path> as captured;
# when the update also changed it, a clean three-way content merge of the
# edit onto the target (as an autostash pop would make), else return 1.
carry_line() {
  _w=$(lsentry "$WTREE" "$1")
  if git --literal-pathspecs diff-tree --quiet -r "$HEAD_OID" "$TARGET" -- "$1" >/dev/null 2>&1; then
    printf '%s\t%s\0' "${_w:-0 $ZERO}" "$1"; return 0
  fi
  _t=$(lsentry "$TARGET" "$1"); _h=$(lsentry "$HEAD_OID" "$1")
  [ "$_w" = "$_t" ] && return 0
  for _e in "$_h" "$_w" "$_t"; do case "${_e%% *}" in 100644|100755) ;; *) return 1 ;; esac; done
  g cat-file blob "${_h#* }" > "$TMP/m.base" && g cat-file blob "${_w#* }" > "$TMP/m.ours" && g cat-file blob "${_t#* }" > "$TMP/m.theirs" || return 1
  g merge-file -p -q "$TMP/m.ours" "$TMP/m.base" "$TMP/m.theirs" > "$TMP/m.out" 2>/dev/null || return 1
  _o=$(g hash-object -w "$TMP/m.out") || return 1
  _m=${_t%% *}; [ "${_w%% *}" = "${_h%% *}" ] || _m=${_w%% *}
  printf '%s %s\t%s\0' "$_m" "$_o" "$1"
}

paths "$HEAD_OID" "$WTREE" "$TMP/delta"
SHELVE=0
[ "$UNMERGED_USER" = 1 ] && SHELVE=1
[ "$SUSER" = "$HTREE" ] || SHELVE=1
: > "$TMP/carry.user"; : > "$TMP/carry.foot"
if [ "$SHELVE" = 0 ]; then
  while IFS= read -r -d '' _p; do
    carry_line "$_p" >> "$TMP/carry.user" || { SHELVE=1; break; }
  done < "$TMP/delta.user"
fi
# A footprint edit rides over when it merges cleanly; otherwise, or when it
# is unmerged, the target's version wins (callers re-render it).
while IFS= read -r -d '' _p; do
  [ -n "$UNMERGED_FOOT" ] && printf '%s\n' "$UNMERGED" | cut -f2- | grep -qxF -- "$_p" && continue
  carry_line "$_p" >> "$TMP/carry.foot"
done < "$TMP/delta.foot"
[ "$SHELVE" = 1 ] && [ "$WUSER" = "$HTREE" ] && [ "$SUSER" = "$HTREE" ] && [ "$UNMERGED_USER" = 0 ] && SHELVE=0

# The result: the target, plus the edits that ride over it.
GIT_INDEX_FILE="$TMP/r.idx" g read-tree "$TARGET" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read $TARGET; nothing was changed"
{ [ "$SHELVE" = 0 ] && cat "$TMP/carry.user"; cat "$TMP/carry.foot"; } > "$TMP/carry.info"
apply_entries "$TMP/r.idx" "$TMP/carry.info" || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed"
RTREE=$(GIT_INDEX_FILE="$TMP/r.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed"

RESCUE="" REF=""
if [ "$SHELVE" = 1 ]; then
  _id() { GIT_AUTHOR_NAME='gstack rescue' GIT_AUTHOR_EMAIL='gstack@localhost' GIT_COMMITTER_NAME='gstack rescue' GIT_COMMITTER_EMAIL='gstack@localhost' "$@"; }
  _label=""
  [ "$UNMERGED_USER" = 1 ] && _label="
Needs manual resolution: the install had unmerged files; their captured
content may contain conflict markers."
  SCOMMIT=$(printf 'gstack rescue: staged state before updating to %s\n' "$TARGET" \
    | _id g commit-tree --no-gpg-sign "$SUSER" -p "$HEAD_OID" 2>>"$TMP/err") \
    || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed"
  RESCUE=$(printf 'gstack rescue: local edits that conflicted with %s\n\nPre-update HEAD: %s\nTarget: %s\nRestore out of the live install: git diff <this>^ <this> > patch, then git apply -3 in a scratch checkout.\n%s\n' \
      "$TARGET" "$HEAD_OID" "$TARGET" "$_label" \
    | _id g commit-tree --no-gpg-sign "$WUSER" -p "$HEAD_OID" -p "$SCOMMIT" 2>>"$TMP/err") \
    || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed"
  REF="refs/gstack/rescue/$RESCUE"
  if ! g update-ref -m "gstack-ff-update: rescue before $TARGET" "$REF" "$RESCUE" "" 2>/dev/null; then
    [ "$(g rev-parse -q --verify "$REF" 2>/dev/null)" = "$RESCUE" ] \
      || refuse 3 autostash-unrecoverable "rescue ref $REF already exists and names another commit; nothing was changed"
  fi
fi
unrescue() { [ -n "$REF" ] && g update-ref -d "$REF" "$RESCUE" 2>/dev/null; }

# The working tree must still be exactly what was captured.
: > "$TMP/err"
RECHECK=$(capture "$TMP/w2.idx")
if [ "$RECHECK" != "$WTREE" ] || [ "$(g rev-parse -q --verify HEAD 2>/dev/null)" != "$HEAD_OID" ]; then
  unrescue
  refuse 3 autostash-unrecoverable "files in $DIR changed while the update ran; nothing was changed, your edits are where they were"
fi

if ! GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$WTREE" "$RTREE" 2>"$TMP/err"; then
  unrescue
  refuse 3 autostash-unrecoverable "git refused to update $DIR: $(head -c 200 "$TMP/err"); nothing was changed, your edits are where they were"
fi
if ! g update-ref -m "gstack-ff-update: fast-forward to $TARGET" HEAD "$TARGET" "$HEAD_OID" 2>"$TMP/err"; then
  GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$RTREE" "$WTREE" 2>/dev/null
  unrescue
  refuse 3 autostash-unrecoverable "HEAD of $DIR moved during the update; the working tree was put back"
fi
# The real index becomes the target with the carried edits unstaged.
cp "$TMP/w.idx" "$TMP/f.idx"
paths "$RTREE" "$TARGET" "$TMP/unstage"
cat "$TMP/unstage.user" "$TMP/unstage.foot" > "$TMP/unstage.all"
entries "$RTREE" "$TARGET" "$TMP/unstage.all" > "$TMP/unstage.info"
apply_entries "$TMP/f.idx" "$TMP/unstage.info"
GIT_INDEX_FILE="$TMP/f.idx" g update-index -q --refresh >/dev/null 2>&1
cat "$TMP/f.idx" > "$INDEX.lock" && mv -f "$INDEX.lock" "$INDEX" && OWN_INDEX_LOCK=""

if [ "$SHELVE" = 1 ] && [ -n "$REF" ]; then
  _q=$(ff_quote "$DIR")
  {
    echo "gstack-ff-update: your local edits in $DIR conflicted with the update; they are saved in $REF and the install now runs $TARGET."
    [ "$UNMERGED_USER" = 1 ] && echo "  They include unmerged files: this rescue needs manual resolution."
    echo "  Inspect:  git -C $_q show --stat '$REF'"
    echo "  Export:   git -C $_q diff '$REF^' '$REF' > ~/gstack-local-edits.patch   (then git apply -3 it in a scratch checkout, not in the install)"
    echo "  Delete:   git -C $_q update-ref -d '$REF'"
  } >&2
  echo "rescued $HEAD_OID $TARGET $REF unmerged=$UNMERGED_USER"
else
  echo "ok $HEAD_OID $TARGET"
fi
exit 0
