Skip to content
Merged
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
29 changes: 29 additions & 0 deletions goal/cli/version_discovery.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 5 additions & 12 deletions goal/cli/version_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import ast
import json
import os
import re
import subprocess
import tomllib
Expand All @@ -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(
Expand Down Expand Up @@ -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))


Expand Down
44 changes: 20 additions & 24 deletions goal/cli/version_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions project/ticket-093/README.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions project/ticket-093/intent.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
}
106 changes: 106 additions & 0 deletions tests/test_version_repository_boundaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""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":
(foreign / ".git").symlink_to(project / "missing-metadata")
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()
(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()
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
assert {path: path.readlink() for path in links} == links