From e24a1dddc73fda700596cd32606729aad935092f Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 8 Sep 2026 14:59:24 +0200 Subject: [PATCH 1/2] [ticket-093] fix: keep automatic version scans inside repository boundaries --- goal/cli/version_discovery.py | 29 ++++++ goal/cli/version_state.py | 17 +--- goal/cli/version_sync.py | 44 ++++---- project/ticket-093/README.md | 20 ++++ project/ticket-093/intent.json | 82 +++++++++++++++ tests/test_version_repository_boundaries.py | 107 ++++++++++++++++++++ 6 files changed, 263 insertions(+), 36 deletions(-) create mode 100644 goal/cli/version_discovery.py create mode 100644 project/ticket-093/README.md create mode 100644 project/ticket-093/intent.json create mode 100644 tests/test_version_repository_boundaries.py diff --git a/goal/cli/version_discovery.py b/goal/cli/version_discovery.py new file mode 100644 index 0000000..f97d636 --- /dev/null +++ b/goal/cli/version_discovery.py @@ -0,0 +1,29 @@ +"""Filesystem boundaries shared by automatic version scans.""" + +import os +from pathlib import Path +from typing import Iterable, Iterator + + +def project_version_files(skip_dirs: Iterable[str]) -> Iterator[Path]: + """Walk this project without entering other checkouts or following links. + + A nested .git directory or gitfile marks a separate repository, including + submodules and linked worktrees. Even an unavailable gitfile target is a + boundary; automatic discovery does not need to resolve Git metadata. + Explicitly configured version sources are handled by their existing API. + """ + excluded = set(skip_dirs) | {".git", ".worktrees"} + for dirpath, dirnames, filenames in os.walk(".", followlinks=False): + directory = Path(dirpath) + dirnames[:] = [ + name for name in dirnames + if name not in excluded + and not name.endswith(".egg-info") + and not (directory / name).is_symlink() + and not os.path.lexists(directory / name / ".git") + ] + for name in filenames: + path = directory / name + if not path.is_symlink(): + yield path diff --git a/goal/cli/version_state.py b/goal/cli/version_state.py index 824e696..4658d55 100644 --- a/goal/cli/version_state.py +++ b/goal/cli/version_state.py @@ -4,7 +4,6 @@ import ast import json -import os import re import subprocess import tomllib @@ -13,6 +12,7 @@ from typing import Iterable, Mapping, Optional, Sequence from .version_utils import bump_version, is_plain_version +from .version_discovery import project_version_files _VERSION_RE = re.compile( @@ -414,17 +414,10 @@ def _candidate_spec(path: Path, content: Optional[str] = None) -> Optional[str]: def discover_version_specs() -> tuple[str, ...]: """Find version declarations while pruning dependencies and fixtures.""" specs: list[str] = [] - for dirpath, dirnames, filenames in os.walk("."): - dirnames[:] = [ - name - for name in dirnames - if name not in _SKIP_DIRS and not name.endswith(".egg-info") - ] - for filename in filenames: - path = Path(dirpath) / filename - spec = _candidate_spec(path) - if spec: - specs.append(_normalized_spec(spec)) + for path in project_version_files(_SKIP_DIRS): + spec = _candidate_spec(path) + if spec: + specs.append(_normalized_spec(spec)) return tuple(dict.fromkeys(specs)) diff --git a/goal/cli/version_sync.py b/goal/cli/version_sync.py index 6a2cc34..a01bd55 100644 --- a/goal/cli/version_sync.py +++ b/goal/cli/version_sync.py @@ -19,6 +19,7 @@ update_readme_metadata, ) from .version_state import validate_version_sources, write_version_source +from .version_discovery import project_version_files from goal.version_validation import update_badge_versions @@ -314,9 +315,8 @@ def _update_init_py_versions(new_version: str, updated: List[str]) -> None: "third_party", ) - for init_file in Path(".").rglob("__init__.py"): - parts = init_file.parts - if any(p in parts for p in skip_dirs) or ".egg-info" in str(init_file): + for init_file in project_version_files(skip_dirs): + if init_file.name != "__init__.py" or ".egg-info" in str(init_file): continue try: content = init_file.read_text() @@ -401,28 +401,24 @@ def _sync_nested_versions( if not old_version or old_version == new_version: return json_files = {"package.json", "composer.json"} - for dirpath, dirnames, filenames in os.walk("."): - dirnames[:] = [ - d for d in dirnames if d not in _NESTED_SKIP_DIRS and not d.endswith(".egg-info") - ] - if os.path.abspath(dirpath) == os.path.abspath("."): + for path in project_version_files(_NESTED_SKIP_DIRS): + if len(path.parts) == 1: continue # root files are handled explicitly by sync_all_versions - for filename in filenames: - if filename not in _NESTED_VERSION_FILES: - continue - path = Path(dirpath) / filename - if _read_version_of(path) != old_version: - continue # not part of the synchronized set — leave it alone - rel = os.path.relpath(str(path)) - if filename == "VERSION": - path.write_text(f"{new_version}\n", encoding="utf-8") - updated.append(rel) - elif filename in json_files: - _update_json_version_file(rel, new_version, user_config, updated) - elif filename == "pyproject.toml": - _update_toml_version(rel, new_version, user_config, updated) - elif filename == "Cargo.toml": - _update_cargo_version(rel, new_version, user_config, updated) + filename = path.name + if filename not in _NESTED_VERSION_FILES: + continue + if _read_version_of(path) != old_version: + continue # not part of the synchronized set — leave it alone + rel = os.path.relpath(str(path)) + if filename == "VERSION": + path.write_text(f"{new_version}\n", encoding="utf-8") + updated.append(rel) + elif filename in json_files: + _update_json_version_file(rel, new_version, user_config, updated) + elif filename == "pyproject.toml": + _update_toml_version(rel, new_version, user_config, updated) + elif filename == "Cargo.toml": + _update_cargo_version(rel, new_version, user_config, updated) def _sync_selected_version_sources( diff --git a/project/ticket-093/README.md b/project/ticket-093/README.md new file mode 100644 index 0000000..8e0f2f9 --- /dev/null +++ b/project/ticket-093/README.md @@ -0,0 +1,20 @@ +# Ticket 093: Preserve repository boundaries in automatic version scanning + +- **Owner**: codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION + +SESSION_EXECUTION_AUTHORIZATION: the user requests continuation, fixes, tests and GitHub publication. This implements the version-discovery boundary gap identified during MaskFleet publication. + +## Acceptance criteria + +- [x] AC-01: Automatic discovery and synchronization preserve nested Git repositories, worktrees and symlink targets while still updating ordinary lockstep subpackages. +- [ ] AC-02: Focused and full tests, governance and stack checks pass; publish through independent exact-head review. + +## Scope + +Explicitly configured version sources retain their existing semantics. No package release, hardware commands or changes to other agents' workspaces. Preserve the primary ticket index and remove only this verified integrated worktree at completion. + +## Validation + +All 12 regression cases failed before the implementation and pass afterward. Focused version checks: 86 passed, 1 existing skip. Full suite: 735 passed, 2 existing skips. Ruff, managed governance (0 errors, 0 warnings), installed host contract and Docker Compose configuration pass. Protected review and merge are pending. diff --git a/project/ticket-093/intent.json b/project/ticket-093/intent.json new file mode 100644 index 0000000..521c262 --- /dev/null +++ b/project/ticket-093/intent.json @@ -0,0 +1,82 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-093", + "summary": "Keep automatic version discovery and synchronization inside repository boundaries", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "goal/cli/version_discovery.py", + "goal/cli/version_state.py", + "goal/cli/version_sync.py", + "tests/test_version_repository_boundaries.py", + "project/ticket-093/**" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python", + "docker" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "144351619db586f8036147a0988ea446e38acadb", + "targetBranch": "main", + "outcome": "Automatic version scanning and synchronization exclude nested Git repositories, linked worktrees and symlink targets while retaining ordinary monorepo packages. Publish through protected independent review.", + "nonGoals": [ + "Change explicit version source configuration semantics.", + "Publish a new package version or change repository side-effect coordination." + ], + "complexity": "S", + "estimatedMinutes": 40, + "budgets": { + "maxImplementationFiles": 5, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Use one pruned filesystem iterator for the three automatic version scans, retaining their existing skip policies and honoring Git boundaries without Git subprocesses.", + "components": [ + { + "name": "version-discovery", + "paths": [ + "goal/cli/version_discovery.py", + "goal/cli/version_state.py", + "goal/cli/version_sync.py", + "tests/test_version_repository_boundaries.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert this bounded source and regression-test commit through protected delivery." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python3 -m pytest tests/test_version_repository_boundaries.py tests/test_version_state.py tests/test_version_sync.py -q", + "python3 -m pytest tests/ -q", + "./project/governance-check.sh", + "docker compose config --quiet" + ], + "evidence": "Real nested Git repository and linked-worktree fixtures; whole-file before/after comparisons and retained monorepo updates." + } + ] + } +} diff --git a/tests/test_version_repository_boundaries.py b/tests/test_version_repository_boundaries.py new file mode 100644 index 0000000..a9b82d3 --- /dev/null +++ b/tests/test_version_repository_boundaries.py @@ -0,0 +1,107 @@ +"""Automatic version scans must not own files in another checkout.""" + +import subprocess +from pathlib import Path + +import pytest + +from goal.cli.version_state import discover_version_specs +from goal.cli.version_sync import sync_all_versions + + +def git(root, *args): + subprocess.run( + ["git", "-C", str(root), *args], check=True, + capture_output=True, text=True, + ) + + +def declarations(root): + root.mkdir(parents=True, exist_ok=True) + (root / "pkg").mkdir(exist_ok=True) + (root / "VERSION").write_text("1.2.3\n") + (root / "package.json").write_text('{"version": "1.2.3"}\n') + (root / "pkg/__init__.py").write_text('__version__ = "1.2.3"\n') + return { + path: path.read_bytes() + for path in (root / "VERSION", root / "package.json", root / "pkg/__init__.py") + } + + +@pytest.fixture +def project(tmp_path, monkeypatch): + root = tmp_path / "project" + root.mkdir() + (root / "VERSION").write_text("1.2.3\n") + git(root, "init", "-q") + git(root, "add", "VERSION") + git(root, "-c", "user.name=Version test", "-c", "user.email=test@example.invalid", + "-c", "commit.gpgsign=false", "commit", "-qm", "base") + declarations(root / "packages/owned") + monkeypatch.chdir(root) + return root + + +def sync(): + function = sync_all_versions + while hasattr(function, "__wrapped__"): + function = function.__wrapped__ + return function("1.2.4") + + +@pytest.mark.parametrize("kind", ["clone", "worktree", "gitfile", "dangling-marker", "worktree-cache"]) +@pytest.mark.parametrize("operation", ["discover", "sync"]) +def test_nested_checkout_boundary(project, kind, operation): + relative = ".worktrees/recovery" if kind == "worktree-cache" else "packages/foreign" + foreign = project / relative + if kind == "clone": + git(project, "clone", "-q", "--no-hardlinks", str(project), str(foreign)) + elif kind == "worktree": + git(project, "worktree", "add", "--detach", str(foreign), "HEAD") + else: + foreign.mkdir(parents=True) + if kind == "gitfile": + (foreign / ".git").write_text("gitdir: /unavailable/submodule/metadata\n") + elif kind == "dangling-marker": + try: + (foreign / ".git").symlink_to(project / "missing-metadata") + except OSError: + pytest.skip("host cannot create symlinks") + before = declarations(foreign) + + if operation == "discover": + specs = discover_version_specs() + assert "VERSION" in specs + assert "packages/owned/VERSION" in specs + assert not any(spec.startswith(relative + "/") for spec in specs) + else: + updated = sync() + assert (project / "VERSION").read_text() == "1.2.4\n" + assert (project / "packages/owned/VERSION").read_text() == "1.2.4\n" + assert '"1.2.4"' in (project / "packages/owned/pkg/__init__.py").read_text() + assert not any(Path(path).as_posix().startswith(relative + "/") for path in updated) + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize("operation", ["discover", "sync"]) +def test_symlinked_declarations_do_not_read_or_write_external_files(project, operation): + outside = project.parent / "outside" + before = declarations(outside) + shared = project / "packages/shared" + shared.mkdir() + try: + (shared / "VERSION").symlink_to(outside / "VERSION") + (shared / "__init__.py").symlink_to(outside / "pkg/__init__.py") + (project / "linked").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("host cannot create symlinks") + + if operation == "discover": + specs = discover_version_specs() + assert not any(spec.startswith(("linked/", "packages/shared/")) for spec in specs) + assert "packages/owned/VERSION" in specs + else: + updated = sync() + assert not any(Path(path).as_posix().startswith(("linked/", "packages/shared/")) for path in updated) + assert (project / "packages/owned/VERSION").read_text() == "1.2.4\n" + assert {path: path.read_bytes() for path in before} == before From 3d30d943b39d7cb4698cb9fe7d311c1e32669366 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 8 Sep 2026 15:07:27 +0200 Subject: [PATCH 2/2] [ticket-093] test: require repository boundary coverage on symlink-capable hosts --- tests/test_version_repository_boundaries.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/test_version_repository_boundaries.py b/tests/test_version_repository_boundaries.py index a9b82d3..5c488cd 100644 --- a/tests/test_version_repository_boundaries.py +++ b/tests/test_version_repository_boundaries.py @@ -63,10 +63,7 @@ def test_nested_checkout_boundary(project, kind, operation): if kind == "gitfile": (foreign / ".git").write_text("gitdir: /unavailable/submodule/metadata\n") elif kind == "dangling-marker": - try: - (foreign / ".git").symlink_to(project / "missing-metadata") - except OSError: - pytest.skip("host cannot create symlinks") + (foreign / ".git").symlink_to(project / "missing-metadata") before = declarations(foreign) if operation == "discover": @@ -89,12 +86,13 @@ def test_symlinked_declarations_do_not_read_or_write_external_files(project, ope before = declarations(outside) shared = project / "packages/shared" shared.mkdir() - try: - (shared / "VERSION").symlink_to(outside / "VERSION") - (shared / "__init__.py").symlink_to(outside / "pkg/__init__.py") - (project / "linked").symlink_to(outside, target_is_directory=True) - except OSError: - pytest.skip("host cannot create symlinks") + (shared / "VERSION").symlink_to(outside / "VERSION") + (shared / "__init__.py").symlink_to(outside / "pkg/__init__.py") + (project / "linked").symlink_to(outside, target_is_directory=True) + links = { + path: path.readlink() + for path in (shared / "VERSION", shared / "__init__.py", project / "linked") + } if operation == "discover": specs = discover_version_specs() @@ -105,3 +103,4 @@ def test_symlinked_declarations_do_not_read_or_write_external_files(project, ope assert not any(Path(path).as_posix().startswith(("linked/", "packages/shared/")) for path in updated) assert (project / "packages/owned/VERSION").read_text() == "1.2.4\n" assert {path: path.read_bytes() for path in before} == before + assert {path: path.readlink() for path in links} == links