From 7a30dbaab2861d8efc89a1001de8564d00dfa9d7 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:56:05 +0000 Subject: [PATCH 1/3] fix(integration-branch): bound every Git read to one budget Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- loopx/capabilities/integration_branch/core.py | 68 ++++++++++++-- .../test_integration_branch_git_budget.py | 91 +++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 tests/capabilities/test_integration_branch_git_budget.py diff --git a/loopx/capabilities/integration_branch/core.py b/loopx/capabilities/integration_branch/core.py index f5654c39a9..03610c4d12 100644 --- a/loopx/capabilities/integration_branch/core.py +++ b/loopx/capabilities/integration_branch/core.py @@ -9,30 +9,86 @@ from pathlib import Path from typing import Any +from ...extensions.process_runtime import retire_owned_process + PLAN_SCHEMA_VERSION = "loopx_integration_branch_plan_v0" STATUS_SCHEMA_VERSION = "loopx_integration_branch_status_v0" SYNC_SCHEMA_VERSION = "loopx_integration_branch_sync_v0" DEFAULT_PLAN_PATH = Path(".loopx/integration-branch.json") ZERO_SHA = "0" * 40 +# A hook or CLI command must not wait forever on a Git helper. A stale +# index lock, an fsmonitor hook or a checkout on a network mount can +# otherwise hold the integration-branch operation open. +GIT_READ_TIMEOUT_SECONDS = 10.0 + class IntegrationBranchError(ValueError): """A fail-closed integration-branch contract error.""" +def _retire_owned_read(process: subprocess.Popen[str]) -> None: + """Retire the read's own process group and drop its pipes within a deadline. + + The shared runtime bounds termination, Windows ``taskkill`` and pipe + closure under one budget. Git starts helpers -- fsmonitor hooks, pagers -- + inside the session it runs in, so killing the leader alone leaves them + behind; a pipe a descendant still holds must not extend the caller's wait. + """ + + try: + retire_owned_process(process, process_tree_owned=True) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError): + # The answer is already unavailable; cleanup must not replace it with + # a traceback. The read's own timeout or cancellation stays the result. + pass + + def _git( repo: Path, *args: str, check: bool = True, ) -> subprocess.CompletedProcess[str]: - result = subprocess.run( - ["git", "-C", str(repo), *args], - check=False, - capture_output=True, - text=True, encoding="utf-8", errors="replace", + """Run one Git read, or refuse when it cannot answer inside its budget. + + An unanswered read is not an empty repository. Callers resolve refs, + inventory worktrees and read remote state through this one entry point; + raising here makes those paths fail closed instead of acting on state they + never observed. + """ + + argv = ["git", "-C", str(repo), *args] + process = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + start_new_session=os.name == "posix", ) + try: + stdout, stderr = process.communicate(timeout=GIT_READ_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + _retire_owned_read(process) + raise IntegrationBranchError( + "git " + + " ".join(args) + + f" did not answer within {GIT_READ_TIMEOUT_SECONDS:g} seconds" + ) from None + except BaseException: + # A terminal cancellation reaches this process, not the session the + # read owns. Retire that session before the cancellation propagates. + _retire_owned_read(process) + raise + result = subprocess.CompletedProcess(argv, process.returncode, stdout, stderr) if check and result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "git command failed" + detail = ( + (result.stderr or "").strip() + or (result.stdout or "").strip() + or "git command failed" + ) raise IntegrationBranchError(detail) return result diff --git a/tests/capabilities/test_integration_branch_git_budget.py b/tests/capabilities/test_integration_branch_git_budget.py new file mode 100644 index 0000000000..6909e966c0 --- /dev/null +++ b/tests/capabilities/test_integration_branch_git_budget.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +import time +from pathlib import Path + +import pytest + +from loopx.capabilities.integration_branch import core + + +def _repository(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main", str(repo)], check=True) + subprocess.run( + ["git", "-C", str(repo), "config", "user.email", "fixture@example.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "config", "user.name", "Fixture"], check=True + ) + (repo / "tracked.txt").write_text("base\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "tracked.txt"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "base"], check=True) + return repo + + +def test_a_finished_read_keeps_text_and_exit_status(tmp_path: Path) -> None: + repo = _repository(tmp_path) + + result = core._git(repo, "rev-parse", "--verify", "HEAD") + assert result.returncode == 0 + assert isinstance(result.stdout, str) + assert len(result.stdout.strip()) == 40 + + missing = core._git(repo, "rev-parse", "--verify", "absent", check=False) + assert missing.returncode != 0 + + with pytest.raises(core.IntegrationBranchError): + core._git(repo, "rev-parse", "--verify", "absent") + + +@pytest.mark.skipif(os.name != "posix", reason="the fake git is a POSIX script") +def test_a_git_that_never_answers_stops_inside_the_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = _repository(tmp_path) + heartbeats = tmp_path / "heartbeats" + heartbeats.write_text("", encoding="utf-8") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "git" + fake.write_text( + "#!/bin/sh\n" + f"while :; do echo tick >> {shlex.quote(str(heartbeats))}; sleep 0.05; done\n", + encoding="utf-8", + ) + fake.chmod(0o755) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + monkeypatch.setattr(core, "GIT_READ_TIMEOUT_SECONDS", 1.0) + + started = time.monotonic() + with pytest.raises(core.IntegrationBranchError, match="did not answer within"): + core._git(repo, "rev-parse", "--show-toplevel") + elapsed = time.monotonic() - started + assert elapsed < 6, f"an unanswered read held the caller for {elapsed:.1f}s" + + time.sleep(0.2) + retired = heartbeats.stat().st_size + assert retired > 0, "the fixture never ran, so this proves nothing" + time.sleep(0.5) + assert heartbeats.stat().st_size == retired, ( + "the helper and its child outlived the read budget" + ) + + +def test_a_read_that_cannot_start_still_raises_oserror( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = _repository(tmp_path) + + def cannot_start(command, **kwargs): + raise OSError("git is not installed") + + monkeypatch.setattr(core.subprocess, "Popen", cannot_start) + + with pytest.raises(OSError, match="git is not installed"): + core._git(repo, "rev-parse", "--show-toplevel") From 2f2222868ba4c720d0cbd11dd8d5e59aad3d1391 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:48:32 +0000 Subject: [PATCH 2/3] fix(integration-branch): unregister a candidate that timed out mid-add Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- loopx/capabilities/integration_branch/core.py | 49 ++++++++++++++++-- .../test_integration_branch_git_budget.py | 51 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/loopx/capabilities/integration_branch/core.py b/loopx/capabilities/integration_branch/core.py index 03610c4d12..66c9df29f7 100644 --- a/loopx/capabilities/integration_branch/core.py +++ b/loopx/capabilities/integration_branch/core.py @@ -602,6 +602,31 @@ def _clean_worktree(path: Path) -> bool: ).stdout.strip() +def _prune_missing_candidate(repo: Path, worktree: Path) -> None: + """Drop this candidate's admin entry when Git removed the directory already. + + Only the path this function is given is considered; other worktrees, stale + or not, stay untouched so a sync never repairs unrelated state. + """ + + listed = _git(repo, "worktree", "list", "--porcelain", check=False) + if listed.returncode != 0: + return + target = worktree.resolve() + for line in listed.stdout.split("\n"): + if line.startswith("worktree "): + candidate = Path(line.removeprefix("worktree ")) + try: + resolved = candidate.resolve() + except OSError: + resolved = candidate + if resolved != target: + continue + if not candidate.exists(): + _git(repo, "worktree", "prune", "--expire", "now", check=False) + return + + def _build_candidate( repo: Path, *, @@ -610,8 +635,13 @@ def _build_candidate( ) -> tuple[str | None, dict[str, Any] | None]: temporary_root = Path(tempfile.mkdtemp(prefix="loopx-integration-branch-")) worktree = temporary_root / "candidate" - added = False + # `git worktree add` registers the candidate in the common dir before the + # checkout hook runs, so a budget that expires inside that hook has already + # written state we own. Track the attempt, not its success: the candidate + # must be unregistered even when the command never returned. + attempted = False try: + attempted = True _git( repo, "worktree", @@ -621,7 +651,6 @@ def _build_candidate( str(worktree), start_sha, ) - added = True for source in sources: result = _git( worktree, @@ -644,8 +673,20 @@ def _build_candidate( } return _resolve_commit(worktree, "HEAD"), None finally: - if added: - _git(repo, "worktree", "remove", "--force", str(worktree), check=False) + if attempted: + # Remove this candidate's own registration while its directory + # still exists; a cleanup failure must not replace the timeout or + # cancellation that ended the attempt. + _git( + repo, + "worktree", + "remove", + "--force", + "--force", + str(worktree), + check=False, + ) + _prune_missing_candidate(repo, worktree) shutil.rmtree(temporary_root, ignore_errors=True) diff --git a/tests/capabilities/test_integration_branch_git_budget.py b/tests/capabilities/test_integration_branch_git_budget.py index 6909e966c0..ff9d406828 100644 --- a/tests/capabilities/test_integration_branch_git_budget.py +++ b/tests/capabilities/test_integration_branch_git_budget.py @@ -89,3 +89,54 @@ def cannot_start(command, **kwargs): with pytest.raises(OSError, match="git is not installed"): core._git(repo, "rev-parse", "--show-toplevel") + + +def _worktree_list(repo: Path) -> str: + return subprocess.run( + ["git", "-C", str(repo), "worktree", "list", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + + +@pytest.mark.skipif(os.name != "posix", reason="the hook is a POSIX script") +def test_a_timed_out_worktree_add_leaves_no_registration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A budget that expires inside post-checkout must still unregister it. + + Git registers the worktree in the common dir before the hook runs, so the + command never returns while the registration already exists. + """ + + repo = _repository(tmp_path) + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + hook = repo / ".git" / "hooks" / "post-checkout" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\nsleep 3\n", encoding="utf-8") + hook.chmod(0o755) + + scratch = tmp_path / "scratch-root" + monkeypatch.setattr(core.tempfile, "mkdtemp", lambda prefix=None: str(scratch)) + monkeypatch.setattr(core, "GIT_READ_TIMEOUT_SECONDS", 0.4) + + before = _worktree_list(repo) + with pytest.raises(core.IntegrationBranchError, match="did not answer within"): + core._build_candidate(repo, start_sha=head, sources=[]) + after = _worktree_list(repo) + + assert after == before, "the candidate stayed registered after the timeout" + assert "prunable" not in after + assert not scratch.exists(), "the temporary root survived the timeout" + + hook.unlink() + recovered, failure = core._build_candidate(repo, start_sha=head, sources=[]) + assert failure is None + assert recovered == head + assert _worktree_list(repo) == before From 2cb8ca0300948d0a918be3707c98ffe1ed397176 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:43:58 +0000 Subject: [PATCH 3/3] fix(integration-branch): finish candidate cleanup without pruning other worktrees Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- loopx/capabilities/integration_branch/core.py | 117 ++++++++---- .../test_integration_branch_git_budget.py | 179 ++++++++++++++++++ 2 files changed, 262 insertions(+), 34 deletions(-) diff --git a/loopx/capabilities/integration_branch/core.py b/loopx/capabilities/integration_branch/core.py index 66c9df29f7..1e847c3bd2 100644 --- a/loopx/capabilities/integration_branch/core.py +++ b/loopx/capabilities/integration_branch/core.py @@ -602,29 +602,77 @@ def _clean_worktree(path: Path) -> bool: ).stdout.strip() -def _prune_missing_candidate(repo: Path, worktree: Path) -> None: - """Drop this candidate's admin entry when Git removed the directory already. +def _unregister_candidate(repo: Path, worktree: Path) -> bool: + """Drop only this candidate's registration; report whether none is left. + + ``git worktree prune --expire now`` has no path filter: it would also + discard stale registrations that belong to unrelated checkouts. Git keeps + one admin directory per registration under ``/worktrees``, and its + ``gitdir`` file names the checkout it belongs to, so this candidate's own + entry is found and removed without touching any other state. + """ + + common = _git(repo, "rev-parse", "--git-common-dir") + root = Path(common.stdout.strip()) + if not root.is_absolute(): + root = repo / root + target = os.path.normcase(str((worktree / ".git").resolve())) + try: + admins = list((root / "worktrees").iterdir()) + except FileNotFoundError: + return True + removed = True + for admin in admins: + try: + pointer = (admin / "gitdir").read_text( + encoding="utf-8", errors="replace" + ).strip() + except OSError: + removed = False + continue + if not pointer: + continue + if os.path.normcase(str(Path(pointer).resolve())) != target: + continue + shutil.rmtree(admin, ignore_errors=True) + removed = removed and not admin.exists() + return removed + - Only the path this function is given is considered; other worktrees, stale - or not, stay untouched so a sync never repairs unrelated state. +def _retire_candidate_worktree(repo: Path, worktree: Path) -> bool: + """Remove this candidate's checkout and only its registration. + + Runs while an earlier timeout or cancellation is unwinding, so nothing + here may replace that result: the removal command is best effort, the + directory goes away regardless, and a registration that cannot be dropped + stays visible in ``git worktree list`` instead of being repaired by a + command that would also touch other worktrees. """ - listed = _git(repo, "worktree", "list", "--porcelain", check=False) - if listed.returncode != 0: - return - target = worktree.resolve() - for line in listed.stdout.split("\n"): - if line.startswith("worktree "): - candidate = Path(line.removeprefix("worktree ")) - try: - resolved = candidate.resolve() - except OSError: - resolved = candidate - if resolved != target: - continue - if not candidate.exists(): - _git(repo, "worktree", "prune", "--expire", "now", check=False) - return + removed = False + try: + result = _git( + repo, + "worktree", + "remove", + "--force", + "--force", + str(worktree), + check=False, + ) + removed = result.returncode == 0 + except (IntegrationBranchError, OSError, subprocess.SubprocessError): + # The removal command has its own budget; its failure, or a timeout in + # that command, must not skip the rest of this cleanup. + removed = False + finally: + shutil.rmtree(worktree, ignore_errors=True) + if removed: + return True + try: + return _unregister_candidate(repo, worktree) + except (IntegrationBranchError, OSError, subprocess.SubprocessError): + return False def _build_candidate( @@ -640,6 +688,7 @@ def _build_candidate( # written state we own. Track the attempt, not its success: the candidate # must be unregistered even when the command never returned. attempted = False + attempt_error: BaseException | None = None try: attempted = True _git( @@ -672,22 +721,22 @@ def _build_candidate( "error": detail, } return _resolve_commit(worktree, "HEAD"), None + except BaseException as error: + attempt_error = error + raise finally: - if attempted: - # Remove this candidate's own registration while its directory - # still exists; a cleanup failure must not replace the timeout or - # cancellation that ended the attempt. - _git( - repo, - "worktree", - "remove", - "--force", - "--force", - str(worktree), - check=False, - ) - _prune_missing_candidate(repo, worktree) + # Both steps below run while a timeout or cancellation may be + # unwinding: neither may replace that result with its own failure, and + # the directory must go even when the removal command never answered. + registration_dropped = not attempted or _retire_candidate_worktree( + repo, worktree + ) shutil.rmtree(temporary_root, ignore_errors=True) + if not registration_dropped and attempt_error is not None: + attempt_error.add_note( + "this candidate's worktree registration could not be removed; " + "`git worktree list` reports it as prunable" + ) def _is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool: diff --git a/tests/capabilities/test_integration_branch_git_budget.py b/tests/capabilities/test_integration_branch_git_budget.py index ff9d406828..6d6d2373d4 100644 --- a/tests/capabilities/test_integration_branch_git_budget.py +++ b/tests/capabilities/test_integration_branch_git_budget.py @@ -2,6 +2,7 @@ import os import shlex +import shutil import subprocess import time from pathlib import Path @@ -100,6 +101,32 @@ def _worktree_list(repo: Path) -> str: ).stdout +def _worktree_paths(repo: Path) -> list[Path]: + paths: list[Path] = [] + for line in _worktree_list(repo).splitlines(): + if line.startswith("worktree "): + paths.append(Path(line.removeprefix("worktree ")).resolve()) + return paths + + +def _wrapper(tmp_path: Path, body: str) -> Path: + """Install a POSIX `git` that behaves normally except for `body` cases.""" + + real = shutil.which("git") + assert real is not None + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "git" + fake.write_text( + "#!/bin/sh\n" + f"{body}\n" + f"exec {shlex.quote(real)} \"$@\"\n", + encoding="utf-8", + ) + fake.chmod(0o755) + return bin_dir + + @pytest.mark.skipif(os.name != "posix", reason="the hook is a POSIX script") def test_a_timed_out_worktree_add_leaves_no_registration( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -140,3 +167,155 @@ def test_a_timed_out_worktree_add_leaves_no_registration( assert failure is None assert recovered == head assert _worktree_list(repo) == before + + +@pytest.mark.skipif(os.name != "posix", reason="the hook and wrapper are POSIX scripts") +def test_a_removal_that_times_out_still_finishes_the_candidate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A cleanup command with its own budget must not outlive the candidate. + + ``check=False`` only tolerates a non-zero exit; a removed that exceeds the + budget still raises. The original add timeout stays the result, the + directory goes away and only this candidate is unregistered. + """ + + repo = _repository(tmp_path) + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + hook = repo / ".git" / "hooks" / "post-checkout" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\nsleep 2\n", encoding="utf-8") + hook.chmod(0o755) + + original_path = os.environ["PATH"] + bin_dir = _wrapper( + tmp_path, + 'if [ "$3" = "worktree" ] && [ "$4" = "remove" ]; then\n' + " sleep 2\n" + " exit 1\n" + "fi", + ) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{original_path}") + + scratch = tmp_path / "scratch-root" + monkeypatch.setattr(core.tempfile, "mkdtemp", lambda prefix=None: str(scratch)) + monkeypatch.setattr(core, "GIT_READ_TIMEOUT_SECONDS", 0.4) + + before = _worktree_list(repo) + with pytest.raises(core.IntegrationBranchError) as caught: + core._build_candidate(repo, start_sha=head, sources=[]) + message = str(caught.value) + assert "worktree add" in message, "the removal timeout replaced the add error" + assert "worktree remove" not in message + + assert not scratch.exists(), "the candidate directory survived its own cleanup" + after = _worktree_list(repo) + assert after == before, "the candidate stayed registered" + assert "prunable" not in after + + hook.unlink() + monkeypatch.setenv("PATH", original_path) + recovered, failure = core._build_candidate(repo, start_sha=head, sources=[]) + assert failure is None + assert recovered == head + assert _worktree_list(repo) == before + + +@pytest.mark.skipif(os.name != "posix", reason="the hook and wrapper are POSIX scripts") +def test_cleanup_keeps_another_worktrees_registration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """This candidate's cleanup must never prune an unrelated worktree.""" + + repo = _repository(tmp_path) + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + other = tmp_path / "other-checkout" + subprocess.run( + ["git", "-C", str(repo), "worktree", "add", "--detach", "--quiet", str(other), head], + check=True, + ) + shutil.rmtree(other) + before = _worktree_list(repo) + assert str(other.resolve()) in before, "the fixture never left a stale registration" + + hook = repo / ".git" / "hooks" / "post-checkout" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\nsleep 2\n", encoding="utf-8") + hook.chmod(0o755) + + bin_dir = _wrapper( + tmp_path, + 'if [ "$3" = "worktree" ] && [ "$4" = "remove" ]; then\n' + " exit 1\n" + "fi", + ) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + + scratch = tmp_path / "scratch-root" + monkeypatch.setattr(core.tempfile, "mkdtemp", lambda prefix=None: str(scratch)) + monkeypatch.setattr(core, "GIT_READ_TIMEOUT_SECONDS", 0.4) + + paths_before = _worktree_paths(repo) + with pytest.raises(core.IntegrationBranchError, match="worktree add"): + core._build_candidate(repo, start_sha=head, sources=[]) + + after = _worktree_list(repo) + assert other.resolve() in _worktree_paths(repo), ( + "cleanup removed an unrelated worktree registration" + ) + assert "prunable gitdir file points to non-existent location" in after + assert not scratch.exists() + assert other.resolve() in paths_before + + +@pytest.mark.skipif(os.name != "posix", reason="the hook and wrapper are POSIX scripts") +def test_an_unconfirmed_registration_stays_visible_on_the_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """When the removal cannot be confirmed, the error says so honestly.""" + + repo = _repository(tmp_path) + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + hook = repo / ".git" / "hooks" / "post-checkout" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\nsleep 2\n", encoding="utf-8") + hook.chmod(0o755) + + bin_dir = _wrapper( + tmp_path, + 'if [ "$3" = "worktree" ] && [ "$4" = "remove" ]; then\n' + " exit 1\n" + "fi\n" + 'if [ "$3" = "rev-parse" ] && [ "$4" = "--git-common-dir" ]; then\n' + " exit 1\n" + "fi", + ) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + + scratch = tmp_path / "scratch-root" + monkeypatch.setattr(core.tempfile, "mkdtemp", lambda prefix=None: str(scratch)) + monkeypatch.setattr(core, "GIT_READ_TIMEOUT_SECONDS", 0.4) + + with pytest.raises(core.IntegrationBranchError) as caught: + core._build_candidate(repo, start_sha=head, sources=[]) + + assert not scratch.exists(), "cleanup stopped short of removing the directory" + notes = getattr(caught.value, "__notes__", []) + assert any("registration could not be removed" in note for note in notes), ( + "an unconfirmed registration was reported as clean" + )