feat(init): scaffold managed .specify/.gitignore (#4000)

* feat(init): scaffold managed .specify/.gitignore

Write a manifest-tracked `.specify/.gitignore` during shared-infra install
so machine-local Spec Kit state stays out of version control while everything
else under `.specify/` remains shareable:

- `feature.json` — the current-feature pointer, rewritten on every feature
  switch (per-checkout state, not something to share).
- `extensions/*/local-config.yml` — per-machine extension config overrides.

The file is routed through the same overwrite/skip/preserve policy as shared
templates: `--force` refreshes it, user edits are preserved on re-init, and
uninstall removes it via the manifest. Addresses github/spec-kit#2304.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9

* docs: correct .specify/.gitignore uninstall claim

The file is tracked in the shared-infra manifest (speckit.manifest.json),
not the per-integration manifest that `specify integration uninstall` loads.
Shared infrastructure is deliberately preserved on uninstall
(see test_uninstall_preserves_shared_infra), so `.specify/.gitignore` is
left in place rather than removed. Reword the code comment and core.md note
to state the actual behavior; keep the true benefits (force-refresh and
preserve-on-edit).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9

* revert: drop manual CHANGELOG.md edit

CHANGELOG.md is auto-generated; do not hand-edit it.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9

* test: add .specify/.gitignore to integration file inventories

The complete-file-inventory tests assert an exact match of every file
produced by `specify init`. Now that shared infra scaffolds a managed
`.specify/.gitignore`, add it to the expected inventories so the exact-match
assertions pass on both sh and ps script types.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9
This commit is contained in:
Manfred Riem
2026-08-06 09:08:09 -05:00
committed by GitHub
co-authored by Copilot App
parent 3dff6f1d50
commit 40037b1aca
10 changed files with 161 additions and 0 deletions
+2
View File
@@ -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
+46
View File
@@ -16,6 +16,22 @@ from .integrations.manifest import IntegrationManifest
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
@@ -608,6 +624,36 @@ def install_shared_infra(
)
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
@@ -238,6 +238,7 @@ class MarkdownIntegrationTests:
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",
@@ -484,6 +484,7 @@ class SkillsIntegrationTests:
".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",
]
@@ -488,6 +488,7 @@ class TomlIntegrationTests:
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 [
@@ -402,6 +402,7 @@ class YamlIntegrationTests:
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 [
@@ -185,6 +185,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
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 [
@@ -274,6 +274,7 @@ class TestCopilotCommandsMode:
".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",
@@ -337,6 +338,7 @@ class TestCopilotCommandsMode:
".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",
@@ -847,6 +849,7 @@ class TestCopilotSkillsMode:
".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",
@@ -342,6 +342,7 @@ class TestGenericIntegration:
".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",
@@ -399,6 +400,7 @@ class TestGenericIntegration:
".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",
+103
View File
@@ -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