-
Notifications
You must be signed in to change notification settings - Fork 8k
feat: Add SPECIFY_SPECS_DIR for centralized specs directory and worktree support #1579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
4f6b64b
f1292f5
fdf0510
8d99e69
71aa427
b2e9c95
296517a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,23 @@ | ||
| #!/usr/bin/env bash | ||
| # Common functions and variables for all scripts | ||
|
|
||
| # Escape a string for safe inclusion in JSON (handles all JSON-required escapes) | ||
| json_escape() { | ||
| local str="$1" | ||
| # Escape backslashes first, then quotes, then control characters | ||
| str="${str//\\/\\\\}" | ||
| str="${str//\"/\\\"}" | ||
| str="${str//$'\n'/\\n}" | ||
| str="${str//$'\r'/\\r}" | ||
| str="${str//$'\t'/\\t}" | ||
| str="${str//$'\b'/\\b}" | ||
| str="${str//$'\f'/\\f}" | ||
| # Remove any remaining control characters (U+0000-U+001F) that are | ||
| # not covered above, since they are invalid unescaped in JSON strings. | ||
| str="$(printf '%s' "$str" | tr -d '\000-\006\016-\037')" | ||
| printf '%s' "$str" | ||
| } | ||
|
|
||
| # Get repository root, with fallback for non-git repositories | ||
| get_repo_root() { | ||
| if git rev-parse --show-toplevel >/dev/null 2>&1; then | ||
|
|
@@ -12,6 +29,24 @@ get_repo_root() { | |
| fi | ||
| } | ||
|
|
||
| # Get specs directory, with support for external location via SPECIFY_SPECS_DIR | ||
| get_specs_dir() { | ||
| local repo_root="${1:-$(get_repo_root)}" | ||
| local specs_dir | ||
|
|
||
| if [[ -n "${SPECIFY_SPECS_DIR:-}" ]]; then | ||
| specs_dir="$SPECIFY_SPECS_DIR" | ||
| # Resolve relative paths against repo root | ||
| if [[ "$specs_dir" != /* ]]; then | ||
| specs_dir="$repo_root/$specs_dir" | ||
| fi | ||
| else | ||
| specs_dir="$repo_root/specs" | ||
| fi | ||
|
|
||
| echo "$specs_dir" | ||
| } | ||
|
Comment on lines
+33
to
+48
|
||
|
|
||
| # Get current branch, with fallback for non-git repositories | ||
| get_current_branch() { | ||
| # First check if SPECIFY_FEATURE environment variable is set | ||
|
|
@@ -28,7 +63,7 @@ get_current_branch() { | |
|
|
||
| # For non-git repos, try to find the latest feature directory | ||
| local repo_root=$(get_repo_root) | ||
| local specs_dir="$repo_root/specs" | ||
| local specs_dir="$(get_specs_dir "$repo_root")" | ||
|
|
||
| if [[ -d "$specs_dir" ]]; then | ||
| local latest_feature="" | ||
|
|
@@ -66,6 +101,12 @@ check_feature_branch() { | |
| local branch="$1" | ||
| local has_git_repo="$2" | ||
|
|
||
| # When SPECIFY_SPECS_DIR is set (e.g., worktree mode), skip branch naming | ||
| # validation since the branch/worktree may not follow the NNN- convention. | ||
| if [[ -n "${SPECIFY_SPECS_DIR:-}" ]]; then | ||
| return 0 | ||
| fi | ||
|
|
||
| # For non-git repos, we can't enforce branch naming but still provide output | ||
| if [[ "$has_git_repo" != "true" ]]; then | ||
| echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 | ||
|
|
@@ -81,14 +122,14 @@ check_feature_branch() { | |
| return 0 | ||
| } | ||
|
|
||
| get_feature_dir() { echo "$1/specs/$2"; } | ||
| get_feature_dir() { echo "$(get_specs_dir "$1")/$2"; } | ||
|
|
||
| # Find feature directory by numeric prefix instead of exact branch match | ||
| # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) | ||
| find_feature_dir_by_prefix() { | ||
| local repo_root="$1" | ||
| local branch_name="$2" | ||
| local specs_dir="$repo_root/specs" | ||
| local specs_dir="$(get_specs_dir "$repo_root")" | ||
|
|
||
| # Extract numeric prefix from branch (e.g., "004" from "004-whatever") | ||
| if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,21 @@ function Get-RepoRoot { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Get specs directory, with support for external location via SPECIFY_SPECS_DIR | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function Get-SpecsDir { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| param([string]$RepoRoot = (Get-RepoRoot)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($env:SPECIFY_SPECS_DIR) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $specsDir = $env:SPECIFY_SPECS_DIR | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Resolve relative paths against repo root | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (-not [System.IO.Path]::IsPathRooted($specsDir)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $specsDir = Join-Path $RepoRoot $specsDir | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return $specsDir | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Join-Path $RepoRoot "specs" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
alanmeadows marked this conversation as resolved.
Comment on lines
+22
to
+30
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($env:SPECIFY_SPECS_DIR) { | |
| $specsDir = $env:SPECIFY_SPECS_DIR | |
| # Resolve relative paths against repo root | |
| if (-not [System.IO.Path]::IsPathRooted($specsDir)) { | |
| $specsDir = Join-Path $RepoRoot $specsDir | |
| } | |
| return $specsDir | |
| } | |
| return Join-Path $RepoRoot "specs" | |
| $specsDir = $null | |
| if ($env:SPECIFY_SPECS_DIR) { | |
| $specsDir = $env:SPECIFY_SPECS_DIR | |
| # Resolve relative paths against repo root | |
| if (-not [System.IO.Path]::IsPathRooted($specsDir)) { | |
| $specsDir = Join-Path $RepoRoot $specsDir | |
| } | |
| } else { | |
| $specsDir = Join-Path $RepoRoot "specs" | |
| } | |
| # Validate that, if the path exists, it is a directory | |
| if (Test-Path $specsDir) { | |
| if (-not (Test-Path $specsDir -PathType Container)) { | |
| Write-Error "Invalid specs directory path '$specsDir': path exists but is not a directory." | |
| return $null | |
| } | |
| } | |
| return $specsDir |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -149,9 +149,28 @@ try { | |||||||||||||||||
|
|
||||||||||||||||||
| Set-Location $repoRoot | ||||||||||||||||||
|
|
||||||||||||||||||
| $specsDir = Join-Path $repoRoot 'specs' | ||||||||||||||||||
| # When SPECIFY_SPECS_DIR is set, we assume an external specs directory (e.g., a | ||||||||||||||||||
| # git worktree dedicated to this feature). Skip branch creation and git fetch | ||||||||||||||||||
| # since the caller controls the branch lifecycle. | ||||||||||||||||||
| $worktreeMode = [bool]$env:SPECIFY_SPECS_DIR | ||||||||||||||||||
|
|
||||||||||||||||||
| $specsDir = Get-SpecsDir -RepoRoot $repoRoot | ||||||||||||||||||
| if (-not $specsDir) { | ||||||||||||||||||
| Write-Host "`n[specify] ERROR: Invalid SPECIFY_SPECS_DIR configuration. Aborting." -ForegroundColor Red | ||||||||||||||||||
| exit 1 | ||||||||||||||||||
| } | ||||||||||||||||||
|
alanmeadows marked this conversation as resolved.
Comment on lines
+161
to
+164
|
||||||||||||||||||
| if (-not $specsDir) { | |
| Write-Host "`n[specify] ERROR: Invalid SPECIFY_SPECS_DIR configuration. Aborting." -ForegroundColor Red | |
| exit 1 | |
| } |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The if (-not $specsDir) guard is currently unreachable because Get-SpecsDir (in common.ps1) always returns a non-empty string (either SPECIFY_SPECS_DIR resolved or the default Join-Path $RepoRoot 'specs'). Either remove this check, or add real validation to Get-SpecsDir (e.g., return $null when the path is invalid/uncreatable) so the error message can actually trigger.
| if (-not $specsDir) { | |
| Write-Host "`n[specify] ERROR: Invalid SPECIFY_SPECS_DIR configuration. Aborting." -ForegroundColor Red | |
| exit 1 | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # Shared Context | ||
|
|
||
| This directory holds project-wide standards and conventions that apply across | ||
| all feature specs. Every `.md` file placed here is automatically loaded as | ||
| read-only context by the following Spec Kit commands: | ||
|
|
||
| - **specify** -- informs spec structure and alignment with project standards | ||
| - **clarify** -- validates spec alignment and informs ambiguity detection | ||
| - **plan** -- guides technical decisions in implementation plans | ||
| - **tasks** -- informs task structure and sequencing | ||
| - **implement** -- guides implementation decisions and coding patterns | ||
| - **checklist** -- incorporates project-wide requirements into validation | ||
| - **analyze** -- uses standards as additional validation criteria | ||
|
|
||
| ## What to put here | ||
|
|
||
| Create `.md` files for any project-wide standards you want consistently applied: | ||
|
|
||
| - **Architecture decisions** -- system boundaries, data flow, service topology | ||
| - **API conventions** -- endpoint naming, versioning, request/response patterns | ||
| - **Coding standards** -- naming, formatting, error handling, logging patterns | ||
| - **Security requirements** -- authentication, authorization, input validation | ||
| - **Accessibility standards** -- WCAG compliance, ARIA patterns | ||
| - **Testing conventions** -- coverage expectations, mocking strategies, test naming | ||
|
|
||
| ## Example | ||
|
|
||
| ``` | ||
| specs/_shared/ | ||
| api-conventions.md | ||
| coding-standards.md | ||
| security-requirements.md | ||
| ``` | ||
|
Comment on lines
+28
to
+33
|
||
|
|
||
| ## Notes | ||
|
|
||
| - Files are **read-only** -- Spec Kit commands never modify shared context. | ||
| - Only `.md` files are loaded; other file types are ignored. | ||
| - This directory is optional. If absent, commands proceed without shared context. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SPECIFY_SPECS_DIRis user-controlled and can now influence values emitted byget_feature_paths(which are later consumed viaeval $(get_feature_paths)in multiple scripts). Becauseget_feature_pathswraps values in single quotes, aSPECIFY_SPECS_DIRcontaining a'can break out of quoting and lead to command injection. Consider switching away fromeval-based exports, or ensure values are safely shell-escaped (e.g., escape single quotes) before being embedded in theget_feature_pathsoutput.