Files
github-spec-kit/tests/test_init_dir.py
Noor ul ain 296fdf2ee7 fix(scripts): use a .NET Framework-safe trim in the PowerShell init-dir resolver (#3872)
`Resolve-SpecifyInitDir` normalized the resolved path with
`[System.IO.Path]::TrimEndingDirectorySeparator`, which is .NET Core only.
Windows PowerShell 5.1 runs on .NET Framework, so on every 5.1 host the call
throws at that line and root resolution fails before the requested command runs:

    $ $env:SPECIFY_INIT_DIR = "C:\repo\web"
    $ .specify\scripts\powershell\check-prerequisites.ps1 -Json
    check-prerequisites.ps1 : Method invocation failed because
    [System.IO.Path] does not contain a method named
    'TrimEndingDirectorySeparator'.

The same file already documents this exact incompatibility and avoids it
correctly in `Get-FeaturePathsEnv` (~150 lines below), which uses `TrimEnd`
with a comment naming `TrimEndingDirectorySeparator` as .NET Core only.

Worse than a clean failure when the resolver is called directly: the throw is
non-terminating, so `$initRoot` stays `$null` and the very next `Join-Path`
throws too, `Get-RepoRoot` returns `$null`, and the shell exits **0**. A caller
that checks the exit code sees success with an empty root.

Switched to the `TrimEnd('/', '\')` the file already endorses. Note the obvious
swap is not quite enough on its own: a bare `TrimEnd` turns `C:\` into `C:`,
which is not the drive root but a drive-relative reference that later path APIs
re-resolve against the *current directory* — so validation would probe the wrong
tree and, from a cwd that happens to contain `.specify/`, could silently accept
`C:` as the project root. A `GetPathRoot` length check keeps a path that is its
own root intact. Both `GetPathRoot` and `TrimEnd` exist on .NET Framework.

Trailing-separator trimming (the reason the call was there — bash's `cd && pwd`
never yields one, so the two resolvers must agree) is unchanged, as are all
error paths and messages.

Tests in `tests/test_init_dir.py`:

- A static check that no shipped `.ps1` calls a .NET Core-only
  `[System.IO.Path]` member (`TrimEndingDirectorySeparator`,
  `EndsInDirectorySeparator`, `GetRelativePath`, `Join`). This one runs on all
  platforms and is what actually guards CI: the matrix runs the PowerShell
  tests under `pwsh`, which is .NET Core, so a .NET Framework-only regression
  is otherwise invisible to it. Anchored to the `Path` type so `[string]::Join`
  is not flagged.
- Two runtime tests under `powershell.exe` specifically (never `pwsh`),
  covering resolution and trailing-separator parity.
- A drive-root test asserting the reported root survives the trim intact.

Test-the-test: reverting the source change fails all four (the runtime pair
with the `does not contain a method named` throw, the static check by locating
the call). Applying only the naive `TrimEnd` fails the drive-root test, which
reports `C:` instead of `C:\`. Verified on Windows PowerShell 5.1.19041.6456,
including the previously-crashing `check-prerequisites.ps1 -Json` end to end.

Also fixes six pre-existing `test_ps_*` failures on 5.1-only hosts, which were
this bug rather than test-harness issues.

Fixes #3749

Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:51:38 -05:00

606 lines
24 KiB
Python

"""Tests for the SPECIFY_INIT_DIR project-root override.
SPECIFY_INIT_DIR lets a non-interactive / CI caller target a member project from
outside its directory (e.g. a monorepo root) without `cd`. It names the project
root — the directory *containing* `.specify/` — and is strict: it must exist and
contain `.specify/`, otherwise the resolver hard-errors with no silent fallback to
cwd or the git toplevel.
See proposals/monorepo-support and github/spec-kit discussion #2834.
"""
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
GIT_CREATE_FEATURE_SH = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
)
HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL
# Windows PowerShell 5.1 (.NET Framework) specifically, never pwsh: the only
# host that lacks the .NET Core-only [System.IO.Path] members, so it is the only
# one that can pin a 5.1 compatibility regression (issue #3749).
_WINDOWS_POWERSHELL = _POWERSHELL if os.name == "nt" else None
def _clean_env() -> dict[str, str]:
"""Inherited env minus all SPECIFY_* vars, so a developer/CI override
(SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, …) cannot leak into the
subprocess and make these resolution tests flaky."""
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _make_project(root: Path, name: str) -> Path:
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
proj = root / name
(proj / ".specify").mkdir(parents=True)
return proj
def _bash(func_call: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
"""Source the real common.sh and run a function, from a given cwd/env."""
return subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && {func_call}'],
cwd=cwd,
capture_output=True,
text=True,
check=False,
env=env,
)
def _ps(script: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
"""Dot-source the real common.ps1 and run PowerShell, from a given cwd/env."""
return subprocess.run(
[_PS_EXE, "-NoProfile", "-Command", f'. "{COMMON_PS}"; {script}'],
cwd=cwd,
capture_output=True,
text=True,
check=False,
env=env,
)
def _feature_dir_line(stdout: str) -> str | None:
for line in stdout.splitlines():
if line.startswith("FEATURE_DIR="):
return line.split("=", 1)[1].strip("'\"")
return None
def _bash_path(path: Path) -> str:
"""Return the path format emitted by Bash `pwd`.
Git-for-Windows Bash reports absolute paths as /c/... while pathlib reports
them as C:\\..., so Bash stdout comparisons need an expected value in Bash's
own path shape.
"""
if os.name != "nt":
return str(path)
resolved = path.resolve()
path_str = str(resolved).replace("\\", "/")
if resolved.drive.endswith(":"):
return f"/{resolved.drive[0].lower()}{path_str[len(resolved.drive):]}"
return path_str
requires_pwsh = pytest.mark.skipif(
not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available"
)
requires_windows_powershell = pytest.mark.skipif(
_WINDOWS_POWERSHELL is None, reason="Windows PowerShell 5.1 not available"
)
# ── Bash: positive cases ────────────────────────────────────────────────────
@requires_bash
def test_valid_path_resolves_from_outside(tmp_path: Path) -> None:
"""P1: a valid project path resolves correctly when run from elsewhere."""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _bash("get_repo_root", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
"""P2: a relative SPECIFY_INIT_DIR is resolved against the current directory."""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
result = _bash("get_repo_root", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_trailing_slash_tolerated(tmp_path: Path) -> None:
"""P3: a trailing slash is collapsed by normalization."""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
result = _bash("get_repo_root", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_precedence_over_cwd_project(tmp_path: Path) -> None:
"""P4: feature resolution happens inside the *target* project, not cwd.
cwd is itself a valid Spec Kit project; SPECIFY_INIT_DIR must redirect
resolution to the target project, so a relative SPECIFY_FEATURE_DIRECTORY
normalizes under the target root, not cwd.
"""
cwd_proj = _make_project(tmp_path, "cwd_proj")
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
}
result = _bash("get_feature_paths", cwd=cwd_proj, env=env)
assert result.returncode == 0, result.stderr
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "001-demo")
assert _bash_path(cwd_proj) not in result.stdout
@requires_bash
def test_composes_with_feature_directory_override(tmp_path: Path) -> None:
"""P5: SPECIFY_INIT_DIR (project axis) composes with SPECIFY_FEATURE_DIRECTORY
(feature axis); a relative feature dir normalizes under the *target* root."""
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
}
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "003-x")
@requires_bash
def test_composes_with_target_feature_json(tmp_path: Path) -> None:
"""P6: the target project's .specify/feature.json is honored."""
web = _make_project(tmp_path, "web")
(web / ".specify" / "feature.json").write_text(
'{"feature_directory": "specs/004-fj"}'
)
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "004-fj")
# ── Bash: negative / contract cases ─────────────────────────────────────────
@requires_bash
def test_unset_preserves_cwd_walk(tmp_path: Path) -> None:
"""N1: with SPECIFY_INIT_DIR unset, resolution walks up from cwd as before."""
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
result = _bash("get_repo_root", cwd=sub, env=_clean_env())
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_empty_string_treated_as_unset(tmp_path: Path) -> None:
"""N2: an empty SPECIFY_INIT_DIR behaves as unset (not as ".").
Run from a deep subdirectory so the two interpretations diverge:
empty-as-unset walks up to the project root; empty-as-"." would resolve to
the cwd (which has no .specify/) and error. Asserting the walk-up result
genuinely guards against a regression to "." semantics.
"""
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
result = _bash("get_repo_root", cwd=sub, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_invalid_init_dir_fails_feature_paths_chain(tmp_path: Path) -> None:
"""N5: an invalid SPECIFY_INIT_DIR hard-fails the load-bearing call site
(get_feature_paths), not just get_repo_root — this is what the decl/assign
split guards against (a `local x=$(get_repo_root)` would mask the failure
and emit a FEATURE_DIR under the wrong root). SPECIFY_FEATURE_DIRECTORY is
set so a feature dir *is* resolvable — only the propagation stops a
wrong-root FEATURE_DIR, so a revert to the masked form fails this test."""
web = _make_project(tmp_path, "web") # valid project at cwd
missing = tmp_path / "does_not_exist"
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(missing),
"SPECIFY_FEATURE_DIRECTORY": "specs/001-x",
}
result = _bash("get_feature_paths", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert "FEATURE_DIR=" not in result.stdout
@requires_bash
def test_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
"""N3: a non-existent path hard-errors — even from inside a valid project,
proving there is no silent fallback to the cwd walk-up or git root."""
web = _make_project(tmp_path, "web") # valid project at cwd
missing = tmp_path / "does_not_exist"
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
result = _bash("get_repo_root", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert _bash_path(web) not in result.stdout
@requires_bash
def test_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
"""N4: a path that exists but lacks .specify/ hard-errors, no fallback."""
web = _make_project(tmp_path, "web") # valid project at cwd
nodot = tmp_path / "nodot"
nodot.mkdir()
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
result = _bash("get_repo_root", cwd=web, env=env)
assert result.returncode != 0
assert "not a Spec Kit project" in result.stderr
assert _bash_path(web) not in result.stdout
@requires_bash
def test_file_path_errors_no_fallback(tmp_path: Path) -> None:
"""N4b: a path that exists but is a file (not a directory) hard-errors with
the existing-directory message, with no fallback."""
web = _make_project(tmp_path, "web") # valid project at cwd
a_file = tmp_path / "afile"
a_file.write_text("x")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
result = _bash("get_repo_root", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert _bash_path(web) not in result.stdout
# ── Bash: bundled Git extension entrypoint ──────────────────────────────────
def _bash_git_create(
args: list[str], cwd: Path, env: dict[str, str]
) -> subprocess.CompletedProcess:
"""Run the bundled git extension's create-new-feature-branch.sh (the real
/speckit.specify before_specify entrypoint)."""
return subprocess.run(
["bash", str(GIT_CREATE_FEATURE_SH), *args],
cwd=cwd,
capture_output=True,
text=True,
check=False,
env=env,
)
def _json_line(stdout: str) -> dict | None:
for line in stdout.splitlines():
line = line.strip()
if line.startswith("{"):
return json.loads(line)
return None
@requires_bash
def test_git_ext_create_feature_numbers_from_target(tmp_path: Path) -> None:
"""P8: the git extension's feature creation numbers from the SPECIFY_INIT_DIR
project, not the cwd project."""
(tmp_path / "specs" / "008-cwd").mkdir(parents=True) # cwd project's specs
web = _make_project(tmp_path, "web")
(web / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
(web / ".specify" / "templates" / "spec-template.md").write_text("# Spec: [FEATURE]\n")
(web / "specs" / "005-existing").mkdir(parents=True)
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _bash_git_create(["--json", "next thing"], cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
data = _json_line(result.stdout)
assert data is not None and data["FEATURE_NUM"] == "006" # 005 in web → 006, not 009
@requires_bash
def test_git_ext_create_feature_invalid_init_dir_errors(tmp_path: Path) -> None:
"""N7: the git extension hard-errors on an invalid SPECIFY_INIT_DIR with no
fallback to the cwd/git-toplevel project."""
web = _make_project(tmp_path, "web") # valid project at cwd
(web / "specs" / "001-cwd").mkdir(parents=True)
missing = tmp_path / "does_not_exist"
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
result = _bash_git_create(["--json", "x"], cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert _json_line(result.stdout) is None
# ── PowerShell mirror (skipped only when no PowerShell is installed; the CI
# ubuntu/windows runners ship pwsh, so these DO run there) ─────────────────
@requires_pwsh
def test_ps_valid_path_resolves_from_outside(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_trailing_slash_tolerated(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_unset_preserves_cwd_walk(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
result = _ps("Get-RepoRoot", cwd=sub, env=_clean_env())
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_precedence_over_cwd_project(tmp_path: Path) -> None:
cwd_proj = _make_project(tmp_path, "cwd_proj")
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
}
result = _ps(
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
cwd=cwd_proj,
env=env,
)
assert result.returncode == 0, result.stderr
# PowerShell Join-Path keeps the embedded "/" of the relative feature dir
# while pathlib uses the platform separator; compare separator-insensitively
# so the Windows CI runner (where pwsh runs) matches.
feature_dir = _feature_dir_line(result.stdout)
assert feature_dir is not None, result.stdout
assert feature_dir.replace("\\", "/") == (web / "specs" / "001-demo").as_posix()
assert str(cwd_proj) not in result.stdout
@requires_pwsh
def test_ps_composes_with_feature_directory_override(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
}
result = _ps(
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
cwd=tmp_path,
env=env,
)
assert result.returncode == 0, result.stderr
# Separator-insensitive: PowerShell Join-Path keeps the embedded "/".
feature_dir = _feature_dir_line(result.stdout)
assert feature_dir is not None, result.stdout
assert feature_dir.replace("\\", "/") == (web / "specs" / "003-x").as_posix()
@requires_pwsh
def test_ps_empty_string_treated_as_unset(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
result = _ps("Get-RepoRoot", cwd=sub, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
missing = tmp_path / "does_not_exist"
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
@requires_pwsh
def test_ps_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
nodot = tmp_path / "nodot"
nodot.mkdir()
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "not a Spec Kit project" in result.stderr
@requires_pwsh
def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None:
"""A file path resolves via Resolve-Path but is not a directory; the resolver
must reject it with the existing-directory message, not not-a-project."""
web = _make_project(tmp_path, "web")
a_file = tmp_path / "afile"
a_file.write_text("x")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
# ── Windows PowerShell 5.1 compatibility (issue #3749) ──────────────────────
#
# The CI matrix runs these PowerShell tests under `pwsh` on every OS, and pwsh
# is .NET Core, so a .NET Framework-only regression is invisible to it. The
# static test below therefore runs everywhere and is the one that actually
# guards CI; the runtime tests pin the real behavior where a 5.1 host exists.
# .NET Core-only [System.IO.Path] members. Absent on .NET Framework, so calling
# one throws "does not contain a method named ..." on Windows PowerShell 5.1.
_DOTNET_CORE_ONLY_PATH_MEMBERS = (
"TrimEndingDirectorySeparator",
"EndsInDirectorySeparator",
"GetRelativePath",
"Join",
)
@pytest.mark.parametrize("member", _DOTNET_CORE_ONLY_PATH_MEMBERS)
def test_shipped_ps1_avoids_dotnet_core_only_path_members(member: str) -> None:
"""No shipped .ps1 may call a .NET Core-only [System.IO.Path] member.
Windows PowerShell 5.1 ships on every Windows box and runs on .NET
Framework, where these members do not exist. A call is not a graceful
degradation but a hard "Method invocation failed" at the call site, which
for a root resolver aborts the command before it starts.
Runs on all platforms because the CI matrix only has pwsh (.NET Core),
where such a call works fine -- so this static check is what keeps CI able
to catch the regression at all.
"""
# Anchored to the Path type: [string]::Join and other same-named members on
# .NET Framework types are unaffected and must not be flagged.
pattern = re.compile(
r"\[(?:System\.IO\.)?Path\]::" + re.escape(member) + r"\s*\(",
re.IGNORECASE,
)
offenders = []
for ps1 in sorted(PROJECT_ROOT.glob("scripts/powershell/*.ps1")) + sorted(
PROJECT_ROOT.glob("extensions/*/scripts/powershell/*.ps1")
):
for lineno, line in enumerate(
ps1.read_text(encoding="utf-8").splitlines(), start=1
):
code = line.split("#", 1)[0]
if pattern.search(code):
offenders.append(f"{ps1.relative_to(PROJECT_ROOT)}:{lineno}")
assert not offenders, (
f"[System.IO.Path]::{member}() is .NET Core only and throws on Windows "
f"PowerShell 5.1; found at {offenders}. Use a .NET Framework-safe "
f"equivalent (e.g. TrimEnd('/', '\\') for a trailing separator)."
)
@requires_windows_powershell
def test_ps51_init_dir_resolves(tmp_path: Path) -> None:
"""SPECIFY_INIT_DIR must resolve under Windows PowerShell 5.1 (issue #3749).
Before the fix, Resolve-SpecifyInitDir called the .NET Core-only
[System.IO.Path]::TrimEndingDirectorySeparator, so every 5.1 invocation
threw at that line -- root resolution failed before the requested command
ran, and $initRoot stayed $null so the very next Join-Path threw too.
"""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = subprocess.run(
[_WINDOWS_POWERSHELL, "-NoProfile", "-Command", f'. "{COMMON_PS}"; Get-RepoRoot'],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=env,
)
assert "does not contain a method named" not in result.stderr, result.stderr
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_windows_powershell
def test_ps51_init_dir_trailing_separator_trimmed(tmp_path: Path) -> None:
"""The 5.1-safe trim must still strip a trailing separator, for bash parity.
Resolve-Path echoes back the input's trailing separator; the bash resolver's
`cd && pwd` never yields one, so the two must agree.
"""
web = _make_project(tmp_path, "web")
for suffix in ("/", "\\"):
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}{suffix}"}
result = subprocess.run(
[
_WINDOWS_POWERSHELL,
"-NoProfile",
"-Command",
f'. "{COMMON_PS}"; Get-RepoRoot',
],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_drive_root_reports_root_not_bare_drive(tmp_path: Path) -> None:
"""A path that IS its own root must survive the trim intact.
A bare TrimEnd('/', '\\') -- the obvious 5.1-safe swap -- turns 'C:\\' into
'C:', which is not the drive root but a drive-relative reference that every
later path API re-resolves against the *current directory*. Validation would
then probe the wrong tree entirely and, on a cwd that happens to contain
.specify/, silently accept 'C:' as the project root. The drive root
normally has no .specify/, so assert on the error naming the intact root.
"""
root = Path(tmp_path.anchor or "/")
if (root / ".specify").exists():
pytest.skip("filesystem root is itself a Spec Kit project")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(root)}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode != 0
assert "not a Spec Kit project" in result.stderr
# The error echoes the resolved root, so it pins what the trim produced:
# 'C:\' (or '/') intact, never the bare 'C:' (or '') a naive TrimEnd leaves.
reported = result.stderr.replace("\r", "").rstrip("\n").split("directory): ", 1)[-1]
assert reported == str(root)