CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/coding-policy

General-purpose coding policy for Baruch's AI agents

74

Quality

93%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Medium

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

prune-worktrees.shskills/herdr-foreman/

#!/usr/bin/env bash
# Remove the spent worker worktrees and local branches a round left behind;
# report the ones holding work that exists nowhere else, never touch them.
#
# Every Herdr round provisions worktrees under the worktree root
# (`provision-worktree.sh`) and the foreman removes the task's own worktree after
# its merge (`rules/agent-worktree-isolation.md` Cleanup). Review, test and
# verify worktrees from earlier rounds, and the local branches a removed
# worktree leaves behind, accumulate until someone notices `git worktree list`
# scrolling. Which of them are safe to remove is one right answer per input,
# so the decision lives here (`rules/script-delegation.md`).
#
# Decision predicate — a worktree under the worktree root, other than the
# shared checkout, not locked and not on origin's default branch, is:
#   * REMOVED (its branch deleted) when IDLE for IDLE_HOURS, clean, holding no
#     other repository's checkout, and either its branch is an ancestor of the
#     commit origin's default branch points at right now (`git ls-remote`,
#     ORIGIN_DEFAULT) or its HEAD is an ancestor of a branch tip origin holds
#     right now (`git ls-remote --heads origin`, ORIGIN_TIPS), detached
#     included. Removal is plain `git worktree remove`, never forced: git
#     refuses a tree that turned dirty.
#   * KEPT and reported as `dirty` (with its changed-file count) or `unpushed`
#     (with the count of commits no origin branch holds) when idle but holding
#     work that exists nowhere else, with the one-line command for the
#     operator. Nothing here pushes, commits, stashes or deletes that work.
#   * KEPT for every other reason below.
# A registration whose directory is gone and that is not locked is reported
# `prunable` wherever it lives, inside the root or outside it, the shared
# checkout's default branch included; a live run's `git worktree prune`
# drops it, and only a registration the prune actually dropped lets its branch
# reach the branch pass.
# A worktree holding another repository's checkout is KEPT: a gitlink found
# from the index (never .gitmodules) whose checkout changed (submodule-dirty)
# or is populated (submodule), or any other .git anywhere below it, found by
# walking the whole tree, ignored and untracked directories alike
# (nested-repo); a directory the walk cannot read keeps it.
# "Clean" is `git status --porcelain --untracked-files=all` empty — untracked
# files count as dirty whatever `status.showUntrackedFiles` says; ignored files
# do not, they are reproducible by the ignore's own claim.
# "IDLE for N hours" is both: no process of this user has its cwd inside the
# worktree (`lsof -F pn0`; a missing or failing probe, an incomplete listing —
# a warning, a process with no cwd name, a cwd lsof could not read — and a
# path lsof cannot print faithfully all keep every worktree, reason
# idle-unknown), and the newest mtime among the worktree directory, its own
# gitdir's HEAD, index and logs/HEAD, and every modified tracked or untracked
# non-ignored file is at least N hours old (a worktree holding more such files
# than ACTIVITY_FILE_LIMIT is never idle). Every read runs with
# --no-optional-locks, so judging never rewrites the index.
# Immediately before each removal (and before a dry run's preview of one),
# the path is re-proven to resolve to itself, HEAD, branch tip, status, age
# and a fresh process probe are re-read, and the removal proof is re-derived
# from origin as it is now, which branch origin's HEAD names included; any
# change keeps the worktree (reason changed). IDLE_HOURS and
# ACTIVITY_FILE_LIMIT are constants beside the functions that use them.
# A git command that talks to origin (fetch, ls-remote, set-head) that fails
# is reported by exit code and the command to rerun, never by its own message,
# which can carry the remote URL with credentials (`network_failure`).
# A fetch or default-branch lookup that fails is a precondition failure, never
# a judgment from stale refs. Every removal is restorable from origin:
# `git worktree add <path> <head>`.
# A local branch with no worktree is DELETED iff it is not the default branch
# and either is an ancestor of origin's default branch or has its tip held by
# a branch origin holds, the proof re-read with `ls-remote` immediately before
# the deletion (and before a dry run's preview). That check is the safety, and
# it judges a captured commit rather than a name that can move. A branch with
# unpushed commits is KEPT: once idle for IDLE_HOURS (its tip commit and its
# newest reflog entry) it is reported as `unpushed` with its commit count, age
# and push command; before that as `not-idle`.
# `update-ref` carries no checked-out-worktree guard of its own, and the
# inventory is a snapshot, so occupancy is re-read from git either side of
# every deletion: a branch a worktree holds is KEPT with reason checked-out,
# and one claimed inside the deletion's own window is restored at the commit
# it was deleted from.
# Deletion is then `git update-ref -d refs/heads/<branch> <that commit>`,
# git's compare-and-delete: it removes the branch only while it still points
# at the commit just proved merged, so a commit landing mid-run keeps the
# branch instead of being force-deleted. `branch -d` would re-derive the
# safety against the local default, which may lag origin's, and `branch -D`
# would skip it entirely; neither is atomic with the check. Removal is `git worktree remove`,
# never `rm -rf`. Stale registrations are dropped by `git worktree prune
# --expire now` after every worktree decision and before the branch pass, so
# a confirmed-gone entry's merged branch goes in the same run. The prune drops
# every entry whose directory it cannot see at that instant, so a root renamed
# after the last identity check would take the registrations of every worktree
# under it (#597). Before it runs, every registration whose directory was
# present at the inventory and is not already locked is locked with the reason
# `prune-worktrees:<pid>:<nonce>` (git's prune never drops a locked entry);
# the root is re-proven; the prune runs; and exactly the entries this run
# locked, still carrying its reason, are unlocked, on every exit path. An
# entry someone else locked is never unlocked. A lock left by a run that was
# killed (its pid no longer alive) is released at the start of the next live
# run. A failed lock stops the prune; nothing is dropped. The prune is skipped
# when any worktree could not be entered. Nothing here pushes to
# origin; a dry run still fetches (without --prune), reads origin's default
# branch with `ls-remote --symref` instead of rewriting origin/HEAD, and skips
# the metadata removals, so its decisions are current and .git is otherwise
# untouched.
#
# Contract:
#   argv  : <shared-checkout> [--dry-run]
#           --dry-run reports the same decisions and removes nothing. It still
#           fetches, so its answer is current: remote-tracking refs, FETCH_HEAD
#           and the object database move as any fetch moves them. No worktree,
#           branch, config or metadata entry changes.
#   stdout: one JSON object —
#           {"shared":"<abs>","default_branch":"<name>","dry_run":bool,
#            "worktrees_removed":[{"path","branch","head"}],
#            "worktrees_kept":[{"path","branch","reason"[,"lock_reason"]
#                               [,"head","age_hours","dirty_files"|"unpushed_commits","command"]}],
#            "branches_deleted":["<name>"],
#            "branches_kept":[{"branch","reason"[,"unpushed_commits","age_hours","command"]}],
#            "failed":[{"target","error"}]}
#           reason is one of: changed (it changed between judgment and
#           removal, or origin's proof went away), checked-out (a worktree
#           claimed the branch after the inventory was taken), default-branch,
#           dirty (idle, uncommitted work; with head, age_hours,
#           dirty_files and command),
#           idle-unknown (the process probe could not run, returned an
#           incomplete listing, or cannot print this path faithfully), in-use
#           (a process works inside it), locked (with its lock_reason),
#           nested-repo, not-idle (activity within IDLE_HOURS), outside-root,
#           prunable (its directory is gone; a live run drops its
#           registration, inside the root or outside it), submodule, submodule-dirty, unpushed (idle, commits
#           origin holds nowhere; with unpushed_commits, age_hours and
#           command, and for a worktree its head too).
#   stderr: diagnostics only.
#   exit  : 0 every decision applied (or previewed),
#           1 precondition unmet (usage, git or python3 absent, not a repo,
#             no origin, fetch failed, default branch unresolvable, worktree
#             or branch inventory unreadable, the worktree root unreadable
#             or not the one PRUNE_ROOT_ID names) — no JSON, nothing decided,
#           2 at least one check, removal or deletion failed; the rest still
#             ran and `failed` names each one. The exception is a worktree
#             root replaced or made unreadable mid-run: the root's identity
#             (lstat <dev>:<ino>, and that it can be listed) is re-proven
#             immediately before every worktree removal, branch deletion and
#             metadata prune, and
#             from the first mismatch on each of those steps is refused and
#             recorded in `failed` (plus one row naming the root).
#   env   : WORKTREE_ROOT overrides the worktree root (default
#           $HOME/.worktrees); the tests point it at a temp dir.
#           PRUNE_ROOT_ID (<dev>:<ino>) is the root identity a caller
#           already proved; without it the run proves its own at the start.
#           PRUNE_IDLE_HOURS / PRUNE_ACTIVITY_FILE_LIMIT override the
#           constants, PRUNE_NOW (epoch seconds) the clock, PRUNE_LSOF the probe.
set -euo pipefail

WORKDIR=""
ERRFILE=""
ROWS=""
#: The worktree root this run inventoried: its resolved path and its
#: <dev>:<ino>. Empty when no root existed at the start.
ROOT_PATH=""
ROOT_ID=""
#: Set once the root is found changed; nothing destructive runs after it.
ROOT_MOVED=0

warn() { printf 'prune-worktrees: %s\n' "$1" >&2; }

