#!/usr/bin/env bash
###############################################################################
# @file svnsnap.sh
# @brief Named snapshots of a Subversion working copy.
# @details
#   Subversion can only commit what is in the working copy right now. This
#   script freezes the current state of the changes under a name, lets work
#   continue, then commits that frozen state alone: the newer edits stay in the
#   working copy for a later commit.
#   It works by stashing the current content of the snapshot's paths, copying
#   the snapshot content back in, committing only those paths, then putting the
#   newer work back. After the commit SVN's BASE for those files is the
#   snapshot revision, so what remains in the working copy is exactly
#   (current - snapshot).
#   Files not covered by a snapshot are never touched and never committed.
#   Run "svnsnap.sh help" for the full command list.
# @author JB Clement
# @date 17/08/2026
###############################################################################
set -euo pipefail

PROG=${0##*/}
SNAPDIR_NAME=.svnsnap
STASH_KEEP=10

# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

if [[ -t 1 ]]; then
    C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_RED=$'\033[31m'
    C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_DIM=$'\033[2m'
else
    C_RESET=; C_BOLD=; C_RED=; C_GREEN=; C_YELLOW=; C_DIM=
fi

# Output must never abort the script: a closed stdout (piping into `head`, or
# quitting a pager) would otherwise kill a run in the middle of a commit.
# Ignoring SIGPIPE turns those writes into plain errors, which the guards below
# swallow -- a signal alone would kill the shell outright, guards or not.
trap '' PIPE
info()  { printf '%s\n' "$*" 2>/dev/null || true; }
step()  { printf '%s==>%s %s\n' "$C_BOLD" "$C_RESET" "$*" 2>/dev/null || true; }
ok()    { printf '%s%s%s\n' "$C_GREEN" "$*" "$C_RESET" 2>/dev/null || true; }
warn()  { printf '%swarning:%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2 2>/dev/null || true; }
die()   { printf '%serror:%s %s\n' "$C_RED" "$C_RESET" "$*" >&2 2>/dev/null || true; exit 1; }

confirm() {
    local prompt=$1 reply
    if [[ ! -t 0 ]]; then
        die "$prompt (no terminal to confirm on; re-run interactively or pass --yes)"
    fi
    read -r -p "$prompt [y/N] " reply
    [[ $reply == [yY] || $reply == [yY][eE][sS] ]]
}

# ---------------------------------------------------------------------------
# Working copy discovery
# ---------------------------------------------------------------------------

command -v svn >/dev/null 2>&1 || die "svn not found in PATH"

WC_ROOT=$(svn info --show-item wc-root . 2>/dev/null) \
    || die "not inside a Subversion working copy"
WC_ROOT=${WC_ROOT%/}

# Everything below runs from the working copy root, so remember where the user
# actually invoked us: paths they typed on the command line are relative to
# that directory, not to the root.
INVOKE_DIR=$PWD
cd "$WC_ROOT"

SNAPROOT=$WC_ROOT/$SNAPDIR_NAME
STASHROOT=$SNAPROOT/.stash

snap_dir()      { printf '%s/%s' "$SNAPROOT" "$1"; }
snap_exists()   { [[ -f $(snap_dir "$1")/manifest.tsv ]]; }

valid_name() {
    [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
}

meta_get() {
    # meta_get <snapshot-dir> <key>
    local f=$1/meta key=$2
    [[ -f $f ]] || return 0
    sed -n "s/^${key}=//p" "$f" | head -n1
}

meta_set() {
    # meta_set <snapshot-dir> <key> <value>
    local f=$1/meta key=$2 value=$3
    touch "$f"
    if grep -q "^${key}=" "$f"; then
        sed -i "s|^${key}=.*|${key}=${value}|" "$f"
    else
        printf '%s=%s\n' "$key" "$value" >>"$f"
    fi
}

now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
stamp()   { date -u +%Y%m%d-%H%M%S; }

# ---------------------------------------------------------------------------
# svn status parsing
#
# Emits one record per changed versioned path:  <letter>\t<kind>\t<path>
#   letter: M modified, A added, D deleted, R replaced, C conflicted
#   kind:   file | dir
# Paths are relative to the working copy root.
# ---------------------------------------------------------------------------

status_records() {
    if command -v python3 >/dev/null 2>&1 \
       && svn status --xml . 2>/dev/null | python3 -c '
import os, sys, xml.etree.ElementTree as ET

try:
    root = ET.fromstring(sys.stdin.read())
except ET.ParseError:
    sys.exit(1)

LETTER = {"modified": "M", "added": "A", "deleted": "D",
          "replaced": "R", "conflicted": "C", "missing": "D"}

for entry in root.iter("entry"):
    path = entry.get("path")
    ws = entry.find("wc-status")
    if ws is None or path in (".", ""):
        continue
    item = ws.get("item", "")
    props = ws.get("props", "")
    tree = ws.get("tree-conflicted", "")

    if tree == "true" or item == "conflicted" or props == "conflicted":
        letter = "C"
    elif item in LETTER:
        letter = LETTER[item]
    elif props == "modified":
        # property-only change: no content to snapshot
        letter = "P"
    else:
        continue

    kind = "dir" if os.path.isdir(path) else "file"
    print("%s\t%s\t%s" % (letter, kind, path))
' 2>/dev/null
    then
        return 0
    fi

    # Fallback: fixed-column plain-text parsing.
    warn "svn status --xml parsing unavailable; using plain-text fallback"
    svn status . | while IFS= read -r line; do
        [[ ${#line} -gt 8 ]] || continue
        local c0=${line:0:1} c1=${line:1:1} c6=${line:6:1} path=${line:8}
        [[ -n $path && $path != "." ]] || continue
        local letter=
        case $c0 in
            M) letter=M ;;
            A) letter=A ;;
            D|!) letter=D ;;
            R) letter=R ;;
            C) letter=C ;;
            ' ') if [[ $c1 == M ]]; then letter=P; fi ;;
            *) continue ;;
        esac
        if [[ $c1 == C || $c6 == C ]]; then letter=C; fi
        [[ -n $letter ]] || continue
        local kind=file
        if [[ -d $path ]]; then kind=dir; fi
        printf '%s\t%s\t%s\n' "$letter" "$kind" "$path"
    done
}

