Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ specify init my-project --integration copilot --preset compliance

> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.

> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.

> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.

## Check Installed Tools
Expand Down
46 changes: 46 additions & 0 deletions src/specify_cli/shared_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@

logger = logging.getLogger(__name__)

# Managed ``.specify/.gitignore``. Keeps machine-local Spec Kit state out of
# version control while leaving shareable project files (specs, constitution,
# templates, scripts, extension config) tracked. Patterns are relative to the
# ``.specify/`` directory the file lives in.
SPECIFY_GITIGNORE_CONTENT = """\
# Machine-local Spec Kit state — not meant to be shared.
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).

# Local pointer to the current feature directory. Rewritten every time you
# switch features, so it is per-checkout state rather than something to share.
feature.json

# Per-machine extension config overrides.
extensions/*/local-config.yml
"""

# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
Expand Down Expand Up @@ -608,6 +624,36 @@ def _ensure_or_bucket_dir(directory: Path) -> bool:
)
planned_templates.append((dst, rel, content))

# Managed ``.specify/.gitignore`` — keeps machine-local state (the
# ``feature.json`` pointer and per-machine ``local-config.yml`` overrides)
# out of git while leaving everything else shareable. Routed through the
# same overwrite/skip/preserve policy as templates so ``--force`` refreshes
# it and user edits are preserved. Like every other shared-infra file it is
# tracked in ``speckit.manifest.json`` (not the per-integration manifest) and
# is therefore intentionally left in place by ``integration uninstall``.
specify_dir = project_path / ".specify"
if _ensure_or_bucket_dir(specify_dir):
gitignore_dst = specify_dir / ".gitignore"
gitignore_rel = gitignore_dst.relative_to(project_path).as_posix()
seen_rels.add(gitignore_rel)
if _safe_dest_or_bucket(gitignore_dst, gitignore_rel):
write, bucket = _decide_overwrite(gitignore_rel, gitignore_dst)
if write:
planned_templates.append(
(gitignore_dst, gitignore_rel, SPECIFY_GITIGNORE_CONTENT)
)
elif bucket == "preserved":
preserved_user_files.append(gitignore_rel)
else:
skipped_files.append(gitignore_rel)
if gitignore_dst.is_file() and gitignore_rel not in prior_hashes:
try:
manifest.record_existing(gitignore_rel, recovered=True)
except (OSError, ValueError) as exc:
console.print(
f"[yellow]⚠[/yellow] could not record {gitignore_rel} in manifest: {exc}"
)

for dst_path, rel, content, mode in planned_copies:
if not _ensure_or_bucket_dir(dst_path.parent):
continue
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")

if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
]
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")

if script_variant == "sh":
for name in [
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")

if script_variant == "sh":
for name in [
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_cline.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")

if script_variant == "sh":
for name in [
Expand Down
3 changes: 3 additions & 0 deletions tests/integrations/test_integration_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def test_complete_file_inventory_sh(self, tmp_path):
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
Expand Down Expand Up @@ -337,6 +338,7 @@ def test_complete_file_inventory_ps(self, tmp_path):
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
Expand Down Expand Up @@ -847,6 +849,7 @@ def test_complete_file_inventory_skills_sh(self, tmp_path):
".specify/integration.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
# Scripts (sh)
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
Expand Down
2 changes: 2 additions & 0 deletions tests/integrations/test_integration_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ def test_complete_file_inventory_sh(self, tmp_path):
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
Expand Down Expand Up @@ -399,6 +400,7 @@ def test_complete_file_inventory_ps(self, tmp_path):
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
Expand Down
103 changes: 103 additions & 0 deletions tests/test_shared_infra_gitignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for the managed ``.specify/.gitignore`` written by shared-infra install.

The Specify CLI scaffolds a ``.specify/.gitignore`` so machine-local Spec Kit
state (the ``feature.json`` current-feature pointer and per-machine extension
``local-config.yml`` overrides) stays out of version control while everything
else under ``.specify/`` remains shareable. These tests pin that behaviour:
the file is created and manifest-tracked, its patterns actually make git ignore
the intended paths, user edits are preserved on a plain re-run, and ``--force``
restores the managed content.
"""

from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest

from specify_cli import _install_shared_infra
from specify_cli.shared_infra import SPECIFY_GITIGNORE_CONTENT


def _install(project: Path, **kwargs) -> None:
(project / ".specify").mkdir(parents=True, exist_ok=True)
_install_shared_infra(project, "sh", **kwargs)


def test_gitignore_is_written_and_tracked(tmp_path: Path) -> None:
project = tmp_path / "proj"
_install(project)

gitignore = project / ".specify" / ".gitignore"
assert gitignore.is_file()

content = gitignore.read_text(encoding="utf-8")
assert "feature.json" in content
assert "extensions/*/local-config.yml" in content

manifest = json.loads(
(project / ".specify" / "integrations" / "speckit.manifest.json").read_text(
encoding="utf-8"
)
)
assert ".specify/.gitignore" in manifest.get("files", {})


@pytest.mark.skipif(shutil.which("git") is None, reason="git not available")
def test_git_ignores_the_intended_paths(tmp_path: Path) -> None:
project = tmp_path / "proj"
project.mkdir()
subprocess.run(["git", "init", "-q"], cwd=project, check=True)

_install(project)

(project / ".specify" / "feature.json").write_text("{}", encoding="utf-8")
ext_local = project / ".specify" / "extensions" / "git" / "local-config.yml"
ext_local.parent.mkdir(parents=True, exist_ok=True)
ext_local.write_text("x\n", encoding="utf-8")

for rel in (
".specify/feature.json",
".specify/extensions/git/local-config.yml",
):
result = subprocess.run(
["git", "check-ignore", rel],
cwd=project,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"{rel} was not ignored"

# A shareable file under .specify/ must NOT be ignored.
tracked = subprocess.run(
["git", "check-ignore", ".specify/memory/constitution.md"],
cwd=project,
capture_output=True,
text=True,
)
assert tracked.returncode == 1


def test_user_edits_preserved_by_default(tmp_path: Path) -> None:
project = tmp_path / "proj"
_install(project)

gitignore = project / ".specify" / ".gitignore"
gitignore.write_text("# my customization\n", encoding="utf-8")

_install(project) # plain re-run must not clobber user edits
assert gitignore.read_text(encoding="utf-8") == "# my customization\n"


def test_force_restores_managed_content(tmp_path: Path) -> None:
project = tmp_path / "proj"
_install(project)

gitignore = project / ".specify" / ".gitignore"
gitignore.write_text("# my customization\n", encoding="utf-8")

_install(project, force=True)
assert gitignore.read_text(encoding="utf-8") == SPECIFY_GITIGNORE_CONTENT