diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fc870a12..1c3c8a42ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ +- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892) + ## [0.10.0] - 2026-06-09 ### Changed diff --git a/docs/reference/core.md b/docs/reference/core.md index c5d9d1b4ec..35cc0a7f86 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -50,8 +50,12 @@ specify init my-project --integration copilot --preset compliance | Variable | Description | | ----------------- | ------------------------------------------------------------------------ | +| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core `get_repo_root` helper, so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. When unset, the project is detected by searching upward from the current directory as before. | +| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (above `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | +> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. + ## Check Installed Tools ```bash diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 6fd3835659..98491205e7 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -235,9 +235,20 @@ if [ "$_common_loaded" != "true" ]; then exit 1 fi -# Resolve repository root +# SPECIFY_INIT_DIR is resolved (and validated) by the core get_repo_root. If only +# the minimal git-common.sh was loaded (core common.sh absent), refuse rather +# than silently falling back to the git toplevel — honoring the no-silent-fallback +# contract instead of creating the feature in the wrong project. +if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type get_repo_root >/dev/null 2>&1; then + echo "Error: SPECIFY_INIT_DIR requires the Spec Kit core scripts (common.sh), which were not found." >&2 + exit 1 +fi + +# Resolve repository root. When the core scripts are present, get_repo_root +# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive / +# CI use) and hard-fails on an invalid value with no silent fallback. if type get_repo_root >/dev/null 2>&1; then - REPO_ROOT=$(get_repo_root) + REPO_ROOT=$(get_repo_root) || exit 1 elif git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) elif [ -n "$_PROJECT_ROOT" ]; then diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 90ea51d19b..60ced743d7 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -197,7 +197,17 @@ if (-not $commonLoaded) { throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." } -# Resolve repository root +# SPECIFY_INIT_DIR is resolved (and validated) by core Get-RepoRoot. If only the +# minimal git-common.ps1 was loaded (core common.ps1 absent), refuse rather than +# silently falling back to the script-dir walk-up project -- honoring the +# no-silent-fallback contract instead of targeting the wrong project. +if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue)) { + throw "SPECIFY_INIT_DIR requires the Spec Kit core scripts (common.ps1), which were not found." +} + +# Resolve repository root. When the core scripts are present, Get-RepoRoot +# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive / +# CI use) and hard-fails on an invalid value with no silent fallback. if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { $repoRoot = Get-RepoRoot } elseif ($projectRoot) { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 3ea66a652d..6d95ca42e0 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -24,9 +24,42 @@ find_specify_root() { return 1 } +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute +# project root, or prints an error and returns 1. Strict by design: the path +# must exist and contain .specify/, with no silent fallback to cwd or the +# script-location default (which would silently write to the wrong project). +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature) rather than duplicating it. +resolve_specify_init_dir() { + local init_root + # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. + # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH + # (which would also echo to stdout and corrupt the captured path). + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 + return 1 + fi + if [[ ! -d "$init_root/.specify" ]]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 + return 1 + fi + printf '%s\n' "$init_root" +} + # Get repository root, prioritizing .specify directory # This prevents using a parent repository when spec-kit is initialized in a subdirectory get_repo_root() { + # Explicit project override wins (see resolve_specify_init_dir). + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + resolve_specify_init_dir + return + fi + # First, look for .specify directory (spec-kit's own marker) local specify_root if specify_root=$(find_specify_root); then @@ -119,8 +152,12 @@ _persist_feature_json() { } get_feature_paths() { - local repo_root=$(get_repo_root) - local current_branch=$(get_current_branch) + # 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 + repo_root=$(get_repo_root) || return 1 + local current_branch + current_branch=$(get_current_branch) # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 023ac6b5a6..c9609764f7 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -123,7 +123,7 @@ clean_branch_name() { SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -REPO_ROOT=$(get_repo_root) +REPO_ROOT=$(get_repo_root) || exit 1 cd "$REPO_ROOT" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 35406d3f66..2485c18da4 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -24,9 +24,47 @@ function Find-SpecifyRoot { } } +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root, +# or writes an error and exits 1. Strict by design: the path must exist and +# contain .specify/, with no silent fallback. (An empty string is falsy, so the +# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.) +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature) rather than duplicating it. +function Resolve-SpecifyInitDir { + $initDir = $env:SPECIFY_INIT_DIR + # Normalize: relative paths resolve against the current directory. + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + # Resolve-Path also succeeds for files, so check the resolved path is a + # directory; otherwise a file value would slip through to the less accurate + # "not a Spec Kit project" error below. + if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") + exit 1 + } + $initRoot = $resolved.Path + if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot") + exit 1 + } + return $initRoot +} + # Get repository root, prioritizing .specify directory # This prevents using a parent repository when spec-kit is initialized in a subdirectory function Get-RepoRoot { + # Explicit project override wins (see Resolve-SpecifyInitDir). + if ($env:SPECIFY_INIT_DIR) { + return (Resolve-SpecifyInitDir) + } + # First, look for .specify directory (spec-kit's own marker) $specifyRoot = Find-SpecifyRoot if ($specifyRoot) { diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 142463683c..2c9a0e0489 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -337,6 +337,20 @@ def test_dry_run(self, tmp_path: Path): 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 git toplevel.""" + 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 the Spec Kit core scripts" in result.stderr + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestCreateFeaturePowerShell: @@ -377,6 +391,23 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): 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 the Spec Kit core scripts" in result.stderr + # ── auto-commit.sh Tests ───────────────────────────────────────────────────── diff --git a/tests/test_init_dir.py b/tests/test_init_dir.py new file mode 100644 index 0000000000..4adf28054e --- /dev/null +++ b/tests/test_init_dir.py @@ -0,0 +1,435 @@ +"""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 //.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 + + +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() == str(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() == str(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() == str(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) == str(web / "specs" / "001-demo") + assert str(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) == str(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) == str(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() == str(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() == str(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).""" + 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_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 str(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 str(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 str(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 when no PowerShell, incl. CI) ───────────────── + + +@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 + assert _feature_dir_line(result.stdout) == str(web / "specs" / "001-demo") + 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 + assert _feature_dir_line(result.stdout) == str(web / "specs" / "003-x") + + +@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