# svn status is expensive on a tree this size, so cache it. Any command that
# changes the working copy must call status_refresh afterwards.
declare -A STATUS_CACHE=()
STATUS_CACHED=0

status_refresh() {
    STATUS_CACHE=()
    local letter kind path
    while IFS=$'\t' read -r letter kind path; do
        STATUS_CACHE[$path]=$letter
    done < <(status_records)
    STATUS_CACHED=1
}

status_letter_of() {
    # status_letter_of <path> -- current letter, or "-" if clean/unversioned
    ((STATUS_CACHED)) || status_refresh
    printf '%s' "${STATUS_CACHE[$1]:--}"
}

status_letter_live() {
    # Uncached single-path lookup, for use right after modifying the path.
    local out
    out=$(svn status --depth empty -- "$1" 2>/dev/null | head -n1) || true
    [[ -n $out ]] || { printf '%s' '-'; return 0; }
    local c0=${out:0:1} c1=${out:1:1}
    case $c0 in
        M|A|D|R|C) printf '%s' "$c0" ;;
        '!') printf '%s' 'D' ;;
        ' ') [[ $c1 == M ]] && printf '%s' 'P' || printf '%s' '-' ;;
        *) printf '%s' '-' ;;
    esac
}

is_versioned() {
    svn info "$1" >/dev/null 2>&1
}

# ---------------------------------------------------------------------------
# File copying
#
# Copies are skipped when content already matches, so unchanged files keep
# their mtime and do not trigger a needless recompile of the model.
# ---------------------------------------------------------------------------

copy_keep_time() {
    # Content-preserving copy that also carries the source mtime over.
    local src=$1 dst=$2
    mkdir -p "$(dirname "$dst")"
    if [[ -f $dst ]] && cmp -s "$src" "$dst"; then
        # Content already matches, so skip the write -- but still align the
        # mtime, or a file left with a fresh timestamp would force a rebuild.
        touch -r "$src" "$dst"
        return 0
    fi
    cp --preserve=mode,timestamps "$src" "$dst"
}

copy_fresh_time() {
    # Copy giving the destination a current mtime, so svn is guaranteed to
    # notice the change (svn's status heuristic keys on mtime + size).
    local src=$1 dst=$2
    mkdir -p "$(dirname "$dst")"
    cp --preserve=mode "$src" "$dst"
    touch "$dst"
}

# ---------------------------------------------------------------------------
# save
# ---------------------------------------------------------------------------

