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

check-leftovers.shskills/release/

#!/usr/bin/env bash
# Refuse to start a release while work sits uncommitted in a worktree.
#
# The release flow's later steps are all scripted; Step 1 was prose, so the
# decision to OPEN a PR rested on the agent remembering to look. It did not:
# jbaruch/coding-policy's pane-label change was written across four files,
# never committed, and sat in `~/.worktrees/coding-policy-pane-model` for nine
# days while its branch reported as merged -- the branch tip was a plain `main`
# commit, so every "delete merged branches" heuristic called the worktree
# disposable while the only copy of the work lived beside it, untracked.
#
# Two verdicts, because the two shapes need different strictness:
#
#   SELF -- the worktree the release runs from. Any staged, unstaged or
#   untracked path blocks. A release publishes what is committed, so anything
#   else is either work about to be lost or junk that belongs in .gitignore,
#   and the caller must say which.
#
#   OTHER -- every other worktree sharing this repository. Concurrent agents
#   legitimately hold work in progress here (rules/agent-worktree-isolation.md),
#   so dirt alone proves nothing. What proves abandonment is dirt on a branch
#   whose tip is already an ANCESTOR of origin/main: nothing was ever committed
#   on it. A branch carrying its own commits is recoverable from git and is left
#   alone, and a branch whose tip is in main cannot carry an open pull request
#   either -- GitHub has no commits to show -- which is why this needs no `gh`
#   and no network. An age floor keeps a worktree created minutes ago from
#   tripping it.
#
# Every read here fails closed. A gate that answers "clean" when it could not
# look is worse than one that refuses to answer, so a git command that fails,
# a clock that cannot be read and a path whose status cannot be parsed all exit
# 2 rather than producing a verdict.
#
# Usage: check-leftovers.sh [--repo <path>]
# Out:   one JSON object on stdout, on every exit code below:
#          {"ok":bool,"self":{...},"others":[{...}],"blocking":["<reason>",...]}
#        `self` carries path, branch, staged/unstaged/untracked counts, and the
#        same tip_in_main/age_hours/verdict the other entries carry. The release
#        gate blocks on any `self` dirt whatever its verdict; the verdict is
#        there for a caller that only wants the abandoned shape, such as the
#        session-start hook, which has no reason to nag about work in progress.
# Exit:  0 nothing blocks the release; 1 leftovers block it, each named in
#        `blocking` with the diagnostic on stderr; 2 usage or tool error (not a
#        git repository, git absent, an unreadable worktree list or status, an
#        unreadable clock, an unreadable modification time, a registered
#        worktree that is not a readable directory, a base ref that exists but
#        does not resolve).
#
# LEFTOVERS_MIN_AGE_HOURS overrides the age floor an OTHER worktree must clear
# before its dirt reads as abandoned rather than freshly started. A value that is
# not a whole number of hours, or is past FLOOR_CEILING, exits 2 rather than
# silently failing every comparison it is used in.
set -euo pipefail

#: An other-worktree leftover younger than this is someone still typing.
LEFTOVERS_MIN_AGE_HOURS="${LEFTOVERS_MIN_AGE_HOURS:-4}"
#: A century in hours, and the largest floor this accepts. Anything above it is
#: unreachable by a real mtime, and a digit string past the shell's arithmetic
#: would break every comparison that used it.
FLOOR_CEILING=876000

#: Where a git command's stderr lands while its stdout carries NUL-separated
#: records. Set once the scratch directory exists.
ERR_SINK=/dev/null
#: That directory. Global, not a local of main(): the EXIT trap runs after
#: main() returns, when a local would already be unset. The trap names this
#: function rather than an interpolated path, so nothing in TMPDIR reaches the
#: trap as shell source.
SCRATCH=""

#: read_status() fills these. Arrays and counters rather than a parsed string:
#: a path may contain a newline, which no line-oriented carrier survives.
WT_PATHS=()
WT_STAGED=0
WT_UNSTAGED=0
WT_UNTRACKED=0

