Ctrl K
ComunidadGuías…

docker/unsloth_sync_notebooks.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# Populate and refresh /workspace/unsloth-notebooks from unslothai/notebooks. The
# user's edits always win: each written file's hash is recorded, and a file whose
# hash differs is left untouched.
#
# Opt-out / tuning (all optional):
#   UNSLOTH_SKIP_NOTEBOOK_SYNC=1      do nothing (no populate, no refresh)
#   UNSLOTH_SKIP_NOTEBOOK_REFRESH=1   populate from the baked template only;
#                                     never touch the network
#   UNSLOTH_KEEP_DELETED_NOTEBOOKS=1  do not restore notebooks the user deleted
#                                     (default: deleted files are healed back)
#   UNSLOTH_KEEP_REMOVED_NOTEBOOKS=1  keep pristine notebooks that upstream deleted
#                                     or renamed (default: they are removed too)
#   UNSLOTH_NOTEBOOKS_DIR=<path>      target dir (default /workspace/unsloth-notebooks)
#   UNSLOTH_NOTEBOOKS_REPO=<url>      source repo (default unslothai/notebooks)
#   UNSLOTH_NOTEBOOK_FETCH_TIMEOUT=N  seconds for each network op (default 60)
#   UNSLOTH_SKIP_NOTEBOOK_VIEW=1      do not build the categorized folder view
#   UNSLOTH_NOTEBOOKS_VIEW_DIR=<path> categorized view dir
#                                     (default "/workspace/Unsloth Notebooks")
#   UNSLOTH_NB_GPU=amd|cuda           force AMD-* notebook visibility (default:
#                                     autodetect; AMD-* shown only on AMD/HIP)
#   UNSLOTH_KEEP_COLAB_INTRO=1        keep the Colab "Run all on Colab" sentence
#                                     (default: strip it for the Docker image)
set -u

TEMPLATE="${UNSLOTH_NOTEBOOKS_TEMPLATE:-/opt/unsloth-notebooks}"
DEST="${UNSLOTH_NOTEBOOKS_DIR:-/workspace/unsloth-notebooks}"
REMOTE="${UNSLOTH_NOTEBOOKS_REPO:-https://github.com/unslothai/notebooks}"
STATE="$DEST/.unsloth_sync_state"     # "sha256  relpath" of what we last wrote
SYNCED="$DEST/.unsloth_sync_commit"   # upstream commit we last synced to
LOCK="$DEST/.unsloth_sync.lock"       # serialises this script against itself
PARTIAL="$DEST/.unsloth_sync_partial" # set when first-boot populate left files behind
TIMEOUT="${UNSLOTH_NOTEBOOK_FETCH_TIMEOUT:-60}"
LOCK_WAIT="${UNSLOTH_NOTEBOOK_LOCK_TIMEOUT:-600}"

PYBIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
_self_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
resolve_helper() {
    if [ -n "$1" ]; then printf '%s' "$1"; return 0; fi
    if command -v "$2" >/dev/null 2>&1; then command -v "$2"; return 0; fi
    [ -n "$_self_dir" ] && [ -f "$_self_dir/$3" ] && printf '%s' "$_self_dir/$3"
    return 0
}
SIG_HELPER="$(resolve_helper "${UNSLOTH_NB_SIG_HELPER:-}" unsloth-nb-content-sig unsloth_nb_content_sig.py)"
VIEW_HELPER="$(resolve_helper "${UNSLOTH_NB_VIEW_HELPER:-}" unsloth-nb-view unsloth_nb_view.py)"
STRIP_HELPER="$(resolve_helper "${UNSLOTH_NB_STRIP_HELPER:-}" unsloth-nb-strip-colab unsloth_nb_strip_colab.py)"

# so a refresh does not rewrite a notebook when only boilerplate moved
middle_unchanged() {
    case "$1" in *.ipynb) : ;; *) return 1 ;; esac
    [ -n "$PYBIN" ] && [ -n "$SIG_HELPER" ] || return 1
    [ "${UNSLOTH_NOTEBOOK_BODY_AWARE:-1}" = "1" ] || return 1
    [ "$("$PYBIN" "$SIG_HELPER" "$1" "$2" 2>/dev/null)" = "SAME" ] || return 1
    return 0
}