# Echo the <dev>:<ino> of the directory at <path> itself, read with lstat so
# a symlink swapped in for it is not that directory. Returns 1 when <path> is
# missing, not a directory, or cannot be listed.
root_identity() { # <path>
  python3 -c '
import os, stat, sys
try:
    info = os.lstat(sys.argv[1])
except OSError as exc:
    sys.stderr.write("cannot read {}: {}\n".format(sys.argv[1], exc.strerror or exc))
    sys.exit(1)
if not stat.S_ISDIR(info.st_mode):
    sys.stderr.write("{} is not a directory\n".format(sys.argv[1]))
    sys.exit(1)
try:
    # Open it without following a symlink and read one entry through that
    # descriptor: the directory proven listable is the one fstat names, so a
    # swap after the lstat above cannot pass as the original.
    fd = os.open(sys.argv[1], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    try:
        opened = os.fstat(fd)
        with os.scandir(fd) as entries:
            next(entries, None)
    finally:
        os.close(fd)
except OSError as exc:
    sys.stderr.write("cannot list {}: {}\n".format(sys.argv[1], exc.strerror or exc))
    sys.exit(1)
try:
    # The path is read once more after the listing: a swap while the
    # descriptor was being read must not pass either.
    after = os.lstat(sys.argv[1])
except OSError as exc:
    sys.stderr.write("cannot read {}: {}\n".format(sys.argv[1], exc.strerror or exc))
    sys.exit(1)
if not ((opened.st_dev, opened.st_ino) == (info.st_dev, info.st_ino) == (after.st_dev, after.st_ino)):
    sys.stderr.write("{} changed while it was being read\n".format(sys.argv[1]))
    sys.exit(1)
print("{}:{}".format(info.st_dev, info.st_ino))' "$1" 2>"$ERRFILE"
}

# Immediately before every destructive step: 0 while the worktree root is
# still the directory this run inventoried. Otherwise the step is recorded as
# not done (the first time, with one row naming the root) and 1 is returned;
# once the root has changed, every later step is refused the same way.
root_holds() { # <target> <branch|"">
  [[ -n "$ROOT_ID" ]] || return 0
  if (( ! ROOT_MOVED )); then
    local now
    if now="$(root_identity "$ROOT_PATH")" && [[ "$now" == "$ROOT_ID" ]]; then return 0; fi
    ROOT_MOVED=1
    row failed "$ROOT_PATH" "" "the worktree root was replaced or became unreadable during the run, so nothing further was removed, deleted or pruned — restore it, then re-run"
  fi
  row failed "$1" "$2" "not done: the worktree root changed during the run"
  return 1
}

#: Lock reasons this script writes start with this prefix, then the pid of
#: the run that wrote them and a per-run nonce.
PRUNE_LOCK_PREFIX="prune-worktrees:"
#: This run's lock reason, and the registered paths it locked.
RUN_LOCK_REASON=""
LOCKED_BY_RUN=()
LOCK_SHARED=""

# Answer one question about the registry snapshot <file> (`git worktree list
# --porcelain -z`), printing NUL-separated registered paths:
#   lockable <candidates>   candidates registered, unlocked, not the main one
#   ours <reason> <paths>   paths locked with exactly <reason>
#   stale                   paths locked by a dead run of this script
#   registered <paths>      paths still registered
registry_query() { # <mode> <snapshot> [args...]
  python3 - "$PRUNE_LOCK_PREFIX" "$$" "$@" <<'PY'
import os, sys

prefix, mypid, mode, snapshot = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
args = sys.argv[5:]


def read_nul(path):
    with open(path, "rb") as handle:
        fields = handle.read().decode("utf-8", "surrogateescape").split("\0")
    return [f for f in fields if f]


records = []  # (path, locked, reason)
with open(snapshot, "rb") as handle:
    raw = handle.read().decode("utf-8", "surrogateescape")
current = None
for field in raw.split("\0"):
    if field.startswith("worktree "):
        current = [field[len("worktree "):], False, None]
        records.append(current)
    elif current is not None and field == "locked":
        current[1] = True
        current[2] = ""
    elif current is not None and field.startswith("locked "):
        current[1] = True
        current[2] = field[len("locked "):]
    elif field == "":
        current = None


def alive(pid):
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    return True


out = []
if mode == "lockable":
    wanted = set(read_nul(args[0]))
    out = [path for index, (path, locked, _) in enumerate(records)
           if index > 0 and not locked and path in wanted]
elif mode == "ours":
    wanted = set(read_nul(args[1]))
    out = [path for path, locked, reason in records if locked and reason == args[0] and path in wanted]
elif mode == "stale":
    for path, locked, reason in records:
        if not locked or not reason or not reason.startswith(prefix):
            continue
        pid_text = reason[len(prefix):].split(":", 1)[0]
        if pid_text.isdigit() and int(pid_text) != mypid and not alive(int(pid_text)):
            out.append(path)
elif mode == "registered":
    wanted = set(read_nul(args[0]))
    out = [path for path, _, _ in records if path in wanted]
else:
    sys.stderr.write("unknown registry query {}".format(mode))
    sys.exit(1)
sys.stdout.write("".join(path + "\0" for path in out))
PY
}

# Write the registry snapshot of <shared> to <file>; 1 (ERRFILE set) on failure.
registry_snapshot() { # <shared> <file>
  git -C "$1" worktree list --porcelain -z >"$2" 2>"$ERRFILE"
}

# Read NUL-separated paths from <file> into the array REPLY_PATHS.
REPLY_PATHS=()
read_paths() { # <file>
  REPLY_PATHS=()
  local p
  while IFS= read -r -d '' p; do REPLY_PATHS+=("$p"); done < "$1"
}

# Release the locks a killed run of this script left behind: locked with its
# prefix by a pid that is no longer alive. Each one is reported when its
# unlock fails.
release_stale_locks() { # <shared>
  local snap="${WORKDIR}/stale-snapshot" out="${WORKDIR}/stale-paths" p
  if ! registry_snapshot "$1" "$snap"; then
    row failed "git worktree list" "" "cannot read the worktree registry to release stale prune locks: $(tr '\n' ' ' < "$ERRFILE") — run \`git -C ${1} worktree list --porcelain\` to see why, then re-run"
    return 0
  fi
  if ! registry_query stale "$snap" >"$out" 2>"$ERRFILE"; then
    row failed "git worktree list" "" "cannot read the stale prune locks: $(tr '\n' ' ' < "$ERRFILE") — report this as a bug"
    return 0
  fi
  read_paths "$out"
  for p in "${REPLY_PATHS[@]+"${REPLY_PATHS[@]}"}"; do
    if ! git -C "$1" worktree unlock "$p" 2>"$ERRFILE"; then
      row failed "$p" "" "cannot release the lock a killed prune left on it: $(tr '\n' ' ' < "$ERRFILE") — run \`git -C ${1} worktree unlock ${p}\`"
    fi
  done
  return 0
}

# Lock every candidate (registered paths present at the inventory) that is
# still registered and unlocked, recording each in LOCKED_BY_RUN. 1 on the
# first failure, which is reported; the caller then prunes nothing.
lock_live_entries() { # <shared> <candidates-file>
  local snap="${WORKDIR}/lock-snapshot" out="${WORKDIR}/lock-paths" p
  LOCK_SHARED="$1"
  if ! registry_snapshot "$1" "$snap"; then
    row failed "git worktree prune" "" "not run: cannot read the worktree registry to lock the live entries: $(tr '\n' ' ' < "$ERRFILE") — run \`git -C ${1} worktree list --porcelain\` to see why, then re-run"
    return 1
  fi
  if ! registry_query lockable "$snap" "$2" >"$out" 2>"$ERRFILE"; then
    row failed "git worktree prune" "" "not run: cannot read which entries to lock: $(tr '\n' ' ' < "$ERRFILE") — report this as a bug"
    return 1
  fi
  read_paths "$out"
  for p in "${REPLY_PATHS[@]+"${REPLY_PATHS[@]}"}"; do
    if ! git -C "$1" worktree lock --reason "$RUN_LOCK_REASON" "$p" 2>"$ERRFILE"; then
      row failed "$p" "" "could not lock it before the stale-registration prune, so nothing was pruned: $(tr '\n' ' ' < "$ERRFILE") — fix the cause, then re-run"
      return 1
    fi
    LOCKED_BY_RUN+=("$p")
  done
  return 0
}

# Unlock exactly the entries this run locked that still carry its reason; an
# entry someone relocked with another reason is left alone. Safe to call
# twice: the record is cleared once read.
unlock_run_locks() {
  (( ${#LOCKED_BY_RUN[@]} )) || return 0
  local shared="$LOCK_SHARED" snap="${WORKDIR}/unlock-snapshot" mine="${WORKDIR}/unlock-mine" out="${WORKDIR}/unlock-paths" p
  local -a pending=("${LOCKED_BY_RUN[@]}")
  LOCKED_BY_RUN=()
  printf '%s\0' "${pending[@]}" >"$mine"
  if ! registry_snapshot "$shared" "$snap" || ! registry_query ours "$snap" "$RUN_LOCK_REASON" "$mine" >"$out" 2>>"$ERRFILE"; then
    for p in "${pending[@]}"; do
      row failed "$p" "" "cannot confirm this run's lock on it to release it: $(tr '\n' ' ' < "$ERRFILE") — check \`git -C ${shared} worktree list --porcelain\` and, if the lock reason is ${RUN_LOCK_REASON}, run \`git -C ${shared} worktree unlock ${p}\`"
    done
    return 0
  fi
  read_paths "$out"
  for p in "${REPLY_PATHS[@]+"${REPLY_PATHS[@]}"}"; do
    if ! git -C "$shared" worktree unlock "$p" 2>"$ERRFILE"; then
      row failed "$p" "" "cannot release this run's lock on it: $(tr '\n' ' ' < "$ERRFILE") — run \`git -C ${shared} worktree unlock ${p}\`"
    fi
  done
  return 0
}

# After a git command that talks to origin fails: replace its stderr in
# ERRFILE with the exit code and the command to rerun. Its own message can
# carry the remote URL, credentials included, so it is never relayed.
network_failure() { # <exit> <shared> <git args...>
  local rc="$1" shared="$2"
  shift 2
  # shellcheck disable=SC2016  # The backticks are literal text in the message, not a command substitution.
  printf '`git %s` exited %s; run `git -C %s %s` to see why (its output is not relayed: it can carry the remote URL with credentials)\n' \
    "$*" "$rc" "$shared" "$*" > "$ERRFILE"
}

cleanup() {
  local f
  # An interrupted run still releases its own locks; the rows are gone with
  # the run, so each failure reaches stderr through `row`.
  if (( ${#LOCKED_BY_RUN[@]} )) && [[ -n "$WORKDIR" ]]; then unlock_run_locks; fi
  if [[ -n "$WORKDIR" ]] && ! rm -rf "$WORKDIR"; then
    warn "could not remove the temporary directory ${WORKDIR} — remove it by hand"
  fi
  for f in "$CWD_FILE" "$ORIGIN_TIPS"; do
    if [[ -n "$f" ]] && ! rm -f "$f"; then
      warn "could not remove temp file ${f} — remove it by hand"
    fi
  done
  return 0
}

# Append one decision row: <kind> <target> <branch> <reason>, NUL-delimited
# so a path or diagnostic holding a tab or newline cannot shift the fields.
row() { # <kind> <target> <branch> <reason> [head] [extra]
  printf '%s\0%s\0%s\0%s\0%s\0%s\0' "$1" "$2" "$3" "$4" "${5:-}" "${6:-}" >> "$ROWS"
  # A failure is a diagnostic on stderr as well as a JSON row
  # (rules/script-delegation.md Script Requirements).
  if [[ "$1" == failed ]]; then
    warn "${2}: ${4} — inspect it by hand; nothing else was skipped on its account"
  fi
}

# 0 when <ref> exists, 1 when it is absent; any other show-ref exit is a tool
# failure, warned about and returned as 2 so no fallback runs on top of it.
ref_exists() { # <shared> <ref>
  local rc=0
  git -C "$1" show-ref --verify --quiet "$2" 2>"$ERRFILE" || rc=$?
  case "$rc" in
    0|1) return "$rc" ;;
    *) warn "\`git show-ref --verify ${2}\` failed (exit ${rc}): $(tr '\n' ' ' < "$ERRFILE") — run it in ${1} to see why, then re-run"; return 2 ;;
  esac
}

# Echo origin's default branch name, or return 1 when none can be confirmed.
# A live run has just rewritten origin/HEAD from the remote; a dry run reads
# the remote's HEAD with `ls-remote --symref` instead, rewriting nothing.
default_branch_of() { # <shared> <dry-run 0|1>
  local db="" rc=0
  if (( $2 )); then
    local sym
    sym="$(git -C "$1" ls-remote --symref origin HEAD 2>"$ERRFILE")" || rc=$?
    if (( rc != 0 )); then
      network_failure "$rc" "$1" ls-remote --symref origin HEAD
      warn "$(cat "$ERRFILE")"
      return 1
    fi
    db="$(printf '%s\n' "$sym" | sed -n 's#^ref: refs/heads/\(.*\)[[:space:]]HEAD$#\1#p' | head -n 1)"
    if [[ -n "$db" ]]; then
      # The remote named its default; a missing local origin/<name> is a
      # refspec or fetch problem, never a reason to guess main or master.
      ref_exists "$1" "refs/remotes/origin/${db}" || rc=$?
      case "$rc" in
        0) printf '%s' "$db"; return 0 ;;
        1) warn "origin reports default branch '${db}' but refs/remotes/origin/${db} is absent after the fetch — check the fetch refspec"; return 1 ;;
        *) return 1 ;;
      esac
    fi
  else
    # The full target, not --short: a local branch named origin/<x> makes the
    # short form ambiguous and git renders it as remotes/origin/<x>.
    db="$(git -C "$1" symbolic-ref --quiet refs/remotes/origin/HEAD 2>"$ERRFILE")" || rc=$?
    case "$rc" in
      0) if [[ "$db" != refs/remotes/origin/* ]]; then
           warn "origin/HEAD points at '${db}', not a refs/remotes/origin/ ref — run \`git remote set-head origin --auto\`"; return 1
         fi
         db="${db#refs/remotes/origin/}"
         # origin/HEAD was just rewritten from the remote; a dangling one is a
         # refspec or fetch problem, never a reason to guess main or master.
         rc=0; ref_exists "$1" "refs/remotes/origin/${db}" || rc=$?
         case "$rc" in
           0) printf '%s' "$db"; return 0 ;;
           1) warn "origin/HEAD names '${db}' but refs/remotes/origin/${db} is absent after the fetch — check the fetch refspec"; return 1 ;;
           *) return 1 ;;
         esac ;;
      1) ;;  # origin/HEAD is simply absent: fall back to the conventional names
      *) warn "\`git symbolic-ref refs/remotes/origin/HEAD\` failed (exit ${rc}): $(tr '\n' ' ' < "$ERRFILE") — run \`git -C ${1} remote set-head origin --auto\`, then re-run"; return 1 ;;
    esac
  fi
  local cand
  for cand in main master; do
    rc=0; ref_exists "$1" "refs/remotes/origin/$cand" || rc=$?
    case "$rc" in 0) printf '%s' "$cand"; return 0 ;; 1) ;; *) return 1 ;; esac
  done
  return 1
}

# Echo merged|unmerged for <commit> against refs/remotes/origin/<default>,
# or return 2 on a tool failure. The caller passes the commit it captured, so
# a commit landing after the capture cannot become the judged tip.
# `merge-base --is-ancestor` exits 1 for "not an ancestor" and
# anything else for an invalid ref or repository error; collapsing both into
# "unmerged" would hide the failure behind a kept row
# (rules/error-handling.md Shell Error Handling).
#: The commit origin's default branch points at, as origin reported it when
#: this run started (`origin_default_tip`). Merged-ness is judged against it,
#: never a local remote-tracking ref, so dry and live runs see the same origin.
ORIGIN_DEFAULT=""

# Echo the commit origin's <default> branch points at right now, read with
# ls-remote; return 1 when it cannot be read, 3 when origin names a commit
# this repository does not hold (origin moved since the fetch).
origin_default_tip() { # <shared> <default>
  local out rc=0 sha
  out="$(git -C "$1" ls-remote origin "refs/heads/$2" 2>"$ERRFILE")" || rc=$?
  if (( rc != 0 )); then
    network_failure "$rc" "$1" ls-remote origin "refs/heads/$2"
    return 1
  fi
  sha="${out%%[[:space:]]*}"
  if [[ -z "$sha" ]]; then printf 'origin has no branch %s\n' "$2" > "$ERRFILE"; return 1; fi
  if ! git -C "$1" cat-file -e "${sha}^{commit}" 2>"$ERRFILE"; then
    printf 'origin %s points at %s, which this repository does not hold\n' "$2" "$sha" > "$ERRFILE"
    return 3
  fi
  printf '%s' "$sha"
}

ancestry() { # <shared> <commit> <default>
  local rc=0
  # Against the commit origin reported, not a ref name: a tag or a local
  # branch named like the default can never stand in for it.
  git -C "$1" merge-base --is-ancestor "$2" "$ORIGIN_DEFAULT" 2>"$ERRFILE" || rc=$?
  case "$rc" in
    0) printf 'merged' ;;
    1) printf 'unmerged' ;;
    *) return 2 ;;
  esac
  return 0
}

# Echo the commit refs/heads/<branch> points at, or return 1 on any failure.
branch_tip() { # <shared> <branch>
  local out rc=0
  out="$(git -C "$1" rev-parse --verify --quiet "refs/heads/$2^{commit}" 2>"$ERRFILE")" || rc=$?
  if (( rc != 0 )) || [[ -z "$out" ]]; then
    return 1
  fi
  printf '%s' "$out"
}

# Echo 1 when some worktree currently has <branch> checked out and 0 when none
# does; return 1 when the inventory cannot be read. `update-ref -d` carries
# none of git's own checked-out-worktree guard, and the run's inventory is a
# snapshot: a `worktree add` claiming the branch after it was taken is
# invisible to `seen_branches` (#410). So occupancy is re-read at the deletion.
branch_checked_out() { # <shared> <branch>
  local listing rc=0 field found=0
  # The caller builds its failure row from ERRFILE, so a bare `return 1` here
  # would report the occupancy read as failing for whatever the previous
  # command left there (#426).
  listing="$(mktemp 2>"$ERRFILE")" || rc=$?
  if (( rc != 0 )) || [[ -z "$listing" ]]; then
    printf 'mktemp failed (exit %s): %s\n' "${rc:-0}" "$(tr '\n' ' ' < "$ERRFILE")" > "$ERRFILE"
    return 1
  fi
  if ! git -C "$1" worktree list --porcelain -z >"$listing" 2>"$ERRFILE"; then
    if ! rm -f "$listing"; then warn "could not remove temp file ${listing} — remove it by hand"; fi
    return 1
  fi
  while IFS= read -r -d '' field || [[ -n "$field" ]]; do
    if [[ "$field" == "branch refs/heads/$2" ]]; then found=1; break; fi
  done < "$listing"
  if ! rm -f "$listing"; then warn "could not remove temp file ${listing} — remove it by hand"; fi
  printf '%s' "$found"
}

# Delete <branch> only if it still points at <tip>, atomically: `update-ref -d`
# with an old value is git's compare-and-delete, so no commit can land between
# the check and the deletion the way a read-then-`branch -D` allows (#405).
# The ancestry proof is the safety `branch -d` would otherwise re-derive
# against the local default, which may lag origin's. Occupancy is re-read
# either side of it: before, so a worktree created since the inventory is
# seen; after, so one created inside that window is put back rather than left
# checked out on a branch that no longer exists (#410).
delete_branch() { # <shared> <branch> <tip>  -> 0 deleted, 1 moved, 2 git refused, 3 deleted but config left, 4 occupancy unreadable, 5 checked out, 6 claimed mid-run and restored, 7 claimed mid-run and not restored, 8 deleted and post-deletion occupancy unreadable, 9 deleted and its config section left to a branch recreated since
  local rc=0 now refusal occupied
  if ! occupied="$(branch_checked_out "$1" "$2")"; then
    return 4
  fi
  if [[ "$occupied" == 1 ]]; then
    return 5
  fi
  git -C "$1" update-ref -d "refs/heads/$2" "$3" 2>"$ERRFILE" || rc=$?
  if (( rc != 0 )); then
    # Hold git's own words: the re-read below truncates ERRFILE, and a
    # refusal the caller reports without them is undiagnosable.
    refusal="$(tr '\n' ' ' < "$ERRFILE")"
    if now="$(branch_tip "$1" "$2")" && [[ "$now" != "$3" ]]; then
      return 1
    fi
    printf '%s' "$refusal" > "$ERRFILE"
    return 2
  fi
  # The window between the check above and the deletion is small, not empty.
  # A worktree that claimed the branch inside it is now checked out on a ref
  # that is gone, so the branch goes back at the commit it was deleted from —
  # `update-ref` with an empty old value creates it only while it is still
  # absent, so a racing `worktree add -b` that made its own is left alone.
  # A failed read here is not "no worktree holds it": returning to the config
  # probe below would overwrite this diagnostic and report a clean deletion
  # whose safety check never ran (rules/error-handling.md Shell Error
  # Handling — an expected non-result is not a tool failure).
  if ! occupied="$(branch_checked_out "$1" "$2")"; then
    return 8
  fi
  if [[ "$occupied" == 1 ]]; then
    if git -C "$1" update-ref "refs/heads/$2" "$3" "" 2>"$ERRFILE"; then
      return 6
    fi
    return 7
  fi
  # `update-ref` leaves the branch config `branch -D` would have removed.
  # Whether there is any is read first: `--remove-section` exits 128 for a
  # missing section and for a malformed config alike, so the exit code alone
  # cannot tell an expected non-result from a failure.
  local keys=""
  rc=0
  keys="$(git -C "$1" config --get-regexp '^branch\.' 2>"$ERRFILE")" || rc=$?
  case "$rc" in
    0) ;;
    1) return 0 ;;  # no branch.* config at all
    *) return 3 ;;
  esac
  # Section equality, not a substring: `branch.foo.` also prefixes
  # `branch.foo.bar.remote`, which belongs to the branch `foo.bar`. Reading
  # that as `foo`'s config makes `--remove-section branch.foo` fail on a
  # section that was never there, and the run reports a cleanup failure it
  # invented (#410). A config key is `branch.<name>.<key>` and `<name>` may
  # hold dots, so the name is what sits between the first dot and the last.
  local keyline key section found=0
  while IFS= read -r keyline; do
    key="${keyline%% *}"
    [[ "$key" == branch.*.* ]] || continue
    section="${key#branch.}"
    section="${section%.*}"
    if [[ "$section" == "$2" ]]; then found=1; break; fi
  done <<<"$keys"
  if (( ! found )); then
    return 0
  fi
  # The section may have been recreated since the deletion: `worktree add
  # --track -b` writes a fresh `branch.<name>.*` for the NEW branch, and
  # removing it then deletes configuration belonging to a live checkout (#426).
  # Occupancy is re-read immediately before the removal, the same guard the
  # deletion itself takes.
  local now_held
  if ! now_held="$(branch_checked_out "$1" "$2")"; then
    # The deletion already happened; 4 would tell the caller it did not.
    return 8
  fi
  if [[ "$now_held" == 1 ]]; then
    return 9
  fi
  rc=0
  git -C "$1" config --remove-section "branch.$2" >/dev/null 2>"$ERRFILE" || rc=$?
  if (( rc != 0 )); then
    return 3
  fi
  return 0
}

# Report a branch deletion that followed a worktree removal.
report_branch_delete() { # <branch> <tip> <delete_branch rc>
  local branch="$1" tip="$2"
  case "$3" in
    0) ;;
    1) row failed "$branch" "$branch" "branch ${branch} moved after its ancestry check and was left alone; its worktree is already removed, so re-run to judge the new tip" ;;
    3) row failed "$branch" "$branch" "${branch} is deleted but its branch.${branch} config could not be cleaned up: $(tr '\n' ' ' < "$ERRFILE") — check and remove it by hand" ;;
    4) row failed "$branch" "$branch" "could not re-read which worktrees hold ${branch} after its own was removed, so it was left alone: $(tr '\n' ' ' < "$ERRFILE")" ;;
    5) row failed "$branch" "$branch" "another worktree claimed ${branch} after its own was removed, so it was left alone; re-run once that worktree is gone" ;;
    6) row failed "$branch" "$branch" "another worktree claimed ${branch} while it was being deleted; the branch was restored at ${tip} and that worktree is intact — re-run once it is gone" ;;
    7) row failed "$branch" "$branch" "another worktree claimed ${branch} while it was being deleted and it could not be restored: $(tr '\n' ' ' < "$ERRFILE") — inspect that worktree by hand" ;;
    8) row failed "$branch" "$branch" "${branch} is deleted and whether a worktree claimed it meanwhile could not be read: $(tr '\n' ' ' < "$ERRFILE") — check \`git worktree list\` and restore ${branch} at ${tip} if one holds it" ;;
    9) row failed "$branch" "$branch" "${branch} is deleted, and a worktree recreated a branch of that name before its config could be cleaned up — branch.${branch} belongs to that live branch and was left untouched; remove nothing by hand" ;;
    *) row failed "$branch" "$branch" "deleting ${branch} failed after the worktree was removed: $(tr '\n' ' ' < "$ERRFILE")" ;;
  esac
}

#: `ok` once CWD_FILE holds every cwd of this user's processes, `failed` when
#: the probe could not run, empty before a probe. `reprobe` clears it so a
#: pre-removal recheck reads the processes as they are now.
CWD_STATE=""
CWD_FILE=""

reprobe() { CWD_STATE=""; }

# 0 = some live process of this user has its cwd inside <real>, 1 = none does,
# 2 = unknown (probe missing or failed, or <real> is a path lsof cannot report
# faithfully). Unknown is never read as idle.
# lsof escapes a newline or another control byte in a name as text (`\n`), so
# a path holding one, a backslash, or a non-ASCII byte cannot be matched
# against its output without guessing; such a worktree is never judged idle.
# The listing is NUL-framed (`-F pn0`), so one name is one field.
in_use() { # <real-path>
  if [[ "$1" == *\\* ]] || ! LC_ALL=C python3 -c 'import sys; sys.exit(0 if all(0x20 <= b <= 0x7e for b in sys.argv[1].encode("utf-8", "surrogateescape")) else 1)' "$1"; then
    return 2
  fi
  if [[ -z "$CWD_STATE" ]]; then
    CWD_STATE=failed
    local lsof_bin="${PRUNE_LSOF:-lsof}" uid
    if ! command -v "$lsof_bin" >/dev/null; then
      warn "${lsof_bin} not found on PATH — cannot tell whether a worktree is in use, so none is judged idle; install lsof"
    elif ! uid="$(id -u)"; then
      warn "id -u failed — cannot scope the process probe, so no worktree is judged idle; run \`id -u\` to see why, then re-run"
    elif [[ -z "$CWD_FILE" ]] && ! CWD_FILE="$(mktemp)"; then
      CWD_FILE=""
      warn "mktemp failed — cannot hold the process probe, so no worktree is judged idle; make ${TMPDIR:-/tmp} writable, then re-run"
    elif ! "$lsof_bin" -a -u "$uid" -d cwd -F pn0 >"$CWD_FILE" 2>"$ERRFILE"; then
      warn "\`${lsof_bin} -a -u ${uid} -d cwd -F pn0\` failed: $(tr '\n' ' ' < "$ERRFILE") — no worktree is judged idle; run it by hand to see why, then re-run"
    elif ! complete_cwd_listing "$CWD_FILE" "$ERRFILE"; then
      # Exit 0 is not a complete answer: a warning other than a mount lsof
      # could not stat, or a live process without a readable cwd name, leaves
      # some process's cwd unknown, and that process could be inside any
      # worktree.
      warn "\`${lsof_bin} -a -u ${uid} -d cwd -F pn0\` returned an incomplete listing — no worktree is judged idle; run it by hand to see which process it could not read"
    else
      if [[ -s "$ERRFILE" ]]; then
        warn "${lsof_bin} warned about a mount it could not stat; process cwds are still complete: $(tr '\n' ' ' < "$ERRFILE")"
      fi
      CWD_STATE=ok
    fi
  fi
  [[ "$CWD_STATE" == ok ]] || return 2
  local field cwd
  while IFS= read -r -d '' field || [[ -n "$field" ]]; do
    # A process set ends with a newline after its last NUL; it leads the
    # next field.
    field="${field#$'\n'}"
    [[ "$field" == n* ]] || continue
    cwd="${field#n}"
    if [[ "$cwd" == "$1" || "$cwd" == "$1"/* ]]; then return 0; fi
  done < "$CWD_FILE"
  return 1
}

# 0 when the NUL-framed lsof listing names a readable cwd for every process
# still alive, and lsof's stderr holds nothing but its warning about a mount
# it could not stat (the cwd records stay complete; the device number comes
# from the mount table). 1 otherwise. A process that exited while lsof read
# it holds no cwd anywhere, so its unreadable record is no gap; one still
# alive, or whose liveness cannot be read, is.
complete_cwd_listing() { # <listing-file> <stderr-file>
  python3 - "$1" "$2" <<'PY'
import os
import re
import sys

MOUNT_WARNING = (
    re.compile(rb"^lsof: WARNING: can't stat\(\) \S+ file system .*$"),
    re.compile(rb"^\s*Output information may be incomplete\.$"),
    re.compile(rb'^\s*assuming "dev=[0-9a-fA-Fx]+" from mount table$'),
)

with open(sys.argv[2], "rb") as handle:
    for line in handle.read().splitlines():
        if line.strip() and not any(p.match(line) for p in MOUNT_WARNING):
            sys.exit(1)


def alive(pid):
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except OSError:
        return True
    return True


with open(sys.argv[1], "rb") as handle:
    fields = [f.lstrip(b"\n") for f in handle.read().split(b"\0")]
gaps = []
pid = b""
named = None
for field in fields:
    if field.startswith(b"p"):
        if named is False:
            gaps.append(pid)
        pid, named = field[1:], False
    elif field.startswith(b"n"):
        if re.search(rb"\((readlink|stat|lstat): [^)]*\)$", field) or b"Permission denied" in field:
            gaps.append(pid)
        named = True
if named is False:
    gaps.append(pid)
for gap in gaps:
    if not gap.isdigit() or alive(int(gap)):
        sys.exit(1)
sys.exit(0)
PY
}

#: The idle clock stats at most this many modified or untracked files; a
#: worktree holding more is never judged idle.
ACTIVITY_FILE_LIMIT="${PRUNE_ACTIVITY_FILE_LIMIT:-20000}"

# Echo whole hours since the newest activity in the worktree at <real>: the
# worktree directory, its own gitdir's HEAD, index and logs/HEAD, and every
# modified tracked or untracked non-ignored file. Returns 1 when that cannot
# be read. PRUNE_NOW (epoch seconds) replaces the clock for tests.
idle_hours() { # <real-path>
  local gitdir
  if ! gitdir="$(git -C "$1" rev-parse --absolute-git-dir 2>"$ERRFILE")"; then
    return 1
  fi
  python3 - "$1" "$gitdir" "${PRUNE_NOW:-}" "$ACTIVITY_FILE_LIMIT" 2>"$ERRFILE" <<'PY'
import os
import subprocess
import sys
import time

path, gitdir, now, limit = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
now = float(now) if now else time.time()
candidates = [path, os.path.join(gitdir, "HEAD"), os.path.join(gitdir, "index"), os.path.join(gitdir, "logs", "HEAD")]
listed = subprocess.run(["git", "--no-optional-locks", "-C", path, "ls-files", "-z", "-m", "-o", "--exclude-standard"],
                        capture_output=True)
if listed.returncode != 0:
    sys.stderr.write("git ls-files failed: {}".format(listed.stderr.decode("utf-8", "replace")))
    sys.exit(1)
files = [name for name in listed.stdout.decode("utf-8", "surrogateescape").split("\0") if name]
if len(files) > limit:
    sys.stderr.write("{} modified or untracked files exceed the idle-clock limit of {}".format(len(files), limit))
    sys.exit(1)
candidates += [os.path.join(path, name) for name in files]
stamps = []
for candidate in candidates:
    try:
        stamps.append(os.lstat(candidate).st_mtime)
    except FileNotFoundError:
        continue
if not stamps:
    sys.exit(1)
print(int(max(0.0, now - max(stamps)) // 3600))
PY
}

# Snapshot what the removal decision rests on, as one line: HEAD, the branch
# tip, and the porcelain status. Returns 1 when any read fails.
worktree_state() { # <shared> <real> <branch|"">
  local head tip="" status
  head="$(git -C "$2" rev-parse --verify --quiet 'HEAD^{commit}' 2>"$ERRFILE")" || return 1
  if [[ -n "$3" ]]; then tip="$(branch_tip "$1" "$3")" || return 1; fi
  status="$(git --no-optional-locks -C "$2" status --porcelain --untracked-files=all 2>"$ERRFILE")" || return 1
  printf '%s %s %s' "$head" "$tip" "$status"
}

# Immediately before a removal: 0 when the worktree is unchanged since it was
# judged and still idle for <window> hours with no process inside; otherwise
# 1 with RECHECK_WHY set.
RECHECK_WHY=""
recheck() { # <shared> <real> <branch|""> <state-when-judged> <window-hours>
  local now_state age rc=0 real
  RECHECK_WHY=""
  # The path still names this same directory, not a symlink swapped in.
  if [[ -L "$2" ]] || ! real="$(cd "$2" 2>"$ERRFILE" && pwd -P)" || [[ "$real" != "$2" ]]; then
    RECHECK_WHY="its path no longer resolves to itself"; return 1
  fi
  if ! now_state="$(worktree_state "$1" "$2" "$3")"; then
    RECHECK_WHY="its state could not be re-read: $(tr '\n' ' ' < "$ERRFILE")"; return 1
  fi
  if [[ "$now_state" != "$4" ]]; then RECHECK_WHY="its HEAD, branch tip or status changed"; return 1; fi
  if ! age="$(idle_hours "$2")"; then RECHECK_WHY="its activity age could not be re-read"; return 1; fi
  if (( age < $5 )); then RECHECK_WHY="it was written to ${age}h ago"; return 1; fi
  reprobe
  in_use "$2" || rc=$?
  case "$rc" in
    1) return 0 ;;
    0) RECHECK_WHY="a process is now working inside it" ;;
    *) RECHECK_WHY="the process probe could not run" ;;
  esac
  return 1
}

#: File of the branch tips origin holds right now (`git ls-remote --heads`),
#: restricted to commits present locally. Live and dry runs both judge
#: reachability against it, so a branch deleted on origin since the last
#: fetch counts for neither.
ORIGIN_TIPS=""

# Record origin's current branch tips in ORIGIN_TIPS. Returns 1 on failure.
read_origin_tips() { # <shared>
  local listing
  if [[ -z "$ORIGIN_TIPS" ]] && ! ORIGIN_TIPS="$(mktemp 2>"$ERRFILE")"; then ORIGIN_TIPS=""; return 1; fi
  if ! listing="$(mktemp 2>"$ERRFILE")"; then return 1; fi
  local rc=0
  git -C "$1" ls-remote --heads origin >"$listing" 2>"$ERRFILE" || rc=$?
  if (( rc != 0 )); then
    network_failure "$rc" "$1" ls-remote --heads origin
    if ! rm -f "$listing"; then warn "could not remove temp file ${listing} — remove it by hand"; fi
    return 1
  fi
  local ok=0
  if python3 - "$1" "$listing" "$ORIGIN_TIPS" 2>"$ERRFILE" <<'PY'
import subprocess
import sys

shared, listing, out = sys.argv[1:4]
with open(listing, encoding="utf-8", errors="surrogateescape") as handle:
    shas = sorted({line.split("\t", 1)[0] for line in handle if line.strip()})
check = subprocess.run(["git", "-C", shared, "cat-file", "--batch-check=%(objectname) %(objecttype)"],
                       input="".join(sha + "\n" for sha in shas).encode("ascii"), capture_output=True)
if check.returncode != 0:
    sys.stderr.write(check.stderr.decode("utf-8", "surrogateescape"))
    sys.exit(1)
present = [line.split(" ")[0] for line in check.stdout.decode("utf-8", "surrogateescape").splitlines()
           if line.endswith(" commit")]
with open(out, "w", encoding="utf-8") as handle:
    handle.write("".join(sha + "\n" for sha in present))
PY
  then ok=1; fi
  if ! rm -f "$listing"; then warn "could not remove temp file ${listing} — remove it by hand"; fi
  (( ok ))
}

# 0 when <commit> is an ancestor of a branch tip origin holds now, 1 when
# none, 2 on a tool failure.
reachable_remotely() { # <shared> <commit>
  local -a tips=()
  local tip
  while IFS= read -r tip; do [[ -n "$tip" ]] && tips+=("^${tip}"); done < "$ORIGIN_TIPS"
  (( ${#tips[@]} )) || return 1
  local out
  # Empty output: every commit <commit> reaches is also reached by a tip.
  if ! out="$(git -C "$1" rev-list -n 1 "$2" "${tips[@]}" 2>"$ERRFILE")"; then
    return 2
  fi
  [[ -z "$out" ]]
}

# Re-derive the removal proof immediately before a removal: 0 when <tip> is
# still merged into origin's default (<mode> merged) or <head> still held by an
# origin ref (<mode> reachable), 1 when the proof is gone, 2 on a failure.
# Echo the branch origin's HEAD names now; 1 (ERRFILE says why) when it
# cannot be read.
origin_head_name() { # <shared>
  local out rc=0 name
  out="$(git -C "$1" ls-remote --symref origin HEAD 2>"$ERRFILE")" || rc=$?
  if (( rc != 0 )); then network_failure "$rc" "$1" ls-remote --symref origin HEAD; return 1; fi
  name="$(printf '%s\n' "$out" | sed -n 's#^ref: refs/heads/\(.*\)[[:space:]]HEAD$#\1#p' | head -n 1)"
  if [[ -z "$name" ]]; then printf 'origin reports no default branch\n' > "$ERRFILE"; return 1; fi
  printf '%s' "$name"
}

proof_holds() { # <shared> <mode> <tip> <head> <default>
  if [[ "$2" == merged ]]; then
    # Origin's default as it is now: a force-push since the run started can
    # drop the merge, and a new default need not hold it at all.
    local now_tip now_db trc=0
    now_db="$(origin_head_name "$1")" || return 2
    [[ "$now_db" == "$5" ]] || return 1
    now_tip="$(origin_default_tip "$1" "$5")" || trc=$?
    case "$trc" in
      0) ;;
      3) return 1 ;;
      *) return 2 ;;
    esac
    git -C "$1" merge-base --is-ancestor "$3" "$now_tip" 2>"$ERRFILE" || trc=$?
    case "$trc" in
      0) return 0 ;;
      1) return 1 ;;
      *) return 2 ;;
    esac
  else
    # Origin as it is now, not as it was when the run started.
    read_origin_tips "$1" || return 2
    reachable_remotely "$1" "$4"
  fi
}

# Echo a keep reason when the worktree at <real> holds another repository's
# checkout that a removal would destroy, else nothing:
#   submodule-dirty  a gitlink (mode 160000, found from the index, never from
#                    .gitmodules) whose checkout has a new commit, changes or
#                    untracked files
#   submodule        a populated gitlink checkout; git refuses to move or
#                    remove a worktree holding one
#   nested-repo      any other .git anywhere below the worktree, in untracked
#                    or ignored directories alike, found by walking the tree
# Returns 1 on a tool failure.
nested_checkouts() { # <real>
  python3 - "$1" 2>"$ERRFILE" <<'PY'
import os
import subprocess
import sys

path = sys.argv[1]


def git(*args):
    run = subprocess.run(["git", "--no-optional-locks", "-C", path, *args], capture_output=True)
    if run.returncode != 0:
        sys.stderr.write(run.stderr.decode("utf-8", "replace"))
        sys.exit(1)
    return run.stdout.decode("utf-8", "surrogateescape")


status = git("status", "--porcelain=v2", "-z", "--untracked-files=all", "--ignore-submodules=none")
for entry in status.split("\0"):
    parts = entry.split(" ")
    if parts[0] in ("1", "2") and len(parts) > 2 and parts[2].startswith("S") and parts[2] != "S...":
        print("submodule-dirty")
        sys.exit(0)
gitlinks = [line.split("\t", 1)[1] for line in git("ls-files", "-s", "-z").split("\0")
            if line.startswith("160000 ")]
if any(os.path.exists(os.path.join(path, link, ".git")) for link in gitlinks):
    print("submodule")
    sys.exit(0)
# Any other .git below the worktree root is an embedded repository: walk the
# whole tree, ignored and untracked directories included, never trusting git's
# own listing (it collapses an untracked directory and skips ignored ones).
own = os.path.join(path, ".git")
def unreadable(error):
    # A directory the walk cannot read could hold an embedded repository.
    sys.stderr.write("cannot read {}: {}".format(error.filename, error.strerror))
    sys.exit(1)


for current, dirs, files in os.walk(path, followlinks=False, onerror=unreadable):
    if ".git" in dirs or ".git" in files:
        if os.path.join(current, ".git") != own:
            print("nested-repo")
            sys.exit(0)
    dirs[:] = [d for d in dirs if d != ".git"]
PY
}

# Decide one worktree; emits a row and performs the removal unless dry-run.
#: Set by `decide_worktree` when, and only when, it decided `prunable`. A
#: locked entry whose directory is gone is kept, its metadata stays, and it
#: stays checked out, so its branch must not be released.
DECIDED_PRUNABLE=0

#: A worktree is judged only once idle this many hours: no git activity and
#: no process inside. Overridable for tests.
IDLE_HOURS="${PRUNE_IDLE_HOURS:-24}"

decide_worktree() { # <shared> <abs_root> <default> <dry-run 0|1> <path> <branch|""> <detached 0|1> <locked 0|1> [lock-reason]
  DECIDED_PRUNABLE=0
  local shared="$1" abs_root="$2" db="$3" dry="$4" path="$5" branch="$6" detached="$7" locked="$8" lock_reason="${9:-}"
  if (( locked )); then
    row kept "$path" "$branch" locked "" "$lock_reason"; return 0
  fi
  if [[ ! -d "$path" ]]; then
    # A stale registration, inside the root or outside it: the caller drops
    # the registration and then releases its branch to the branch pass (which
    # never deletes the default branch).
    DECIDED_PRUNABLE=1
    row kept "$path" "$branch" prunable; return 0
  fi
  if [[ "$path" != "$abs_root"/* ]]; then
    row kept "$path" "$branch" outside-root; return 0
  fi
  if [[ -n "$branch" && "$branch" == "$db" ]]; then
    row kept "$path" "$branch" default-branch; return 0
  fi
  # Age first, and every read below without optional locks: judging a
  # worktree must never refresh its index and reset its clock.
  local age="" age_ok=1
  if ! age="$(idle_hours "$path")"; then
    age_ok=0
    warn "cannot read the activity age of ${path}: $(tr '\n' ' ' < "$ERRFILE") — not judging it idle"
  fi
  local state
  if ! state="$(worktree_state "$shared" "$path" "$branch")"; then
    row failed "$path" "$branch" "cannot read HEAD, branch tip or status: $(tr '\n' ' ' < "$ERRFILE")"; return 0
  fi
  local head="${state%% *}" rest="${state#* }" tip status
  tip="${rest%% *}"; status="${rest#* }"
  [[ -n "$tip" ]] || tip="$head"
  if (( ! age_ok )) || (( age < IDLE_HOURS )); then
    row kept "$path" "$branch" not-idle; return 0
  fi
  local rc=0
  in_use "$path" || rc=$?
  case "$rc" in
    0) row kept "$path" "$branch" in-use; return 0 ;;
    1) ;;
    *) row kept "$path" "$branch" idle-unknown; return 0 ;;
  esac
  local nested
  if ! nested="$(nested_checkouts "$path")"; then
    row failed "$path" "$branch" "cannot read its submodules or nested repositories, so it was kept: $(tr '\n' ' ' < "$ERRFILE")"; return 0
  fi
  if [[ -n "$nested" ]]; then
    row kept "$path" "$branch" "$nested"; return 0
  fi
  # Dirty: work git does not hold at all. Kept and reported, never removed.
  if [[ -n "$status" ]]; then
    local files; files="$(printf '%s\n' "$status" | grep -c .)"
    row kept "$path" "$branch" dirty "$head" "${files} ${age}"; return 0
  fi
  local merged="" mode=""
  if [[ -n "$branch" ]]; then
    if ! merged="$(ancestry "$shared" "$tip" "$db")"; then
      row failed "$path" "$branch" "git merge-base failed for ${branch}: $(tr '\n' ' ' < "$ERRFILE")"; return 0
    fi
    [[ "$merged" == merged ]] && mode=merged
  fi
  if [[ -z "$mode" ]]; then
    rc=0; reachable_remotely "$shared" "$head" || rc=$?
    case "$rc" in
      0) mode=reachable ;;
      1) ;;
      *) row failed "$path" "$branch" "cannot read which origin branches hold ${head}: $(tr '\n' ' ' < "$ERRFILE")"; return 0 ;;
    esac
  fi
  # Unpushed: commits origin does not hold. Kept and reported, never removed.
  if [[ -z "$mode" ]]; then
    local ahead
    if ! ahead="$(unpushed_count "$shared" "$head")"; then
      row failed "$path" "$branch" "cannot count its unpushed commits: $(tr '\n' ' ' < "$ERRFILE")"; return 0
    fi
    row kept "$path" "$branch" unpushed "$head" "${ahead} ${age}"; return 0
  fi
  # The same read-only recheck for a preview as for a removal: a dry run
  # promises only what the live run would do.
  if ! recheck "$shared" "$path" "$branch" "$state" "$IDLE_HOURS"; then
    row kept "$path" "$branch" changed; warn "kept ${path}: ${RECHECK_WHY}"; return 0
  fi
  # The proof is remote state a concurrent fetch can change: re-derive it last.
  rc=0; proof_holds "$shared" "$mode" "$tip" "$head" "$db" || rc=$?
  case "$rc" in
    0) ;;
    1) row kept "$path" "$branch" changed; warn "kept ${path}: origin no longer holds ${head}"; return 0 ;;
    *) row failed "$path" "$branch" "cannot re-verify that origin holds ${head}, so it was kept: $(tr '\n' ' ' < "$ERRFILE")"; return 0 ;;
  esac
  remove_worktree "$shared" "$dry" "$path" "$branch" "$tip" "$mode" "$head" "$db"
  return 0
}

# Echo how many commits <commit> reaches that no branch origin holds reaches.
unpushed_count() { # <shared> <commit>
  local -a tips=()
  local tip
  while IFS= read -r tip; do [[ -n "$tip" ]] && tips+=("^${tip}"); done < "$ORIGIN_TIPS"
  git -C "$1" rev-list --count "$2" "${tips[@]+"${tips[@]}"}" 2>"$ERRFILE"
}

# Remove the worktree at <path> with plain `git worktree remove` (git itself
# refuses a tree that turned dirty), then delete its branch at <tip> once
# origin is proven, again, to hold it: the removal takes time, and a
# force-push inside it would leave the branch the last ref to its commits.
remove_worktree() { # <shared> <dry> <path> <branch> <tip> <mode> <head> <default>
  local shared="$1" dry="$2" path="$3" branch="$4" tip="$5" mode="$6" head="$7" db="$8"
  if (( dry )); then
    row removed "$path" "$branch" "" "$tip"; return 0
  fi
  root_holds "$path" "$branch" || return 0
  if ! git -C "$shared" worktree remove "$path" 2>"$ERRFILE"; then
    row failed "$path" "$branch" "git worktree remove failed: $(tr '\n' ' ' < "$ERRFILE")"; return 0
  fi
  # The removal happened: report it whatever the deletion does, so the JSON
  # matches the disk a retry would find (#405).
  row removed "$path" "$branch" "" "$tip"
  [[ -n "$branch" ]] || return 0
  local rc=0
  proof_holds "$shared" "$mode" "$tip" "$head" "$db" || rc=$?
  case "$rc" in
    0) ;;
    1) row failed "$branch" "$branch" "origin stopped holding ${tip} after its worktree was removed, so ${branch} was kept; push it or delete it by hand"; return 0 ;;
    *) row failed "$branch" "$branch" "cannot re-verify that origin holds ${tip} after its worktree was removed, so ${branch} was kept: $(tr '\n' ' ' < "$ERRFILE")"; return 0 ;;
  esac
  root_holds "$branch" "$branch" || return 0
  delete_branch "$shared" "$branch" "$tip" || rc=$?
  report_branch_delete "$branch" "$tip" "$rc"
  return 0
}

# Echo whole hours since the newest activity on <branch>: its tip commit's
# committer time or its newest reflog entry, whichever is later. PRUNE_NOW
# (epoch seconds) replaces the clock for tests. Returns 1 on failure.
branch_idle_hours() { # <shared> <branch> <tip>
  local committed logged
  committed="$(git -C "$1" log -1 --format=%ct "$3" 2>"$ERRFILE")" || return 1
  # A branch without a reflog prints nothing and exits 0; any failure is real.
  logged="$(git -C "$1" reflog show -1 --format=%ct "refs/heads/$2" 2>"$ERRFILE")" || return 1
  python3 -c '
import sys, time
now = float(sys.argv[1]) if sys.argv[1] else time.time()
newest = max(int(v) for v in sys.argv[2:] if v)
print(int(max(0.0, now - newest) // 3600))' "${PRUNE_NOW:-}" "$committed" "$logged" 2>"$ERRFILE"
}

decide_branch() { # <shared> <default> <dry-run 0|1> <branch>
  local shared="$1" db="$2" dry="$3" branch="$4"
  if [[ "$branch" == "$db" ]]; then
    return 0
  fi
  local tip merged rc=0
  if ! tip="$(branch_tip "$shared" "$branch")"; then
    row failed "$branch" "$branch" "cannot read the tip of ${branch}: $(tr '\n' ' ' < "$ERRFILE")"; return 0
  fi
  if ! merged="$(ancestry "$shared" "$tip" "$db")"; then
    row failed "$branch" "$branch" "git merge-base failed for ${branch}: $(tr '\n' ' ' < "$ERRFILE")"; return 0
  fi
  # Merged into origin's default, or its tip held by an origin branch: the
  # branch holds nothing origin lacks.
  local mode=""
  [[ "$merged" == merged ]] && mode=merged
  if [[ -z "$mode" ]]; then
    rc=0; reachable_remotely "$shared" "$tip" || rc=$?
    case "$rc" in
      0) mode=reachable ;;
      1) ;;
      *) row failed "$branch" "$branch" "cannot read which origin branches hold ${tip}: $(tr '\n' ' ' < "$ERRFILE")"; return 0 ;;
    esac
  fi
  if [[ -z "$mode" ]]; then
    # Unpushed commits: kept, and reported once idle, never deleted.
    local age ahead
    if ! age="$(branch_idle_hours "$shared" "$branch" "$tip")"; then
      row failed "$branch" "$branch" "cannot read the age of ${branch}: $(tr '\n' ' ' < "$ERRFILE")"; return 0
    fi
    if (( age < IDLE_HOURS )); then
      row branch-kept "$branch" "$branch" not-idle; return 0
    fi
    if ! ahead="$(unpushed_count "$shared" "$tip")"; then
      row failed "$branch" "$branch" "cannot count the unpushed commits of ${branch}: $(tr '\n' ' ' < "$ERRFILE")"; return 0
    fi
    row branch-kept "$branch" "$branch" unpushed "$ahead" "$age"; return 0
  fi
  # Origin as it is now, the same proof a worktree removal takes, for the
  # preview as for the deletion.
  rc=0; proof_holds "$shared" "$mode" "$tip" "$tip" "$db" || rc=$?
  case "$rc" in
    0) ;;
    1) row branch-kept "$branch" "$branch" changed; warn "kept ${branch}: origin no longer holds ${tip}"; return 0 ;;
    *) row failed "$branch" "$branch" "cannot re-verify that origin holds ${tip}, so ${branch} was kept: $(tr '\n' ' ' < "$ERRFILE")"; return 0 ;;
  esac
  if (( dry )); then
    row branch-deleted "$branch" "$branch" ""; return 0
  fi
  root_holds "$branch" "$branch" || return 0
  delete_branch "$shared" "$branch" "$tip" || rc=$?
  case "$rc" in
    0) row branch-deleted "$branch" "$branch" "" ;;
    1) row failed "$branch" "$branch" "branch ${branch} moved after its ancestry check and was left alone; re-run to judge the new tip" ;;
    3) row branch-deleted "$branch" "$branch" ""
       row failed "$branch" "$branch" "${branch} is deleted but its branch.${branch} config could not be cleaned up: $(tr '\n' ' ' < "$ERRFILE") — check and remove it by hand" ;;
    4) row failed "$branch" "$branch" "could not re-read which worktrees hold ${branch}, so it was left alone: $(tr '\n' ' ' < "$ERRFILE")" ;;
    5) row branch-kept "$branch" "$branch" checked-out ;;
    6) row failed "$branch" "$branch" "a worktree claimed ${branch} while it was being deleted; the branch was restored at ${tip} and that worktree is intact — re-run once it is gone" ;;
    7) row failed "$branch" "$branch" "a worktree claimed ${branch} while it was being deleted and it could not be restored: $(tr '\n' ' ' < "$ERRFILE") — inspect that worktree by hand" ;;
    8) row failed "$branch" "$branch" "${branch} is deleted and whether a worktree claimed it meanwhile could not be read: $(tr '\n' ' ' < "$ERRFILE") — check \`git worktree list\` and restore ${branch} at ${tip} if one holds it" ;;
    9) row branch-deleted "$branch" "$branch" ""
       row failed "$branch" "$branch" "${branch} is deleted, and a worktree recreated a branch of that name before its config could be cleaned up — branch.${branch} belongs to that live branch and was left untouched; remove nothing by hand" ;;
    *) row failed "$branch" "$branch" "deleting ${branch} failed: $(tr '\n' ' ' < "$ERRFILE")" ;;
  esac
  return 0
}

main() {
  local shared="" dry=0 arg
  for arg in "$@"; do
    case "$arg" in
      --dry-run) dry=1 ;;
      -*) warn "unknown flag '${arg}'"; warn "usage: prune-worktrees.sh <shared-checkout> [--dry-run]"; return 1 ;;
      *) if [[ -n "$shared" ]]; then warn "usage: prune-worktrees.sh <shared-checkout> [--dry-run]"; return 1; fi; shared="$arg" ;;
    esac
  done
  if [[ -z "$shared" ]]; then
    warn "usage: prune-worktrees.sh <shared-checkout> [--dry-run]"
    return 1
  fi
  local tool
  for tool in git python3; do
    if ! command -v "$tool" >/dev/null; then
      warn "${tool} not found on PATH — install it"
      return 1
    fi
  done
  if ! WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/prune-worktrees.XXXXXX")"; then
    WORKDIR=""
    warn "cannot create a temporary directory under ${TMPDIR:-/tmp} — make it writable, then re-run"
    return 1
  fi
  trap cleanup EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  ERRFILE="${WORKDIR}/err"; ROWS="${WORKDIR}/rows"
  RUN_LOCK_REASON="${PRUNE_LOCK_PREFIX}$$:${WORKDIR##*.}"
  : > "$ERRFILE"; : > "$ROWS"
  if [[ ! -d "$shared" ]] || ! git -C "$shared" rev-parse --is-inside-work-tree >/dev/null 2>"$ERRFILE"; then
    warn "'${shared}' is not a git work tree ($(tr '\n' ' ' < "$ERRFILE")) — pass the shared checkout's path"
    return 1
  fi
  local abs_shared
  # A sentinel past git's own newline: command substitution strips both, and a
  # path ending in a newline would lose its own.
  abs_shared="$(git -C "$shared" rev-parse --show-toplevel && printf x)"
  abs_shared="${abs_shared%x}"; abs_shared="${abs_shared%$'\n'}"
  if ! git -C "$shared" remote get-url origin >/dev/null 2>"$ERRFILE"; then
    warn "${shared} has no origin remote — merged-ness is judged against origin's default branch; add one with \`git -C ${shared} remote add origin <url>\`, then re-run"
    return 1
  fi
  local root="${WORKTREE_ROOT:-${HOME}/.worktrees}" abs_root
  if [[ ! -d "$root" ]]; then
    warn "worktree root ${root} does not exist — nothing to prune"
    abs_root="$root"
  else
    abs_root="$(cd "$root" && pwd -P)"
    if ! ROOT_ID="$(root_identity "$abs_root")"; then
      warn "cannot read the worktree root: $(tr '\n' ' ' < "$ERRFILE") — restore it, then re-run; nothing was decided"
      return 1
    fi
    ROOT_PATH="$abs_root"
  fi
  if [[ -n "${PRUNE_ROOT_ID:-}" && "$ROOT_ID" != "$PRUNE_ROOT_ID" ]]; then
    warn "the worktree root ${root} is no longer the directory the caller proved (PRUNE_ROOT_ID ${PRUNE_ROOT_ID}, now ${ROOT_ID:-absent}) — restore it, then re-run; nothing was decided"
    return 1
  fi
  # Merged-ness is only as good as the refs it is judged against: a stale
  # origin/<default> after a force-push, or a cached origin/HEAD after the
  # remote's default branch moved, would delete work origin no longer holds.
  local -a fetch_args=(fetch --quiet origin)
  if (( ! dry )); then fetch_args=(fetch --quiet --prune origin); fi
  local frc=0
  git -C "$shared" "${fetch_args[@]}" 2>"$ERRFILE" || frc=$?
  if (( frc != 0 )); then
    network_failure "$frc" "$shared" "${fetch_args[@]}"
    warn "$(cat "$ERRFILE") — check connectivity; refusing to judge merged-ness from stale refs"
    return 1
  fi
  local src=0
  if (( ! dry )); then git -C "$shared" remote set-head origin --auto >/dev/null 2>"$ERRFILE" || src=$?; fi
  if (( src != 0 )); then
    network_failure "$src" "$shared" remote set-head origin --auto
    warn "$(cat "$ERRFILE") — cannot confirm origin's current default branch; fix access to origin, then re-run"
    return 1
  fi
  if ! read_origin_tips "$shared"; then
    warn "cannot read origin's current branch tips: $(tr '\n' ' ' < "$ERRFILE") — refusing to judge reachability from stale refs; fix access to origin, then re-run"
    return 1
  fi
  local db
  if ! db="$(default_branch_of "$shared" "$dry")"; then
    warn "cannot resolve origin's default branch — run \`git -C ${shared} remote set-head origin --auto\`"
    return 1
  fi
  if ! ORIGIN_DEFAULT="$(origin_default_tip "$shared" "$db")"; then
    warn "cannot read origin's current ${db}: $(cat "$ERRFILE") — refusing to judge merged-ness; fix access to origin, then re-run"
    return 1
  fi

  # A killed earlier run's locks would read as locked and keep its entries.
  if (( ! dry )); then release_stale_locks "$shared"; fi

  # Take both inventories up front: a failure inside a process substitution
  # would not reach the loop, and an empty inventory would read as "nothing
  # to prune" with exit 0 (rules/file-hygiene.md I/O Conventions). Nothing has
  # been decided yet, so an unreadable inventory is a precondition failure.
  local inventory branches
  inventory="${WORKDIR}/inventory"; branches="${WORKDIR}/branches"
  # `-z` (git >= 2.36) terminates each attribute with NUL, so a path holding a
  # newline stays one field. Without it there is no unambiguous read and no
  # cross-check that settles one: a scan refusing unrecognized lines still
  # accepts a path whose tail reads as an attribute (`...<newline>HEAD <sha>`,
  # `...<newline>branch refs/heads/other`), and the parser then takes that
  # tail for the record's metadata. So an inventory without `-z` decides
  # nothing (#410).
  if ! git -C "$shared" worktree list --porcelain -z >"$inventory" 2>"$ERRFILE"; then
    if grep -qiE 'unknown option|usage: git worktree' "$ERRFILE"; then
      warn "\`git worktree list --porcelain -z\` is unavailable (git < 2.36): a worktree path cannot be listed unambiguously — upgrade git to 2.36 or newer; nothing was decided"
    else
      warn "\`git worktree list --porcelain -z\` failed: $(tr '\n' ' ' < "$ERRFILE") — cannot inventory worktrees; run it in ${shared} to see why, then re-run"
    fi
    rm -f "$inventory" "$branches"
    return 1
  fi
  # Full refnames: `%(refname:short)` renders a local branch named like a
  # remote-tracking ref (origin/main) as heads/origin/main.
  if ! git -C "$shared" for-each-ref --format='%(refname)' refs/heads/ >"$branches" 2>"$ERRFILE"; then
    warn "\`git for-each-ref refs/heads/\` failed: $(tr '\n' ' ' < "$ERRFILE") — cannot inventory branches; run it in ${shared} to see why, then re-run"
    rm -f "$inventory" "$branches"
    return 1
  fi

  # Walk `worktree list --porcelain`: blank-line-separated blocks.
  local path="" branch="" detached=0 locked=0 lock_reason="" line unenterable=0
  local -a seen_branches=() prunable_paths=() prunable_owners=() live_paths=()
  flush() {
    if [[ -n "$path" ]]; then
      local real="" rc=0 parent
      # The sentinel guards the same truncation `pwd -P` needs it for below:
      # when the PARENT's own name ends in a newline, `dirname`'s output ends
      # in two and command substitution strips both (#410).
      parent="$(dirname "$path" && printf 'x')"
      parent="${parent%x}"
      parent="${parent%$'\n'}"
      if [[ ! -e "$path" ]]; then
        # `-e` is false for a missing path and for one whose ancestor denies
        # traversal. Absence is confirmed only through a traversable parent;
        # anything else is a failure that also inhibits the metadata prune.
        if [[ -d "$parent" && -x "$parent" ]]; then
          # Confirmed gone: reported prunable. The prune below drops it, and
          # its branch goes to the no-worktree pass only once it is gone.
          decide_worktree "$shared" "$abs_root" "$db" "$dry" "$path" "$branch" "$detached" "$locked" "$lock_reason"
          if (( DECIDED_PRUNABLE )); then
            prunable_paths+=("$path"); prunable_owners+=("$branch")
          elif [[ -n "$branch" ]]; then
            # Locked: git keeps a locked entry, so it is still checked out.
            seen_branches+=("$branch")
          fi
          path=""; branch=""; detached=0; locked=0; lock_reason=""
          return 0
        else
          row failed "$path" "$branch" "cannot confirm the worktree is gone: its parent ${parent} is missing or not traversable"
          # Still checked out as far as git knows: the branch pass must not
          # try `branch -D` on it (#405).
          if [[ -n "$branch" ]]; then seen_branches+=("$branch"); fi
          unenterable=1
          path=""; branch=""; detached=0; locked=0; lock_reason=""
          return 0
        fi
      else
        # A sentinel past `pwd`'s own newline: command substitution strips
        # trailing newlines, and a worktree path may end in one now that `-z`
        # can carry it. Without this the run would decide against a truncated
        # path.
        real="$(cd "$path" 2>"$ERRFILE" && pwd -P && printf 'x')" || rc=$?
        real="${real%x}"
        real="${real%$'\n'}"
        if (( rc != 0 )); then
          row failed "$path" "$branch" "cannot enter the worktree: $(tr '\n' ' ' < "$ERRFILE")"
          if [[ -n "$branch" ]]; then seen_branches+=("$branch"); fi
          unenterable=1
          path=""; branch=""; detached=0; locked=0; lock_reason=""
          return 0
        fi
      fi
      if [[ -n "$branch" ]]; then seen_branches+=("$branch"); fi
      if (( ! locked )); then live_paths+=("$path"); fi
      if [[ "$real" != "$abs_shared" ]]; then
        decide_worktree "$shared" "$abs_root" "$db" "$dry" "$real" "$branch" "$detached" "$locked" "$lock_reason"
      fi
    fi
    path=""; branch=""; detached=0; locked=0; lock_reason=""
  }
  while IFS= read -r -d '' line || [[ -n "$line" ]]; do
    case "$line" in
      "worktree "*) flush; path="${line#worktree }" ;;
      "branch refs/heads/"*) branch="${line#branch refs/heads/}" ;;
      detached) detached=1 ;;
      locked) locked=1 ;;
      "locked "*) locked=1; lock_reason="${line#locked }" ;;
      "") flush ;;
    esac
  done < "$inventory"
  flush

  # Stale registrations go between the passes: after every worktree decision,
  # so a confirmed-gone entry is released before its branch is judged below.
  # Skipped when a worktree could not be entered: the operator restores
  # access before the run changes git's records. The live entries are locked
  # for the prune's duration, so a root moved after its last check cannot
  # take their registrations with it (#597).
  # A prunable entry's branch is still checked out until its registration
  # goes, so it reaches the branch pass only once the prune dropped it. A dry
  # run previews the live outcome, where the prune does run (#405).
  local released=1
  if (( unenterable )); then released=0; fi
  if (( ! dry )); then
    local candidates="${WORKDIR}/lock-candidates"
    : > "$candidates"
    if (( ${#live_paths[@]} )); then printf '%s\0' "${live_paths[@]}" >"$candidates"; fi
    if (( unenterable )); then
      warn "skipping \`git worktree prune\`: a worktree could not be entered; restore access and re-run"
    elif ! lock_live_entries "$shared" "$candidates"; then
      released=0
    elif ! root_holds "git worktree prune" ""; then
      # A replaced root reads every unlocked worktree under it as gone.
      released=0
    elif ! git -C "$shared" worktree prune --expire now 2>"$ERRFILE"; then
      row failed "git worktree prune" "" "failed: $(tr '\n' ' ' < "$ERRFILE") — stale registrations may remain; run \`git -C ${shared} worktree prune\` to see why, then re-run"
      released=0
    fi
    unlock_run_locks
  fi
  local i gone_left="${WORKDIR}/gone-left" snap="${WORKDIR}/after-snapshot" gone_file="${WORKDIR}/gone-paths"
  local -a still=()
  if (( released && ! dry && ${#prunable_paths[@]} )); then
    # A path that reappeared before the prune keeps its registration.
    printf '%s\0' "${prunable_paths[@]}" >"$gone_file"
    if registry_snapshot "$shared" "$snap" && registry_query registered "$snap" "$gone_file" >"$gone_left" 2>"$ERRFILE"; then
      read_paths "$gone_left"
      still=("${REPLY_PATHS[@]+"${REPLY_PATHS[@]}"}")
    else
      row failed "git worktree list" "" "cannot confirm which stale registrations the prune dropped, so their branches were kept: $(tr '\n' ' ' < "$ERRFILE") — re-run"
      released=0
    fi
  fi
  local s_path kept
  for i in "${!prunable_paths[@]}"; do
    kept=0
    if (( ! released )); then kept=1; fi
    for s_path in "${still[@]+"${still[@]}"}"; do
      if [[ "$s_path" == "${prunable_paths[$i]}" ]]; then kept=1; break; fi
    done
    if (( kept )) && [[ -n "${prunable_owners[$i]}" ]]; then seen_branches+=("${prunable_owners[$i]}"); fi
  done

  # Local branches with no worktree.
  local name skip
  while IFS= read -r name; do
    name="${name#refs/heads/}"
    skip=0
    local s
    for s in "${seen_branches[@]+"${seen_branches[@]}"}"; do
      if [[ "$s" == "$name" ]]; then skip=1; break; fi
    done
    if (( skip )); then continue; fi
    decide_branch "$shared" "$db" "$dry" "$name"
  done < "$branches"
  if ! rm -f "$inventory" "$branches"; then
    warn "could not remove temp inventories ${inventory} ${branches} — remove them by hand"
  fi

  local rc=0
  python3 - "$abs_shared" "$db" "$dry" "$ROWS" <<'PY' || rc=$?
import json, shlex, sys
shared, db, dry, rows_path = sys.argv[1], sys.argv[2], sys.argv[3] == "1", sys.argv[4]
result = {"shared": shared, "default_branch": db, "dry_run": dry, "worktrees_removed": [], "worktrees_kept": [],
          "branches_deleted": [], "branches_kept": [], "failed": []}
with open(rows_path, "rb") as handle:
    fields = handle.read().decode("utf-8", "surrogateescape").split("\0")
if fields and fields[-1] == "":
    fields.pop()
if len(fields) % 6:
    sys.stderr.write("prune-worktrees: decision rows are malformed; report this as a bug\n")
    sys.exit(2)
for index in range(0, len(fields), 6):
    kind, target, branch, reason, head, extra = fields[index:index + 6]
    if kind == "removed":
        result["worktrees_removed"].append({"path": target, "branch": branch or None, "head": head})
    elif kind == "kept":
        kept = {"path": target, "branch": branch or None, "reason": reason}
        if reason == "locked":
            kept["lock_reason"] = extra or None
        if reason in ("dirty", "unpushed"):
            # Work git or origin does not hold: the operator decides.
            where = shlex.quote(target)
            count, age = (int(v) for v in extra.split(" "))
            kept["head"] = head
            kept["age_hours"] = age
            if reason == "dirty":
                kept["dirty_files"] = count
                kept["command"] = "git -C {} status".format(where)
            else:
                kept["unpushed_commits"] = count
                kept["command"] = ("git -C {} push -u origin HEAD".format(where) if branch else
                                   "git -C {0} switch -c <branch> && git -C {0} push -u origin HEAD".format(where))
        result["worktrees_kept"].append(kept)
    elif kind == "branch-deleted":
        result["branches_deleted"].append(branch)
    elif kind == "branch-kept":
        entry = {"branch": branch, "reason": reason}
        if reason == "unpushed":
            entry.update(unpushed_commits=int(head), age_hours=int(extra),
                         command="git -C {} push -u origin {}".format(shlex.quote(shared), shlex.quote(branch)))
        result["branches_kept"].append(entry)
    else:
        result["failed"].append({"target": target, "error": reason})
print(json.dumps(result, sort_keys=True))
sys.exit(2 if result["failed"] else 0)
PY
  return "$rc"
}

# Entry-point guard (rules/file-hygiene.md Standalone Scripts).
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  main "$@"
fi

skills

herdr-foreman

bounded-run.sh

compose-briefs.sh

config.example.json

foreman-tier-check.py

foreman.sh

label-workspaces.sh

provision-worktree.sh

prune-remote-branches.sh

prune-report-caches.py

prune-worktrees.sh

resolve-gates.sh

resolve-policy-paths.sh

review-package.sh

roster.sh

round-preflight.sh

SKILL.md

start-judge-worker.sh

state-schema.md

sweep-worktrees.sh

verify-authority.sh

wait-report.sh

README.md

tile.json