diff --git a/src/autoskillit/cli/doctor/_doctor_config.py b/src/autoskillit/cli/doctor/_doctor_config.py index f20a0b4580..bceccf8a5a 100644 --- a/src/autoskillit/cli/doctor/_doctor_config.py +++ b/src/autoskillit/cli/doctor/_doctor_config.py @@ -157,7 +157,11 @@ def _collect_uncovered_gitignore_entries( autoskillit_dir: Path, gitignore_content: str, ) -> list[str]: - from autoskillit.core import _AUTOSKILLIT_GITIGNORE_ENTRIES, _COMMITTED_BY_DESIGN + from autoskillit.core import ( + _AUTOSKILLIT_GITIGNORE_ENTRIES, + _COMMITTED_BY_DESIGN, + is_self_ignored, + ) uncovered: list[str] = [] for item in sorted(autoskillit_dir.iterdir()): @@ -165,6 +169,8 @@ def _collect_uncovered_gitignore_entries( continue if item.name in _COMMITTED_BY_DESIGN: continue + if item.is_dir() and is_self_ignored(item): + continue check_name = item.name + "/" if item.is_dir() else item.name if check_name not in gitignore_content: uncovered.append(item.name) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 25ccba53eb..9d78f6e856 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -146,6 +146,7 @@ from .io import destination_location as destination_location from .io import directory_tree_digest as directory_tree_digest from .io import dump_yaml_str as dump_yaml_str from .io import ensure_project_temp as ensure_project_temp +from .io import ensure_self_ignored as ensure_self_ignored from .io import extract_section as extract_section from .io import fast_dumps as fast_dumps from .io import fast_loads as fast_loads @@ -156,6 +157,7 @@ from .io import is_git_main_checkout as is_git_main_checkout from .io import is_git_worktree as is_git_worktree from .io import is_in_git_repo as is_in_git_repo from .io import is_python_bytecode_path as is_python_bytecode_path +from .io import is_self_ignored as is_self_ignored from .io import is_yaml_mapping_node as is_yaml_mapping_node from .io import load_yaml as load_yaml from .io import main_checkout_root as main_checkout_root diff --git a/src/autoskillit/core/io/__init__.py b/src/autoskillit/core/io/__init__.py index 82eb3d54b4..04428257be 100644 --- a/src/autoskillit/core/io/__init__.py +++ b/src/autoskillit/core/io/__init__.py @@ -38,6 +38,7 @@ decode_versioned_json_bytes, directory_tree_digest, ensure_project_temp, + ensure_self_ignored, is_python_bytecode_path, read_versioned_json, resolve_skill_temp_dir, @@ -81,6 +82,7 @@ is_git_main_checkout, is_git_worktree, is_in_git_repo, + is_self_ignored, main_checkout_root, parse_worktree_porcelain, pkg_root, @@ -130,6 +132,7 @@ "directory_tree_digest", "dump_yaml_str", "ensure_project_temp", + "ensure_self_ignored", "extract_section", "fast_dumps", "fast_loads", @@ -140,6 +143,7 @@ "is_git_worktree", "is_in_git_repo", "is_python_bytecode_path", + "is_self_ignored", "is_yaml_mapping_node", "load_yaml", "main_checkout_root", diff --git a/src/autoskillit/core/io/io.py b/src/autoskillit/core/io/io.py index cff2d59191..2fbaf4fe3a 100644 --- a/src/autoskillit/core/io/io.py +++ b/src/autoskillit/core/io/io.py @@ -49,6 +49,7 @@ def __init__(self, path: Path, cause: OSError) -> None: "atomic_write", "decode_versioned_json_bytes", "ensure_project_temp", + "ensure_self_ignored", "is_python_bytecode_path", "read_versioned_json", "resolve_skill_temp_dir", @@ -718,12 +719,24 @@ def _reset_schema_drift_logged_for_tests() -> None: _COMMITTED_BY_DESIGN: frozenset[str] = frozenset( { "config.yaml", + "evidence", + "experiment-types", + "methodology-traditions", "recipes", + "skills", "test-source-map.json", + "waivers", } ) +def ensure_self_ignored(directory: Path) -> None: + """Write a ``*`` marker if absent; preserve an existing marker.""" + marker = directory / ".gitignore" + if not marker.exists(): + atomic_write(marker, "# Created by autoskillit automatically.\n*\n") + + def ensure_project_temp(project_dir: Path, override: str | None = None) -> Path: """Create the autoskillit temp directory with a self-gitignore; idempotent. @@ -739,14 +752,7 @@ def ensure_project_temp(project_dir: Path, override: str | None = None) -> Path: """ temp_dir = resolve_temp_dir(project_dir, override) temp_dir.mkdir(parents=True, exist_ok=True) - # Race-safe ordering: .gitignore is the FIRST file written after mkdir, - # before any session content lands. See pytest #12167 / mypy #12442. - gitignore_path = temp_dir / ".gitignore" - if not gitignore_path.exists(): - atomic_write( - gitignore_path, - "# Created by autoskillit automatically.\n*\n", - ) + ensure_self_ignored(temp_dir) autoskillit_dir = project_dir / ".autoskillit" if autoskillit_dir.is_dir(): autoskillit_gitignore = autoskillit_dir / ".gitignore" diff --git a/src/autoskillit/core/io/paths.py b/src/autoskillit/core/io/paths.py index 627b2aff45..8d1cbe8fb8 100644 --- a/src/autoskillit/core/io/paths.py +++ b/src/autoskillit/core/io/paths.py @@ -357,6 +357,14 @@ def is_generated_path(file_path: str) -> bool: return False +def is_self_ignored(directory: Path) -> bool: + """Recognize a regular, non-symlink marker with a standalone ``*`` rule.""" + marker = directory / ".gitignore" + if marker.is_symlink() or not marker.is_file(): + return False + return "*" in marker.read_text(encoding="utf-8", errors="replace").splitlines() + + def _coerce_str_or_path(name: str, value: object) -> str | Path: """Reject non-str/Path inputs with a uniform error message. diff --git a/src/autoskillit/execution/session/_managed_headless_session_lineage.py b/src/autoskillit/execution/session/_managed_headless_session_lineage.py index 436b25e719..f1d5de8060 100644 --- a/src/autoskillit/execution/session/_managed_headless_session_lineage.py +++ b/src/autoskillit/execution/session/_managed_headless_session_lineage.py @@ -1,4 +1,9 @@ -"""Crash-durable managed headless session lineage persistence.""" +"""Crash-durable managed headless session lineage persistence. + +Records live in /.autoskillit/managed-headless-session-lineage/, a +self-ignoring runtime directory at the .autoskillit root. Records are durable +across resume; keeping the namespace here repairs existing stores in place. +""" from __future__ import annotations diff --git a/src/autoskillit/execution/session/_managed_headless_session_lineage_records.py b/src/autoskillit/execution/session/_managed_headless_session_lineage_records.py index c76c01bf4b..6a5adf7277 100644 --- a/src/autoskillit/execution/session/_managed_headless_session_lineage_records.py +++ b/src/autoskillit/execution/session/_managed_headless_session_lineage_records.py @@ -22,6 +22,8 @@ NativeShellCaptureDecision, acquire_flock_with_timeout, atomic_write, + ensure_self_ignored, + is_self_ignored, ) from autoskillit.execution.session._managed_headless_session_lineage import ( _DISPATCH_INDEX, @@ -137,6 +139,9 @@ def _prepare_root(anchor: Path) -> Path: current.mkdir(mode=0o700, exist_ok=True) if current.is_symlink() or not current.is_dir(): raise ValueError("Managed lineage namespace is not a regular directory") + ensure_self_ignored(current) + if not is_self_ignored(current): + raise OSError("Lineage namespace requires a self-ignoring .gitignore") for relative in ( Path(_RECORDS_DIR), Path(_INDEXES_DIR) / _FINAL_NATIVE_INDEX, diff --git a/src/autoskillit/skill_resources/arch-constraint-catalog.md b/src/autoskillit/skill_resources/arch-constraint-catalog.md index 94938ff0ef..f3e01d15d7 100644 --- a/src/autoskillit/skill_resources/arch-constraint-catalog.md +++ b/src/autoskillit/skill_resources/arch-constraint-catalog.md @@ -26,6 +26,7 @@ classified `REJECT` with `category: "arch_violation"`. | Canonical maintenance argv | `test_maintenance_install_argv_contract.py` | Hand-built list or tuple argv containing `--maintenance-update` outside `MaintenanceInstallArgv.to_argv()` | | step_name in run_cmd | `test_anti_pattern_guards.py` | Missing `step_name` in `run_cmd` `with:` blocks in recipe YAML | | No hardcoded temp paths | `test_python_no_hardcoded_temp.py` | Hardcoded temp-root path strings in Python outside whitelist | +| AutoSkillit root children | `test_autoskillit_root_children_registered.py` | Naming an unclassified `.autoskillit/` child in production Python; runtime directories need a self-ignore marker and a real-Git proof | | run_python path resolution | `test_run_python_path_resolution.py` | smoke_utils callable with relative-path fallback or path-like param not in `_PATH_LIKE_ARGS` | | SkillResult kill_reason | `test_skill_result_construction_guard.py` | `SkillResult()` without `kill_reason=` kwarg | | Never-raises contracts | `test_never_raises_contracts.py` | `mcp.tool()` handlers without top-level `try/except` and "Never raises" docstring | diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 5a8cfcb557..c50e420a0a 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -150,7 +150,9 @@ class SourceMapRejection(enum.StrEnum): "tests/arch/_rules.py": _ARCH_HELPER_TEST_TARGETS, "tests/fleet/_reaper_test_support.py": frozenset({"fleet"}), "tests/hooks/_interactive_guard_harness.py": frozenset({"hooks", "skills"}), - "tests/_git_topology.py": frozenset({"core", "execution", "server", "integration"}), + "tests/_git_topology.py": frozenset( + {"core", "execution", "server", "integration", "workspace", "fleet"} + ), "tests/fleet/_codex_mcp_env.py": frozenset({"fleet", "integration"}), "tests/fleet/_descendant_worker.py": frozenset({"fleet"}), } diff --git a/tests/arch/test_autoskillit_root_children_registered.py b/tests/arch/test_autoskillit_root_children_registered.py new file mode 100644 index 0000000000..eba43e4568 --- /dev/null +++ b/tests/arch/test_autoskillit_root_children_registered.py @@ -0,0 +1,133 @@ +"""Architectural invariant: every source-named .autoskillit child is classified.""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Iterator +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] + +_STR_RX = re.compile(r"(?:^|[/\"'`\s(~])\.autoskillit/([A-Za-z0-9._-]+)") + +# These names are accounted for elsewhere: home-level artifacts, a read-only +# legacy path, a runtime directory with a real-git proof, and a tracked gap. +_CLASSIFIED_ELSEWHERE: set[str] = { + # Home-level hook state: hooks/guards/mcp_health_advisor.py:36. + "active_kitchens.json", + # Home-level marketplace shape: core/types/constants/_type_constants_retirements.py:309. + "marketplace", + # Home-level plugin storage: cli/doctor/_doctor_install.py:355. + "plugin-generations", + # Home-level hook projection repair: workspace/_projected_artifact/_hook_repair.py:538. + "plugin-projections", + # Home-level dismissal state: cli/update/_update_checks.py:79. + "update_check.json", + # Legacy path is read only for migration: cli/install/_marketplace.py:162. + "scripts", + # Runtime self-ignore is proven by tests/workspace/test_clone_gate_runtime_state.py. + "managed-headless-session-lineage", + # Known gap: init writes this without a keep-out rule; tracked by #5258 + # recommendation 4. + ".state.yaml", +} + + +def _str_const(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _assignment_targets(node: ast.AST) -> Iterator[ast.expr]: + if isinstance(node, ast.Assign): + yield from node.targets + elif isinstance(node, ast.AnnAssign) and node.value is not None: + yield node.target + + +def _is_root(node: ast.AST, roots: frozenset[str]) -> bool: + if _str_const(node) == ".autoskillit": + return True + if isinstance(node, ast.Name): + return node.id in roots + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): + return _str_const(node.right) == ".autoskillit" + if isinstance(node, ast.Call) and node.args: + return _str_const(node.args[-1]) == ".autoskillit" + return False + + +def _children_named_in(tree: ast.Module) -> set[str]: + """Collect children from literal paths and simple syntactic path expressions. + + Recognize direct root assignments, slash joins, and adjacent literal + arguments or sequence elements. Root names are matched across the module + without scope analysis; dynamic strings and indirect aliases are not traced. + """ + roots = { + target.id + for node in ast.walk(tree) + for target in _assignment_targets(node) + if isinstance(node, (ast.Assign, ast.AnnAssign)) + and node.value is not None + and isinstance(target, ast.Name) + and _is_root(node.value, frozenset()) + } + names: set[str] = set() + for node in ast.walk(tree): + text = _str_const(node) + if text is not None: + names.update(match.group(1) for match in _STR_RX.finditer(text)) + if ( + isinstance(node, ast.BinOp) + and isinstance(node.op, ast.Div) + and _is_root(node.left, roots) + ): + right = _str_const(node.right) + if right: + names.add(right.split("/")[0]) + seq = ( + node.elts + if isinstance(node, (ast.Tuple, ast.List)) + else node.args + if isinstance(node, ast.Call) + else () + ) + for first, second in zip(seq, seq[1:]): + if _str_const(first) == ".autoskillit" and _str_const(second): + names.add(_str_const(second).split("/")[0]) + return {name.rstrip(".") for name in names} - {""} + + +def _src_root() -> Path: + return Path(__file__).resolve().parent.parent.parent / "src" / "autoskillit" + + +def test_every_autoskillit_child_named_in_src_is_classified() -> None: + from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES, _COMMITTED_BY_DESIGN + + src = _src_root() + assert src.is_dir(), f"src/autoskillit not found at {src}" + + named = { + child + for path in src.rglob("*.py") + for child in _children_named_in(ast.parse(path.read_text(encoding="utf-8"))) + } + ignored_names = {entry.rstrip("/") for entry in _AUTOSKILLIT_GITIGNORE_ENTRIES} + unclassified = sorted( + named - set(_COMMITTED_BY_DESIGN) - ignored_names - {".gitignore"} - _CLASSIFIED_ELSEWHERE + ) + + assert not unclassified, ( + f"Unclassified .autoskillit children named in src/autoskillit: {unclassified}. " + f"Commit a persistent child by adding it to _COMMITTED_BY_DESIGN; list a file " + f"in _AUTOSKILLIT_GITIGNORE_ENTRIES only when a setup command writes it, since " + f"init and ensure_project_temp maintain the tracked .gitignore. For a runtime " + f"directory, call ensure_self_ignored when creating it, prove the behavior with " + f"a real-git test, and name that test in an adjacent justification comment." + ) diff --git a/tests/arch/test_layer_enforcement.py b/tests/arch/test_layer_enforcement.py index 0fdabf1f51..51c8dea33c 100644 --- a/tests/arch/test_layer_enforcement.py +++ b/tests/arch/test_layer_enforcement.py @@ -2002,6 +2002,8 @@ def test_default_classes_only_instantiated_inside_factory_or_allowlist() -> None "tests/execution/test_process_tether.py": frozenset({"autoskillit.config"}), # workspace tests "tests/workspace/test_clone_ci_contract.py": frozenset({"autoskillit.execution"}), + # Clone cleanliness is exercised against the real execution-layer lineage writer. + "tests/workspace/test_clone_gate_runtime_state.py": frozenset({"autoskillit.execution"}), # the frontmatter write scope round-trips through the stdlib-only write-scope # authority and the hook-side manifest decoder it feeds "tests/workspace/test_skill_write_boundary_contract.py": frozenset({"autoskillit.hooks"}), diff --git a/tests/cli/test_doctor.py b/tests/cli/test_doctor.py index 375fca71ad..5f844e3861 100644 --- a/tests/cli/test_doctor.py +++ b/tests/cli/test_doctor.py @@ -1086,6 +1086,31 @@ def test_doctor_gitignore_ok_when_all_covered( assert result.severity == Severity.OK +def test_doctor_gitignore_exempts_only_self_ignoring_directories( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES + + autoskillit_dir = tmp_path / ".autoskillit" + autoskillit_dir.mkdir() + (autoskillit_dir / ".gitignore").write_text( + "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", encoding="utf-8" + ) + runtime_dir = autoskillit_dir / "runtime-a" + runtime_dir.mkdir() + (runtime_dir / ".gitignore").write_text("*\n", encoding="utf-8") + (autoskillit_dir / "runtime-b").mkdir() + + monkeypatch.chdir(tmp_path) + from autoskillit.cli.doctor import _check_gitignore_completeness + from autoskillit.core import Severity + + result = _check_gitignore_completeness(tmp_path) + assert result.severity == Severity.WARNING + assert "runtime-b" in result.message + assert "runtime-a" not in result.message + + # SS-DOC-1 def test_doctor_includes_secret_scanning_hook_check( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index f933d68f07..edea9acadb 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -537,7 +537,11 @@ def test_init_all_created_files_covered_by_gitignore(tmp_path: Path) -> None: be in the committed allowlist. """ from autoskillit.cli._init_helpers import _create_secrets_template - from autoskillit.core.io import _COMMITTED_BY_DESIGN, ensure_project_temp + from autoskillit.core.io import ( + _COMMITTED_BY_DESIGN, + ensure_project_temp, + is_self_ignored, + ) ensure_project_temp(tmp_path) _create_secrets_template(tmp_path) @@ -552,10 +556,7 @@ def test_init_all_created_files_covered_by_gitignore(tmp_path: Path) -> None: continue if item.is_dir() and (item / ".gitignore").exists(): # Self-gitignoring directory — its own .gitignore covers its contents. - self_ignore = (item / ".gitignore").read_text() - assert "*" in self_ignore, ( - f"{item.name}/ is a directory without a self-gitignore covering '*'" - ) + assert is_self_ignored(item) continue check_name = item.name + "/" if item.is_dir() else item.name assert check_name in gitignore_content, ( diff --git a/tests/conftest.py b/tests/conftest.py index 4be839ca04..4c193a50fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -279,6 +279,13 @@ def exploration_snapshot_service() -> MagicMock: return service +@pytest.fixture +def isolated_git_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep real-Git visibility tests independent of user and system config.""" + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + + @pytest.fixture def remote_only_base_clone(tmp_path: _Path, monkeypatch: pytest.MonkeyPatch) -> tuple[_Path, str]: """Create an isolated ``feat`` clone with only remote ``develop`` history.""" diff --git a/tests/contracts/test_skill_resource_derived_counts.py b/tests/contracts/test_skill_resource_derived_counts.py index 29a27d2106..00b4b0aa73 100644 --- a/tests/contracts/test_skill_resource_derived_counts.py +++ b/tests/contracts/test_skill_resource_derived_counts.py @@ -19,7 +19,7 @@ @pytest.mark.parametrize( ("resource_id", "expected_count"), [ - ("arch-constraint-catalog", 71), + ("arch-constraint-catalog", 72), ("review-approach-criteria", None), ], ) diff --git a/tests/core/test_grab_bag_split_completeness.py b/tests/core/test_grab_bag_split_completeness.py index 048593d0e4..1f36e350c2 100644 --- a/tests/core/test_grab_bag_split_completeness.py +++ b/tests/core/test_grab_bag_split_completeness.py @@ -533,6 +533,7 @@ def test_no_unintended_new_test_files_under_tests_core() -> None: "test_resolve_main_worktree.py", "test_resolve_temp_dir.py", "test_session_checkpoint.py", + "test_self_ignored.py", "test_session_env_specs.py", "test_session_hook_root.py", "test_session_index_schema.py", diff --git a/tests/core/test_self_ignored.py b/tests/core/test_self_ignored.py new file mode 100644 index 0000000000..942a150d98 --- /dev/null +++ b/tests/core/test_self_ignored.py @@ -0,0 +1,45 @@ +"""Tests for self-ignore markers in runtime directories.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from autoskillit.core.io import ensure_self_ignored, is_self_ignored + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + + +def test_ensure_self_ignored_writes_marker_only_when_absent(tmp_path: Path) -> None: + target = tmp_path / "runtime" + target.mkdir() + marker = target / ".gitignore" + ensure_self_ignored(target) + assert marker.is_file() and is_self_ignored(target) + custom = "# custom user content\n*\n!keep.txt\n" + marker.write_text(custom, encoding="utf-8") + + ensure_self_ignored(target) + + assert marker.read_text(encoding="utf-8") == custom + + +def test_is_self_ignored_requires_a_star_line(tmp_path: Path) -> None: + target = tmp_path / "runtime" + target.mkdir() + assert not is_self_ignored(target) + marker = target / ".gitignore" + marker.write_text("*.pyc\n", encoding="utf-8") + assert not is_self_ignored(target) + marker.write_text("# generated\n*\n", encoding="utf-8") + assert is_self_ignored(target) + + +def test_is_self_ignored_rejects_symlinked_marker(tmp_path: Path) -> None: + target = tmp_path / "runtime" + target.mkdir() + external_marker = tmp_path / "external-ignore" + external_marker.write_text("*\n", encoding="utf-8") + (target / ".gitignore").symlink_to(external_marker) + assert not is_self_ignored(target) diff --git a/tests/execution/session/test_managed_headless_session_lineage.py b/tests/execution/session/test_managed_headless_session_lineage.py index 7793cbcf4d..ec9f657609 100644 --- a/tests/execution/session/test_managed_headless_session_lineage.py +++ b/tests/execution/session/test_managed_headless_session_lineage.py @@ -363,6 +363,56 @@ def test_corrupt_unsupported_and_digest_mismatched_records_fail_closed( store.load(lineage_anchor=anchor, launch_id=lineage.launch_id) +def test_create_fails_closed_when_namespace_cannot_be_self_ignored( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + anchor = tmp_path / "project" + anchor.mkdir() + from autoskillit.execution.session import ( + _managed_headless_session_lineage_records as lineage_module, + ) + + launch_id = "1" * 32 + + def fail_self_ignore(_namespace: Path) -> None: + raise OSError("disk unavailable") + + monkeypatch.setattr(lineage_module, "ensure_self_ignored", fail_self_ignore) + with pytest.raises(OSError, match="disk unavailable"): + _create(DefaultManagedHeadlessSessionLineageStore(), anchor, launch_id=launch_id) + + record = ( + anchor + / ".autoskillit" + / "managed-headless-session-lineage" + / "records" + / f"{launch_id}.json" + ) + assert not record.exists() + assert not list((anchor / ".autoskillit" / "managed-headless-session-lineage").rglob("*.json")) + + +def test_create_rejects_existing_non_self_ignoring_marker( + tmp_path: Path, +) -> None: + anchor = tmp_path / "project" + anchor.mkdir() + namespace = anchor / ".autoskillit" / "managed-headless-session-lineage" + namespace.mkdir(parents=True) + marker = namespace / ".gitignore" + malformed = "# not a self-ignoring marker\nmanaged-data/\n" + marker.write_text(malformed, encoding="utf-8") + + with pytest.raises( + OSError, + match="Lineage namespace requires a self-ignoring .gitignore", + ): + _create(DefaultManagedHeadlessSessionLineageStore(), anchor) + + assert marker.read_text(encoding="utf-8") == malformed + assert not (namespace / "records").exists() + + def test_cross_process_conflicting_creation_has_one_winner(tmp_path: Path) -> None: anchor = tmp_path / "project" anchor.mkdir() diff --git a/tests/fleet/test_dispatch_checkout_cleanliness.py b/tests/fleet/test_dispatch_checkout_cleanliness.py new file mode 100644 index 0000000000..d11aa6bd55 --- /dev/null +++ b/tests/fleet/test_dispatch_checkout_cleanliness.py @@ -0,0 +1,74 @@ +"""Ensure a real fleet dispatch adds no git-visible AutoSkillit state.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES +from autoskillit.fleet import execute_dispatch +from tests._git_topology import commit_file, init_checkout +from tests.fleet._helpers import ( + _noop_quota_refresher, + _setup_dispatch, + _simple_prompt_builder, +) + +pytestmark = [ + pytest.mark.layer("fleet"), + pytest.mark.medium, + pytest.mark.feature("fleet"), + pytest.mark.usefixtures("isolated_git_config"), +] + + +def _autoskillit_status(project_dir: Path) -> set[str]: + output = subprocess.run( + [ + "git", + "status", + "--porcelain=v1", + "--untracked-files=all", + "--", + ".autoskillit", + ], + cwd=project_dir, + check=True, + capture_output=True, + text=True, + ).stdout + return {line for line in output.splitlines() if line.strip()} + + +@pytest.mark.anyio +async def test_dispatch_adds_nothing_git_visible_under_autoskillit( + tool_ctx, monkeypatch: pytest.MonkeyPatch +) -> None: + project_dir = tool_ctx.project_dir + init_checkout(project_dir) + commit_file( + project_dir, + ".autoskillit/.gitignore", + "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", + "add AutoSkillit ignore entries", + ) + _setup_dispatch(tool_ctx, monkeypatch) + before = _autoskillit_status(project_dir) + + await execute_dispatch( + tool_ctx=tool_ctx, + recipe="test-recipe", + task="task", + ingredients=None, + dispatch_name="dispatch", + timeout_sec=None, + prompt_builder=_simple_prompt_builder, + quota_refresher=_noop_quota_refresher, + ) + + assert len(tool_ctx.executor.dispatch_calls) == 1 + records_dir = project_dir / ".autoskillit" / "managed-headless-session-lineage" / "records" + assert records_dir.is_dir() and any(records_dir.iterdir()) + assert _autoskillit_status(project_dir) - before == set() diff --git a/tests/infra/test_release_sanity.py b/tests/infra/test_release_sanity.py index a010cec094..72ad6a68a3 100644 --- a/tests/infra/test_release_sanity.py +++ b/tests/infra/test_release_sanity.py @@ -35,15 +35,20 @@ def test_sync_manifest_in_gitignore(): assert ".autoskillit/sync_manifest.json" in gitignore -def test_autoskillit_root_files_are_all_registered(): - """.autoskillit/ root must only contain files registered in _COMMITTED_BY_DESIGN - or _AUTOSKILLIT_GITIGNORE_ENTRIES (or .gitignore itself). +def test_autoskillit_root_entries_are_all_registered(): + """Every .autoskillit/ root entry must be registered or self-ignored. - Catches any session-scoped file mistakenly placed outside temp/ at CI time. - If .hook_config.json (or any future runtime artifact) is present in the root - rather than temp/, this test fails immediately. + Files may be registered in _COMMITTED_BY_DESIGN or + _AUTOSKILLIT_GITIGNORE_ENTRIES; .gitignore itself is always accepted. A + runtime directory must have a valid self-ignore marker. A directory without + one is a namespace left by an earlier version: delete it or run a dispatch + to repair its marker. """ - from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES, _COMMITTED_BY_DESIGN + from autoskillit.core.io import ( + _AUTOSKILLIT_GITIGNORE_ENTRIES, + _COMMITTED_BY_DESIGN, + is_self_ignored, + ) autoskillit_dir = REPO_ROOT / ".autoskillit" if not autoskillit_dir.is_dir(): @@ -58,13 +63,15 @@ def test_autoskillit_root_files_are_all_registered(): unregistered = [ p.name for p in autoskillit_dir.iterdir() - if not p.is_dir() and p.name not in registered_names + if p.name not in registered_names and not (p.is_dir() and is_self_ignored(p)) ] assert not unregistered, ( - f"Unregistered files found in .autoskillit/: {unregistered}. " - f"Session-scoped runtime files belong in .autoskillit/temp/ where they " - f"are auto-gitignored by temp/.gitignore ('*'). Persistent files that " - f"belong in .autoskillit/ root need an entry added to " + f"Unregistered entries found in .autoskillit/: {unregistered}. " + f"A runtime directory without a valid self-ignore marker is a namespace " + f"left by an earlier version; delete it or run a dispatch to repair the " + f"marker. Runtime files belong in .autoskillit/temp/ where they are " + f"auto-gitignored by temp/.gitignore ('*'). Persistent files that belong " + f"in .autoskillit/ root need an entry added to " f"_AUTOSKILLIT_GITIGNORE_ENTRIES in src/autoskillit/core/io/io.py." ) diff --git a/tests/workspace/test_clone_gate_runtime_state.py b/tests/workspace/test_clone_gate_runtime_state.py new file mode 100644 index 0000000000..33a1c5d2d2 --- /dev/null +++ b/tests/workspace/test_clone_gate_runtime_state.py @@ -0,0 +1,99 @@ +"""Git visibility of AutoSkillit-owned state during clone checks.""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest + +from autoskillit.core import ( + ManagedHeadlessSessionKind, + NativeShellCaptureMode, + resolve_native_shell_capture_decision, +) +from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES, ensure_project_temp +from autoskillit.execution.session import DefaultManagedHeadlessSessionLineageStore +from autoskillit.workspace.clone import detect_uncommitted_changes +from tests._git_topology import commit_file + +pytestmark = [ + pytest.mark.layer("workspace"), + pytest.mark.medium, + pytest.mark.usefixtures("isolated_git_config"), +] + + +def _initialized(repo: Path) -> Path: + commit_file( + repo, + ".autoskillit/.gitignore", + "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", + "add AutoSkillit ignore entries", + ) + commit_file(repo, ".autoskillit/config.yaml", "project: probe\n", "add project config") + return repo + + +def _create_record(anchor: Path): + return DefaultManagedHeadlessSessionLineageStore().create( + lineage_anchor=anchor, + launch_id=uuid.uuid4().hex, + decision=resolve_native_shell_capture_decision(NativeShellCaptureMode.CAPTURE), + backend="claude-code", + session_kind=ManagedHeadlessSessionKind.FOOD_TRUCK, + dispatch_id=str(uuid.uuid4()), + ) + + +def test_launch_sequence_leaves_checkout_clean(git_repo: Path) -> None: + repo = _initialized(git_repo) + gitignore_path = repo / ".autoskillit" / ".gitignore" + committed_gitignore = gitignore_path.read_bytes() + + ensure_project_temp(repo) + _create_record(repo) + + records_dir = repo / ".autoskillit" / "managed-headless-session-lineage" / "records" + assert records_dir.is_dir() and any(records_dir.iterdir()) + assert detect_uncommitted_changes(str(repo)) == [] + assert gitignore_path.read_bytes() == committed_gitignore + + +def test_user_edits_still_block_after_launch(git_repo: Path) -> None: + repo = _initialized(git_repo) + gitignore_path = repo / ".autoskillit" / ".gitignore" + committed_gitignore = gitignore_path.read_bytes() + + ensure_project_temp(repo) + _create_record(repo) + + (repo / "notes.txt").write_text("keep this note\n", encoding="utf-8") + config_path = repo / ".autoskillit" / "config.yaml" + config_path.write_text( + config_path.read_text(encoding="utf-8") + "user_setting: retained\n", + encoding="utf-8", + ) + recipe_path = repo / ".autoskillit" / "recipes" / "mine.yaml" + recipe_path.parent.mkdir(parents=True, exist_ok=True) + recipe_path.write_text("name: mine\n", encoding="utf-8") + + changed = detect_uncommitted_changes(str(repo)) + assert "?? notes.txt" in changed + assert " M .autoskillit/config.yaml" in changed + assert "?? .autoskillit/recipes/" in changed + assert gitignore_path.read_bytes() == committed_gitignore + + +def test_legacy_namespace_is_repaired_by_a_store_read(git_repo: Path) -> None: + repo = _initialized(git_repo) + lineage = _create_record(repo) + namespace = repo / ".autoskillit" / "managed-headless-session-lineage" + (namespace / ".gitignore").unlink(missing_ok=True) + + assert detect_uncommitted_changes(str(repo)) + DefaultManagedHeadlessSessionLineageStore().load( + lineage_anchor=repo, + launch_id=lineage.launch_id, + ) + assert detect_uncommitted_changes(str(repo)) == []