json_str() {
  local s="$1" out="" i ch esc
  s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
  s="${s//$'\t'/\\t}"; s="${s//$'\n'/\\n}"; s="${s//$'\r'/\\r}"
  # Every remaining C0 control character is legal in a path and illegal raw in
  # a JSON string, so each becomes its \u escape. Raw, one of them makes the
  # whole envelope unparseable -- the shape every reader of this script
  # depends on (rules/script-delegation.md Script Requirements).
  case "$s" in
    *[[:cntrl:]]*) ;;
    *) printf '"%s"' "$s"; return 0 ;;
  esac
  for (( i = 0; i < ${#s}; i++ )); do
    ch="${s:i:1}"
    case "$ch" in
      [[:cntrl:]]) printf -v esc '\\u%04x' "'$ch"; out+="$esc" ;;
      *) out+="$ch" ;;
    esac
  done
  printf '"%s"' "$out"
}

die() { echo "check-leftovers: $*" >&2; printf '{"ok":false,"self":null,"others":[],"blocking":[]}\n'; exit 2; }

# The removal is checked explicitly, not suppressed, and `return 0` is last:
# under `set -e` a failing `rm` would abort the handler before it got there and
# replace the verdict this script exits with (rules/error-handling.md Shell
# Error Handling).
cleanup() {
  if [ -n "$SCRATCH" ] && ! rm -rf "$SCRATCH"; then
    echo "check-leftovers: could not remove the temporary directory ${SCRATCH} -- delete it by hand" >&2
  fi
  return 0
}

# Seconds since a path was last written. GNU `stat` is probed first: BSD's `-f`
# is GNU's --file-system, which answers an unrelated question and SUCCEEDS while
# doing it, so probing the other way round yields a non-time on Linux and every
# age reads as zero.
#
# A path that no longer exists is a deletion, and deleting it is exactly what
# updated its parent directory's mtime -- so the parent answers for it. Without
# that, an uncommitted deletion stats nothing, reads as just-written, and the
# one change git cannot recover would be the one that does not block.
# The clock, read once. Non-zero when it cannot be: an unread clock is not a
# time to work from.
clock_now() {
  local now status=0
  now="$(date +%s)" || status=$?
  # The status is checked before the output: a non-zero `date` that still prints
  # digits is a failed clock read, not a reading to accept.
  if [ "$status" -ne 0 ]; then
    echo "check-leftovers: cannot read the system clock (date +%s exited ${status}) -- no age can be computed, so no worktree can be classified" >&2
    return 1
  fi
  case "$now" in
    ''|*[!0-9]*)
      echo "check-leftovers: the system clock reported '${now}', which is not a whole number of seconds -- no age can be computed, so no worktree can be classified" >&2
      return 1
      ;;
  esac
  echo "$now"
}

# Seconds since a path was last written, against a clock the caller already
# read. Non-zero when the mtime cannot be read at all: an age this script had to
# guess would decide a verdict, and a gate that cannot tell must refuse rather
# than answer the reassuring way.
#
# A path that no longer exists is a deletion, and deleting it is exactly what
# updated its parent directory's mtime -- so the parent answers for it. Without
# that, an uncommitted deletion stats nothing and the one change git cannot
# recover would be the one that does not block.
age_seconds() { # <now> <path>
  local now="$1" path="$2" target mtime
  target="$path"
  if [ ! -e "$target" ] && [ ! -L "$target" ]; then
    target="$(dirname -- "$path")"
  fi
  # GNU `stat` is probed first: BSD's `-f` is GNU's --file-system, which answers
  # an unrelated question and SUCCEEDS while doing it, so probing the other way
  # round yields a non-time on Linux and every age reads as zero.
  mtime="$(stat -c %Y "$target" 2>/dev/null)" || mtime=""
  [ -n "$mtime" ] || mtime="$(stat -f %m "$target" 2>/dev/null)" || mtime=""
  case "$mtime" in
    ''|*[!0-9]*)
      echo "check-leftovers: cannot read the modification time of ${target} -- check the path is readable, then re-run" >&2
      return 1
      ;;
  esac
  echo $(( now - mtime ))
}

