mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7511c8c24c |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
fetch-depth: 0 # Fetch all history for git info
|
fetch-depth: 0 # Fetch all history for git info
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.x'
|
dotnet-version: '8.x'
|
||||||
|
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,21 +2,6 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- 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
|
## [0.12.4] - 2026-07-02
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
|||||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
|
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
|
||||||
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
|
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
|
||||||
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
||||||
@@ -218,7 +218,7 @@ Some integrations accept additional options via `--integration-options`:
|
|||||||
| Integration | Option | Description |
|
| Integration | Option | Description |
|
||||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` |
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
This extension provides Git operations as an optional, self-contained module. It manages:
|
||||||
|
|
||||||
- **Repository initialization** with configurable commit messages
|
- **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
|
- **Branch validation** to ensure branches follow naming conventions
|
||||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
- **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 strategy: "sequential" or "timestamp"
|
||||||
branch_numbering: sequential
|
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
|
# Custom commit message for git init
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|
||||||
@@ -75,10 +65,6 @@ auto_commit:
|
|||||||
message: "[Spec Kit] Add specification"
|
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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```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:
|
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
|
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
- `--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
|
## 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)
|
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
|
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
|
## Execution
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
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
|
- 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
|
- You must only ever run this script once per feature
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
- 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
|
## Graceful Degradation
|
||||||
|
|
||||||
@@ -78,5 +64,5 @@ If Git is not installed or the current directory is not a Git repository:
|
|||||||
## Output
|
## Output
|
||||||
|
|
||||||
The script outputs JSON with:
|
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
|
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||||
|
|||||||
@@ -22,24 +22,24 @@ Get the current branch name:
|
|||||||
git rev-parse --abbrev-ref HEAD
|
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`)
|
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`, `jdoe/web/20260319-143022-feature-name`)
|
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
If on a feature branch (matches either pattern):
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
- Output: `✓ On feature branch: <branch-name>`
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
- 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 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, regardless of branch namespace prefixes
|
- 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 exists: `✓ Spec directory found: <path>`
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||||
|
|
||||||
If NOT on a feature branch:
|
If NOT on a feature branch:
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
- 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
|
## Graceful Degradation
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,6 @@
|
|||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||||
branch_numbering: sequential
|
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
|
# Commit message used by `git commit` during repository initialization
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ extension:
|
|||||||
id: git
|
id: git
|
||||||
name: "Git Branching Workflow"
|
name: "Git Branching Workflow"
|
||||||
version: "1.0.0"
|
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
|
author: spec-kit-core
|
||||||
repository: https://github.com/github/spec-kit
|
repository: https://github.com/github/spec-kit
|
||||||
license: MIT
|
license: MIT
|
||||||
@@ -19,7 +19,7 @@ provides:
|
|||||||
commands:
|
commands:
|
||||||
- name: speckit.git.feature
|
- name: speckit.git.feature
|
||||||
file: commands/speckit.git.feature.md
|
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
|
- name: speckit.git.validate
|
||||||
file: commands/speckit.git.validate.md
|
file: commands/speckit.git.validate.md
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
description: "Validate current branch follows feature branch naming conventions"
|
||||||
@@ -137,6 +137,4 @@ tags:
|
|||||||
config:
|
config:
|
||||||
defaults:
|
defaults:
|
||||||
branch_numbering: sequential
|
branch_numbering: sequential
|
||||||
branch_template: ""
|
|
||||||
branch_prefix: ""
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|||||||
@@ -4,16 +4,6 @@
|
|||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||||
branch_numbering: sequential
|
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
|
# Commit message used by `git commit` during repository initialization
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
init_commit_message: "[Spec Kit] Initial commit"
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,6 @@ while [ $i -le $# ]; do
|
|||||||
echo "Environment variables:"
|
echo "Environment variables:"
|
||||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||||
echo ""
|
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 "Examples:"
|
||||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
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
|
# Function to get highest number from git branches
|
||||||
get_highest_from_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
|
||||||
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 the highest sequential feature number from a list of ref names (one per line).
|
||||||
_extract_highest_number() {
|
_extract_highest_number() {
|
||||||
local scope_prefix="${1:-}"
|
|
||||||
local highest=0
|
local highest=0
|
||||||
while IFS= read -r name; do
|
while IFS= read -r name; do
|
||||||
[ -z "$name" ] && continue
|
[ -z "$name" ] && continue
|
||||||
if [ -n "$scope_prefix" ]; then
|
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
case "$name" in
|
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||||
"$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))
|
number=$((10#$number))
|
||||||
if [ "$number" -gt "$highest" ]; then
|
if [ "$number" -gt "$highest" ]; then
|
||||||
highest=$number
|
highest=$number
|
||||||
@@ -164,12 +148,11 @@ _extract_highest_number() {
|
|||||||
|
|
||||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||||
get_highest_from_remote_refs() {
|
get_highest_from_remote_refs() {
|
||||||
local scope_prefix="${1:-}"
|
|
||||||
local highest=0
|
local highest=0
|
||||||
|
|
||||||
for remote in $(git remote 2>/dev/null); do
|
for remote in $(git remote 2>/dev/null); do
|
||||||
local remote_highest
|
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
|
if [ "$remote_highest" -gt "$highest" ]; then
|
||||||
highest=$remote_highest
|
highest=$remote_highest
|
||||||
fi
|
fi
|
||||||
@@ -182,17 +165,16 @@ get_highest_from_remote_refs() {
|
|||||||
check_existing_branches() {
|
check_existing_branches() {
|
||||||
local specs_dir="$1"
|
local specs_dir="$1"
|
||||||
local skip_fetch="${2:-false}"
|
local skip_fetch="${2:-false}"
|
||||||
local scope_prefix="${3:-}"
|
|
||||||
|
|
||||||
if [ "$skip_fetch" = true ]; then
|
if [ "$skip_fetch" = true ]; then
|
||||||
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
|
local highest_remote=$(get_highest_from_remote_refs)
|
||||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
local highest_branch=$(get_highest_from_branches)
|
||||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||||
highest_branch=$highest_remote
|
highest_branch=$highest_remote
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
git fetch --all --prune >/dev/null 2>&1 || true
|
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
|
fi
|
||||||
|
|
||||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||||
@@ -291,152 +273,6 @@ fi
|
|||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
SPECS_DIR="$REPO_ROOT/specs"
|
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
|
# Function to generate branch name with stop word filtering
|
||||||
generate_branch_name() {
|
generate_branch_name() {
|
||||||
@@ -482,8 +318,18 @@ generate_branch_name() {
|
|||||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||||
FEATURE_NUM=$(extract_feature_num_from_branch "$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"
|
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
# Generate branch name
|
# Generate branch name
|
||||||
if [ -n "$SHORT_NAME" ]; then
|
if [ -n "$SHORT_NAME" ]; then
|
||||||
@@ -501,17 +347,16 @@ else
|
|||||||
# Determine branch prefix
|
# Determine branch prefix
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
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
|
else
|
||||||
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
|
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; 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
|
elif [ "$DRY_RUN" = true ]; then
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
elif [ "$HAS_GIT" = true ]; then
|
elif [ "$HAS_GIT" = true ]; then
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
else
|
else
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
@@ -519,7 +364,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
fi
|
fi
|
||||||
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."
|
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||||
exit 1
|
exit 1
|
||||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
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"
|
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||||
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
|
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_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] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||||
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
|
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||||
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||||
>&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
|
fi
|
||||||
|
|
||||||
if [ "$DRY_RUN" != true ]; then
|
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.
|
# Validate that a branch name matches the expected feature branch pattern.
|
||||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
|
# 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 scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||||
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
|
|
||||||
check_feature_branch() {
|
check_feature_branch() {
|
||||||
local raw="$1"
|
local raw="$1"
|
||||||
local has_git_repo="$2"
|
local has_git_repo="$2"
|
||||||
@@ -38,17 +37,16 @@ check_feature_branch() {
|
|||||||
|
|
||||||
local branch
|
local branch
|
||||||
branch=$(spec_kit_effective_branch_name "$raw")
|
branch=$(spec_kit_effective_branch_name "$raw")
|
||||||
local feature_segment="${branch##*/}"
|
|
||||||
|
|
||||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
# 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")
|
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||||
local is_sequential=false
|
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
|
is_sequential=true
|
||||||
fi
|
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 "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
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ if ($Help) {
|
|||||||
Write-Host "Environment variables:"
|
Write-Host "Environment variables:"
|
||||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||||
Write-Host ""
|
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
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,23 +67,11 @@ function Get-HighestNumberFromSpecs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Get-HighestNumberFromNames {
|
function Get-HighestNumberFromNames {
|
||||||
param(
|
param([string[]]$Names)
|
||||||
[string[]]$Names,
|
|
||||||
[string]$ScopePrefix = ''
|
|
||||||
)
|
|
||||||
|
|
||||||
[long]$highest = 0
|
[long]$highest = 0
|
||||||
foreach ($name in $Names) {
|
foreach ($name in $Names) {
|
||||||
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
|
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||||
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
|
[long]$num = 0
|
||||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||||
$highest = $num
|
$highest = $num
|
||||||
@@ -98,7 +82,7 @@ function Get-HighestNumberFromNames {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Get-HighestNumberFromBranches {
|
function Get-HighestNumberFromBranches {
|
||||||
param([string]$ScopePrefix = '')
|
param()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$branches = git branch -a 2>$null
|
$branches = git branch -a 2>$null
|
||||||
@@ -106,7 +90,7 @@ function Get-HighestNumberFromBranches {
|
|||||||
$cleanNames = $branches | ForEach-Object {
|
$cleanNames = $branches | ForEach-Object {
|
||||||
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
}
|
}
|
||||||
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
|
return Get-HighestNumberFromNames -Names $cleanNames
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Verbose "Could not check Git branches: $_"
|
Write-Verbose "Could not check Git branches: $_"
|
||||||
@@ -115,8 +99,6 @@ function Get-HighestNumberFromBranches {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Get-HighestNumberFromRemoteRefs {
|
function Get-HighestNumberFromRemoteRefs {
|
||||||
param([string]$ScopePrefix = '')
|
|
||||||
|
|
||||||
[long]$highest = 0
|
[long]$highest = 0
|
||||||
try {
|
try {
|
||||||
$remotes = git remote 2>$null
|
$remotes = git remote 2>$null
|
||||||
@@ -129,7 +111,7 @@ function Get-HighestNumberFromRemoteRefs {
|
|||||||
$refNames = $refs | ForEach-Object {
|
$refNames = $refs | ForEach-Object {
|
||||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||||
} | Where-Object { $_ }
|
} | Where-Object { $_ }
|
||||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
|
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,19 +125,18 @@ function Get-HighestNumberFromRemoteRefs {
|
|||||||
function Get-NextBranchNumber {
|
function Get-NextBranchNumber {
|
||||||
param(
|
param(
|
||||||
[string]$SpecsDir,
|
[string]$SpecsDir,
|
||||||
[switch]$SkipFetch,
|
[switch]$SkipFetch
|
||||||
[string]$ScopePrefix = ''
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($SkipFetch) {
|
if ($SkipFetch) {
|
||||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
git fetch --all --prune 2>$null | Out-Null
|
git fetch --all --prune 2>$null | Out-Null
|
||||||
} catch { }
|
} catch { }
|
||||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
}
|
}
|
||||||
|
|
||||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||||
@@ -251,145 +232,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
|||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
||||||
$specsDir = Join-Path $repoRoot 'specs'
|
$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 {
|
function Get-BranchName {
|
||||||
param([string]$Description)
|
param([string]$Description)
|
||||||
@@ -434,11 +276,19 @@ function Get-BranchName {
|
|||||||
if ($env:GIT_BRANCH_NAME) {
|
if ($env:GIT_BRANCH_NAME) {
|
||||||
$branchName = $env:GIT_BRANCH_NAME
|
$branchName = $env:GIT_BRANCH_NAME
|
||||||
# Check 244-byte limit (UTF-8) for override names
|
# 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) {
|
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."
|
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 {
|
} else {
|
||||||
if ($ShortName) {
|
if ($ShortName) {
|
||||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||||
@@ -453,41 +303,39 @@ if ($env:GIT_BRANCH_NAME) {
|
|||||||
|
|
||||||
if ($Timestamp) {
|
if ($Timestamp) {
|
||||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
} else {
|
} else {
|
||||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
|
||||||
if ($Number -eq 0) {
|
if ($Number -eq 0) {
|
||||||
if ($DryRun -and $hasGit) {
|
if ($DryRun -and $hasGit) {
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||||
} elseif ($DryRun) {
|
} elseif ($DryRun) {
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
} elseif ($hasGit) {
|
} elseif ($hasGit) {
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||||
} else {
|
} else {
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
$featureNum = ('{0:000}' -f $Number)
|
||||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxBranchLength = 244
|
$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
|
$originalBranchName = $branchName
|
||||||
$truncatedSuffix = $branchSuffix
|
$branchName = "$featureNum-$truncatedSuffix"
|
||||||
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] Branch name exceeded GitHub's 244-byte limit"
|
||||||
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||||
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
|
|||||||
@@ -37,15 +37,14 @@ function Test-FeatureBranch {
|
|||||||
|
|
||||||
$raw = $Branch
|
$raw = $Branch
|
||||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||||
$featureSegment = ($Branch -split '/')[-1]
|
|
||||||
|
|
||||||
# Accept sequential prefix (3+ digits), at the start or after namespace
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||||
# segments, 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 = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
|
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||||
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||||
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
|
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("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 $false
|
||||||
}
|
}
|
||||||
return $true
|
return $true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.12.6.dev0"
|
version = "0.12.4"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
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}://). "
|
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
|
||||||
"HTTP is only allowed for localhost."
|
"HTTP is only allowed for localhost."
|
||||||
)
|
)
|
||||||
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
if not parsed.netloc:
|
||||||
# "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(
|
raise BundlerError(
|
||||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
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.
|
Results are sorted by bundle id for deterministic output.
|
||||||
"""
|
"""
|
||||||
needle = query.strip().lower()
|
needle = query.strip().lower()
|
||||||
# Resolve each id to its highest-precedence entry FIRST, then filter by
|
seen: dict[str, ResolvedBundle] = {}
|
||||||
# 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 source in self._sources:
|
||||||
for bundle_id, entry in self._entries_for(source).items():
|
for bundle_id, entry in self._entries_for(source).items():
|
||||||
if bundle_id in resolved:
|
if bundle_id in seen:
|
||||||
continue
|
continue
|
||||||
resolved[bundle_id] = ResolvedBundle(
|
if needle and not _matches(entry, needle):
|
||||||
|
continue
|
||||||
|
seen[bundle_id] = ResolvedBundle(
|
||||||
entry=entry.with_provenance(source), source=source
|
entry=entry.with_provenance(source), source=source
|
||||||
)
|
)
|
||||||
return [
|
return [seen[k] for k in sorted(seen)]
|
||||||
resolved[k]
|
|
||||||
for k in sorted(resolved)
|
|
||||||
if not needle or _matches(resolved[k].entry, needle)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _matches(entry: CatalogEntry, needle: str) -> bool:
|
def _matches(entry: CatalogEntry, needle: str) -> bool:
|
||||||
|
|||||||
@@ -1178,18 +1178,12 @@ class YamlIntegration(IntegrationBase):
|
|||||||
default_flow_style=False,
|
default_flow_style=False,
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
# Indent the body for YAML block scalar. Use an explicit indentation
|
# Indent the body for YAML block scalar
|
||||||
# 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"))
|
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
header_yaml,
|
header_yaml,
|
||||||
"prompt: |2",
|
"prompt: |",
|
||||||
indented,
|
indented,
|
||||||
"",
|
"",
|
||||||
f"# Source: {source_id}",
|
f"# Source: {source_id}",
|
||||||
|
|||||||
@@ -75,15 +75,7 @@ class CursorAgentIntegration(SkillsIntegration):
|
|||||||
either drops tool calls or exits non-zero on the first approval
|
either drops tool calls or exits non-zero on the first approval
|
||||||
prompt.
|
prompt.
|
||||||
"""
|
"""
|
||||||
args = [
|
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
|
||||||
self._resolve_executable(),
|
|
||||||
"-p",
|
|
||||||
"--trust",
|
|
||||||
"--approve-mcps",
|
|
||||||
"--force",
|
|
||||||
prompt,
|
|
||||||
]
|
|
||||||
self._apply_extra_args_env_var(args)
|
|
||||||
if model:
|
if model:
|
||||||
args.extend(["--model", model])
|
args.extend(["--model", model])
|
||||||
if output_json:
|
if output_json:
|
||||||
|
|||||||
@@ -183,65 +183,6 @@ def _is_single_expression(stripped: str) -> bool:
|
|||||||
return True
|
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]:
|
def _split_top_level_commas(text: str) -> list[str]:
|
||||||
"""Split *text* on commas that are not inside quotes or nested brackets.
|
"""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)
|
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:
|
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
||||||
"""Compare two values for ordering, coercing numeric strings when possible.
|
"""Safely compare two values, coercing types when possible."""
|
||||||
|
try:
|
||||||
Numeric coercion is applied only when *both* operands look numeric, so a
|
if isinstance(left, str):
|
||||||
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either
|
left = float(left) if "." in left else int(left)
|
||||||
side is a non-numeric string, both fall back to their original values and
|
if isinstance(right, str):
|
||||||
are compared directly -- so ordinary strings (dates, semver-ish tags,
|
right = float(right) if "." in right else int(right)
|
||||||
names) compare lexicographically the way Python does, instead of every
|
except (ValueError, TypeError):
|
||||||
such comparison silently returning ``False`` after a failed int()/float()
|
return False
|
||||||
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:
|
try:
|
||||||
if op == ">":
|
if op == ">":
|
||||||
return left > right # type: ignore[operator]
|
return left > right # type: ignore[operator]
|
||||||
@@ -550,11 +472,12 @@ def evaluate_expression(template: str, context: Any) -> Any:
|
|||||||
if _is_single_expression(stripped):
|
if _is_single_expression(stripped):
|
||||||
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
|
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
|
||||||
|
|
||||||
# Multi-expression: interpolate each block inline. Uses a quote-aware scan
|
# Multi-expression: string interpolation
|
||||||
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument
|
def _replacer(m: re.Match[str]) -> str:
|
||||||
# in any block does not close that block early -- matching the handling the
|
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
|
||||||
# single-expression path above already got in #3208/#3228.
|
return str(val) if val is not None else ""
|
||||||
return _interpolate_expressions(template, namespace)
|
|
||||||
|
return _EXPR_PATTERN.sub(_replacer, template)
|
||||||
|
|
||||||
|
|
||||||
def evaluate_condition(condition: str, context: Any) -> bool:
|
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)
|
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||||
output["choice"] = choice
|
output["choice"] = choice
|
||||||
|
|
||||||
# Match rejection case-insensitively. ``_prompt`` echoes the option's
|
if choice in ("reject", "abort"):
|
||||||
# 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":
|
if on_reject == "abort":
|
||||||
output["aborted"] = True
|
output["aborted"] = True
|
||||||
return StepResult(
|
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."""
|
"""Run an extension PowerShell script."""
|
||||||
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
|
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(
|
return subprocess.run(
|
||||||
["pwsh", "-NoProfile", "-File", str(script), *args],
|
["pwsh", "-NoProfile", "-File", str(script), *args],
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
@@ -320,15 +320,6 @@ class TestCreateFeatureBash:
|
|||||||
assert rt.returncode == 0, rt.stderr
|
assert rt.returncode == 0, rt.stderr
|
||||||
assert "HAS_GIT" not in rt.stdout
|
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):
|
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
|
"""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
|
as an acronym in UPPERCASE in the description (case-sensitive, must match the
|
||||||
@@ -372,183 +363,6 @@ class TestCreateFeatureBash:
|
|||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
assert data["FEATURE_NUM"] == "003"
|
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):
|
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
|
||||||
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
||||||
project = _setup_project(tmp_path / "project")
|
project = _setup_project(tmp_path / "project")
|
||||||
@@ -670,15 +484,6 @@ class TestCreateFeaturePowerShell:
|
|||||||
assert rt.returncode == 0, rt.stderr
|
assert rt.returncode == 0, rt.stderr
|
||||||
assert "HAS_GIT" not in rt.stdout
|
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):
|
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
|
"""PowerShell must match the bash twin: a short word is dropped unless it
|
||||||
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
|
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
|
||||||
@@ -720,176 +525,6 @@ class TestCreateFeaturePowerShell:
|
|||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
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):
|
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||||
"""create-new-feature-branch.ps1 works without git."""
|
"""create-new-feature-branch.ps1 works without git."""
|
||||||
project = _setup_project(tmp_path, git=False)
|
project = _setup_project(tmp_path, git=False)
|
||||||
@@ -1376,31 +1011,13 @@ class TestGitCommonBash:
|
|||||||
)
|
)
|
||||||
assert result.returncode == 0
|
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)
|
project = _setup_project(tmp_path)
|
||||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
|
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
|
||||||
capture_output=True, text=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
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1420,33 +1037,3 @@ class TestGitCommonPowerShell:
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
assert result.returncode == 0
|
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"]
|
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 test_unreachable_source_raises_named_error():
|
||||||
def fetcher(src):
|
def fetcher(src):
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
|
|||||||
@@ -184,23 +184,6 @@ class YamlIntegrationTests:
|
|||||||
assert "scripts:" not in parsed["prompt"]
|
assert "scripts:" not in parsed["prompt"]
|
||||||
assert "---" 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):
|
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||||
"""The generated plan command must not carry a context-file placeholder.
|
"""The generated plan command must not carry a context-file placeholder.
|
||||||
|
|
||||||
|
|||||||
@@ -125,55 +125,6 @@ class TestCursorAgentCliDispatch:
|
|||||||
assert argv is not None
|
assert argv is not None
|
||||||
assert argv[0] == "cursor-agent"
|
assert argv[0] == "cursor-agent"
|
||||||
|
|
||||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
|
||||||
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE`` overrides argv[0].
|
|
||||||
|
|
||||||
Every other CLI-dispatch integration (codex, devin, ...) routes
|
|
||||||
argv[0] through ``_resolve_executable()`` so operators can pin a
|
|
||||||
binary path (issue #2596). cursor-agent hardcoded ``self.key`` and
|
|
||||||
silently ignored the documented override.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv(
|
|
||||||
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE", "/custom/cursor"
|
|
||||||
)
|
|
||||||
i = get_integration("cursor-agent")
|
|
||||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
|
||||||
assert args[0] == "/custom/cursor"
|
|
||||||
# The mandatory headless flags must still be present.
|
|
||||||
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
|
|
||||||
assert flag in args
|
|
||||||
|
|
||||||
def test_build_exec_args_honors_extra_args_override(self, monkeypatch):
|
|
||||||
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS`` flags are injected
|
|
||||||
*before* Spec Kit's canonical ``--model`` / ``--output-format`` flags.
|
|
||||||
|
|
||||||
The ``_apply_extra_args_env_var()`` hook (issue #2595) was never
|
|
||||||
invoked by cursor-agent, so operator-supplied flags were dropped.
|
|
||||||
Insertion order is the real contract: extra args must land after the
|
|
||||||
mandatory headless flags but before ``--model`` / ``--output-format``,
|
|
||||||
so they cannot clobber, displace, or reorder Spec Kit's canonical
|
|
||||||
trailing flags. Exercise with both a model and JSON output so both
|
|
||||||
canonical flags are present to pin against.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv(
|
|
||||||
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS", "--foo bar"
|
|
||||||
)
|
|
||||||
i = get_integration("cursor-agent")
|
|
||||||
args = i.build_exec_args(
|
|
||||||
"/speckit-plan", model="sonnet-4-thinking", output_json=True
|
|
||||||
)
|
|
||||||
assert "--foo" in args
|
|
||||||
assert "bar" in args
|
|
||||||
# "bar" is the value of "--foo": the tokens stay adjacent and in order.
|
|
||||||
assert args.index("bar") == args.index("--foo") + 1
|
|
||||||
# Extra args are inserted before the canonical flags, so they cannot
|
|
||||||
# clobber or reorder them (the behavioral contract this test guards).
|
|
||||||
assert args.index("--foo") < args.index("--model")
|
|
||||||
assert args.index("--foo") < args.index("--output-format")
|
|
||||||
# The canonical flags themselves remain intact and correctly paired.
|
|
||||||
assert args[args.index("--model") + 1] == "sonnet-4-thinking"
|
|
||||||
assert args[args.index("--output-format") + 1] == "json"
|
|
||||||
|
|
||||||
def test_build_command_invocation_uses_hyphenated_skill_name(self):
|
def test_build_command_invocation_uses_hyphenated_skill_name(self):
|
||||||
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
|
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
|
||||||
i = get_integration("cursor-agent")
|
i = get_integration("cursor-agent")
|
||||||
|
|||||||
@@ -260,60 +260,6 @@ class TestExpressions:
|
|||||||
ctx = StepContext(inputs={"text": "uses }} syntax"})
|
ctx = StepContext(inputs={"text": "uses }} syntax"})
|
||||||
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
|
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):
|
def test_comparison_equals(self):
|
||||||
from specify_cli.workflows.expressions import evaluate_expression
|
from specify_cli.workflows.expressions import evaluate_expression
|
||||||
from specify_cli.workflows.base import StepContext
|
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 True
|
||||||
assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False
|
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):
|
def test_boolean_and(self):
|
||||||
from specify_cli.workflows.expressions import evaluate_expression
|
from specify_cli.workflows.expressions import evaluate_expression
|
||||||
from specify_cli.workflows.base import StepContext
|
from specify_cli.workflows.base import StepContext
|
||||||
@@ -4060,48 +3977,6 @@ steps:
|
|||||||
assert state.status == RunStatus.ABORTED
|
assert state.status == RunStatus.ABORTED
|
||||||
assert "should-not-run" not in state.step_results
|
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):
|
def test_validation_rejects_non_bool_continue_on_error(self):
|
||||||
"""`continue_on_error` must be a literal boolean; coerced
|
"""`continue_on_error` must be a literal boolean; coerced
|
||||||
strings like `"true"` are rejected at validation time so
|
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)
|
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||||
fetcher(_source("https://example.com/c.json"))
|
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