mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
`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>
606 lines
24 KiB
Python
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)
|