From c529459cfb500d1b4bbee7b5fb2ce8c4552a1e0b Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sat, 27 Jun 2026 01:56:31 +0500 Subject: [PATCH 1/2] fix: stop check-prerequisites --paths-only from writing feature.json (#3025) check-prerequisites --paths-only / -PathsOnly is documented as pure, read-only path resolution, but when SPECIFY_FEATURE_DIRECTORY was set it called the persist routine and rewrote .specify/feature.json. That dirtied the working tree and overwrote a pinned feature directory during what should be a no-op. Add an explicit opt-out at the resolver boundary instead of a global env back-channel: - bash: get_feature_paths accepts a leading --no-persist flag that skips _persist_feature_json; check-prerequisites.sh passes it in --paths-only mode. - PowerShell: Get-FeaturePathsEnv gains a -NoPersist switch that skips Save-FeatureJson; check-prerequisites.ps1 passes it in -PathsOnly mode. Normal (non-paths-only) invocations are unchanged and still persist the override, so future sessions without the env var keep working. Add regression tests asserting --paths-only/-PathsOnly leaves a pinned feature.json untouched even when the env override differs, plus a guard that normal mode still persists. --- scripts/bash/check-prerequisites.sh | 10 ++- scripts/bash/common.sh | 16 +++- scripts/powershell/check-prerequisites.ps1 | 10 ++- scripts/powershell/common.ps1 | 14 ++- tests/test_check_prerequisites_paths_only.py | 89 ++++++++++++++++++++ 5 files changed, 131 insertions(+), 8 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 2c1b8e1351..1d24336327 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -78,8 +78,14 @@ done SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -# Get feature paths -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +# Get feature paths. +# In --paths-only mode this is pure resolution, so pass --no-persist to opt out +# of the feature.json write side effect (issue #3025). +if $PATHS_ONLY; then + _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +else + _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +fi eval "$_paths_output" unset _paths_output diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 70ab89b013..1ffed373e6 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -152,6 +152,15 @@ _persist_feature_json() { } get_feature_paths() { + # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass + # --no-persist so pure path resolution never writes .specify/feature.json, + # which would dirty the working tree or overwrite a pinned value (issue #3025). + local no_persist=false + if [[ "${1:-}" == "--no-persist" ]]; then + no_persist=true + shift + 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`. local repo_root @@ -168,8 +177,11 @@ get_feature_paths() { feature_dir="$SPECIFY_FEATURE_DIRECTORY" # Normalize relative paths to absolute under repo root [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" - # Persist to feature.json so future sessions without the env var still work - _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" + # Persist to feature.json so future sessions without the env var still + # work — unless the caller opted out for read-only resolution (#3025). + if [[ "$no_persist" != true ]]; then + _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" + fi elif [[ -f "$repo_root/.specify/feature.json" ]]; then local _fd _fd=$(read_feature_json_feature_directory "$repo_root") diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 52469aa19a..2a424b49a8 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -56,8 +56,14 @@ EXAMPLES: # Source common functions . "$PSScriptRoot/common.ps1" -# Get feature paths -$paths = Get-FeaturePathsEnv +# Get feature paths. +# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of +# the feature.json write side effect (issue #3025). +if ($PathsOnly) { + $paths = Get-FeaturePathsEnv -NoPersist +} else { + $paths = Get-FeaturePathsEnv +} # If paths-only mode, output paths and exit (no validation) if ($PathsOnly) { diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index f56fc26577..f149f98106 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -143,6 +143,13 @@ function Save-FeatureJson { } function Get-FeaturePathsEnv { + # Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist + # so pure path resolution never writes .specify/feature.json, which would + # dirty the working tree or overwrite a pinned value (issue #3025). + param( + [switch]$NoPersist + ) + $repoRoot = Get-RepoRoot $currentBranch = Get-CurrentBranch @@ -157,8 +164,11 @@ function Get-FeaturePathsEnv { if (-not [System.IO.Path]::IsPathRooted($featureDir)) { $featureDir = Join-Path $repoRoot $featureDir } - # Persist to feature.json so future sessions without the env var still work - Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY + # 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) { + Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY + } } elseif (Test-Path $featureJson) { $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw try { diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py index c8c2926abc..3ed3e59204 100644 --- a/tests/test_check_prerequisites_paths_only.py +++ b/tests/test_check_prerequisites_paths_only.py @@ -163,6 +163,66 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: assert result.stdout.strip() == "" +@requires_bash +def test_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None: + """--paths-only must not rewrite feature.json even when the env override + differs from the pinned value (#3025). + + Path resolution is read-only, so it must never dirty the working tree or + overwrite the persisted feature directory. + """ + pinned = "specs/001-my-feature" + (prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True) + (prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True) + _write_feature_json(prereq_repo, pinned) + fj = prereq_repo / ".specify" / "feature.json" + before = fj.read_text(encoding="utf-8") + + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + env = _clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other" + result = subprocess.run( + ["bash", str(script), "--json", "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + # The override is honored in the output... + data = json.loads(result.stdout) + assert "002-other" in data["FEATURE_DIR"] + # ...but the pinned file on disk is untouched. + assert fj.read_text(encoding="utf-8") == before + + +@requires_bash +def test_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None: + """Without --paths-only, the env override is still persisted to feature.json, + so the --no-persist opt-out does not regress normal write behavior (#3025).""" + (prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True) + feat = prereq_repo / "specs" / "002-other" + feat.mkdir(parents=True, exist_ok=True) + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + _write_feature_json(prereq_repo, "specs/001-my-feature") + fj = prereq_repo / ".specify" / "feature.json" + + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + env = _clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other" + + # ── PowerShell tests ────────────────────────────────────────────────────── @@ -283,3 +343,32 @@ def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None: assert "tasks.md not found" in result.stderr assert "tasks.md not found" not in result.stdout assert result.stdout.strip() == "" + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None: + """-PathsOnly must not rewrite feature.json even when the env override + differs from the pinned value (#3025).""" + pinned = "specs/001-my-feature" + (prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True) + (prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True) + _write_feature_json(prereq_repo, pinned) + fj = prereq_repo / ".specify" / "feature.json" + before = fj.read_text(encoding="utf-8") + + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + env = _clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other" + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "002-other" in data["FEATURE_DIR"] + assert fj.read_text(encoding="utf-8") == before From 024ccda583b4dfc0eb6e439a775325425f217988 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sat, 27 Jun 2026 13:08:20 +0500 Subject: [PATCH 2/2] fix: use ASCII hyphen in common.ps1 comment for PS 5.1 compatibility The em-dash in the persist comment introduced non-ASCII bytes, failing test_ps1_file_is_ascii_only which enforces ASCII-only PowerShell sources for Windows PowerShell 5.1 compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/powershell/common.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index f149f98106..acaad63d8a 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -165,7 +165,7 @@ function Get-FeaturePathsEnv { $featureDir = Join-Path $repoRoot $featureDir } # Persist to feature.json so future sessions without the env var still - # work — unless the caller opted out for read-only resolution (#3025). + # work - unless the caller opted out for read-only resolution (#3025). if (-not $NoPersist) { Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY }