mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
Compare commits
12 Commits
add-orches
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73f77c200f | ||
|
|
b8d27e472f | ||
|
|
587b1859fa | ||
|
|
52480ee50f | ||
|
|
d3e7b06fa7 | ||
|
|
de6a04eaad | ||
|
|
5217206fdf | ||
|
|
c978faac57 | ||
|
|
b5f1194168 | ||
|
|
44c112c807 | ||
|
|
3b4e7f3cb6 | ||
|
|
f494a8e33e |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.5] - 2026-07-06
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): match gate reject option case-insensitively (#3335)
|
||||
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
|
||||
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
|
||||
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
|
||||
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
|
||||
- Support namespaced git feature branch templates (#3293)
|
||||
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
|
||||
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
|
||||
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
|
||||
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
|
||||
|
||||
## [0.12.4] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti
|
||||
This extension provides Git operations as an optional, self-contained module. It manages:
|
||||
|
||||
- **Repository initialization** with configurable commit messages
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
@@ -53,6 +53,16 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
||||
# Branch numbering strategy: "sequential" or "timestamp"
|
||||
branch_numbering: sequential
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
|
||||
# before {number}, and the final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
@@ -65,6 +75,10 @@ auto_commit:
|
||||
message: "[Spec Kit] Add specification"
|
||||
```
|
||||
|
||||
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
|
||||
|
||||
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -35,6 +35,19 @@ Determine the branch numbering strategy by checking configuration in this order:
|
||||
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
|
||||
4. Default to `sequential` if none of the above exist
|
||||
|
||||
## Branch Name Template
|
||||
|
||||
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
|
||||
|
||||
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
|
||||
- `{app}`: sanitized Spec Kit init directory name
|
||||
- `{number}`: sequential number or timestamp
|
||||
- `{slug}`: generated short branch slug
|
||||
|
||||
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
|
||||
|
||||
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
@@ -54,6 +67,7 @@ Run the appropriate script based on your platform:
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
@@ -64,5 +78,5 @@ If Git is not installed or the current directory is not a Git repository:
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
|
||||
@@ -22,24 +22,24 @@ Get the current branch name:
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
The branch name's final path segment must start with one of these feature markers:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`)
|
||||
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}
|
||||
# {slug} must not appear before {number}; final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ extension:
|
||||
id: git
|
||||
name: "Git Branching Workflow"
|
||||
version: "1.0.0"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
@@ -19,7 +19,7 @@ provides:
|
||||
commands:
|
||||
- name: speckit.git.feature
|
||||
file: commands/speckit.git.feature.md
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
description: "Create a feature branch with sequential or timestamp numbering and optional templates"
|
||||
- name: speckit.git.validate
|
||||
file: commands/speckit.git.validate.md
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
@@ -137,4 +137,6 @@ tags:
|
||||
config:
|
||||
defaults:
|
||||
branch_numbering: sequential
|
||||
branch_template: ""
|
||||
branch_prefix: ""
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}
|
||||
# {slug} must not appear before {number}; final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ while [ $i -le $# ]; do
|
||||
echo "Environment variables:"
|
||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
@@ -127,16 +131,28 @@ get_highest_from_specs() {
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
local scope_prefix="${1:-}"
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
_extract_highest_number() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
if [ -n "$scope_prefix" ]; then
|
||||
case "$name" in
|
||||
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
name="${name##*/}"
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
@@ -148,11 +164,12 @@ _extract_highest_number() {
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
@@ -165,16 +182,17 @@ get_highest_from_remote_refs() {
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
local scope_prefix="${3:-}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
fi
|
||||
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
@@ -273,6 +291,152 @@ fi
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
|
||||
read_git_config_value() {
|
||||
local key="$1"
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
|
||||
| head -n 1 \
|
||||
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
|
||||
| sed -E 's/[[:space:]]+#.*$//' \
|
||||
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
|
||||
| sed -E 's/^"//; s/"$//' \
|
||||
| sed -E "s/^'//; s/'$//"
|
||||
}
|
||||
|
||||
branch_token() {
|
||||
local value="$1"
|
||||
local fallback="$2"
|
||||
local cleaned
|
||||
cleaned=$(clean_branch_name "$value")
|
||||
if [ -n "$cleaned" ]; then
|
||||
printf '%s\n' "$cleaned"
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
get_author_token() {
|
||||
local author=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
author=$(git config user.name 2>/dev/null || true)
|
||||
if [ -z "$author" ]; then
|
||||
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
|
||||
fi
|
||||
fi
|
||||
if [ -z "$author" ]; then
|
||||
author="${USER:-unknown}"
|
||||
fi
|
||||
branch_token "$author" "unknown"
|
||||
}
|
||||
|
||||
get_app_token() {
|
||||
branch_token "$(basename "$REPO_ROOT")" "app"
|
||||
}
|
||||
|
||||
resolve_branch_template() {
|
||||
local template
|
||||
local prefix
|
||||
template=$(read_git_config_value "branch_template")
|
||||
if [ -n "$template" ]; then
|
||||
printf '%s\n' "$template"
|
||||
return
|
||||
fi
|
||||
|
||||
prefix=$(read_git_config_value "branch_prefix")
|
||||
if [ -z "$prefix" ]; then
|
||||
printf '%s\n' ""
|
||||
return
|
||||
fi
|
||||
case "$prefix" in
|
||||
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_branch_template() {
|
||||
local template="$1"
|
||||
local feature_num="$2"
|
||||
local branch_suffix="$3"
|
||||
local rendered="$template"
|
||||
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
|
||||
rendered=${rendered//\{app\}/$APP_TOKEN}
|
||||
rendered=${rendered//\{number\}/$feature_num}
|
||||
rendered=${rendered//\{slug\}/$branch_suffix}
|
||||
printf '%s\n' "$rendered"
|
||||
}
|
||||
|
||||
validate_branch_template() {
|
||||
local template="$1"
|
||||
[ -n "$template" ] || return 0
|
||||
local feature_segment
|
||||
feature_segment="${template##*/}"
|
||||
case "$template" in
|
||||
*"{number}"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$template" in
|
||||
*"{slug}"*"{number}"*)
|
||||
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$feature_segment" in
|
||||
"{number}-"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
if [ -n "$BRANCH_TEMPLATE" ]; then
|
||||
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
|
||||
else
|
||||
printf '%s-%s\n' "$feature_num" "$branch_suffix"
|
||||
fi
|
||||
}
|
||||
|
||||
branch_scope_prefix() {
|
||||
local template="$1"
|
||||
local prefix="$template"
|
||||
[ -n "$prefix" ] || return 0
|
||||
case "$prefix" in
|
||||
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
|
||||
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
|
||||
}
|
||||
|
||||
extract_feature_num_from_branch() {
|
||||
local branch_name="$1"
|
||||
local feature_segment="${branch_name##*/}"
|
||||
local match
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$branch_name"
|
||||
}
|
||||
|
||||
AUTHOR_TOKEN=$(get_author_token)
|
||||
APP_TOKEN=$(get_app_token)
|
||||
BRANCH_TEMPLATE=$(resolve_branch_template)
|
||||
validate_branch_template "$BRANCH_TEMPLATE"
|
||||
|
||||
# Function to generate branch name with stop word filtering
|
||||
generate_branch_name() {
|
||||
@@ -318,18 +482,8 @@ generate_branch_name() {
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
else
|
||||
FEATURE_NUM="$BRANCH_NAME"
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
fi
|
||||
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
else
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
@@ -347,16 +501,17 @@ else
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
else
|
||||
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
@@ -364,7 +519,7 @@ else
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -376,18 +531,23 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
|
||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||
exit 1
|
||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
|
||||
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
|
||||
done
|
||||
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
|
||||
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
|
||||
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
|
||||
@@ -23,8 +23,9 @@ spec_kit_effective_branch_name() {
|
||||
}
|
||||
|
||||
# Validate that a branch name matches the expected feature branch pattern.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
|
||||
# either at the start of the branch or after path-style namespace prefixes.
|
||||
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
@@ -37,16 +38,17 @@ check_feature_branch() {
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
local feature_segment="${branch##*/}"
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ if ($Help) {
|
||||
Write-Host "Environment variables:"
|
||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
Write-Host ""
|
||||
Write-Host "Configuration:"
|
||||
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -67,11 +71,23 @@ function Get-HighestNumberFromSpecs {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
|
||||
continue
|
||||
}
|
||||
if ($ScopePrefix) {
|
||||
$name = $name.Substring($ScopePrefix.Length)
|
||||
}
|
||||
$name = ($name -split '/')[-1]
|
||||
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
|
||||
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
@@ -82,7 +98,7 @@ function Get-HighestNumberFromNames {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
@@ -90,7 +106,7 @@ function Get-HighestNumberFromBranches {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
@@ -99,6 +115,8 @@ function Get-HighestNumberFromBranches {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
@@ -111,7 +129,7 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
@@ -125,18 +143,19 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
[switch]$SkipFetch,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
}
|
||||
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
@@ -232,6 +251,145 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
|
||||
function Read-GitConfigValue {
|
||||
param([string]$Key)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
|
||||
$escapedKey = [regex]::Escape($Key)
|
||||
foreach ($line in Get-Content -LiteralPath $configFile) {
|
||||
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
|
||||
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
|
||||
$val = $val -replace '^["'']', '' -replace '["'']$', ''
|
||||
return $val
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function ConvertTo-BranchToken {
|
||||
param(
|
||||
[string]$Value,
|
||||
[string]$Fallback
|
||||
)
|
||||
|
||||
$cleaned = ConvertTo-CleanBranchName -Name $Value
|
||||
if ($cleaned) { return $cleaned }
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
function Get-GitAuthorToken {
|
||||
$author = ''
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
|
||||
if (-not $author) {
|
||||
try {
|
||||
$email = (git config user.email 2>$null | Out-String).Trim()
|
||||
if ($email) { $author = ($email -split '@')[0] }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
|
||||
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
|
||||
}
|
||||
|
||||
function Get-AppToken {
|
||||
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
|
||||
}
|
||||
|
||||
function Resolve-BranchTemplate {
|
||||
$template = Read-GitConfigValue -Key 'branch_template'
|
||||
if ($template) { return $template }
|
||||
|
||||
$prefix = Read-GitConfigValue -Key 'branch_prefix'
|
||||
if (-not $prefix) { return '' }
|
||||
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
|
||||
return "$prefix/{number}-{slug}"
|
||||
}
|
||||
|
||||
function Expand-BranchTemplate {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$rendered = $Template.Replace('{author}', $authorToken)
|
||||
$rendered = $rendered.Replace('{app}', $appToken)
|
||||
$rendered = $rendered.Replace('{number}', $FeatureNum)
|
||||
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
|
||||
return $rendered
|
||||
}
|
||||
|
||||
function Assert-BranchTemplateValid {
|
||||
param([string]$Template)
|
||||
|
||||
if ($Template -and -not $Template.Contains('{number}')) {
|
||||
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
}
|
||||
if ($Template) {
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
|
||||
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
}
|
||||
$featureSegment = ($Template -split '/')[-1]
|
||||
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
|
||||
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-BranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if ($branchTemplate) {
|
||||
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
|
||||
}
|
||||
return "$FeatureNum-$BranchSuffix"
|
||||
}
|
||||
|
||||
function Get-BranchScopePrefix {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if (-not $Template) { return '' }
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
|
||||
if (-not $indexes) { return '' }
|
||||
$prefix = $Template.Substring(0, $indexes[0])
|
||||
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
|
||||
}
|
||||
|
||||
function Get-FeatureNumberFromBranchName {
|
||||
param([string]$BranchName)
|
||||
|
||||
$featureSegment = ($BranchName -split '/')[-1]
|
||||
if ($featureSegment -match '^(\d{8}-\d{6})-') {
|
||||
return $matches[1]
|
||||
}
|
||||
if ($featureSegment -match '^(\d+)-') {
|
||||
return $matches[1]
|
||||
}
|
||||
return $BranchName
|
||||
}
|
||||
|
||||
function Get-Utf8ByteCount {
|
||||
param([string]$Value)
|
||||
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
|
||||
}
|
||||
|
||||
$authorToken = Get-GitAuthorToken
|
||||
$appToken = Get-AppToken
|
||||
$branchTemplate = Resolve-BranchTemplate
|
||||
Assert-BranchTemplateValid -Template $branchTemplate
|
||||
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
@@ -276,19 +434,11 @@ function Get-BranchName {
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
||||
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
|
||||
if ($branchNameUtf8ByteCount -gt 244) {
|
||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||
}
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$featureNum = $matches[1]
|
||||
} elseif ($branchName -match '^(\d+)-') {
|
||||
$featureNum = $matches[1]
|
||||
} else {
|
||||
$featureNum = $branchName
|
||||
}
|
||||
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
@@ -303,39 +453,41 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
$truncatedSuffix = $branchSuffix
|
||||
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
|
||||
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
|
||||
}
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
}
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
|
||||
@@ -37,14 +37,15 @@ function Test-FeatureBranch {
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
$featureSegment = ($Branch -split '/')[-1]
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
# Accept sequential prefix (3+ digits), at the start or after namespace
|
||||
# segments, but exclude malformed timestamps.
|
||||
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.5.dev0"
|
||||
version = "0.12.6.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
207
scripts/python/check_prerequisites.py
Normal file
207
scripts/python/check_prerequisites.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Consolidated prerequisite checking script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check_prerequisites.py --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check_prerequisites.py --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check_prerequisites.py --paths-only
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
require_tasks: bool = False
|
||||
include_tasks: bool = False
|
||||
paths_only: bool = False
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> Args:
|
||||
json_mode = False
|
||||
require_tasks = False
|
||||
include_tasks = False
|
||||
paths_only = False
|
||||
|
||||
for arg in argv:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--require-tasks":
|
||||
require_tasks = True
|
||||
elif arg == "--include-tasks":
|
||||
include_tasks = True
|
||||
elif arg == "--paths-only":
|
||||
paths_only = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(HELP_TEXT)
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
print(
|
||||
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
require_tasks=require_tasks,
|
||||
include_tasks=include_tasks,
|
||||
paths_only=paths_only,
|
||||
)
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
if include_tasks and paths.tasks.is_file():
|
||||
docs.append("tasks.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"REPO_ROOT": str(paths.repo_root),
|
||||
"BRANCH": paths.current_branch,
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"TASKS": str(paths.tasks),
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
print(f"REPO_ROOT: {paths.repo_root}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"TASKS: {paths.tasks}")
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
print(f"FEATURE_DIR:{paths.feature_dir}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
if include_tasks:
|
||||
_check_file(paths.tasks, "tasks.md")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(
|
||||
no_persist=args.paths_only,
|
||||
script_file=Path(__file__),
|
||||
)
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if args.paths_only:
|
||||
_print_paths_only(paths, args.json_mode)
|
||||
return 0
|
||||
|
||||
if not paths.feature_dir.is_dir():
|
||||
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.require_tasks and not paths.tasks.is_file():
|
||||
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths, args.include_tasks)
|
||||
if args.json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
|
||||
)
|
||||
else:
|
||||
_print_text_results(paths, args.include_tasks)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
210
scripts/python/common.py
Normal file
210
scripts/python/common.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Shared helpers for Spec Kit Python scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _trim_trailing_separators(value: Path) -> str:
|
||||
text = str(value)
|
||||
while len(text) > 1 and text.endswith((os.sep, "/")):
|
||||
text = text[:-1]
|
||||
return text
|
||||
|
||||
|
||||
def find_specify_root(start_dir: Path | None = None) -> Path | None:
|
||||
current = (start_dir or Path.cwd()).resolve()
|
||||
while True:
|
||||
if (current / ".specify").is_dir():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def resolve_specify_init_dir() -> Path:
|
||||
raw = os.environ.get("SPECIFY_INIT_DIR", "")
|
||||
candidate = Path(raw)
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
try:
|
||||
init_root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not init_root.is_dir():
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not (init_root / ".specify").is_dir():
|
||||
print(
|
||||
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
|
||||
f"(no .specify/ directory): {init_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return init_root
|
||||
|
||||
|
||||
def get_repo_root(script_file: Path | None = None) -> Path:
|
||||
if os.environ.get("SPECIFY_INIT_DIR"):
|
||||
return resolve_specify_init_dir()
|
||||
|
||||
specify_root = find_specify_root()
|
||||
if specify_root is not None:
|
||||
return specify_root
|
||||
|
||||
if script_file is not None:
|
||||
script_root = find_specify_root(script_file.resolve().parent)
|
||||
if script_root is not None:
|
||||
return script_root
|
||||
|
||||
# Installed scripts live at .specify/scripts/python/<script>.py.
|
||||
return script_file.resolve().parents[3]
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def get_current_branch() -> str:
|
||||
return os.environ.get("SPECIFY_FEATURE", "")
|
||||
|
||||
|
||||
def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
feature_json = repo_root / ".specify" / "feature.json"
|
||||
if not feature_json.is_file():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _json_dump(data: dict[str, str]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
value = feature_dir_value
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
return
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeaturePaths:
|
||||
repo_root: Path
|
||||
current_branch: str
|
||||
feature_dir: Path
|
||||
feature_spec: Path
|
||||
impl_plan: Path
|
||||
tasks: Path
|
||||
research: Path
|
||||
data_model: Path
|
||||
quickstart: Path
|
||||
contracts_dir: Path
|
||||
|
||||
|
||||
def get_feature_paths(
|
||||
*, no_persist: bool = False, script_file: Path | None = None
|
||||
) -> FeaturePaths:
|
||||
repo_root = get_repo_root(script_file)
|
||||
current_branch = get_current_branch()
|
||||
|
||||
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
|
||||
if feature_dir_raw:
|
||||
feature_dir = Path(feature_dir_raw)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
if not no_persist:
|
||||
persist_feature_json(repo_root, feature_dir_raw)
|
||||
elif (repo_root / ".specify" / "feature.json").is_file():
|
||||
stored = read_feature_json_feature_directory(repo_root)
|
||||
if not stored:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or ensure .specify/feature.json contains feature_directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
feature_dir = Path(stored)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
else:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or run the specify command to create .specify/feature.json.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not current_branch:
|
||||
current_branch = Path(_trim_trailing_separators(feature_dir)).name
|
||||
|
||||
return FeaturePaths(
|
||||
repo_root=repo_root,
|
||||
current_branch=current_branch,
|
||||
feature_dir=feature_dir,
|
||||
feature_spec=feature_dir / "spec.md",
|
||||
impl_plan=feature_dir / "plan.md",
|
||||
tasks=feature_dir / "tasks.md",
|
||||
research=feature_dir / "research.md",
|
||||
data_model=feature_dir / "data-model.md",
|
||||
quickstart=feature_dir / "quickstart.md",
|
||||
contracts_dir=feature_dir / "contracts",
|
||||
)
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
return "."
|
||||
try:
|
||||
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return "."
|
||||
|
||||
|
||||
def format_speckit_command(command_name: str, repo_root: Path) -> str:
|
||||
separator = get_invoke_separator(repo_root)
|
||||
name = command_name.lstrip("/")
|
||||
if name.startswith("speckit."):
|
||||
name = name[len("speckit.") :]
|
||||
elif name.startswith("speckit-"):
|
||||
name = name[len("speckit-") :]
|
||||
name = name.replace(".", separator)
|
||||
return f"/speckit{separator}{name}"
|
||||
@@ -75,7 +75,11 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
||||
# "https://:8080" or "https://user@...", so requiring netloc would let
|
||||
# those through even though they carry no host. hostname is None in those
|
||||
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
||||
)
|
||||
|
||||
@@ -88,17 +88,25 @@ class CatalogStack:
|
||||
Results are sorted by bundle id for deterministic output.
|
||||
"""
|
||||
needle = query.strip().lower()
|
||||
seen: dict[str, ResolvedBundle] = {}
|
||||
# Resolve each id to its highest-precedence entry FIRST, then filter by
|
||||
# the query. Claiming an id only when it matches would let a lower-
|
||||
# precedence entry with the same id surface when the highest-precedence
|
||||
# one doesn't match the query — but that shadowed entry is not what
|
||||
# `resolve()`/install would use, so search would advertise a bundle
|
||||
# (name, version, author) the user can never actually get.
|
||||
resolved: dict[str, ResolvedBundle] = {}
|
||||
for source in self._sources:
|
||||
for bundle_id, entry in self._entries_for(source).items():
|
||||
if bundle_id in seen:
|
||||
if bundle_id in resolved:
|
||||
continue
|
||||
if needle and not _matches(entry, needle):
|
||||
continue
|
||||
seen[bundle_id] = ResolvedBundle(
|
||||
resolved[bundle_id] = ResolvedBundle(
|
||||
entry=entry.with_provenance(source), source=source
|
||||
)
|
||||
return [seen[k] for k in sorted(seen)]
|
||||
return [
|
||||
resolved[k]
|
||||
for k in sorted(resolved)
|
||||
if not needle or _matches(resolved[k].entry, needle)
|
||||
]
|
||||
|
||||
|
||||
def _matches(entry: CatalogEntry, needle: str) -> bool:
|
||||
|
||||
@@ -2688,7 +2688,12 @@ class ConfigManager:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
|
||||
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
|
||||
# Coerce a non-mapping root (list/scalar, or None for an empty
|
||||
# file) to {} so callers that iterate/merge the result — e.g.
|
||||
# _merge_configs' .items() — never crash. Mirrors the same
|
||||
# non-dict-root guard in get_project_config().
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1178,12 +1178,18 @@ class YamlIntegration(IntegrationBase):
|
||||
default_flow_style=False,
|
||||
).strip()
|
||||
|
||||
# Indent the body for YAML block scalar
|
||||
# Indent the body for YAML block scalar. Use an explicit indentation
|
||||
# indicator ("|2") rather than a bare "|": YAML infers a plain block
|
||||
# scalar's indentation from its first non-empty line, so a body whose
|
||||
# first line is itself indented (e.g. a markdown code block or a nested
|
||||
# list item) would make the parser expect that deeper indent for the
|
||||
# whole block and reject the later, less-indented lines. Pinning the
|
||||
# indent to 2 keeps the recipe parseable whatever the body looks like.
|
||||
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
||||
|
||||
lines = [
|
||||
header_yaml,
|
||||
"prompt: |",
|
||||
"prompt: |2",
|
||||
indented,
|
||||
"",
|
||||
f"# Source: {source_id}",
|
||||
|
||||
@@ -253,6 +253,11 @@ class HermesIntegration(SkillsIntegration):
|
||||
"""
|
||||
args = [self._resolve_executable(), "chat", "-Q"]
|
||||
|
||||
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
|
||||
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
|
||||
# flags — so they can't displace or clobber them (mirrors opencode).
|
||||
self._apply_extra_args_env_var(args)
|
||||
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
|
||||
@@ -183,6 +183,65 @@ def _is_single_expression(stripped: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
|
||||
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.
|
||||
|
||||
Walks the template and, for each block, finds the closing ``}}`` that lies
|
||||
outside string literals -- the same quote-scanning used by
|
||||
``_is_single_expression``. This keeps a literal ``}}`` inside a string
|
||||
argument (e.g. ``| default('}}')``) from prematurely closing a block.
|
||||
|
||||
``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first
|
||||
``}}`` regardless of quoting, so in a multi-expression template any block
|
||||
whose argument contains a literal ``}}`` is captured truncated and mis-parsed
|
||||
(raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly
|
||||
this for the single-expression fast path but left the interpolation path on
|
||||
the old regex.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(template)
|
||||
while i < n:
|
||||
start = template.find("{{", i)
|
||||
if start == -1:
|
||||
out.append(template[i:])
|
||||
break
|
||||
out.append(template[i:start])
|
||||
# Scan for the block-closing ``}}`` that is outside any string literal.
|
||||
j = start + 2
|
||||
quote: str | None = None
|
||||
close = -1
|
||||
while j < n:
|
||||
ch = template[j]
|
||||
if quote is not None:
|
||||
if ch == quote:
|
||||
quote = None
|
||||
elif ch in ("'", '"'):
|
||||
quote = ch
|
||||
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
|
||||
close = j
|
||||
break
|
||||
j += 1
|
||||
if close == -1:
|
||||
# No quote-aware close. Two sub-cases, both kept identical to the old
|
||||
# regex so a malformed template is never silently hidden:
|
||||
# * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote
|
||||
# in a filter arg swallowed the real delimiter) -- fall back to
|
||||
# that first raw ``}}`` and evaluate, letting the parser surface
|
||||
# a ValueError just as ``_EXPR_PATTERN.sub`` would have.
|
||||
# * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the
|
||||
# tail verbatim, again matching the regex (which cannot match).
|
||||
raw_close = template.find("}}", start + 2)
|
||||
if raw_close == -1:
|
||||
out.append(template[start:])
|
||||
break
|
||||
close = raw_close
|
||||
val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace)
|
||||
out.append(str(val) if val is not None else "")
|
||||
i = close + 2
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _split_top_level_commas(text: str) -> list[str]:
|
||||
"""Split *text* on commas that are not inside quotes or nested brackets.
|
||||
|
||||
@@ -408,15 +467,34 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
return _resolve_dot_path(namespace, expr)
|
||||
|
||||
|
||||
def _coerce_number(value: Any) -> Any:
|
||||
"""Return *value* as int/float if it is a numeric string, else unchanged."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value) if "." in value else int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
||||
"""Safely compare two values, coercing types when possible."""
|
||||
try:
|
||||
if isinstance(left, str):
|
||||
left = float(left) if "." in left else int(left)
|
||||
if isinstance(right, str):
|
||||
right = float(right) if "." in right else int(right)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
"""Compare two values for ordering, coercing numeric strings when possible.
|
||||
|
||||
Numeric coercion is applied only when *both* operands look numeric, so a
|
||||
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either
|
||||
side is a non-numeric string, both fall back to their original values and
|
||||
are compared directly -- so ordinary strings (dates, semver-ish tags,
|
||||
names) compare lexicographically the way Python does, instead of every
|
||||
such comparison silently returning ``False`` after a failed int()/float()
|
||||
coercion. A genuinely incomparable pair (e.g. number vs non-numeric string)
|
||||
raises ``TypeError`` and yields ``False``.
|
||||
"""
|
||||
cl, cr = _coerce_number(left), _coerce_number(right)
|
||||
# Only use the coerced numbers when both converted; otherwise a numeric
|
||||
# string paired with a plain string would become an int-vs-str mismatch
|
||||
# (always False) rather than a lexicographic string comparison.
|
||||
if isinstance(cl, (int, float)) and isinstance(cr, (int, float)):
|
||||
left, right = cl, cr
|
||||
try:
|
||||
if op == ">":
|
||||
return left > right # type: ignore[operator]
|
||||
@@ -472,12 +550,11 @@ def evaluate_expression(template: str, context: Any) -> Any:
|
||||
if _is_single_expression(stripped):
|
||||
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
|
||||
|
||||
# Multi-expression: string interpolation
|
||||
def _replacer(m: re.Match[str]) -> str:
|
||||
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
|
||||
return str(val) if val is not None else ""
|
||||
|
||||
return _EXPR_PATTERN.sub(_replacer, template)
|
||||
# Multi-expression: interpolate each block inline. Uses a quote-aware scan
|
||||
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument
|
||||
# in any block does not close that block early -- matching the handling the
|
||||
# single-expression path above already got in #3208/#3228.
|
||||
return _interpolate_expressions(template, namespace)
|
||||
|
||||
|
||||
def evaluate_condition(condition: str, context: Any) -> bool:
|
||||
|
||||
@@ -73,7 +73,14 @@ class GateStep(StepBase):
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
output["choice"] = choice
|
||||
|
||||
if choice in ("reject", "abort"):
|
||||
# Match rejection case-insensitively. ``_prompt`` echoes the option's
|
||||
# original casing, and ``validate`` accepts a reject option
|
||||
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
|
||||
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
|
||||
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
|
||||
# approval and silently skip the abort — the reject path must agree with
|
||||
# the check that let the option through.
|
||||
if choice.lower() in ("reject", "abort"):
|
||||
if on_reject == "abort":
|
||||
output["aborted"] = True
|
||||
return StepResult(
|
||||
|
||||
@@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None =
|
||||
)
|
||||
|
||||
|
||||
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run an extension PowerShell script."""
|
||||
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
|
||||
env = {**os.environ, **_GIT_ENV}
|
||||
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), *args],
|
||||
cwd=cwd,
|
||||
@@ -320,6 +320,15 @@ class TestCreateFeatureBash:
|
||||
assert rt.returncode == 0, rt.stderr
|
||||
assert "HAS_GIT" not in rt.stdout
|
||||
|
||||
def test_help_documents_branch_prefix(self, tmp_path: Path):
|
||||
"""--help documents both template config knobs."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash("create-new-feature-branch.sh", project, "--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "branch_template" in result.stdout
|
||||
assert "branch_prefix" in result.stdout
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
|
||||
"""A short word is dropped from the derived branch name unless it appears
|
||||
as an acronym in UPPERCASE in the description (case-sensitive, must match the
|
||||
@@ -363,6 +372,183 @@ class TestCreateFeatureBash:
|
||||
data = json.loads(result.stdout)
|
||||
assert data["FEATURE_NUM"] == "003"
|
||||
|
||||
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
|
||||
"""branch_template namespaces generated branch names for monorepos."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
|
||||
"""branch_prefix expands to a namespace before the default branch shape."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_prefix: "features/{app}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
|
||||
"""Numeric-looking namespace segments must not be parsed as feature numbers."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
|
||||
"""Malformed timestamp-looking branches must not inflate sequential numbering."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
|
||||
"""Templated branch numbering ignores branches outside the current namespace."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_requires_number_token(self, tmp_path: Path):
|
||||
"""Configured templates must include {number} so generated branches validate."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
|
||||
"""Templates must render a final path segment that validation accepts."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}-" in result.stderr
|
||||
|
||||
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
|
||||
"""{slug} before {number} would make branch-number scanning slug-specific."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{slug}" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
|
||||
"""GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
|
||||
"""GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
|
||||
"""GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
|
||||
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
|
||||
|
||||
def test_truncation_warning_reports_utf8_bytes(self):
|
||||
"""Bash truncation warnings should use the same byte counter as enforcement."""
|
||||
source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source
|
||||
assert '_byte_length "$BRANCH_NAME"' in source
|
||||
|
||||
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
|
||||
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
||||
project = _setup_project(tmp_path / "project")
|
||||
@@ -484,6 +670,15 @@ class TestCreateFeaturePowerShell:
|
||||
assert rt.returncode == 0, rt.stderr
|
||||
assert "HAS_GIT" not in rt.stdout
|
||||
|
||||
def test_help_documents_branch_prefix(self, tmp_path: Path):
|
||||
"""-Help documents both template config knobs."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "branch_template" in result.stdout
|
||||
assert "branch_prefix" in result.stdout
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
|
||||
"""PowerShell must match the bash twin: a short word is dropped unless it
|
||||
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
|
||||
@@ -525,6 +720,176 @@ class TestCreateFeaturePowerShell:
|
||||
data = json.loads(result.stdout)
|
||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||
|
||||
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
|
||||
"""PowerShell supports branch_template namespaces."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
|
||||
"""PowerShell supports branch_prefix shorthand namespaces."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_prefix: "features/{app}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
|
||||
"""PowerShell ignores numeric-looking namespace segments when numbering."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
|
||||
"""PowerShell skips malformed timestamp-looking refs during sequential numbering."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
|
||||
"""PowerShell templated numbering ignores branches outside the namespace."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_requires_number_token(self, tmp_path: Path):
|
||||
"""PowerShell rejects templates without {number}."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
|
||||
"""PowerShell rejects templates whose final segment cannot validate."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}-" in result.stderr
|
||||
|
||||
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
|
||||
"""PowerShell rejects templates where {slug} scopes number scanning."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{slug}" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
|
||||
"""PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
|
||||
"""PowerShell GIT_BRANCH_NAME ignores numeric namespace segments."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
|
||||
"""PowerShell keeps the full override name when no feature marker exists."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
|
||||
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature-branch.ps1 works without git."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
@@ -1011,13 +1376,31 @@ class TestGitCommonBash:
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
|
||||
def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
@@ -1037,3 +1420,33 @@ class TestGitCommonPowerShell:
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
@@ -71,6 +71,47 @@ def test_search_dedupes_by_precedence_and_filters():
|
||||
assert [r.entry.id for r in qa_only] == ["beta"]
|
||||
|
||||
|
||||
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
|
||||
"""Search must resolve each id at its highest-precedence source, then
|
||||
filter — never fall through to a shadowed lower-precedence entry the query
|
||||
happens to match.
|
||||
|
||||
If the query matched only the lower-precedence copy of an id, search used
|
||||
to return that copy, even though `resolve()`/install always use the
|
||||
higher-precedence one. That advertised a bundle (name/version/source) the
|
||||
user could never actually get.
|
||||
"""
|
||||
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
|
||||
payloads = {
|
||||
# Highest-precedence entry for 'shared' does NOT match "widget".
|
||||
"high": catalog_payload({
|
||||
"shared": catalog_entry_dict(
|
||||
"shared", name="Alpha Tool", role="developer",
|
||||
description="nothing relevant", version="2.0.0",
|
||||
),
|
||||
}),
|
||||
# Lower-precedence entry for the same id DOES match "widget".
|
||||
"low": catalog_payload({
|
||||
"shared": catalog_entry_dict(
|
||||
"shared", name="Searchable Widget", version="1.0.0",
|
||||
),
|
||||
}),
|
||||
}
|
||||
stack = _stack(sources, payloads)
|
||||
|
||||
# resolve() uses the high-precedence entry.
|
||||
assert stack.resolve("shared").source.id == "high"
|
||||
|
||||
# A query that only the shadowed low-precedence entry matches returns
|
||||
# nothing — search agrees with resolve().
|
||||
assert stack.search("widget") == []
|
||||
|
||||
# And a query the high-precedence entry matches returns it (from 'high').
|
||||
alpha = stack.search("alpha tool")
|
||||
assert [r.entry.id for r in alpha] == ["shared"]
|
||||
assert alpha[0].source.id == "high"
|
||||
|
||||
|
||||
def test_unreachable_source_raises_named_error():
|
||||
def fetcher(src):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
"""Shared test helpers for integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
home = tmp_path / "home"
|
||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
||||
|
||||
|
||||
class StubIntegration(MarkdownIntegration):
|
||||
"""Minimal concrete integration for testing."""
|
||||
|
||||
|
||||
21
tests/integrations/test_home_isolation.py
Normal file
21
tests/integrations/test_home_isolation.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Regression tests for integration-test environment isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
|
||||
assert Path(os.environ["HOME"]) == home
|
||||
assert Path(os.environ["USERPROFILE"]) == home
|
||||
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
|
||||
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
|
||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
||||
|
||||
assert home.is_dir()
|
||||
assert (home / ".cache").is_dir()
|
||||
assert (home / ".config").is_dir()
|
||||
assert (home / ".local" / "share").is_dir()
|
||||
@@ -184,6 +184,23 @@ class YamlIntegrationTests:
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
|
||||
"""A body whose first line is indented must still parse.
|
||||
|
||||
A bare ``|`` block scalar infers its indentation from the first
|
||||
non-empty line, so a body starting with an indented line (e.g. a
|
||||
markdown code block or nested list item) made the parser expect that
|
||||
deeper indent for the whole block and reject the later, shallower
|
||||
lines. The explicit ``|2`` indicator pins the indent so it parses."""
|
||||
body = " indented first line\nback to normal\n indented again"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
|
||||
yaml_lines = [
|
||||
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
|
||||
]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
|
||||
@@ -353,3 +353,38 @@ class TestHermesInitFlow:
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
|
||||
|
||||
class TestHermesBuildExecArgs:
|
||||
"""CLI dispatch argv, including the operator extra-args env hook."""
|
||||
|
||||
def test_build_exec_args_default_shape(self):
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
|
||||
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
|
||||
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
|
||||
devin; hermes previously skipped _apply_extra_args_env_var entirely).
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
args = i.build_exec_args("/speckit-plan hi", output_json=True)
|
||||
assert args == [
|
||||
"hermes", "chat", "-Q", "--temperature", "0.2",
|
||||
"--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
# Injected before the canonical flags so it can't displace them.
|
||||
assert args.index("--temperature") < args.index("--json")
|
||||
assert args.index("--temperature") < args.index("-s")
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"
|
||||
|
||||
@@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_orders() -> list[list[str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
if len(safe_keys) < 2:
|
||||
return [safe_keys]
|
||||
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
|
||||
|
||||
|
||||
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
|
||||
if not ordered_keys:
|
||||
return "no-safe-integrations"
|
||||
return f"init-{ordered_keys[0]}"
|
||||
|
||||
|
||||
def _posix_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
@@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _path_is_inside(path: str | None, directory: str | None) -> bool:
|
||||
if not path or not directory:
|
||||
return False
|
||||
try:
|
||||
PurePosixPath(path).relative_to(PurePosixPath(directory))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_registry_is_dict(self):
|
||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||
@@ -162,6 +165,15 @@ class TestRegistrarKeyAlignment:
|
||||
class TestMultiInstallSafeContracts:
|
||||
"""Declared safe integrations must stay isolated from each other."""
|
||||
|
||||
def test_safe_install_orders_rotate_each_integration_through_init(self):
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
orders = _multi_install_safe_orders()
|
||||
|
||||
assert len(safe_keys) >= 2
|
||||
assert [order[0] for order in orders] == safe_keys
|
||||
assert len({tuple(order) for order in orders}) == len(safe_keys)
|
||||
assert all(sorted(order) == safe_keys for order in orders)
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
assert _integration_root_dir(key), (
|
||||
@@ -187,62 +199,77 @@ class TestMultiInstallSafeContracts:
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
@pytest.mark.parametrize(
|
||||
"ordered_keys",
|
||||
_multi_install_safe_orders(),
|
||||
ids=_multi_install_safe_order_id,
|
||||
)
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
tmp_path,
|
||||
first,
|
||||
second,
|
||||
ordered_keys,
|
||||
):
|
||||
for initial, additional in ((first, second), (second, first)):
|
||||
project_root = tmp_path / f"project-{initial}-{additional}"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
# The pairwise disjointness contract is only meaningful with at least
|
||||
# two safe integrations. Guard so a shrunken registry fails loudly here
|
||||
# rather than passing vacuously (or tripping over ordered_keys[0] below).
|
||||
assert len(ordered_keys) >= 2, (
|
||||
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
|
||||
)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
project_root = tmp_path / "project"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
|
||||
# Install every safe integration once into a single project, then assert
|
||||
# pairwise manifest isolation. Each safe integration writes only to its
|
||||
# own (disjoint) directories and always records what it writes, so a
|
||||
# manifest's contents are independent of install order and of which other
|
||||
# integrations are co-installed. The parametrized rotations keep the
|
||||
# aggregate setup while placing each safe integration first once, so each
|
||||
# one still exercises the `specify init --integration ...` path.
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
ordered_keys[0],
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
for key in ordered_keys[1:]:
|
||||
install_result = runner.invoke(
|
||||
app,
|
||||
["integration", "install", additional, "--script", "sh"],
|
||||
["integration", "install", key, "--script", "sh"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
initial_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
additional_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
integrations_dir = project_root / ".specify" / "integrations"
|
||||
manifests = {}
|
||||
for key in ordered_keys:
|
||||
manifest = json.loads(
|
||||
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
files = manifest.get("files", {})
|
||||
assert isinstance(files, dict), f"{key} manifest files must be an object"
|
||||
manifests[key] = set(files.keys())
|
||||
|
||||
initial_files = set(initial_manifest.get("files", {}))
|
||||
additional_files = set(additional_manifest.get("files", {}))
|
||||
|
||||
assert initial_files.isdisjoint(additional_files), (
|
||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(initial_files & additional_files)}"
|
||||
for first, second in _multi_install_safe_pairs():
|
||||
overlap = manifests[first] & manifests[second]
|
||||
assert not overlap, (
|
||||
f"{first} and {second} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
339
tests/test_check_prerequisites_python_parity.py
Normal file
339
tests/test_check_prerequisites_python_parity.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Parity tests for the Python check-prerequisites PoC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
||||
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (
|
||||
shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_scripts(repo: Path) -> None:
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, bash_dir / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _bash_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
return ["bash", str(script), *args]
|
||||
|
||||
|
||||
def _ps_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
return [exe, "-NoProfile", "-File", str(script), *args]
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env or _clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_help_text(text: str) -> str:
|
||||
normalized = text.replace("\r\n", "\n").replace(
|
||||
"check-prerequisites.sh", "check_prerequisites.py"
|
||||
)
|
||||
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json",),
|
||||
("--json", "--include-tasks"),
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("--json", "--paths-only"),
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "contracts").mkdir()
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(("--json",), ("-Json",)),
|
||||
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
|
||||
(
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("-Json", "-RequireTasks", "-IncludeTasks"),
|
||||
),
|
||||
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
|
||||
],
|
||||
ids=[
|
||||
"json",
|
||||
"json_include_tasks",
|
||||
"json_require_tasks_include_tasks",
|
||||
"json_paths_only",
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert py.stderr == ps.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(ps)
|
||||
|
||||
|
||||
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
outside = tmp_path / "outside"
|
||||
repo.mkdir()
|
||||
outside.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
|
||||
py_dir = repo / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
|
||||
|
||||
|
||||
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
feature_json = prereq_repo / ".specify" / "feature.json"
|
||||
before = feature_json.read_text(encoding="utf-8")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
|
||||
assert feature_json.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
data = json.loads(
|
||||
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
(("--json",), "Feature directory not found"),
|
||||
(("--json",), "plan.md not found"),
|
||||
(("--json", "--require-tasks"), "tasks.md not found"),
|
||||
],
|
||||
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
|
||||
)
|
||||
def test_python_negative_errors_are_stderr_only(
|
||||
tmp_path: Path, args: tuple[str, ...], expected: str
|
||||
) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
|
||||
if expected in {"plan.md not found", "tasks.md not found"}:
|
||||
feat = repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
_write_feature_json(repo)
|
||||
if expected == "tasks.md not found":
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
py = _run(_py_cmd(repo, *args), repo)
|
||||
|
||||
assert py.returncode != 0
|
||||
assert expected in py.stderr
|
||||
assert expected not in py.stdout
|
||||
assert py.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
@@ -31,6 +31,7 @@ from specify_cli.extensions import (
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
CommandRegistrar,
|
||||
ConfigManager,
|
||||
HookExecutor,
|
||||
ExtensionCatalog,
|
||||
ExtensionError,
|
||||
@@ -7492,3 +7493,52 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
|
||||
class TestConfigManagerNonMappingYaml:
|
||||
"""A non-mapping YAML config root must not crash config/hook resolution."""
|
||||
|
||||
def _make(self, tmp_path, body: str):
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
|
||||
return ConfigManager(tmp_path, "jira")
|
||||
|
||||
def test_get_config_coerces_list_root(self, tmp_path):
|
||||
"""A YAML list root previously raised AttributeError in _merge_configs."""
|
||||
cm = self._make(tmp_path, "- foo\n- bar\n")
|
||||
assert cm.get_config() == {}
|
||||
|
||||
def test_get_config_coerces_scalar_root(self, tmp_path):
|
||||
cm = self._make(tmp_path, "just a string\n")
|
||||
assert cm.get_config() == {}
|
||||
|
||||
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
|
||||
cm = self._make(tmp_path, "- foo\n")
|
||||
assert cm.has_value("anything") is False
|
||||
assert cm.get_value("anything") is None
|
||||
|
||||
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
|
||||
"""A malformed project config must not block a valid local config."""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
|
||||
(ext_dir / "local-config.yml").write_text(
|
||||
"notifications:\n enabled: true\n", encoding="utf-8"
|
||||
)
|
||||
cm = ConfigManager(tmp_path, "jira")
|
||||
assert cm.get_value("notifications.enabled") is True
|
||||
|
||||
def test_hook_condition_returns_false_without_raising(self, tmp_path):
|
||||
"""`config.x is set` on a scalar-root config must evaluate cleanly.
|
||||
|
||||
Before the fix, _merge_configs raised AttributeError and the
|
||||
exception was swallowed by should_execute_hook, silently disabling
|
||||
every config-based hook for the extension. Assert on
|
||||
_evaluate_condition directly so the crash isn't masked.
|
||||
"""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
|
||||
executor = HookExecutor(tmp_path)
|
||||
assert executor._evaluate_condition("config.x is set", "jira") is False
|
||||
|
||||
@@ -260,6 +260,60 @@ class TestExpressions:
|
||||
ctx = StepContext(inputs={"text": "uses }} syntax"})
|
||||
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
|
||||
|
||||
def test_multi_expression_with_literal_close_brace_in_argument(self):
|
||||
"""A multi-expression template with a literal ``}}`` inside a string
|
||||
argument must interpolate, not raise. #3208/#3228 hardened the single-
|
||||
expression fast path for literal braces but left the interpolation path
|
||||
on ``_EXPR_PATTERN``, whose non-greedy body stops at the first ``}}`` --
|
||||
so the block was captured truncated and the filter parser raised
|
||||
ValueError."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"name": "Bob", "missing": None})
|
||||
# ``}}`` in the default fallback of the second block.
|
||||
result = evaluate_expression(
|
||||
"{{ inputs.name }}: {{ inputs.missing | default('}}') }}", ctx
|
||||
)
|
||||
assert result == "Bob: }}"
|
||||
# ``}}`` in the first block, expression following it.
|
||||
result = evaluate_expression(
|
||||
"{{ inputs.missing | default('}}') }} / {{ inputs.name }}", ctx
|
||||
)
|
||||
assert result == "}} / Bob"
|
||||
|
||||
def test_multi_expression_with_literal_open_brace_in_argument(self):
|
||||
"""A literal ``{{`` inside a string argument in a multi-expression
|
||||
template must not confuse block detection either."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"name": "Bob", "missing": None})
|
||||
result = evaluate_expression(
|
||||
"{{ inputs.name }} {{ inputs.missing | default('{{') }}", ctx
|
||||
)
|
||||
assert result == "Bob {{"
|
||||
|
||||
def test_multi_expression_unbalanced_quote_still_raises(self):
|
||||
"""A malformed block (an unbalanced quote in a filter arg) must still
|
||||
surface a ValueError, not be silently emitted verbatim.
|
||||
|
||||
The quote-aware scan never finds a block-closing ``}}`` when a quote is
|
||||
left open, but a raw ``}}`` is still present in the tail. It must fall
|
||||
back to that raw delimiter and evaluate — same as the old regex path —
|
||||
so a typo fails loudly instead of being hidden (Copilot review on
|
||||
#3307)."""
|
||||
import pytest
|
||||
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"name": "Bob", "missing": None})
|
||||
with pytest.raises(ValueError):
|
||||
evaluate_expression(
|
||||
"{{ inputs.name }} {{ inputs.missing | default('oops }}", ctx
|
||||
)
|
||||
|
||||
def test_comparison_equals(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -288,6 +342,35 @@ class TestExpressions:
|
||||
assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True
|
||||
assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False
|
||||
|
||||
def test_ordering_comparison_of_non_numeric_strings(self):
|
||||
"""`<`/`>`/`<=`/`>=` between non-numeric strings must compare
|
||||
lexicographically, not silently return False.
|
||||
|
||||
`_safe_compare` used to coerce both operands to int/float unconditionally;
|
||||
a non-numeric string (date, version tag, name) failed that coercion and
|
||||
the whole comparison returned False. Ordinary strings should order the
|
||||
way Python does; numeric strings must still compare as numbers."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
# ISO dates compare lexicographically (correct chronological order).
|
||||
ctx = StepContext(inputs={"d": "2026-01-01"})
|
||||
assert evaluate_expression("{{ inputs.d < '2026-02-01' }}", ctx) is True
|
||||
assert evaluate_expression("{{ inputs.d > '2026-02-01' }}", ctx) is False
|
||||
|
||||
# Plain string ordering.
|
||||
ctx = StepContext(inputs={"name": "beta"})
|
||||
assert evaluate_expression("{{ inputs.name > 'alpha' }}", ctx) is True
|
||||
|
||||
# Two numeric strings still compare numerically, not lexically
|
||||
# ("10" > "9" is True as numbers; as strings it would be False).
|
||||
ctx = StepContext(inputs={"v": "10"})
|
||||
assert evaluate_expression("{{ inputs.v > '9' }}", ctx) is True
|
||||
|
||||
# A number vs a non-numeric string is genuinely incomparable -> False.
|
||||
ctx = StepContext(inputs={"n": 5})
|
||||
assert evaluate_expression("{{ inputs.n > 'abc' }}", ctx) is False
|
||||
|
||||
def test_boolean_and(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -3977,6 +4060,48 @@ steps:
|
||||
assert state.status == RunStatus.ABORTED
|
||||
assert "should-not-run" not in state.step_results
|
||||
|
||||
def test_gate_reject_matches_case_insensitively(
|
||||
self, project_dir, monkeypatch
|
||||
):
|
||||
"""A capitalised reject option (`options: [Approve, Reject]`) still
|
||||
aborts the run. `validate` accepts a reject choice case-insensitively,
|
||||
so the runtime reject check must agree — a case-sensitive comparison
|
||||
would treat the echoed `Reject` as approval and silently run
|
||||
downstream steps.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
|
||||
# `_prompt` echoes the option's original casing, so the operator
|
||||
# picking "Reject" hands `execute` the capitalised string.
|
||||
_force_gate_stdin(monkeypatch, tty=True)
|
||||
monkeypatch.setattr(
|
||||
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "gate-reject-case"
|
||||
name: "Gate Reject Case"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: gate-step
|
||||
type: gate
|
||||
message: "Approve?"
|
||||
options: [Approve, Reject]
|
||||
on_reject: abort
|
||||
- id: should-not-run
|
||||
type: shell
|
||||
run: "echo nope"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.ABORTED
|
||||
assert "should-not-run" not in state.step_results
|
||||
|
||||
def test_validation_rejects_non_bool_continue_on_error(self):
|
||||
"""`continue_on_error` must be a literal boolean; coerced
|
||||
strings like `"true"` are rejected at validation time so
|
||||
|
||||
@@ -69,3 +69,30 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
fetcher(_source("https://example.com/c.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://:8080", # port only, no host
|
||||
"https://:0",
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pw@",
|
||||
"https://:8080/catalog.json",
|
||||
],
|
||||
)
|
||||
def test_validate_remote_url_rejects_host_less_urls(url):
|
||||
"""A URL with a truthy netloc but no host (``https://:8080``,
|
||||
``https://user@``) must be rejected.
|
||||
|
||||
``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``,
|
||||
so a ``netloc`` check would wrongly accept them. This mirrors the fix in
|
||||
``specify_cli.catalogs`` (#3210), which the docstring says this validator
|
||||
mirrors."""
|
||||
with pytest.raises(BundlerError, match="valid URL with a host"):
|
||||
adapters._validate_remote_url("team", url)
|
||||
|
||||
|
||||
def test_validate_remote_url_accepts_normal_https_url():
|
||||
# Sanity: a real host with a port still passes.
|
||||
adapters._validate_remote_url("team", "https://example.com:8080/c.json")
|
||||
|
||||
Reference in New Issue
Block a user