Files
computer-playbook/scripts/system/worktree/lib.sh
kevinveenbirkenbachandClaude Opus 5 4f888ee80f fix(worktree): release branch on pinned metadata
git worktree remove deletes the checkout first and the metadata dir
second. Under the agent sandbox, which holds config.worktree and
commondir as read-only bind mounts, it therefore dies with EBUSY after
the checkout is already gone, aborting make worktree-down with rc 255
even though the branch is in fact free. down.sh now falls back to the
unregistration prune.sh already used (drop gitdir + HEAD, best-effort
rm -rf) and reports any dir the sandbox still pins. That routine moves
into lib.sh so both callers cannot drift apart, and gains a third
outcome for the case where gitdir/HEAD themselves survive: only then is
the branch still claimed, and only then must the teardown fail.

The discriminator for the fallback is whether the checkout survived,
not a match on "Device or resource busy" — the identical half-removed
state arises from EACCES, EPERM and EROFS.

A failing git status probe no longer reads as clean: on a broken .git
link it exits 128 with empty output, which used to satisfy the guard,
tear down the compose stack, and only then die at git worktree remove
with an unexplained 128.

Measured against the unfixed scripts with real fixtures: pinned
metadata dir 255 -> 0, broken .git link 128 -> 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:08 +02:00

100 lines
3.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# shellcheck shell=bash
#
# Shared helpers for the worktree slot manager. A slot is an integer that
# isolates one checkout from every other: it shifts the compose subnet, the
# host bind IP and the swarm lab subnet, so several worktrees can run their
# stacks side by side. Slot 0 is the primary checkout and owns the shared
# cache stack; every worktree gets the next free slot and joins that cache
# over an external docker network.
#
# Volumes are deliberately not slotted: compose already namespaces them by
# project, and INFINITO_DOCKER_VOLUME names a top-level compose key, so
# renaming it would point the mount at an undeclared volume.
#
# The pins are written to custom.env AND passed to the generator in a cleared
# environment: custom.env keeps them reproducible across later `make dotenv`
# runs, while the explicit env covers branches whose generator predates
# custom.env support. Inheriting the caller's environment would leak the
# invoking worktree's own slot into the new one.
#
# Slot discovery reads custom.env first: a branch whose default.env predates
# INFINITO_INSTANCE never materialises the key into .env, which would make the
# checkout look like slot 0 and hand its number out twice.
#
# Sourced by up.sh, down.sh and prune.sh; not meant to be executed directly.
worktree_slug() {
printf '%s' "$1" | sed -e 's#[^a-zA-Z0-9._-]#-#g' -e 's#^[^a-zA-Z0-9]*##' -e 's#[-.]*$##'
}
worktree_default_base() {
local url
url="$(git remote get-url origin)"
url="${url%.git}"
url="${url#*://}"
url="${url#*@}"
printf '%s/.local/share/worktrees/%s/%s' "${HOME}" "${url%%[:/]*}" "${url#*[:/]}"
}
worktree_path() {
printf '%s/%s' "$2" "$1"
}
worktree_slot_of() {
local slot=0 file
for file in "$1/custom.env" "$1/.env"; do
if [ -f "${file}" ]; then
slot="$(awk -F= '/^INFINITO_INSTANCE=/{print $2}' "${file}" | tail -n1)"
if [ -n "${slot}" ]; then
break
fi
fi
done
case "${slot}" in
'' | *[!0-9]*) slot=0 ;;
esac
printf '%s' "${slot}"
}
worktree_next_slot() {
local max=0 path slot
while read -r path; do
slot="$(worktree_slot_of "${path}")"
if [ "${slot}" -gt "${max}" ]; then
max="${slot}"
fi
done < <(git worktree list --porcelain | awk '/^worktree /{print $2}')
printf '%s' "$((max + 1))"
}
worktree_meta_dir() {
sed -n 's/^gitdir: //p' "$1/.git" 2>/dev/null
}
# Param entry: a .git/worktrees/<id> directory.
# Returns: 0 fully removed, 1 unregistered but the directory is pinned,
# 2 still registered.
worktree_unregister() {
local entry="$1"
rm -f "${entry}/gitdir" "${entry}/HEAD" 2>/dev/null
if [ -e "${entry}/gitdir" ] || [ -e "${entry}/HEAD" ]; then
return 2
fi
rm -rf "${entry}" 2>/dev/null || return 1
return 0
}
worktree_report_held() {
[ "$#" -gt 0 ] || return 0
echo ">>> Unregistered, but the sandbox pins these metadata dirs; git ignores them now:"
printf ' %s\n' "$@"
echo ">>> Clear them outside the sandbox: rm -rf $*"
}
worktree_cache_network() {
docker inspect infinito-registry-cache \
--format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null |
head -n1
}