Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

<!-- insert new changelog below this comment -->

- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)

## [0.10.1] - 2026-06-09

### Changed
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (takes precedence over `.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
Expand Down
14 changes: 12 additions & 2 deletions extensions/git/scripts/bash/create-new-feature-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,19 @@ if [ "$_common_loaded" != "true" ]; then
exit 1
fi

# Resolve repository root
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
# minimal git-common.sh was loaded, or an older core common.sh without the
# resolver was loaded, refuse rather than silently falling back to the wrong root.
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
echo "Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.sh with resolve_specify_init_dir), 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,16 @@ 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 the core resolver. If only the
# minimal git-common.ps1 was loaded, or an older core common.ps1 without the
# resolver was loaded, refuse rather than silently falling back to the wrong root.
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) {
throw "SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.ps1 with Resolve-SpecifyInitDir), 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) {
Expand Down
41 changes: 39 additions & 2 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-branch) 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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
38 changes: 38 additions & 0 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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-branch) 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) {
Expand Down
67 changes: 67 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,36 @@ 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 walk-up project root."""
project = _setup_project(tmp_path, git=False)
# Simulate a no-core install: drop core common.sh so only git-common.sh loads.
(project / "scripts" / "bash" / "common.sh").unlink()
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "x", "X feature",
env_extra={"SPECIFY_INIT_DIR": str(project)},
)
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr

def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
"""With an older core common.sh, a set SPECIFY_INIT_DIR must hard-error
instead of calling the stale get_repo_root that ignores the override."""
project = _setup_project(tmp_path, git=False)
(project / "scripts" / "bash" / "common.sh").write_text(
"#!/usr/bin/env bash\nget_repo_root() { pwd; }\n",
encoding="utf-8",
)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "x", "X feature",
env_extra={"SPECIFY_INIT_DIR": str(tmp_path / "missing")},
)
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr


@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
Expand Down Expand Up @@ -377,6 +407,43 @@ 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 updated Spec Kit core scripts" in result.stderr

def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
"""With an older core common.ps1, a set SPECIFY_INIT_DIR must hard-error
instead of calling the stale Get-RepoRoot that ignores the override."""
project = _setup_project(tmp_path, git=False)
(project / "scripts" / "powershell" / "common.ps1").write_text(
"function Get-RepoRoot { return (Get-Location).Path }\n",
encoding="utf-8",
)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(tmp_path / "missing")}
result = subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
cwd=project,
capture_output=True,
text=True,
env=env,
)
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr


# ── auto-commit.sh Tests ─────────────────────────────────────────────────────

Expand Down
Loading