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
11 changes: 8 additions & 3 deletions scripts/bash/check-prerequisites.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1

# If paths-only mode, output paths and exit (support JSON + paths-only combined)
if $PATHS_ONLY; then
SPECS_DIR="$(get_specs_dir "$REPO_ROOT")" || exit 1
if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed)
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s","SPECS_DIR":"%s"}\n' \
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" "$(json_escape "$SPECS_DIR")"
else
echo "REPO_ROOT: $REPO_ROOT"
echo "BRANCH: $CURRENT_BRANCH"
echo "FEATURE_DIR: $FEATURE_DIR"
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "TASKS: $TASKS"
echo "SPECS_DIR: $SPECS_DIR"
fi
exit 0
fi
Expand Down Expand Up @@ -148,7 +151,9 @@ if $JSON_MODE; then
json_docs="[${json_docs%,}]"
fi

printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs"
SPECS_DIR="$(get_specs_dir "$REPO_ROOT")" || exit 1
# Note: $json_docs is not escaped because it is already a pre-formatted JSON array
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"SPECS_DIR":"%s"}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$SPECS_DIR")"
else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
Expand Down
47 changes: 44 additions & 3 deletions scripts/bash/common.sh
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
Expand All @@ -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
Comment on lines +37 to +42
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SPECIFY_SPECS_DIR is user-controlled and can now influence values emitted by get_feature_paths (which are later consumed via eval $(get_feature_paths) in multiple scripts). Because get_feature_paths wraps values in single quotes, a SPECIFY_SPECS_DIR containing a ' can break out of quoting and lead to command injection. Consider switching away from eval-based exports, or ensure values are safely shell-escaped (e.g., escape single quotes) before being embedded in the get_feature_paths output.

Copilot uses AI. Check for mistakes.
else
specs_dir="$repo_root/specs"
fi

echo "$specs_dir"
}
Comment on lines +33 to +48
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_specs_dir is used as though it can fail (SPECS_DIR="$(get_specs_dir ...)" || exit 1), but the function always exits 0 and just echoes a path. Either remove the || exit 1 patterns, or add validation so get_specs_dir returns non-zero for invalid configurations (e.g., empty repo_root, path exists but is not a directory, or directory cannot be created when required).

Copilot uses AI. Check for mistakes.

