mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
631 lines
23 KiB
Bash
631 lines
23 KiB
Bash
# shellcheck source=scripts/pr-lib/github.sh
|
|
source "$(cd "${BASH_SOURCE[0]%/*}" && pwd -P)/github.sh" || return 1
|
|
|
|
# shellcheck source=scripts/pr-lib/host-tools.sh
|
|
source "${BASH_SOURCE[0]%/*}/host-tools.sh" || return 1
|
|
|
|
# Load receipt helpers for the invocation before cleanup can delete this module's worktree.
|
|
# shellcheck source=scripts/pr-lib/merge-outcome.sh
|
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/merge-outcome.sh" || return 1
|
|
|
|
require_artifact() {
|
|
local path="$1"
|
|
if [ ! -s "$path" ]; then
|
|
echo "Missing required artifact: $path"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
validate_pr_temp_storage() {
|
|
local temp_dir="${TMPDIR:-/tmp}"
|
|
local probe=""
|
|
if ! probe=$(mktemp "${temp_dir%/}/openclaw-pr.XXXXXX"); then
|
|
:
|
|
elif ! printf 'openclaw-pr-temp-probe\n' >"$probe"; then
|
|
rm -f "$probe" 2>/dev/null || true
|
|
elif rm -f "$probe"; then
|
|
return 0
|
|
fi
|
|
|
|
echo "scripts/pr temporary-storage preflight failed under TMPDIR=$temp_dir." >&2
|
|
echo "Free disk space or set TMPDIR to a writable filesystem, then retry." >&2
|
|
return 1
|
|
}
|
|
|
|
path_is_docsish() {
|
|
local path="$1"
|
|
case "$path" in
|
|
CHANGELOG.md|AGENTS.md|CLAUDE.md|README*.md|docs/*|*.md|*.mdx|docs.json)
|
|
return 0
|
|
;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
file_list_is_docsish_only() {
|
|
local files="$1"
|
|
local saw_any=false
|
|
local path
|
|
while [ -n "$files" ]; do
|
|
path="${files%%$'\n'*}"
|
|
if [ "$path" = "$files" ]; then
|
|
files=""
|
|
else
|
|
files="${files#*$'\n'}"
|
|
fi
|
|
[ -n "$path" ] || continue
|
|
saw_any=true
|
|
if ! path_is_docsish "$path"; then
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
[ "$saw_any" = "true" ]
|
|
}
|
|
|
|
changelog_required_for_changed_files() {
|
|
# Changelog artifacts are release-owned. Normal PRs carry release-note
|
|
# context in PR bodies and commit messages.
|
|
return 1
|
|
}
|
|
|
|
release_changelog_file_list_mode() {
|
|
local helper_root
|
|
helper_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) || return 1
|
|
node --input-type=module - "$helper_root" "$1" <<'EOF_NODE'
|
|
import { pathToFileURL } from "node:url";
|
|
const [root, files] = process.argv.slice(2);
|
|
const { isReleaseChangelogPath } = await import(pathToFileURL(`${root}/scripts/lib/release-changelog.mjs`));
|
|
const paths = files.split("\n").filter(Boolean);
|
|
const count = paths.filter((file) => isReleaseChangelogPath(file)).length;
|
|
console.log(count === 0 ? "none" : count === paths.length ? "only" : "mixed");
|
|
EOF_NODE
|
|
}
|
|
|
|
root_changelog_update_allowed_for_pr() {
|
|
case "${OPENCLAW_ALLOW_ROOT_CHANGELOG_PR:-}" in
|
|
1|true|TRUE|yes|YES|on|ON)
|
|
printf 'override\n'
|
|
return 0
|
|
;;
|
|
esac
|
|
local record="${1:-}" branch version helper_root
|
|
branch=$(printf '%s\n' "$record" | jq -r '.headRefName // ""') || return 1
|
|
[[ "$branch" =~ ^release/([0-9]{4}\.[0-9]+\.[0-9]+(-[0-9]+)?)-main-closeout$ ]] || return 1
|
|
version="${BASH_REMATCH[1]}"
|
|
printf '%s\n' "$record" | jq -e --arg title "chore(release): close out $version on main" \
|
|
'.title == $title and .baseRefName == "main" and .isCrossRepository == false' >/dev/null || return 1
|
|
pr_git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null 2>&1 || return 1
|
|
helper_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) || return 1
|
|
# Compare complete sections, not just added lines: a closeout must preserve
|
|
# every byte outside its released version, including removed historical text.
|
|
node --input-type=module - "$version" "$PR_MAIN_SHA" "$helper_root" <<'EOF_NODE' || return 1
|
|
import { execFileSync } from "node:child_process";
|
|
import { pathToFileURL } from "node:url";
|
|
const [version, base, root] = process.argv.slice(2);
|
|
const { loadReleaseChangelog, checkChangelogLayout, changelogEntryPath, isReleaseChangelogPath } =
|
|
await import(pathToFileURL(`${root}/scripts/lib/release-changelog.mjs`));
|
|
const git = (...args) => execFileSync(process.env.OPENCLAW_PR_GIT || process.env.GIT_EXEC || "git", args, { encoding: "utf8", maxBuffer: Infinity });
|
|
const read = (ref) => git("show", `${ref}:CHANGELOG.md`);
|
|
const changed = git("diff", "--name-only", "--no-renames", "-z", base, "HEAD", "--", "CHANGELOG.md", "CHANGELOG/").split("\0").filter(Boolean);
|
|
const release = loadReleaseChangelog({ rootDir: process.cwd(), ref: "HEAD", version });
|
|
if (release.layout === "split") {
|
|
// The migration itself requires the existing explicit release override.
|
|
checkChangelogLayout({ rootDir: process.cwd(), ref: base });
|
|
checkChangelogLayout({ rootDir: process.cwd(), ref: "HEAD" });
|
|
if (changed.some((file) => !isReleaseChangelogPath(file, { version }))) process.exit(1);
|
|
const entry = changelogEntryPath(version);
|
|
const indexLine = `- [${version}](${entry}) · [Raw](https://github.com/openclaw/openclaw/raw/refs/heads/main/${entry})\n`;
|
|
const outside = (text) => text.replace(indexLine, "");
|
|
if (outside(read(base)) !== outside(read("HEAD"))) process.exit(1);
|
|
process.exit(0);
|
|
}
|
|
// Historical refs and fixtures still contain monolithic release sections.
|
|
if (changed.some((file) => file !== "CHANGELOG.md")) process.exit(1);
|
|
const heading = `## ${version}\n`;
|
|
function split(text, allowUnreleased = false) {
|
|
const parts = text.split(/(?=^## )/m);
|
|
let matches = parts.filter((part) => part.startsWith(heading));
|
|
if (!matches.length && allowUnreleased) {
|
|
// Older draft headings can be placeholders; newer trains remain outside this closeout.
|
|
matches = parts.filter((part) => {
|
|
const draft = /^## (?:Unreleased|([0-9]{4}\.[0-9]+\.[0-9]+(?:-[0-9]+)?) \(Unreleased\))\n/i.exec(part);
|
|
return draft && (!draft[1] || draft[1].localeCompare(version, "en", { numeric: true }) <= 0);
|
|
});
|
|
}
|
|
if (matches.length > 1) process.exit(1);
|
|
const index = parts.indexOf(matches[0]);
|
|
return index < 0 ? { rest: text } : {
|
|
prefix: parts.slice(0, index).join(""),
|
|
suffix: parts.slice(index + 1).join(""),
|
|
};
|
|
}
|
|
const before = split(read(base), true);
|
|
const after = split(read("HEAD"));
|
|
if (after.rest !== undefined || (before.rest !== undefined
|
|
? before.rest !== after.prefix + after.suffix
|
|
: before.prefix !== after.prefix || before.suffix !== after.suffix)) process.exit(1);
|
|
EOF_NODE
|
|
printf 'closeout\n'
|
|
}
|
|
|
|
print_review_stdout_summary() {
|
|
require_artifact .local/review.json
|
|
require_artifact .local/pr-meta.env
|
|
|
|
# shellcheck disable=SC1091
|
|
source .local/pr-meta.env
|
|
|
|
local recommendation
|
|
recommendation=$(jq -r '.recommendation // ""' .local/review.json)
|
|
local finding_count
|
|
finding_count=$(jq '[.findings[]?] | length' .local/review.json)
|
|
|
|
echo "review summary:"
|
|
echo "pr_url=${PR_URL:-}"
|
|
echo "recommendation: $recommendation"
|
|
echo "findings: $finding_count"
|
|
node "$(review_artifacts_helper_path)" render .local/review.json
|
|
}
|
|
|
|
print_relevant_log_excerpt() {
|
|
local log_file="$1"
|
|
if [ ! -s "$log_file" ]; then
|
|
echo "(no output captured)"
|
|
return 0
|
|
fi
|
|
|
|
local filtered_log
|
|
filtered_log=$(mktemp)
|
|
if rg -n -i 'error|err|failed|fail|fatal|panic|exception|TypeError|ReferenceError|SyntaxError|ELIFECYCLE|ERR_' "$log_file" >"$filtered_log"; then
|
|
echo "Relevant log lines:"
|
|
tail -n 120 "$filtered_log"
|
|
else
|
|
echo "No focused error markers found; showing last 120 lines:"
|
|
tail -n 120 "$log_file"
|
|
fi
|
|
rm -f "$filtered_log"
|
|
}
|
|
|
|
print_unrelated_gate_failure_guidance() {
|
|
local label="$1"
|
|
case "$label" in
|
|
pnpm\ build*|pnpm\ check*|pnpm\ test*)
|
|
cat <<'EOF_GUIDANCE'
|
|
If this local gate failure already reproduces on latest origin/main and is clearly unrelated to the PR:
|
|
- treat it as baseline repo noise
|
|
- document it explicitly
|
|
- report the scoped verification that validates the PR itself
|
|
- do not use this to ignore plausibly related failures
|
|
EOF_GUIDANCE
|
|
;;
|
|
esac
|
|
}
|
|
|
|
run_quiet_logged() {
|
|
local label="$1"
|
|
local log_file="$2"
|
|
shift 2
|
|
|
|
mkdir -p .local
|
|
if "$@" >"$log_file" 2>&1; then
|
|
echo "$label passed"
|
|
return 0
|
|
fi
|
|
|
|
echo "$label failed (log: $log_file)"
|
|
print_relevant_log_excerpt "$log_file"
|
|
print_unrelated_gate_failure_guidance "$label"
|
|
return 1
|
|
}
|
|
|
|
bootstrap_deps_if_needed() {
|
|
if [ ! -x node_modules/.bin/vitest ]; then
|
|
run_quiet_logged "pnpm install --frozen-lockfile" ".local/bootstrap-install.log" pnpm install --frozen-lockfile
|
|
fi
|
|
}
|
|
|
|
read_pr_view_json() {
|
|
local pr="$1"
|
|
local fields="$2"
|
|
local max_attempts=3
|
|
local temp_dir
|
|
temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pr-view.XXXXXX") || {
|
|
echo "Unable to create temporary storage for GitHub PR metadata." >&2
|
|
return 1
|
|
}
|
|
local stdout_file="$temp_dir/stdout"
|
|
local stderr_file="$temp_dir/stderr"
|
|
local attempt exit_code reason
|
|
|
|
for attempt in $(seq 1 "$max_attempts"); do
|
|
exit_code=0
|
|
if pr_gh pr view "$pr" --json "$fields" >"$stdout_file" 2>"$stderr_file"; then
|
|
if [ -s "$stdout_file" ] && jq -se 'length == 1 and (.[0] | type == "object")' "$stdout_file" >/dev/null 2>&1; then
|
|
cat "$stdout_file"
|
|
rm -rf "$temp_dir"
|
|
return 0
|
|
fi
|
|
if [ ! -s "$stdout_file" ]; then
|
|
reason="gh pr view returned empty stdout"
|
|
else
|
|
reason="gh pr view did not return one JSON object"
|
|
fi
|
|
else
|
|
exit_code=$?
|
|
reason="gh pr view exited with status $exit_code"
|
|
if [ "$exit_code" -eq 65 ] || [ "$exit_code" -eq 75 ] || [ "$exit_code" -eq 77 ]; then
|
|
cat "$stderr_file" >&2
|
|
rm -rf "$temp_dir"
|
|
return 1
|
|
fi
|
|
fi
|
|
[ "$attempt" -eq "$max_attempts" ] || sleep "$attempt"
|
|
done
|
|
|
|
echo "GitHub API failure while reading PR #$pr: $reason after $max_attempts attempts." >&2
|
|
[ ! -s "$stderr_file" ] || cat "$stderr_file" >&2
|
|
rm -rf "$temp_dir"
|
|
return 1
|
|
}
|
|
|
|
read_pr_observation() {
|
|
read_pr_view_json "$1" "number,url,title,state,isDraft,author,baseRefName,baseRefOid,baseRepository,headRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository"
|
|
}
|
|
|
|
use_pr_observation() {
|
|
local pr="$1" observation="$2" repository_url
|
|
repository_url=$(printf '%s\n' "$observation" | jq -er --argjson pr "$pr" '
|
|
.baseRepository as $repo |
|
|
select(.number == $pr and
|
|
($repo.id | type == "string" and length > 0) and
|
|
($repo.databaseId | type == "number" and . > 0 and floor == .) and
|
|
($repo.nameWithOwner | type == "string" and test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")) and
|
|
($repo.url | type == "string" and test("^https://[A-Za-z0-9.-]+(:[0-9]+)?/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") and endswith("/" + $repo.nameWithOwner)) and
|
|
.url == ($repo.url + "/pull/" + ($pr | tostring))) |
|
|
$repo.url') || {
|
|
echo "Invalid base repository identity for PR #$pr." >&2
|
|
return 1
|
|
}
|
|
PR_OBSERVATION="$observation"
|
|
PR_REPOSITORY_URL="$repository_url"
|
|
PR_REPOSITORY_SELECTOR="${GH_REPO:-}"
|
|
PR_REPOSITORY_HOST="${GH_HOST:-}"
|
|
}
|
|
|
|
pr_observe() {
|
|
local observation
|
|
observation=$(read_pr_observation "$1") || return 1
|
|
use_pr_observation "$1" "$observation"
|
|
}
|
|
|
|
pr_view_string_field() {
|
|
local json="$1" field="$2" pr="$3" remedy="${4:-Retry the command.}" label value
|
|
case "$field" in
|
|
headRefOid) label="a head SHA" ;;
|
|
baseRefName) label="a base branch" ;;
|
|
headRefName) label="a head branch" ;;
|
|
*) label="a non-empty .$field string" ;;
|
|
esac
|
|
if ! value=$(printf '%s\n' "$json" | jq -er --arg field "$field" '.[$field] | if type == "string" and length > 0 then . else error("missing string field") end' 2>/dev/null); then
|
|
echo "GitHub PR metadata for #$pr did not include $label. $remedy" >&2
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$value"
|
|
}
|
|
|
|
wait_for_pr_head_sha() {
|
|
local pr="$1"
|
|
local expected_sha="$2"
|
|
local max_attempts="${3:-6}"
|
|
local sleep_seconds="${4:-2}"
|
|
|
|
local attempt
|
|
for attempt in $(seq 1 "$max_attempts"); do
|
|
local observed_sha
|
|
pr_observe "$pr" || return 1
|
|
observed_sha=$(pr_view_string_field "$PR_OBSERVATION" headRefOid "$pr") || return 1
|
|
if [ "$observed_sha" = "$expected_sha" ]; then
|
|
return 0
|
|
fi
|
|
|
|
if [ "$attempt" -lt "$max_attempts" ]; then
|
|
sleep "$sleep_seconds"
|
|
fi
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
pr_contributor_allows_human_trailers() {
|
|
local contrib="${1:-}"
|
|
local normalized
|
|
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
|
|
|
|
case "$normalized" in
|
|
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
return 0
|
|
}
|
|
|
|
resolve_contributor_coauthor_email() {
|
|
local contrib="${1:-}"
|
|
|
|
if ! pr_contributor_allows_human_trailers "$contrib"; then
|
|
return 1
|
|
fi
|
|
|
|
local contrib_id
|
|
contrib_id=$(pr_gh api "users/$contrib" --jq .id) || return 1
|
|
printf '%s+%s@users.noreply.github.com\n' "$contrib_id" "$contrib"
|
|
}
|
|
|
|
common_repo_root() {
|
|
if command -v repo_root >/dev/null 2>&1; then
|
|
repo_root
|
|
return
|
|
fi
|
|
|
|
local base_dir
|
|
base_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
pr_git -C "$base_dir" rev-parse --show-toplevel
|
|
}
|
|
|
|
worktree_path_for_branch() (
|
|
set -o pipefail
|
|
local branch="$1"
|
|
local ref="refs/heads/$branch"
|
|
local field worktree="" match="" records=0 pipeline_status
|
|
# Drain foreground Git before the supervisor checks for leftover children.
|
|
pr_git worktree list --porcelain -z | {
|
|
while IFS= read -r -d '' field; do
|
|
case "$field" in
|
|
worktree\ *) worktree="${field#worktree }"; records=$((records + 1)) ;;
|
|
"branch $ref") match="$worktree" ;;
|
|
"") worktree="" ;;
|
|
esac
|
|
done
|
|
[ -z "$field" ] && [ -z "$worktree" ] && [ "$records" -gt 0 ] || return 1
|
|
[ -z "$match" ] || printf '%s\n' "$match"
|
|
} || {
|
|
pipeline_status=("${PIPESTATUS[@]}")
|
|
[ "${pipeline_status[0]}" -eq 0 ] || return "${pipeline_status[0]}"
|
|
return "${pipeline_status[1]}"
|
|
}
|
|
)
|
|
|
|
worktree_registration_state() (
|
|
set -o pipefail
|
|
local path="$1"
|
|
local field found=0 records=0 open=false pipeline_status
|
|
# Git must finish before a successful operation can release its lock.
|
|
pr_git worktree list --porcelain -z | {
|
|
while IFS= read -r -d '' field; do
|
|
case "$field" in
|
|
worktree\ *)
|
|
records=$((records + 1)); open=true
|
|
[ "${field#worktree }" != "$path" ] || found=$((found + 1))
|
|
;;
|
|
"") open=false ;;
|
|
esac
|
|
done
|
|
[ -z "$field" ] && [ "$open" = false ] && [ "$records" -gt 0 ] && [ "$found" -le 1 ] || return 1
|
|
if [ "$found" -eq 1 ]; then printf 'registered\n'; else printf 'absent\n'; fi
|
|
} || {
|
|
pipeline_status=("${PIPESTATUS[@]}")
|
|
[ "${pipeline_status[0]}" -eq 0 ] || return "${pipeline_status[0]}"
|
|
return "${pipeline_status[1]}"
|
|
}
|
|
)
|
|
|
|
resolve_existing_dir_path() {
|
|
local path="$1"
|
|
if [ ! -d "$path" ]; then
|
|
return 1
|
|
fi
|
|
|
|
(
|
|
cd "$path" >/dev/null 2>&1 &&
|
|
pwd -P
|
|
)
|
|
}
|
|
|
|
pr_worktree_state() {
|
|
local root common_dir
|
|
root=$(common_repo_root) || return $?
|
|
common_dir=$(pr_git -C "$root" rev-parse --path-format=absolute --git-common-dir) || return $?
|
|
# Git omits damaged admin entries from its listing. Bind the exact backlink
|
|
# separately, and distinguish genuine absence from an unreadable path.
|
|
node - "$root" "$common_dir" "$1" "${2:-}" "${3:-cleanup}" <<'EOF_NODE'
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const [root, common, requested, previousAdmin, purpose] = process.argv.slice(2);
|
|
function stat(file) {
|
|
try { return fs.lstatSync(file); } catch (error) {
|
|
if (error.code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
function read(file) {
|
|
if (!stat(file)?.isFile()) throw new Error("damaged worktree metadata");
|
|
return fs.readFileSync(file, "utf8").trimEnd();
|
|
}
|
|
try {
|
|
const canonicalParent = path.join(fs.realpathSync(root), ".worktrees");
|
|
const parent = stat(canonicalParent) ? fs.realpathSync(canonicalParent) : canonicalParent;
|
|
const input = path.resolve(requested);
|
|
const inputParent = path.dirname(input);
|
|
const resolvedParent = stat(inputParent) ? fs.realpathSync(inputParent) : inputParent;
|
|
const leaf = path.basename(input);
|
|
if (!/^pr-[1-9][0-9]*$/.test(leaf) || resolvedParent !== parent) {
|
|
throw new Error("non-canonical PR-worktree path");
|
|
}
|
|
const target = path.join(parent, leaf);
|
|
const targetStat = stat(target);
|
|
if (targetStat && !targetStat.isDirectory()) throw new Error("non-canonical PR-worktree path");
|
|
const commonDir = fs.realpathSync(common);
|
|
const adminRoot = path.join(commonDir, "worktrees");
|
|
const adminStat = stat(adminRoot);
|
|
if (adminStat && !adminStat.isDirectory()) throw new Error("damaged worktree metadata");
|
|
const matches = [];
|
|
let ids;
|
|
// Reusing a healthy worktree needs its own identity, not proof that unrelated
|
|
// admin entries are readable. Destruction still requires the complete scan.
|
|
if (purpose === "entry" && targetStat) {
|
|
const gitfile = path.join(target, ".git");
|
|
if (!stat(gitfile)?.isFile()) {
|
|
throw new Error("unregistered or ambiguous PR worktree; scripts/pr refuses to mutate the shared canonical checkout");
|
|
}
|
|
const pointer = read(gitfile);
|
|
if (!pointer.startsWith("gitdir: ")) throw new Error("damaged worktree metadata");
|
|
const admin = path.resolve(target, pointer.slice(8));
|
|
if (path.dirname(admin) !== adminRoot) throw new Error("damaged worktree metadata");
|
|
ids = [path.basename(admin)];
|
|
} else {
|
|
ids = adminStat ? fs.readdirSync(adminRoot) : [];
|
|
}
|
|
// Git preserves admin IDs across moves. Only readable, valid backlinks can
|
|
// attribute entries; an unknown backlink cannot establish target absence.
|
|
for (const id of ids) {
|
|
const admin = path.join(adminRoot, id);
|
|
if (!stat(admin)?.isDirectory()) throw new Error("damaged worktree metadata");
|
|
const backlink = read(path.join(admin, "gitdir"));
|
|
if (!backlink.endsWith("/.git")) throw new Error("damaged worktree metadata");
|
|
if (path.resolve(admin, backlink) !== path.join(target, ".git")) continue;
|
|
if (path.resolve(admin, read(path.join(admin, "commondir"))) !== commonDir) {
|
|
throw new Error("damaged worktree metadata");
|
|
}
|
|
matches.push(admin);
|
|
}
|
|
if (matches.length > 1 || (purpose === "entry" && targetStat && matches.length !== 1)) {
|
|
throw new Error("ambiguous worktree metadata");
|
|
}
|
|
process.stdout.write(JSON.stringify({
|
|
path: target, present: Boolean(targetStat), admin: matches[0] ?? "", common: commonDir,
|
|
previousAdminPresent: previousAdmin ? Boolean(stat(previousAdmin)) : false,
|
|
}) + "\n");
|
|
} catch (error) {
|
|
console.error(`Refusing PR worktree cleanup: ${error.code ?? error.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
EOF_NODE
|
|
}
|
|
|
|
has_worktree_merge_output() {
|
|
local path="$1" capture
|
|
for capture in "$path/.local/merge-output.log" "$path"/.local/merge-output.*.log; do
|
|
if [ -e "$capture" ] || [ -L "$capture" ]; then return 0; fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
require_worktree_cleanup_evidence() (
|
|
local path="$1" pr
|
|
has_worktree_merge_output "$path" || return 0
|
|
# Keep loader state separate from an uninterrupted merge's live outcome owner.
|
|
# Even an empty capture can be the only evidence of an earlier dispatch.
|
|
if pr=$(pr_number_from_worktree_dir "$path") &&
|
|
merge_outcome_load_local "$pr" && [ -n "$MERGE_OUTCOME_OID" ]; then
|
|
return 0
|
|
fi
|
|
echo "Preserving $path: merge output has no valid retained merge outcome. Keep the worktree, metadata, and local branches; reconcile the earlier request manually before cleanup." >&2
|
|
return 1
|
|
)
|
|
|
|
remove_worktree_if_present() {
|
|
local path="$1" state registered_path registration admin dirty
|
|
state=$(pr_worktree_state "$path") || return $?
|
|
registered_path=$(printf '%s\n' "$state" | jq -r '.path') || return $?
|
|
admin=$(printf '%s\n' "$state" | jq -r '.admin') || return $?
|
|
registration=$(worktree_registration_state "$registered_path") || return $?
|
|
require_worktree_cleanup_evidence "$path" || return $?
|
|
if [ "$registration" = absent ] &&
|
|
printf '%s\n' "$state" | jq -e '.present == false and .admin == ""' >/dev/null; then
|
|
return 0
|
|
fi
|
|
if [ "$registration" != registered ] || [ -z "$admin" ]; then
|
|
echo "Preserving $path: unregistered or ambiguous PR worktree; scripts/pr refuses to mutate the shared canonical checkout." >&2
|
|
return 1
|
|
fi
|
|
if [ -d "$path" ]; then
|
|
dirty=$(pr_git -C "$path" status --porcelain --untracked-files=all --ignore-submodules=none) || return $?
|
|
if [ -n "$dirty" ] || [ -e "$admin/locked" ]; then
|
|
echo "Preserving $path: worktree has local changes or is locked. Review its contents and ownership before cleanup." >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
[ "${2:-false}" != true ] || return 0
|
|
# One native removal owns both the path and its exact admin entry. A partial
|
|
# deletion still fails; neither repository-wide prune nor orphan trash is safe.
|
|
pr_git worktree remove -- "$registered_path" || return $?
|
|
state=$(pr_worktree_state "$path" "$admin") || return $?
|
|
registration=$(worktree_registration_state "$registered_path") || return $?
|
|
if [ "$registration" != absent ] ||
|
|
! printf '%s\n' "$state" | jq -e --arg path "$registered_path" \
|
|
'.path == $path and .present == false and .admin == "" and .previousAdminPresent == false' >/dev/null; then
|
|
echo "Preserving cleanup state for $path: worktree removal is incomplete." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
delete_local_branch_if_safe() {
|
|
local branch="$1" retained="${2:-}"
|
|
local ref="refs/heads/$branch"
|
|
|
|
local existing status
|
|
# for-each-ref can warn about a broken ref with status zero. Such a warning
|
|
# is not proof of absence, so retain diagnostics in the validated result.
|
|
existing=$(pr_git for-each-ref --format="%(if:equals=$ref)%(refname)%(then)%(refname)%(end)" -- "$ref" 2>&1) || {
|
|
status=$?; printf '%s\n' "$existing" >&2; return "$status"
|
|
}
|
|
[ -n "$existing" ] || return 0
|
|
[ "$existing" = "$ref" ] || { printf '%s\n' "$existing" >&2; return 1; }
|
|
|
|
local branch_worktree=""
|
|
branch_worktree=$(worktree_path_for_branch "$branch") || return $?
|
|
if [ -n "$branch_worktree" ]; then
|
|
echo "Skipping local branch delete for $branch; checked out in worktree $branch_worktree"
|
|
return 1
|
|
fi
|
|
|
|
local config=()
|
|
# A confirmed squash receipt retains the reviewed source as ancestry even
|
|
# though main does not. Use it only for this command's non-force merge check.
|
|
# Git still rejects advanced tips and branches checked out in another worktree.
|
|
if [ -n "$retained" ]; then
|
|
# merge is multi-valued: appending must not redirect an existing upstream.
|
|
if pr_git config --get-all "branch.$branch.merge" >/dev/null; then
|
|
:
|
|
else
|
|
status=$?
|
|
[ "$status" -eq 1 ] || return "$status"
|
|
config=(-c "branch.$branch.remote=." -c "branch.$branch.merge=$retained")
|
|
fi
|
|
fi
|
|
pr_git ${config[@]+"${config[@]}"} branch -d -- "$branch" || return $?
|
|
existing=$(pr_git for-each-ref --format="%(if:equals=$ref)%(refname)%(then)%(refname)%(end)" -- "$ref" 2>&1) || {
|
|
status=$?; printf '%s\n' "$existing" >&2; return "$status"
|
|
}
|
|
[ -z "$existing" ] || { printf 'Branch cleanup incomplete: %s\n' "$existing" >&2; return 1; }
|
|
}
|
|
|
|
cleanup_pr_worktree() {
|
|
local path="$1" pr branch retained=""
|
|
# Preserve the uninterrupted merge's live receipt while validating cleanup proof.
|
|
local MERGE_OUTCOME_REF="" MERGE_OUTCOME_OID="" MERGE_OUTCOME_RECORD=""
|
|
pr=$(pr_number_from_worktree_dir "$path") || return 1
|
|
merge_outcome_load_local "$pr" || return 1
|
|
if [ -n "$MERGE_OUTCOME_OID" ] &&
|
|
printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -e '.phase != "intent"' >/dev/null; then
|
|
retained="$MERGE_OUTCOME_REF"
|
|
fi
|
|
remove_worktree_if_present "$path" || return $?
|
|
# Refusal or incomplete removal preserves the branches with the worktree.
|
|
[ ! -e "$path" ] && [ ! -L "$path" ] || return 1
|
|
for branch in "temp/pr-$pr" "pr-$pr" "pr-$pr-prep"; do
|
|
delete_local_branch_if_safe "$branch" "$retained" || return $?
|
|
done
|
|
}
|