#!/bin/sh
# `fossil tarball` vs `zit tarball`, as a differential harness (#95).
#
# The target invariant: for one check-in, the two tools produce archives that
# UNPACK TO THE SAME TREE — same member set, same contents, and same modes.
#
# Modes are the reason this exists rather than a self-comparison. An archive
# whose members are all 0644 looks perfectly correct — right files, right
# bytes, right hashes — until someone unpacks a release and `./configure` will
# not run. Nothing zit can check against itself catches that, because the
# writer and the reader would agree in the same wrong direction; fossil is an
# independent witness that already gets it right.
#
# What it compares, in the order a failure is worth reading:
#   1. member sets   -- a file present in one archive and not the other
#   2. modes         -- regular / executable / symlink, per member
#   3. contents      -- sha256 per member, so a truncated blob is named
#
# The archives are NOT compared byte for byte, deliberately: fossil and zit
# make different, equally legal tar choices (fossil emits directory members,
# the header flavours differ), and demanding identical bytes would fail on
# differences no consumer can observe. Unpacking is what a consumer does.
#
# Usage:  scripts/tarball-fossil-diff.sh ORIGIN.fossil [VERSION] [WORKDIR]
#
# VERSION defaults to the origin's trunk tip, named by its full artifact id so
# both tools resolve the identical check-in. WORKDIR defaults to a fresh
# mktemp directory, left behind on failure; removed on success unless KEEP=1.
#
# Requires: a built ./zig-out/bin/zit, and `fossil` and `python3` on PATH.
set -eu

usage() {
    echo "usage: $0 ORIGIN.fossil [VERSION] [WORKDIR]" >&2
    exit 2
}

