diff --git a/backend/app/config.py b/backend/app/config.py index 3f2f78c..16311eb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -92,6 +92,9 @@ def for_testing(cls, **values: Any) -> Settings: default=1 * 1024 * 1024, validation_alias="HIVE_TASK_MAX_STRUCTURED_TEXT_BYTES" ) cas_zstd_level: int = Field(default=3, validation_alias="HIVE_CAS_ZSTD_LEVEL") + repository_git_timeout_seconds: float = Field( + default=30.0, ge=5.0, le=120.0, validation_alias="HIVE_REPOSITORY_GIT_TIMEOUT_SECONDS" + ) repository_max_files: int = Field(default=10_000, validation_alias="HIVE_REPOSITORY_MAX_FILES") repository_max_file_bytes: int = Field( default=10 * 1024 * 1024, validation_alias="HIVE_REPOSITORY_MAX_FILE_BYTES" diff --git a/backend/app/context_manager.py b/backend/app/context_manager.py index 303055e..9c8c8a8 100644 --- a/backend/app/context_manager.py +++ b/backend/app/context_manager.py @@ -2347,7 +2347,10 @@ def _assert_delta_target_stable( except ContextStaleError as exc: if exc.code == "repository_source_changed": try: - current_head = _git_head(state.snapshot.project_path) + current_head = _git_head( + state.snapshot.project_path, + timeout_seconds=settings.repository_git_timeout_seconds, + ) except (OSError, RepositoryIndexingError, RuntimeError): raise if current_head.lower() != state.snapshot.repository_head_sha.lower(): diff --git a/backend/app/execution_orchestrator.py b/backend/app/execution_orchestrator.py index 586283a..44db4a6 100644 --- a/backend/app/execution_orchestrator.py +++ b/backend/app/execution_orchestrator.py @@ -435,7 +435,11 @@ def __init__( self.project_loader = project_loader or get_project self.task_loader = task_loader or get_task self.context_builder = context_builder or context_manager.build_context - self.repository_inspector = repository_inspector or inspect_project + self.repository_inspector = repository_inspector or ( + lambda workspace: inspect_project( + workspace, timeout_seconds=self.settings.repository_git_timeout_seconds + ) + ) default_seams = all( seam is None for seam in ( diff --git a/backend/app/project_discovery.py b/backend/app/project_discovery.py index d38588d..be545cc 100644 --- a/backend/app/project_discovery.py +++ b/backend/app/project_discovery.py @@ -93,7 +93,9 @@ def scan_immediate_git_repositories(settings: Settings) -> tuple[list[ProjectCan continue if any(os.path.samefile(resolved_path, prior) for prior in seen_paths): continue - inspection = inspect_project(resolved_path) + inspection = inspect_project( + resolved_path, timeout_seconds=settings.repository_git_timeout_seconds + ) except (OSError, RuntimeError, ValueError, ProjectPathError): continue if ( diff --git a/backend/app/registry.py b/backend/app/registry.py index f5455e8..897bdd9 100644 --- a/backend/app/registry.py +++ b/backend/app/registry.py @@ -21,7 +21,7 @@ # Git status on a Windows-backed Docker bind mount can take several seconds for # a moderate repository. Keep inspection bounded without treating ordinary I/O # variance as an inaccessible project. -GIT_TIMEOUT_SECONDS = 15 +GIT_TIMEOUT_SECONDS = 30.0 SHA_PATTERN = re.compile(r"^[0-9a-fA-F]{40,64}$") REGISTRY_ADVISORY_LOCK = (12002, 1) @@ -195,7 +195,11 @@ def _detect_languages(project_path: Path) -> list[str]: def _run_git( - project_path: Path, arguments: list[str], *, allow_nonzero: bool = False + project_path: Path, + arguments: list[str], + *, + allow_nonzero: bool = False, + timeout_seconds: float = GIT_TIMEOUT_SECONDS, ) -> subprocess.CompletedProcess[str]: command = [ "git", @@ -217,7 +221,7 @@ def _run_git( cwd=None, env=environment, text=True, - timeout=GIT_TIMEOUT_SECONDS, + timeout=timeout_seconds, shell=False, ) except FileNotFoundError as exc: @@ -229,7 +233,9 @@ def _run_git( return result -def inspect_project(project_path: Path) -> InspectionResult: +def inspect_project( + project_path: Path, *, timeout_seconds: float = GIT_TIMEOUT_SECONDS +) -> InspectionResult: try: if not project_path.exists() or not project_path.is_dir(): return InspectionResult( @@ -256,11 +262,15 @@ def inspect_project(project_path: Path) -> InspectionResult: language_stack = _detect_languages(project_path) try: - inside_work_tree = _run_git(project_path, ["rev-parse", "--is-inside-work-tree"]) + inside_work_tree = _run_git( + project_path, ["rev-parse", "--is-inside-work-tree"], timeout_seconds=timeout_seconds + ) if inside_work_tree.stdout.strip().lower() != "true": return _failure_result("not_a_git_repository", language_stack) - head = _run_git(project_path, ["rev-parse", "--verify", "HEAD"]).stdout.strip() + head = _run_git( + project_path, ["rev-parse", "--verify", "HEAD"], timeout_seconds=timeout_seconds + ).stdout.strip() if not SHA_PATTERN.fullmatch(head): return _failure_result("git_head_unavailable", language_stack) @@ -268,6 +278,7 @@ def inspect_project(project_path: Path) -> InspectionResult: project_path, ["symbolic-ref", "--quiet", "--short", "HEAD"], allow_nonzero=True, + timeout_seconds=timeout_seconds, ) if branch_result.returncode == 0: branch = branch_result.stdout.strip() or None @@ -278,7 +289,11 @@ def inspect_project(project_path: Path) -> InspectionResult: else: return _failure_result("git_branch_unavailable", language_stack) - status = _run_git(project_path, ["status", "--porcelain=v1", "--untracked-files=no"]) + status = _run_git( + project_path, + ["status", "--porcelain=v1", "--untracked-files=no", "--ignore-submodules=dirty"], + timeout_seconds=timeout_seconds, + ) return InspectionResult( git_branch=branch, git_head_sha=head.lower(), @@ -387,7 +402,9 @@ def get_project(settings: Settings, project_id: UUID) -> ProjectResponse | None: def register_project(settings: Settings, request: ProjectCreateRequest) -> ProjectResponse: identity, resolved_path = normalize_project_path(request.relative_path, settings) - inspection = inspect_project(resolved_path) + inspection = inspect_project( + resolved_path, timeout_seconds=settings.repository_git_timeout_seconds + ) project_id = uuid4() try: with database_connection(settings) as connection, connection.cursor() as cursor: @@ -459,7 +476,9 @@ def inspect_registered_project(settings: Settings, project_id: UUID) -> ProjectR inspection = _blocked_result("physical_identity_conflict") canonical_identity = existing.relative_path else: - inspection = inspect_project(resolved_path) + inspection = inspect_project( + resolved_path, timeout_seconds=settings.repository_git_timeout_seconds + ) _update_inspection(cursor, project_id, canonical_identity, inspection) row = cursor.fetchone() return _project_from_row(row) if row else None diff --git a/backend/app/repository_indexer.py b/backend/app/repository_indexer.py index c271532..32b3b05 100644 --- a/backend/app/repository_indexer.py +++ b/backend/app/repository_indexer.py @@ -27,7 +27,7 @@ from .registry import ProjectPathError, normalize_project_path from .telemetry import emit_event -GIT_TIMEOUT_SECONDS = 5 +GIT_TIMEOUT_SECONDS = 30.0 SHA_PATTERN = re.compile(r"^[0-9a-f]{40,64}$") INDEX_ADVISORY_LOCK_KEY = 12005 logger = logging.getLogger(__name__) @@ -228,6 +228,7 @@ def _run_git( arguments: list[str], *, allow_nonzero: bool = False, + timeout_seconds: float = GIT_TIMEOUT_SECONDS, ) -> subprocess.CompletedProcess[bytes]: command = [ "git", @@ -248,7 +249,7 @@ def _run_git( check=False, cwd=None, env=environment, - timeout=GIT_TIMEOUT_SECONDS, + timeout=timeout_seconds, shell=False, ) except FileNotFoundError as exc: @@ -267,9 +268,13 @@ def _decode_git_text(value: bytes, error_code: str) -> str: raise RepositoryIndexingError(error_code) from exc -def _git_head(project_path: Path) -> str: +def _git_head(project_path: Path, *, timeout_seconds: float = GIT_TIMEOUT_SECONDS) -> str: try: - result = _run_git(project_path, ["rev-parse", "--verify", "HEAD^{commit}"]) + result = _run_git( + project_path, + ["rev-parse", "--verify", "HEAD^{commit}"], + timeout_seconds=timeout_seconds, + ) except RepositoryIndexingError as exc: raise RepositoryIndexingError("git_head_unavailable") from exc head = _decode_git_text(result.stdout, "git_head_unavailable").lower() @@ -278,12 +283,13 @@ def _git_head(project_path: Path) -> str: return head -def _git_branch(project_path: Path) -> str | None: +def _git_branch(project_path: Path, *, timeout_seconds: float = GIT_TIMEOUT_SECONDS) -> str | None: try: result = _run_git( project_path, ["symbolic-ref", "--quiet", "--short", "HEAD"], allow_nonzero=True, + timeout_seconds=timeout_seconds, ) except RepositoryIndexingError as exc: raise RepositoryIndexingError("git_branch_unavailable") from exc @@ -294,11 +300,21 @@ def _git_branch(project_path: Path) -> str | None: raise RepositoryIndexingError("git_branch_unavailable") -def _git_status_inventory(project_path: Path) -> tuple[set[str], bytes]: +def _git_status_inventory( + project_path: Path, *, timeout_seconds: float = GIT_TIMEOUT_SECONDS +) -> tuple[set[str], bytes]: try: result = _run_git( project_path, - ["status", "--porcelain=v1", "--untracked-files=no", "-z", "--"], + [ + "status", + "--porcelain=v1", + "--untracked-files=no", + "--ignore-submodules=all", + "-z", + "--", + ], + timeout_seconds=timeout_seconds, ) except RepositoryIndexingError as exc: raise RepositoryIndexingError("git_status_unavailable") from exc @@ -322,12 +338,18 @@ def _inventory_fingerprint(listing: bytes, status: bytes) -> str: return digest.hexdigest() -def _git_inventory_fingerprint(project_path: Path) -> str: +def _git_inventory_fingerprint( + project_path: Path, *, timeout_seconds: float = GIT_TIMEOUT_SECONDS +) -> str: try: - listing = _run_git(project_path, ["ls-files", "--cached", "--stage", "-z", "--"]) + listing = _run_git( + project_path, + ["ls-files", "--cached", "--stage", "-z", "--"], + timeout_seconds=timeout_seconds, + ) except RepositoryIndexingError as exc: raise RepositoryIndexingError("git_inventory_unavailable") from exc - _, status = _git_status_inventory(project_path) + _, status = _git_status_inventory(project_path, timeout_seconds=timeout_seconds) return _inventory_fingerprint(listing.stdout, status) @@ -415,24 +437,34 @@ def _collect_inventory(settings: Settings, project_path: Path) -> _RepositorySna raise RepositoryIndexingError("path_unavailable") try: - inside = _run_git(project_root, ["rev-parse", "--is-inside-work-tree"]) + inside = _run_git( + project_root, + ["rev-parse", "--is-inside-work-tree"], + timeout_seconds=settings.repository_git_timeout_seconds, + ) except RepositoryIndexingError as exc: if exc.code == "git_command_failed": raise RepositoryIndexingError("not_a_git_repository") from exc raise if inside.stdout.strip().lower() != b"true": raise RepositoryIndexingError("not_a_git_repository") - head = _git_head(project_root) - branch = _git_branch(project_root) + head = _git_head(project_root, timeout_seconds=settings.repository_git_timeout_seconds) + branch = _git_branch(project_root, timeout_seconds=settings.repository_git_timeout_seconds) try: - listing = _run_git(project_root, ["ls-files", "--cached", "--stage", "-z", "--"]) + listing = _run_git( + project_root, + ["ls-files", "--cached", "--stage", "-z", "--"], + timeout_seconds=settings.repository_git_timeout_seconds, + ) except RepositoryIndexingError as exc: raise RepositoryIndexingError("git_inventory_unavailable") from exc records = [record for record in listing.stdout.split(b"\0") if record] if len(records) > settings.repository_max_files: raise RepositoryIndexingError("repository_file_count_limit") - status_paths, status_inventory = _git_status_inventory(project_root) + status_paths, status_inventory = _git_status_inventory( + project_root, timeout_seconds=settings.repository_git_timeout_seconds + ) inventory_fingerprint = _inventory_fingerprint(listing.stdout, status_inventory) files: list[_TrackedFile] = [] total_size = 0 @@ -450,10 +482,12 @@ def _collect_inventory(settings: Settings, project_path: Path) -> _RepositorySna raise RepositoryIndexingError("git_inventory_unavailable") from exc if stage != "0": raise RepositoryIndexingError("git_unmerged_entry") - if mode == "160000": - raise RepositoryIndexingError("tracked_submodule_unsupported") if not SHA_PATTERN.fullmatch(blob_sha): raise RepositoryIndexingError("git_inventory_unavailable") + if mode == "160000": + # Gitlink SHA remains in the tracked inventory fingerprint. Never traverse or index + # the nested submodule worktree as source for the enclosing project. + continue try: resolved_path = _resolve_tracked_path(project_root, path) except ProjectPathError as exc: @@ -493,11 +527,22 @@ def _assert_snapshot_stable(settings: Settings, snapshot: _RepositorySnapshot) - source, stamp = _read_stable_file(resolved, settings) if stamp != entry.stamp or hashlib.sha256(source).hexdigest() != entry.content_sha256: raise RepositoryIndexingError("source_mutated_during_index") - if _git_inventory_fingerprint(snapshot.project_path) != snapshot.git_inventory_fingerprint: + if ( + _git_inventory_fingerprint( + snapshot.project_path, timeout_seconds=settings.repository_git_timeout_seconds + ) + != snapshot.git_inventory_fingerprint + ): raise RepositoryIndexingError("git_inventory_changed_during_index") - if _git_head(snapshot.project_path) != snapshot.repository_head_sha: + if ( + _git_head(snapshot.project_path, timeout_seconds=settings.repository_git_timeout_seconds) + != snapshot.repository_head_sha + ): raise RepositoryIndexingError("repository_changed_during_index") - if _git_branch(snapshot.project_path) != snapshot.git_branch: + if ( + _git_branch(snapshot.project_path, timeout_seconds=settings.repository_git_timeout_seconds) + != snapshot.git_branch + ): raise RepositoryIndexingError("repository_changed_during_index") diff --git a/backend/app/retrieval.py b/backend/app/retrieval.py index 268fcb4..92ba83a 100644 --- a/backend/app/retrieval.py +++ b/backend/app/retrieval.py @@ -148,6 +148,8 @@ class _SourceBundle: source_fingerprint: str skipped_binary_count: int skipped_decode_count: int + # Gitlink pointer metadata is captured for race checks, not indexed as content. + gitlink_inventory: tuple[tuple[str, str], ...] = () @dataclass(frozen=True) @@ -524,7 +526,9 @@ def _project_path_and_index_run( return project_path, cast(UUID, index_row[0]), str(index_row[1]), indexed_paths -def _git_output(project_path: Path, arguments: list[str]) -> bytes: +def _git_output( + project_path: Path, arguments: list[str], *, timeout_seconds: float = 30.0 +) -> bytes: environment = os.environ.copy() environment["GIT_OPTIONAL_LOCKS"] = "0" try: @@ -542,7 +546,7 @@ def _git_output(project_path: Path, arguments: list[str]) -> bytes: capture_output=True, check=False, env=environment, - timeout=5, + timeout=timeout_seconds, ) except (FileNotFoundError, subprocess.TimeoutExpired) as exc: raise RetrievalSyncError("repository_inventory_unavailable") from exc @@ -551,6 +555,52 @@ def _git_output(project_path: Path, arguments: list[str]) -> bytes: return result.stdout +def _git_content_inventory( + project_path: Path, *, timeout_seconds: float +) -> tuple[set[str], tuple[tuple[str, str], ...]]: + """Align lexical corpus paths with the indexer while retaining gitlink pointer identity. + + Gitlinks (160000) have no repository_files content row. Their path/commit SHA + are nevertheless frozen across load and promotion to catch an index-stage race. + An unmerged, duplicated or malformed Git inventory always fails closed. + """ + listing = _git_output( + project_path, + ["ls-files", "--cached", "--stage", "-z", "--"], + timeout_seconds=timeout_seconds, + ) + content_paths: set[str] = set() + gitlinks: list[tuple[str, str]] = [] + seen: set[str] = set() + for raw in listing.split(b"\0"): + if not raw: + continue + metadata, sep, raw_path = raw.partition(b"\t") + fields = metadata.split() + if not sep or not raw_path or len(fields) != 3: + raise RetrievalSyncError("repository_inventory_unavailable") + try: + mode = fields[0].decode("ascii") + sha = fields[1].decode("ascii").lower() + stage = fields[2].decode("ascii") + path = raw_path.decode("utf-8") + except UnicodeDecodeError as exc: + raise RetrievalSyncError("repository_inventory_unavailable") from exc + if ( + stage != "0" + or not re.fullmatch(r"[0-7]{6}", mode) + or not re.fullmatch(r"[0-9a-f]{40}(?:[0-9a-f]{24})?", sha) + or path in seen + ): + raise RetrievalSyncError("repository_inventory_unavailable") + seen.add(path) + if mode == "160000": + gitlinks.append((path, sha)) + else: + content_paths.add(path) + return content_paths, tuple(sorted(gitlinks)) + + def _load_repository_sources( settings: Settings, project_id: UUID ) -> tuple[ @@ -563,16 +613,22 @@ def _load_repository_sources( str, tuple[str, ...], tuple[tuple[str, str], ...], + tuple[tuple[str, str], ...], ]: project_path, index_run_id, indexed_head, indexed_paths = _project_path_and_index_run( settings, project_id ) try: - current_head = _git_output(project_path, ["rev-parse", "--verify", "HEAD^{commit}"]) + current_head = _git_output( + project_path, + ["rev-parse", "--verify", "HEAD^{commit}"], + timeout_seconds=settings.repository_git_timeout_seconds, + ) if current_head.decode("ascii").strip().lower() != indexed_head.lower(): raise RetrievalSyncError("repository_index_stale") - inventory = _git_output(project_path, ["ls-files", "--cached", "-z", "--"]) - actual_paths = {raw.decode("utf-8") for raw in inventory.split(b"\0") if raw} + actual_paths, gitlinks = _git_content_inventory( + project_path, timeout_seconds=settings.repository_git_timeout_seconds + ) except UnicodeDecodeError as exc: raise RetrievalSyncError("repository_inventory_unavailable") from exc if actual_paths != indexed_paths: @@ -691,6 +747,7 @@ def _load_repository_sources( indexed_head, tuple(sorted(indexed_paths)), repository_file_hashes, + gitlinks, ) @@ -746,6 +803,7 @@ def _load_source_bundle(settings: Settings, project_id: UUID) -> _SourceBundle: repository_head, repository_inventory, repository_file_hashes, + gitlinks, ) = _load_repository_sources(settings, project_id) task_sources, task_fingerprint = _load_task_sources(settings, project_id) # The corpus source fingerprint is material content identity. The @@ -767,26 +825,34 @@ def _load_source_bundle(settings: Settings, project_id: UUID) -> _SourceBundle: source_fingerprint, skipped_binary, skipped_decode, + gitlinks, ) def _revalidate_bundle(settings: Settings, bundle: _SourceBundle) -> None: try: current_head = ( - _git_output(bundle.repository_path, ["rev-parse", "--verify", "HEAD^{commit}"]) + _git_output( + bundle.repository_path, + ["rev-parse", "--verify", "HEAD^{commit}"], + timeout_seconds=settings.repository_git_timeout_seconds, + ) .decode("ascii") .strip() .lower() ) - inventory_bytes = _git_output(bundle.repository_path, ["ls-files", "--cached", "-z", "--"]) - current_inventory = tuple( - sorted(raw.decode("utf-8") for raw in inventory_bytes.split(b"\0") if raw) + content_paths, current_gitlinks = _git_content_inventory( + bundle.repository_path, timeout_seconds=settings.repository_git_timeout_seconds ) + current_inventory = tuple(sorted(content_paths)) except (RetrievalSyncError, UnicodeDecodeError) as exc: raise RetrievalSyncError("repository_source_stale") from exc if current_head != bundle.repository_head.lower(): raise RetrievalSyncError("repository_source_stale") - if current_inventory != bundle.repository_inventory: + if ( + current_inventory != bundle.repository_inventory + or current_gitlinks != bundle.gitlink_inventory + ): raise RetrievalSyncError("repository_source_stale") for path, expected_hash in bundle.repository_file_hashes: diff --git a/backend/tests/test_context_manager.py b/backend/tests/test_context_manager.py index 50f41cb..90b4968 100644 --- a/backend/tests/test_context_manager.py +++ b/backend/tests/test_context_manager.py @@ -1094,7 +1094,13 @@ def test_delta_final_stability_preserves_precise_race_reason( ) -> None: patch_build_dependencies(monkeypatch) target = context_manager.build_context(Settings(), PROJECT_ID, TASK_ID) - monkeypatch.setattr(context_manager, "_git_head", lambda _path: current_head) + observed_timeouts: list[float] = [] + + def capture_head(_path: Path, *, timeout_seconds: float) -> str: + observed_timeouts.append(timeout_seconds) + return current_head + + monkeypatch.setattr(context_manager, "_git_head", capture_head) monkeypatch.setattr( context_manager, "_assert_state_stable", @@ -1104,9 +1110,12 @@ def test_delta_final_stability_preserves_precise_race_reason( ) with pytest.raises(context_manager.ContextStaleError) as caught: - context_manager._assert_delta_target_stable(Settings(), PROJECT_ID, TASK_ID, target) + context_manager._assert_delta_target_stable( + Settings.for_testing(repository_git_timeout_seconds=45), PROJECT_ID, TASK_ID, target + ) assert caught.value.code == expected_code + assert observed_timeouts == [45.0] def test_context_api_accepts_project_and_task_ids_without_query_assembly( diff --git a/backend/tests/test_execution_orchestrator.py b/backend/tests/test_execution_orchestrator.py index beeda99..2d4b67a 100644 --- a/backend/tests/test_execution_orchestrator.py +++ b/backend/tests/test_execution_orchestrator.py @@ -453,3 +453,31 @@ def counted(request_value: ExecutorRequest) -> tuple[ExecutionIdentity, ProjectR assert result.status == "STAGED" assert calls == 1 + + +def test_default_execution_inspector_uses_bounded_configured_git_timeout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + observed: list[tuple[Path, float]] = [] + + def capture(path: Path, *, timeout_seconds: float) -> InspectionResult: + observed.append((path, timeout_seconds)) + return inspection() + + monkeypatch.setattr("app.execution_orchestrator.inspect_project", capture) + settings = Settings.for_testing(projects_root=tmp_path, repository_git_timeout_seconds=45) + orchestrator = ExecutionOrchestrator(settings=settings) + assert orchestrator.repository_inspector(tmp_path) == inspection() + assert observed == [(tmp_path, 45.0)] + + # A user-injected inspector still receives one Path argument. + injected_paths: list[Path] = [] + + def injected(path: Path) -> InspectionResult: + injected_paths.append(path) + return inspection() + + custom = ExecutionOrchestrator(settings=settings, repository_inspector=injected) + assert custom.repository_inspector(tmp_path) == inspection() + assert injected_paths == [tmp_path] + assert observed == [(tmp_path, 45.0)] diff --git a/backend/tests/test_project_discovery.py b/backend/tests/test_project_discovery.py index 502163a..ac91336 100644 --- a/backend/tests/test_project_discovery.py +++ b/backend/tests/test_project_discovery.py @@ -66,3 +66,24 @@ def test_symlink_project_paths_are_rejected(tmp_path: Path) -> None: assert candidates == [] with pytest.raises(ProjectPathError, match="resolves outside"): normalize_project_path("linked-project", settings) + + +def test_discovery_forwards_configured_git_timeout_to_registry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + import app.project_discovery as discovery + from app.registry import inspect_project as inspect_actual + + repository = tmp_path / "sample" + create_repository(repository) + settings = Settings.for_testing(projects_root=tmp_path, repository_git_timeout_seconds=45) + observed: list[float] = [] + + def capture(path: Path, *, timeout_seconds: float) -> object: + observed.append(timeout_seconds) + return inspect_actual(path, timeout_seconds=timeout_seconds) + + monkeypatch.setattr(discovery, "inspect_project", capture) + candidates, _examined = scan_immediate_git_repositories(settings) + assert any(candidate.relative_path == "sample" for candidate in candidates) + assert observed == [45.0] diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py index eb310f4..6e1b7ee 100644 --- a/backend/tests/test_registry.py +++ b/backend/tests/test_registry.py @@ -145,7 +145,7 @@ def fake_run(command: list[str], **kwargs: object) -> subprocess.CompletedProces assert f"safe.directory={repository}" in command assert "safe.directory=*" not in command assert kwargs["shell"] is False - assert kwargs["timeout"] == GIT_TIMEOUT_SECONDS == 15 + assert kwargs["timeout"] == GIT_TIMEOUT_SECONDS == 30 environment = kwargs["env"] assert isinstance(environment, dict) assert environment["GIT_OPTIONAL_LOCKS"] == "0" @@ -199,3 +199,34 @@ def timeout(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[st assert result.state is ProjectState.DEGRADED assert result.inspection_error == "git_timeout" + + +def test_registry_inspection_uses_configured_git_timeout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + repository = create_git_repository(tmp_path) + settings = Settings.for_testing(projects_root=tmp_path, repository_git_timeout_seconds=45) + original_run = subprocess.run + observed: list[float] = [] + + def capture(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + observed.append(float(kwargs["timeout"])) + return cast(subprocess.CompletedProcess[str], original_run(command, **kwargs)) + + monkeypatch.setattr("app.registry.subprocess.run", capture) + result = inspect_project(repository, timeout_seconds=settings.repository_git_timeout_seconds) + assert result.state is ProjectState.READY + assert observed == [45.0] * 4 + + +def test_registry_staged_gitlink_pointer_is_not_mistaken_for_clean_tree( + tmp_path: Path, +) -> None: + repository = create_git_repository(tmp_path) + run_git( + repository, + ["update-index", "--add", "--cacheinfo", "160000," + "a" * 40 + ",vendor/gef-bootstrap"], + ) + result = inspect_project(repository) + assert result.repository_accessible is True + assert result.working_tree_clean is False diff --git a/backend/tests/test_repository_indexer.py b/backend/tests/test_repository_indexer.py index 2e914d8..b62885e 100644 --- a/backend/tests/test_repository_indexer.py +++ b/backend/tests/test_repository_indexer.py @@ -4,11 +4,13 @@ import pytest +import app.repository_indexer as indexer from app.config import Settings from app.repository_indexer import ( RepositoryIndexingError, _assert_snapshot_stable, _collect_inventory, + _git_inventory_fingerprint, parse_python_symbols, ) @@ -149,3 +151,60 @@ def test_tracked_symlink_escape_fails_before_reading_outside_bytes(tmp_path: Pat _collect_inventory(Settings(projects_root=tmp_path), repository) assert error.value.code == "tracked_path_unsafe" + + +def test_repository_git_timeout_is_configurable_and_bounded( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + settings = Settings.for_testing(projects_root=tmp_path, repository_git_timeout_seconds=45) + assert settings.repository_git_timeout_seconds == 45 + with pytest.raises(ValueError): + Settings.for_testing(projects_root=tmp_path, repository_git_timeout_seconds=4) + with pytest.raises(ValueError): + Settings.for_testing(projects_root=tmp_path, repository_git_timeout_seconds=121) + + observed: dict[str, object] = {} + original_run = subprocess.run + + def observe(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]: + observed["timeout"] = kwargs["timeout"] + observed["command"] = command + return subprocess.CompletedProcess(command, 0, b"true\n", b"") + + monkeypatch.setattr(subprocess, "run", observe) + try: + indexer._run_git(tmp_path, ["rev-parse", "--is-inside-work-tree"], timeout_seconds=45) + assert observed["timeout"] == 45 + command = observed["command"] + assert isinstance(command, list) + assert command[-2:] == ["rev-parse", "--is-inside-work-tree"] + finally: + monkeypatch.setattr(subprocess, "run", original_run) + + +def test_gitlinks_are_not_traversed_and_pointer_changes_invalidate_snapshot(tmp_path: Path) -> None: + repository = tmp_path / "gitlink" + repository.mkdir() + subprocess.run(["git", "init", "-b", "main", str(repository)], check=True, capture_output=True) + git(repository, ["config", "user.email", "test@example.invalid"]) + git(repository, ["config", "user.name", "HIVE Tests"]) + (repository / "tracked.py").write_text("def safe():\n return True\n", encoding="utf-8") + git(repository, ["add", "tracked.py"]) + git(repository, ["commit", "-m", "initial"]) + git( + repository, + ["update-index", "--add", "--cacheinfo", "160000," + "a" * 40 + ",vendor/gef-bootstrap"], + ) + settings = Settings.for_testing(projects_root=tmp_path) + snapshot = _collect_inventory(settings, repository) + assert [entry.path for entry in snapshot.files] == ["tracked.py"] + initial_fingerprint = snapshot.git_inventory_fingerprint + assert initial_fingerprint == _git_inventory_fingerprint(repository) + git( + repository, + ["update-index", "--add", "--cacheinfo", "160000," + "b" * 40 + ",vendor/gef-bootstrap"], + ) + assert _git_inventory_fingerprint(repository) != initial_fingerprint + with pytest.raises(RepositoryIndexingError) as changed: + _assert_snapshot_stable(settings, snapshot) + assert changed.value.code == "git_inventory_changed_during_index" diff --git a/backend/tests/test_retrieval.py b/backend/tests/test_retrieval.py index 93a0fd2..0f4e02e 100644 --- a/backend/tests/test_retrieval.py +++ b/backend/tests/test_retrieval.py @@ -1,7 +1,9 @@ import hashlib import subprocess +from dataclasses import replace as dataclass_replace from pathlib import Path -from uuid import UUID +from unittest.mock import MagicMock +from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient @@ -193,3 +195,115 @@ def test_sync_missing_project_is_stable(monkeypatch: pytest.MonkeyPatch) -> None assert response.status_code == 404 assert response.json() == {"detail": "project not found"} + + +def test_gitlink_excluded_from_content_inventory_and_pointer_race_rejected( + tmp_path: Path, +) -> None: + repository = tmp_path / "gitlink-repo" + repository.mkdir() + git(repository, "init", "-b", "main") + git(repository, "config", "user.email", "hive-test@example.invalid") + git(repository, "config", "user.name", "HIVE Test") + (repository / "tracked.txt").write_text("stable\n", encoding="utf-8") + git(repository, "add", "tracked.txt") + git(repository, "commit", "-m", "baseline") + git( + repository, + "update-index", + "--add", + "--cacheinfo", + "160000," + "a" * 40 + ",vendor/gef-bootstrap", + ) + head = git(repository, "rev-parse", "HEAD") + actual_paths, gitlinks = retrieval._git_content_inventory(repository, timeout_seconds=30) + assert actual_paths == {"tracked.txt"} + assert gitlinks == (("vendor/gef-bootstrap", "a" * 40),) + bundle = dataclass_replace( + source_bundle(repository, head, ("tracked.txt",)), + gitlink_inventory=gitlinks, + ) + retrieval._revalidate_bundle(Settings(), bundle) + git( + repository, + "update-index", + "--add", + "--cacheinfo", + "160000," + "b" * 40 + ",vendor/gef-bootstrap", + ) + with pytest.raises(retrieval.RetrievalSyncError, match="repository_source_stale"): + retrieval._revalidate_bundle(Settings(), bundle) + + +def test_gitlink_corpus_preflight_matches_indexed_content( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repository = tmp_path / "gitlink-preflight" + repository.mkdir() + git(repository, "init", "-b", "main") + git(repository, "config", "user.email", "hive-test@example.invalid") + git(repository, "config", "user.name", "HIVE Test") + (repository / "tracked.txt").write_text("stable\n", encoding="utf-8") + git(repository, "add", "tracked.txt") + git(repository, "commit", "-m", "baseline") + git( + repository, + "update-index", + "--add", + "--cacheinfo", + "160000," + "a" * 40 + ",vendor/gef-bootstrap", + ) + project_id, index_run_id, file_id = uuid4(), uuid4(), uuid4() + head = git(repository, "rev-parse", "HEAD") + monkeypatch.setattr( + retrieval, + "_project_path_and_index_run", + lambda _settings, _id: (repository, index_run_id, head, {"tracked.txt"}), + ) + data = (repository / "tracked.txt").read_bytes() + file_row = (file_id, "tracked.txt", hashlib.sha256(data).hexdigest(), "text") + cursor = MagicMock() + cursor.fetchall.side_effect = [[file_row], []] + connection = MagicMock() + connection.cursor.return_value.__enter__.return_value = cursor + database = MagicMock() + database.__enter__.return_value = connection + monkeypatch.setattr(retrieval, "database_connection", lambda _settings: database) + + output = retrieval._load_repository_sources(Settings(), project_id) + assert output[1] == index_run_id + assert output[6] == head + assert output[7] == ("tracked.txt",) + assert output[9] == (("vendor/gef-bootstrap", "a" * 40),) + + +def test_staged_inventory_rejects_unmerged_duplicate_and_malformed_records( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + sha = b"a" * 40 + for raw in ( + b"100644 " + sha + b" 1\ttracked.txt\0", + b"broken-record\0", + (b"100644 " + sha + b" 0\ttracked.txt\0") * 2, + ): + monkeypatch.setattr(retrieval, "_git_output", lambda *_args, raw=raw, **_kwargs: raw) + with pytest.raises(retrieval.RetrievalSyncError, match="repository_inventory_unavailable"): + retrieval._git_content_inventory(tmp_path, timeout_seconds=30) + + +def test_retrieval_git_respects_bounded_timeout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + observed: dict[str, object] = {} + + def fake_run( + command: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[bytes]: + observed["timeout"] = kwargs["timeout"] + return subprocess.CompletedProcess(command, 0, b"good\n", b"") + + monkeypatch.setattr(subprocess, "run", fake_run) + result = retrieval._git_output(tmp_path, ["rev-parse", "HEAD"], timeout_seconds=60) + assert result == b"good\n" + assert observed["timeout"] == 60 diff --git a/backend/tests/test_review_evidence.py b/backend/tests/test_review_evidence.py index e26fa7d..a181308 100644 --- a/backend/tests/test_review_evidence.py +++ b/backend/tests/test_review_evidence.py @@ -14606,3 +14606,22 @@ def test_wo031_corrective_scope_is_exact_and_base_bound() -> None: def test_wo031_is_registered_and_requires_authorized_base_marker() -> None: review_evidence.require_supported_work_order(review_evidence.WO031_WORK_ORDER) assert review_evidence.WO031_WORK_ORDER in review_evidence.AUTHORIZED_BASE_MARKER_WORK_ORDERS + + +def test_hive_fv_001_is_explicitly_registered_without_future_wildcard() -> None: + review_evidence.require_supported_work_order("HIVE-FV-001") + review_evidence.require_current_work_order_authorization("HIVE-FV-001") + assert "HIVE-FV-001" in review_evidence.REGISTERED_WORK_ORDERS + for unsupported in ("HIVE-FV-001-C1", "HIVE-FV-002", "HIVE-REL-013"): + assert unsupported not in review_evidence.REGISTERED_WORK_ORDERS + with pytest.raises(ValueError, match="unsupported"): + review_evidence.require_supported_work_order(unsupported) + + +def test_hive_fv_001_marker_parses_without_wildcard() -> None: + marker = "" + assert review_evidence.parse_work_order_marker(marker) == "HIVE-FV-001" + review_evidence.require_current_work_order_authorization("HIVE-FV-001") + for bad in ("HIVE-FV-002", "HIVE-FV-001-C1", "HIVE-REL-013"): + with pytest.raises(ValueError, match="unsupported"): + review_evidence.require_current_work_order_authorization(bad) diff --git a/docker-compose.yml b/docker-compose.yml index 0ca2508..5d5eb3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,7 @@ services: HIVE_CONTEXT_DIAGNOSTICS: ${HIVE_CONTEXT_DIAGNOSTICS:-false} HIVE_DATA_ROOT: /var/lib/hive HIVE_PROJECTS_ROOT: /workspace/projects + HIVE_REPOSITORY_GIT_TIMEOUT_SECONDS: ${HIVE_REPOSITORY_GIT_TIMEOUT_SECONDS:-30} HIVE_DELTA_CONTEXT_RACE_CONTROL: ${HIVE_DELTA_CONTEXT_RACE_CONTROL:-} HIVE_EMBEDDING_ENABLED: ${HIVE_EMBEDDING_ENABLED:-false} HIVE_EMBEDDING_BASE_URL: ${HIVE_EMBEDDING_BASE_URL:-} @@ -149,6 +150,7 @@ services: HIVE_ENVIRONMENT: ${HIVE_ENVIRONMENT:-development} HIVE_DATA_ROOT: /var/lib/hive HIVE_PROJECTS_ROOT: /workspace/projects + HIVE_REPOSITORY_GIT_TIMEOUT_SECONDS: ${HIVE_REPOSITORY_GIT_TIMEOUT_SECONDS:-30} HIVE_DELTA_CONTEXT_RACE_CONTROL: ${HIVE_DELTA_CONTEXT_RACE_CONTROL:-} HIVE_EMBEDDING_ENABLED: ${HIVE_EMBEDDING_ENABLED:-false} HIVE_EMBEDDING_BASE_URL: ${HIVE_EMBEDDING_BASE_URL:-} diff --git a/docs/maintenance/HIVE-FV-001.md b/docs/maintenance/HIVE-FV-001.md new file mode 100644 index 0000000..740b7f7 --- /dev/null +++ b/docs/maintenance/HIVE-FV-001.md @@ -0,0 +1,49 @@ +# HIVE-FV-001 | Windows-mounted Git indexer maintenance +Issue: https://github.com/KayzenRoot/hive/issues/168; consumer: https://github.com/KayzenRoot/fairview/issues/1 +Base: `b7f5bd8a9c9c1737c64412ebe481c9a70d9ecfc5`, descendant of immutable published HIVE v1.0.3 tag. Scope: no public API change, no database migration, no altered deployment of existing HIVE on the user's machine. + +## Repro evidence (external, local R5 report) +Fairview registered READY with HIVE v1.0.3 on an isolated Docker Compose project, read-only `D:/Projects` mount. A Git status command took 6.87 s in Docker Desktop Windows bind mount; `backend/app/repository_indexer.py` hardcodes 5 s, leading to `git_status_unavailable`. The Fairview repository tracks GEF as a Git submodule (mode 160000); this indexer currently rejects any gitlink, so a timeout-only fix would reveal another failure. Host symptoms originate from an operator-provided screenshot and have not been replayed on that host by this PR. + +## Authorized candidate patch +- Add typed, bounded `HIVE_REPOSITORY_GIT_TIMEOUT_SECONDS` setting (5-120 s, default 30 s), forward through all repository-indexer Git invocations including stability revalidation. Include Compose API/optional executor environment passthrough. +- Pass `--ignore-submodules=all` for status used by indexed project and for Registry inspection. This avoids recursing into submodule worktrees mounted from Windows. Submodule pointer (gitlink SHA) must remain part of the `ls-files --stage -z` inventory fingerprint; status of the nested worktree is intentionally excluded. +- Safely skip mode 160000 in file-content indexing. Never read or recurse into the submodule; optionally register its own checkout as a separate read-only project. Keep fail-closed symlink/traversal checks, races, and bounds. +- Add deterministic focused tests proving timeout parameter propagation, validation range, gitlink-content skip and pointer-change fingerprint invalidation; preserve existing regression tests. + +## Test/evidence +Run focused `pytest backend/tests/test_repository_indexer.py backend/tests/test_registry.py`, ruff/mypy for changed backend files, config/unit tests and `python scripts/validate.py` / HIVE required hosted Validate, Integration health and Review Evidence according to HIVE ADR-019/020. Exact-head owner self-audit is not independent. If existing hosted gates are unrelated-red or this maintenance scope lacks governance registration, mark BLOCKED and do not bypass protected main. Never claim local Windows completion from hosted Linux checks. + +## Deployment and STOP CONDITION +Never mutate the existing `D:/HIVE` installation, change its Compose project, mount paths or migrate its volumes. A candidate HIVE fix on this branch is **not a published v1.0.4**; only an accepted immutable commit may be pinned by a separate Fairview Work Order with truthful maintenance labeling and isolated-only upgrade/rebuild safeguards. Before any host upgrade, prove isolation, snapshot the isolated data, check mounted project access, compare upstream commit SHA and rerun REAL Fairview READY/index/corpus/lexical+hybrid and optional semantic CURRENT only if a real embedding provider is configured. HIVE MCP handshake must be verified separately. Stop if actual branch, project root, dataset ownership, mount access or provenance differs. + + +## R8 actual-host correction: retrieval preflight must match gitlink-safe indexer + +Actual isolated Fairview R8 execution (operator's sanitized 3-page PDF dated 2026-09-28; raw Evidence Bundle retained off Git) independently exposed an integration seam omitted by the original narrow code patch. The operator observed clean Fairview SHA `a1495aed386c3990a31c2ae6a48b9be149150ffe`, index COMPLETED (78 content files) and corpus BLOCKED `repository_index_stale` despite the same HEAD. Exact Git staged inventory has 79 entries; the only missing content row is `vendor/gef-bootstrap` (160000 gitlink). `retrieval.py` currently uses the unfiltered `git ls-files --cached -z` at both corpus preload and final revalidation; its 5s timeout also contradicts this patch's configurable 30s indexer timeout. These are necessary fixes within the already admitted gitlink/Windows Git compatibility scope; the indexer correctly DOES NOT create a content row for a gitlink. + +Authorized bounded implementation in the EXISTING HIVE-FV-001 PR: enumerate Git staged inventory, reject malformed/unmerged metadata and Unicode failures, exclude mode 160000 only from *content-path* equality, but capture all gitlink path+pointer SHA and detect their mutation between corpus load and promotion. Real file additions/removals, head drift and source-byte races remain fail-closed. Use the bounded `Settings.repository_git_timeout_seconds` on each retrieval Git call. Add focused real-Git regression that first proves content-path preflight excludes an unchanged gitlink, then proves gitlink pointer change, real tracked-path additions and malformed/unmerged inventory all fail closed. Do not alter schemas, migrations, persistent corpus state, cloud/model providers, Compose beyond the existing candidate or the global HIVE instance. + +At the time of the R8 host report, Review Evidence did not recognize `HIVE-FV-001`, even though issue #168 and PR #169 authorized the maintenance patch. This PR now registers ONLY that exact identifier in the closed registry and parser with negative near-miss regression; the prior exact-head CI `36439052982` verified Validate and Integration health PASS, and Review Evidence resolved the marker but stopped at its mandatory **Ready pull request** check because the PR event was Draft. The PR has since been explicitly changed to Ready for review. This documentation-only correction creates a new exact-head PR synchronize event so all mandatory checks, including Review Evidence, must re-run against a Ready event. Do not interpret the earlier blocked Review Evidence or the Ready transition alone as proof of approval. Keep all other Work Order rejections, promotion boundaries, protected-main checks and the requirement for exact-head self-audit, which is NOT independent. No merge/release until Validate, Integration health and Review Evidence PASS, authorized HEAD reviewed, and unrelated WO-032/#163 protected. Host recovery still needs a CURRENT fresh backup, exclusive writer window, build provenance and only an explicitly authorized conditional corpus retry; source CI cannot establish local R8 FULL. No speculative reindex or second semantic sync in the user's host. + +## Exact-marker parser corrective gate +The current PR Review Evidence job proved that closed `REGISTERED_WORK_ORDERS` registration alone is insufficient: the older bounded PR work-order *syntax parser* and the local manifest parser reject the explicitly admitted `HIVE-FV-001` token before consulting the registry. Correct ONLY those two exact-token paths, not the identifier wildcard, and add regression for `parse_work_order_marker` accepting the exact approved PR token and rejecting unsupported near misses via `require_current_work_order_authorization`. Keep `HIVE-REL-013` unregistered and release publication blocked. No alteration of actual reviewer requirements, validation evidence checks, protected main or cross-Work-Order privileges. + + +## Ready-review P1 correction: registry timeout and staged gitlink pointer + +Review of the SAME HIVE-FV-001 PR in Ready state identified two HIGH/P1 defects in the original Windows Git compatibility patch. This is a bounded correction INSIDE the authorized maintenance Work Order and does not authorize HIVE-REL-013, WO-032, migrations, release or local host mutations. + +- Registry currently uses a fixed `GIT_TIMEOUT_SECONDS=15`, even though the candidate indexer/retrieval uses bounded `Settings.repository_git_timeout_seconds` (5-120 s, default 30 s). Forward the exact typed value through the registry's `register_project`, `inspect_registered_project`, and auto-discovery's pre-registration inspect; preserve the bounded default for standalone direct callers. Fail closed on a true Git timeout. Add deterministic tests proving a configured 45-second timeout reaches all four Git calls and that range validation is unchanged. +- `git status --ignore-submodules=all` also hides staged changes to the superproject's gitlink pointer, incorrectly labeling it clean. Use `--ignore-submodules=dirty` to ignore nested submodule working-tree changes while observing the staged superproject gitlink pointer. Prove it with an actual Git fixture: a staged `160000` pointer mutation must return `working_tree_clean=false`. Preserve the indexer's staged pointer fingerprint and retrieval's final gitlink pointer race checks. + +Run focused registry/discovery/index/retrieval/governance tests and all exact-head CI gates; use the actual PR Ready event and exact authorized-base SHA marker. Both defects are SELF_HEALABLE source corrections. Do not weaken Reviewed Evidence or the security/authoritative-HEAD gates. STOP if tests reveal unexpected registry API changes, host state dependence, or HIGH/CRITICAL defects requiring a broader Work Order. + + +## Ready-review P1 follow-up: default execution inspection timeout + +The same typed, bounded Git timeout must also reach `ExecutionOrchestrator`'s default repository-inspection preflight. After the preceding registry fix, a further automated review observed that `ExecutionOrchestrator.repository_inspector` was still initialized to the bare `inspect_project` function and therefore silently used its default 30s instead of `self.settings.repository_git_timeout_seconds` when an operator explicitly configured, for example, 45s. Fix only the **default** seam to bind the already-supplied orchestrator Settings, without changing the public single-argument `RepositoryInspector` injection contract or allowing arbitrary timeout input. Add a deterministic injected/default test showing the real configured value is forwarded and an explicitly injected inspector still receives only `Path`. Preserve the fail-closed inspection error behavior; no runtime host data or WO-032/WO-036 scope changes. Tests and exact HEAD Review Evidence remain mandatory. + +## Final Ready review: Context Manager delta HEAD recheck + +CodeRabbit's minor functional finding on PR #169 identifies one remaining source-identity caller within this Work Order's configured Git timeout scope: `context_manager._assert_delta_target_stable` rechecks the HEAD with `_git_head(project_path)` and therefore uses the default 30s instead of the bounded `settings.repository_git_timeout_seconds` during an otherwise valid slow Windows bind mount. Forward the existing typed bounded Settings value in this final fail-closed race recheck. Extend its existing precise-race-reason test to assert an explicit 45-second configured timeout is forwarded while preserving the `project_head_stale` and `repository_source_changed` distinctions. This is not authorization for other Context Manager changes, unrelated cleanup, HIVE release or host mutation. Final acceptance remains exact-head Validate, Integration health, Review Evidence, CodeQL, Dependency Review, resolved review threads and transparent OWNER_SELF_AUDIT (not independent), then governed squash and exact-main postmerge CI. diff --git a/scripts/review_evidence.py b/scripts/review_evidence.py index 1fb1cdd..5dc53b7 100644 --- a/scripts/review_evidence.py +++ b/scripts/review_evidence.py @@ -3228,6 +3228,7 @@ def parse_work_order_marker(body: str) -> str: and GEF_WORK_ORDER_IDENTIFIER.fullmatch(work_order) is None and HIVE_REL_WORK_ORDER_IDENTIFIER.fullmatch(work_order) is None and RELEASE_TRAIN_WORK_ORDER_IDENTIFIER.fullmatch(work_order) is None + and work_order != "HIVE-FV-001" ): raise ValueError(f"invalid or unbounded HIVE work-order identifier: {work_order!r}") return work_order @@ -3375,6 +3376,9 @@ def authorized_base_marker_sha(work_order: str, pr_body: str) -> str | None: WO031_WORK_ORDER, "WO-032", "WO-033", + # Explicit HIVE-FV-001 maintenance registration, authorized by issue #168. + # Do not enable a prefix/wildcard or release HIVE-REL-013 prematurely. + "HIVE-FV-001", "WO-1.1-01", } ) @@ -17755,6 +17759,7 @@ def build_manifest(args: argparse.Namespace) -> dict[str, object]: and GEF_WORK_ORDER_IDENTIFIER.fullmatch(work_order) is None and HIVE_REL_WORK_ORDER_IDENTIFIER.fullmatch(work_order) is None and RELEASE_TRAIN_WORK_ORDER_IDENTIFIER.fullmatch(work_order) is None + and work_order != "HIVE-FV-001" ): raise ValueError(f"invalid or unbounded HIVE work-order identifier: {work_order!r}") require_current_work_order_authorization(work_order)