mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)
* feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root Resolve an explicit SPECIFY_INIT_DIR project override once in the core get_repo_root / Get-RepoRoot, so a non-interactive / CI caller can target a member project (the directory containing .specify/) from a monorepo root without cd. Strict by design: the path must exist and contain .specify/, otherwise it hard-errors with no silent fallback. - Single resolver in core; the git feature-branch script inherits it by sourcing core, with no per-extension copies. - PS resolver verifies the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - get_feature_paths splits decl/assignment so a SPECIFY_INIT_DIR failure propagates instead of being masked by `local`. - create-new-feature-branch: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error rather than silently using the git root. - Document SPECIFY_INIT_DIR and SPECIFY_FEATURE_DIRECTORY in the core reference. - Tests for valid/relative/trailing-slash/file/missing/no-.specify targets, feature-axis composition, the no-core guard, and a PowerShell mirror. * fix: guard SPECIFY_INIT_DIR with stale core scripts * docs: clarify SPECIFY_FEATURE_DIRECTORY precedence wording * fix: normalize trailing slash in PowerShell SPECIFY_INIT_DIR resolver Resolve-Path preserves a trailing separator from its input, so a SPECIFY_INIT_DIR ending in a slash returned a root that didn't match the bash resolver (whose `cd && pwd` strips it). That broke test_ps_trailing_slash_tolerated on the CI runners, which do have pwsh. Trim it with TrimEndingDirectorySeparator (no-op on a bare root or a path with no trailing separator). Also fix the misleading test comment: the PowerShell mirror runs on the CI ubuntu/windows runners (they ship pwsh), it is not skipped there. * test: normalize bash path expectations on Windows * docs: clarify SPECIFY_INIT_DIR root helpers
This commit is contained in:
@@ -382,6 +382,36 @@ class TestCreateFeatureBash:
|
||||
assert data.get("DRY_RUN") is True
|
||||
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
|
||||
|
||||
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
|
||||
"""With no core scripts (only git-common.sh loaded), a set SPECIFY_INIT_DIR
|
||||
hard-errors instead of silently falling back to the walk-up project root."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
# Simulate a no-core install: drop core common.sh so only git-common.sh loads.
|
||||
(project / "scripts" / "bash" / "common.sh").unlink()
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "x", "X feature",
|
||||
env_extra={"SPECIFY_INIT_DIR": str(project)},
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
|
||||
"""With an older core common.sh, a set SPECIFY_INIT_DIR must hard-error
|
||||
instead of calling the stale get_repo_root that ignores the override."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
(project / "scripts" / "bash" / "common.sh").write_text(
|
||||
"#!/usr/bin/env bash\nget_repo_root() { pwd; }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "x", "X feature",
|
||||
env_extra={"SPECIFY_INIT_DIR": str(tmp_path / "missing")},
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
@@ -437,6 +467,43 @@ class TestCreateFeaturePowerShell:
|
||||
assert "BRANCH_NAME" in data
|
||||
assert "FEATURE_NUM" in data
|
||||
|
||||
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
|
||||
"""With no core scripts (only git-common.ps1 loaded), a set SPECIFY_INIT_DIR
|
||||
hard-errors instead of silently falling back to the walk-up project root."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
(project / "scripts" / "powershell" / "common.ps1").unlink()
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(project)}
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
|
||||
cwd=project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
|
||||
"""With an older core common.ps1, a set SPECIFY_INIT_DIR must hard-error
|
||||
instead of calling the stale Get-RepoRoot that ignores the override."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
(project / "scripts" / "powershell" / "common.ps1").write_text(
|
||||
"function Get-RepoRoot { return (Get-Location).Path }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(tmp_path / "missing")}
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
|
||||
cwd=project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
467
tests/test_init_dir.py
Normal file
467
tests/test_init_dir.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""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 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
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# ── 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
|
||||
Reference in New Issue
Block a user