ci/woodpecker/pr/ci Pipeline was canceled
An independent reviewer broke all three new controls before they shipped. Every finding is reproduced as a fixture or a repro, because the class is recurring rather than incidental: each hole was a case where the answer was "allow" because something was ABSENT rather than because it was CHECKED. 1. wrapper-guard read only the spellings it knew. `curl -d@body` (no space), `--request=POST` (equals form), and a URL path assembled from shell variables each carried a real provider write straight through. Write detection now covers every body and method form curl accepts, and the endpoint match no longer anchors on a literal host path that a variable can dissolve. 2. wrapper-guard blocked only when the wrapper FILE existed. A host with a broken or partial install therefore permitted exactly the raw writes the guard exists to stop. Blocking is now on the endpoint; a missing wrapper changes the remedy text, not the verdict — a broken install is not permission to bypass gate 7. 3. mosaic-worktree read a worktree's safety from two questions, and a clean, fully-pushed tree holding a gitignored `local.secret` answered both with zero. `git worktree remove` then deleted the one copy in existence. A file is gitignored precisely so nothing else holds it, so ignored-but-not- disposable files are now a third evidence question. Build junk (node_modules, .venv, dist, caches, *.pyc) stays disposable, so the common case still reads SAFE. 4. check-tools-index counted a documented tool as discoverable at mode 0644. Every caller tests `[ -x ]`, so a non-executable tool is a missing tool; it now fails the gate with its own message. Local gates green: sanitization, resident budget, test enumeration, tools-index (4/4 self-test, 100% on the enforced git suite), and wrapper-guard 20/20.
268 lines
11 KiB
Bash
Executable File
268 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
|
# worktree on a fleet host.
|
|
#
|
|
# Why this exists as a helper and not as a rule: the rule already existed, in
|
|
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
|
# $HOME across 842 directories anyway. Five placement conventions were live on
|
|
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
|
# and a decision an agent has to make is a decision that drifts.
|
|
#
|
|
# So this script makes NO placement decision available. The caller supplies a
|
|
# branch name. Every path is DERIVED:
|
|
#
|
|
# main worktree <- git worktree list --porcelain (never cwd, which may
|
|
# itself already be a worktree)
|
|
# REPO_NAME <- basename of the main worktree
|
|
# REPO_PARENT <- dirname of the main worktree
|
|
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
|
# SLUG <- branch with '/' replaced by '-'
|
|
# WT_PATH <- $WT_ROOT/$SLUG
|
|
#
|
|
# The derivation puts the worktree on the same filesystem as the object store
|
|
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
|
# properties that make the checkout cheap and — via `git worktree list` —
|
|
# enumerable, which is the only reason automated cleanup can ever be safe.
|
|
#
|
|
# Usage:
|
|
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
|
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
|
# mosaic-worktree.sh list this repo's worktrees + state
|
|
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
|
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
|
#
|
|
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
|
# commits absent from every remote, or holding ignored files that are not of the
|
|
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
|
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
|
# by size or age. --force overrides it and is yours to type deliberately.
|
|
#
|
|
# Run from anywhere inside the repo, or pass --repo <path>.
|
|
|
|
set -euo pipefail
|
|
|
|
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
|
|
|
REPO_HINT=""
|
|
ARGS=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
|
*) ARGS+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
|
|
|
CMD="${1:-}"
|
|
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
|
shift || true
|
|
|
|
# ---- mechanical derivation -------------------------------------------------
|
|
# The FIRST entry of `git worktree list --porcelain` is always the main
|
|
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
|
# would nest worktrees inside worktrees.
|
|
resolve_repo() {
|
|
local start="${REPO_HINT:-$PWD}"
|
|
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
|
|| die "not inside a git repository: $start"
|
|
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')"
|
|
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
|
REPO_NAME="$(basename -- "$MAIN_WT")"
|
|
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
|
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
|
}
|
|
|
|
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
|
|
|
derive_path() {
|
|
local branch="$1"
|
|
[ -n "$branch" ] || die "branch name required"
|
|
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
|
}
|
|
|
|
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
|
# $HOME is for configuration and state, not work products. Refuse rather than
|
|
# silently produce the layout we are trying to eliminate.
|
|
assert_not_home() {
|
|
local p="$1" home_real repo_real
|
|
home_real="$(cd "$HOME" && pwd -P)"
|
|
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
|
case "$repo_real/" in
|
|
"$home_real"/*)
|
|
die "refusing: derived path is under \$HOME ($p).
|
|
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
|
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
|
configuration, credentials, state and caches — not checkouts." ;;
|
|
esac
|
|
}
|
|
|
|
# ---- work-loss evidence ----------------------------------------------------
|
|
# Two independent questions, both answered from git, neither from size or age:
|
|
# dirty — anything uncommitted in the tree
|
|
# unpushed — commits reachable from HEAD that no remote ref contains
|
|
# precious — IGNORED files git will not mention and will not miss
|
|
#
|
|
# The third question is not obvious and was missed on the first pass. An
|
|
# independent reviewer demonstrated it in four commands: a pushed, clean
|
|
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
|
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
|
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
|
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
|
# precisely the files that are ignored BECAUSE they must not be committed, which
|
|
# is also why nothing else is holding a copy.
|
|
#
|
|
# So ignored files count as work unless they are the well-known regenerable
|
|
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
|
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
|
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
|
# The list stays short and conservative for that reason.
|
|
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
|
|
|
wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; }
|
|
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; }
|
|
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
|
# directory entry instead of being enumerated and then discarded.
|
|
wt_precious() {
|
|
git -C "$1" status --porcelain --ignored 2>/dev/null \
|
|
| awk '/^!! /{print substr($0,4)}' \
|
|
| grep -Ev "$DISPOSABLE_RE" | head -200 | wc -l
|
|
}
|
|
|
|
wt_state() {
|
|
local wt="$1" d u p
|
|
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
|
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
|
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
|
else
|
|
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
|
fi
|
|
}
|
|
|
|
# ---- commands --------------------------------------------------------------
|
|
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
|
|
|
cmd_new() {
|
|
local branch="${1:-}" base=""
|
|
shift || true
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
|
done
|
|
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
|
|
|
resolve_repo
|
|
local path; path="$(derive_path "$branch")"
|
|
assert_not_home "$path"
|
|
|
|
if [ -e "$path" ]; then
|
|
echo "exists: $path"
|
|
echo "(already checked out — reuse it, or 'rm' it first)"
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$WT_ROOT"
|
|
|
|
# Existing branch -> check it out. New branch -> create from base (default:
|
|
# the remote's default branch if resolvable, else current HEAD).
|
|
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
|
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
|
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
|
else
|
|
if [ -z "$base" ]; then
|
|
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
|
[ -n "$base" ] || base="HEAD"
|
|
fi
|
|
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
|
fi
|
|
|
|
cat <<EOF
|
|
|
|
worktree: $path
|
|
branch: $branch
|
|
|
|
Removal is part of this task, not a later chore. When the work is pushed:
|
|
mosaic-worktree.sh rm $branch
|
|
EOF
|
|
}
|
|
|
|
cmd_list() {
|
|
resolve_repo
|
|
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
|
git -C "$MAIN_WT" worktree list --porcelain \
|
|
| awk '/^worktree /{print substr($0,10)}' \
|
|
| while read -r wt; do
|
|
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
|
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
|
done
|
|
}
|
|
|
|
cmd_rm() {
|
|
local branch="${1:-}" force=0
|
|
shift || true
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
|
done
|
|
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
|
|
|
resolve_repo
|
|
local path; path="$(derive_path "$branch")"
|
|
[ -d "$path" ] || die "no worktree at $path"
|
|
|
|
local d u p
|
|
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
|
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
|
die "refusing to remove $path
|
|
uncommitted files: $d
|
|
unpushed commits: $u
|
|
ignored, not disposable: $p
|
|
Commit and push first — that is the contract. Ignored files are counted because
|
|
git will neither report them nor miss them: a .env or a *.secret is ignored
|
|
precisely so it is never committed, which is also why nothing else holds a copy.
|
|
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
|
If this work is genuinely disposable, re-run with --force."
|
|
fi
|
|
|
|
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
|
if [ "$force" -eq 1 ]; then
|
|
git -C "$MAIN_WT" worktree remove --force "$path"
|
|
else
|
|
git -C "$MAIN_WT" worktree remove "$path"
|
|
fi
|
|
git -C "$MAIN_WT" worktree prune
|
|
echo "removed: $path"
|
|
rmdir "$WT_ROOT" 2>/dev/null || true
|
|
}
|
|
|
|
cmd_gc() {
|
|
local apply=0
|
|
[ "${1:-}" = "--apply" ] && apply=1
|
|
resolve_repo
|
|
git -C "$MAIN_WT" worktree prune
|
|
git -C "$MAIN_WT" worktree list --porcelain \
|
|
| awk '/^worktree /{print substr($0,10)}' \
|
|
| while read -r wt; do
|
|
[ "$wt" = "$MAIN_WT" ] && continue
|
|
local_state="$(wt_state "$wt")"
|
|
case "$local_state" in
|
|
SAFE*)
|
|
if [ "$apply" -eq 1 ]; then
|
|
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
|
else
|
|
echo "reclaimable (clean + fully pushed): $wt"
|
|
fi ;;
|
|
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
|
esac
|
|
done
|
|
git -C "$MAIN_WT" worktree prune
|
|
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
|
}
|
|
|
|
case "$CMD" in
|
|
new) cmd_new "$@" ;;
|
|
path) cmd_path "$@" ;;
|
|
list) cmd_list "$@" ;;
|
|
rm) cmd_rm "$@" ;;
|
|
gc) cmd_gc "$@" ;;
|
|
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
|
esac
|