Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a17a658

Browse files
authored
feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)
* feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root Resolve an explicit SPECIFY_INIT_DIR project override once in the core get_repo_root / Get-RepoRoot, so a non-interactive / CI caller can target a member project (the directory containing .specify/) from a monorepo root without cd. Strict by design: the path must exist and contain .specify/, otherwise it hard-errors with no silent fallback. - Single resolver in core; the git feature-branch script inherits it by sourcing core, with no per-extension copies. - PS resolver verifies the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - get_feature_paths splits decl/assignment so a SPECIFY_INIT_DIR failure propagates instead of being masked by `local`. - create-new-feature-branch: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error rather than silently using the git root. - Document SPECIFY_INIT_DIR and SPECIFY_FEATURE_DIRECTORY in the core reference. - Tests for valid/relative/trailing-slash/file/missing/no-.specify targets, feature-axis composition, the no-core guard, and a PowerShell mirror. * fix: guard SPECIFY_INIT_DIR with stale core scripts * docs: clarify SPECIFY_FEATURE_DIRECTORY precedence wording * fix: normalize trailing slash in PowerShell SPECIFY_INIT_DIR resolver Resolve-Path preserves a trailing separator from its input, so a SPECIFY_INIT_DIR ending in a slash returned a root that didn't match the bash resolver (whose `cd && pwd` strips it). That broke test_ps_trailing_slash_tolerated on the CI runners, which do have pwsh. Trim it with TrimEndingDirectorySeparator (no-op on a bare root or a path with no trailing separator). Also fix the misleading test comment: the PowerShell mirror runs on the CI ubuntu/windows runners (they ship pwsh), it is not skipped there. * test: normalize bash path expectations on Windows * docs: clarify SPECIFY_INIT_DIR root helpers
1 parent 46ade96 commit a17a658

9 files changed

Lines changed: 644 additions & 7 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 2 additions & 1 deletion
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.11.2] - 2026-06-18
68

79
### Changed
@@ -1823,4 +1825,3 @@
18231825
### Changed
18241826

18251827
- Update release.yml
1826-

β€Ž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 root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), 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 (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. |
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: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,19 @@ 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 resolver. If only the
239+
# minimal git-common.sh was loaded, or an older core common.sh without the
240+
# resolver was loaded, refuse rather than silently falling back to the wrong root.
241+
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
242+
echo "Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.sh with resolve_specify_init_dir), which were not found." >&2
243+
exit 1
244+
fi
245+
246+
# Resolve repository root. When the core scripts are present, get_repo_root
247+
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
248+
# CI use) and hard-fails on an invalid value with no silent fallback.
239249
if type get_repo_root >/dev/null 2>&1; then
240-
REPO_ROOT=$(get_repo_root)
250+
REPO_ROOT=$(get_repo_root) || exit 1
241251
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
242252
REPO_ROOT=$(git rev-parse --show-toplevel)
243253
elif [ -n "$_PROJECT_ROOT" ]; then

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,16 @@ 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 the core resolver. If only the
201+
# minimal git-common.ps1 was loaded, or an older core common.ps1 without the
202+
# resolver was loaded, refuse rather than silently falling back to the wrong root.
203+
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) {
204+
throw "SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found."
205+
}
206+
207+
# Resolve repository root. When the core scripts are present, Get-RepoRoot
208+
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
209+
# CI use) and hard-fails on an invalid value with no silent fallback.
201210
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
202211
$repoRoot = Get-RepoRoot
203212
} 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,51 @@ 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+
# Resolve-Path echoes back any trailing separator from the input; trim it so
53+
# the returned root matches the bash resolver, whose `cd && pwd` never yields
54+
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
55+
# that already has no trailing separator.
56+
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
57+
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
58+
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
59+
exit 1
60+
}
61+
return $initRoot
62+
}
63+
2764
# Get repository root, prioritizing .specify directory
2865
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
2966
function Get-RepoRoot {
67+
# Explicit project override wins (see Resolve-SpecifyInitDir).
68+
if ($env:SPECIFY_INIT_DIR) {
69+
return (Resolve-SpecifyInitDir)
70+
}
71+
3072
# First, look for .specify directory (spec-kit's own marker)
3173
$specifyRoot = Find-SpecifyRoot
3274
if ($specifyRoot) {

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,36 @@ def test_dry_run(self, tmp_path: Path):
382382
assert data.get("DRY_RUN") is True
383383
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
384384

385+
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
386+
"""With no core scripts (only git-common.sh loaded), a set SPECIFY_INIT_DIR
387+
hard-errors instead of silently falling back to the walk-up project root."""
388+
project = _setup_project(tmp_path, git=False)
389+
# Simulate a no-core install: drop core common.sh so only git-common.sh loads.
390+
(project / "scripts" / "bash" / "common.sh").unlink()
391+
result = _run_bash(
392+
"create-new-feature-branch.sh", project,
393+
"--json", "--short-name", "x", "X feature",
394+
env_extra={"SPECIFY_INIT_DIR": str(project)},
395+
)
396+
assert result.returncode != 0
397+
assert "requires updated Spec Kit core scripts" in result.stderr
398+
399+
def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
400+
"""With an older core common.sh, a set SPECIFY_INIT_DIR must hard-error
401+
instead of calling the stale get_repo_root that ignores the override."""
402+
project = _setup_project(tmp_path, git=False)
403+
(project / "scripts" / "bash" / "common.sh").write_text(
404+
"#!/usr/bin/env bash\nget_repo_root() { pwd; }\n",
405+
encoding="utf-8",
406+
)
407+
result = _run_bash(
408+
"create-new-feature-branch.sh", project,
409+
"--json", "--short-name", "x", "X feature",
410+
env_extra={"SPECIFY_INIT_DIR": str(tmp_path / "missing")},
411+
)
412+
assert result.returncode != 0
413+
assert "requires updated Spec Kit core scripts" in result.stderr
414+
385415

386416
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
387417
class TestCreateFeaturePowerShell:
@@ -437,6 +467,43 @@ def test_no_git_graceful_degradation(self, tmp_path: Path):
437467
assert "BRANCH_NAME" in data
438468
assert "FEATURE_NUM" in data
439469

470+
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
471+
"""With no core scripts (only git-common.ps1 loaded), a set SPECIFY_INIT_DIR
472+
hard-errors instead of silently falling back to the walk-up project root."""
473+
project = _setup_project(tmp_path, git=False)
474+
(project / "scripts" / "powershell" / "common.ps1").unlink()
475+
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
476+
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(project)}
477+
result = subprocess.run(
478+
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
479+
cwd=project,
480+
capture_output=True,
481+
text=True,
482+
env=env,
483+
)
484+
assert result.returncode != 0
485+
assert "requires updated Spec Kit core scripts" in result.stderr
486+
487+
def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path):
488+
"""With an older core common.ps1, a set SPECIFY_INIT_DIR must hard-error
489+
instead of calling the stale Get-RepoRoot that ignores the override."""
490+
project = _setup_project(tmp_path, git=False)
491+
(project / "scripts" / "powershell" / "common.ps1").write_text(
492+
"function Get-RepoRoot { return (Get-Location).Path }\n",
493+
encoding="utf-8",
494+
)
495+
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
496+
env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(tmp_path / "missing")}
497+
result = subprocess.run(
498+
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"],
499+
cwd=project,
500+
capture_output=True,
501+
text=True,
502+
env=env,
503+
)
504+
assert result.returncode != 0
505+
assert "requires updated Spec Kit core scripts" in result.stderr
506+
440507

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

0 commit comments

Comments
Β (0)