mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
Compare commits
1 Commits
v0.12.5
...
update-doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2228c6f45e |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,21 +2,6 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
@@ -50,7 +50,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-01T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1072,10 +1072,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
|
||||
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
|
||||
"author": "raccioly",
|
||||
"version": "0.28.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -1111,7 +1111,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-06-23T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
|
||||
@@ -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 and optional templates for branch namespaces
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||
- **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,16 +53,6 @@ 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"
|
||||
|
||||
@@ -75,10 +65,6 @@ 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 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
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -35,19 +35,6 @@ 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:
|
||||
@@ -67,7 +54,6 @@ 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
|
||||
|
||||
@@ -78,5 +64,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`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-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's final path segment must start with one of these feature markers:
|
||||
The branch name must match one of these patterns:
|
||||
|
||||
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`)
|
||||
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`)
|
||||
|
||||
## 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, regardless of branch namespace prefixes
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
|
||||
- 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
|
||||
- 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, 20260319-143022-feature-name, or <namespace>/001-feature-name`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
|
||||
@@ -4,16 +4,6 @@
|
||||
# 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), templating, validation, and Git remote detection"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), 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 and optional templates"
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
- name: speckit.git.validate
|
||||
file: commands/speckit.git.validate.md
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
@@ -137,6 +137,4 @@ tags:
|
||||
config:
|
||||
defaults:
|
||||
branch_numbering: sequential
|
||||
branch_template: ""
|
||||
branch_prefix: ""
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
@@ -4,16 +4,6 @@
|
||||
# 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,10 +75,6 @@ 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"
|
||||
@@ -131,28 +127,16 @@ get_highest_from_specs() {
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
local scope_prefix="${1:-}"
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# 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 [ -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")
|
||||
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")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
@@ -164,12 +148,11 @@ _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 "$scope_prefix")
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
@@ -182,17 +165,16 @@ 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 "$scope_prefix")
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
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 "$scope_prefix")
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
fi
|
||||
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
@@ -291,152 +273,6 @@ 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() {
|
||||
@@ -482,8 +318,18 @@ 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"
|
||||
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
|
||||
BRANCH_SUFFIX="$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
|
||||
else
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
@@ -501,17 +347,16 @@ else
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
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_SCOPE_PREFIX")
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
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" false "$BRANCH_SCOPE_PREFIX")
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
@@ -519,7 +364,7 @@ else
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -531,23 +376,18 @@ 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"
|
||||
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
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
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)"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
|
||||
@@ -23,9 +23,8 @@ 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,
|
||||
# either at the start of the branch or after path-style namespace prefixes.
|
||||
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
@@ -38,17 +37,16 @@ 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 [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[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, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -71,23 +67,11 @@ function Get-HighestNumberFromSpecs {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromNames {
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
param([string[]]$Names)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
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) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
@@ -98,7 +82,7 @@ function Get-HighestNumberFromNames {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param([string]$ScopePrefix = '')
|
||||
param()
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
@@ -106,7 +90,7 @@ function Get-HighestNumberFromBranches {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
@@ -115,8 +99,6 @@ function Get-HighestNumberFromBranches {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
@@ -129,7 +111,7 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
@@ -143,19 +125,18 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch,
|
||||
[string]$ScopePrefix = ''
|
||||
[switch]$SkipFetch
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
}
|
||||
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
@@ -251,145 +232,6 @@ 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)
|
||||
@@ -434,11 +276,19 @@ function Get-BranchName {
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
|
||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($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."
|
||||
}
|
||||
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
|
||||
# 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
|
||||
}
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
@@ -453,41 +303,39 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
$originalBranchName = $branchName
|
||||
$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."
|
||||
}
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
|
||||
@@ -37,15 +37,14 @@ function Test-FeatureBranch {
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
$featureSegment = ($Branch -split '/')[-1]
|
||||
|
||||
# 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}-') {
|
||||
# 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}-') {
|
||||
[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, 20260319-143022-feature-name, or <prefix>/001-feature-name")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.5"
|
||||
version = "0.12.5.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"
|
||||
|
||||
@@ -75,11 +75,7 @@ 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."
|
||||
)
|
||||
# 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:
|
||||
if not parsed.netloc:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
||||
)
|
||||
|
||||
@@ -88,25 +88,17 @@ class CatalogStack:
|
||||
Results are sorted by bundle id for deterministic output.
|
||||
"""
|
||||
needle = query.strip().lower()
|
||||
# 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] = {}
|
||||
seen: dict[str, ResolvedBundle] = {}
|
||||
for source in self._sources:
|
||||
for bundle_id, entry in self._entries_for(source).items():
|
||||
if bundle_id in resolved:
|
||||
if bundle_id in seen:
|
||||
continue
|
||||
resolved[bundle_id] = ResolvedBundle(
|
||||
if needle and not _matches(entry, needle):
|
||||
continue
|
||||
seen[bundle_id] = ResolvedBundle(
|
||||
entry=entry.with_provenance(source), source=source
|
||||
)
|
||||
return [
|
||||
resolved[k]
|
||||
for k in sorted(resolved)
|
||||
if not needle or _matches(resolved[k].entry, needle)
|
||||
]
|
||||
return [seen[k] for k in sorted(seen)]
|
||||
|
||||
|
||||
def _matches(entry: CatalogEntry, needle: str) -> bool:
|
||||
|
||||
@@ -183,65 +183,6 @@ 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.
|
||||
|
||||
@@ -467,34 +408,15 @@ 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:
|
||||
"""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
|
||||
"""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
|
||||
try:
|
||||
if op == ">":
|
||||
return left > right # type: ignore[operator]
|
||||
@@ -550,11 +472,12 @@ def evaluate_expression(template: str, context: Any) -> Any:
|
||||
if _is_single_expression(stripped):
|
||||
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
|
||||
def evaluate_condition(condition: str, context: Any) -> bool:
|
||||
|
||||
@@ -73,14 +73,7 @@ class GateStep(StepBase):
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
output["choice"] = choice
|
||||
|
||||
# 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 choice 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, env_extra: dict | None = None) -> subprocess.CompletedProcess:
|
||||
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Run an extension PowerShell script."""
|
||||
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
|
||||
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
|
||||
env = {**os.environ, **_GIT_ENV}
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), *args],
|
||||
cwd=cwd,
|
||||
@@ -320,15 +320,6 @@ 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
|
||||
@@ -372,183 +363,6 @@ 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")
|
||||
@@ -670,15 +484,6 @@ 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)."""
|
||||
@@ -720,176 +525,6 @@ 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)
|
||||
@@ -1376,31 +1011,13 @@ class TestGitCommonBash:
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
|
||||
def test_check_feature_branch_rejects_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
|
||||
|
||||
|
||||
@@ -1420,33 +1037,3 @@ 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,47 +71,6 @@ 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")
|
||||
|
||||
@@ -260,60 +260,6 @@ 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
|
||||
@@ -342,35 +288,6 @@ 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
|
||||
@@ -4060,48 +3977,6 @@ 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,30 +69,3 @@ 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