# in_scope PATH -- true when PATH is covered by the active --only filters,
# and always true when there are none. Also records which filter matched, so
# save can warn about a filter that selected nothing (usually a typo).
# Reads and updates only_paths/only_hits from its caller (cmd_save).
in_scope() {
    local p=$1 o
    ((${#only_paths[@]})) || return 0
    for o in "${only_paths[@]}"; do
        if [[ $p == "$o" || $p == "$o"/* ]]; then
            only_hits[$o]=$(( only_hits[$o] + 1 ))
            return 0
        fi
    done
    return 1
}

cmd_save() {
    local name= message= message_file= force=0
    local -a includes=() onlys=()

    while (($#)); do
        case $1 in
            -m) message=${2:?-m needs an argument}; shift 2 ;;
            -F) message_file=${2:?-F needs an argument}; shift 2 ;;
            --include) includes+=("${2:?--include needs a path}"); shift 2 ;;
            --only) onlys+=("${2:?--only needs a path}"); shift 2 ;;
            --force|-f) force=1; shift ;;
            -*) die "save: unknown option '$1'" ;;
            *) [[ -z $name ]] || die "save: unexpected argument '$1'"; name=$1; shift ;;
        esac
    done

    [[ -n $name ]] || die "usage: $PROG save <name> [-m MSG | -F FILE] [--only PATH]... [--include PATH]..."
    valid_name "$name" || die "invalid snapshot name '$name' (letters, digits, . _ - only)"

    # --only paths are normalised to working-copy-relative form, so they can be
    # compared against the paths svn status reports whatever the caller typed
    # (absolute, relative to the current directory, with a trailing slash).
    local -a only_paths=()
    local -A only_hits=()
    local o abs rel
    for o in "${onlys[@]+"${onlys[@]}"}"; do
        # -m: the path may be scheduled for deletion and already gone from disk.
        abs=$(cd "$INVOKE_DIR" && realpath -m -- "$o") \
            || die "--only: cannot resolve '$o'"
        if [[ $abs == "$WC_ROOT" ]]; then
            rel=.
        elif [[ $abs == "$WC_ROOT"/* ]]; then
            rel=${abs#"$WC_ROOT"/}
        else
            die "--only: '$o' is outside the working copy $WC_ROOT"
        fi
        if [[ $rel == "." ]]; then
            warn "--only '$o' is the working copy root; the whole tree stays in scope"
            only_paths=(); only_hits=(); break
        fi
        only_paths+=("$rel"); only_hits[$rel]=0
    done

    local dir; dir=$(snap_dir "$name")
    if snap_exists "$name"; then
        ((force)) || die "snapshot '$name' already exists (use --force to overwrite)"
        rm -rf "$dir"
    fi

    # Collect the changed versioned paths.
    local -a letters=() kinds=() paths=()
    local letter kind path conflicts=0 propmods=0
    while IFS=$'\t' read -r letter kind path; do
        # Out-of-scope paths are skipped before the checks below: a conflict in
        # a part of the tree the snapshot does not cover must not block it.
        in_scope "$path" || continue
        case $letter in
            C) warn "conflicted: $path"; conflicts=1; continue ;;
            P) warn "property-only change not captured: $path"; propmods=1; continue ;;
        esac
        letters+=("$letter"); kinds+=("$kind"); paths+=("$path")
    done < <(status_records)

    if ((conflicts)); then die "resolve the conflicts above before taking a snapshot"; fi

    # Explicitly included unversioned files.
    local inc
    for inc in "${includes[@]+"${includes[@]}"}"; do
        inc=$(cd "$INVOKE_DIR" && realpath --relative-to="$WC_ROOT" -- "$inc") \
            || die "--include: cannot resolve '$inc'"
        [[ $inc != /* && $inc != ".." && $inc != ../* ]] \
            || die "--include: '$inc' is outside the working copy $WC_ROOT"
        [[ -f $WC_ROOT/$inc ]] || die "--include: '$inc' is not a regular file"
        in_scope "$inc" \
            || die "--include: '$inc' lies outside the --only scope (add it to --only, or drop --only)"
        local seen=0 p
        for p in "${paths[@]+"${paths[@]}"}"; do
            if [[ $p == "$inc" ]]; then seen=1; break; fi
        done
        if ((seen)); then continue; fi
        letters+=("A"); kinds+=("file"); paths+=("$inc")
    done

    for o in "${only_paths[@]+"${only_paths[@]}"}"; do
        if ((only_hits[$o] == 0)); then
            warn "--only '$o' matched no change"
        fi
    done

    if ((!${#paths[@]})); then
        if ((${#only_paths[@]})); then
            die "nothing to snapshot: no versioned changes under ${only_paths[*]}"
        fi
        die "nothing to snapshot: no versioned changes in $WC_ROOT"
    fi

    mkdir -p "$dir/files"

    # Manifest + content copies.
    : >"$dir/manifest.tsv"
    local i
    for i in "${!paths[@]}"; do
        letter=${letters[$i]}; kind=${kinds[$i]}; path=${paths[$i]}
        printf '%s\t%s\t%s\n' "$letter" "$kind" "$path" >>"$dir/manifest.tsv"
        if [[ $kind == file && $letter != D ]]; then
            [[ -f $path ]] || die "$path is listed as '$letter' but missing on disk"
            copy_keep_time "$path" "$dir/files/$path"
        fi
    done

    # Reference diff (best effort -- purely for humans to read back), kept to
    # the same scope as the snapshot itself.
    if ((${#only_paths[@]})); then
        svn diff -- "${only_paths[@]}" >"$dir/diff.patch" 2>/dev/null || : >"$dir/diff.patch"
    else
        svn diff . >"$dir/diff.patch" 2>/dev/null || : >"$dir/diff.patch"
    fi

    # Commit message.
    if [[ -n $message ]]; then
        printf '%s\n' "$message" >"$dir/message.txt"
    elif [[ -n $message_file ]]; then
        [[ -f $message_file ]] || die "-F: no such file '$message_file'"
        cp "$message_file" "$dir/message.txt"
    elif [[ -f $WC_ROOT/msgcommit.txt ]]; then
        cp "$WC_ROOT/msgcommit.txt" "$dir/message.txt"
        info "captured commit message from msgcommit.txt"
    else
        : >"$dir/message.txt"
        warn "no commit message captured (set one later with: $PROG msg $name)"
    fi

    local base_rev; base_rev=$(svn info --show-item revision . 2>/dev/null || echo "?")
    meta_set "$dir" created   "$(now_utc)"
    meta_set "$dir" base_rev  "$base_rev"
    meta_set "$dir" wc_root   "$WC_ROOT"
    if ((${#only_paths[@]})); then meta_set "$dir" only "${only_paths[*]}"; fi

    local scope_note=
    if ((${#only_paths[@]})); then scope_note=" (scope: ${only_paths[*]})"; fi
    ok "snapshot '$name' saved: ${#paths[@]} path(s) at r$base_rev$scope_note"
    if ((propmods)); then warn "property changes are not snapshotted; they will be committed in their current state"; fi
    info "${C_DIM}review with: $PROG show $name${C_RESET}"
}

# ---------------------------------------------------------------------------
# Stash / restore -- the safety net around commit and restore
# ---------------------------------------------------------------------------

STASH_DIR=
RESTORE_PENDING=0
PENDING_FILE=$SNAPROOT/.pending

# The trap below covers the ordinary failure paths, but a process-group SIGINT
# (a real Ctrl-C) can kill bash before any handler runs. So the intent to
# restore is also recorded on disk: any later run of this script notices the
# marker and puts the working copy back. The working copy is therefore never
# left silently holding snapshot content.
pending_set()   { mkdir -p "$SNAPROOT"; printf '%s\n' "$1" >"$PENDING_FILE"; }
pending_clear() { rm -f "$PENDING_FILE"; }

recover_pending() {
    [[ -f $PENDING_FILE ]] || return 0
    local sd; sd=$(<"$PENDING_FILE")
    warn "a previous run was interrupted while the working copy held snapshot content."
    warn "restoring your work from $sd"
    if [[ -d $sd ]] && restore_stash "$sd"; then
        pending_clear
        ok "working copy restored"
        warn "the interrupted commit may still have reached the server --"
        warn "check 'svn log -l1' and run 'svn update' before continuing"
    else
        die "cannot restore from '$sd'; your files are there. Recover with: $PROG unstash $(basename "$sd")"
    fi
}

make_stash() {
    # make_stash <snapshot-dir> <op> <name> -- stash current content of the
    # snapshot's paths. Records existence and current svn letter per path.
    local dir=$1 op=$2 name=$3
    local sd=$STASHROOT/$(stamp)-$op-$name
    mkdir -p "$sd/files"
    : >"$sd/meta.tsv"

    local letter kind path cur
    while IFS=$'\t' read -r letter kind path; do
        cur=$(status_letter_of "$path")
        if [[ $kind == file && -f $path ]]; then
            copy_keep_time "$path" "$sd/files/$path"
            printf '%s\t%s\t%s\t%s\n' "1" "$cur" "$kind" "$path" >>"$sd/meta.tsv"
        else
            printf '%s\t%s\t%s\t%s\n' "0" "$cur" "$kind" "$path" >>"$sd/meta.tsv"
        fi
    done <"$dir/manifest.tsv"

    printf '%s' "$sd"
}

restore_stash() {
    # restore_stash <stash-dir> -- put the stashed working state back.
    local sd=$1
    [[ -f $sd/meta.tsv ]] || { warn "stash $sd has no meta.tsv; nothing restored"; return 0; }

    local exists cur kind path
    while IFS=$'\t' read -r exists cur kind path; do
        if [[ $exists == 1 ]]; then
            copy_keep_time "$sd/files/$path" "$path"
            if [[ $cur == A ]] && ! is_versioned "$path"; then
                svn add --parents --quiet "$path" || warn "could not re-add $path"
            fi
        else
            # The path did not exist in the working state we stashed.
            if [[ $cur == D ]]; then
                if is_versioned "$path"; then
                    svn delete --force --quiet "$path" || warn "could not re-delete $path"
                else
                    rm -f "$path"
                fi
            else
                rm -f "$path"
            fi
        fi
    done <"$sd/meta.tsv"

    # Guard against svn's mtime+size heuristic hiding a restored change: the
    # stashed content carries its original mtime, which svn may mistake for
    # "unchanged". Force a re-scan on anything that reports clean.
    while IFS=$'\t' read -r exists cur kind path; do
        [[ $exists == 1 && $kind == file ]] || continue
        [[ $cur == M || $cur == A || $cur == R ]] || continue
        [[ $(status_letter_live "$path") == '-' ]] || continue
        # svn reports the file as unmodified. That is correct when the change
        # was just committed, and wrong when the mtime heuristic hid a real
        # difference -- so compare against BASE before forcing a re-scan.
        if svn cat "$path" 2>/dev/null | cmp -s - "$path"; then
            info "  ${C_DIM}$path is identical to the committed revision${C_RESET}"
        else
            touch "$path"
        fi
    done <"$sd/meta.tsv"

    STATUS_CACHED=0
}

prune_stashes() {
    [[ -d $STASHROOT ]] || return 0
    local -a old
    mapfile -t old < <(ls -1 "$STASHROOT" 2>/dev/null | sort | head -n -"$STASH_KEEP")
    local d
    for d in "${old[@]+"${old[@]}"}"; do
        rm -rf "${STASHROOT:?}/$d"
    done
}

cleanup_trap() {
    local rc=$?
    local signal=${1:-}
    [[ -n $signal ]] && rc=130
    if ((RESTORE_PENDING)) && [[ -n $STASH_DIR ]]; then
        RESTORE_PENDING=0
        printf '\n'
        warn "${signal:-failure} -- restoring your working copy from $STASH_DIR"
        if restore_stash "$STASH_DIR"; then
            info "working copy restored"
        else
            warn "restore failed! your files are safe in $STASH_DIR"
            warn "put them back with: $PROG unstash $(basename "$STASH_DIR")"
        fi
        pending_clear
        if [[ -n $signal ]]; then
            warn "if svn had already sent the commit, it may have landed anyway --"
            warn "check with 'svn log -l1' and run 'svn update' before continuing"
        fi
    fi
    exit $rc
}

# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------

rev_from_targets() {
    # rev_from_targets <targets-file> -- highest last-changed revision among the
    # paths just committed. Used when svn's "Committed revision N." line could
    # not be parsed, so the snapshot is still recorded as committed.
    local targets=$1 path rev best=
    while IFS= read -r path; do
        [[ -e $path ]] || continue
        rev=$(svn info --show-item last-changed-revision -- "$path" 2>/dev/null) || continue
        rev=${rev//[[:space:]]/}
        [[ $rev =~ ^[0-9]+$ ]] || continue
        if [[ -z $best ]] || ((rev > best)); then best=$rev; fi
    done <"$targets"
    printf '%s' "$best"
}

cmd_commit() {
    local name= dry=0 force=0 yes=0 message= message_file=

    while (($#)); do
        case $1 in
            --dry-run|-n) dry=1; shift ;;
            --force|-f) force=1; shift ;;
            --yes|-y) yes=1; shift ;;
            -m) message=${2:?-m needs an argument}; shift 2 ;;
            -F) message_file=${2:?-F needs an argument}; shift 2 ;;
            -*) die "commit: unknown option '$1'" ;;
            *) [[ -z $name ]] || die "commit: unexpected argument '$1'"; name=$1; shift ;;
        esac
    done

    [[ -n $name ]] || die "usage: $PROG commit <name> [--dry-run] [-m MSG | -F FILE]"
    snap_exists "$name" || die "no such snapshot: '$name' (see: $PROG list)"

    local dir; dir=$(snap_dir "$name")

    # --- Preconditions ------------------------------------------------------
    local committed_rev; committed_rev=$(meta_get "$dir" committed_rev)
    if [[ -n $committed_rev ]] && ((!force)); then
        die "snapshot '$name' was already committed as r$committed_rev (use --force to commit it again)"
    fi

    local letter kind path
    local -a conflicted=()
    while IFS=$'\t' read -r letter kind path; do
        if [[ $letter == C ]]; then conflicted+=("$path"); fi
    done < <(status_records)
    if ((${#conflicted[@]})); then
        printf '%s\n' "${conflicted[@]}" >&2
        die "resolve the conflicts above before committing a snapshot"
    fi

    local base_rev cur_rev
    base_rev=$(meta_get "$dir" base_rev)
    cur_rev=$(svn info --show-item revision . 2>/dev/null || echo "?")
    if [[ -n $base_rev && $base_rev != "$cur_rev" ]] && ((!yes)); then
        warn "snapshot was taken at r$base_rev but the working copy is now at r$cur_rev."
        warn "An update has landed since. Committing the stored content will overwrite"
        warn "anything that was merged into these files in between."
        warn "Check first with: $PROG diff $name"
        ((dry)) || confirm "Commit snapshot '$name' anyway?" || die "aborted"
    fi

    # --- Commit message -----------------------------------------------------
    local msgfile=$dir/message.txt
    if [[ -n $message ]]; then
        msgfile=$(mktemp); printf '%s\n' "$message" >"$msgfile"
    elif [[ -n $message_file ]]; then
        [[ -f $message_file ]] || die "-F: no such file '$message_file'"
        msgfile=$message_file
    fi
    if [[ ! -s $msgfile ]] && ((!dry)); then
        die "snapshot '$name' has an empty commit message (set one with: $PROG msg $name)"
    fi

    # --- Stash the current work --------------------------------------------
    step "stashing your current work for the snapshot's paths"
    STASH_DIR=$(make_stash "$dir" precommit "$name")
    RESTORE_PENDING=1
    pending_set "$STASH_DIR"
    trap 'cleanup_trap' EXIT
    trap 'cleanup_trap interrupted' INT
    trap 'cleanup_trap terminated' TERM
    info "${C_DIM}stash: $STASH_DIR${C_RESET}"

    # --- Apply the snapshot content ----------------------------------------
    step "applying snapshot '$name' to the working copy"
    while IFS=$'\t' read -r letter kind path; do
        case $letter in
            M|R)
                [[ $kind == file ]] || continue
                copy_fresh_time "$dir/files/$path" "$path"
                ;;
            A)
                if [[ $kind == file ]]; then
                    copy_fresh_time "$dir/files/$path" "$path"
                else
                    mkdir -p "$path"
                fi
                is_versioned "$path" || svn add --parents --quiet "$path"
                ;;
            D)
                if is_versioned "$path"; then
                    if [[ $(status_letter_live "$path") != D ]]; then
                        svn delete --force --quiet "$path"
                    fi
                fi
                ;;
        esac
    done <"$dir/manifest.tsv"

    # --- Build the target list ---------------------------------------------
    # Any parent directory that is itself scheduled for addition must be part
    # of the same commit, or svn refuses the child.
    local targets; targets=$(mktemp)
    cut -f3 "$dir/manifest.tsv" >"$targets"

    status_refresh
    local parent
    while IFS=$'\t' read -r letter kind path; do
        [[ $letter == A ]] || continue
        parent=$(dirname "$path")
        while [[ $parent != "." && $parent != "/" ]]; do
            if [[ $(status_letter_of "$parent") == A ]] && ! grep -qxF "$parent" "$targets"; then
                printf '%s\n' "$parent" >>"$targets"
            fi
            parent=$(dirname "$parent")
        done
    done <"$dir/manifest.tsv"

    sort -u -o "$targets" "$targets"

    # --- Commit -------------------------------------------------------------
    local new_rev=
    if ((dry)); then
        step "dry run -- nothing will be committed"
        info "would run: svn commit --depth empty --targets <targets> -F $msgfile"
        info "targets:"
        sed 's/^/  /' "$targets" 2>/dev/null || true
        info "message:"
        sed 's/^/  | /' "$msgfile" 2>/dev/null || true
    else
        step "committing ${C_BOLD}$name${C_RESET} ($(wc -l <"$targets") target(s))"
        # Run svn directly (not in a command substitution) so its output is
        # live and signal handling stays as simple as possible.
        local outfile; outfile=$(mktemp)
        local rc=0
        # PIPESTATUS must be read by the very next command, so errexit is
        # lifted here rather than using a '|| rc=$?' guard.
        #
        # LC_MESSAGES=C keeps svn's "Committed revision N." line in English so
        # the revision can be parsed out of it; under a localized LANG it reads
        # "Révision N propagée." and the snapshot would never be marked
        # committed. LC_ALL is emptied because it would otherwise override
        # LC_MESSAGES -- and only LC_MESSAGES is forced, so LC_CTYPE still
        # follows the user's locale and svn keeps converting an accented commit
        # message correctly.
        set +e
        LC_ALL= LC_MESSAGES=C \
            svn commit --depth empty --targets "$targets" -F "$msgfile" 2>&1 | tee "$outfile"
        rc=${PIPESTATUS[0]}
        set -e
        if ((rc == 0)); then
            new_rev=$(sed -n 's/^Committed revision \([0-9]*\).*/\1/p' "$outfile" | tail -n1)
            # Belt and braces: if the wording ever changes, ask svn instead.
            [[ -n $new_rev ]] || new_rev=$(rev_from_targets "$targets")
            rm -f "$outfile"
        else
            rm -f "$outfile" "$targets"
            die "svn commit failed -- your working copy will be restored"
        fi
    fi
    rm -f "$targets"

    # --- Restore your newer work -------------------------------------------
    step "restoring your newer work"
    RESTORE_PENDING=0
    trap - EXIT INT TERM
    restore_stash "$STASH_DIR"
    pending_clear

    if ((!dry)); then
        if [[ -n $new_rev ]]; then
            meta_set "$dir" committed_rev "$new_rev"
        else
            warn "could not determine the committed revision; '$name' stays marked pending"
            warn "check 'svn log -l1' and record it with: $PROG setrev $name <rev>"
        fi
        meta_set "$dir" committed_at "$(now_utc)"
        ok "snapshot '$name' committed${new_rev:+ as r$new_rev}"
    else
        ok "dry run complete; working copy unchanged"
    fi

    prune_stashes

    info ""
    info "remaining working-copy changes:"
    svn status -q | sed 's/^/  /' || true
}

# ---------------------------------------------------------------------------
# restore
# ---------------------------------------------------------------------------

cmd_restore() {
    local name= yes=0
    while (($#)); do
        case $1 in
            --yes|-y) yes=1; shift ;;
            -*) die "restore: unknown option '$1'" ;;
            *) [[ -z $name ]] || die "restore: unexpected argument '$1'"; name=$1; shift ;;
        esac
    done
    [[ -n $name ]] || die "usage: $PROG restore <name>"
    snap_exists "$name" || die "no such snapshot: '$name'"

    local dir; dir=$(snap_dir "$name")
    warn "this overwrites your current content for the snapshot's paths"
    ((yes)) || confirm "Restore snapshot '$name' into the working copy?" || die "aborted"

    local sd; sd=$(make_stash "$dir" prerestore "$name")
    info "${C_DIM}current state stashed at: $sd${C_RESET}"

    # Unlike commit, restore is meant to leave snapshot content in place, so no
    # pending marker is written -- the stash above is the way back.
    local letter kind path
    while IFS=$'\t' read -r letter kind path; do
        case $letter in
            M|R|A)
                [[ $kind == file ]] || { mkdir -p "$path"; continue; }
                copy_fresh_time "$dir/files/$path" "$path"
                if [[ $letter == A ]] && ! is_versioned "$path"; then
                    svn add --parents --quiet "$path"
                fi
                ;;
            D)
                if is_versioned "$path" && [[ $(status_letter_live "$path") != D ]]; then
                    svn delete --force --quiet "$path"
                fi
                ;;
        esac
    done <"$dir/manifest.tsv"

    prune_stashes
    ok "snapshot '$name' restored into the working copy"
    info "undo with: $PROG unstash $(basename "$sd")"
}

cmd_unstash() {
    local sd=${1:?usage: $PROG unstash <stash-id>}
    [[ -d $STASHROOT/$sd ]] || die "no such stash: '$sd' (see: $PROG stashes)"
    restore_stash "$STASHROOT/$sd"
    ok "working copy restored from stash $sd"
}

cmd_stashes() {
    [[ -d $STASHROOT ]] || { info "no stashes"; return 0; }
    local d n
    for d in "$STASHROOT"/*/; do
        [[ -d $d ]] || continue
        d=${d%/}; n=$(wc -l <"$d/meta.tsv" 2>/dev/null || echo 0)
        printf '%s  %s path(s)\n' "$(basename "$d")" "$n"
    done
}

# ---------------------------------------------------------------------------
# list / show / diff / msg / delete
# ---------------------------------------------------------------------------

cmd_list() {
    [[ -d $SNAPROOT ]] || { info "no snapshots yet (create one with: $PROG save <name>)"; return 0; }

    local -a rows=()
    local d name created base rev n subject state
    for d in "$SNAPROOT"/*/; do
        [[ -f ${d}manifest.tsv ]] || continue
        d=${d%/}; name=$(basename "$d")
        created=$(meta_get "$d" created); base=$(meta_get "$d" base_rev)
        rev=$(meta_get "$d" committed_rev)
        n=$(wc -l <"$d/manifest.tsv")
        subject=$(grep -m1 -v '^[[:space:]]*$' "$d/message.txt" 2>/dev/null || true)
        if [[ ${#subject} -gt 48 ]]; then subject=${subject:0:45}...; fi
        if [[ -n $rev ]]; then state="r$rev"; else state="pending"; fi
        rows+=("$(printf '%s\t%s\t%s\t%s\t%s\t%s' \
            "$name" "${created:0:16}" "r${base:-?}" "$n" "$state" "${subject:-<no message>}")")
    done

    ((${#rows[@]})) || { info "no snapshots yet (create one with: $PROG save <name>)"; return 0; }

    { printf 'NAME\tCREATED\tBASE\tFILES\tSTATE\tMESSAGE\n'
      printf '%s\n' "${rows[@]}"; } | column -t -s $'\t'
}

cmd_show() {
    local name= want_diff=0
    while (($#)); do
        case $1 in
            --diff|-d) want_diff=1; shift ;;
            -*) die "show: unknown option '$1'" ;;
            *) [[ -z $name ]] || die "show: unexpected argument '$1'"; name=$1; shift ;;
        esac
    done
    [[ -n $name ]] || die "usage: $PROG show <name> [--diff]"
    snap_exists "$name" || die "no such snapshot: '$name'"

    local dir; dir=$(snap_dir "$name")
    printf '%ssnapshot%s %s\n' "$C_BOLD" "$C_RESET" "$name"
    printf '  created      %s\n' "$(meta_get "$dir" created)"
    printf '  base rev     r%s\n' "$(meta_get "$dir" base_rev)"
    local only; only=$(meta_get "$dir" only)
    if [[ -n $only ]]; then printf '  scope        %s\n' "$only"; fi
    local rev; rev=$(meta_get "$dir" committed_rev)
    printf '  state        %s\n' "${rev:+committed as r$rev}${rev:-pending}"

    printf '\n%spaths%s\n' "$C_BOLD" "$C_RESET"
    local letter kind path cur
    while IFS=$'\t' read -r letter kind path; do
        cur=$(status_letter_of "$path")
        if [[ $cur == "$letter" ]]; then
            printf '  %s  %s\n' "$letter" "$path"
        else
            printf '  %s  %s   %s(now: %s)%s\n' "$letter" "$path" "$C_DIM" "$cur" "$C_RESET"
        fi
    done <"$dir/manifest.tsv"

    printf '\n%smessage%s\n' "$C_BOLD" "$C_RESET"
    if [[ -s $dir/message.txt ]]; then
        sed 's/^/  | /' "$dir/message.txt"
    else
        printf '  %s<empty>%s\n' "$C_DIM" "$C_RESET"
    fi

    if ((want_diff)); then
        printf '\n%sdiff at snapshot time%s\n' "$C_BOLD" "$C_RESET"
        cat "$dir/diff.patch"
    fi
}

cmd_diff() {
    local name=${1:?usage: $PROG diff <name>}
    snap_exists "$name" || die "no such snapshot: '$name'"
    local dir; dir=$(snap_dir "$name")

    info "${C_DIM}changes made since snapshot '$name' (snapshot -> working copy)${C_RESET}"
    local letter kind path stored
    local any=0
    while IFS=$'\t' read -r letter kind path; do
        [[ $kind == file ]] || continue
        stored=$dir/files/$path
        if [[ $letter == D ]]; then
            if [[ -f $path ]]; then info "restored since snapshot: $path"; any=1; fi
            continue
        fi
        if [[ ! -f $path ]]; then
            info "deleted since snapshot: $path"; any=1; continue
        fi
        if cmp -s "$stored" "$path"; then continue; fi
        any=1
        diff -u --label "snapshot/$path" --label "working/$path" "$stored" "$path" || true
    done <"$dir/manifest.tsv"
    ((any)) || info "no changes since the snapshot"
}

cmd_msg() {
    local name=${1:?usage: $PROG msg <name>}
    snap_exists "$name" || die "no such snapshot: '$name'"
    "${EDITOR:-${VISUAL:-vi}}" "$(snap_dir "$name")/message.txt"
    ok "message updated"
}

cmd_setrev() {
    # Record (or correct) the revision a snapshot was committed as -- for a
    # commit whose revision could not be read back from svn's output.
    local name=${1:?usage: $PROG setrev <name> <rev>}
    local rev=${2:?usage: $PROG setrev <name> <rev>}
    snap_exists "$name" || die "no such snapshot: '$name'"
    rev=${rev#r}
    [[ $rev =~ ^[0-9]+$ ]] || die "'$rev' is not a revision number"

    local dir; dir=$(snap_dir "$name")
    local old; old=$(meta_get "$dir" committed_rev)
    meta_set "$dir" committed_rev "$rev"
    [[ -n $(meta_get "$dir" committed_at) ]] || meta_set "$dir" committed_at "$(now_utc)"
    if [[ -n $old && $old != "$rev" ]]; then
        ok "snapshot '$name' now recorded as r$rev (was r$old)"
    else
        ok "snapshot '$name' recorded as committed in r$rev"
    fi
}

cmd_delete() {
    local name= yes=0
    while (($#)); do
        case $1 in
            --yes|-y) yes=1; shift ;;
            -*) die "delete: unknown option '$1'" ;;
            *) [[ -z $name ]] || die "delete: unexpected argument '$1'"; name=$1; shift ;;
        esac
    done
    [[ -n $name ]] || die "usage: $PROG delete <name>"
    snap_exists "$name" || die "no such snapshot: '$name'"

    local dir; dir=$(snap_dir "$name")
    if [[ -z $(meta_get "$dir" committed_rev) ]] && ((!yes)); then
        confirm "Snapshot '$name' has not been committed. Delete it anyway?" || die "aborted"
    fi
    rm -rf "$dir"
    ok "snapshot '$name' deleted"
}

# ---------------------------------------------------------------------------
# ignore-setup
# ---------------------------------------------------------------------------

cmd_ignore_setup() {
    local cfg=${HOME}/.subversion/config
    [[ -f $cfg ]] || die "no $cfg (run any svn command once to create it)"

    if grep -qE '^[[:space:]]*global-ignores[^#]*(^|[[:space:]])\.svnsnap([[:space:]]|$)' "$cfg"; then
        ok ".svnsnap is already in global-ignores"
        return 0
    fi

    local line; line=$(grep -nE '^[[:space:]]*global-ignores[[:space:]]*=' "$cfg" | head -n1 || true)
    cp "$cfg" "$cfg.svnsnap-backup"
    if [[ -n $line ]]; then
        sed -i "${line%%:*}s|\$| .svnsnap|" "$cfg"
    else
        printf '\n[miscellany]\nglobal-ignores = .svnsnap\n' >>"$cfg"
    fi
    ok "added .svnsnap to global-ignores in $cfg (backup: $cfg.svnsnap-backup)"
    info "it will no longer appear in svn status"
}

# ---------------------------------------------------------------------------
# help
# ---------------------------------------------------------------------------

cmd_help() {
    cat <<EOF
${C_BOLD}$PROG${C_RESET} -- named snapshots of a Subversion working copy

Freeze the current state of your changes under a name, keep working, then
commit that frozen state alone. Your newer edits stay in the working copy.

${C_BOLD}USAGE${C_RESET}
  $PROG save <name> [-m MSG | -F FILE] [--only PATH]... [--include PATH]... [--force]
        Freeze all versioned changes (M/A/D/R) under <name>.
        The commit message is taken from msgcommit.txt unless -m/-F is given.
        --include adds an unversioned file (a new .F90 not yet svn-added).
        --only restricts the snapshot to a subtree or a single file; repeat it
        for several. Changes elsewhere are ignored, and stay in the working
        copy for a later commit -- as does a conflict outside the scope.

  $PROG commit <name> [--dry-run] [-m MSG | -F FILE] [--force] [--yes]
        Commit the snapshot's content, then put your newer work back.
        Only the snapshot's paths are committed; everything else is untouched.
        --dry-run shows exactly what would be committed and changes nothing.

  $PROG list                 List snapshots.
  $PROG show <name> [--diff] Show paths, message and state of a snapshot.
  $PROG diff <name>          What you have changed since the snapshot.
  $PROG msg <name>           Edit the snapshot's commit message.
  $PROG setrev <name> <rev>  Record the revision a snapshot was committed as.
  $PROG restore <name>       Put the snapshot's content back in the working copy.
  $PROG delete <name>        Remove a snapshot.
  $PROG stashes              List automatic safety stashes.
  $PROG unstash <id>         Roll the working copy back to a stash.
  $PROG recover              Undo an interrupted commit (also done automatically).
  $PROG ignore-setup         Hide .svnsnap from svn status (edits ~/.subversion/config).

${C_BOLD}TYPICAL SESSION${C_RESET}
  \$ $PROG save geothermal        # freeze today's finished work
  ... keep editing the same files ...
  \$ $PROG diff geothermal        # what have I done since?
  \$ $PROG commit geothermal -n   # dry run first
  \$ $PROG commit geothermal      # commits only the frozen state
  \$ svn status                   # shows only the newer work

${C_BOLD}SNAPSHOTTING PART OF THE TREE${C_RESET}
  \$ $PROG save gen --only LMDZ.GENERIC
  \$ $PROG save pem --only PEM/src/mars --only PEM/changelog.txt

Snapshots live in $SNAPROOT
EOF
}

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

main() {
    local cmd=${1:-help}
    (($#)) && shift || true

    # Before anything else, put the working copy back if a previous run was
    # killed mid-commit.
    case $cmd in
        help|-h|--help|unstash|stashes) : ;;
        *) recover_pending ;;
    esac

    case $cmd in
        save)                 cmd_save "$@" ;;
        commit|ci)            cmd_commit "$@" ;;
        list|ls)              cmd_list "$@" ;;
        show|info)            cmd_show "$@" ;;
        diff)                 cmd_diff "$@" ;;
        msg|message)          cmd_msg "$@" ;;
        setrev)               cmd_setrev "$@" ;;
        restore)              cmd_restore "$@" ;;
        delete|rm|remove)     cmd_delete "$@" ;;
        stashes)              cmd_stashes "$@" ;;
        unstash)              cmd_unstash "$@" ;;
        recover)              recover_pending; ok "nothing else to recover" ;;
        ignore-setup)         cmd_ignore_setup "$@" ;;
        help|-h|--help)       cmd_help ;;
        *) die "unknown command '$cmd' (try: $PROG help)" ;;
    esac
}

main "$@"