[ "${UNSLOTH_SKIP_NOTEBOOK_SYNC:-0}" = "1" ] && exit 0
[ -d "$TEMPLATE" ] || exit 0

hash_of() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1; }

# Give a new file the default mode and the OWNER of its directory, so a host-owned bind
# mount does not get root:root notebooks.
own_like_dir() {
    chmod "$(printf '%03o' "$(( 0666 & ~0$(umask) ))")" "$1" 2>/dev/null || true
    chown --reference="$2" "$1" 2>/dev/null || true
    return 0
}

# rename swaps the DIRECTORY ENTRY, so without this the clone's root:root 0644 lands
# on a bind-mounted notebook the host user owns. Best effort.
stage_metadata() {
    if [ ! -e "$2" ]; then
        # A notebook upstream just added: no destination metadata exists, and
        # returning here published the clone's ownership unchanged.
        own_like_dir "$1" "$(dirname "$2")"
        return 0
    fi
    chmod --reference="$2" "$1" 2>/dev/null || true
    chown --reference="$2" "$1" 2>/dev/null || true
    return 0
}

# plain `cp` truncates the existing inode and leaves its metadata alone, where
# `cp -a` would chown the host user's file to root
cp_keep_meta() {
    if [ -e "$2" ]; then cp "$1" "$2" 2>/dev/null; else cp -a "$1" "$2" 2>/dev/null; fi
}

# mkdir gives the caller's uid; anchor ownership on the nearest existing ancestor.
mkdir_keep_owner() {
    local dir="$1" anchor parent rest cur part
    local -a parts
    [ -d "$dir" ] && return 0
    anchor="$dir"
    while [ ! -d "$anchor" ]; do
        parent="$(dirname "$anchor")"
        [ "$parent" = "$anchor" ] && break
        anchor="$parent"
    done
    mkdir -p "$dir" 2>/dev/null || return 0
    [ -d "$anchor" ] || return 0
    rest="${dir#"$anchor"}"
    cur="$anchor"
    IFS='/' read -r -a parts <<< "$rest"
    for part in ${parts[@]+"${parts[@]}"}; do
        [ -z "$part" ] && continue
        cur="$cur/$part"
        chown --reference="$anchor" "$cur" 2>/dev/null || true
    done
    return 0
}

# Through the helper: $DEST is the ownership anchor for everything below.
mkdir_keep_owner "$DEST"
[ -d "$DEST" ] || exit 0

# The refresh runs in a detached child, so both copies mutate $DEST/$STATE; one lock
# per invocation serialises them.
_LOCK_HELD=0
lock_acquire() {
    [ "$_LOCK_HELD" = "1" ] && return 0
    command -v flock >/dev/null 2>&1 || return 0
    # group-redirect: bash reports a failed exec redirection before applying it
    { exec 9>>"$LOCK"; } 2>/dev/null || return 0
    flock -w "$LOCK_WAIT" 9 2>/dev/null || return 0
    _LOCK_HELD=1
    return 0
}
lock_release() {
    [ "$_LOCK_HELD" = "1" ] || return 0
    _LOCK_HELD=0
    flock -u 9 2>/dev/null || true
    exec 9>&- 2>/dev/null || true
    return 0
}

nb_gpu_is_amd() {
    case "${UNSLOTH_NB_GPU:-}" in
        amd|AMD|hip|HIP|rocm|ROCm|ROCM) return 0 ;;
        cuda|CUDA|nvidia|NVIDIA|nv|NV) return 1 ;;
    esac
    if command -v nvidia-smi >/dev/null 2>&1 \
       && nvidia-smi -L 2>/dev/null | grep -q '^GPU'; then
        return 1
    fi
    if command -v rocm-smi >/dev/null 2>&1 || command -v rocminfo >/dev/null 2>&1; then
        return 0
    fi
    return 1   # default: non-AMD, so AMD-* stay hidden
}