# Every path git reports as changed, with its staged/unstaged/untracked counts,
# read into the WT_* globals. Non-zero, naming git's own message, when git could
# not read the worktree: an unreadable worktree and a clean one are opposite
# answers and must not collapse into the same zero.
#
# Parsed from the NUL-separated records directly, never through a line-oriented
# intermediate, because a path may legally contain a newline. A rename or copy
# record is followed by a second NUL field holding the ORIGINAL path with no
# status prefix; it is consumed here so it cannot be mistaken for a record of
# its own. `--untracked-files=all` rather than `normal`: normal collapses an
# untracked directory to a single entry, and a directory's mtime does not move
# when a file already inside it is edited, so a fresh edit under an old
# directory would read as abandoned.
read_status() { # <worktree>
  local wt="$1" file="${SCRATCH}/status" record x y path origin
  WT_PATHS=(); WT_STAGED=0; WT_UNSTAGED=0; WT_UNTRACKED=0

  if ! git -C "$wt" status --porcelain -z --untracked-files=all >"$file" 2>"$ERR_SINK"; then
    echo "check-leftovers: cannot read git status in ${wt}: $(cat "$ERR_SINK")" >&2
    return 1
  fi

  while IFS= read -r -d '' record; do
    [ ${#record} -ge 3 ] || {
      echo "check-leftovers: ${wt} reported a status record shorter than its own prefix -- run 'git -C ${wt} status --porcelain -z' to see it" >&2
      return 1
    }
    x="${record:0:1}"; y="${record:1:1}"; path="${record:3}"

    if [ "$x$y" = '??' ]; then
      WT_UNTRACKED=$(( WT_UNTRACKED + 1 ))
    else
      # Every code other than a space counts, not a hand-picked set: `T` for a
      # type change and `U` for an unmerged path are dirt too, and a worktree
      # holding only those would otherwise count zero and let the release run.
      [ "$x" = ' ' ] || WT_STAGED=$(( WT_STAGED + 1 ))
      [ "$y" = ' ' ] || WT_UNSTAGED=$(( WT_UNSTAGED + 1 ))
    fi
    WT_PATHS+=("$path")

    case "$x$y" in
      R?|C?|?R|?C)
        # shellcheck disable=SC2034  # Read to consume the field, not to use
        # it: the original path of a rename is gone from disk and has no age.
        if ! IFS= read -r -d '' origin; then
          echo "check-leftovers: ${wt}'s status ended part-way through a rename record -- run 'git -C ${wt} status --porcelain -z' to see it" >&2
          return 1
        fi
        ;;
    esac
  done < "$file"
  return 0
}

# The newest write among the paths read_status collected, in whole hours. The
# paths are relative to the worktree git read them from, so that root is an
# argument rather than something this function could guess.
dirt_age_hours() { # <worktree>
  local wt="$1" newest="" rel age now
  now="$(clock_now)" || return 1
  for rel in ${WT_PATHS+"${WT_PATHS[@]}"}; do
    age="$(age_seconds "$now" "${wt}/${rel}")" || return 1
    if [ -z "$newest" ] || [ "$age" -lt "$newest" ]; then newest="$age"; fi
  done
  # No readable path at all is the only case with no age to report.
  [ -z "$newest" ] && newest=0
  # A path dated in the future is a skewed clock or a skewed mtime, never an old
  # one. It reads as just-written, and says so rather than producing the
  # negative age that would sort ahead of every real one.
  if [ "$newest" -lt 0 ]; then
    echo "check-leftovers: a changed path in ${wt} is dated in the future -- treating it as just written; check the system clock and the file's timestamp" >&2
    newest=0
  fi
  echo $(( newest / 3600 ))
}

# A worktree's branch name, or DETACHED when HEAD points at no branch -- which
# `--abbrev-ref` reports by printing HEAD and exiting 0. A non-zero exit is a
# tool error and is never dressed up as a branch name.
branch_of() { # <worktree>
  local name err status=0
  err="$(git -C "$1" rev-parse --abbrev-ref HEAD 2>&1)" || status=$?
  if [ "$status" -ne 0 ]; then
    echo "check-leftovers: cannot read the checked-out branch in ${1} (exit ${status}): ${err}" >&2
    return 1
  fi
  name="$err"
  [ "$name" = "HEAD" ] && name="DETACHED"
  echo "$name"
}

# true when <rev> is already an ancestor of <base>. git answers 0 for yes and 1
# for no; anything above that is a real failure (a bad object, an unreadable
# worktree) and must not read as "carries its own commits", which is the answer
# that spares a worktree from the verdict.
tip_is_in() { # <worktree> <rev> <base>
  local err status=0
  err="$(git -C "$1" merge-base --is-ancestor "$2" "$3" 2>&1)" || status=$?
  case "$status" in
    0) echo true ;;
    1) echo false ;;
    *) echo "check-leftovers: cannot tell whether ${2} is already in ${3} inside ${1} (exit ${status}): ${err}" >&2
       return 1 ;;
  esac
}

