Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ specify init my-project --integration copilot --preset compliance
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
| `SPECIFY_NO_PERSIST` | Set to `1` or `true` to stop every core script from writing `.specify/feature.json`, even when it would otherwise persist `SPECIFY_FEATURE_DIRECTORY` on read. Useful when multiple agents run concurrently against the same checkout, each with its own `SPECIFY_FEATURE_DIRECTORY`: without it, each invocation's persist step can overwrite another agent's pinned feature directory. |

> **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.

Expand Down
7 changes: 7 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ get_feature_paths() {
no_persist=true
shift
fi
# SPECIFY_NO_PERSIST is the environment-level equivalent of --no-persist,
# letting an orchestrator (multi-agent runner, CI matrix) guarantee that no
# script invocation in the process tree writes .specify/feature.json, even
# scripts that don't pass --no-persist themselves (#4128).
if [[ "${SPECIFY_NO_PERSIST:-}" == "1" || "${SPECIFY_NO_PERSIST:-}" == "true" ]]; then
no_persist=true
fi

# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
# get_repo_root propagates as a hard error instead of being masked by `local`.
Expand Down
8 changes: 7 additions & 1 deletion scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ function Get-FeaturePathsEnv {
[switch]$ReturnNullOnError
)

# SPECIFY_NO_PERSIST is the environment-level equivalent of -NoPersist,
# letting an orchestrator (multi-agent runner, CI matrix) guarantee that no
# script invocation in the process tree writes .specify/feature.json, even
# scripts that don't pass -NoPersist themselves (#4128).
$noPersist = [bool]$NoPersist -or $env:SPECIFY_NO_PERSIST -eq '1' -or $env:SPECIFY_NO_PERSIST -eq 'true'

$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
if (-not $repoRoot) { return $null }
$currentBranch = Get-CurrentBranch
Expand All @@ -183,7 +189,7 @@ function Get-FeaturePathsEnv {
}
# Persist to feature.json so future sessions without the env var still
# work - unless the caller opted out for read-only resolution (#3025).
if (-not $NoPersist) {
if (-not $noPersist) {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
} elseif (Test-Path $featureJson) {
Expand Down
6 changes: 6 additions & 0 deletions scripts/python/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ def get_feature_paths(
repo_root = get_repo_root(script_file)
current_branch = get_current_branch()

# SPECIFY_NO_PERSIST is the environment-level equivalent of no_persist=True,
# letting an orchestrator (multi-agent runner, CI matrix) guarantee that no
# script invocation in the process tree writes .specify/feature.json, even
# scripts that don't pass no_persist themselves (#4128).
no_persist = no_persist or os.environ.get("SPECIFY_NO_PERSIST", "") in ("1", "true")

feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
if feature_dir_raw:
feature_dir = Path(feature_dir_raw)
Expand Down
114 changes: 114 additions & 0 deletions tests/test_specify_no_persist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Tests for SPECIFY_NO_PERSIST, the env-level equivalent of --no-persist (#4128).

Scripts like setup-plan/setup-tasks call get_feature_paths() without
--no-persist, so every invocation with SPECIFY_FEATURE_DIRECTORY set
overwrites .specify/feature.json. In multi-agent setups where several
processes each set their own SPECIFY_FEATURE_DIRECTORY, this creates a
write-write race on the shared file. SPECIFY_NO_PERSIST lets an orchestrator
suppress that write across every script invocation without having to patch
each call site.
"""

import json
import shutil
from pathlib import Path

import pytest

from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
PROJECT_ROOT,
bash_cmd,
clean_env,
install_scripts,
make_repo,
ps_cmd,
py_cmd,
run,
)

SCRIPT = "setup-plan"
PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md"


def _setup_repo(tmp_path: Path, name: str = "proj") -> Path:
repo = make_repo(tmp_path, name)
install_scripts(repo, SCRIPT)
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True, exist_ok=True)
shutil.copy(PLAN_TEMPLATE, templates / "plan-template.md")
return repo


def _feature_json(repo: Path) -> dict | None:
fj = repo / ".specify" / "feature.json"
if not fj.is_file():
return None
return json.loads(fj.read_text(encoding="utf-8"))


@pytest.fixture
def repo(tmp_path: Path) -> Path:
return _setup_repo(tmp_path)


@requires_bash
def test_bash_persists_by_default(repo: Path) -> None:
(repo / "specs" / "001-a").mkdir(parents=True)
env = clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a"
result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
assert result.returncode == 0, result.stderr
assert _feature_json(repo) == {"feature_directory": "specs/001-a"}


@requires_bash
def test_bash_specify_no_persist_suppresses_write(repo: Path) -> None:
(repo / "specs" / "001-a").mkdir(parents=True)
env = clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a"
env["SPECIFY_NO_PERSIST"] = "1"
result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
assert result.returncode == 0, result.stderr
assert _feature_json(repo) is None


@requires_bash
def test_bash_specify_no_persist_does_not_clobber_existing_pin(repo: Path) -> None:
"""A second agent's SPECIFY_FEATURE_DIRECTORY must not overwrite the
first agent's persisted feature.json when SPECIFY_NO_PERSIST is set."""
(repo / "specs" / "001-a").mkdir(parents=True)
(repo / "specs" / "002-b").mkdir(parents=True)
env = clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a"
result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
assert result.returncode == 0, result.stderr
assert _feature_json(repo) == {"feature_directory": "specs/001-a"}

env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-b"
env["SPECIFY_NO_PERSIST"] = "1"
result = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
assert result.returncode == 0, result.stderr
assert _feature_json(repo) == {"feature_directory": "specs/001-a"}


@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_ps_specify_no_persist_suppresses_write(repo: Path) -> None:
(repo / "specs" / "001-a").mkdir(parents=True)
env = clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a"
env["SPECIFY_NO_PERSIST"] = "true"
result = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
assert result.returncode == 0, result.stderr
assert _feature_json(repo) is None


def test_py_specify_no_persist_suppresses_write(repo: Path) -> None:
(repo / "specs" / "001-a").mkdir(parents=True)
env = clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-a"
env["SPECIFY_NO_PERSIST"] = "1"
result = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
assert result.returncode == 0, result.stderr
assert _feature_json(repo) is None