Skip to content

Commit e933b4a

Browse files
committed
refactor(scripts): centralize SPECIFY_INIT_DIR resolution in core
Resolve SPECIFY_INIT_DIR once in core get_repo_root / Get-RepoRoot and drop the duplicated resolvers from the git and agent-context extensions, per PR review. The git feature-branch script inherits the resolver by sourcing core; auto-commit and agent-context revert to their prior root resolution. - PS resolver: verify the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - create-new-feature: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error instead of silently falling back to the git root, keeping the no-silent-fallback contract. - Drop the legacy-core fallback path and its tests; fix a non-ASCII char in common.ps1 for PowerShell 5.1.
1 parent e28b8db commit e933b4a

13 files changed

Lines changed: 77 additions & 334 deletions

File tree

‎docs/reference/core.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ specify init my-project --integration copilot --branch-numbering timestamp
5959

6060
| Variable | Description |
6161
| ----------------- | ------------------------------------------------------------------------ |
62-
| `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. |
62+
| `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. 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. |
6363
| `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. |
6464
| `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. |
6565

66-
> **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.
66+
> **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.
6767
6868
## Check Installed Tools
6969

‎extensions/agent-context/scripts/bash/update-agent-context.sh‎

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,7 @@
1616

1717
set -euo pipefail
1818

19-
resolve_project_root() {
20-
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
21-
local init_root
22-
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
23-
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
24-
exit 1
25-
fi
26-
if [[ ! -d "$init_root/.specify" ]]; then
27-
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
28-
exit 1
29-
fi
30-
printf '%s\n' "$init_root"
31-
return
32-
fi
33-
34-
pwd
35-
}
36-
37-
PROJECT_ROOT="$(resolve_project_root)"
19+
PROJECT_ROOT="$(pwd)"
3820
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
3921
DEFAULT_START="<!-- SPECKIT START -->"
4022
DEFAULT_END="<!-- SPECKIT END -->"

‎extensions/agent-context/scripts/powershell/update-agent-context.ps1‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -52,32 +52,10 @@ function Test-ConfigObject {
5252
return $false
5353
}
5454

55-
function Resolve-ProjectRoot {
56-
if ($env:SPECIFY_INIT_DIR) {
57-
$initDir = $env:SPECIFY_INIT_DIR
58-
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
59-
$initDir = Join-Path (Get-Location).Path $initDir
60-
}
61-
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
62-
if (-not $resolved) {
63-
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
64-
exit 1
65-
}
66-
$initRoot = $resolved.Path
67-
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
68-
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
69-
exit 1
70-
}
71-
return $initRoot
72-
}
73-
74-
return (Get-Location).Path
75-
}
76-
7755
$ErrorActionPreference = 'Stop'
7856
$DefaultStart = '<!-- SPECKIT START -->'
7957
$DefaultEnd = '<!-- SPECKIT END -->'
80-
$ProjectRoot = Resolve-ProjectRoot
58+
$ProjectRoot = (Get-Location).Path
8159
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
8260

