diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 2c1b8e1351..4b70070868 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -79,7 +79,11 @@ 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; } +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..3a46b9b74e 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -152,6 +152,11 @@ _persist_feature_json() { } get_feature_paths() { + local no_persist=false + if [[ "${1:-}" == "--no-persist" ]]; 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`. local repo_root @@ -169,7 +174,9 @@ get_feature_paths() { # 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" + if [[ "$no_persist" == "false" ]]; 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 bb60e52c85..a750fd8015 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -57,7 +57,11 @@ EXAMPLES: . "$PSScriptRoot/common.ps1" # Get feature paths -$paths = Get-FeaturePathsEnv +$paths = if ($PathsOnly) { + Get-FeaturePathsEnv -NoPersist +} else { + 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..ce3824a970 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -143,6 +143,10 @@ function Save-FeatureJson { } function Get-FeaturePathsEnv { + param( + [switch]$NoPersist + ) + $repoRoot = Get-RepoRoot $currentBranch = Get-CurrentBranch @@ -158,7 +162,9 @@ function Get-FeaturePathsEnv { $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 + 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 03e2fc6e8b..2d1c8c6b83 100644 --- a/tests/test_check_prerequisites_paths_only.py +++ b/tests/test_check_prerequisites_paths_only.py @@ -17,7 +17,11 @@ CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1" HAS_PWSH = shutil.which("pwsh") is not None -_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None +_WINDOWS_POWERSHELL = ( + (shutil.which("powershell.exe") or shutil.which("powershell")) + if os.name == "nt" + else None +) def _install_bash_scripts(repo: Path) -> None: @@ -141,6 +145,41 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None: assert "FEATURE_DIR:" in result.stdout +@requires_bash +def test_paths_only_does_not_overwrite_feature_json(prereq_repo: Path) -> None: + """--paths-only must not rewrite .specify/feature.json when env differs.""" + common = (prereq_repo / ".specify" / "scripts" / "bash" / "common.sh").read_text( + encoding="utf-8" + ) + script_text = ( + prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + ).read_text(encoding="utf-8") + assert "SPECIFY_NO_PERSIST_FEATURE_JSON" not in common + assert "get_feature_paths --no-persist" in script_text + + feat = prereq_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + _write_feature_json(prereq_repo) + before = (prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8") + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + env = _clean_env() + env["SPECIFY_FEATURE_DIRECTORY"] = "specs/999-temp" + 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 + assert (prereq_repo / ".specify" / "feature.json").read_text( + encoding="utf-8" + ) == before + data = json.loads(result.stdout) + assert data["FEATURE_DIR"].endswith("specs/999-temp") + + @requires_bash def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: """Without --paths-only, feature directory validation must still fail on main.""" @@ -160,13 +199,17 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: # ── PowerShell tests ────────────────────────────────────────────────────── -@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +@pytest.mark.skipif( + not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available" +) def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: """-PathsOnly must return paths when feature.json pins the feature dir.""" feat = prereq_repo / "specs" / "001-my-feature" feat.mkdir(parents=True, exist_ok=True) _write_feature_json(prereq_repo) - script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + script = ( + prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + ) exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL result = subprocess.run( [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], @@ -183,7 +226,9 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: assert "FEATURE_DIR" in data -@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +@pytest.mark.skipif( + not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available" +) def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: """-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree.""" subprocess.run( @@ -194,7 +239,9 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: feat = prereq_repo / "specs" / "001-my-feature" feat.mkdir(parents=True, exist_ok=True) _write_feature_json(prereq_repo) - script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + script = ( + prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + ) exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL env = _clean_env() env["SPECIFY_FEATURE"] = "001-my-feature" @@ -211,10 +258,54 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: assert "FEATURE_DIR" in data -@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +@pytest.mark.skipif( + not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available" +) +def test_ps_paths_only_does_not_overwrite_feature_json(prereq_repo: Path) -> None: + """-PathsOnly must not rewrite .specify/feature.json when env differs.""" + common = ( + prereq_repo / ".specify" / "scripts" / "powershell" / "common.ps1" + ).read_text(encoding="utf-8") + script_text = ( + prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + ).read_text(encoding="utf-8") + assert "SPECIFY_NO_PERSIST_FEATURE_JSON" not in common + assert "Get-FeaturePathsEnv -NoPersist" in script_text + + feat = prereq_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + _write_feature_json(prereq_repo) + before = (prereq_repo / ".specify" / "feature.json").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/999-temp" + 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 + assert (prereq_repo / ".specify" / "feature.json").read_text( + encoding="utf-8" + ) == before + data = json.loads(result.stdout) + assert data["FEATURE_DIR"].replace("\\", "/").endswith("specs/999-temp") + + +@pytest.mark.skipif( + not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available" +) def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: """Without -PathsOnly, feature directory validation must still fail on main.""" - script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + script = ( + prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + ) exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL result = subprocess.run( [exe, "-NoProfile", "-File", str(script), "-Json"],