build_categorized_view() {
    [ "${UNSLOTH_SKIP_NOTEBOOK_VIEW:-0}" = "1" ] && return 0
    [ -n "$PYBIN" ] && [ -n "$VIEW_HELPER" ] || return 0
    [ -d "$DEST/nb" ] || return 0
    _view="${UNSLOTH_NOTEBOOKS_VIEW_DIR:-/workspace/Unsloth Notebooks}"
    if nb_gpu_is_amd; then
        "$PYBIN" "$VIEW_HELPER" "$DEST" "$_view" --amd 2>/dev/null || true
    else
        "$PYBIN" "$VIEW_HELPER" "$DEST" "$_view" 2>/dev/null || true
    fi
}

strip_colab_intros() {
    [ "${UNSLOTH_KEEP_COLAB_INTRO:-0}" = "1" ] && return 0
    [ -n "$PYBIN" ] && [ -n "$STRIP_HELPER" ] || return 0
    [ -f "$STATE" ] || return 0
    "$PYBIN" "$STRIP_HELPER" --state "$STATE" --dest "$DEST" 2>/dev/null || true
}

# called explicitly BEFORE the fork, so the strip never overlaps the child's copy
_FINALIZED=0
finalize() {
    [ "$_FINALIZED" = "1" ] && return 0
    _FINALIZED=1
    strip_colab_intros
    build_categorized_view
    return 0
}
trap 'finalize; lock_release' EXIT

lock_acquire

# Undo section 1b's restores when its state rewrite is abandoned.
undo_restores() {
    [ "${#RESTORED_PATHS[@]}" -gt 0 ] || return 0
    for _p in "${RESTORED_PATHS[@]}"; do
        rm -f "$_p" 2>/dev/null || true
    done
    RESTORED_PATHS=()
}


record_state() {
    : > "$STATE.tmp" 2>/dev/null || return 0
    ( cd "$DEST" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do
        rel="${rel#./}"
        case "$rel" in
            .unsloth_sync_state|.unsloth_sync_state.tmp|.unsloth_sync_commit) continue ;;
            .unsloth_sync.lock|.unsloth_sync_partial) continue ;;
        esac
        printf '%s  %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp"
    done
    mv "$STATE.tmp" "$STATE" 2>/dev/null || rm -f "$STATE.tmp"
}

# 1) First-boot populate; re-runs when the last one left files behind. Process
# substitution so the counter survives the loop.
if [ ! -f "$STATE" ] || [ -f "$PARTIAL" ]; then
    # Unlink a leftover $STATE.tmp from a different uid, or appends fail and a foreign file
    # is published as our state.
    # -- which needs write on $DEST, not on the file -- then publishes the FOREIGN
    rm -f "$STATE.tmp" 2>/dev/null || true
    populate_failed=0
    # Set when the merge loop loses a record; those are the only records for upstream-only
    # notebooks.
    merge_lost=0
    stage_ok=1
    if ! : > "$STATE.tmp" 2>/dev/null; then
        # Still unstageable, so $DEST itself is the problem. Nothing has been copied
        # yet, so bail like the refresh child does: leave the marker off and retry on
        # the next start rather than publish notebooks we cannot record.
        echo "[unsloth-nb] the sync state could not be staged in $DEST; leaving the sync marker off so the next start retries"
        stage_ok=0
    fi
    while IFS= read -r -d '' rel; do
        [ "$stage_ok" = 1 ] || continue
        rel="${rel#./}"
        case "$rel" in .unsloth_template_commit) continue ;; esac
        mkdir_keep_owner "$DEST/$(dirname "$rel")"
        # user data: do NOT record it, else the refresh reads it as pristine
        if [ -e "$DEST/$rel" ]; then
            if [ "$(hash_of "$DEST/$rel")" != "$(hash_of "$TEMPLATE/$rel")" ]; then
                echo "[unsloth-nb] kept existing user file: $DEST/$rel"
                continue
            fi
            # Same bytes already there, so copying would only stamp root:root on it.
            # Check every append: an unrecorded file reads as a user edit forever.
            printf '%s  %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp" \
                || populate_failed=$((populate_failed + 1))
            continue
        fi
        if cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null; then
            # cp -a preserves the TEMPLATE's root:root 0644; hand it to the host user.
            own_like_dir "$DEST/$rel" "$(dirname "$DEST/$rel")"
            printf '%s  %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp" \
                || populate_failed=$((populate_failed + 1))
        else
            populate_failed=$((populate_failed + 1))
        fi
    done < <(cd "$TEMPLATE" && find . -type f -print0)
    # A retry must keep records the refresh already manages. -r, not just -f: an unreadable
    # $STATE would silently contribute nothing.
    if [ "$stage_ok" = 1 ] && [ -f "$STATE" ] && [ ! -r "$STATE" ]; then
        echo "[unsloth-nb] the sync state in $DEST is unreadable; leaving it alone rather than republishing it without the records it holds"
        stage_ok=0
    fi
    if [ "$stage_ok" = 1 ] && [ -f "$STATE" ]; then
        declare -A POPULATED=()
        while IFS= read -r line; do
            p="${line#*  }"
            [ -n "$p" ] && [ "$p" != "$line" ] && POPULATED["$p"]=1
        done < "$STATE.tmp"
        while IFS= read -r line; do
            p="${line#*  }"
            [ -n "$p" ] && [ "$p" != "$line" ] || continue
            [ -n "${POPULATED[$p]:-}" ] && continue
            # same reason as the copy loop: a dropped record here hands a notebook the
            # refresh already manages to the user permanently
            printf '%s\n' "$line" >> "$STATE.tmp" \
                || { populate_failed=$((populate_failed + 1)); merge_lost=1; }
        done < "$STATE"
        unset POPULATED
    fi
    # Only publish a COMPLETE state; discarding is recoverable, a short state is not.
    if [ "$stage_ok" = 1 ] && [ "$merge_lost" -eq 0 ]; then
        mv "$STATE.tmp" "$STATE" 2>/dev/null || rm -f "$STATE.tmp"
    else
        rm -f "$STATE.tmp" 2>/dev/null || true
    fi
    if [ "$stage_ok" != 1 ]; then
        : > "$PARTIAL" 2>/dev/null || true
        rm -f "$SYNCED" 2>/dev/null || true
    elif [ "$populate_failed" -eq 0 ]; then
        rm -f "$PARTIAL" 2>/dev/null || true
        cp -a "$TEMPLATE/.unsloth_template_commit" "$SYNCED" 2>/dev/null || true
        echo "[unsloth-nb] notebooks ready at $DEST"
    else
        : > "$PARTIAL" 2>/dev/null || true
        rm -f "$SYNCED" 2>/dev/null || true
        echo "[unsloth-nb] $populate_failed notebook(s) could not be written; leaving the sync marker off so the next start retries"
    fi
fi