# Get current branch, with fallback for non-git repositories
get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
Expand All @@ -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=""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 37 additions & 6 deletions scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ set -e
JSON_MODE=false
SHORT_NAME=""
BRANCH_NUMBER=""
NO_BRANCH=false
ARGS=()
i=1
while [ $i -le $# ]; do
Expand All @@ -13,6 +14,9 @@ while [ $i -le $# ]; do
--json)
JSON_MODE=true
;;
--no-branch)
NO_BRANCH=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
Expand Down Expand Up @@ -41,17 +45,19 @@ while [ $i -le $# ]; do
BRANCH_NUMBER="$next_arg"
;;
--help|-h)
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--no-branch] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --no-branch Skip branch creation/checkout and git fetch"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --no-branch 'Fix payment timeout' --short-name 'fix-payment'"
exit 0
;;
*)
Expand Down Expand Up @@ -160,6 +166,10 @@ clean_branch_name() {
# were initialised with --no-git.
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source shared functions (get_specs_dir, json_escape, etc.)
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

if git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
HAS_GIT=true
Expand All @@ -174,9 +184,19 @@ fi

cd "$REPO_ROOT"

SPECS_DIR="$REPO_ROOT/specs"
SPECS_DIR="$(get_specs_dir "$REPO_ROOT")" || exit 1
mkdir -p "$SPECS_DIR"

# Scaffold _shared directory with README if it doesn't exist yet
SHARED_DIR="$SPECS_DIR/_shared"
if [ ! -d "$SHARED_DIR" ]; then
mkdir -p "$SHARED_DIR"
SHARED_README="$REPO_ROOT/.specify/templates/_shared/README.md"
if [ -f "$SHARED_README" ]; then
cp "$SHARED_README" "$SHARED_DIR/README.md"
fi
fi

# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
Expand Down Expand Up @@ -234,13 +254,21 @@ else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi

# When SPECIFY_SPECS_DIR is set, automatically enable --no-branch since the
# caller controls the branch lifecycle (e.g., a git worktree dedicated to this
# feature).
if [ -n "${SPECIFY_SPECS_DIR:-}" ]; then
NO_BRANCH=true
fi

# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$HAS_GIT" = true ]; then
if [ "$HAS_GIT" = true ] && [ "$NO_BRANCH" = false ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
# Fall back to local directory check (also used in --no-branch mode to
# avoid running git fetch --all --prune against the parent repo)
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
Expand Down Expand Up @@ -271,7 +299,9 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi

if [ "$HAS_GIT" = true ]; then
if [ "$NO_BRANCH" = true ]; then
>&2 echo "[specify] --no-branch: skipping branch creation"
elif [ "$HAS_GIT" = true ]; then
git checkout -b "$BRANCH_NAME"
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
Expand All @@ -288,7 +318,8 @@ if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"
export SPECIFY_FEATURE="$BRANCH_NAME"

if $JSON_MODE; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM"
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","SPECS_DIR":"%s","NO_BRANCH":%s}\n' \
"$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" "$(json_escape "$SPECS_DIR")" "$NO_BRANCH"
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
Expand Down
9 changes: 8 additions & 1 deletion scripts/powershell/check-prerequisites.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI

# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
if ($PathsOnly) {
$specsDir = Get-SpecsDir -RepoRoot $paths.REPO_ROOT
if (-not $specsDir) { exit 1 }
if ($Json) {
[PSCustomObject]@{
REPO_ROOT = $paths.REPO_ROOT
Expand All @@ -73,6 +75,7 @@ if ($PathsOnly) {
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
TASKS = $paths.TASKS
SPECS_DIR = $specsDir
} | ConvertTo-Json -Compress
} else {
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
Expand All @@ -81,6 +84,7 @@ if ($PathsOnly) {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "TASKS: $($paths.TASKS)"
Write-Output "SPECS_DIR: $specsDir"
}
exit 0
}
Expand Down Expand Up @@ -127,9 +131,12 @@ if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
# Output results
if ($Json) {
# JSON output
$specsDir = Get-SpecsDir -RepoRoot $paths.REPO_ROOT
if (-not $specsDir) { exit 1 }
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
AVAILABLE_DOCS = $docs
SPECS_DIR = $specsDir
} | ConvertTo-Json -Compress
} else {
# Text output
Expand Down
25 changes: 23 additions & 2 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +22 to +30
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get-SpecsDir always returns a (possibly relative-resolved) string, so callers' if (-not $specsDir) { exit 1 } checks can never trigger. Either implement real validation here (and return $null for invalid values like an existing non-directory path), or remove the dead checks and adjust the error messaging accordingly.

Suggested change
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

Copilot uses AI. Check for mistakes.
}

function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set
if ($env:SPECIFY_FEATURE) {
Expand All @@ -33,7 +48,7 @@ function Get-CurrentBranch {

# For non-git repos, try to find the latest feature directory
$repoRoot = Get-RepoRoot
$specsDir = Join-Path $repoRoot "specs"
$specsDir = Get-SpecsDir -RepoRoot $repoRoot

if (Test-Path $specsDir) {
$latestFeature = ""
Expand Down Expand Up @@ -73,6 +88,12 @@ function Test-FeatureBranch {
[bool]$HasGit = $true
)

# 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 ($env:SPECIFY_SPECS_DIR) {
return $true
}

# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
Expand All @@ -89,7 +110,7 @@ function Test-FeatureBranch {

function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
Join-Path (Get-SpecsDir -RepoRoot $RepoRoot) $Branch
}

function Get-FeaturePathsEnv {
Expand Down
Loading
Loading