From ddf6dddb199e0fe39dec975040064ce05568b374 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:44:30 -0700 Subject: [PATCH 1/8] fix: opt-in OpenCode discovery of hidden Spec Kit files OpenCode install and upgrade accept an opt-in --configure-ignore-file flag that appends whichever of !.specify/ and !.specify/** is not already an exact line, keeps existing .ignore bytes as a prefix (including non-ASCII text, CRLF, and a missing final newline), and records the root file as partial ownership. Symlinks, non-files, and unwritable paths are rejected before any write. Uninstall, including with --force, leaves .ignore in place, and installs without the flag, along with other integrations, leave it untouched. Upgrade replays the stored flag, a new --integration-options value replaces the whole stored set, and the integrations reference documents the option and the checked ripgrep behavior. Fixes #5028 Assisted-by: AI --- docs/reference/integrations.md | 25 +- .../integrations/opencode/__init__.py | 118 ++++++++- .../integrations/test_integration_opencode.py | 236 ++++++++++++++++++ 3 files changed, 377 insertions(+), 2 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 2480695630..072165b957 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -37,7 +37,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | | [Muse Code](https://dev.meta.ai/docs/muse-code) | `muse` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | | [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | -| [opencode](https://opencode.ai/) | `opencode` | | +| [opencode](https://opencode.ai/) | `opencode` | Optional `--configure-ignore-file` appends `!.specify/` and `!.specify/**` to the project root `.ignore` so OpenCode's glob tool can discover hidden `.specify` files. See [OpenCode: hidden Spec Kit files](#opencode-hidden-spec-kit-files). | | [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | | [Qoder CLI](https://qoder.com/cli) | `qodercli` | | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | @@ -443,6 +443,7 @@ Some integrations accept additional options via `--integration-options`: | `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. | | `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. | | `claude` | `--no-model-invocation` | Write `disable-model-invocation: true` into the core Spec Kit command skills so Claude Code runs them only when invoked as `/speckit-` and keeps their descriptions out of its context. See [Claude: user-invocable-only skills](#claude-user-invocable-only-skills). | +| `opencode` | `--configure-ignore-file` | Append `!.specify/` and `!.specify/**` to the project root `.ignore` so OpenCode's glob tool can see hidden `.specify` files. See [OpenCode: hidden Spec Kit files](#opencode-hidden-spec-kit-files). | Example: @@ -496,6 +497,28 @@ specify init my-project --integration claude --integration-options="--no-model-i those commands modify installed skill files, and it overwrites local edits to Spec Kit-managed files). +### OpenCode: hidden Spec Kit files + +OpenCode 1.18.31 and 1.19.3 on darwin arm64 discover files with a glob tool that calls ripgrep without `--hidden`. In a temporary Spec Kit tree, `opencode debug rg files --glob '.specify/**/*'` (the same `Ripgrep.glob` call the glob tool makes, with no model involved) returned no paths. `**/*.md` on that same command returned only a visible `README.md`, and missed `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, and `.specify/scripts/bash/check-prerequisites.sh`. After the project root `.ignore` contained: + +``` +!.specify/ +!.specify/** +``` + +those patterns returned the constitution, both templates, and the script. A catch-all `**/*` already listed `.specify` files before the exceptions. `!.specify/**` alone still returned no `.specify/**/*` matches; the directory rule is what re-includes the hidden directory on this ripgrep path. Cloud-model fallback prompts and OpenCode 2.x were outside this check. + +Opt in at install or upgrade: + +```bash +specify init my-project --integration opencode --integration-options="--configure-ignore-file" +specify integration upgrade opencode --integration-options="--configure-ignore-file" +``` + +The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. Bytes already in the file stay as a prefix, including non-ASCII text, CRLF, and a missing final newline. Default installs leave `.ignore` untouched. The file is recorded with partial ownership. `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Delete the two lines manually to restore the previous glob behavior. + +`.ignore` is a shared ignore file. OpenCode's glob tool sees the rules because it searches with ripgrep, and other tools that read `.ignore` (including fd) can start showing `.specify` as well. `specify integration upgrade opencode` replays the stored flag. Passing `--integration-options` replaces the whole stored set (repeat `--configure-ignore-file` alongside any other flags, such as `--events false`); `--integration-options=""` clears it. + ## Scaffold a New Integration ```bash diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 73f1ae24fe..fd6b5d8bfc 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,10 +1,20 @@ """opencode integration.""" +import os from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any -from ..base import MarkdownIntegration +from ..base import IntegrationOption, MarkdownIntegration +from ..manifest import IntegrationManifest + +# OpenCode 1.18.31 and 1.19.3 glob through ripgrep without ``--hidden``. +# Root ``.ignore`` rules ``!.specify/`` and ``!.specify/**`` make +# ``.specify/**/*`` return constitution, template, and script paths on +# that path (``opencode debug rg files --glob``, the same ``Ripgrep.glob`` +# call the glob tool makes). ``!.specify/**`` alone does not. OpenCode 2.x +# was not part of that check. +_SPECIFY_IGNORE_RULES = ("!.specify/", "!.specify/**") class OpencodeIntegration(MarkdownIntegration): @@ -42,6 +52,44 @@ class OpencodeIntegration(MarkdownIntegration): events_config_file = "opencode.json" events_format = "ts-plugin" + @classmethod + def options(cls) -> list[IntegrationOption]: + opts = super().options() + opts.append( + IntegrationOption( + "--configure-ignore-file", + is_flag=True, + default=False, + help=( + "Append !.specify/ and !.specify/** to the project root " + ".ignore so OpenCode can glob hidden Spec Kit files. " + "Uninstall leaves .ignore in place." + ), + ) + ) + return opts + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Render commands, then honor an explicit ``.ignore`` opt-in. + + Command files stay on the Markdown layout from ``super().setup()``. + ``--configure-ignore-file`` appends missing ``!.specify/`` and + ``!.specify/**`` rules and records the root file as partial + ownership. Without the flag the root ``.ignore`` is left alone. + """ + created = super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + if (parsed_options or {}).get("configure_ignore_file") is True: + created.append(_configure_root_ignore(manifest)) + return created + def build_exec_args( self, prompt: str, @@ -74,3 +122,71 @@ def build_exec_args( if message: args.append(message) return args + + +def _ignore_newline(raw: bytes) -> bytes: + return b"\r\n" if b"\r\n" in raw else b"\n" + + +def _present_ignore_rules(text: str) -> set[str]: + present: set[str] = set() + for line in text.splitlines(): + if line in _SPECIFY_IGNORE_RULES: + present.add(line) + return present + + +def _with_specify_ignore_rules(raw: bytes) -> bytes: + """Append missing Spec Kit rules, keeping *raw* as a byte prefix. + + New rules use the file's newline (``\\n`` when *raw* is empty) and a + trailing newline. Rules already present as exact lines are not repeated. + """ + if not raw: + newline = b"\n" + body = newline.join(rule.encode("ascii") for rule in _SPECIFY_IGNORE_RULES) + return body + newline + + missing = [ + rule + for rule in _SPECIFY_IGNORE_RULES + if rule not in _present_ignore_rules(raw.decode("utf-8")) + ] + if not missing: + return raw + + newline = _ignore_newline(raw) + addition = b"" if raw.endswith(b"\n") else newline + addition += newline.join(rule.encode("ascii") for rule in missing) + newline + return raw + addition + + +def _configure_root_ignore(manifest: IntegrationManifest) -> Path: + """Record root ``.ignore`` with partial ownership. + + Symlinks are rejected before they are followed. Directories and + unreadable files raise without a write. ``record_file`` journals a + real change; an already-complete file is re-recorded in place. + """ + path = manifest.project_root / ".ignore" + if path.is_symlink(): + raise ValueError(f"Refusing to configure symlinked .ignore: {path}") + if path.exists() and not path.is_file(): + raise ValueError( + f"Refusing to configure .ignore because it is not a regular file: {path}" + ) + + raw = path.read_bytes() if path.exists() else b"" + updated = _with_specify_ignore_rules(raw) + if updated == raw and path.is_file(): + from .._file_changes import before_file_change + + before_file_change(path) + manifest.record_existing(".ignore", ownership="partial") + return path + + if path.exists() and not os.access(path, os.W_OK): + raise PermissionError(f"Cannot update .ignore at {path}") + if not path.exists() and not os.access(path.parent, os.W_OK): + raise PermissionError(f"Cannot create .ignore at {path}") + return manifest.record_file(".ignore", updated, ownership="partial") diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 947f885d8f..3d3e5a162a 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,6 +1,11 @@ """Tests for OpencodeIntegration.""" +import json import warnings +from pathlib import Path +from unittest.mock import patch + +import pytest from specify_cli.agents import CommandRegistrar from specify_cli.integrations import get_integration @@ -8,6 +13,8 @@ from .test_integration_base_markdown import MarkdownIntegrationTests +_OPT_IN = {"configure_ignore_file": True} + class TestOpencodeIntegration(MarkdownIntegrationTests): KEY = "opencode" @@ -200,3 +207,232 @@ def test_setup_writes_to_canonical_dir(self, tmp_path): assert canonical.is_dir() assert not legacy.exists() assert any(canonical.glob("speckit.*.md")) + + def test_configure_ignore_file_opt_in_records_partial_and_renders_commands(self, tmp_path): + """Opt-in appends both rules, renders commands, and records partial ownership.""" + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + created = integration.setup(tmp_path, manifest, parsed_options=_OPT_IN) + + ignore = tmp_path / ".ignore" + assert ignore.read_bytes() == b"!.specify/\n!.specify/**\n" + assert any(path.resolve() == ignore.resolve() for path in created) + assert manifest.ownership_modes[".ignore"] == "partial" + assert ".ignore" in manifest.files + + canonical = tmp_path / ".opencode" / "commands" + command_files = list(canonical.glob("speckit.*.md")) + assert command_files + plan = canonical / integration.command_filename("plan") + text = plan.read_text(encoding="utf-8") + assert "{SCRIPT}" not in text + assert "__AGENT__" not in text + + def test_configure_ignore_file_preserves_prefix_and_repeats_cleanly(self, tmp_path): + """Existing bytes stay a prefix; missing rules are appended once.""" + integration = get_integration(self.KEY) + cases = { + "empty": (b"", b"!.specify/\n!.specify/**\n"), + "lf-non-ascii-no-final-newline": ( + "café keep".encode(), + "café keep\n!.specify/\n!.specify/**\n".encode(), + ), + "crlf-non-ascii-no-final-newline": ( + "café\r\nkeep".encode(), + "café\r\nkeep\r\n!.specify/\r\n!.specify/**\r\n".encode(), + ), + "dir-rule-only": (b"!.specify/\n", b"!.specify/\n!.specify/**\n"), + "contents-rule-only": (b"!.specify/**\n", b"!.specify/**\n!.specify/\n"), + "both-rules": ( + "café\r\n!.specify/\r\n!.specify/**\r\n".encode(), + "café\r\n!.specify/\r\n!.specify/**\r\n".encode(), + ), + "broader-exclusions": ( + b"*\n.*\n.specify/**\n", + b"*\n.*\n.specify/**\n!.specify/\n!.specify/**\n", + ), + } + for name, (original, expected) in cases.items(): + root = tmp_path / name + root.mkdir() + ignore = root / ".ignore" + ignore.write_bytes(original) + manifest = IntegrationManifest(self.KEY, root) + integration.setup(root, manifest, parsed_options=_OPT_IN) + assert ignore.read_bytes() == expected + assert ignore.read_bytes().startswith(original) + assert manifest.ownership_modes[".ignore"] == "partial" + integration.setup( + root, IntegrationManifest(self.KEY, root), parsed_options=_OPT_IN + ) + assert ignore.read_bytes() == expected + + def test_without_flag_and_other_integrations_leave_ignore_untouched(self, tmp_path): + plain = tmp_path / "plain" + plain.mkdir() + get_integration(self.KEY).setup(plain, IntegrationManifest(self.KEY, plain)) + assert not (plain / ".ignore").exists() + + other_root = tmp_path / "other" + other_root.mkdir() + (other_root / ".ignore").write_bytes(b"user\n") + get_integration("gemini").setup( + other_root, IntegrationManifest("gemini", other_root) + ) + assert (other_root / ".ignore").read_bytes() == b"user\n" + + def test_flag_parses_with_events_and_persists_across_upgrade(self, tmp_path): + from specify_cli.integrations._helpers import _parse_integration_options + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + integration = get_integration(self.KEY) + parsed = _parse_integration_options( + integration, "--configure-ignore-file --events false" + ) + assert parsed == {"configure_ignore_file": True, "events": "false"} + names = [option.name for option in integration.options()] + assert "--events" in names + assert "--configure-ignore-file" in names + + project = _init_project( + tmp_path, "opencode", "--configure-ignore-file --events false" + ) + ignore = project / ".ignore" + state = json.loads( + (project / ".specify" / "integration.json").read_text(encoding="utf-8") + ) + settings = state["integration_settings"]["opencode"] + assert settings["parsed_options"] == { + "configure_ignore_file": True, + "events": "false", + } + + ignore.write_bytes(b"keep me\n") + result = _run_in_project(project, ["integration", "upgrade", "opencode", "--force"]) + assert result.exit_code == 0, result.output + assert ignore.read_bytes() == b"keep me\n!.specify/\n!.specify/**\n" + result = _run_in_project(project, ["integration", "upgrade", "opencode", "--force"]) + assert result.exit_code == 0, result.output + assert ignore.read_bytes() == b"keep me\n!.specify/\n!.specify/**\n" + + def test_uninstall_preserves_partial_ignore_even_when_forced(self, tmp_path): + integration = get_integration(self.KEY) + for force in (False, True): + root = tmp_path / f"force-{force}" + root.mkdir() + manifest = IntegrationManifest(self.KEY, root) + integration.setup(root, manifest, parsed_options=_OPT_IN) + ignore = root / ".ignore" + expected = ignore.read_bytes() + b"user-rule\n" + ignore.write_bytes(expected) + removed, skipped = integration.teardown(root, manifest, force=force) + assert ignore.read_bytes() == expected + assert ignore.resolve() not in {path.resolve() for path in removed} + assert ignore.resolve() in {path.resolve() for path in skipped} + + def test_configure_ignore_file_rejects_unsafe_destinations(self, tmp_path): + integration = get_integration(self.KEY) + outside = tmp_path / "outside-secret" + outside.write_bytes(b"secret\n") + + linked = tmp_path / "linked" + linked.mkdir() + link = linked / ".ignore" + link.symlink_to(outside) + with pytest.raises(ValueError, match="symlinked"): + integration.setup(linked, IntegrationManifest(self.KEY, linked), parsed_options=_OPT_IN) + assert link.is_symlink() + assert outside.read_bytes() == b"secret\n" + + directory = tmp_path / "directory" + directory.mkdir() + ignore_dir = directory / ".ignore" + ignore_dir.mkdir() + (ignore_dir / "user.txt").write_text("keep", encoding="utf-8") + with pytest.raises(ValueError, match="not a regular file"): + integration.setup( + directory, IntegrationManifest(self.KEY, directory), parsed_options=_OPT_IN + ) + assert (ignore_dir / "user.txt").read_text(encoding="utf-8") == "keep" + + unreadable = tmp_path / "unreadable" + unreadable.mkdir() + blocked = unreadable / ".ignore" + blocked.write_bytes(b"user\n") + blocked.chmod(0) + try: + with pytest.raises(OSError): + integration.setup( + unreadable, + IntegrationManifest(self.KEY, unreadable), + parsed_options=_OPT_IN, + ) + finally: + blocked.chmod(0o644) + assert blocked.read_bytes() == b"user\n" + + readonly = tmp_path / "readonly" + readonly.mkdir() + frozen = readonly / ".ignore" + frozen.write_bytes(b"user\n") + frozen.chmod(0o444) + try: + with pytest.raises(OSError): + integration.setup( + readonly, IntegrationManifest(self.KEY, readonly), parsed_options=_OPT_IN + ) + finally: + frozen.chmod(0o644) + assert frozen.read_bytes() == b"user\n" + + failing = tmp_path / "write-fail" + failing.mkdir() + target = failing / ".ignore" + target.write_bytes(b"user\n") + real_write = Path.write_bytes + + def fail_ignore_write(self, data): + if self.name == ".ignore": + raise OSError("disk full") + return real_write(self, data) + + with patch.object(Path, "write_bytes", fail_ignore_write): + with pytest.raises(OSError, match="disk full"): + integration.setup( + failing, IntegrationManifest(self.KEY, failing), parsed_options=_OPT_IN + ) + assert target.read_bytes() == b"user\n" + + def test_upgrade_rolls_back_existing_ignore_when_a_later_step_fails(self, tmp_path): + from specify_cli.integrations import installer + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "opencode") + ignore = project / ".ignore" + original = "café rule\r\n".encode() + ignore.write_bytes(original) + # Built-in upgrades open the file journal when an external package + # record is present. The sentinel starts that transaction without + # loading another adapter. + with ( + patch.object(installer, "read_records", return_value={"sample": {"id": "sample"}}), + patch.object(installer, "load_installed_integrations", return_value=[]), + patch( + "specify_cli.integrations.command_upgrade._write_integration_json", + side_effect=OSError("later failure"), + ), + ): + result = _run_in_project( + project, + [ + "integration", + "upgrade", + "opencode", + "--force", + "--integration-options", + "--configure-ignore-file", + ], + ) + assert result.exit_code != 0 + assert "later failure" in result.output + assert ignore.read_bytes() == original From b395863b8a49136b2197b2912b57445b234f9503 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:36:53 -0700 Subject: [PATCH 2/8] docs: add a language to the OpenCode .ignore code fence --- docs/reference/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 072165b957..341d148311 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -501,7 +501,7 @@ specify init my-project --integration claude --integration-options="--no-model-i OpenCode 1.18.31 and 1.19.3 on darwin arm64 discover files with a glob tool that calls ripgrep without `--hidden`. In a temporary Spec Kit tree, `opencode debug rg files --glob '.specify/**/*'` (the same `Ripgrep.glob` call the glob tool makes, with no model involved) returned no paths. `**/*.md` on that same command returned only a visible `README.md`, and missed `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, and `.specify/scripts/bash/check-prerequisites.sh`. After the project root `.ignore` contained: -``` +```text !.specify/ !.specify/** ``` From dca252d923cad0ba3ff67a261dd29dd15e7c2c3f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:13:41 -0700 Subject: [PATCH 3/8] fix(opencode): validate .ignore before setup and write it atomically Address the rest of the Copilot review on #5032. The code fence fix already landed in b395863; this covers the other four comments: - Document that upgrades still hash-check .ignore, that --force also overwrites edited command files, and that the stored flag must be cleared before removing the two rules. - Check .ignore (symlink, directory, unreadable, unwritable) before command files are rendered, and check it again before the write, so a rejected destination leaves existing command files unchanged. - Match the two rules as ASCII bytes so a non-UTF-8 .ignore is kept byte for byte instead of raising UnicodeDecodeError. - Write .ignore through the journal-aware atomic writer, keeping its file mode, and record partial ownership afterward, so a failed write can no longer truncate existing user rules. Refs #5028 --- docs/reference/integrations.md | 2 +- .../integrations/opencode/__init__.py | 57 +++++++++--------- .../integrations/test_integration_opencode.py | 58 +++++++++++++++---- 3 files changed, 79 insertions(+), 38 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 341d148311..70c0cd43b1 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -515,7 +515,7 @@ specify init my-project --integration opencode --integration-options="--configur specify integration upgrade opencode --integration-options="--configure-ignore-file" ``` -The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. Bytes already in the file stay as a prefix, including non-ASCII text, CRLF, and a missing final newline. Default installs leave `.ignore` untouched. The file is recorded with partial ownership. `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Delete the two lines manually to restore the previous glob behavior. +The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. Bytes already in the file stay as a prefix, including non-ASCII text, CRLF, and a missing final newline. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. `.ignore` is a shared ignore file. OpenCode's glob tool sees the rules because it searches with ripgrep, and other tools that read `.ignore` (including fd) can start showing `.specify` as well. `specify integration upgrade opencode` replays the stored flag. Passing `--integration-options` replaces the whole stored set (repeat `--configure-ignore-file` alongside any other flags, such as `--events false`); `--integration-options=""` clears it. diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index fd6b5d8bfc..6cab950de2 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,6 +1,7 @@ """opencode integration.""" import os +import stat from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -76,17 +77,20 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Render commands, then honor an explicit ``.ignore`` opt-in. + """Validate an explicit ``.ignore`` opt-in, then render commands. Command files stay on the Markdown layout from ``super().setup()``. ``--configure-ignore-file`` appends missing ``!.specify/`` and ``!.specify/**`` rules and records the root file as partial ownership. Without the flag the root ``.ignore`` is left alone. """ + configure = (parsed_options or {}).get("configure_ignore_file") is True + if configure: + _preflight_root_ignore(manifest) created = super().setup( project_root, manifest, parsed_options=parsed_options, **opts ) - if (parsed_options or {}).get("configure_ignore_file") is True: + if configure: created.append(_configure_root_ignore(manifest)) return created @@ -128,29 +132,23 @@ def _ignore_newline(raw: bytes) -> bytes: return b"\r\n" if b"\r\n" in raw else b"\n" -def _present_ignore_rules(text: str) -> set[str]: - present: set[str] = set() - for line in text.splitlines(): - if line in _SPECIFY_IGNORE_RULES: - present.add(line) - return present - - def _with_specify_ignore_rules(raw: bytes) -> bytes: """Append missing Spec Kit rules, keeping *raw* as a byte prefix. New rules use the file's newline (``\\n`` when *raw* is empty) and a - trailing newline. Rules already present as exact lines are not repeated. + trailing newline. Exact ASCII lines are matched without decoding, so a + non-UTF-8 prefix is preserved. """ if not raw: newline = b"\n" body = newline.join(rule.encode("ascii") for rule in _SPECIFY_IGNORE_RULES) return body + newline + present = set(raw.splitlines()) missing = [ rule for rule in _SPECIFY_IGNORE_RULES - if rule not in _present_ignore_rules(raw.decode("utf-8")) + if rule.encode("ascii") not in present ] if not missing: return raw @@ -161,13 +159,7 @@ def _with_specify_ignore_rules(raw: bytes) -> bytes: return raw + addition -def _configure_root_ignore(manifest: IntegrationManifest) -> Path: - """Record root ``.ignore`` with partial ownership. - - Symlinks are rejected before they are followed. Directories and - unreadable files raise without a write. ``record_file`` journals a - real change; an already-complete file is re-recorded in place. - """ +def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, bytes]: path = manifest.project_root / ".ignore" if path.is_symlink(): raise ValueError(f"Refusing to configure symlinked .ignore: {path}") @@ -175,9 +167,21 @@ def _configure_root_ignore(manifest: IntegrationManifest) -> Path: raise ValueError( f"Refusing to configure .ignore because it is not a regular file: {path}" ) - - raw = path.read_bytes() if path.exists() else b"" + try: + raw = path.read_bytes() if path.is_file() else b"" + except OSError as exc: + raise OSError(f"Cannot read .ignore at {path}") from exc updated = _with_specify_ignore_rules(raw) + if updated != raw and path.exists() and not os.access(path, os.W_OK): + raise PermissionError(f"Cannot update .ignore at {path}") + if updated != raw and not path.exists() and not os.access(path.parent, os.W_OK): + raise PermissionError(f"Cannot create .ignore at {path}") + return path, raw, updated + + +def _configure_root_ignore(manifest: IntegrationManifest) -> Path: + """Recheck root ``.ignore``, write it atomically, and record partial ownership.""" + path, raw, updated = _preflight_root_ignore(manifest) if updated == raw and path.is_file(): from .._file_changes import before_file_change @@ -185,8 +189,9 @@ def _configure_root_ignore(manifest: IntegrationManifest) -> Path: manifest.record_existing(".ignore", ownership="partial") return path - if path.exists() and not os.access(path, os.W_OK): - raise PermissionError(f"Cannot update .ignore at {path}") - if not path.exists() and not os.access(path.parent, os.W_OK): - raise PermissionError(f"Cannot create .ignore at {path}") - return manifest.record_file(".ignore", updated, ownership="partial") + from ...shared_infra import _write_shared_bytes + + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644 + _write_shared_bytes(manifest.project_root, path, updated, mode=mode) + manifest.record_existing(".ignore", ownership="partial") + return path diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 3d3e5a162a..cea7198d7d 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,8 +1,9 @@ """Tests for OpencodeIntegration.""" import json +import os +import stat import warnings -from pathlib import Path from unittest.mock import patch import pytest @@ -251,21 +252,28 @@ def test_configure_ignore_file_preserves_prefix_and_repeats_cleanly(self, tmp_pa b"*\n.*\n.specify/**\n", b"*\n.*\n.specify/**\n!.specify/\n!.specify/**\n", ), + "windows-1252-comment": ( + b"# caf\xe9\n", + b"# caf\xe9\n!.specify/\n!.specify/**\n", + ), } for name, (original, expected) in cases.items(): root = tmp_path / name root.mkdir() ignore = root / ".ignore" ignore.write_bytes(original) + ignore.chmod(0o640) manifest = IntegrationManifest(self.KEY, root) integration.setup(root, manifest, parsed_options=_OPT_IN) assert ignore.read_bytes() == expected assert ignore.read_bytes().startswith(original) + assert stat.S_IMODE(ignore.stat().st_mode) == 0o640 assert manifest.ownership_modes[".ignore"] == "partial" integration.setup( root, IntegrationManifest(self.KEY, root), parsed_options=_OPT_IN ) assert ignore.read_bytes() == expected + assert stat.S_IMODE(ignore.stat().st_mode) == 0o640 def test_without_flag_and_other_integrations_leave_ignore_untouched(self, tmp_path): plain = tmp_path / "plain" @@ -337,10 +345,14 @@ def test_configure_ignore_file_rejects_unsafe_destinations(self, tmp_path): linked = tmp_path / "linked" linked.mkdir() + integration.setup(linked, IntegrationManifest(self.KEY, linked)) + plan = linked / ".opencode" / "commands" / integration.command_filename("plan") + plan.write_bytes(b"custom-command\n") link = linked / ".ignore" link.symlink_to(outside) with pytest.raises(ValueError, match="symlinked"): integration.setup(linked, IntegrationManifest(self.KEY, linked), parsed_options=_OPT_IN) + assert plan.read_bytes() == b"custom-command\n" assert link.is_symlink() assert outside.read_bytes() == b"secret\n" @@ -353,6 +365,7 @@ def test_configure_ignore_file_rejects_unsafe_destinations(self, tmp_path): integration.setup( directory, IntegrationManifest(self.KEY, directory), parsed_options=_OPT_IN ) + assert not (directory / ".opencode").exists() assert (ignore_dir / "user.txt").read_text(encoding="utf-8") == "keep" unreadable = tmp_path / "unreadable" @@ -370,6 +383,7 @@ def test_configure_ignore_file_rejects_unsafe_destinations(self, tmp_path): finally: blocked.chmod(0o644) assert blocked.read_bytes() == b"user\n" + assert not (unreadable / ".opencode").exists() readonly = tmp_path / "readonly" readonly.mkdir() @@ -384,24 +398,46 @@ def test_configure_ignore_file_rejects_unsafe_destinations(self, tmp_path): finally: frozen.chmod(0o644) assert frozen.read_bytes() == b"user\n" + assert not (readonly / ".opencode").exists() failing = tmp_path / "write-fail" failing.mkdir() target = failing / ".ignore" - target.write_bytes(b"user\n") - real_write = Path.write_bytes - - def fail_ignore_write(self, data): - if self.name == ".ignore": - raise OSError("disk full") - return real_write(self, data) - - with patch.object(Path, "write_bytes", fail_ignore_write): + original = b"user-rule-keep-this-prefix-intact\n" + target.write_bytes(original) + real_fdopen = os.fdopen + + def fdopen_fail(fd, mode="r", *args, **kwargs): + stream = real_fdopen(fd, mode, *args, **kwargs) + if mode != "wb": + return stream + raw_write = stream.write + + class _Stream: + def write(self, data): + payload = bytes(data) + if b"!.specify/" in payload: + raw_write(payload[:8]) + raise OSError("disk full") + return raw_write(data) + + def __enter__(self): + return self + + def __exit__(self, *exc): + stream.close() + return False + + return _Stream() + + with patch("os.fdopen", fdopen_fail): with pytest.raises(OSError, match="disk full"): integration.setup( failing, IntegrationManifest(self.KEY, failing), parsed_options=_OPT_IN ) - assert target.read_bytes() == b"user\n" + assert target.read_bytes() == original + assert (failing / ".opencode" / "commands").is_dir() + assert list(failing.glob(".ignore.*")) == [] def test_upgrade_rolls_back_existing_ignore_when_a_later_step_fails(self, tmp_path): from specify_cli.integrations import installer From ed12123ec42539ace46402973f0c27fe6c5d0857 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:09:21 -0700 Subject: [PATCH 4/8] fix(opencode): check .ignore parent writability on replace - Require a writable project root whenever .ignore must change, including replacing an existing writable file, so setup fails before any command files are written. Add a read-only-root regression test. - Compare post-setup .ignore modes with the mode chmod() actually set, so the mode-preservation test passes on Windows. - Match the atomic writer's ..ignore. temp prefix in the cleanup assertion. --- .../integrations/opencode/__init__.py | 4 ++- .../integrations/test_integration_opencode.py | 34 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 6cab950de2..301d2faa6a 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -174,7 +174,9 @@ def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, updated = _with_specify_ignore_rules(raw) if updated != raw and path.exists() and not os.access(path, os.W_OK): raise PermissionError(f"Cannot update .ignore at {path}") - if updated != raw and not path.exists() and not os.access(path.parent, os.W_OK): + if updated != raw and not os.access(path.parent, os.W_OK): + if path.exists(): + raise PermissionError(f"Cannot update .ignore at {path}") raise PermissionError(f"Cannot create .ignore at {path}") return path, raw, updated diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index cea7198d7d..6847b4fae7 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -263,17 +263,18 @@ def test_configure_ignore_file_preserves_prefix_and_repeats_cleanly(self, tmp_pa ignore = root / ".ignore" ignore.write_bytes(original) ignore.chmod(0o640) + before_mode = stat.S_IMODE(ignore.stat().st_mode) manifest = IntegrationManifest(self.KEY, root) integration.setup(root, manifest, parsed_options=_OPT_IN) assert ignore.read_bytes() == expected assert ignore.read_bytes().startswith(original) - assert stat.S_IMODE(ignore.stat().st_mode) == 0o640 + assert stat.S_IMODE(ignore.stat().st_mode) == before_mode assert manifest.ownership_modes[".ignore"] == "partial" integration.setup( root, IntegrationManifest(self.KEY, root), parsed_options=_OPT_IN ) assert ignore.read_bytes() == expected - assert stat.S_IMODE(ignore.stat().st_mode) == 0o640 + assert stat.S_IMODE(ignore.stat().st_mode) == before_mode def test_without_flag_and_other_integrations_leave_ignore_untouched(self, tmp_path): plain = tmp_path / "plain" @@ -437,7 +438,34 @@ def __exit__(self, *exc): ) assert target.read_bytes() == original assert (failing / ".opencode" / "commands").is_dir() - assert list(failing.glob(".ignore.*")) == [] + assert list(failing.glob("..ignore.*")) == [] + + @pytest.mark.skipif( + os.name == "nt", + reason="Windows reports directories writable regardless of mode bits", + ) + def test_configure_ignore_file_rejects_writable_ignore_in_readonly_root(self, tmp_path): + integration = get_integration(self.KEY) + root = tmp_path / "readonly-root" + root.mkdir() + integration.setup(root, IntegrationManifest(self.KEY, root)) + plan = root / ".opencode" / "commands" / integration.command_filename("plan") + plan.write_bytes(b"custom-command\n") + ignore = root / ".ignore" + ignore.write_bytes(b"user\n") + ignore.chmod(0o644) + root.chmod(0o555) + try: + assert os.access(ignore, os.W_OK) + assert not os.access(root, os.W_OK) + with pytest.raises(PermissionError, match="Cannot update .ignore"): + integration.setup( + root, IntegrationManifest(self.KEY, root), parsed_options=_OPT_IN + ) + finally: + root.chmod(0o755) + assert ignore.read_bytes() == b"user\n" + assert plan.read_bytes() == b"custom-command\n" def test_upgrade_rolls_back_existing_ignore_when_a_later_step_fails(self, tmp_path): from specify_cli.integrations import installer From 246dd00c92575dff639c2f13c96947048d0c79cf Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:11:40 -0700 Subject: [PATCH 5/8] fix(opencode): reject overridden .ignore exceptions and non-UTF-8 files - Evaluate the final .ignore in file order with pathspec's GitIgnoreSpec and fail setup when a later exclusion, such as .specify/** after the !.specify/ and !.specify/** rules, still hides a Spec Kit path. A later exception that restores visibility is accepted. Add ordering regression cases. - Validate UTF-8 during preflight, before command files are written, and reject other encodings with a conversion hint, since ripgrep stops at the first invalid byte. Replace the windows-1252 positive fixture with a rejection test and update the docstring. --- docs/integrations/opencode.md | 2 +- .../integrations/opencode/__init__.py | 40 ++++++++++++++++++- .../integrations/test_integration_opencode.py | 24 +++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/integrations/opencode.md b/docs/integrations/opencode.md index 5d913bf097..7445291b5a 100644 --- a/docs/integrations/opencode.md +++ b/docs/integrations/opencode.md @@ -56,6 +56,6 @@ specify init my-project --integration opencode --integration-options="--configur specify integration upgrade opencode --integration-options="--configure-ignore-file" ``` -The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. Bytes already in the file stay as a prefix, including non-ASCII text, CRLF, and a missing final newline. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. +The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. A later line that excludes `.specify` or files under it, such as `.specify/**`, `*`, `*.md`, or `.specify/templates/**`, is rejected; move `!.specify/` and `!.specify/**` below it and rerun. Bytes already in the file stay as a prefix, including UTF-8 non-ASCII text, CRLF, and a missing final newline. Non-UTF-8 content is rejected before command files are written; convert the file to UTF-8 and rerun, because ripgrep stops at the first invalid byte. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. `.ignore` is a shared ignore file. OpenCode's glob tool sees the rules because it searches with ripgrep, and other tools that read `.ignore` (including fd) can start showing `.specify` as well. `specify integration upgrade opencode` replays the stored flag. Passing `--integration-options` replaces the whole stored set (repeat `--configure-ignore-file` alongside any other flags, such as `--events false`); `--integration-options=""` clears it. diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 301d2faa6a..a7eece1ff6 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any +import pathspec + from ..base import IntegrationOption, MarkdownIntegration from ..manifest import IntegrationManifest @@ -16,6 +18,11 @@ # call the glob tool makes). ``!.specify/**`` alone does not. OpenCode 2.x # was not part of that check. _SPECIFY_IGNORE_RULES = ("!.specify/", "!.specify/**") +# Paths a later exclusion must not match after the Spec Kit rules. +_SPECIFY_PROBES = ( + ".specify/", ".specify/memory/constitution.md", ".specify/templates/spec-template.md", + ".specify/scripts/bash/common.sh", ".specify/scripts/powershell/common.ps1", +) class OpencodeIntegration(MarkdownIntegration): @@ -132,12 +139,33 @@ def _ignore_newline(raw: bytes) -> bytes: return b"\r\n" if b"\r\n" in raw else b"\n" +def _reject_hidden_specify_paths(path: Path, updated: bytes) -> None: + """Fail when *updated* still hides Spec Kit paths, evaluated in file order.""" + patterns = [] + for line in updated.decode("utf-8").splitlines(): + try: + patterns.append(pathspec.GitIgnoreSpec.from_lines([line]).patterns[0]) + except (ValueError, IndexError): + continue + spec = pathspec.GitIgnoreSpec(patterns) + probes = set(_SPECIFY_PROBES) + specify = path.parent / ".specify" + if specify.is_dir() and not specify.is_symlink(): + probes.update(f.relative_to(path.parent).as_posix() for f in specify.rglob("*") if f.is_file()) + hidden = sorted(probe for probe in probes if spec.match_file(probe)) + if hidden: + raise ValueError( + f"A later exclusion in {path} hides {hidden[0]}. " + "Move !.specify/ and !.specify/** below it, then rerun." + ) + + def _with_specify_ignore_rules(raw: bytes) -> bytes: """Append missing Spec Kit rules, keeping *raw* as a byte prefix. New rules use the file's newline (``\\n`` when *raw* is empty) and a - trailing newline. Exact ASCII lines are matched without decoding, so a - non-UTF-8 prefix is preserved. + trailing newline. Exact ASCII lines are matched without decoding. + Non-UTF-8 files are rejected before this runs and are not preserved. """ if not raw: newline = b"\n" @@ -171,7 +199,15 @@ def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, raw = path.read_bytes() if path.is_file() else b"" except OSError as exc: raise OSError(f"Cannot read .ignore at {path}") from exc + try: + raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError( + f"{path} is not UTF-8 ({exc.reason} at byte {exc.start}). " + "Convert it to UTF-8 and rerun; ripgrep stops at the first invalid byte." + ) from exc updated = _with_specify_ignore_rules(raw) + _reject_hidden_specify_paths(path, updated) if updated != raw and path.exists() and not os.access(path, os.W_OK): raise PermissionError(f"Cannot update .ignore at {path}") if updated != raw and not os.access(path.parent, os.W_OK): diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 6847b4fae7..bfdc8b200e 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -252,10 +252,6 @@ def test_configure_ignore_file_preserves_prefix_and_repeats_cleanly(self, tmp_pa b"*\n.*\n.specify/**\n", b"*\n.*\n.specify/**\n!.specify/\n!.specify/**\n", ), - "windows-1252-comment": ( - b"# caf\xe9\n", - b"# caf\xe9\n!.specify/\n!.specify/**\n", - ), } for name, (original, expected) in cases.items(): root = tmp_path / name @@ -276,6 +272,26 @@ def test_configure_ignore_file_preserves_prefix_and_repeats_cleanly(self, tmp_pa assert ignore.read_bytes() == expected assert stat.S_IMODE(ignore.stat().st_mode) == before_mode + def test_configure_ignore_file_rejects_non_utf8_and_overrides(self, tmp_path): + integration = get_integration(self.KEY) + for index, (original, match) in enumerate(( + (b"# caf\xe9\n", "Convert it to UTF-8"), + (b"!.specify/\n!.specify/**\n.specify/**\n", "later exclusion"), + (b"!.specify/\n!.specify/**\n/.specify/templates/**\n", "later exclusion"), + (b"!.specify/\n!.specify/**\n.specify/memory/custom.md\n", "custom.md"), + )): + root = tmp_path / str(index) + (root / ".specify" / "memory").mkdir(parents=True) + (root / ".specify" / "memory" / "custom.md").write_text("x\n") + (root / ".ignore").write_bytes(original) + with pytest.raises(ValueError, match=match): + integration.setup(root, IntegrationManifest(self.KEY, root), parsed_options=_OPT_IN) + assert (root / ".ignore").read_bytes() == original and not (root / ".opencode").exists() + restored = b"!.specify/\n!.specify/**\n.specify/**\n!.specify/**\n" + (tmp_path / ".ignore").write_bytes(restored) + integration.setup(tmp_path, IntegrationManifest(self.KEY, tmp_path), parsed_options=_OPT_IN) + assert (tmp_path / ".ignore").read_bytes() == restored + def test_without_flag_and_other_integrations_leave_ignore_untouched(self, tmp_path): plain = tmp_path / "plain" plain.mkdir() From f82d057a8bcb74a2bda1ba11adc5929f4de00c21 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:21:44 -0700 Subject: [PATCH 6/8] fix(opencode): validate planned files and parent dirs in .ignore - Check the bundled templates and scripts, the constitution and .specify/.gitignore even before init creates them, so a later .ignore line such as .specify/templates/plan-template.md fails before any command files are written. Add a regression where that template does not exist yet. - Check each required path's parent directories in file order, since ripgrep prunes an excluded directory before it reaches a re-included child. Add regressions for .specify/memory followed by !.specify/memory/*.md and for a later rule that restores the parent. --- docs/integrations/opencode.md | 2 +- .../integrations/opencode/__init__.py | 39 +++++++++++++++---- .../integrations/test_integration_opencode.py | 8 ++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/integrations/opencode.md b/docs/integrations/opencode.md index 7445291b5a..ab1c9ab369 100644 --- a/docs/integrations/opencode.md +++ b/docs/integrations/opencode.md @@ -56,6 +56,6 @@ specify init my-project --integration opencode --integration-options="--configur specify integration upgrade opencode --integration-options="--configure-ignore-file" ``` -The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. A later line that excludes `.specify` or files under it, such as `.specify/**`, `*`, `*.md`, or `.specify/templates/**`, is rejected; move `!.specify/` and `!.specify/**` below it and rerun. Bytes already in the file stay as a prefix, including UTF-8 non-ASCII text, CRLF, and a missing final newline. Non-UTF-8 content is rejected before command files are written; convert the file to UTF-8 and rerun, because ripgrep stops at the first invalid byte. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. +The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. A later line that excludes `.specify`, a parent directory such as `.specify/memory`, or a file under it (including a template or script init has not created yet, such as `.specify/templates/plan-template.md`) is rejected; move `!.specify/` and `!.specify/**` below it and rerun. A child exception does not uncover a file while its parent directory stays excluded. Bytes already in the file stay as a prefix, including UTF-8 non-ASCII text, CRLF, and a missing final newline. Non-UTF-8 content is rejected before command files are written; convert the file to UTF-8 and rerun, because ripgrep stops at the first invalid byte. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. `.ignore` is a shared ignore file. OpenCode's glob tool sees the rules because it searches with ripgrep, and other tools that read `.ignore` (including fd) can start showing `.specify` as well. `specify integration upgrade opencode` replays the stored flag. Passing `--integration-options` replaces the whole stored set (repeat `--configure-ignore-file` alongside any other flags, such as `--events false`); `--integration-options=""` clears it. diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index a7eece1ff6..ee2ff4e466 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -18,11 +18,6 @@ # call the glob tool makes). ``!.specify/**`` alone does not. OpenCode 2.x # was not part of that check. _SPECIFY_IGNORE_RULES = ("!.specify/", "!.specify/**") -# Paths a later exclusion must not match after the Spec Kit rules. -_SPECIFY_PROBES = ( - ".specify/", ".specify/memory/constitution.md", ".specify/templates/spec-template.md", - ".specify/scripts/bash/common.sh", ".specify/scripts/powershell/common.ps1", -) class OpencodeIntegration(MarkdownIntegration): @@ -141,18 +136,46 @@ def _ignore_newline(raw: bytes) -> bytes: def _reject_hidden_specify_paths(path: Path, updated: bytes) -> None: """Fail when *updated* still hides Spec Kit paths, evaluated in file order.""" + from ..._assets import _locate_core_pack, _repo_root + patterns = [] for line in updated.decode("utf-8").splitlines(): try: patterns.append(pathspec.GitIgnoreSpec.from_lines([line]).patterns[0]) except (ValueError, IndexError): continue - spec = pathspec.GitIgnoreSpec(patterns) - probes = set(_SPECIFY_PROBES) + + def ignored(candidate: str) -> bool: + state = False + for pattern in patterns: + hit = pattern.include is not None and pattern.match_file(candidate) is not None + state = bool(pattern.include) if hit else state + return state + + def hides(probe: str) -> bool: + parts = [part for part in probe.split("/") if part] + built, ancestors = "", parts if probe.endswith("/") else parts[:-1] + for part in ancestors: + built = f"{built}/{part}" if built else part + if ignored(built + "/"): + return True + return not probe.endswith("/") and ignored(probe) + + pack, repo = _locate_core_pack(), _repo_root() + probes = {".specify/", ".specify/.gitignore", ".specify/memory/constitution.md"} + for kind in ("templates", "scripts"): + base = (pack if pack and (pack / kind).is_dir() else repo) / kind + walk = () if not base.is_dir() else (base.iterdir() if kind == "templates" else base.rglob("*")) + probes.update( + f".specify/{kind}/{item.relative_to(base).as_posix()}" + for item in walk + if item.is_file() and "__pycache__" not in item.parts + and not item.name.startswith(".") and item.name != "vscode-settings.json" + ) specify = path.parent / ".specify" if specify.is_dir() and not specify.is_symlink(): probes.update(f.relative_to(path.parent).as_posix() for f in specify.rglob("*") if f.is_file()) - hidden = sorted(probe for probe in probes if spec.match_file(probe)) + hidden = sorted(probe for probe in probes if hides(probe)) if hidden: raise ValueError( f"A later exclusion in {path} hides {hidden[0]}. " diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index bfdc8b200e..b2b1dce40c 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -279,6 +279,8 @@ def test_configure_ignore_file_rejects_non_utf8_and_overrides(self, tmp_path): (b"!.specify/\n!.specify/**\n.specify/**\n", "later exclusion"), (b"!.specify/\n!.specify/**\n/.specify/templates/**\n", "later exclusion"), (b"!.specify/\n!.specify/**\n.specify/memory/custom.md\n", "custom.md"), + (b"!.specify/\n!.specify/**\n.specify/templates/plan-template.md\n", "plan-template.md"), + (b"!.specify/\n!.specify/**\n.specify/memory\n!.specify/memory/*.md\n", "constitution.md"), )): root = tmp_path / str(index) (root / ".specify" / "memory").mkdir(parents=True) @@ -291,6 +293,12 @@ def test_configure_ignore_file_rejects_non_utf8_and_overrides(self, tmp_path): (tmp_path / ".ignore").write_bytes(restored) integration.setup(tmp_path, IntegrationManifest(self.KEY, tmp_path), parsed_options=_OPT_IN) assert (tmp_path / ".ignore").read_bytes() == restored + kept = b"!.specify/\n!.specify/**\n.specify/memory\n!.specify/memory/\n!.specify/memory/**\n" + reinclude = tmp_path / "reinclude" + reinclude.mkdir() + (reinclude / ".ignore").write_bytes(kept) + integration.setup(reinclude, IntegrationManifest(self.KEY, reinclude), parsed_options=_OPT_IN) + assert (reinclude / ".ignore").read_bytes() == kept def test_without_flag_and_other_integrations_leave_ignore_untouched(self, tmp_path): plain = tmp_path / "plain" From e7d405b47dcc5eda9a390645a5c9766d6432474a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:46:04 -0700 Subject: [PATCH 7/8] opencode: match ripgrep line splitting and gitignore precedence Split .ignore content on LF and strip a trailing CR only, so a bare CR or a Unicode line separator inside a comment no longer starts a new rule. Evaluate the full GitIgnoreSpec with match_file() for the probe paths, keeping the ancestor-directory checks, so file-versus-directory rule precedence matches ripgrep. Add regressions for each case. --- .../integrations/opencode/__init__.py | 34 +++++++++++++------ .../integrations/test_integration_opencode.py | 13 +++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index ee2ff4e466..7d2b073a50 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -134,22 +134,34 @@ def _ignore_newline(raw: bytes) -> bytes: return b"\r\n" if b"\r\n" in raw else b"\n" +def _split_ignore_lines(raw: bytes) -> list[bytes]: + lines = raw.split(b"\n") + last = len(lines) - 1 + return [ + line[:-1] if index < last and line.endswith(b"\r") else line + for index, line in enumerate(lines) + ] + + def _reject_hidden_specify_paths(path: Path, updated: bytes) -> None: """Fail when *updated* still hides Spec Kit paths, evaluated in file order.""" from ..._assets import _locate_core_pack, _repo_root - patterns = [] - for line in updated.decode("utf-8").splitlines(): + parsed = [] + for raw_line in _split_ignore_lines(updated): + text = raw_line.decode("utf-8") try: - patterns.append(pathspec.GitIgnoreSpec.from_lines([line]).patterns[0]) - except (ValueError, IndexError): + pathspec.GitIgnoreSpec.from_lines([text]) + except ValueError: continue + parsed.append(text) + spec = pathspec.GitIgnoreSpec.from_lines(parsed) - def ignored(candidate: str) -> bool: + def ancestor_excluded(candidate: str) -> bool: state = False - for pattern in patterns: - hit = pattern.include is not None and pattern.match_file(candidate) is not None - state = bool(pattern.include) if hit else state + for pattern in spec.patterns: + if pattern.include is not None and pattern.match_file(candidate) is not None: + state = bool(pattern.include) return state def hides(probe: str) -> bool: @@ -157,9 +169,9 @@ def hides(probe: str) -> bool: built, ancestors = "", parts if probe.endswith("/") else parts[:-1] for part in ancestors: built = f"{built}/{part}" if built else part - if ignored(built + "/"): + if ancestor_excluded(built + "/"): return True - return not probe.endswith("/") and ignored(probe) + return not probe.endswith("/") and spec.match_file(probe) pack, repo = _locate_core_pack(), _repo_root() probes = {".specify/", ".specify/.gitignore", ".specify/memory/constitution.md"} @@ -195,7 +207,7 @@ def _with_specify_ignore_rules(raw: bytes) -> bytes: body = newline.join(rule.encode("ascii") for rule in _SPECIFY_IGNORE_RULES) return body + newline - present = set(raw.splitlines()) + present = set(_split_ignore_lines(raw)) missing = [ rule for rule in _SPECIFY_IGNORE_RULES diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index b2b1dce40c..7459e6470d 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -252,6 +252,14 @@ def test_configure_ignore_file_preserves_prefix_and_repeats_cleanly(self, tmp_pa b"*\n.*\n.specify/**\n", b"*\n.*\n.specify/**\n!.specify/\n!.specify/**\n", ), + "bare-cr-comment": ( + b"# user comment\r!.specify/\n!.specify/**\n", + b"# user comment\r!.specify/\n!.specify/**\n!.specify/\n", + ), + "unicode-separator-comment": ( + "!.specify/\n!.specify/**\n# note\u2028.specify/memory\n".encode(), + "!.specify/\n!.specify/**\n# note\u2028.specify/memory\n".encode(), + ), } for name, (original, expected) in cases.items(): root = tmp_path / name @@ -281,6 +289,11 @@ def test_configure_ignore_file_rejects_non_utf8_and_overrides(self, tmp_path): (b"!.specify/\n!.specify/**\n.specify/memory/custom.md\n", "custom.md"), (b"!.specify/\n!.specify/**\n.specify/templates/plan-template.md\n", "plan-template.md"), (b"!.specify/\n!.specify/**\n.specify/memory\n!.specify/memory/*.md\n", "constitution.md"), + (b"!.specify/**\n*.md\n", "constitution.md"), + ( + b"!.specify/\n!.specify/**\n.specify/memory/constitution.md\n!.specify/\n", + "constitution.md", + ), )): root = tmp_path / str(index) (root / ".specify" / "memory").mkdir(parents=True) From 5aaaeab0ecce1f9be9997e22251e5a1ea407f5b4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 29 Oct 2026 07:53:43 -0700 Subject: [PATCH 8/8] opencode: check .ignore before shared writes and only for installed script variants Add an IntegrationBase.preflight_setup() hook that install, switch and upgrade call before they write shared templates or scripts. OpenCode uses it to run the --configure-ignore-file checks, so a symlinked or hiding .ignore now fails a forced upgrade without touching shared files. The .ignore probe now covers only the script variants the selected --script installs (via the new shared_infra.script_variant_dirs()), plus files already under .specify/. --- docs/integrations/opencode.md | 2 +- src/specify_cli/integrations/_helpers.py | 21 +++++++ src/specify_cli/integrations/base.py | 14 +++++ .../integrations/command_install.py | 5 +- .../integrations/command_switch.py | 9 ++- .../integrations/command_upgrade.py | 5 +- .../integrations/opencode/__init__.py | 45 +++++++++++--- src/specify_cli/shared_infra.py | 14 +++-- .../integrations/test_integration_opencode.py | 61 +++++++++++++++++++ 9 files changed, 157 insertions(+), 19 deletions(-) diff --git a/docs/integrations/opencode.md b/docs/integrations/opencode.md index ab1c9ab369..b7cf65a15d 100644 --- a/docs/integrations/opencode.md +++ b/docs/integrations/opencode.md @@ -56,6 +56,6 @@ specify init my-project --integration opencode --integration-options="--configur specify integration upgrade opencode --integration-options="--configure-ignore-file" ``` -The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. A later line that excludes `.specify`, a parent directory such as `.specify/memory`, or a file under it (including a template or script init has not created yet, such as `.specify/templates/plan-template.md`) is rejected; move `!.specify/` and `!.specify/**` below it and rerun. A child exception does not uncover a file while its parent directory stays excluded. Bytes already in the file stay as a prefix, including UTF-8 non-ASCII text, CRLF, and a missing final newline. Non-UTF-8 content is rejected before command files are written; convert the file to UTF-8 and rerun, because ripgrep stops at the first invalid byte. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. +The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. A later line that excludes `.specify`, a parent directory such as `.specify/memory`, or a file under it (including a template or script init has not created yet, such as `.specify/templates/plan-template.md`) is rejected; move `!.specify/` and `!.specify/**` below it and rerun. Bundled scripts are checked only for the `--script` variant being installed, plus any script already under `.specify/scripts`. `specify integration install`, `switch` and `upgrade` run this check before they write shared templates or scripts, so a rejected `.ignore` leaves the project unchanged, including with `--force`. A child exception does not uncover a file while its parent directory stays excluded. Bytes already in the file stay as a prefix, including UTF-8 non-ASCII text, CRLF, and a missing final newline. Non-UTF-8 content is rejected before command files are written; convert the file to UTF-8 and rerun, because ripgrep stops at the first invalid byte. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines. `.ignore` is a shared ignore file. OpenCode's glob tool sees the rules because it searches with ripgrep, and other tools that read `.ignore` (including fd) can start showing `.specify` as well. `specify integration upgrade opencode` replays the stored flag. Passing `--integration-options` replaces the whole stored set (repeat `--configure-ignore-file` alongside any other flags, such as `--events false`); `--integration-options=""` clears it. diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 9bfec2e8c7..d038cae234 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -246,6 +246,27 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, return parsed or None +def _preflight_setup_or_exit( + integration: Any, + project_root: Path, + parsed_options: dict[str, Any] | None, + script_type: str, + action: str, +) -> None: + """Run ``integration.preflight_setup()`` and exit before any write on refusal.""" + try: + integration.preflight_setup( + project_root, parsed_options, script_type=script_type + ) + except (ValueError, OSError) as exc: + console.print( + f"[red]Error:[/red] Cannot {action} integration " + f"'{escape(integration.key)}': {escape(_cli_error_detail(exc))}" + ) + console.print("No files were changed.") + raise typer.Exit(1) from exc + + def _resolve_integration_options( integration: Any, state: dict[str, Any], diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 259ded3191..8c1f861fc3 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -998,6 +998,20 @@ def process_template( return content + def preflight_setup( + self, + project_root: Path, + parsed_options: dict[str, Any] | None = None, + *, + script_type: str | None = None, + ) -> None: + """Reject a planned ``setup()`` before the CLI writes shared files. + + Install, switch and upgrade call this before touching shared + infrastructure. Raise ``ValueError`` or ``OSError`` to refuse. + The default accepts every setup. + """ + def setup( self, project_root: Path, diff --git a/src/specify_cli/integrations/command_install.py b/src/specify_cli/integrations/command_install.py index c6b8e3e728..a39eb7cec0 100644 --- a/src/specify_cli/integrations/command_install.py +++ b/src/specify_cli/integrations/command_install.py @@ -20,7 +20,7 @@ ) from ._commands import integration_app from ._lifecycle import external_lifecycle, lifecycle_owns_rollback, lifecycle_success -from ._helpers import _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _update_init_options_for_integration, _write_integration_json +from ._helpers import _cli_error_detail, _cli_phase_label, _get_speckit_version, _preflight_setup_or_exit, _read_integration_json, _refresh_init_options_speckit_version, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _update_init_options_for_integration, _write_integration_json @integration_app.command("install") @@ -99,6 +99,9 @@ def integration_install( raw_options, parsed_options = _resolve_integration_options( integration, current, key, integration_options ) + _preflight_setup_or_exit( + integration, project_root, parsed_options, selected_script, "install" + ) # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). diff --git a/src/specify_cli/integrations/command_switch.py b/src/specify_cli/integrations/command_switch.py index 24d0fd2c5a..6c38542baa 100644 --- a/src/specify_cli/integrations/command_switch.py +++ b/src/specify_cli/integrations/command_switch.py @@ -16,7 +16,7 @@ ) from ._commands import integration_app from ._lifecycle import external_lifecycle, lifecycle_owns_rollback, lifecycle_success -from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _clear_init_options_for_integration, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json +from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _clear_init_options_for_integration, _cli_error_detail, _cli_phase_label, _get_speckit_version, _preflight_setup_or_exit, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json def _missing_required_options(integration, parsed_options) -> list[str]: @@ -152,6 +152,13 @@ def integration_switch( ) console.print("No files were changed.") raise typer.Exit(1) + _preflight_setup_or_exit( + target_integration, + project_root, + target_parsed_options, + selected_script, + "switch to", + ) # Phase 1: Uninstall current integration (if any) if installed_key: diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 004efa69af..32ff9b5253 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -24,7 +24,7 @@ ) from ._commands import integration_app from ._lifecycle import external_lifecycle, lifecycle_success -from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json +from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _preflight_setup_or_exit, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json @integration_app.command("upgrade") @@ -94,6 +94,9 @@ def integration_upgrade( raw_options, parsed_options = _resolve_integration_options( integration, current, key, integration_options ) + _preflight_setup_or_exit( + integration, project_root, parsed_options, selected_script, "upgrade" + ) legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending( integration, diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 7d2b073a50..93e990aeb4 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -87,15 +87,27 @@ def setup( ownership. Without the flag the root ``.ignore`` is left alone. """ configure = (parsed_options or {}).get("configure_ignore_file") is True + script_type = opts.get("script_type") if configure: - _preflight_root_ignore(manifest) + _preflight_root_ignore(manifest.project_root, script_type) created = super().setup( project_root, manifest, parsed_options=parsed_options, **opts ) if configure: - created.append(_configure_root_ignore(manifest)) + created.append(_configure_root_ignore(manifest, script_type)) return created + def preflight_setup( + self, + project_root: Path, + parsed_options: dict[str, Any] | None = None, + *, + script_type: str | None = None, + ) -> None: + """Refuse an unusable ``.ignore`` before shared files are written.""" + if (parsed_options or {}).get("configure_ignore_file") is True: + _preflight_root_ignore(project_root, script_type) + def build_exec_args( self, prompt: str, @@ -143,9 +155,17 @@ def _split_ignore_lines(raw: bytes) -> list[bytes]: ] -def _reject_hidden_specify_paths(path: Path, updated: bytes) -> None: - """Fail when *updated* still hides Spec Kit paths, evaluated in file order.""" +def _reject_hidden_specify_paths( + path: Path, updated: bytes, script_type: str | None = None +) -> None: + """Fail when *updated* still hides Spec Kit paths, evaluated in file order. + + Bundled scripts are probed only for the variants *script_type* installs + (every variant when it is unknown); files already under ``.specify/`` + are always probed. + """ from ..._assets import _locate_core_pack, _repo_root + from ...shared_infra import script_variant_dirs parsed = [] for raw_line in _split_ignore_lines(updated): @@ -175,12 +195,15 @@ def hides(probe: str) -> bool: pack, repo = _locate_core_pack(), _repo_root() probes = {".specify/", ".specify/.gitignore", ".specify/memory/constitution.md"} + variants = None if script_type is None else set(script_variant_dirs(script_type)) for kind in ("templates", "scripts"): base = (pack if pack and (pack / kind).is_dir() else repo) / kind walk = () if not base.is_dir() else (base.iterdir() if kind == "templates" else base.rglob("*")) probes.update( f".specify/{kind}/{item.relative_to(base).as_posix()}" for item in walk + if kind == "templates" or variants is None + or item.relative_to(base).parts[0] in variants if item.is_file() and "__pycache__" not in item.parts and not item.name.startswith(".") and item.name != "vscode-settings.json" ) @@ -222,8 +245,10 @@ def _with_specify_ignore_rules(raw: bytes) -> bytes: return raw + addition -def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, bytes]: - path = manifest.project_root / ".ignore" +def _preflight_root_ignore( + project_root: Path, script_type: str | None = None +) -> tuple[Path, bytes, bytes]: + path = project_root / ".ignore" if path.is_symlink(): raise ValueError(f"Refusing to configure symlinked .ignore: {path}") if path.exists() and not path.is_file(): @@ -242,7 +267,7 @@ def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, "Convert it to UTF-8 and rerun; ripgrep stops at the first invalid byte." ) from exc updated = _with_specify_ignore_rules(raw) - _reject_hidden_specify_paths(path, updated) + _reject_hidden_specify_paths(path, updated, script_type) if updated != raw and path.exists() and not os.access(path, os.W_OK): raise PermissionError(f"Cannot update .ignore at {path}") if updated != raw and not os.access(path.parent, os.W_OK): @@ -252,9 +277,11 @@ def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, return path, raw, updated -def _configure_root_ignore(manifest: IntegrationManifest) -> Path: +def _configure_root_ignore( + manifest: IntegrationManifest, script_type: str | None = None +) -> Path: """Recheck root ``.ignore``, write it atomically, and record partial ownership.""" - path, raw, updated = _preflight_root_ignore(manifest) + path, raw, updated = _preflight_root_ignore(manifest.project_root, script_type) if updated == raw and path.is_file(): from .._file_changes import before_file_change diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 134e329758..44960706a1 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -464,6 +464,13 @@ def refresh_shared_templates( console.print(f" {rel}") +def script_variant_dirs(script_type: str) -> tuple[str, ...]: + """Return the ``scripts/`` variant directories installed for *script_type*.""" + if script_type == "py": + return ("python", "powershell" if os.name == "nt" else "bash") + return ("bash" if script_type == "sh" else "powershell",) + + def install_shared_infra( project_path: Path, script_type: str, @@ -515,12 +522,7 @@ def _is_managed(rel: str, dst: Path) -> bool: # manifest entries the core no longer ships (stale-script cleanup, #3076). seen_rels: set[str] = set() scanned_variant_dirs: set[str] = set() - shell_variant = "powershell" if os.name == "nt" else "bash" - variant_dirs = ( - ("python", shell_variant) - if script_type == "py" - else ("bash" if script_type == "sh" else "powershell",) - ) + variant_dirs = script_variant_dirs(script_type) def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]: """Return (write, bucket) where bucket is 'skip', 'preserved', or None.""" diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 7459e6470d..075fc272cc 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -537,3 +537,64 @@ def test_upgrade_rolls_back_existing_ignore_when_a_later_step_fails(self, tmp_pa assert result.exit_code != 0 assert "later failure" in result.output assert ignore.read_bytes() == original + + def test_forced_upgrade_rejects_bad_ignore_before_shared_writes(self, tmp_path): + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "opencode") + template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "common.sh" + template.write_text("custom template\n", encoding="utf-8") + script.write_text("# custom script\n", encoding="utf-8") + outside = tmp_path / "outside-ignore" + outside.write_bytes(b"secret\n") + ignore = project / ".ignore" + for setup_ignore, match in ( + (lambda: ignore.symlink_to(outside), "symlinked"), + (lambda: ignore.write_bytes(b"!.specify/\n!.specify/**\n.specify/**\n"), "later exclusion"), + ): + setup_ignore() + result = _run_in_project( + project, + [ + "integration", + "upgrade", + "opencode", + "--force", + "--integration-options", + "--configure-ignore-file", + ], + ) + assert result.exit_code != 0 + assert match in " ".join(result.output.split()) + assert template.read_text(encoding="utf-8") == "custom template\n" + assert script.read_text(encoding="utf-8") == "# custom script\n" + assert outside.read_bytes() == b"secret\n" + ignore.unlink() + + def test_ignore_check_skips_script_variants_setup_does_not_install(self, tmp_path): + integration = get_integration(self.KEY) + rules = b"!.specify/\n!.specify/**\n.specify/scripts/powershell/check-prerequisites.ps1\n" + sh_root = tmp_path / "sh" + sh_root.mkdir() + (sh_root / ".ignore").write_bytes(rules) + integration.setup( + sh_root, IntegrationManifest(self.KEY, sh_root), + parsed_options=_OPT_IN, script_type="sh", + ) + assert (sh_root / ".ignore").read_bytes() == rules + + for name, script_type, present in (("ps", "ps", False), ("sh-present", "sh", True)): + root = tmp_path / name + root.mkdir() + (root / ".ignore").write_bytes(rules) + if present: + installed = root / ".specify" / "scripts" / "powershell" + installed.mkdir(parents=True) + (installed / "check-prerequisites.ps1").write_text("x\n") + with pytest.raises(ValueError, match="check-prerequisites.ps1"): + integration.setup( + root, IntegrationManifest(self.KEY, root), + parsed_options=_OPT_IN, script_type=script_type, + ) + assert (root / ".ignore").read_bytes() == rules