if [ -f "$STATE" ] && [ "${UNSLOTH_KEEP_DELETED_NOTEBOOKS:-0}" != "1" ]; then
    restored=0
    downgraded=0
    # Beside $STATE, not /tmp: an atomic same-dir rename that keeps $STATE's owner and mode.
    RS_TMP="$STATE.tmp"
    # Abandon the rewrite on a failed append; the old $STATE is still valid.
    rs_ok=1
    # Files this loop restores (previously absent), so a doomed rewrite can be undone.
    RESTORED_PATHS=()
    rm -f "$RS_TMP" 2>/dev/null || true
    if ! : > "$RS_TMP" 2>/dev/null; then
        rs_ok=0
    fi
    # An unreadable $STATE must not become an EMPTY one (no set -e).
    if [ "$rs_ok" = 1 ] && [ ! -r "$STATE" ]; then
        echo "[unsloth-nb] the sync state in $DEST is unreadable; leaving it alone rather than replacing it with an empty one"
        rs_ok=0
    fi
    [ "$rs_ok" = 1 ] && while IFS= read -r line; do
        # Re-tested inside the loop so a failed append stops further restores.
        [ "$rs_ok" = 1 ] || continue
        h="${line%%  *}"; rel="${line#*  }"
        if [ -n "$rel" ] && [ "$rel" != "$line" ] \
           && [ ! -e "$DEST/$rel" ] && [ -f "$TEMPLATE/$rel" ]; then
            mkdir_keep_owner "$DEST/$(dirname "$rel")"
            if cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null; then
                # cp -a preserves the TEMPLATE's root:root 0644; hand it to the host user.
                own_like_dir "$DEST/$rel" "$(dirname "$DEST/$rel")"
                RESTORED_PATHS+=("$DEST/$rel")
                new_h="$(hash_of "$DEST/$rel")"
                printf '%s  %s\n' "$new_h" "$rel" >> "$RS_TMP" || rs_ok=0
                restored=$((restored + 1))
                # The restored copy is older than the record, so drop the marker to force a refresh.
                [ "$new_h" != "$h" ] && downgraded=$((downgraded + 1))
                continue
            fi
        fi
        printf '%s\n' "$line" >> "$RS_TMP" || rs_ok=0
    done < "$STATE"
    if [ "$rs_ok" = 1 ]; then
        # Loud, and drop the marker, like the sibling branch below.
        mv "$RS_TMP" "$STATE" 2>/dev/null || {
            echo "[unsloth-nb] the sync state could not be published in $DEST; keeping the previous state and dropping the sync marker so the next start retries"
            rm -f "$RS_TMP"
            undo_restores
            rm -f "$SYNCED" 2>/dev/null || true
        }
    else
        echo "[unsloth-nb] the sync state could not be rewritten in $DEST; keeping the previous state and dropping the sync marker so the next start retries"
        rm -f "$RS_TMP"
        undo_restores
        rm -f "$SYNCED" 2>/dev/null || true
    fi
    # Only when one went backwards, or every delete would cost a full clone.
    if [ "$downgraded" -gt 0 ]; then
        rm -f "$SYNCED" 2>/dev/null || true
    fi
    [ "$restored" -gt 0 ] \
        && echo "[unsloth-nb] restored $restored deleted notebook(s) from the baked set ($downgraded needing a refresh)"
fi

# 2) Best-effort GitHub refresh, detached because ls-remote + clone can spend 2x
# TIMEOUT when offline and must not delay container startup.
[ "${UNSLOTH_SKIP_NOTEBOOK_REFRESH:-0}" = "1" ] && exit 0
command -v git >/dev/null 2>&1 || exit 0
command -v sha256sum >/dev/null 2>&1 || exit 0
if [ "${UNSLOTH_NB_REFRESH_CHILD:-0}" != "1" ]; then
    # BEFORE the fork: the EXIT trap fired while the child was already copying
    finalize
    lock_release
    UNSLOTH_NB_REFRESH_CHILD=1 "$0" >/dev/null 2>&1 &
    exit 0
fi

# --- refresh child ---
# the parent already finalized, so re-arm below only if this refresh rewrites anything
_FINALIZED=1

last="$(cat "$SYNCED" 2>/dev/null || true)"
remote="$(timeout "$TIMEOUT" git ls-remote "$REMOTE" HEAD 2>/dev/null | cut -f1)"
[ -z "$remote" ] && exit 0            # offline: keep what we have
[ "$remote" = "$last" ] && exit 0

TMP="$(mktemp -d)"
if ! timeout "$TIMEOUT" git clone -q --depth 1 "$REMOTE" "$TMP" 2>/dev/null; then
    rm -rf "$TMP"; exit 0             # network died mid-way: keep what we have
fi

# An unreadable $STATE must not publish an EMPTY state and stamp the marker.
if [ -f "$STATE" ] && [ ! -r "$STATE" ]; then
    echo "[unsloth-nb] the sync state in $DEST is unreadable; skipping the refresh rather than republishing it empty"
    rm -rf "$TMP"
    exit 0
fi

declare -A LAST
if [ -f "$STATE" ]; then
    while read -r h p; do
        [ -n "${p:-}" ] && LAST["$p"]="$h"
    done < "$STATE"
