diff --git a/scripts/probe_branch_rebase.sh b/scripts/probe_branch_rebase.sh new file mode 100755 index 00000000..358a0b1f --- /dev/null +++ b/scripts/probe_branch_rebase.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -uo pipefail + +EXIT_OK=0 +EXIT_FAIL=1 +EXIT_UNCHECKED=3 +EXIT_USAGE=64 + +usage() { + echo "usage: probe_branch_rebase.sh " >&2 + echo " exit ${EXIT_OK} OK rebases cleanly onto " >&2 + echo " exit ${EXIT_FAIL} FAIL rebase ran and found a content conflict" >&2 + echo " exit ${EXIT_UNCHECKED} UNCHECKED the probe could not run; nothing is proven" >&2 + echo " exit ${EXIT_USAGE} usage error" >&2 + exit "$EXIT_USAGE" +} + +unchecked() { + echo "UNCHECKED: $*" >&2 + exit "$EXIT_UNCHECKED" +} + +safe_name() { + printf '%s' "$1" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_' | cut -c1-40 +} + +cleanup() { + if [ -n "${SCRATCH_DIR:-}" ]; then + git worktree remove --force "$SCRATCH_DIR" >/dev/null 2>&1 || true + fi +} + +[ "$#" -eq 2 ] || usage + +SOURCE_REF="$1" +BASE_REF="$2" +SCRATCH_DIR="" + +if ! git rev-parse --git-dir >/dev/null; then + unchecked "$(pwd) is not a git checkout" +fi + +if ! SCRATCH_ROOT="$(git rev-parse --git-path rebase-probe-worktrees)"; then + unchecked "could not resolve scratch worktree root" +fi + +case "$SCRATCH_ROOT" in + /*) ;; + *) + if ! REPO_ROOT="$(git rev-parse --show-toplevel)"; then + unchecked "could not resolve repository root" + fi + SCRATCH_ROOT="${REPO_ROOT}/${SCRATCH_ROOT}" + ;; +esac + +if ! mkdir -p "$SCRATCH_ROOT"; then + unchecked "could not create scratch worktree root: ${SCRATCH_ROOT}" +fi + +if ! git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then + unchecked "base ref is not a commit: ${BASE_REF}" +fi + +if ! DIGEST="$(printf '%s\n%s\n' "$SOURCE_REF" "$BASE_REF" | git hash-object --stdin)"; then + unchecked "could not build scratch worktree name" +fi + +SAFE_SOURCE="$(safe_name "$SOURCE_REF")" +SAFE_BASE="$(safe_name "$BASE_REF")" +SCRATCH_DIR="${SCRATCH_ROOT}/probe-${SAFE_SOURCE}-onto-${SAFE_BASE}-${DIGEST:0:12}" + +if ! git worktree add --detach "$SCRATCH_DIR" "$SOURCE_REF"; then + unchecked "could not create scratch worktree: ${SCRATCH_DIR}" +fi + +trap cleanup EXIT + +if git -C "$SCRATCH_DIR" rebase "$BASE_REF"; then + exit "$EXIT_OK" +fi + +if ! UNMERGED="$(git -C "$SCRATCH_DIR" ls-files -u)"; then + unchecked "could not inspect rebase failure in scratch worktree: ${SCRATCH_DIR}" +fi + +if [ -n "$UNMERGED" ]; then + exit "$EXIT_FAIL" +fi + +unchecked "rebase failed without content conflicts: ${SOURCE_REF} onto ${BASE_REF}" diff --git a/tests/test_probe_branch_rebase.py b/tests/test_probe_branch_rebase.py new file mode 100644 index 00000000..b11656fb --- /dev/null +++ b/tests/test_probe_branch_rebase.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import shutil +import string +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SCRIPT = REPO / "scripts" / "probe_branch_rebase.sh" +sys.path.insert(0, str(REPO / "scripts")) + +from git_test_repo import init_repo # noqa: E402 + +HERMETIC_ENV = dict( + os.environ, + GIT_CONFIG_GLOBAL=os.devnull, + GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="Fixture", + GIT_AUTHOR_EMAIL="fixture@example.invalid", + GIT_COMMITTER_NAME="Fixture", + GIT_COMMITTER_EMAIL="fixture@example.invalid", +) + + +class Fixture: + def __init__(self, root: Path): + self.root = root + init_repo(root, "-b", "main", env=HERMETIC_ENV) + self.commit("seed", {"file.txt": "seed\n"}) + + def git(self, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(self.root), *args], + check=True, + capture_output=True, + text=True, + env=HERMETIC_ENV, + ).stdout.strip() + + def commit(self, message: str, files: dict[str, str]) -> None: + for path, text in files.items(): + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + self.git("add", path) + self.git("commit", "-q", "-m", message) + + def run_probe(self, source_ref: str, base_ref: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT), source_ref, base_ref], + cwd=self.root, + capture_output=True, + text=True, + env=HERMETIC_ENV, + ) + + def scratch_path(self, source_ref: str, base_ref: str) -> Path: + allowed = set(string.ascii_letters + string.digits + "._-") + + def safe(value: str) -> str: + return "".join(ch if ch in allowed else "_" for ch in value)[:40] + + digest = subprocess.run( + ["git", "-C", str(self.root), "hash-object", "--stdin"], + input=f"{source_ref}\n{base_ref}\n", + check=True, + capture_output=True, + text=True, + env=HERMETIC_ENV, + ).stdout.strip()[:12] + scratch_root = Path(self.git("rev-parse", "--git-path", "rebase-probe-worktrees")) + if not scratch_root.is_absolute(): + scratch_root = self.root / scratch_root + return scratch_root / f"probe-{safe(source_ref)}-onto-{safe(base_ref)}-{digest}" + + def add_stale_probe_worktree(self, source_ref: str, base_ref: str) -> None: + scratch = self.scratch_path(source_ref, base_ref) + scratch.parent.mkdir(parents=True, exist_ok=True) + self.git("worktree", "add", "--detach", str(scratch), source_ref) + shutil.rmtree(scratch) + + +class ProbeBranchRebaseTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.repo = Fixture(Path(self._tmp.name) / "repo") + + def make_clean_rebase(self) -> None: + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic adds file", {"topic.txt": "topic\n"}) + self.repo.git("checkout", "-q", "main") + self.repo.commit("base adds file", {"base.txt": "base\n"}) + + def make_conflicting_rebase(self) -> None: + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic edits file", {"file.txt": "topic\n"}) + self.repo.git("checkout", "-q", "main") + self.repo.commit("base edits file", {"file.txt": "base\n"}) + + def test_clean_rebase_exits_ok(self): + self.make_clean_rebase() + + result = self.repo.run_probe("topic", "main") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.repo.scratch_path("topic", "main").exists()) + + def test_content_conflict_exits_fail(self): + self.make_conflicting_rebase() + + result = self.repo.run_probe("topic", "main") + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("could not apply", result.stderr) + self.assertFalse(self.repo.scratch_path("topic", "main").exists()) + + def test_missing_registered_scratch_worktree_exits_unchecked(self): + self.make_conflicting_rebase() + self.repo.add_stale_probe_worktree("topic", "main") + + result = self.repo.run_probe("topic", "main") + + self.assertEqual(result.returncode, 3, result.stderr) + self.assertIn("missing but already registered worktree", result.stderr) + self.assertIn("UNCHECKED: could not create scratch worktree", result.stderr) + + +if __name__ == "__main__": + unittest.main()