main() {
  local repo="."
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --repo) [ "$#" -ge 2 ] || die "--repo needs a path"; repo="$2"; shift 2 ;;
      *) echo "usage: check-leftovers.sh [--repo <path>]" >&2
         printf '{"ok":false,"self":null,"others":[],"blocking":[]}\n'; exit 2 ;;
    esac
  done

  command -v git >/dev/null || die "git not found on PATH -- install it, then re-run"

  case "$LEFTOVERS_MIN_AGE_HOURS" in
    ''|*[!0-9]*)
      die "LEFTOVERS_MIN_AGE_HOURS is '${LEFTOVERS_MIN_AGE_HOURS}' -- set it to a whole number of hours (0 or more), or unset it to use the default" ;;
  esac
  # Digits alone are not enough. A value past what the shell's arithmetic holds
  # makes every comparison using it exit 2, and a failed comparison is false --
  # the verdict that spares the worktree. The length test runs first so the
  # numeric one never sees a value it cannot evaluate. FLOOR_CEILING is a
  # century in hours: a floor above it can never be reached by a real mtime,
  # so rejecting it costs nothing and catches a fat-fingered value.
  LEFTOVERS_MIN_AGE_HOURS="${LEFTOVERS_MIN_AGE_HOURS#"${LEFTOVERS_MIN_AGE_HOURS%%[!0]*}"}"
  [ -n "$LEFTOVERS_MIN_AGE_HOURS" ] || LEFTOVERS_MIN_AGE_HOURS=0
  if [ "${#LEFTOVERS_MIN_AGE_HOURS}" -gt "${#FLOOR_CEILING}" ] \
     || [ "$LEFTOVERS_MIN_AGE_HOURS" -gt "$FLOOR_CEILING" ]; then
    die "LEFTOVERS_MIN_AGE_HOURS is '${LEFTOVERS_MIN_AGE_HOURS}', past the ${FLOOR_CEILING}-hour ceiling -- set it to a whole number of hours between 0 and ${FLOOR_CEILING}, or unset it to use the default"
  fi

  SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/check-leftovers.XXXXXX")" \
    || die "cannot create a temporary directory under ${TMPDIR:-/tmp} -- check it is writable, then re-run"
  trap cleanup EXIT
  ERR_SINK="${SCRATCH}/git-stderr"
  : > "$ERR_SINK" || die "cannot write to ${ERR_SINK} -- check ${TMPDIR:-/tmp} is writable, then re-run"

  git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 \
    || die "${repo} is not a git repository -- run this from the worktree you are releasing from"

  local self_path
  self_path="$(git -C "$repo" rev-parse --show-toplevel && printf x)" \
    || die "cannot resolve the worktree root of ${repo}"
  self_path="${self_path%x}"
  self_path="${self_path%$'\n'}"

  # A ref that does not resolve is not the same as a ref that is not there. If
  # origin/main exists in the ref store but will not resolve to a commit, the
  # repository is damaged, and judging a worktree against local `main` instead
  # would answer the wrong question rather than refuse to answer.
  local base="origin/main" present
  if ! git -C "$repo" rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then
    # `for-each-ref` answers existence alone: it exits 0 either way and prints
    # the name only when the ref is there. `show-ref --verify` cannot be asked
    # this -- it exits non-zero for a damaged ref and for an absent one alike,
    # which is the distinction being drawn.
    present="$(git -C "$repo" for-each-ref --format='%(refname)' "refs/remotes/${base}" 2>"$ERR_SINK")" \
      || die "cannot read ${base} in ${repo}: $(cat "$ERR_SINK")"
    if [ -n "$present" ]; then
      die "${base} exists but does not resolve to a commit -- the ref is damaged; run 'git -C ${repo} fsck', then re-run"
    fi
    base="main"
    if ! git -C "$repo" rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then
      die "neither origin/main nor main resolves -- fetch the remote, then re-run"
    fi
  fi

  local blocking=() others_json=() ok=true

  local self_branch staged unstaged untracked self_tip_in_main self_age self_verdict
  self_branch="$(branch_of "$self_path")" \
    || die "cannot read the checked-out branch in ${self_path} -- see the diagnostic above"
  read_status "$self_path" \
    || die "cannot read the working tree at ${self_path} -- see the diagnostic above"
  staged="$WT_STAGED"; unstaged="$WT_UNSTAGED"; untracked="$WT_UNTRACKED"
  self_tip_in_main="$(tip_is_in "$self_path" HEAD "$base")" \
    || die "cannot classify ${self_path} against ${base} -- see the diagnostic above"
  self_age="$(dirt_age_hours "$self_path")" \
    || die "cannot age the changes in ${self_path} -- see the diagnostic above"
  self_verdict="clean"
  if [ $(( staged + unstaged + untracked )) -gt 0 ]; then
    self_verdict="in_progress"
    if [ "$self_tip_in_main" = true ] && [ "$self_age" -ge "$LEFTOVERS_MIN_AGE_HOURS" ]; then
      self_verdict="abandoned"
    fi
    ok=false
    blocking+=("$(printf 'this worktree (%s) has %d staged, %d unstaged and %d untracked path(s); a release publishes only what is committed' \
      "$self_branch" "$staged" "$unstaged" "$untracked")")
  fi

  # `--porcelain -z`, and read as NUL records: a worktree path may contain a
  # newline, and the newline-delimited form would split it into two worktrees
  # that are each neither.
  local inventory="${SCRATCH}/worktrees"
  git -C "$repo" worktree list --porcelain -z >"$inventory" 2>"$ERR_SINK" \
    || die "cannot read the worktree list for ${repo}: $(cat "$ERR_SINK")"

  local field wt branch tip_in_main age verdict
  while IFS= read -r -d '' field; do
    case "$field" in
      'worktree '*) wt="${field#worktree }" ;;
      *) continue ;;
    esac
    [ -n "$wt" ] || continue
    [ "$wt" = "$self_path" ] && continue
    if [ ! -d "$wt" ]; then
      # A registered worktree whose directory is gone took its files with it and
      # holds nothing to lose. One that exists and is not a searchable directory
      # may hold the only copy of the work, and skipping it silently is the
      # fail-open this gate exists to close.
      if [ -e "$wt" ] || [ -L "$wt" ]; then
        die "${wt} is registered as a worktree but is not a readable directory -- fix its permissions, or run 'git -C ${repo} worktree prune', then re-run"
      fi
      continue
    fi
    read_status "$wt" \
      || die "cannot read the working tree at ${wt} -- see the diagnostic above"
    [ "${#WT_PATHS[@]}" -gt 0 ] || continue

    branch="$(branch_of "$wt")" \
      || die "cannot read the checked-out branch in ${wt} -- see the diagnostic above"
    tip_in_main="$(tip_is_in "$wt" HEAD "$base")" \
      || die "cannot classify ${wt} against ${base} -- see the diagnostic above"
    age="$(dirt_age_hours "$wt")" \
      || die "cannot age the changes in ${wt} -- see the diagnostic above"

    verdict="in_progress"
    if [ "$tip_in_main" = true ] && [ "$age" -ge "$LEFTOVERS_MIN_AGE_HOURS" ]; then
      verdict="abandoned"
      ok=false
      blocking+=("$(printf '%s holds uncommitted work on %s, a branch carrying no commits of its own, last written %dh ago; nothing in git preserves it' \
        "$wt" "$branch" "$age")")
    fi
    others_json+=("$(printf '{"path":%s,"branch":%s,"tip_in_main":%s,"age_hours":%d,"verdict":%s}' \
      "$(json_str "$wt")" "$(json_str "$branch")" "$tip_in_main" "$age" "$(json_str "$verdict")")")
  done < "$inventory"

  local others_out="" blocking_out="" item
  for item in ${others_json+"${others_json[@]}"}; do
    others_out="${others_out:+${others_out},}${item}"
  done
  for item in ${blocking+"${blocking[@]}"}; do
    blocking_out="${blocking_out:+${blocking_out},}$(json_str "$item")"
  done

  printf '{"ok":%s,"self":{"path":%s,"branch":%s,"staged":%d,"unstaged":%d,"untracked":%d,"tip_in_main":%s,"age_hours":%d,"verdict":%s},"others":[%s],"blocking":[%s]}\n' \
    "$ok" "$(json_str "$self_path")" "$(json_str "$self_branch")" \
    "$staged" "$unstaged" "$untracked" "$self_tip_in_main" "$self_age" \
    "$(json_str "$self_verdict")" "$others_out" "$blocking_out"

  if [ "$ok" = false ]; then
    echo "check-leftovers: the release cannot start -- uncommitted work would be left behind:" >&2
    for item in ${blocking+"${blocking[@]}"}; do echo "  - ${item}" >&2; done
    echo "Commit it, stash it, or add it to .gitignore, then re-run. Inspect a named worktree with 'git -C <path> status'." >&2
    exit 1
  fi
}

[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@"

skills

README.md

tile.json