Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions scripts/probe_branch_rebase.sh
Original file line number Diff line number Diff line change
@@ -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 <source-ref> <base-ref>" >&2
echo " exit ${EXIT_OK} OK <source-ref> rebases cleanly onto <base-ref>" >&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}"
135 changes: 135 additions & 0 deletions tests/test_probe_branch_rebase.py
Original file line number Diff line number Diff line change
@@ -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()
Loading