From 2a594960e41206a2edc02a9aa89e2dfcc3fccdaf Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 16:30:35 +0200 Subject: [PATCH 1/6] feat(scripts): add SPECIFY_INIT_DIR to target a member project from outside its directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a project-root override so a non-interactive / CI caller can run a Spec Kit command against a member project (e.g. apps/web in a monorepo) from outside that directory, without cd. SPECIFY_INIT_DIR names the project root — the directory containing .specify/ — and is honored by get_repo_root in scripts/bash/common.sh, mirrored in scripts/powershell/common.ps1. Strict by design, per maintainer guidance on #2834: when set, the path must exist and contain .specify/, otherwise the resolver hard-errors and never falls back to the current directory or the git toplevel (which would silently write to the wrong project's specs/). Relative paths normalize against the current directory (with CDPATH="" to avoid CDPATH-based misresolution); trailing slashes are tolerated; an empty string is treated as unset. Resolution stays two independent axes: SPECIFY_INIT_DIR selects the project, while SPECIFY_FEATURE_DIRECTORY / .specify/feature.json / branch detection select the feature within it (project-then-feature). The bash get_repo_root call sites that feed real resolution are split into decl/assignment so the hard error propagates instead of being masked by `local`. Env var only; the --project CLI flag is deferred. Adds tests/test_init_dir.py (positive + negative, bash and PowerShell) and documents the variable and the two-axis precedence in docs/reference/core.md. Refs: https://github.com/github/spec-kit/discussions/2834 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + docs/reference/core.md | 4 + scripts/bash/common.sh | 33 ++- scripts/bash/create-new-feature.sh | 2 +- scripts/powershell/common.ps1 | 25 +++ tests/test_init_dir.py | 348 +++++++++++++++++++++++++++++ 6 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 tests/test_init_dir.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5731432cb4..7c8d4cee60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## [Unreleased] + +### Added + +- feat(scripts): add `SPECIFY_INIT_DIR` to target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use (#2834) + ## [0.9.5] - 2026-06-05 ### Changed diff --git a/docs/reference/core.md b/docs/reference/core.md index 70c711b1cc..bbf283f240 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -59,8 +59,12 @@ specify init my-project --integration copilot --branch-numbering timestamp | 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 or the Git root. 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 (highest priority, above `.specify/feature.json` and branch-name detection). 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` / `SPECIFY_FEATURE` select the **feature** within that project. They are independent — project first, then feature. + ## Check Installed Tools ```bash diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 9d7dd21edf..8692201ab9 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -27,6 +27,29 @@ find_specify_root() { # Get repository root, prioritizing .specify directory over git # This prevents using a parent git repo when spec-kit is initialized in a subdirectory get_repo_root() { + # Explicit project override for non-interactive / CI use (e.g. running a + # Spec Kit command against a member project from a monorepo root without cd). + # SPECIFY_INIT_DIR names the project root: the directory *containing* .specify/. + # Strict by design: it must exist and contain .specify/. On failure we error + # and never fall back to cwd or the git toplevel, which would silently write + # to the wrong project's specs/. + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + local init_root + # Normalize (relative paths resolve against $(pwd); 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 + echo "$init_root" + return 0 + fi + # First, look for .specify directory (spec-kit's own marker) local specify_root if specify_root=$(find_specify_root); then @@ -54,7 +77,10 @@ get_current_branch() { fi # Then check git if available at the spec-kit root (not parent) - local repo_root=$(get_repo_root) + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in + # get_repo_root propagates instead of being masked by `local`. + local repo_root + repo_root=$(get_repo_root) || return 1 if has_git; then git -C "$repo_root" rev-parse --abbrev-ref HEAD return @@ -252,7 +278,10 @@ find_feature_dir_by_prefix() { } get_feature_paths() { - local repo_root=$(get_repo_root) + # 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=$(get_current_branch) local has_git_repo="false" diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c3537704f6..980a748210 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -192,7 +192,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 # Check if git is available at this repo root (not a parent) if has_git; then diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 42ffdf1390..56e4f84e71 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -27,6 +27,31 @@ function Find-SpecifyRoot { # Get repository root, prioritizing .specify directory over git # This prevents using a parent git repo when spec-kit is initialized in a subdirectory function Get-RepoRoot { + # Explicit project override for non-interactive / CI use (e.g. running a + # Spec Kit command against a member project from a monorepo root without cd). + # SPECIFY_INIT_DIR names the project root: the directory *containing* .specify/. + # Strict by design: it must exist and contain .specify/. On failure we error + # and never fall back to cwd or the git toplevel, which would silently write + # to the wrong project's specs/. (Empty string is treated as unset.) + if ($env:SPECIFY_INIT_DIR) { + $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 + if (-not $resolved) { + [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 + } + # First, look for .specify directory (spec-kit's own marker) $specifyRoot = Find-SpecifyRoot if ($specifyRoot) { diff --git a/tests/test_init_dir.py b/tests/test_init_dir.py new file mode 100644 index 0000000000..56c8b036f0 --- /dev/null +++ b/tests/test_init_dir.py @@ -0,0 +1,348 @@ +"""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 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" + +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_branch_lookup(tmp_path: Path) -> None: + """P4: feature resolution happens inside the *target* project, not cwd. + + cwd is itself a valid Spec Kit project with its own specs/; SPECIFY_INIT_DIR + must redirect resolution to the target project's specs/. + """ + cwd_proj = _make_project(tmp_path, "cwd_proj") + (cwd_proj / "specs" / "001-cwd").mkdir(parents=True) + web = _make_project(tmp_path, "web") + (web / "specs" / "001-demo").mkdir(parents=True) + + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(web), + "SPECIFY_FEATURE": "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 + + +# ── 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_branch_lookup(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") + (web / "specs" / "001-demo").mkdir(parents=True) + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(web), + "SPECIFY_FEATURE": "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 From 1a6e6d281d7c189cb15d5b7f3707b50e0afa005f Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 18:42:53 +0200 Subject: [PATCH 2/6] fix: honor SPECIFY_INIT_DIR in agent-context updates --- .../scripts/bash/update-agent-context.sh | 20 ++- .../powershell/update-agent-context.ps1 | 24 +++- tests/test_init_dir.py | 118 ++++++++++++++++++ 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 42ce44df9a..538fee2284 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -16,7 +16,25 @@ set -euo pipefail -PROJECT_ROOT="$(pwd)" +resolve_project_root() { + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + local init_root + 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 + exit 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 + exit 1 + fi + printf '%s\n' "$init_root" + return + fi + + pwd +} + +PROJECT_ROOT="$(resolve_project_root)" EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" DEFAULT_START="" DEFAULT_END="" diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index dad309c03a..74f15bc2be 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -52,10 +52,32 @@ function Test-ConfigObject { return $false } +function Resolve-ProjectRoot { + if ($env:SPECIFY_INIT_DIR) { + $initDir = $env:SPECIFY_INIT_DIR + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + if (-not $resolved) { + [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 + } + + return (Get-Location).Path +} + $ErrorActionPreference = 'Stop' $DefaultStart = '' $DefaultEnd = '' -$ProjectRoot = (Get-Location).Path +$ProjectRoot = Resolve-ProjectRoot $ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' if (-not (Test-Path -LiteralPath $ExtConfig)) { diff --git a/tests/test_init_dir.py b/tests/test_init_dir.py index 56c8b036f0..29d589cdd1 100644 --- a/tests/test_init_dir.py +++ b/tests/test_init_dir.py @@ -21,6 +21,22 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +AGENT_CONTEXT_SH = ( + PROJECT_ROOT + / "extensions" + / "agent-context" + / "scripts" + / "bash" + / "update-agent-context.sh" +) +AGENT_CONTEXT_PS = ( + PROJECT_ROOT + / "extensions" + / "agent-context" + / "scripts" + / "powershell" + / "update-agent-context.ps1" +) HAS_PWSH = shutil.which("pwsh") is not None _POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") @@ -45,6 +61,18 @@ def _make_project(root: Path, name: str) -> Path: return proj +def _write_agent_context_config(project: Path, context_file: str = "AGENTS.md") -> None: + """Create the minimal agent-context extension config.""" + config_dir = project / ".specify" / "extensions" / "agent-context" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "agent-context-config.yml").write_text( + f"context_file: {context_file}\n" + "context_markers:\n" + ' start: ""\n' + ' end: ""\n' + ) + + 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( @@ -57,6 +85,18 @@ def _bash(func_call: str, cwd: Path, env: dict[str, str]) -> subprocess.Complete ) +def _bash_agent_context(cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess: + """Run the real agent-context bash update script.""" + return subprocess.run( + ["bash", str(AGENT_CONTEXT_SH)], + 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( @@ -69,6 +109,18 @@ def _ps(script: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProc ) +def _ps_agent_context(cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess: + """Run the real agent-context PowerShell update script.""" + return subprocess.run( + [_PS_EXE, "-NoProfile", "-File", str(AGENT_CONTEXT_PS)], + 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="): @@ -165,6 +217,25 @@ def test_composes_with_target_feature_json(tmp_path: Path) -> None: assert _feature_dir_line(result.stdout) == str(web / "specs" / "004-fj") +@requires_bash +def test_agent_context_update_honors_init_dir(tmp_path: Path) -> None: + """P7: agent-context updates the target project, not the caller cwd.""" + mono = tmp_path / "mono" + web = _make_project(mono / "apps", "web") + _write_agent_context_config(web) + (web / "specs" / "001-target").mkdir(parents=True) + (web / "specs" / "001-target" / "plan.md").write_text("# Plan\n") + + env = {**_clean_env(), "SPECIFY_INIT_DIR": "apps/web"} + result = _bash_agent_context(cwd=mono, env=env) + + assert result.returncode == 0, result.stderr + content = (web / "AGENTS.md").read_text() + assert "" in content + assert "at specs/001-target/plan.md" in content + assert not (mono / "AGENTS.md").exists() + + # ── Bash: negative / contract cases ───────────────────────────────────────── @@ -238,6 +309,21 @@ def test_path_without_specify_errors_no_fallback(tmp_path: Path) -> None: assert str(web) not in result.stdout +@requires_bash +def test_agent_context_invalid_init_dir_errors_no_fallback(tmp_path: Path) -> None: + """N6: agent-context must not fall back to cwd when SPECIFY_INIT_DIR is bad.""" + cwd_proj = _make_project(tmp_path, "cwd_proj") + _write_agent_context_config(cwd_proj, "ROOT.md") + missing = tmp_path / "does_not_exist" + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)} + + result = _bash_agent_context(cwd=cwd_proj, env=env) + + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + assert not (cwd_proj / "ROOT.md").exists() + + # ── PowerShell mirror (skipped when no PowerShell, incl. CI) ───────────────── @@ -316,6 +402,24 @@ def test_ps_composes_with_feature_directory_override(tmp_path: Path) -> None: assert _feature_dir_line(result.stdout) == str(web / "specs" / "003-x") +@requires_pwsh +def test_ps_agent_context_update_honors_init_dir(tmp_path: Path) -> None: + mono = tmp_path / "mono" + web = _make_project(mono / "apps", "web") + _write_agent_context_config(web) + (web / "specs" / "001-target").mkdir(parents=True) + (web / "specs" / "001-target" / "plan.md").write_text("# Plan\n") + + env = {**_clean_env(), "SPECIFY_INIT_DIR": "apps/web"} + result = _ps_agent_context(cwd=mono, env=env) + + assert result.returncode == 0, result.stderr + content = (web / "AGENTS.md").read_text() + assert "" in content + assert "at specs/001-target/plan.md" in content + assert not (mono / "AGENTS.md").exists() + + @requires_pwsh def test_ps_empty_string_treated_as_unset(tmp_path: Path) -> None: web = _make_project(tmp_path, "web") @@ -346,3 +450,17 @@ def test_ps_path_without_specify_errors_no_fallback(tmp_path: Path) -> None: 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_agent_context_invalid_init_dir_errors_no_fallback(tmp_path: Path) -> None: + cwd_proj = _make_project(tmp_path, "cwd_proj") + _write_agent_context_config(cwd_proj, "ROOT.md") + missing = tmp_path / "does_not_exist" + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)} + + result = _ps_agent_context(cwd=cwd_proj, env=env) + + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + assert not (cwd_proj / "ROOT.md").exists() From 068c5be38949ee09261c1475fbe17fcaefbed472 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 18:48:37 +0200 Subject: [PATCH 3/6] docs(changelog): note agent-context honors SPECIFY_INIT_DIR Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8d4cee60..4c71f72feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - feat(scripts): add `SPECIFY_INIT_DIR` to target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use (#2834) +- feat(extensions): honor `SPECIFY_INIT_DIR` in the agent-context extension's `update-agent-context` scripts, so context updates target the selected project (#2834) ## [0.9.5] - 2026-06-05 From 66286c18e14cba1d6faf116eab06c216b66ad6b7 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 22:00:15 +0200 Subject: [PATCH 4/6] feat(git): honor SPECIFY_INIT_DIR in git extension; address review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends SPECIFY_INIT_DIR coverage to the bundled Git extension — the actual /speckit.specify before_specify entrypoint — so a monorepo/CI user setting it gets the feature branch created in, and auto-commit run against, the targeted project rather than the cwd/git toplevel. create-new-feature.sh/.ps1 and auto-commit.sh/.ps1 now resolve the project root from SPECIFY_INIT_DIR first, with the same strict contract as core (must exist + contain .specify/, hard error, no silent fallback). Adds bash tests for the git entrypoint. Also addresses the max-effort review of the diff: - common.sh: split the remaining `local x=$(get_repo_root)` / `local x=$(get_current_branch)` call sites (has_git, get_feature_paths) so a SPECIFY_INIT_DIR validation failure is never masked by `local`. - docs: note that the agent-context update selects the most recently modified plan within the resolved project (feature-axis selectors apply to the core feature scripts), and that SPECIFY_INIT_DIR is honored by core + git ext. - CHANGELOG: drop the hand-added [Unreleased] block; the release workflow generates entries from commit subjects, so manual entries would duplicate. Refs: https://github.com/github/spec-kit/discussions/2834 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 -- docs/reference/core.md | 4 +- extensions/git/scripts/bash/auto-commit.sh | 17 +++- .../git/scripts/bash/create-new-feature.sh | 17 +++- .../git/scripts/powershell/auto-commit.ps1 | 24 +++++- .../scripts/powershell/create-new-feature.ps1 | 22 ++++- scripts/bash/common.sh | 8 +- tests/test_init_dir.py | 83 +++++++++++++++++++ 8 files changed, 164 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c71f72feb..5731432cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,6 @@ -## [Unreleased] - -### Added - -- feat(scripts): add `SPECIFY_INIT_DIR` to target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use (#2834) -- feat(extensions): honor `SPECIFY_INIT_DIR` in the agent-context extension's `update-agent-context` scripts, so context updates target the selected project (#2834) - ## [0.9.5] - 2026-06-05 ### Changed diff --git a/docs/reference/core.md b/docs/reference/core.md index bbf283f240..689c0d6dcd 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -59,11 +59,11 @@ specify init my-project --integration copilot --branch-numbering timestamp | 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 or the Git root. When unset, the project is detected by searching upward from the current directory as before. | +| `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 or the Git root. Honored by the core feature scripts and the bundled Git extension (feature-branch creation and auto-commit). 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 (highest priority, above `.specify/feature.json` and branch-name detection). 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` / `SPECIFY_FEATURE` select the **feature** within that project. They are independent — project first, then feature. +> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` / `SPECIFY_FEATURE` select the **feature** within that project. They are independent — project first, then feature. Feature-axis selection applies to the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …); the agent-context update picks the most recently modified `specs/*/plan.md` within the resolved project regardless of the feature-axis variables. ## Check Installed Tools diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh index f0b423187b..5dff46f42e 100755 --- a/extensions/git/scripts/bash/auto-commit.sh +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -28,7 +28,22 @@ _find_project_root() { return 1 } -REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +# SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins, +# with the same strict contract as core scripts/bash/common.sh: it must exist and +# contain .specify/, else hard error with no silent fallback. Otherwise keep the +# previous script-relative / cwd resolution. +if [ -n "${SPECIFY_INIT_DIR:-}" ]; then + if ! REPO_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 + exit 1 + fi + if [ ! -d "$REPO_ROOT/.specify" ]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $REPO_ROOT" >&2 + exit 1 + fi +else + REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +fi cd "$REPO_ROOT" # Check if git is available diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index f7aa31610e..bf122ea24a 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -234,8 +234,21 @@ if [ "$_common_loaded" != "true" ]; then exit 1 fi -# Resolve repository root -if type get_repo_root >/dev/null 2>&1; then +# Resolve repository root. SPECIFY_INIT_DIR (explicit project override for +# non-interactive / CI use) wins, with the same strict contract as core +# scripts/bash/common.sh: it must exist and contain .specify/, else hard error +# with no silent fallback to cwd or the git toplevel. Checked inline so it is +# honored regardless of which common.sh variant (core or git-common.sh) loaded. +if [ -n "${SPECIFY_INIT_DIR:-}" ]; then + if ! REPO_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 + exit 1 + fi + if [ ! -d "$REPO_ROOT/.specify" ]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $REPO_ROOT" >&2 + exit 1 + fi +elif type get_repo_root >/dev/null 2>&1; then REPO_ROOT=$(get_repo_root) elif git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index 34767f8a36..a6adcd1b51 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -26,8 +26,28 @@ function Find-ProjectRoot { } } -$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot -if (-not $repoRoot) { $repoRoot = Get-Location } +# SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins, +# with the strict contract from core scripts/powershell/common.ps1: it must exist +# and contain .specify/, else hard error with no silent fallback. +if ($env:SPECIFY_INIT_DIR) { + $initDir = $env:SPECIFY_INIT_DIR + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + if (-not $resolved) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") + exit 1 + } + $repoRoot = $resolved.Path + if (-not (Test-Path -LiteralPath (Join-Path $repoRoot '.specify') -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $repoRoot") + exit 1 + } +} else { + $repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot + if (-not $repoRoot) { $repoRoot = Get-Location } +} Set-Location $repoRoot # Check if git is available diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index b579f05160..1c176bca11 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -196,8 +196,26 @@ if (-not $commonLoaded) { throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." } -# Resolve repository root -if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { +# Resolve repository root. SPECIFY_INIT_DIR (explicit project override for +# non-interactive / CI use) wins, with the strict contract from core +# scripts/powershell/common.ps1: it must exist and contain .specify/, else hard +# error with no silent fallback to cwd or the git toplevel. +if ($env:SPECIFY_INIT_DIR) { + $initDir = $env:SPECIFY_INIT_DIR + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + if (-not $resolved) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") + exit 1 + } + $repoRoot = $resolved.Path + if (-not (Test-Path -LiteralPath (Join-Path $repoRoot '.specify') -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $repoRoot") + exit 1 + } +} elseif (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { $repoRoot = Get-RepoRoot } elseif ($projectRoot) { $repoRoot = $projectRoot diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 8692201ab9..967a9dbe50 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -133,7 +133,10 @@ get_current_branch() { has_git() { # First check if git command is available (before calling get_repo_root which may use git) command -v git >/dev/null 2>&1 || return 1 - local repo_root=$(get_repo_root) + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in + # get_repo_root propagates instead of being masked by `local`. + local repo_root + repo_root=$(get_repo_root) || return 1 # Check if .git exists (directory or file for worktrees/submodules) [ -e "$repo_root/.git" ] || return 1 # Verify it's actually a valid git work tree @@ -282,7 +285,8 @@ get_feature_paths() { # 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=$(get_current_branch) + local current_branch + current_branch=$(get_current_branch) || return 1 local has_git_repo="false" if has_git; then diff --git a/tests/test_init_dir.py b/tests/test_init_dir.py index 29d589cdd1..d986be3f67 100644 --- a/tests/test_init_dir.py +++ b/tests/test_init_dir.py @@ -9,6 +9,7 @@ See proposals/monorepo-support and github/spec-kit discussion #2834. """ +import json import os import shutil import subprocess @@ -37,6 +38,12 @@ / "powershell" / "update-agent-context.ps1" ) +GIT_CREATE_FEATURE_SH = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +) +GIT_AUTO_COMMIT_SH = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "auto-commit.sh" +) HAS_PWSH = shutil.which("pwsh") is not None _POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") @@ -324,6 +331,82 @@ def test_agent_context_invalid_init_dir_errors_no_fallback(tmp_path: Path) -> No assert not (cwd_proj / "ROOT.md").exists() +# ── 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.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 + + +@requires_bash +def test_git_ext_auto_commit_invalid_init_dir_errors(tmp_path: Path) -> None: + """N8: the git extension auto-commit hook hard-errors on an invalid + SPECIFY_INIT_DIR rather than committing in the cwd project.""" + 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 = subprocess.run( + ["bash", str(GIT_AUTO_COMMIT_SH), "after_specify"], + cwd=web, + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + + # ── PowerShell mirror (skipped when no PowerShell, incl. CI) ───────────────── From cae2621b7432a8c3d4c051af123ad228b08803b0 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 22:16:49 +0200 Subject: [PATCH 5/6] refactor: extract SPECIFY_INIT_DIR resolver to one helper per library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the duplication the review flagged: the strict SPECIFY_INIT_DIR validation was inlined per-script across the git extension. Extract it into a named resolver — resolve_specify_init_dir (bash) / Resolve-SpecifyInitDir (PowerShell) — defined once per independently-shippable library: - core scripts/bash/common.sh + scripts/powershell/common.ps1 (get_repo_root now delegates to it) - extensions/git git-common.sh + git-common.ps1 (create-new-feature and auto-commit call it instead of carrying their own inline copy) The three libraries still each carry one copy because the git and agent-context extensions must run without the core scripts installed — a single cross-library source would couple them — but the per-script inline duplication within the git extension is gone, and each copy is now a named, greppable function that is trivial to diff and keep in sync. Behavior and error strings are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/git/scripts/bash/auto-commit.sh | 16 ++---- .../git/scripts/bash/create-new-feature.sh | 17 ++---- extensions/git/scripts/bash/git-common.sh | 20 +++++++ .../git/scripts/powershell/auto-commit.ps1 | 21 ++----- .../scripts/powershell/create-new-feature.ps1 | 20 ++----- .../git/scripts/powershell/git-common.ps1 | 25 +++++++++ scripts/bash/common.sh | 51 ++++++++++------- scripts/powershell/common.ps1 | 55 +++++++++++-------- 8 files changed, 128 insertions(+), 97 deletions(-) diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh index 5dff46f42e..ddc4c8a683 100755 --- a/extensions/git/scripts/bash/auto-commit.sh +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -29,18 +29,12 @@ _find_project_root() { } # SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins, -# with the same strict contract as core scripts/bash/common.sh: it must exist and -# contain .specify/, else hard error with no silent fallback. Otherwise keep the -# previous script-relative / cwd resolution. +# with the strict contract from resolve_specify_init_dir (git-common.sh): it must +# exist and contain .specify/, else hard error with no silent fallback. Otherwise +# keep the previous script-relative / cwd resolution unchanged. if [ -n "${SPECIFY_INIT_DIR:-}" ]; then - if ! REPO_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 - exit 1 - fi - if [ ! -d "$REPO_ROOT/.specify" ]; then - echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $REPO_ROOT" >&2 - exit 1 - fi + source "$SCRIPT_DIR/git-common.sh" + REPO_ROOT=$(resolve_specify_init_dir) || exit 1 else REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" fi diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index bf122ea24a..4360d1b2f9 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -235,19 +235,12 @@ if [ "$_common_loaded" != "true" ]; then fi # Resolve repository root. SPECIFY_INIT_DIR (explicit project override for -# non-interactive / CI use) wins, with the same strict contract as core -# scripts/bash/common.sh: it must exist and contain .specify/, else hard error -# with no silent fallback to cwd or the git toplevel. Checked inline so it is -# honored regardless of which common.sh variant (core or git-common.sh) loaded. +# non-interactive / CI use) wins, with the strict contract from +# resolve_specify_init_dir (provided by core common.sh or git-common.sh, +# whichever was sourced above): it must exist and contain .specify/, else hard +# error with no silent fallback to cwd or the git toplevel. if [ -n "${SPECIFY_INIT_DIR:-}" ]; then - if ! REPO_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 - exit 1 - fi - if [ ! -d "$REPO_ROOT/.specify" ]; then - echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $REPO_ROOT" >&2 - exit 1 - fi + REPO_ROOT=$(resolve_specify_init_dir) || exit 1 elif type get_repo_root >/dev/null 2>&1; then REPO_ROOT=$(get_repo_root) elif git rev-parse --show-toplevel >/dev/null 2>&1; then diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index b78356d1c6..dbcf1abba9 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -3,6 +3,26 @@ # Extracted from scripts/bash/common.sh — contains only git-specific # branch validation and detection logic. +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/). Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the +# validated absolute project root, or prints an error and returns 1. Strict by +# design: must exist and contain .specify/, with no silent fallback. +# +# Byte-identical copy of resolve_specify_init_dir in scripts/bash/common.sh — the +# git extension can run without the core scripts installed; keep the two in sync. +resolve_specify_init_dir() { + local init_root + 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" +} + # Check if we have git available at the repo root has_git() { local repo_root="${1:-$(pwd)}" diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index a6adcd1b51..5298cfe3ad 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -27,23 +27,12 @@ function Find-ProjectRoot { } # SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins, -# with the strict contract from core scripts/powershell/common.ps1: it must exist -# and contain .specify/, else hard error with no silent fallback. +# with the strict contract from Resolve-SpecifyInitDir (git-common.ps1): it must +# exist and contain .specify/, else hard error with no silent fallback. Otherwise +# keep the previous script-relative / cwd resolution unchanged. if ($env:SPECIFY_INIT_DIR) { - $initDir = $env:SPECIFY_INIT_DIR - if (-not [System.IO.Path]::IsPathRooted($initDir)) { - $initDir = Join-Path (Get-Location).Path $initDir - } - $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue - if (-not $resolved) { - [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") - exit 1 - } - $repoRoot = $resolved.Path - if (-not (Test-Path -LiteralPath (Join-Path $repoRoot '.specify') -PathType Container)) { - [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $repoRoot") - exit 1 - } + . (Join-Path $PSScriptRoot 'git-common.ps1') + $repoRoot = Resolve-SpecifyInitDir } else { $repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot if (-not $repoRoot) { $repoRoot = Get-Location } diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 1c176bca11..c3aa5bbc9c 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -197,24 +197,12 @@ if (-not $commonLoaded) { } # Resolve repository root. SPECIFY_INIT_DIR (explicit project override for -# non-interactive / CI use) wins, with the strict contract from core -# scripts/powershell/common.ps1: it must exist and contain .specify/, else hard +# non-interactive / CI use) wins, with the strict contract from +# Resolve-SpecifyInitDir (provided by core common.ps1 or git-common.ps1, +# whichever was sourced above): it must exist and contain .specify/, else hard # error with no silent fallback to cwd or the git toplevel. if ($env:SPECIFY_INIT_DIR) { - $initDir = $env:SPECIFY_INIT_DIR - if (-not [System.IO.Path]::IsPathRooted($initDir)) { - $initDir = Join-Path (Get-Location).Path $initDir - } - $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue - if (-not $resolved) { - [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") - exit 1 - } - $repoRoot = $resolved.Path - if (-not (Test-Path -LiteralPath (Join-Path $repoRoot '.specify') -PathType Container)) { - [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $repoRoot") - exit 1 - } + $repoRoot = Resolve-SpecifyInitDir } elseif (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { $repoRoot = Get-RepoRoot } elseif ($projectRoot) { diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 13ea7542c4..b178699bb0 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -3,6 +3,31 @@ # Extracted from scripts/powershell/common.ps1 -- contains only git-specific # branch validation and detection logic. +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/). Precondition: $env:SPECIFY_INIT_DIR is set. Returns the +# validated project root, or writes an error and exits 1. Strict by design: must +# exist and contain .specify/, with no silent fallback. +# +# Byte-identical copy of Resolve-SpecifyInitDir in scripts/powershell/common.ps1 +# -- the git extension can run without the core scripts installed; keep in sync. +function Resolve-SpecifyInitDir { + $initDir = $env:SPECIFY_INIT_DIR + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + if (-not $resolved) { + [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 +} + function Test-HasGit { param([string]$RepoRoot = (Get-Location)) try { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 967a9dbe50..366dcb510f 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -24,30 +24,41 @@ 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 git +# toplevel (which would silently write to the wrong project's specs/). +# +# This is the single source of truth for the SPECIFY_INIT_DIR contract. The git +# and agent-context extensions carry a byte-identical copy because they can run +# without the core scripts installed; keep the three in sync. +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 over git # This prevents using a parent git repo when spec-kit is initialized in a subdirectory get_repo_root() { - # Explicit project override for non-interactive / CI use (e.g. running a - # Spec Kit command against a member project from a monorepo root without cd). - # SPECIFY_INIT_DIR names the project root: the directory *containing* .specify/. - # Strict by design: it must exist and contain .specify/. On failure we error - # and never fall back to cwd or the git toplevel, which would silently write - # to the wrong project's specs/. + # Explicit project override wins (see resolve_specify_init_dir). if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then - local init_root - # Normalize (relative paths resolve against $(pwd); 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 - echo "$init_root" - return 0 + resolve_specify_init_dir + return fi # First, look for .specify directory (spec-kit's own marker) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 56e4f84e71..36eda730f3 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -24,32 +24,43 @@ 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.) +# +# Single source of truth for the SPECIFY_INIT_DIR contract; the git and +# agent-context extensions carry a byte-identical copy because they can run +# without the core scripts installed. Keep the three in sync. +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 + if (-not $resolved) { + [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 over git # This prevents using a parent git repo when spec-kit is initialized in a subdirectory function Get-RepoRoot { - # Explicit project override for non-interactive / CI use (e.g. running a - # Spec Kit command against a member project from a monorepo root without cd). - # SPECIFY_INIT_DIR names the project root: the directory *containing* .specify/. - # Strict by design: it must exist and contain .specify/. On failure we error - # and never fall back to cwd or the git toplevel, which would silently write - # to the wrong project's specs/. (Empty string is treated as unset.) + # Explicit project override wins (see Resolve-SpecifyInitDir). if ($env:SPECIFY_INIT_DIR) { - $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 - if (-not $resolved) { - [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 + return (Resolve-SpecifyInitDir) } # First, look for .specify directory (spec-kit's own marker) From e28b8db6a91954fa979de0ce91a5679795c02e0c Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 22:37:02 +0200 Subject: [PATCH 6/6] Harden git extension init dir fallback --- .../git/scripts/bash/create-new-feature.sh | 17 ++++- extensions/git/scripts/bash/git-common.sh | 4 +- .../scripts/powershell/create-new-feature.ps1 | 17 ++++- .../git/scripts/powershell/git-common.ps1 | 4 +- scripts/bash/common.sh | 5 +- scripts/powershell/common.ps1 | 5 +- tests/extensions/git/test_git_extension.py | 62 +++++++++++++++++++ 7 files changed, 98 insertions(+), 16 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 4360d1b2f9..baa185e87c 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -234,11 +234,22 @@ if [ "$_common_loaded" != "true" ]; then exit 1 fi +# A project may have an older core common.sh while the git extension has been +# updated independently. In that case, fall back to the extension-local helper +# for the SPECIFY_INIT_DIR contract instead of failing with "command not found". +if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then + if [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + else + echo "Error: SPECIFY_INIT_DIR requires resolve_specify_init_dir, but git-common.sh was not found." >&2 + exit 1 + fi +fi + # Resolve repository root. SPECIFY_INIT_DIR (explicit project override for # non-interactive / CI use) wins, with the strict contract from -# resolve_specify_init_dir (provided by core common.sh or git-common.sh, -# whichever was sourced above): it must exist and contain .specify/, else hard -# error with no silent fallback to cwd or the git toplevel. +# resolve_specify_init_dir: it must exist and contain .specify/, else hard error +# with no silent fallback to cwd or the git toplevel. if [ -n "${SPECIFY_INIT_DIR:-}" ]; then REPO_ROOT=$(resolve_specify_init_dir) || exit 1 elif type get_repo_root >/dev/null 2>&1; then diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index dbcf1abba9..4d7ea408e4 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -8,8 +8,8 @@ # validated absolute project root, or prints an error and returns 1. Strict by # design: must exist and contain .specify/, with no silent fallback. # -# Byte-identical copy of resolve_specify_init_dir in scripts/bash/common.sh — the -# git extension can run without the core scripts installed; keep the two in sync. +# Keep this contract aligned with scripts/bash/common.sh. The git extension can +# run without the core scripts installed. resolve_specify_init_dir() { local init_root if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index c3aa5bbc9c..81297296d4 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -196,11 +196,22 @@ if (-not $commonLoaded) { throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." } +# A project may have an older core common.ps1 while the git extension has been +# updated independently. In that case, fall back to the extension-local helper +# for the SPECIFY_INIT_DIR contract instead of failing with "command not found". +if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -ErrorAction SilentlyContinue)) { + $gitCommon = Join-Path $PSScriptRoot 'git-common.ps1' + if (Test-Path $gitCommon) { + . $gitCommon + } else { + throw "SPECIFY_INIT_DIR requires Resolve-SpecifyInitDir, but git-common.ps1 was not found." + } +} + # Resolve repository root. SPECIFY_INIT_DIR (explicit project override for # non-interactive / CI use) wins, with the strict contract from -# Resolve-SpecifyInitDir (provided by core common.ps1 or git-common.ps1, -# whichever was sourced above): it must exist and contain .specify/, else hard -# error with no silent fallback to cwd or the git toplevel. +# Resolve-SpecifyInitDir: it must exist and contain .specify/, else hard error +# with no silent fallback to cwd or the git toplevel. if ($env:SPECIFY_INIT_DIR) { $repoRoot = Resolve-SpecifyInitDir } elseif (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index b178699bb0..e163ef5669 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -8,8 +8,8 @@ # validated project root, or writes an error and exits 1. Strict by design: must # exist and contain .specify/, with no silent fallback. # -# Byte-identical copy of Resolve-SpecifyInitDir in scripts/powershell/common.ps1 -# -- the git extension can run without the core scripts installed; keep in sync. +# Keep this contract aligned with scripts/powershell/common.ps1. The git +# extension can run without the core scripts installed. function Resolve-SpecifyInitDir { $initDir = $env:SPECIFY_INIT_DIR if (-not [System.IO.Path]::IsPathRooted($initDir)) { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 366dcb510f..27ad0549e2 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -33,9 +33,8 @@ find_specify_root() { # must exist and contain .specify/, with no silent fallback to cwd or the git # toplevel (which would silently write to the wrong project's specs/). # -# This is the single source of truth for the SPECIFY_INIT_DIR contract. The git -# and agent-context extensions carry a byte-identical copy because they can run -# without the core scripts installed; keep the three in sync. +# Keep this contract aligned with standalone extension helpers: git and +# agent-context can run without the core scripts installed. resolve_specify_init_dir() { local init_root # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 36eda730f3..03418d8371 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -33,9 +33,8 @@ function Find-SpecifyRoot { # 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.) # -# Single source of truth for the SPECIFY_INIT_DIR contract; the git and -# agent-context extensions carry a byte-identical copy because they can run -# without the core scripts installed. Keep the three in sync. +# Keep this contract aligned with standalone extension helpers: git and +# agent-context can run without the core scripts installed. function Resolve-SpecifyInitDir { $initDir = $env:SPECIFY_INIT_DIR # Normalize: relative paths resolve against the current directory. diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index b3fde0bd4a..6e3aa9d9f7 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -337,6 +337,32 @@ 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_falls_back_when_core_common_is_legacy(self, tmp_path: Path): + """Extension-local git-common handles SPECIFY_INIT_DIR with old core scripts.""" + repo = tmp_path / "repo" + project = _setup_project(repo / "member", git=False) + (project / "scripts" / "bash" / "common.sh").write_text( + "#!/usr/bin/env bash\n" + "get_repo_root() { pwd; }\n" + "has_git() { return 1; }\n", + encoding="utf-8", + ) + + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": "member"} + result = subprocess.run( + ["bash", str(script), "--json", "--dry-run", "--short-name", "compat", "Compat feature"], + cwd=repo, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "command not found" not in result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "001-compat" + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestCreateFeaturePowerShell: @@ -377,6 +403,42 @@ 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_falls_back_when_core_common_is_legacy(self, tmp_path: Path): + """Extension-local git-common handles SPECIFY_INIT_DIR with old core scripts.""" + repo = tmp_path / "repo" + project = _setup_project(repo / "member", git=False) + (project / "scripts" / "powershell" / "common.ps1").write_text( + "function Get-RepoRoot { return (Get-Location).Path }\n" + "function Test-HasGit { return $false }\n", + encoding="utf-8", + ) + + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" + env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": "member"} + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-File", + str(script), + "-Json", + "-DryRun", + "-ShortName", + "compat", + "Compat feature", + ], + cwd=repo, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] + assert json_line, f"No JSON in output: {result.stdout}" + data = json.loads(json_line[-1]) + assert data["BRANCH_NAME"] == "001-compat" + # ── auto-commit.sh Tests ─────────────────────────────────────────────────────