Skip to content

Commit 24cf7cd

Browse files
committed
feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root
Resolve an explicit SPECIFY_INIT_DIR project override once in the core get_repo_root / Get-RepoRoot, so a non-interactive / CI caller can target a member project (the directory containing .specify/) from a monorepo root without cd. Strict by design: the path must exist and contain .specify/, otherwise it hard-errors with no silent fallback. - Single resolver in core; the git feature-branch script inherits it by sourcing core, with no per-extension copies. - PS resolver verifies the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - get_feature_paths splits decl/assignment so a SPECIFY_INIT_DIR failure propagates instead of being masked by `local`. - create-new-feature-branch: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error rather than silently using the git root. - Document SPECIFY_INIT_DIR and SPECIFY_FEATURE_DIRECTORY in the core reference. - Tests for valid/relative/trailing-slash/file/missing/no-.specify targets, feature-axis composition, the no-core guard, and a PowerShell mirror.
1 parent 7c610a3 commit 24cf7cd

9 files changed

Lines changed: 584 additions & 6 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

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

5+
- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)
6+
57
## [0.10.1] - 2026-06-09
68

79
### Changed

β€Ždocs/reference/core.mdβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ specify init my-project --integration copilot --preset compliance
5050

5151
| Variable | Description |
5252
| ----------------- | ------------------------------------------------------------------------ |
53+
| `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. |
54+
| `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. |
5355
| `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. |
5456

57+
> **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.
58+
5559
## Check Installed Tools
5660

5761
```bash

β€Žextensions/git/scripts/bash/create-new-feature-branch.shβ€Ž

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,20 @@ if [ "$_common_loaded" != "true" ]; then
235235
exit 1
236236
fi
237237

238-
# Resolve repository root
238+
# SPECIFY_INIT_DIR is resolved (and validated) by the core get_repo_root. If only
239+
# the minimal git-common.sh was loaded (core common.sh absent), refuse rather
240+
# than silently falling back to the git toplevel β€” honoring the no-silent-fallback
241+
# contract instead of creating the feature in the wrong project.
242+
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type get_repo_root >/dev/null 2>&1; then
243+
echo "Error: SPECIFY_INIT_DIR requires the Spec Kit core scripts (common.sh), which were not found." >&2
244+
exit 1
245+
fi
246+
247+
# Resolve repository root. When the core scripts are present, get_repo_root
248+
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
249+
# CI use) and hard-fails on an invalid value with no silent fallback.
239250
if type get_repo_root >/dev/null 2>&1; then
240-
REPO_ROOT=$(get_repo_root)
251+
REPO_ROOT=$(get_repo_root) || exit 1
241252
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
242253
REPO_ROOT=$(git rev-parse --show-toplevel)
243254
elif [ -n "$_PROJECT_ROOT" ]; then

β€Žextensions/git/scripts/powershell/create-new-feature-branch.ps1β€Ž

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,17 @@ if (-not $commonLoaded) {
197197
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
198198
}
199199

