|
#!/bin/sh |
|
# Create or converge one isolated Git worktree without discarding files that |
|
# already exist at the target path. |
|
# |
|
# Usage: worktree-bootstrap.sh <repository-root> <target-directory> <worker-id> [--sync] |
|
|
|
set -eu |
|
|
|
die() { |
|
printf 'worktree-bootstrap: %s\n' "$*" >&2 |
|
exit 1 |
|
} |
|
|
|
REPO_INPUT=${1:?usage: worktree-bootstrap.sh <repository-root> <target-directory> <worker-id> [--sync]} |
|
TARGET_INPUT=${2:?missing target-directory} |
|
WORKER_ID=${3:?missing worker-id} |
|
SYNC_MODE=${4:-} |
|
|
|
case "$SYNC_MODE" in |
|
""|--sync) ;; |
|
*) die "unknown option: $SYNC_MODE" ;; |
|
esac |
|
|
|
case "$WORKER_ID" in |
|
""|.*|*..*|*[!A-Za-z0-9._-]*) |
|
die "worker-id must use letters, digits, dots, underscores, or hyphens" |
|
;; |
|
esac |
|
|
|
[ -d "$REPO_INPUT" ] || die "repository root is not a directory: $REPO_INPUT" |
|
REPO_DIR=$(CDPATH='' cd -- "$REPO_INPUT" && pwd -P) |
|
REPO_ROOT=$(git -C "$REPO_DIR" rev-parse --show-toplevel 2>/dev/null) || |
|
die "not a Git repository: $REPO_INPUT" |
|
REPO_ROOT=$(CDPATH='' cd -- "$REPO_ROOT" && pwd -P) |
|
[ "$REPO_DIR" = "$REPO_ROOT" ] || die "first argument must be the repository root" |
|
|
|
case "$TARGET_INPUT" in |
|
/*) RAW_TARGET=$TARGET_INPUT ;; |
|
*) RAW_TARGET=$PWD/$TARGET_INPUT ;; |
|
esac |
|
|
|
canonicalize_allow_missing() { |
|
PROBE=$1 |
|
SUFFIX= |
|
while [ ! -d "$PROBE" ]; do |
|
COMPONENT=$(basename "$PROBE") |
|
PARENT=$(dirname "$PROBE") |
|
[ "$PARENT" != "$PROBE" ] || die "cannot resolve target-directory: $1" |
|
SUFFIX=/$COMPONENT$SUFFIX |
|
PROBE=$PARENT |
|
done |
|
RESOLVED=$(CDPATH='' cd -- "$PROBE" && pwd -P) |
|
printf '%s%s\n' "$RESOLVED" "$SUFFIX" |
|
} |
|
|
|
# Resolve the nearest existing ancestor. This catches both direct and symlinked |
|
# attempts to nest a worktree in the source repository before any path is made. |
|
RAW_TARGET=$(canonicalize_allow_missing "$RAW_TARGET") |
|
case "$RAW_TARGET/" in |
|
"$REPO_ROOT/"*) die "target-directory must be outside the repository" ;; |
|
esac |
|
|
|
TARGET_PARENT_INPUT=$(dirname "$RAW_TARGET") |
|
[ -d "$TARGET_PARENT_INPUT" ] || |
|
die "target parent must already exist: $TARGET_PARENT_INPUT" |
|
TARGET_PARENT=$(CDPATH='' cd -- "$TARGET_PARENT_INPUT" && pwd -P) |
|
TARGET_NAME=$(basename "$RAW_TARGET") |
|
case "$TARGET_NAME" in |
|
""|.|..) die "target-directory must name a child of its parent" ;; |
|
esac |
|
WT=$TARGET_PARENT/$TARGET_NAME |
|
|
|
if [ -e "$WT" ] || [ -L "$WT" ]; then |
|
[ -d "$WT" ] || die "target exists and is not a directory: $WT" |
|
WT=$(CDPATH='' cd -- "$WT" && pwd -P) |
|
fi |
|
|
|
# Resolve symlinked parents before making the same boundary decision again. |
|
case "$WT/" in |
|
"$REPO_ROOT/"*) die "target-directory must be outside the repository" ;; |
|
esac |
|
|
|
# Make every Git operation non-interactive. Network failures remain visible. |
|
export GIT_TERMINAL_PROMPT=0 |
|
export GIT_EDITOR=true |
|
export GIT_SEQUENCE_EDITOR=true |
|
export GIT_HTTP_LOW_SPEED_LIMIT="${GIT_HTTP_LOW_SPEED_LIMIT:-1}" |
|
export GIT_HTTP_LOW_SPEED_TIME="${GIT_HTTP_LOW_SPEED_TIME:-15}" |
|
if [ -n "${GIT_SSH_COMMAND:-}" ]; then |
|
export GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o BatchMode=yes -o ConnectTimeout=10" |
|
else |
|
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10" |
|
fi |
|
|
|
git_common_dir() { |
|
CHECKOUT=$1 |
|
COMMON=$(git -C "$CHECKOUT" rev-parse --git-common-dir 2>/dev/null) || return 1 |
|
case "$COMMON" in |
|
/*) COMMON_PATH=$COMMON ;; |
|
*) COMMON_PATH=$CHECKOUT/$COMMON ;; |
|
esac |
|
(CDPATH='' cd -- "$COMMON_PATH" && pwd -P) |
|
} |
|
|
|
append_local_exclude() { |
|
EXCLUDE_PATTERN=$1 |
|
grep -qxF "$EXCLUDE_PATTERN" "$EXCLUDE_FILE" 2>/dev/null || |
|
printf '%s\n' "$EXCLUDE_PATTERN" >> "$EXCLUDE_FILE" |
|
} |
|
|
|
install_local_excludes() { |
|
EXCLUDE_FILE=$(git -C "$WT" rev-parse --git-path info/exclude) |
|
case "$EXCLUDE_FILE" in |
|
/*) ;; |
|
*) EXCLUDE_FILE=$WT/$EXCLUDE_FILE ;; |
|
esac |
|
mkdir -p "$(dirname "$EXCLUDE_FILE")" |
|
touch "$EXCLUDE_FILE" |
|
|
|
EXCLUDE_MARKER="# Local worktree-only files" |
|
if ! grep -qF "$EXCLUDE_MARKER" "$EXCLUDE_FILE" 2>/dev/null; then |
|
if [ -s "$EXCLUDE_FILE" ] && |
|
[ "$(tail -c 1 "$EXCLUDE_FILE" 2>/dev/null || true)" != "" ]; then |
|
printf '\n' >> "$EXCLUDE_FILE" |
|
fi |
|
printf '%s\n' "$EXCLUDE_MARKER" >> "$EXCLUDE_FILE" |
|
fi |
|
|
|
append_local_exclude ".runtime/" |
|
append_local_exclude ".logs/" |
|
append_local_exclude ".worktree-local/" |
|
append_local_exclude ".editor-state/" |
|
append_local_exclude "state.local.json" |
|
} |
|
|
|
mechanical_ignore_drift() { |
|
git -C "$WT" diff --cached --quiet -- .gitignore 2>/dev/null || return 1 |
|
git -C "$WT" diff -- .gitignore 2>/dev/null | awk ' |
|
BEGIN { changed = 0; safe = 1 } |
|
/^diff --git / || /^index / || /^--- / || /^\+\+\+ / || /^@@ / || /^\\/ { next } |
|
/^\+/ { |
|
line = substr($0, 2) |
|
changed = 1 |
|
if (line == "" || |
|
line == "# Local worktree-only files" || |
|
line == ".runtime/" || |
|
line == ".logs/" || |
|
line == ".worktree-local/" || |
|
line == ".editor-state/" || |
|
line == "state.local.json") { |
|
next |
|
} |
|
safe = 0 |
|
next |
|
} |
|
/^-/ { safe = 0; next } |
|
END { exit (changed && safe ? 0 : 1) } |
|
' |
|
} |
|
|
|
ignore_diff_mentions_local_rule() { |
|
git -C "$WT" diff -- .gitignore 2>/dev/null | |
|
grep -E '^\+((# Local worktree-only files)|\.runtime/|\.logs/|\.worktree-local/|\.editor-state/|state\.local\.json)$' \ |
|
>/dev/null 2>&1 |
|
} |
|
|
|
repair_ignore_drift() { |
|
git -C "$WT" diff --quiet -- .gitignore 2>/dev/null && return 0 |
|
if mechanical_ignore_drift; then |
|
git -C "$WT" restore -- .gitignore |
|
printf '%s\n' \ |
|
"worktree-bootstrap: repaired mechanical .gitignore drift; rules live in local excludes" >&2 |
|
elif ignore_diff_mentions_local_rule; then |
|
printf '%s\n' \ |
|
"worktree-bootstrap: warning: non-mechanical .gitignore drift preserved for review" >&2 |
|
fi |
|
} |
|
|
|
sync_if_requested() { |
|
[ "$SYNC_MODE" = "--sync" ] || return 0 |
|
if ! git -C "$WT" remote get-url origin >/dev/null 2>&1; then |
|
printf '%s\n' "worktree-bootstrap: warning: --sync skipped because origin is absent" >&2 |
|
return 0 |
|
fi |
|
if [ -n "$(git -C "$WT" status --porcelain)" ]; then |
|
die "--sync requires a clean worktree" |
|
fi |
|
git -C "$WT" fetch origin |
|
UPSTREAM=$(git -C "$WT" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true) |
|
if [ -n "$UPSTREAM" ]; then |
|
git -C "$WT" merge --ff-only "$UPSTREAM" |
|
else |
|
printf '%s\n' "worktree-bootstrap: warning: fetched origin; branch has no upstream to merge" >&2 |
|
fi |
|
} |
|
|
|
verify_existing_worktree() { |
|
EXPECTED_COMMON=$(git_common_dir "$REPO_ROOT") || |
|
die "cannot resolve repository metadata" |
|
ACTUAL_COMMON=$(git_common_dir "$WT") || |
|
die "target has .git metadata but is not a usable worktree" |
|
[ "$ACTUAL_COMMON" = "$EXPECTED_COMMON" ] || |
|
die "target belongs to a different Git repository" |
|
} |
|
|
|
converge_worktree() { |
|
verify_existing_worktree |
|
install_local_excludes |
|
repair_ignore_drift |
|
git -C "$WT" submodule init >/dev/null 2>&1 || true |
|
sync_if_requested |
|
} |
|
|
|
# Existing worktrees are converged on every run, rather than only at creation. |
|
if [ -d "$WT/.git" ] || [ -f "$WT/.git" ]; then |
|
converge_worktree |
|
exit 0 |
|
fi |
|
|
|
TARGET_HASH=$(printf '%s' "$WT" | git -C "$REPO_ROOT" hash-object --stdin | cut -c1-12) |
|
BRANCH=wt-$WORKER_ID-$TARGET_HASH |
|
git -C "$REPO_ROOT" check-ref-format --branch "$BRANCH" >/dev/null 2>&1 || |
|
die "derived branch name is invalid" |
|
|
|
# Refresh before staging target data, so a slow network operation never runs |
|
# inside the recovery-critical section. |
|
DEFAULT_REF=$(git -C "$REPO_ROOT" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true) |
|
if [ -n "$DEFAULT_REF" ]; then |
|
DEFAULT_BRANCH=${DEFAULT_REF#refs/remotes/origin/} |
|
if ! git -C "$REPO_ROOT" fetch origin "$DEFAULT_BRANCH" >/dev/null 2>&1; then |
|
printf '%s\n' \ |
|
"worktree-bootstrap: warning: origin refresh failed; using cached $DEFAULT_REF" >&2 |
|
fi |
|
fi |
|
|
|
STAGE= |
|
STAGE_META=metadata |
|
ADD_PID= |
|
|
|
write_stage_metadata() { |
|
META_DIR=$1 |
|
META_STATE=$2 |
|
META_TMP=$META_DIR/.metadata.tmp.$$ |
|
{ |
|
printf 'target=%s\n' "$WT" |
|
printf 'state=%s\n' "$META_STATE" |
|
} > "$META_TMP" |
|
mv "$META_TMP" "$META_DIR/$STAGE_META" |
|
} |
|
|
|
merge_staged_entry() { |
|
SOURCE=$1 |
|
DESTINATION=$2 |
|
|
|
if [ -d "$SOURCE" ] && [ ! -L "$SOURCE" ]; then |
|
if [ -e "$DESTINATION" ] && [ ! -d "$DESTINATION" ]; then |
|
return 1 |
|
fi |
|
mkdir -p "$DESTINATION" |
|
MERGE_FAILED=0 |
|
for CHILD in "$SOURCE"/.[!.]* "$SOURCE"/..?* "$SOURCE"/*; do |
|
[ -e "$CHILD" ] || continue |
|
merge_staged_entry "$CHILD" "$DESTINATION/$(basename "$CHILD")" || |
|
MERGE_FAILED=1 |
|
done |
|
[ "$MERGE_FAILED" -eq 0 ] || return 1 |
|
rmdir "$SOURCE" |
|
return 0 |
|
fi |
|
|
|
[ ! -e "$DESTINATION" ] || return 1 |
|
mv "$SOURCE" "$DESTINATION" |
|
} |
|
|
|
restore_stage() { |
|
[ -n "$STAGE" ] || return 0 |
|
mkdir -p "$WT" |
|
STAGE_DATA=$STAGE/data |
|
RESTORE_FAILED=0 |
|
for ENTRY in "$STAGE_DATA"/.[!.]* "$STAGE_DATA"/..?* "$STAGE_DATA"/*; do |
|
[ -e "$ENTRY" ] || continue |
|
merge_staged_entry "$ENTRY" "$WT/$(basename "$ENTRY")" || RESTORE_FAILED=1 |
|
done |
|
if [ "$RESTORE_FAILED" -eq 0 ] && rmdir "$STAGE_DATA" 2>/dev/null; then |
|
rm -f "$STAGE/$STAGE_META" |
|
else |
|
RESTORE_FAILED=1 |
|
fi |
|
if [ "$RESTORE_FAILED" -ne 0 ] || ! rmdir "$STAGE" 2>/dev/null; then |
|
write_stage_metadata "$STAGE" "restore-conflict" |
|
printf '%s\n' \ |
|
"worktree-bootstrap: recovery stage retained because target entries conflict: $STAGE" >&2 |
|
STAGE= |
|
return 1 |
|
fi |
|
STAGE= |
|
} |
|
|
|
on_exit() { |
|
EXIT_STATUS=$1 |
|
trap - EXIT |
|
if [ -n "$STAGE" ] && ! restore_stage; then |
|
EXIT_STATUS=1 |
|
fi |
|
exit "$EXIT_STATUS" |
|
} |
|
|
|
on_signal() { |
|
SIGNAL_STATUS=$1 |
|
trap - EXIT HUP INT TERM |
|
if [ -n "$ADD_PID" ]; then |
|
kill -TERM "$ADD_PID" 2>/dev/null || true |
|
wait "$ADD_PID" 2>/dev/null || true |
|
ADD_PID= |
|
fi |
|
restore_stage || true |
|
exit "$SIGNAL_STATUS" |
|
} |
|
|
|
run_worktree_add() { |
|
GIT_LFS_SKIP_SMUDGE=1 git "$@" & |
|
ADD_PID=$! |
|
if wait "$ADD_PID"; then |
|
ADD_RESULT=0 |
|
else |
|
ADD_RESULT=$? |
|
fi |
|
ADD_PID= |
|
return "$ADD_RESULT" |
|
} |
|
|
|
trap 'on_exit $?' EXIT |
|
trap 'on_signal 129' HUP |
|
trap 'on_signal 130' INT |
|
trap 'on_signal 143' TERM |
|
|
|
if [ -d "$WT" ] && [ -n "$(find "$WT" -mindepth 1 -maxdepth 1 -print | sed -n '1p')" ]; then |
|
STAGE=$(mktemp -d "$TARGET_PARENT/.worktree-stage.XXXXXX") |
|
mkdir "$STAGE/data" |
|
write_stage_metadata "$STAGE" "moving" |
|
for ENTRY in "$WT"/.[!.]* "$WT"/..?* "$WT"/*; do |
|
[ -e "$ENTRY" ] || continue |
|
mv "$ENTRY" "$STAGE/data/" |
|
done |
|
fi |
|
|
|
rmdir "$WT" 2>/dev/null || true |
|
git -C "$REPO_ROOT" worktree prune >/dev/null 2>&1 || true |
|
|
|
if git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$BRANCH"; then |
|
if ! run_worktree_add -C "$REPO_ROOT" worktree add "$WT" "$BRANCH"; then |
|
die "failed to create worktree at $WT from existing branch $BRANCH" |
|
fi |
|
elif [ -n "$DEFAULT_REF" ]; then |
|
if ! run_worktree_add -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WT" "$DEFAULT_REF"; then |
|
die "failed to create worktree at $WT from $DEFAULT_REF" |
|
fi |
|
else |
|
if ! run_worktree_add -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WT"; then |
|
die "failed to create worktree at $WT from current HEAD" |
|
fi |
|
fi |
|
|
|
if [ -n "$STAGE" ]; then |
|
STAGE_DATA=$STAGE/data |
|
MERGE_FAILED=0 |
|
for ENTRY in "$STAGE_DATA"/.[!.]* "$STAGE_DATA"/..?* "$STAGE_DATA"/*; do |
|
[ -e "$ENTRY" ] || continue |
|
merge_staged_entry "$ENTRY" "$WT/$(basename "$ENTRY")" || MERGE_FAILED=1 |
|
done |
|
if [ "$MERGE_FAILED" -eq 0 ] && rmdir "$STAGE_DATA" 2>/dev/null; then |
|
rm -f "$STAGE/$STAGE_META" |
|
else |
|
MERGE_FAILED=1 |
|
fi |
|
if [ "$MERGE_FAILED" -ne 0 ] || ! rmdir "$STAGE" 2>/dev/null; then |
|
write_stage_metadata "$STAGE" "merge-conflict" |
|
printf '%s\n' \ |
|
"worktree-bootstrap: refused to discard conflicting staged entries: $STAGE" >&2 |
|
STAGE= |
|
exit 1 |
|
fi |
|
STAGE= |
|
fi |
|
|
|
trap - EXIT HUP INT TERM |
|
converge_worktree |