8361
if (-not (Test-Path -LiteralPath $ExtConfig)) {

‎extensions/git/scripts/bash/auto-commit.sh‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,7 @@ _find_project_root() {
2828
return 1
2929
}
3030

31-
# SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins,
32-
# with the strict contract from resolve_specify_init_dir (git-common.sh): it must
33-
# exist and contain .specify/, else hard error with no silent fallback. Otherwise
34-
# keep the previous script-relative / cwd resolution unchanged.
35-
if [ -n "${SPECIFY_INIT_DIR:-}" ]; then
36-
source "$SCRIPT_DIR/git-common.sh"
37-
REPO_ROOT=$(resolve_specify_init_dir) || exit 1
38-
else
39-
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
40-
fi
31+
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
4132
cd "$REPO_ROOT"
4233

4334
# Check if git is available

‎extensions/git/scripts/bash/create-new-feature.sh‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -234,26 +234,20 @@ if [ "$_common_loaded" != "true" ]; then
234234
exit 1
235235
fi
236236

237-
# A project may have an older core common.sh while the git extension has been
238-
# updated independently. In that case, fall back to the extension-local helper
239-
# for the SPECIFY_INIT_DIR contract instead of failing with "command not found".
240-
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
241-
if [ -f "$SCRIPT_DIR/git-common.sh" ]; then
242-
source "$SCRIPT_DIR/git-common.sh"
243-
else
244-
echo "Error: SPECIFY_INIT_DIR requires resolve_specify_init_dir, but git-common.sh was not found." >&2
245-
exit 1
246-
fi
237+
# SPECIFY_INIT_DIR is resolved (and validated) by the core get_repo_root. If only
238+
# the minimal git-common.sh was loaded (core common.sh absent), refuse rather
239+
# than silently falling back to the git toplevel — honoring the no-silent-fallback
240+
# contract instead of creating the feature in the wrong project.
241+
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type get_repo_root >/dev/null 2>&1; then
242+
echo "Error: SPECIFY_INIT_DIR requires the Spec Kit core scripts (common.sh), which were not found." >&2
243+
exit 1
247244
fi
248245

249-
# Resolve repository root. SPECIFY_INIT_DIR (explicit project override for
250-
# non-interactive / CI use) wins, with the strict contract from
251-
# resolve_specify_init_dir: it must exist and contain .specify/, else hard error
252-
# with no silent fallback to cwd or the git toplevel.
253-
if [ -n "${SPECIFY_INIT_DIR:-}" ]; then
254-
REPO_ROOT=$(resolve_specify_init_dir) || exit 1
255-
elif type get_repo_root >/dev/null 2>&1; then
256-
REPO_ROOT=$(get_repo_root)
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.
249+
if type get_repo_root >/dev/null 2>&1; then
250+
REPO_ROOT=$(get_repo_root) || exit 1
257251
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
258252
REPO_ROOT=$(git rev-parse --show-toplevel)
259253
elif [ -n "$_PROJECT_ROOT" ]; then

‎extensions/git/scripts/bash/git-common.sh‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,6 @@
33
# Extracted from scripts/bash/common.sh — contains only git-specific
44
# branch validation and detection logic.
55

6-
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
7-
# *contains* .specify/). Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the
8-
# validated absolute project root, or prints an error and returns 1. Strict by
9-
# design: must exist and contain .specify/, with no silent fallback.
10-
#
11-
# Keep this contract aligned with scripts/bash/common.sh. The git extension can
12-
# run without the core scripts installed.
13-
resolve_specify_init_dir() {
14-
local init_root
15-
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
16-
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
17-
return 1
18-
fi
19-
if [[ ! -d "$init_root/.specify" ]]; then
20-
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
21-
return 1
22-
fi
23-
printf '%s\n' "$init_root"
24-
}
25-
266
# Check if we have git available at the repo root
277
has_git() {
288
local repo_root="${1:-$(pwd)}"

‎extensions/git/scripts/powershell/auto-commit.ps1‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,8 @@ function Find-ProjectRoot {
2626
}
2727
}
2828

29-
# SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins,
30-
# with the strict contract from Resolve-SpecifyInitDir (git-common.ps1): it must
31-
# exist and contain .specify/, else hard error with no silent fallback. Otherwise
32-
# keep the previous script-relative / cwd resolution unchanged.
33-
if ($env:SPECIFY_INIT_DIR) {
34-
. (Join-Path $PSScriptRoot 'git-common.ps1')
35-
$repoRoot = Resolve-SpecifyInitDir
36-
} else {
37-
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
38-
if (-not $repoRoot) { $repoRoot = Get-Location }
39-
}
29+
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
30+
if (-not $repoRoot) { $repoRoot = Get-Location }
4031
Set-Location $repoRoot
4132

4233
# Check if git is available

‎extensions/git/scripts/powershell/create-new-feature.ps1‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -196,25 +196,18 @@ if (-not $commonLoaded) {
196196
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
197197
}
198198

199-
# A project may have an older core common.ps1 while the git extension has been
200-
# updated independently. In that case, fall back to the extension-local helper
201-
# for the SPECIFY_INIT_DIR contract instead of failing with "command not found".
202-
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -ErrorAction SilentlyContinue)) {
203-
$gitCommon = Join-Path $PSScriptRoot 'git-common.ps1'
204-
if (Test-Path $gitCommon) {
205-
. $gitCommon
206-
} else {
207-
throw "SPECIFY_INIT_DIR requires Resolve-SpecifyInitDir, but git-common.ps1 was not found."
208-
}
199+
# SPECIFY_INIT_DIR is resolved (and validated) by core Get-RepoRoot. If only the
200+
# minimal git-common.ps1 was loaded (core common.ps1 absent), refuse rather than
201+
# silently falling back to the script-dir walk-up project -- honoring the
202+
# no-silent-fallback contract instead of targeting the wrong project.
203+
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue)) {
204+
throw "SPECIFY_INIT_DIR requires the Spec Kit core scripts (common.ps1), which were not found."
209205
}
210206

211-
# Resolve repository root. SPECIFY_INIT_DIR (explicit project override for
212-
# non-interactive / CI use) wins, with the strict contract from
213-
# Resolve-SpecifyInitDir: it must exist and contain .specify/, else hard error
214-
# with no silent fallback to cwd or the git toplevel.
215-
if ($env:SPECIFY_INIT_DIR) {
216-
$repoRoot = Resolve-SpecifyInitDir
217-
} elseif (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
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.
210+
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
218211
$repoRoot = Get-RepoRoot
219212
} elseif ($projectRoot) {
220213
$repoRoot = $projectRoot

‎extensions/git/scripts/powershell/git-common.ps1‎

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,6 @@
33
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
44
# branch validation and detection logic.
55

6-
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
7-
# *contains* .specify/). Precondition: $env:SPECIFY_INIT_DIR is set. Returns the
8-
# validated project root, or writes an error and exits 1. Strict by design: must
9-
# exist and contain .specify/, with no silent fallback.
10-
#
11-
# Keep this contract aligned with scripts/powershell/common.ps1. The git
12-
# extension can run without the core scripts installed.
13-
function Resolve-SpecifyInitDir {
14-
$initDir = $env:SPECIFY_INIT_DIR
15-
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
16-
$initDir = Join-Path (Get-Location).Path $initDir
17-
}
18-
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
19-
if (-not $resolved) {
20-
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
21-
exit 1
22-
}
23-
$initRoot = $resolved.Path
24-
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
25-
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
26-
exit 1
27-
}
28-
return $initRoot
29-
}
30-
316
function Test-HasGit {
327
param([string]$RepoRoot = (Get-Location))
338
try {

‎scripts/bash/common.sh‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ find_specify_root() {
3333
# must exist and contain .specify/, with no silent fallback to cwd or the git
3434
# toplevel (which would silently write to the wrong project's specs/).
3535
#
36-
# Keep this contract aligned with standalone extension helpers: git and
37-
# agent-context can run without the core scripts installed.
36+
# This is the single resolver: bundled extensions inherit it by sourcing core
37+
# (e.g. the git extension's create-new-feature) rather than duplicating it.
3838
resolve_specify_init_dir() {
3939
local init_root
4040
# Normalize: relative paths resolve against $(pwd); a trailing slash collapses.

0 commit comments

Comments
 (0)