200-
# Resolve repository root
200+
# SPECIFY_INIT_DIR is resolved (and validated) by core Get-RepoRoot. If only the
201+
# minimal git-common.ps1 was loaded (core common.ps1 absent), refuse rather than
202+
# silently falling back to the script-dir walk-up project -- honoring the
203+
# no-silent-fallback contract instead of targeting the wrong project.
204+
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue)) {
205+
throw "SPECIFY_INIT_DIR requires the Spec Kit core scripts (common.ps1), which were not found."
206+
}
207+
208+
# Resolve repository root. When the core scripts are present, Get-RepoRoot
209+
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
210+
# CI use) and hard-fails on an invalid value with no silent fallback.
201211
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
202212
$repoRoot = Get-RepoRoot
203213
} elseif ($projectRoot) {

β€Žscripts/bash/common.shβ€Ž

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,42 @@ find_specify_root() {
2424
return 1
2525
}
2626

27+
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
28+
# *contains* .specify/), for non-interactive / CI use β€” e.g. running a Spec Kit
29+
# command against a member project from a monorepo root without cd.
30+
#
31+
# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute
32+
# project root, or prints an error and returns 1. Strict by design: the path
33+
# must exist and contain .specify/, with no silent fallback to cwd or the
34+
# script-location default (which would silently write to the wrong project).
35+
#
36+
# This is the single resolver: bundled extensions inherit it by sourcing core
37+
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
38+
resolve_specify_init_dir() {
39+
local init_root
40+
# Normalize: relative paths resolve against $(pwd); a trailing slash collapses.
41+
# CDPATH="" so a relative value cannot be resolved against the caller's CDPATH
42+
# (which would also echo to stdout and corrupt the captured path).
43+
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
44+
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
45+
return 1
46+
fi
47+
if [[ ! -d "$init_root/.specify" ]]; then
48+
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
49+
return 1
50+
fi
51+
printf '%s\n' "$init_root"
52+
}
53+
2754
# Get repository root, prioritizing .specify directory
2855
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
2956
get_repo_root() {
57+
# Explicit project override wins (see resolve_specify_init_dir).
58+
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
59+
resolve_specify_init_dir
60+
return
61+
fi
62+
3063
# First, look for .specify directory (spec-kit's own marker)
3164
local specify_root
3265
if specify_root=$(find_specify_root); then
@@ -119,8 +152,12 @@ _persist_feature_json() {
119152
}
120153

121154
get_feature_paths() {
122-
local repo_root=$(get_repo_root)
123-
local current_branch=$(get_current_branch)
155+
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
156+
# get_repo_root propagates as a hard error instead of being masked by `local`.
157+
local repo_root
158+
repo_root=$(get_repo_root) || return 1
159+
local current_branch
160+
current_branch=$(get_current_branch)
124161

125162
# Resolve feature directory. Priority:
126163
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)

β€Žscripts/bash/create-new-feature.shβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ clean_branch_name() {
123123
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
124124
source "$SCRIPT_DIR/common.sh"
125125

126-
REPO_ROOT=$(get_repo_root)
126+
REPO_ROOT=$(get_repo_root) || exit 1
127127

128128
cd "$REPO_ROOT"
129129

β€Žscripts/powershell/common.ps1β€Ž

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,47 @@ function Find-SpecifyRoot {
2424
}
2525
}
2626

27+
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
28+
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
29+
# command against a member project from a monorepo root without cd.
30+
#
31+
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
32+
# or writes an error and exits 1. Strict by design: the path must exist and
33+
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
34+
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
35+
#
36+
# This is the single resolver: bundled extensions inherit it by sourcing core
37+
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
38+
function Resolve-SpecifyInitDir {
39+
$initDir = $env:SPECIFY_INIT_DIR
40+
# Normalize: relative paths resolve against the current directory.
41+
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
42+
$initDir = Join-Path (Get-Location).Path $initDir
43+
}
44+
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
45+
# Resolve-Path also succeeds for files, so check the resolved path is a
46+
# directory; otherwise a file value would slip through to the less accurate
47+
# "not a Spec Kit project" error below.
48+
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
49+
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
50+
exit 1
51+
}
52+
$initRoot = $resolved.Path
53+
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
54+
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
55+
exit 1
56+
}
57+
return $initRoot
58+
}
59+
2760
# Get repository root, prioritizing .specify directory
2861
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
2962
function Get-RepoRoot {
63+
# Explicit project override wins (see Resolve-SpecifyInitDir).
64+
if ($env:SPECIFY_INIT_DIR) {
65+
return (Resolve-SpecifyInitDir)
66+
}
67+
3068
# First, look for .specify directory (spec-kit's own marker)
3169
$specifyRoot = Find-SpecifyRoot
3270
if ($specifyRoot) {

β€Žtests/extensions/git/test_git_extension.pyβ€Ž

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,20 @@ def test_dry_run(self, tmp_path: Path):
337337
assert data.get("DRY_RUN") is True
338338
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
339339

340+
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
341+
"""With no core scripts (only git-common.sh loaded), a set SPECIFY_INIT_DIR
342+
hard-errors instead of silently falling back to the walk-up project root."""
343+
project = _setup_project(tmp_path, git=False)
344+
# Simulate a no-core install: drop core common.sh so only git-common.sh loads.
345+
(project / "scripts" / "bash" / "common.sh").unlink()
346+
result = _run_bash(
347+
"create-new-feature-branch.sh", project,
348+
"--json", "--short-name", "x", "X feature",
349+
env_extra={"SPECIFY_INIT_DIR": str(project)},
350+
)
351+
assert result.returncode != 0
352+
assert "requires the Spec Kit core scripts" in result.stderr
353+
340354

341355
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
342356
class TestCreateFeaturePowerShell:
@@ -377,6 +391,23 @@ def test_no_git_graceful_degradation(self, tmp_path: Path):
377391
assert "BRANCH_NAME" in data
378392
assert "FEATURE_NUM" in data
379393

394+
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
395+
"""With no core scripts (only git-common.ps1 loaded), a set SPECIFY_INIT_DIR
396+
hard-errors instead of silently falling back to the walk-up project root."""
397+
project = _setup_project(tmp_path, git=False)
398+
(project / "scripts" / "powershell" / "common.ps1").unlink()
399+
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
400+
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(project)}
401+
result = subprocess.run(
402+
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
403+
cwd=project,
404+
capture_output=True,
405+
text=True,
406+
env=env,
407+
)
408+
assert result.returncode != 0
409+
assert "requires the Spec Kit core scripts" in result.stderr
410+
380411

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

0 commit comments

Comments
Β (0)