R1/R2: `git status --porcelain` honours status.showUntrackedFiles, so a `no` setting in a submodule (or the worktree) hid untracked files from the checks that justify worktree_remove.sh's single --force and from the review snapshot guard (_dirty_submodules), which then approved content it never captured. All three checks (lib_py/worktree.py, worktree_remove.sh, runner._dirty_submodules) now pass --untracked-files=all. The source tree's own snapshot was already safe: `git add -A` ignores that setting. R3: worktree_prepare.sh no longer links .venv to the control root's. It creates the worktree's own venv with the control root's interpreter and installs requirements*.txt; a leftover link is refused (exit 1), a control root without an interpreter is a warning (VENV=skipped); VENV= is printed. Tests: w1 (real venv, kept on re-run), w1b (link refused / skipped), w6e parametrised over the hidden setting for the post-stop recheck, w6e2 (hidden untracked file in the submodule and in the worktree blocks the removal), l17 untracked-hidden / during-hidden (guard refuses before the first review; a mid-round hidden file makes the round stale). Each new case fails without the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
172 lines
7.3 KiB
Bash
Executable File
172 lines
7.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# worktree_remove.sh — remove an issue worktree of the control root (Issue #21
|
|
# item 4). Every check runs first and changes nothing; only when all pass are
|
|
# sessions stopped and the worktree removed.
|
|
#
|
|
# Usage:
|
|
# worktree_remove.sh --path <WT> [--stop] [--dry-run]
|
|
#
|
|
# Checks: (1) a linked worktree of the control root, not locked; (2) no running
|
|
# session under it unless --stop; (3) no open job of those sessions (the
|
|
# delegate job of a session --stop will stop is cancelled by stop itself);
|
|
# (4) clean tree, submodules included; (5) submodules: nothing modified and no
|
|
# commit reachable from HEAD or any local ref (tags included - a submodule is
|
|
# on a detached HEAD) that is on no remote, and no stash - in every repository under the worktree's modules/,
|
|
# initialised or not. (4) and (5) run again right before the removal, after
|
|
# --stop: a session that is being stopped can still write, commit or stash.
|
|
# Removal is plain, or - when the worktree holds submodule state, which
|
|
# git refuses to remove without it - a single --force justified by (4) and (5).
|
|
# Never run `git worktree remove --force` by hand on such a worktree.
|
|
#
|
|
# Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control
|
|
# root, 3 refused by a check (nothing changed).
|
|
set -euo pipefail
|
|
|
|
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$(cd "$_script_dir/../.." && pwd)/lib.sh"
|
|
|
|
usage() {
|
|
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
}
|
|
|
|
WT=""; STOP=0; DRY_RUN=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--path) WT="${2:-}"; shift 2 ;;
|
|
--stop) STOP=1; shift ;;
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
[ -n "$WT" ] || { echo "ERROR: --path is required" >&2; usage >&2; exit 2; }
|
|
|
|
MAIN="$(mam_abs_workspace "$WORKSPACE_ROOT")"
|
|
case "$WT" in /*) ;; *) WT="$PWD/$WT" ;; esac
|
|
WT="$(mam_abs_workspace "${WT%/}")"
|
|
|
|
PY="$(_delegate_py_bin)"
|
|
wt_py() { PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.worktree "$@"; }
|
|
jget() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))' "$1"; }
|
|
|
|
if [ "$(wt_py is-linked "$MAIN" "$WT")" != "true" ]; then
|
|
echo "ERROR: '$WT' is not a linked worktree of the control root '$MAIN'" >&2
|
|
exit 2
|
|
fi
|
|
|
|
REFUSED=0
|
|
refuse() { echo "REFUSED: $*" >&2; REFUSED=1; }
|
|
|
|
# (1) locked: even a single --force is refused by git
|
|
if [ "$(wt_py is-locked "$MAIN" "$WT")" = "true" ]; then
|
|
refuse "the worktree is locked (run: git worktree unlock '$WT')"
|
|
fi
|
|
|
|
# (2) sessions
|
|
STATE="$(load_state_json)"
|
|
ROWS="$(MAM_STATE_JSON="$STATE" wt_py rows "$WT")"
|
|
RUNNING="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] for s in d if s.get("status") == "running")')"
|
|
RUNNING_AGENTS="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] + ":" + ((s.get("pane") or {}).get("cmd") or "") for s in d if s.get("status") == "running")')"
|
|
STOP_JIDS="$(printf '%s' "$ROWS" | jget '",".join(str(s.get("delegate_job_id")) for s in d if s.get("status") == "running" and s.get("delegate_job_id"))')"
|
|
if [ -n "$RUNNING" ] && [ "$STOP" != "1" ]; then
|
|
refuse "running session(s) in the worktree: $RUNNING (pass --stop to stop them before removal)"
|
|
fi
|
|
|
|
# (3) open jobs (not the delegate jobs --stop will cancel)
|
|
EXCLUDE=""
|
|
[ "$STOP" = "1" ] && EXCLUDE="$STOP_JIDS"
|
|
JOBS="$(MAM_STATE_JSON="$STATE" wt_py open-jobs "$WT" --exclude "$EXCLUDE")"
|
|
if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then
|
|
refuse "open job(s) of sessions in the worktree - not cancelled automatically, they may belong to a running loop:"
|
|
printf '%s' "$JOBS" | jget '"\n".join(" %s %s %s (%s)" % (j["job_id"], j["agent_session"], j["status"], j["registry_dir"]) for j in d)' >&2
|
|
echo " close one after checking it: python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py --registry-dir <dir> close --job <id> --request cancelled" >&2
|
|
fi
|
|
|
|
# (4) clean, submodules included, and (5) submodules. A function: it runs now,
|
|
# before any change, and again right before the removal (review R1). It sets
|
|
# METHOD from what it saw and returns 1 on any finding. --untracked-files=all:
|
|
# a status.showUntrackedFiles=no setting (worktree or submodule) must not hide
|
|
# files from a check that decides a forced removal (review R1).
|
|
METHOD="plain" # set before any early return below: the summary line reads it under set -u
|
|
judge_tree() {
|
|
local found=0 dirty sub trace key val
|
|
if ! dirty="$(git -C "$WT" status --porcelain --untracked-files=all --ignore-submodules=none 2>&1)"; then
|
|
refuse "git status failed in the worktree: $dirty"
|
|
if [ ! -d "$WT" ]; then
|
|
echo " the directory no longer exists; if that is intended, run: git -C '$MAIN' worktree prune" >&2
|
|
fi
|
|
return 1
|
|
fi
|
|
if [ -n "$dirty" ]; then
|
|
refuse "the worktree has uncommitted changes:"
|
|
printf '%s\n' "$dirty" | sed 's/^/ /' >&2
|
|
found=1
|
|
fi
|
|
sub="$(wt_py submodules "$WT")"
|
|
trace="$(printf '%s' "$sub" | jget 'd["trace"]')"
|
|
METHOD="plain"
|
|
if [ "$trace" = "True" ]; then
|
|
METHOD="single --force"
|
|
for key in error dirty unpushed stashes; do
|
|
val="$(printf '%s' "$sub" | jget "d.get('$key', '')")"
|
|
if [ -n "$val" ]; then
|
|
refuse "submodule check '$key' is not empty (a forced removal would destroy it):"
|
|
printf '%s\n' "$val" | sed 's/^/ /' >&2
|
|
found=1
|
|
fi
|
|
done
|
|
fi
|
|
return "$found"
|
|
}
|
|
judge_tree || true
|
|
|
|
echo "checks: locked/sessions/jobs/clean/submodules -> $([ "$REFUSED" = 1 ] && echo refused || echo passed); removal method: $METHOD"
|
|
if [ "$REFUSED" = "1" ]; then
|
|
echo "nothing was changed" >&2
|
|
exit 3
|
|
fi
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
echo "[dry-run] would ${RUNNING:+stop: $RUNNING, then }remove '$WT' ($METHOD)"
|
|
exit 0
|
|
fi
|
|
|
|
# (6) stop, then re-check every open job
|
|
if [ "$STOP" = "1" ] && [ -n "$RUNNING_AGENTS" ]; then
|
|
for pair in $RUNNING_AGENTS; do
|
|
name="${pair%%:*}"; agent="${pair#*:}"
|
|
stop_args=(--session "$name")
|
|
[ -n "$agent" ] && stop_args+=(--agent "$agent")
|
|
if ! bash "$SKILL_DIR/multi-agent-mux-stop/scripts/stop_session.sh" "${stop_args[@]}"; then
|
|
echo "ERROR: stopping '$name' failed; the worktree was not removed" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
JOBS="$(MAM_STATE_JSON="$(load_state_json)" wt_py open-jobs "$WT")"
|
|
if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then
|
|
echo "REFUSED: the sessions were stopped, but open job(s) appeared meanwhile; the worktree was not removed:" >&2
|
|
printf '%s' "$JOBS" | jget '"\n".join(" %s %s %s" % (j["job_id"], j["agent_session"], j["status"]) for j in d)' >&2
|
|
exit 3
|
|
fi
|
|
fi
|
|
|
|
# (6b) the tree again, right before removing it: the stopped sessions may have
|
|
# written, committed or stashed while they stopped (review R1). The
|
|
# removal method follows this latest look.
|
|
if ! judge_tree; then
|
|
echo "REFUSED: ${RUNNING:+the sessions were stopped, but }the worktree changed after the checks; it was not removed" >&2
|
|
exit 3
|
|
fi
|
|
|
|
# (7) remove; the branch is kept
|
|
if [ "$METHOD" = "plain" ]; then
|
|
git -C "$MAIN" worktree remove "$WT" || exit 1
|
|
else
|
|
git -C "$MAIN" worktree remove --force "$WT" || exit 1
|
|
echo "submodule checks passed; removed with a single --force"
|
|
fi
|
|
echo "removed: $WT ($METHOD)"
|
|
NAMES="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] for s in d)')"
|
|
if [ -n "$NAMES" ]; then
|
|
echo "Sessions created in this worktree ($NAMES) cannot be resumed; their conversations are kept, and recreating a worktree at the same path makes them resumable again."
|
|
fi
|