[ $# -ge 1 ] || usage
ORIGIN=$1
[ -f "$ORIGIN" ] || { echo "$0: no such repository: $ORIGIN" >&2; exit 2; }
ORIGIN=$(cd "$(dirname "$ORIGIN")" && pwd)/$(basename "$ORIGIN")
VERSION=${2:-}

ROOT=$(cd "$(dirname "$0")/.." && pwd)
ZIT=$ROOT/zig-out/bin/zit
[ -x "$ZIT" ] || { echo "$0: build zit first (zig build): $ZIT" >&2; exit 2; }
command -v fossil >/dev/null || { echo "$0: fossil is not on PATH" >&2; exit 2; }
command -v python3 >/dev/null || { echo "$0: python3 is not on PATH" >&2; exit 2; }

WORK=${3:-$(mktemp -d)}
mkdir -p "$WORK"
WORK=$(cd "$WORK" && pwd)

# The same isolation the round-trip harness needs, for the same reason: this
# must not register throwaway clones in the operator's real ~/.fossil, and a
# leaked server from an earlier run must not lock this one out.
FOSSIL_HOME=$WORK
export FOSSIL_HOME

port_free() {
    python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()'
}

SERVER_PID=
cleanup() {
    [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null
    return 0
}
trap cleanup EXIT INT TERM

fail() {
    echo >&2
    echo "TARBALL DIFF FAILED: $1" >&2
    echo "  work directory kept for examination: $WORK" >&2
    exit 1
}

step() { echo "== $*"; }

# The top-level directory both tools are told to use. Fixed, so a difference
# in the two default-derivation rules cannot masquerade as a member-set
# difference — that rule is zit's own choice and is not what this compares.
TOP=diff-top

# --- 1. pick the check-in --------------------------------------------------
step "reading the origin"
if [ -z "$VERSION" ]; then
    # The trunk tip by FULL artifact id: a symbolic name could in principle
    # resolve differently in the two tools, and then a real difference and a
    # naming difference would be indistinguishable.
    VERSION=$(fossil sqlite3 --readonly -R "$ORIGIN" \
        "select blob.uuid from event join blob on blob.rid=event.objid
         where event.type='ci' order by event.mtime desc limit 1;")
fi
[ -n "$VERSION" ] || fail "the origin has no check-in to archive"
echo "   check-in $VERSION"

# --- 2. ORIGIN -> zit ------------------------------------------------------
step "serving the origin with stock fossil, cloning it into zit"
PORT=$(port_free)
fossil server "$ORIGIN" --port "$PORT" > "$WORK/fossil-serve.log" 2>&1 &
SERVER_PID=$!
sleep 2
rm -rf "$WORK/zit"
"$ZIT" clone "http://127.0.0.1:$PORT/" "$WORK/zit" > "$WORK/zit-clone.log" 2>&1 \
    || fail "zit clone of the origin failed (see $WORK/zit-clone.log)"
# Reap it: an unwaited server keeps the global config database locked, and the
# next fossil command dies with "database is locked" while nothing was wrong.
kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null || :; SERVER_PID=

# --- 3. both tarballs ------------------------------------------------------
step "writing both tarballs"
fossil tarball -R "$ORIGIN" --name "$TOP" "$VERSION" "$WORK/fossil.tar.gz" \
    > "$WORK/fossil-tarball.log" 2>&1 \
    || fail "fossil tarball failed (see $WORK/fossil-tarball.log)"
"$ZIT" tarball -R "$WORK/zit" "$VERSION" "$WORK/zit.tar.gz" --name "$TOP" \
    > "$WORK/zit-tarball.log" 2>&1 \
    || fail "zit tarball failed (see $WORK/zit-tarball.log)"
echo "   fossil $(wc -c < "$WORK/fossil.tar.gz" | tr -d ' ') bytes," \
     "zit $(wc -c < "$WORK/zit.tar.gz" | tr -d ' ') bytes"

# --- 4. unpack and describe ------------------------------------------------
step "unpacking both and describing every member"
for side in fossil zit; do
    rm -rf "$WORK/x-$side"
    mkdir -p "$WORK/x-$side"
    tar -xzf "$WORK/$side.tar.gz" -C "$WORK/x-$side" \
        || fail "could not unpack the $side archive"
    [ -d "$WORK/x-$side/$TOP" ] \
        || fail "the $side archive has no top-level directory '$TOP'"
done

# One description per member: path, kind, executable bit, content hash.
# Directories are skipped rather than compared: fossil emits directory members
# and zit does not, which is a legal difference in what the archive SAYS and no
# difference at all in the tree it produces.
describe() {
    python3 - "$1" <<'PY'
import hashlib, os, stat, sys
root = sys.argv[1]
rows = []
for dirpath, dirnames, filenames in os.walk(root):
    dirnames.sort()
    for name in sorted(filenames):
        full = os.path.join(dirpath, name)
        rel = os.path.relpath(full, root)
        st = os.lstat(full)
        if stat.S_ISLNK(st.st_mode):
            payload = os.readlink(full).encode()
            kind = "symlink"
        else:
            with open(full, "rb") as fh:
                payload = fh.read()
            kind = "exec" if st.st_mode & 0o111 else "file"
        rows.append("%s\t%s\t%s" % (rel, kind, hashlib.sha256(payload).hexdigest()))
# os.walk does not descend into symlinked directories, and a symlink to a
# directory lands in dirnames rather than filenames -- catch those too.
for dirpath, dirnames, filenames in os.walk(root):
    for name in sorted(dirnames):
        full = os.path.join(dirpath, name)
        if os.path.islink(full):
            rel = os.path.relpath(full, root)
            payload = os.readlink(full).encode()
            rows.append("%s\tsymlink\t%s" % (rel, hashlib.sha256(payload).hexdigest()))
for row in sorted(rows):
    print(row)
PY
}
describe "$WORK/x-fossil/$TOP" > "$WORK/fossil.members"
describe "$WORK/x-zit/$TOP" > "$WORK/zit.members"
echo "   fossil $(wc -l < "$WORK/fossil.members" | tr -d ' ') members," \
     "zit $(wc -l < "$WORK/zit.members" | tr -d ' ') members"

# --- 5. compare ------------------------------------------------------------
# One comparator rather than a pipeline of join and awk. The three categories
# are reported separately on purpose: "same bytes, wrong bit" is a different
# bug from "wrong bytes", and rolling them together hides which one happened.
#
# The shell-native spelling of this needs process substitution, which is a
# bashism -- this script is #!/bin/sh, and `<(...)` fails at PARSE time, so a
# harness written that way dies before it compares anything.
step "comparing member sets, modes, and contents"
status=0
python3 "$ROOT/scripts/archive-compare.py" "$WORK/fossil.members" "$WORK/zit.members" >&2 || status=1

# --- 6. zit's own extra promise -------------------------------------------
# Reproducibility is zit's, not fossil's -- fossil stamps the archive with the
# current time and its tarballs differ run to run. Checked here anyway because
# this harness already has a real corpus and a built binary, which is exactly
# what the property needs and what a unit test cannot supply.
step "zit reproducibility"
"$ZIT" tarball -R "$WORK/zit" "$VERSION" "$WORK/zit2.tar.gz" --name "$TOP" \
    > "$WORK/zit-tarball2.log" 2>&1 \
    || fail "the second zit tarball failed (see $WORK/zit-tarball2.log)"
if cmp -s "$WORK/zit.tar.gz" "$WORK/zit2.tar.gz"; then
    echo "   two runs are byte-identical"
else
    echo "   DIFFER: two runs of one check-in produced different bytes" >&2
    status=1
fi

echo
if [ "$status" -eq 0 ]; then
    echo "TARBALL DIFF CLEAN"
    if [ "${KEEP:-0}" = "1" ]; then
        echo "  work directory kept: $WORK"
    else
        rm -rf "$WORK"
    fi
    exit 0
fi
fail "see the differences above"