fi

# Beside $STATE, not /tmp, so the publish is an atomic rename that fails with the notebooks.
TMPSTATE="$STATE.tmp"
# No /tmp fallback: it would record nothing when $DEST is unwritable. Clear a leftover
# from a different uid first.
rm -f "$TMPSTATE" 2>/dev/null || true
if ! : > "$TMPSTATE" 2>/dev/null; then
    echo "[unsloth-nb] the sync state could not be staged in $DEST; leaving the sync marker so the next start retries"
    rm -rf "$TMP"
    exit 0
fi
updated=0; kept=0; unchanged=0; failed=0
# Check every append: a lost record with `failed` at 0 publishes a truncated state and
# advances the marker, stranding notebooks as user edits.
record_tmpstate() {
    printf '%s  %s\n' "$1" "$2" >> "$TMPSTATE"
}

# Remove our own unrecordable copy (only while it still has our hash); otherwise the next
# refresh reads it as a user edit.
drop_unrecordable() {
    local _d="$DEST/$1"
    [ -e "$_d" ] || return 0
    [ "$(hash_of "$_d")" = "${LAST[$1]:-}" ] || return 0
    rm -f "$_d" 2>/dev/null || true
}

unrecorded() {
    failed=$((failed + 1))
    drop_unrecordable "$1"
}
while IFS= read -r -d '' f; do
    rel="${f#"$TMP"/}"
    case "$rel" in .git|.git/*) continue ;; esac
    dst="$DEST/$rel"
    if [ -e "$dst" ]; then
        rec="${LAST[$rel]:-}"
        if [ -z "$rec" ]; then
            # An unrecorded file byte-identical to the clone is ours (e.g. EBUSY on a file bind mount);
            # adopt its record.
            adopted="$(hash_of "$dst")"
            if [ -n "$adopted" ] && [ "$adopted" = "$(hash_of "$f")" ]; then
                record_tmpstate "$adopted" "$rel" || failed=$((failed + 1))
                unchanged=$((unchanged + 1))
                continue
            fi
            kept=$((kept + 1))
            continue
        fi
        if [ -n "$rec" ] && [ "$(hash_of "$dst")" != "$rec" ]; then
            record_tmpstate "$rec" "$rel" || unrecorded "$rel"
            kept=$((kept + 1))
            continue
        fi
        if [ -n "$rec" ] && middle_unchanged "$dst" "$f"; then
            # a changed package spec is NOT cosmetic and falls through below
            record_tmpstate "$rec" "$rel" || unrecorded "$rel"
            unchanged=$((unchanged + 1))
            continue
        fi
    elif [ -n "${LAST[$rel]:-}" ] && [ "${UNSLOTH_KEEP_DELETED_NOTEBOOKS:-0}" = "1" ]; then
        record_tmpstate "${LAST[$rel]}" "$rel" || unrecorded "$rel"
        kept=$((kept + 1))
        continue
    fi
    mkdir_keep_owner "$(dirname "$dst")"
    # Same-dir temp + rename: JupyterLab is already serving $DEST, and an in-place
    # cp -a exposes half-written JSON and destroys a save made since the hash check.
    new="$(dirname "$dst")/.unsloth_nb_new.$$"
    if cp -a "$f" "$new" 2>/dev/null; then
        # hash what is about to be PUBLISHED: re-reading $dst after the rename would
        # record a save that landed meanwhile as the pristine version
        staged="$(hash_of "$new")"
        if [ -e "$dst" ] && [ "$(hash_of "$dst")" != "${LAST[$rel]:-}" ]; then
            rm -f "$new"
            record_tmpstate "${LAST[$rel]:-}" "$rel" || unrecorded "$rel"
            kept=$((kept + 1))
            continue
        fi
        stage_metadata "$new" "$dst"
        # a single-FILE bind mount cannot be renamed over (EBUSY)
        if mv -f "$new" "$dst" 2>/dev/null || { rm -f "$new"; cp_keep_meta "$f" "$dst"; }; then
            # $staged, not hash_of "$dst": both branches write the bytes of "$f"
            if record_tmpstate "$staged" "$rel"; then
                updated=$((updated + 1))
            else
                failed=$((failed + 1))
                # Published but not recordable: remove it (it held no user edit) so the next refresh
                # re-copies and re-records it.
                rm -f "$dst" 2>/dev/null || true
            fi
        else
            # carry the PREVIOUS record forward, or the next refresh reads $dst as
            # user-owned and keeps the stale copy forever
            record_tmpstate "${LAST[$rel]:-}" "$rel" || drop_unrecordable "$rel"
            failed=$((failed + 1))
        fi
    else
        record_tmpstate "${LAST[$rel]:-}" "$rel" || drop_unrecordable "$rel"
        failed=$((failed + 1))
    fi
done < <(find "$TMP" -type f -print0)

# Upstream deleted or renamed it: remove our copy only if it still has our hash.
removed=0
if [ "${#LAST[@]}" -gt 0 ]; then
    # A case-only rename looks like a deletion on a case-insensitive bind mount.
    declare -A CLONED_LOWER=()
    while IFS= read -r -d '' f; do
        p="${f#"$TMP"/}"
        case "$p" in .git|.git/*) continue ;; esac
        CLONED_LOWER["${p,,}"]=1
    done < <(find "$TMP" -type f -print0)
    for rel in "${!LAST[@]}"; do
        [ -e "$TMP/$rel" ] && continue
        [ -n "${CLONED_LOWER[${rel,,}]:-}" ] && continue
        dst="$DEST/$rel"
        [ -f "$dst" ] || continue
        [ -n "${LAST[$rel]}" ] || continue
        [ "$(hash_of "$dst")" = "${LAST[$rel]}" ] || continue
        if [ "${UNSLOTH_KEEP_REMOVED_NOTEBOOKS:-0}" = "1" ]; then
            # Keep the record with the kept file, and never drop the file on a failed append: upstream
            # deleted it, so this is the last copy.
            record_tmpstate "${LAST[$rel]}" "$rel" || failed=$((failed + 1))
            continue
        fi
        if rm -f "$dst" 2>/dev/null; then
            removed=$((removed + 1))
            # one level, and never $DEST itself: `rmdir -p` would climb out of it
            d="$(dirname "$dst")"
            [ "$d" != "$DEST" ] && rmdir "$d" 2>/dev/null
        else
            # A file bind mount cannot be unlinked (EBUSY), so its record stays with it.
            record_tmpstate "${LAST[$rel]}" "$rel" || drop_unrecordable "$rel"
            failed=$((failed + 1))
        fi
    done
    unset CLONED_LOWER
fi

# The state must land before the marker, or stale hashes strand notebooks as user edits.
published=1
mv "$TMPSTATE" "$STATE" 2>/dev/null || { rm -f "$TMPSTATE"; published=0; }
# recording the commit after a failure makes the next boot exit on remote == last
if [ "$failed" -eq 0 ] && [ "$published" -eq 1 ]; then
    # Renamed into place so a root-owned marker cannot block a later --user boot.
    { printf '%s\n' "$remote" > "$SYNCED.tmp" && mv -f "$SYNCED.tmp" "$SYNCED"; } 2>/dev/null || {
        rm -f "$SYNCED.tmp" 2>/dev/null || true
        echo "[unsloth-nb] the sync marker could not be written in $DEST; the next start will refresh again"
    }
elif [ "$published" -eq 0 ]; then
    echo "[unsloth-nb] the sync state could not be written; leaving the sync marker so the next start retries"
else
    echo "[unsloth-nb] $failed notebook(s) could not be written; leaving the sync marker so the next start retries"
fi
rm -rf "$TMP"
echo "[unsloth-nb] notebooks refreshed from GitHub: $updated updated, $kept kept (your edits), $unchanged kept (only header/footer changed upstream), $removed removed upstream"
if [ "$updated" -gt 0 ] || [ "$removed" -gt 0 ]; then
    _FINALIZED=0
    finalize
fi
exit 0