From 9bd3512025885b00f20e5a39c9273c7cf32d4c57 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:57:50 -0500 Subject: [PATCH 001/134] chore: release 0.12.4, begin 0.12.5.dev0 development (#3305) * chore: bump version to 0.12.4 * chore: begin 0.12.5.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d700d4c26b..f6aa12e08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.12.4] - 2026-07-02 + +### Changed + +- feat(cli): add `py` script type & Python interpreter resolution (#3278) (#3285) +- fix: resolve GitHub release asset API URL for private repo bundle downloads (#3136) +- [extension] Add Analytics extension to community catalog (#3296) +- fix: interpolate multi-expression templates instead of returning None (#3208) (#3228) +- feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver (#3186) +- fix(extensions): resolve core-command dirs via _assets helpers (#3274) (#3287) +- fix: fall back to feature dir basename for empty CURRENT_BRANCH (#3026) (#3229) +- feat(bug-fix): add label-driven bug-fix agentic workflow (#3258) +- feat(workflows): add label-driven bug-test workflow (#3239) (#3257) +- chore: release 0.12.3, begin 0.12.4.dev0 development (#3295) + ## [0.12.3] - 2026-07-01 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 2f26d76ea6..fa27c228e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.12.4.dev0" +version = "0.12.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From 288bd679f362feac1b08637a659a93905be240fb Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 2 Jul 2026 18:40:30 +0500 Subject: [PATCH 002/134] docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291) * docs: drop stale kimi KIMI.md->AGENTS.md migration note #3097 made the agent-context extension a full opt-in and removed the KIMI.md -> AGENTS.md context migration from the kimi integration (_migrate_legacy_kimi_context_file and the context_file handling are gone). kimi's --migrate-legacy now only moves the skills directory. two lines in the integrations reference still promised the removed context migration; drop that clause so the docs match the code. * docs: clarify kimi legacy migration is skill naming, not directory names address review: the parenthetical said 'dotted->hyphenated directory names', but the migration is about skill naming (speckit.xxx -> speckit-xxx), matching the module docstring. reword to match. --- docs/reference/integrations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index b5ae740d45..f538fac86f 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -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 | | [Junie](https://junie.jetbrains.com/) | `junie` | | | [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, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` | +| [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 | | [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 | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | @@ -218,7 +218,7 @@ Some integrations accept additional options via `--integration-options`: | Integration | Option | Description | | ----------- | ------------------- | -------------------------------------------------------------- | | `generic` | `--commands-dir` | Required. Directory for command files | -| `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` | +| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) | Example: From bba473c223dc298fb76ecf8f87f86d5d2b8ed11c Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:49:53 +0500 Subject: [PATCH 003/134] fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(integrations): cursor-agent ignores executable/extra-args env overrides cursor-agent's build_exec_args() hardcoded self.key as argv[0] and never called _apply_extra_args_env_var(), so the documented SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE (issue #2596) and SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS (issue #2595) hooks were silently dropped — unlike every other CLI-dispatch integration (codex, devin). Route argv[0] through _resolve_executable() and apply the extra-args hook after the mandatory headless flags, mirroring the twins. Co-Authored-By: Claude Opus 4.8 * test(integrations): pin extra-args insertion order for cursor-agent Per Copilot feedback: the extra-args override test only asserted the injected tokens were present, not that they land before Spec Kit's canonical --model / --output-format flags. Exercise build_exec_args with both a model and JSON output and assert the extra args are inserted before --model / --output-format (and the canonical flags stay intact and paired). Verified this fails if the _apply_extra_args_env_var call is moved after the flag extends. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../integrations/cursor_agent/__init__.py | 10 +++- .../test_integration_cursor_agent.py | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index 2c328b2fda..07f2a6318b 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -75,7 +75,15 @@ def build_exec_args( either drops tool calls or exits non-zero on the first approval prompt. """ - args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt] + args = [ + self._resolve_executable(), + "-p", + "--trust", + "--approve-mcps", + "--force", + prompt, + ] + self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) if output_json: diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 32318dc90f..7b6822f7e3 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -125,6 +125,55 @@ def test_build_exec_args_supports_dispatch_without_requires_cli(self): assert argv is not None 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): """SkillsIntegration: /speckit-plan (not /speckit.plan).""" i = get_integration("cursor-agent") From 92b7cf7658a177cc417b7ddbeaa4c0a941a5f41b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:41:12 -0500 Subject: [PATCH 004/134] chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315) Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/9a946fdbd5fb07b82b2f5a4466058b876ab72bb2...26b0ec14cb23fa6904739307f278c14f94c95bf1) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1d987c7a46..3007f1032f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 # Fetch all history for git info - name: Setup .NET - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: dotnet-version: '8.x' From f494a8e33ef04598cf94fffa8c0c12c6e6101a48 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Mon, 6 Jul 2026 22:41:58 +0200 Subject: [PATCH 005/134] Support namespaced git feature branch templates (#3293) * test: cover namespaced git branch templates Assisted-by: Codex (model: GPT-5, autonomous) * feat: support namespaced git branch templates Assisted-by: Codex (model: GPT-5, autonomous) * test: cover git branch template edge cases Assisted-by: Codex (model: GPT-5, autonomous) * fix: harden git branch template parsing Assisted-by: Codex (model: GPT-5, autonomous) * fix: address git branch template review feedback Address Copilot review feedback for branch_prefix help text, namespaced GIT_BRANCH_NAME fallback behavior, final-segment validation docs, and Bash UTF-8 byte reporting. Assisted-by: Codex (model: GPT-5, autonomous) * fix: reject slug-scoped branch templates Reject branch templates that place {slug} before {number}, because that makes namespace scanning depend on the generated feature slug and can reset numbering per feature name. Assisted-by: Codex (model: GPT-5, autonomous) * fix: ignore malformed timestamp refs when numbering Align branch-number scanning with feature-branch validation so malformed timestamp-looking refs do not inflate sequential numbering. Also updates the stale git-common comment called out in review. Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/git/README.md | 16 +- .../git/commands/speckit.git.feature.md | 18 +- .../git/commands/speckit.git.validate.md | 12 +- extensions/git/config-template.yml | 10 + extensions/git/extension.yml | 6 +- extensions/git/git-config.yml | 10 + .../scripts/bash/create-new-feature-branch.sh | 224 ++++++++-- extensions/git/scripts/bash/git-common.sh | 12 +- .../powershell/create-new-feature-branch.ps1 | 218 +++++++-- .../git/scripts/powershell/git-common.ps1 | 13 +- tests/extensions/git/test_git_extension.py | 419 +++++++++++++++++- 11 files changed, 868 insertions(+), 90 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index e2c53fb769..b5df3e31ee 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti This extension provides Git operations as an optional, self-contained module. It manages: - **Repository initialization** with configurable commit messages -- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces - **Branch validation** to ensure branches follow naming conventions - **Git remote detection** for GitHub integration (e.g., issue creation) - **Auto-commit** after core commands (configurable per-command with custom messages) @@ -53,6 +53,16 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`: # Branch numbering strategy: "sequential" or "timestamp" branch_numbering: sequential +# Optional branch name template. Leave empty for the default "{number}-{slug}". +# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear +# before {number}, and the final path segment must start with {number}-. +# Example for monorepos: "{author}/{app}/{number}-{slug}" +branch_template: "" + +# Optional shorthand namespace. Leave empty to use branch_template/default behavior. +# Example: "features/{app}" expands to "features/{app}/{number}-{slug}" +branch_prefix: "" + # Custom commit message for git init init_commit_message: "[Spec Kit] Initial commit" @@ -65,6 +75,10 @@ auto_commit: message: "[Spec Kit] Add specification" ``` +`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. + +For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `/{number}-{slug}`. + ## Installation ```bash diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 27fdbd5f72..01f664f84b 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: - The script uses the exact value as the branch name, bypassing all prefix/suffix generation - `--short-name`, `--number`, and `--timestamp` flags are ignored -- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name +- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name ## Prerequisites @@ -35,6 +35,19 @@ Determine the branch numbering strategy by checking configuration in this order: 3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release) 4. Default to `sequential` if none of the above exist +## Branch Name Template + +Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens: + +- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part) +- `{app}`: sanitized Spec Kit init directory name +- `{number}`: sequential number or timestamp +- `{slug}`: generated short branch slug + +For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering. + +The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `/{number}-{slug}`. + ## Execution Generate a concise short name (2-4 words) for the branch: @@ -54,6 +67,7 @@ Run the appropriate script based on your platform: - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature - The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` +- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently ## Graceful Degradation @@ -64,5 +78,5 @@ If Git is not installed or the current directory is not a Git repository: ## Output The script outputs JSON with: -- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`) - `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md index dd84618cb8..c7feeb2600 100644 --- a/extensions/git/commands/speckit.git.validate.md +++ b/extensions/git/commands/speckit.git.validate.md @@ -22,24 +22,24 @@ Get the current branch name: git rev-parse --abbrev-ref HEAD ``` -The branch name must match one of these patterns: +The branch name's final path segment must start with one of these feature markers: -1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) -2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) +1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`) +2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`) ## Execution If on a feature branch (matches either pattern): - Output: `✓ On feature branch: ` - Check if the corresponding spec directory exists under `specs/`: - - For sequential branches, look for `specs/-*` where prefix matches the numeric portion - - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion, regardless of branch namespace prefixes + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes - If spec directory exists: `✓ Spec directory found: ` - If spec directory missing: `⚠ No spec directory found for prefix ` If NOT on a feature branch: - Output: `✗ Not on a feature branch. Current branch: ` -- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` +- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or /001-feature-name` ## Graceful Degradation diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index 8c414babe6..99e3d31692 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -4,6 +4,16 @@ # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) branch_numbering: sequential +# Optional branch name template. Leave empty for the default "{number}-{slug}". +# Supported tokens: {author}, {app}, {number}, {slug} +# {slug} must not appear before {number}; final path segment must start with {number}-. +# Example for monorepos: "{author}/{app}/{number}-{slug}" +branch_template: "" + +# Optional shorthand namespace. Leave empty to use branch_template/default behavior. +# Example: "features/{app}" expands to "features/{app}/{number}-{slug}" +branch_prefix: "" + # Commit message used by `git commit` during repository initialization init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 13c1977ea1..c92322d8b1 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -4,7 +4,7 @@ extension: id: git name: "Git Branching Workflow" version: "1.0.0" - description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit license: MIT @@ -19,7 +19,7 @@ provides: commands: - name: speckit.git.feature file: commands/speckit.git.feature.md - description: "Create a feature branch with sequential or timestamp numbering" + description: "Create a feature branch with sequential or timestamp numbering and optional templates" - name: speckit.git.validate file: commands/speckit.git.validate.md description: "Validate current branch follows feature branch naming conventions" @@ -137,4 +137,6 @@ tags: config: defaults: branch_numbering: sequential + branch_template: "" + branch_prefix: "" init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index 8c414babe6..99e3d31692 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -4,6 +4,16 @@ # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) branch_numbering: sequential +# Optional branch name template. Leave empty for the default "{number}-{slug}". +# Supported tokens: {author}, {app}, {number}, {slug} +# {slug} must not appear before {number}; final path segment must start with {number}-. +# Example for monorepos: "{author}/{app}/{number}-{slug}" +branch_template: "" + +# Optional shorthand namespace. Leave empty to use branch_template/default behavior. +# Example: "features/{app}" expands to "features/{app}/{number}-{slug}" +branch_prefix: "" + # Commit message used by `git commit` during repository initialization init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index c6e4e0668f..856cb0bec4 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -75,6 +75,10 @@ while [ $i -le $# ]; do echo "Environment variables:" echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" echo "" + echo "Configuration:" + echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" + echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}" + echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" @@ -127,16 +131,28 @@ get_highest_from_specs() { # Function to get highest number from git branches get_highest_from_branches() { - git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number + local scope_prefix="${1:-}" + git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix" } # Extract the highest sequential feature number from a list of ref names (one per line). _extract_highest_number() { + local scope_prefix="${1:-}" local highest=0 while IFS= read -r name; do [ -z "$name" ] && continue - if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + if [ -n "$scope_prefix" ]; then + case "$name" in + "$scope_prefix"*) name="${name#"$scope_prefix"}" ;; + *) continue ;; + esac + fi + name="${name##*/}" + if echo "$name" | grep -Eq '^[0-9]{3,}-' \ + && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \ + && ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \ + && ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then + number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -148,11 +164,12 @@ _extract_highest_number() { # Function to get highest number from remote branches without fetching (side-effect-free) get_highest_from_remote_refs() { + local scope_prefix="${1:-}" local highest=0 for remote in $(git remote 2>/dev/null); do local remote_highest - remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix") if [ "$remote_highest" -gt "$highest" ]; then highest=$remote_highest fi @@ -165,16 +182,17 @@ get_highest_from_remote_refs() { check_existing_branches() { local specs_dir="$1" local skip_fetch="${2:-false}" + local scope_prefix="${3:-}" if [ "$skip_fetch" = true ]; then - local highest_remote=$(get_highest_from_remote_refs) - local highest_branch=$(get_highest_from_branches) + local highest_remote=$(get_highest_from_remote_refs "$scope_prefix") + local highest_branch=$(get_highest_from_branches "$scope_prefix") if [ "$highest_remote" -gt "$highest_branch" ]; then highest_branch=$highest_remote fi else git fetch --all --prune >/dev/null 2>&1 || true - local highest_branch=$(get_highest_from_branches) + local highest_branch=$(get_highest_from_branches "$scope_prefix") fi local highest_spec=$(get_highest_from_specs "$specs_dir") @@ -273,6 +291,152 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" +CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml" + +read_git_config_value() { + local key="$1" + [ -f "$CONFIG_FILE" ] || return 0 + grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \ + | sed -E 's/[[:space:]]+#.*$//' \ + | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \ + | sed -E 's/^"//; s/"$//' \ + | sed -E "s/^'//; s/'$//" +} + +branch_token() { + local value="$1" + local fallback="$2" + local cleaned + cleaned=$(clean_branch_name "$value") + if [ -n "$cleaned" ]; then + printf '%s\n' "$cleaned" + else + printf '%s\n' "$fallback" + fi +} + +get_author_token() { + local author="" + if command -v git >/dev/null 2>&1; then + author=$(git config user.name 2>/dev/null || true) + if [ -z "$author" ]; then + author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true) + fi + fi + if [ -z "$author" ]; then + author="${USER:-unknown}" + fi + branch_token "$author" "unknown" +} + +get_app_token() { + branch_token "$(basename "$REPO_ROOT")" "app" +} + +resolve_branch_template() { + local template + local prefix + template=$(read_git_config_value "branch_template") + if [ -n "$template" ]; then + printf '%s\n' "$template" + return + fi + + prefix=$(read_git_config_value "branch_prefix") + if [ -z "$prefix" ]; then + printf '%s\n' "" + return + fi + case "$prefix" in + */) printf '%s%s\n' "$prefix" "{number}-{slug}" ;; + *) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;; + esac +} + +render_branch_template() { + local template="$1" + local feature_num="$2" + local branch_suffix="$3" + local rendered="$template" + rendered=${rendered//\{author\}/$AUTHOR_TOKEN} + rendered=${rendered//\{app\}/$APP_TOKEN} + rendered=${rendered//\{number\}/$feature_num} + rendered=${rendered//\{slug\}/$branch_suffix} + printf '%s\n' "$rendered" +} + +validate_branch_template() { + local template="$1" + [ -n "$template" ] || return 0 + local feature_segment + feature_segment="${template##*/}" + case "$template" in + *"{number}"*) ;; + *) + >&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches." + exit 1 + ;; + esac + case "$template" in + *"{slug}"*"{number}"*) + >&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment." + exit 1 + ;; + esac + case "$feature_segment" in + "{number}-"*) ;; + *) + >&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches." + exit 1 + ;; + esac +} + +build_branch_name() { + local feature_num="$1" + local branch_suffix="$2" + if [ -n "$BRANCH_TEMPLATE" ]; then + render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix" + else + printf '%s-%s\n' "$feature_num" "$branch_suffix" + fi +} + +branch_scope_prefix() { + local template="$1" + local prefix="$template" + [ -n "$prefix" ] || return 0 + case "$prefix" in + *"{number}"*) prefix="${prefix%%\{number\}*}" ;; + *"{slug}"*) prefix="${prefix%%\{slug\}*}" ;; + *) return 0 ;; + esac + render_branch_template "$prefix" "" "$BRANCH_SUFFIX" +} + +extract_feature_num_from_branch() { + local branch_name="$1" + local feature_segment="${branch_name##*/}" + local match + match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true) + if [ -n "$match" ]; then + printf '%s\n' "$match" | sed -E 's/-$//' + return + fi + match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true) + if [ -n "$match" ]; then + printf '%s\n' "$match" | sed -E 's/-$//' + return + fi + printf '%s\n' "$branch_name" +} + +AUTHOR_TOKEN=$(get_author_token) +APP_TOKEN=$(get_app_token) +BRANCH_TEMPLATE=$(resolve_branch_template) +validate_branch_template "$BRANCH_TEMPLATE" # Function to generate branch name with stop word filtering generate_branch_name() { @@ -318,18 +482,8 @@ generate_branch_name() { # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) if [ -n "${GIT_BRANCH_NAME:-}" ]; then BRANCH_NAME="$GIT_BRANCH_NAME" - # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix - # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern - if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') - BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" - elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then - FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') - BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" - else - FEATURE_NUM="$BRANCH_NAME" - BRANCH_SUFFIX="$BRANCH_NAME" - fi + FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME") + BRANCH_SUFFIX="$BRANCH_NAME" else # Generate branch name if [ -n "$SHORT_NAME" ]; then @@ -347,16 +501,17 @@ else # Determine branch prefix if [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") else + BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE") if [ -z "$BRANCH_NUMBER" ]; then if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX") elif [ "$DRY_RUN" = true ]; then HIGHEST=$(get_highest_from_specs "$SPECS_DIR") BRANCH_NUMBER=$((HIGHEST + 1)) elif [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX") else HIGHEST=$(get_highest_from_specs "$SPECS_DIR") BRANCH_NUMBER=$((HIGHEST + 1)) @@ -364,7 +519,7 @@ else fi FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") fi fi @@ -376,18 +531,23 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." exit 1 elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then - PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) - - TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) - TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + TRUNCATED_SUFFIX="$BRANCH_SUFFIX" + while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do + TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}" + TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX") + done + if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then + >&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit." + exit 1 + fi >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" - >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" - >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" + ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME") + TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)" fi if [ "$DRY_RUN" != true ]; then diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index b78356d1c6..60bb79c867 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -23,8 +23,9 @@ spec_kit_effective_branch_name() { } # Validate that a branch name matches the expected feature branch pattern. -# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. -# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats, +# either at the start of the branch or after path-style namespace prefixes. +# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin. check_feature_branch() { local raw="$1" local has_git_repo="$2" @@ -37,16 +38,17 @@ check_feature_branch() { local branch branch=$(spec_kit_effective_branch_name "$raw") + local feature_segment="${branch##*/}" # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") local is_sequential=false - if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then is_sequential=true fi - if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 - echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name" >&2 return 1 fi diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 0439ec80ad..68983db63c 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -34,6 +34,10 @@ if ($Help) { Write-Host "Environment variables:" Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" Write-Host "" + Write-Host "Configuration:" + Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" + Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}" + Write-Host "" exit 0 } @@ -67,11 +71,23 @@ function Get-HighestNumberFromSpecs { } function Get-HighestNumberFromNames { - param([string[]]$Names) + param( + [string[]]$Names, + [string]$ScopePrefix = '' + ) [long]$highest = 0 foreach ($name in $Names) { - if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) { + continue + } + if ($ScopePrefix) { + $name = $name.Substring($ScopePrefix.Length) + } + $name = ($name -split '/')[-1] + $hasTimestampPrefix = $name -match '^\d{8}-\d{6}-' + $hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$') + if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { $highest = $num @@ -82,7 +98,7 @@ function Get-HighestNumberFromNames { } function Get-HighestNumberFromBranches { - param() + param([string]$ScopePrefix = '') try { $branches = git branch -a 2>$null @@ -90,7 +106,7 @@ function Get-HighestNumberFromBranches { $cleanNames = $branches | ForEach-Object { $_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', '' } - return Get-HighestNumberFromNames -Names $cleanNames + return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix } } catch { Write-Verbose "Could not check Git branches: $_" @@ -99,6 +115,8 @@ function Get-HighestNumberFromBranches { } function Get-HighestNumberFromRemoteRefs { + param([string]$ScopePrefix = '') + [long]$highest = 0 try { $remotes = git remote 2>$null @@ -111,7 +129,7 @@ function Get-HighestNumberFromRemoteRefs { $refNames = $refs | ForEach-Object { if ($_ -match 'refs/heads/(.+)$') { $matches[1] } } | Where-Object { $_ } - $remoteHighest = Get-HighestNumberFromNames -Names $refNames + $remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix if ($remoteHighest -gt $highest) { $highest = $remoteHighest } } } @@ -125,18 +143,19 @@ function Get-HighestNumberFromRemoteRefs { function Get-NextBranchNumber { param( [string]$SpecsDir, - [switch]$SkipFetch + [switch]$SkipFetch, + [string]$ScopePrefix = '' ) if ($SkipFetch) { - $highestBranch = Get-HighestNumberFromBranches - $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix + $highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix $highestBranch = [Math]::Max($highestBranch, $highestRemote) } else { try { git fetch --all --prune 2>$null | Out-Null } catch { } - $highestBranch = Get-HighestNumberFromBranches + $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix } $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir @@ -232,6 +251,145 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" + +function Read-GitConfigValue { + param([string]$Key) + + if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' } + $escapedKey = [regex]::Escape($Key) + foreach ($line in Get-Content -LiteralPath $configFile) { + if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") { + $val = ($matches[1] -replace '\s+#.*$', '').Trim() + $val = $val -replace '^["'']', '' -replace '["'']$', '' + return $val + } + } + return '' +} + +function ConvertTo-BranchToken { + param( + [string]$Value, + [string]$Fallback + ) + + $cleaned = ConvertTo-CleanBranchName -Name $Value + if ($cleaned) { return $cleaned } + return $Fallback +} + +function Get-GitAuthorToken { + $author = '' + if (Get-Command git -ErrorAction SilentlyContinue) { + try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {} + if (-not $author) { + try { + $email = (git config user.email 2>$null | Out-String).Trim() + if ($email) { $author = ($email -split '@')[0] } + } catch {} + } + } + if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } } + return ConvertTo-BranchToken -Value $author -Fallback 'unknown' +} + +function Get-AppToken { + return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app' +} + +function Resolve-BranchTemplate { + $template = Read-GitConfigValue -Key 'branch_template' + if ($template) { return $template } + + $prefix = Read-GitConfigValue -Key 'branch_prefix' + if (-not $prefix) { return '' } + if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" } + return "$prefix/{number}-{slug}" +} + +function Expand-BranchTemplate { + param( + [string]$Template, + [string]$FeatureNum, + [string]$BranchSuffix + ) + + $rendered = $Template.Replace('{author}', $authorToken) + $rendered = $rendered.Replace('{app}', $appToken) + $rendered = $rendered.Replace('{number}', $FeatureNum) + $rendered = $rendered.Replace('{slug}', $BranchSuffix) + return $rendered +} + +function Assert-BranchTemplateValid { + param([string]$Template) + + if ($Template -and -not $Template.Contains('{number}')) { + throw "branch_template must include the {number} token so generated branches remain valid feature branches." + } + if ($Template) { + $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal) + $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal) + if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) { + throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment." + } + $featureSegment = ($Template -split '/')[-1] + if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) { + throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches." + } + } +} + +function New-BranchName { + param( + [string]$FeatureNum, + [string]$BranchSuffix + ) + + if ($branchTemplate) { + return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix + } + return "$FeatureNum-$BranchSuffix" +} + +function Get-BranchScopePrefix { + param( + [string]$Template, + [string]$BranchSuffix + ) + + if (-not $Template) { return '' } + $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal) + $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal) + $indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object + if (-not $indexes) { return '' } + $prefix = $Template.Substring(0, $indexes[0]) + return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix +} + +function Get-FeatureNumberFromBranchName { + param([string]$BranchName) + + $featureSegment = ($BranchName -split '/')[-1] + if ($featureSegment -match '^(\d{8}-\d{6})-') { + return $matches[1] + } + if ($featureSegment -match '^(\d+)-') { + return $matches[1] + } + return $BranchName +} + +function Get-Utf8ByteCount { + param([string]$Value) + return [System.Text.Encoding]::UTF8.GetByteCount($Value) +} + +$authorToken = Get-GitAuthorToken +$appToken = Get-AppToken +$branchTemplate = Resolve-BranchTemplate +Assert-BranchTemplateValid -Template $branchTemplate function Get-BranchName { param([string]$Description) @@ -276,19 +434,11 @@ function Get-BranchName { if ($env:GIT_BRANCH_NAME) { $branchName = $env:GIT_BRANCH_NAME # Check 244-byte limit (UTF-8) for override names - $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + $branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName if ($branchNameUtf8ByteCount -gt 244) { throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." } - # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix - # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern - if ($branchName -match '^(\d{8}-\d{6})-') { - $featureNum = $matches[1] - } elseif ($branchName -match '^(\d+)-') { - $featureNum = $matches[1] - } else { - $featureNum = $branchName - } + $featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName } else { if ($ShortName) { $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName @@ -303,39 +453,41 @@ if ($env:GIT_BRANCH_NAME) { if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } else { + $branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix if ($Number -eq 0) { if ($DryRun -and $hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix } elseif ($DryRun) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } elseif ($hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir + $Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix } else { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } } $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } } $maxBranchLength = 244 -if ($branchName.Length -gt $maxBranchLength) { - $prefixLength = $featureNum.Length + 1 - $maxSuffixLength = $maxBranchLength - $prefixLength - - $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) - $truncatedSuffix = $truncatedSuffix -replace '-$', '' - +if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" + $truncatedSuffix = $branchSuffix + while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) { + $truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', '' + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix + } + if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { + throw "Branch template prefix exceeds GitHub's 244-byte branch name limit." + } Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" - Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" - Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" + Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)" } if (-not $DryRun) { diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 13ea7542c4..a7ea724a31 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -37,14 +37,15 @@ function Test-FeatureBranch { $raw = $Branch $Branch = Get-SpecKitEffectiveBranchName $raw + $featureSegment = ($Branch -split '/')[-1] - # Accept sequential prefix (3+ digits) but exclude malformed timestamps - # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") - $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') - $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + # Accept sequential prefix (3+ digits), at the start or after namespace + # segments, but exclude malformed timestamps. + $hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') { [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") - [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name") return $false } return $true diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 2f53854d82..71dfd02223 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = ) -def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess: +def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: """Run an extension PowerShell script.""" script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name - env = {**os.environ, **_GIT_ENV} + env = {**os.environ, **_GIT_ENV, **(env_extra or {})} return subprocess.run( ["pwsh", "-NoProfile", "-File", str(script), *args], cwd=cwd, @@ -320,6 +320,15 @@ def test_output_omits_has_git_for_parity(self, tmp_path: Path): assert rt.returncode == 0, rt.stderr assert "HAS_GIT" not in rt.stdout + def test_help_documents_branch_prefix(self, tmp_path: Path): + """--help documents both template config knobs.""" + project = _setup_project(tmp_path) + result = _run_bash("create-new-feature-branch.sh", project, "--help") + + assert result.returncode == 0 + assert "branch_template" in result.stdout + assert "branch_prefix" in result.stdout + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): """A short word is dropped from the derived branch name unless it appears as an acronym in UPPERCASE in the description (case-sensitive, must match the @@ -363,6 +372,183 @@ def test_increments_from_existing_specs(self, tmp_path: Path): data = json.loads(result.stdout) assert data["FEATURE_NUM"] == "003" + def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path): + """branch_template namespaces generated branch names for monorepos.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path): + """branch_prefix expands to a namespace before the default branch shape.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_prefix: "features/{app}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path): + """Numeric-looking namespace segments must not be parsed as feature numbers.""" + project = _setup_project(tmp_path / "2026-app") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path): + """Malformed timestamp-looking branches must not inflate sequential numbering.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): + """Templated branch numbering ignores branches outside the current namespace.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_branch_template_requires_number_token(self, tmp_path: Path): + """Configured templates must include {number} so generated branches validate.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/{slug}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}" in result.stderr + + def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path): + """Templates must render a final path segment that validation accepts.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}-" in result.stderr + + def test_branch_template_rejects_slug_before_number(self, tmp_path: Path): + """{slug} before {number} would make branch-number scanning slug-specific.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{slug}" in result.stderr + assert "{number}" in result.stderr + + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): + """GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" + project = _setup_project(tmp_path / "app-a") + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path): + """GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments.""" + project = _setup_project(tmp_path / "2026-app") + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path): + """GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM.""" + project = _setup_project(tmp_path / "app-a") + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch" + assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch" + + def test_truncation_warning_reports_utf8_bytes(self): + """Bash truncation warnings should use the same byte counter as enforcement.""" + source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8") + + assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source + assert '_byte_length "$BRANCH_NAME"' in source + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): """Branches checked out in sibling worktrees still reserve their prefix.""" project = _setup_project(tmp_path / "project") @@ -484,6 +670,15 @@ def test_output_omits_has_git_to_match_bash(self, tmp_path: Path): assert rt.returncode == 0, rt.stderr assert "HAS_GIT" not in rt.stdout + def test_help_documents_branch_prefix(self, tmp_path: Path): + """-Help documents both template config knobs.""" + project = _setup_project(tmp_path) + result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help") + + assert result.returncode == 0 + assert "branch_template" in result.stdout + assert "branch_prefix" in result.stdout + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): """PowerShell must match the bash twin: a short word is dropped unless it appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match).""" @@ -525,6 +720,176 @@ def test_creates_branch_timestamp(self, tmp_path: Path): data = json.loads(result.stdout) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) + def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path): + """PowerShell supports branch_template namespaces.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path): + """PowerShell supports branch_prefix shorthand namespaces.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_prefix: "features/{app}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path): + """PowerShell ignores numeric-looking namespace segments when numbering.""" + project = _setup_project(tmp_path / "2026-app") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path): + """PowerShell skips malformed timestamp-looking refs during sequential numbering.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): + """PowerShell templated numbering ignores branches outside the namespace.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_branch_template_requires_number_token(self, tmp_path: Path): + """PowerShell rejects templates without {number}.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/{slug}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}" in result.stderr + + def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path): + """PowerShell rejects templates whose final segment cannot validate.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}-" in result.stderr + + def test_branch_template_rejects_slug_before_number(self, tmp_path: Path): + """PowerShell rejects templates where {slug} scopes number scanning.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{slug}" in result.stderr + assert "{number}" in result.stderr + + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): + """PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" + project = _setup_project(tmp_path / "app-a") + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path): + """PowerShell GIT_BRANCH_NAME ignores numeric namespace segments.""" + project = _setup_project(tmp_path / "2026-app") + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path): + """PowerShell keeps the full override name when no feature marker exists.""" + project = _setup_project(tmp_path / "app-a") + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch" + assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch" + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.ps1 works without git.""" project = _setup_project(tmp_path, git=False) @@ -1011,13 +1376,31 @@ def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): ) assert result.returncode == 0 - def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path): + def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path): project = _setup_project(tmp_path) script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" result = subprocess.run( ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'], capture_output=True, text=True, ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'], + capture_output=True, text=True, + ) assert result.returncode != 0 @@ -1037,3 +1420,33 @@ def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): text=True, ) assert result.returncode == 0 + + def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode != 0 From 3b4e7f3cb6067c26bd2d4a5cb082f078a0ee55bc Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 7 Jul 2026 01:46:35 +0500 Subject: [PATCH 006/134] fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307) * fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates #3208/#3228 hardened the single-expression fast path (_is_single_expression) so a literal {{ or }} inside a string argument like `| default('}}')` stays on the typed path. the multi-expression interpolation path was left on the old _EXPR_PATTERN regex, whose non-greedy `(.+?)}}` body stops at the first }} regardless of quoting. so a multi-expression template with a literal }} in any block captured a truncated body, hit the filter parser malformed, and raised ValueError. e.g. `{{ inputs.name }}: {{ inputs.missing | default('}}') }}` raised instead of interpolating. replace _EXPR_PATTERN.sub with _interpolate_expressions, which scans each block for a }} outside string literals - the same quote handling _is_single_expression already uses. plain-value passthrough (a literal }} in a resolved value, not an expression) is unchanged. add regression tests for a literal }} in the second block and in the first block, plus a literal {{ guard. * fix(workflows): surface malformed templates in interpolation instead of emitting verbatim address copilot review on #3307: when the quote-aware scan finds no block-closing `}}` (e.g. an unbalanced quote in a filter arg swallowed the delimiter), fall back to the first raw `}}` in the tail and evaluate it, so the parser raises ValueError just as the old _EXPR_PATTERN.sub path did. only when there is no `}}` at all is the tail left verbatim (a genuinely unterminated `{{`, which the regex also could not match). keeps a typo failing loudly rather than being silently hidden. add a regression test for an unbalanced quote in a multi-expression template. --- src/specify_cli/workflows/expressions.py | 70 ++++++++++++++++++++++-- tests/test_workflows.py | 54 ++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index cc63be523c..35f6486b2c 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -183,6 +183,65 @@ def _is_single_expression(stripped: str) -> bool: return True +def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: + """Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware. + + Walks the template and, for each block, finds the closing ``}}`` that lies + outside string literals -- the same quote-scanning used by + ``_is_single_expression``. This keeps a literal ``}}`` inside a string + argument (e.g. ``| default('}}')``) from prematurely closing a block. + + ``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first + ``}}`` regardless of quoting, so in a multi-expression template any block + whose argument contains a literal ``}}`` is captured truncated and mis-parsed + (raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly + this for the single-expression fast path but left the interpolation path on + the old regex. + """ + out: list[str] = [] + i = 0 + n = len(template) + while i < n: + start = template.find("{{", i) + if start == -1: + out.append(template[i:]) + break + out.append(template[i:start]) + # Scan for the block-closing ``}}`` that is outside any string literal. + j = start + 2 + quote: str | None = None + close = -1 + while j < n: + ch = template[j] + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch == "}" and j + 1 < n and template[j + 1] == "}": + close = j + break + j += 1 + if close == -1: + # No quote-aware close. Two sub-cases, both kept identical to the old + # regex so a malformed template is never silently hidden: + # * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote + # in a filter arg swallowed the real delimiter) -- fall back to + # that first raw ``}}`` and evaluate, letting the parser surface + # a ValueError just as ``_EXPR_PATTERN.sub`` would have. + # * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the + # tail verbatim, again matching the regex (which cannot match). + raw_close = template.find("}}", start + 2) + if raw_close == -1: + out.append(template[start:]) + break + close = raw_close + val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace) + out.append(str(val) if val is not None else "") + i = close + 2 + return "".join(out) + + def _split_top_level_commas(text: str) -> list[str]: """Split *text* on commas that are not inside quotes or nested brackets. @@ -472,12 +531,11 @@ def evaluate_expression(template: str, context: Any) -> Any: if _is_single_expression(stripped): return _evaluate_simple_expression(stripped[2:-2].strip(), namespace) - # Multi-expression: string interpolation - def _replacer(m: re.Match[str]) -> str: - val = _evaluate_simple_expression(m.group(1).strip(), namespace) - return str(val) if val is not None else "" - - return _EXPR_PATTERN.sub(_replacer, template) + # Multi-expression: interpolate each block inline. Uses a quote-aware scan + # (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument + # in any block does not close that block early -- matching the handling the + # single-expression path above already got in #3208/#3228. + return _interpolate_expressions(template, namespace) def evaluate_condition(condition: str, context: Any) -> bool: diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 2fdbf887b3..c0d8d22374 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -260,6 +260,60 @@ def test_single_expression_with_literal_braces_preserves_type(self): ctx = StepContext(inputs={"text": "uses }} syntax"}) assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True + def test_multi_expression_with_literal_close_brace_in_argument(self): + """A multi-expression template with a literal ``}}`` inside a string + argument must interpolate, not raise. #3208/#3228 hardened the single- + expression fast path for literal braces but left the interpolation path + on ``_EXPR_PATTERN``, whose non-greedy body stops at the first ``}}`` -- + so the block was captured truncated and the filter parser raised + ValueError.""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "Bob", "missing": None}) + # ``}}`` in the default fallback of the second block. + result = evaluate_expression( + "{{ inputs.name }}: {{ inputs.missing | default('}}') }}", ctx + ) + assert result == "Bob: }}" + # ``}}`` in the first block, expression following it. + result = evaluate_expression( + "{{ inputs.missing | default('}}') }} / {{ inputs.name }}", ctx + ) + assert result == "}} / Bob" + + def test_multi_expression_with_literal_open_brace_in_argument(self): + """A literal ``{{`` inside a string argument in a multi-expression + template must not confuse block detection either.""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "Bob", "missing": None}) + result = evaluate_expression( + "{{ inputs.name }} {{ inputs.missing | default('{{') }}", ctx + ) + assert result == "Bob {{" + + def test_multi_expression_unbalanced_quote_still_raises(self): + """A malformed block (an unbalanced quote in a filter arg) must still + surface a ValueError, not be silently emitted verbatim. + + The quote-aware scan never finds a block-closing ``}}`` when a quote is + left open, but a raw ``}}`` is still present in the tail. It must fall + back to that raw delimiter and evaluate — same as the old regex path — + so a typo fails loudly instead of being hidden (Copilot review on + #3307).""" + import pytest + + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "Bob", "missing": None}) + with pytest.raises(ValueError): + evaluate_expression( + "{{ inputs.name }} {{ inputs.missing | default('oops }}", ctx + ) + def test_comparison_equals(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext From 44c112c8077c1d655a5f9e19107d6dd4a2b54c63 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 7 Jul 2026 02:20:45 +0500 Subject: [PATCH 007/134] fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323) _safe_compare coerced both operands to int/float unconditionally for <, >, <=, >=. any non-numeric string (an iso date, a version tag, a name) failed that coercion and the whole comparison silently returned False -- so `{{ inputs.d < '2026-02-01' }}` was False even when the date was earlier. only coerce when both operands look numeric; otherwise compare the original values, so two strings order lexicographically the way python does and two numeric strings still compare as numbers ("10" > "9"). a number vs a non-numeric string stays incomparable and yields False. add a regression test covering dates, plain strings, numeric strings, and the number-vs-string case. --- src/specify_cli/workflows/expressions.py | 35 ++++++++++++++++++------ tests/test_workflows.py | 29 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 35f6486b2c..2b7ceca151 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -467,15 +467,34 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: return _resolve_dot_path(namespace, expr) +def _coerce_number(value: Any) -> Any: + """Return *value* as int/float if it is a numeric string, else unchanged.""" + if isinstance(value, str): + try: + return float(value) if "." in value else int(value) + except ValueError: + return value + return value + + def _safe_compare(left: Any, right: Any, op: str) -> bool: - """Safely compare two values, coercing types when possible.""" - try: - if isinstance(left, str): - left = float(left) if "." in left else int(left) - if isinstance(right, str): - right = float(right) if "." in right else int(right) - except (ValueError, TypeError): - return False + """Compare two values for ordering, coercing numeric strings when possible. + + Numeric coercion is applied only when *both* operands look numeric, so a + pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either + side is a non-numeric string, both fall back to their original values and + are compared directly -- so ordinary strings (dates, semver-ish tags, + names) compare lexicographically the way Python does, instead of every + such comparison silently returning ``False`` after a failed int()/float() + coercion. A genuinely incomparable pair (e.g. number vs non-numeric string) + raises ``TypeError`` and yields ``False``. + """ + cl, cr = _coerce_number(left), _coerce_number(right) + # Only use the coerced numbers when both converted; otherwise a numeric + # string paired with a plain string would become an int-vs-str mismatch + # (always False) rather than a lexicographic string comparison. + if isinstance(cl, (int, float)) and isinstance(cr, (int, float)): + left, right = cl, cr try: if op == ">": return left > right # type: ignore[operator] diff --git a/tests/test_workflows.py b/tests/test_workflows.py index c0d8d22374..8fcf5b49c7 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -342,6 +342,35 @@ def test_numeric_comparison(self): assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False + def test_ordering_comparison_of_non_numeric_strings(self): + """`<`/`>`/`<=`/`>=` between non-numeric strings must compare + lexicographically, not silently return False. + + `_safe_compare` used to coerce both operands to int/float unconditionally; + a non-numeric string (date, version tag, name) failed that coercion and + the whole comparison returned False. Ordinary strings should order the + way Python does; numeric strings must still compare as numbers.""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + # ISO dates compare lexicographically (correct chronological order). + ctx = StepContext(inputs={"d": "2026-01-01"}) + assert evaluate_expression("{{ inputs.d < '2026-02-01' }}", ctx) is True + assert evaluate_expression("{{ inputs.d > '2026-02-01' }}", ctx) is False + + # Plain string ordering. + ctx = StepContext(inputs={"name": "beta"}) + assert evaluate_expression("{{ inputs.name > 'alpha' }}", ctx) is True + + # Two numeric strings still compare numerically, not lexically + # ("10" > "9" is True as numbers; as strings it would be False). + ctx = StepContext(inputs={"v": "10"}) + assert evaluate_expression("{{ inputs.v > '9' }}", ctx) is True + + # A number vs a non-numeric string is genuinely incomparable -> False. + ctx = StepContext(inputs={"n": 5}) + assert evaluate_expression("{{ inputs.n > 'abc' }}", ctx) is False + def test_boolean_and(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext From b5f1194168e70cf428716c3b3c26bee4a68281d9 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 7 Jul 2026 02:42:45 +0500 Subject: [PATCH 008/134] fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331) CatalogStack.search() claimed a bundle id in `seen` only when the entry matched the query. so when the highest-precedence entry for an id did NOT match, a lower-precedence entry with the same id could match and be returned instead -- even though resolve()/install always use the highest-precedence entry. search advertised a bundle (name, version, source) the user could never actually get, contradicting the method's own docstring ("resolved at its highest-precedence source"). resolve every id to its highest-precedence entry first, then filter the resolved set by the query. search now agrees with resolve(): a query that only a shadowed lower-precedence copy matches returns nothing. add a regression test covering the shadowed-entry case. --- .../bundler/services/catalog_stack.py | 20 ++++++--- .../integration/test_bundler_catalog_stack.py | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/bundler/services/catalog_stack.py b/src/specify_cli/bundler/services/catalog_stack.py index d1e7fddde9..a6c1d23522 100644 --- a/src/specify_cli/bundler/services/catalog_stack.py +++ b/src/specify_cli/bundler/services/catalog_stack.py @@ -88,17 +88,25 @@ def search(self, query: str = "") -> list[ResolvedBundle]: Results are sorted by bundle id for deterministic output. """ needle = query.strip().lower() - seen: dict[str, ResolvedBundle] = {} + # Resolve each id to its highest-precedence entry FIRST, then filter by + # the query. Claiming an id only when it matches would let a lower- + # precedence entry with the same id surface when the highest-precedence + # one doesn't match the query — but that shadowed entry is not what + # `resolve()`/install would use, so search would advertise a bundle + # (name, version, author) the user can never actually get. + resolved: dict[str, ResolvedBundle] = {} for source in self._sources: for bundle_id, entry in self._entries_for(source).items(): - if bundle_id in seen: + if bundle_id in resolved: continue - if needle and not _matches(entry, needle): - continue - seen[bundle_id] = ResolvedBundle( + resolved[bundle_id] = ResolvedBundle( entry=entry.with_provenance(source), source=source ) - return [seen[k] for k in sorted(seen)] + return [ + resolved[k] + for k in sorted(resolved) + if not needle or _matches(resolved[k].entry, needle) + ] def _matches(entry: CatalogEntry, needle: str) -> bool: diff --git a/tests/integration/test_bundler_catalog_stack.py b/tests/integration/test_bundler_catalog_stack.py index 55af49039b..e9ab8d912f 100644 --- a/tests/integration/test_bundler_catalog_stack.py +++ b/tests/integration/test_bundler_catalog_stack.py @@ -71,6 +71,47 @@ def test_search_dedupes_by_precedence_and_filters(): assert [r.entry.id for r in qa_only] == ["beta"] +def test_search_does_not_surface_a_shadowed_lower_precedence_entry(): + """Search must resolve each id at its highest-precedence source, then + filter — never fall through to a shadowed lower-precedence entry the query + happens to match. + + If the query matched only the lower-precedence copy of an id, search used + to return that copy, even though `resolve()`/install always use the + higher-precedence one. That advertised a bundle (name/version/source) the + user could never actually get. + """ + sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")] + payloads = { + # Highest-precedence entry for 'shared' does NOT match "widget". + "high": catalog_payload({ + "shared": catalog_entry_dict( + "shared", name="Alpha Tool", role="developer", + description="nothing relevant", version="2.0.0", + ), + }), + # Lower-precedence entry for the same id DOES match "widget". + "low": catalog_payload({ + "shared": catalog_entry_dict( + "shared", name="Searchable Widget", version="1.0.0", + ), + }), + } + stack = _stack(sources, payloads) + + # resolve() uses the high-precedence entry. + assert stack.resolve("shared").source.id == "high" + + # A query that only the shadowed low-precedence entry matches returns + # nothing — search agrees with resolve(). + assert stack.search("widget") == [] + + # And a query the high-precedence entry matches returns it (from 'high'). + alpha = stack.search("alpha tool") + assert [r.entry.id for r in alpha] == ["shared"] + assert alpha[0].source.id == "high" + + def test_unreachable_source_raises_named_error(): def fetcher(src): raise RuntimeError("boom") From c978faac574373e9abc804cc2ba7da52856006ee Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 7 Jul 2026 02:44:08 +0500 Subject: [PATCH 009/134] fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333) _validate_remote_url in bundler/services/adapters.py guarded on parsed.netloc, which is truthy for host-less URLs like "https://:8080" or "https://user@" even though they carry no host. so those passed the "must be a valid URL with a host" check. its docstring says it mirrors specify_cli.catalogs validation, but that site was already fixed to use hostname in #3210/#3227 and this twin was missed. switch to parsed.hostname (None for host-less URLs), matching catalogs.py. this guard runs before any network call, so it is a pre-flight safety check. add parametrized regression tests for the host-less forms plus a valid host+port sanity case. --- src/specify_cli/bundler/services/adapters.py | 6 ++++- tests/unit/test_bundler_adapters.py | 27 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index 17c9d3324c..91e3cf1cb4 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -75,7 +75,11 @@ def _validate_remote_url(source_id: str, url: str) -> None: f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.netloc: + # Check hostname, not netloc: netloc is truthy for host-less URLs like + # "https://:8080" or "https://user@...", so requiring netloc would let + # those through even though they carry no host. hostname is None in those + # cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210). + if not parsed.hostname: raise BundlerError( f"Catalog '{source_id}' URL must be a valid URL with a host: {url}" ) diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py index 4a6b2cb808..a6a9b5c42d 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/unit/test_bundler_adapters.py @@ -69,3 +69,30 @@ def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): fetcher = adapters.make_catalog_fetcher(allow_network=True) with pytest.raises(BundlerError, match="must use HTTPS"): fetcher(_source("https://example.com/c.json")) + + +@pytest.mark.parametrize( + "url", + [ + "https://:8080", # port only, no host + "https://:0", + "https://user@", # userinfo only, no host + "https://user:pw@", + "https://:8080/catalog.json", + ], +) +def test_validate_remote_url_rejects_host_less_urls(url): + """A URL with a truthy netloc but no host (``https://:8080``, + ``https://user@``) must be rejected. + + ``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``, + so a ``netloc`` check would wrongly accept them. This mirrors the fix in + ``specify_cli.catalogs`` (#3210), which the docstring says this validator + mirrors.""" + with pytest.raises(BundlerError, match="valid URL with a host"): + adapters._validate_remote_url("team", url) + + +def test_validate_remote_url_accepts_normal_https_url(): + # Sanity: a real host with a port still passes. + adapters._validate_remote_url("team", "https://example.com:8080/c.json") From 5217206fdfde2886482541897418781f7d54aa1f Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Tue, 7 Jul 2026 02:48:33 +0500 Subject: [PATCH 010/134] fix(workflows): match gate reject option case-insensitively (#3335) `validate` accepts a reject option case-insensitively (`o.lower() in {"reject", "abort"}`), so a gate authored as `options: [Approve, Reject]` passes validation. But `execute` compared the echoed choice case-sensitively, so picking `Reject` fell through to the approval path and silently ran downstream steps instead of aborting. Lower-case `choice` before the reject comparison so the runtime agrees with the validation that let the option through. Co-authored-by: Claude Opus 4.8 (1M context) --- .../workflows/steps/gate/__init__.py | 9 +++- tests/test_workflows.py | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py index e07b6ebd62..0c9399ce3f 100644 --- a/src/specify_cli/workflows/steps/gate/__init__.py +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -73,7 +73,14 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: choice = self._prompt(self._compose_prompt(message, show_file), options) output["choice"] = choice - if choice in ("reject", "abort"): + # Match rejection case-insensitively. ``_prompt`` echoes the option's + # original casing, and ``validate`` accepts a reject option + # case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate + # authored as ``options: [Approve, Reject]`` passes validation. Comparing + # ``choice`` case-sensitively here would then treat a ``Reject`` pick as + # approval and silently skip the abort — the reject path must agree with + # the check that let the option through. + if choice.lower() in ("reject", "abort"): if on_reject == "abort": output["aborted"] = True return StepResult( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 8fcf5b49c7..11338b3009 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4060,6 +4060,48 @@ def test_gate_abort_still_halts_with_continue_on_error( assert state.status == RunStatus.ABORTED assert "should-not-run" not in state.step_results + def test_gate_reject_matches_case_insensitively( + self, project_dir, monkeypatch + ): + """A capitalised reject option (`options: [Approve, Reject]`) still + aborts the run. `validate` accepts a reject choice case-insensitively, + so the runtime reject check must agree — a case-sensitive comparison + would treat the echoed `Reject` as approval and silently run + downstream steps. + """ + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.steps.gate import GateStep + + # `_prompt` echoes the option's original casing, so the operator + # picking "Reject" hands `execute` the capitalised string. + _force_gate_stdin(monkeypatch, tty=True) + monkeypatch.setattr( + GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject") + ) + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "gate-reject-case" + name: "Gate Reject Case" + version: "1.0.0" +steps: + - id: gate-step + type: gate + message: "Approve?" + options: [Approve, Reject] + on_reject: abort + - id: should-not-run + type: shell + run: "echo nope" +""") + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.ABORTED + assert "should-not-run" not in state.step_results + def test_validation_rejects_non_bool_continue_on_error(self): """`continue_on_error` must be a literal boolean; coerced strings like `"true"` are rejected at validation time so From de6a04eaad6656dfb74e891d511423e5e1af31dd Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:51:18 -0500 Subject: [PATCH 011/134] chore: release 0.12.5, begin 0.12.6.dev0 development (#3381) * chore: bump version to 0.12.5 * chore: begin 0.12.6.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6aa12e08d..0ea1af4ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.12.5] - 2026-07-06 + +### Changed + +- fix(workflows): match gate reject option case-insensitively (#3335) +- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333) +- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331) +- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323) +- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307) +- Support namespaced git feature branch templates (#3293) +- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315) +- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265) +- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291) +- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305) + ## [0.12.4] - 2026-07-02 ### Changed diff --git a/pyproject.toml b/pyproject.toml index fa27c228e9..59713806a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.12.5.dev0" +version = "0.12.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From d3e7b06fa71b8deb89a50701ed5773eaf3866364 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 7 Jul 2026 03:34:16 +0500 Subject: [PATCH 012/134] fix(yaml): pin goose recipe prompt block-scalar indentation (#3343) the goose recipe renderer emitted the prompt body under a bare '|' block scalar. yaml infers a plain block scalar's indentation from its first non-empty line, so a command body whose first line is itself indented (a markdown code block, a nested list item) made the parser expect that deeper indent for the whole block and reject the later, shallower lines - the generated .goose recipe then failed to parse. use an explicit '|2' indentation indicator so the block is always read at 2 spaces regardless of the body. added a regression test that round-trips an indented-first-line body through the yaml parser. --- src/specify_cli/integrations/base.py | 10 ++++++++-- .../integrations/test_integration_base_yaml.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index d5ebce78e2..37d3cdf965 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1178,12 +1178,18 @@ def _render_yaml(cls, title: str, description: str, body: str, source_id: str) - default_flow_style=False, ).strip() - # Indent the body for YAML block scalar + # Indent the body for YAML block scalar. Use an explicit indentation + # indicator ("|2") rather than a bare "|": YAML infers a plain block + # scalar's indentation from its first non-empty line, so a body whose + # first line is itself indented (e.g. a markdown code block or a nested + # list item) would make the parser expect that deeper indent for the + # whole block and reject the later, less-indented lines. Pinning the + # indent to 2 keeps the recipe parseable whatever the body looks like. indented = "\n".join(f" {line}" for line in body.split("\n")) lines = [ header_yaml, - "prompt: |", + "prompt: |2", indented, "", f"# Source: {source_id}", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 74cdab2d7d..56bed09eb2 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -184,6 +184,23 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] + def test_yaml_prompt_with_indented_first_line_stays_valid(self): + """A body whose first line is indented must still parse. + + A bare ``|`` block scalar infers its indentation from the first + non-empty line, so a body starting with an indented line (e.g. a + markdown code block or nested list item) made the parser expect that + deeper indent for the whole block and reject the later, shallower + lines. The explicit ``|2`` indicator pins the indent so it parses.""" + body = " indented first line\nback to normal\n indented again" + rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src") + + yaml_lines = [ + ln for ln in rendered.split("\n") if not ln.startswith("# Source:") + ] + parsed = yaml.safe_load("\n".join(yaml_lines)) + assert parsed["prompt"].rstrip("\n") == body + def test_plan_command_has_no_context_placeholder(self, tmp_path): """The generated plan command must not carry a context-file placeholder. From 52480ee50f76341ecf57241edafd5bf30918f4e7 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:36:35 +0500 Subject: [PATCH 013/134] fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigManager._load_yaml_config returned yaml.safe_load(...) or {}, which only guards falsy roots — a truthy non-mapping root (a YAML list or scalar) flows straight into _merge_configs, whose .items() raises AttributeError. get_config()/has_value()/get_value() then crash, and via should_execute_hook's blanket 'except Exception: return False' every config-based hook condition for that extension is silently disabled. Coerce a non-dict root to {}, mirroring the existing non-dict-root guard in get_project_config(). Hardens all three call sites in one place. Co-authored-by: Claude Fable 5 --- src/specify_cli/extensions/__init__.py | 7 +++- tests/test_extensions.py | 50 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 8dd1b10554..5eea80c77d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2688,7 +2688,12 @@ def _load_yaml_config(self, file_path: Path) -> Dict[str, Any]: return {} try: - return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(file_path.read_text(encoding="utf-8")) + # Coerce a non-mapping root (list/scalar, or None for an empty + # file) to {} so callers that iterate/merge the result — e.g. + # _merge_configs' .items() — never crash. Mirrors the same + # non-dict-root guard in get_project_config(). + return data if isinstance(data, dict) else {} except (yaml.YAMLError, OSError, UnicodeError): return {} diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2a4b2aa660..2a3d029878 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -31,6 +31,7 @@ ExtensionRegistry, ExtensionManager, CommandRegistrar, + ConfigManager, HookExecutor, ExtensionCatalog, ExtensionError, @@ -7492,3 +7493,52 @@ def fake_open(url, timeout=None, extra_headers=None): ) assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7" assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"] + + +class TestConfigManagerNonMappingYaml: + """A non-mapping YAML config root must not crash config/hook resolution.""" + + def _make(self, tmp_path, body: str): + ext_dir = tmp_path / ".specify" / "extensions" / "jira" + ext_dir.mkdir(parents=True) + (ext_dir / "jira-config.yml").write_text(body, encoding="utf-8") + return ConfigManager(tmp_path, "jira") + + def test_get_config_coerces_list_root(self, tmp_path): + """A YAML list root previously raised AttributeError in _merge_configs.""" + cm = self._make(tmp_path, "- foo\n- bar\n") + assert cm.get_config() == {} + + def test_get_config_coerces_scalar_root(self, tmp_path): + cm = self._make(tmp_path, "just a string\n") + assert cm.get_config() == {} + + def test_has_value_and_get_value_do_not_raise(self, tmp_path): + cm = self._make(tmp_path, "- foo\n") + assert cm.has_value("anything") is False + assert cm.get_value("anything") is None + + def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path): + """A malformed project config must not block a valid local config.""" + ext_dir = tmp_path / ".specify" / "extensions" / "jira" + ext_dir.mkdir(parents=True) + (ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8") + (ext_dir / "local-config.yml").write_text( + "notifications:\n enabled: true\n", encoding="utf-8" + ) + cm = ConfigManager(tmp_path, "jira") + assert cm.get_value("notifications.enabled") is True + + def test_hook_condition_returns_false_without_raising(self, tmp_path): + """`config.x is set` on a scalar-root config must evaluate cleanly. + + Before the fix, _merge_configs raised AttributeError and the + exception was swallowed by should_execute_hook, silently disabling + every config-based hook for the extension. Assert on + _evaluate_condition directly so the crash isn't masked. + """ + ext_dir = tmp_path / ".specify" / "extensions" / "jira" + ext_dir.mkdir(parents=True) + (ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8") + executor = HookExecutor(tmp_path) + assert executor._evaluate_condition("config.x is set", "jira") is False From 587b1859faaf21579fb85ac43e07dd06c1dd1c32 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:38:39 +0500 Subject: [PATCH 014/134] fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HermesIntegration.build_exec_args routed argv[0] through _resolve_executable() but never called _apply_extra_args_env_var(), so the documented per-integration extra-args env hook was silently dropped for hermes — the same class of bug fixed for cursor-agent in #3265. Insert the hook after the base 'chat -Q' command and before Spec Kit's canonical -m/--json/-s/-q flags (mirrors opencode), so operator args can't displace or clobber the canonical flags. Co-authored-by: Claude Fable 5 --- .../integrations/hermes/__init__.py | 5 +++ tests/integrations/test_integration_hermes.py | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index f4bc43be6e..5d1f3a261b 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -253,6 +253,11 @@ def build_exec_args( """ args = [self._resolve_executable(), "chat", "-Q"] + # Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here — + # after the base command but before Spec Kit's canonical -m/--json/-s/-q + # flags — so they can't displace or clobber them (mirrors opencode). + self._apply_extra_args_env_var(args) + if model: args.extend(["-m", model]) if output_json: diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index 521a310cb8..b2106050b7 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -353,3 +353,38 @@ def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch): if "agent-context" not in d.name ] assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}" + + +class TestHermesBuildExecArgs: + """CLI dispatch argv, including the operator extra-args env hook.""" + + def test_build_exec_args_default_shape(self): + i = get_integration("hermes") + assert i.build_exec_args("/speckit-plan hi", output_json=True) == [ + "hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi", + ] + + def test_build_exec_args_honors_extra_args(self, monkeypatch): + """SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the + canonical -m/--json/-s/-q flags (same env hook as codex/opencode/ + devin; hermes previously skipped _apply_extra_args_env_var entirely). + """ + monkeypatch.setenv( + "SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2" + ) + i = get_integration("hermes") + args = i.build_exec_args("/speckit-plan hi", output_json=True) + assert args == [ + "hermes", "chat", "-Q", "--temperature", "0.2", + "--json", "-s", "speckit-plan", "-q", "hi", + ] + # Injected before the canonical flags so it can't displace them. + assert args.index("--temperature") < args.index("--json") + assert args.index("--temperature") < args.index("-s") + + def test_build_exec_args_honors_executable_override(self, monkeypatch): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes" + ) + i = get_integration("hermes") + assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes" From b8d27e472f6b898afd0ad75c9eca3b5d7dc0744d Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Tue, 7 Jul 2026 00:50:33 +0200 Subject: [PATCH 015/134] test: reduce registry manifest test repetition (#3146) * test: isolate integration test home Assisted-by: Codex (model: GPT-5, autonomous) * test: reduce registry manifest test repetition Assisted-by: Codex (model: GPT-5, autonomous) * test: clarify disjoint-manifest order rationale and guard safe set Add a >=2 precondition, explain why two install orders are tested (manifests are order-independent; the orders only vary the init path), and build the manifest map with a comprehension. * test: rotate init coverage for manifest isolation Assisted-by: Codex (model: GPT-5, autonomous) * test: assert integration home isolation Assisted-by: Codex (model: GPT-5, autonomous) * test: guard multi-install manifest rotations Assisted-by: Codex (model: GPT-5, autonomous) --- tests/integrations/conftest.py | 16 +++ tests/integrations/test_home_isolation.py | 21 ++++ tests/integrations/test_registry.py | 135 +++++++++++++--------- 3 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 tests/integrations/test_home_isolation.py diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 833e272b27..4671872356 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -1,8 +1,24 @@ """Shared test helpers for integration tests.""" +import pytest + from specify_cli.integrations.base import MarkdownIntegration +@pytest.fixture(autouse=True) +def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path): + """Keep integration tests from reading or writing the real user home.""" + home = tmp_path / "home" + for path in (home, home / ".cache", home / ".config", home / ".local" / "share"): + path.mkdir(parents=True, exist_ok=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) + + class StubIntegration(MarkdownIntegration): """Minimal concrete integration for testing.""" diff --git a/tests/integrations/test_home_isolation.py b/tests/integrations/test_home_isolation.py new file mode 100644 index 0000000000..e79ff5dff6 --- /dev/null +++ b/tests/integrations/test_home_isolation.py @@ -0,0 +1,21 @@ +"""Regression tests for integration-test environment isolation.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def test_integration_tests_use_tmp_home(tmp_path: Path) -> None: + home = tmp_path / "home" + + assert Path(os.environ["HOME"]) == home + assert Path(os.environ["USERPROFILE"]) == home + assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache" + assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config" + assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share" + + assert home.is_dir() + assert (home / ".cache").is_dir() + assert (home / ".config").is_dir() + assert (home / ".local" / "share").is_dir() diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0014ca3dab..d3049b4265 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]: ] +def _multi_install_safe_orders() -> list[list[str]]: + safe_keys = _multi_install_safe_keys() + if len(safe_keys) < 2: + return [safe_keys] + return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))] + + +def _multi_install_safe_order_id(ordered_keys: list[str]) -> str: + if not ordered_keys: + return "no-safe-integrations" + return f"init-{ordered_keys[0]}" + + def _posix_path(value: str | None) -> str | None: if not value: return None @@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool: return False -def _path_is_inside(path: str | None, directory: str | None) -> bool: - if not path or not directory: - return False - try: - PurePosixPath(path).relative_to(PurePosixPath(directory)) - return True - except ValueError: - return False - - class TestRegistry: def test_registry_is_dict(self): assert isinstance(INTEGRATION_REGISTRY, dict) @@ -162,6 +165,15 @@ def test_no_stale_cursor_shorthand(self): class TestMultiInstallSafeContracts: """Declared safe integrations must stay isolated from each other.""" + def test_safe_install_orders_rotate_each_integration_through_init(self): + safe_keys = _multi_install_safe_keys() + orders = _multi_install_safe_orders() + + assert len(safe_keys) >= 2 + assert [order[0] for order in orders] == safe_keys + assert len({tuple(order) for order in orders}) == len(safe_keys) + assert all(sorted(order) == safe_keys for order in orders) + @pytest.mark.parametrize("key", _multi_install_safe_keys()) def test_safe_integrations_have_static_isolated_paths(self, key): assert _integration_root_dir(key), ( @@ -187,62 +199,77 @@ def test_safe_integrations_have_distinct_command_dirs(self, first, second): f"{_integration_commands_dir(second)!r}" ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + @pytest.mark.parametrize( + "ordered_keys", + _multi_install_safe_orders(), + ids=_multi_install_safe_order_id, + ) def test_safe_integrations_have_disjoint_manifests( self, tmp_path, - first, - second, + ordered_keys, ): - for initial, additional in ((first, second), (second, first)): - project_root = tmp_path / f"project-{initial}-{additional}" - project_root.mkdir() - runner = CliRunner() - - original_cwd = os.getcwd() - try: - os.chdir(project_root) - init_result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - initial, - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - assert init_result.exit_code == 0, init_result.output + # The pairwise disjointness contract is only meaningful with at least + # two safe integrations. Guard so a shrunken registry fails loudly here + # rather than passing vacuously (or tripping over ordered_keys[0] below). + assert len(ordered_keys) >= 2, ( + f"expected at least two multi-install-safe integrations, got {ordered_keys}" + ) + project_root = tmp_path / "project" + project_root.mkdir() + runner = CliRunner() + + # Install every safe integration once into a single project, then assert + # pairwise manifest isolation. Each safe integration writes only to its + # own (disjoint) directories and always records what it writes, so a + # manifest's contents are independent of install order and of which other + # integrations are co-installed. The parametrized rotations keep the + # aggregate setup while placing each safe integration first once, so each + # one still exercises the `specify init --integration ...` path. + original_cwd = os.getcwd() + try: + os.chdir(project_root) + init_result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + ordered_keys[0], + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + assert init_result.exit_code == 0, init_result.output + + for key in ordered_keys[1:]: install_result = runner.invoke( app, - ["integration", "install", additional, "--script", "sh"], + ["integration", "install", key, "--script", "sh"], catch_exceptions=False, ) assert install_result.exit_code == 0, install_result.output - finally: - os.chdir(original_cwd) + finally: + os.chdir(original_cwd) - initial_manifest = json.loads( - ( - project_root / ".specify" / "integrations" / f"{initial}.manifest.json" - ).read_text(encoding="utf-8") + integrations_dir = project_root / ".specify" / "integrations" + manifests = {} + for key in ordered_keys: + manifest = json.loads( + (integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8") ) - additional_manifest = json.loads( - ( - project_root / ".specify" / "integrations" / f"{additional}.manifest.json" - ).read_text(encoding="utf-8") - ) - - initial_files = set(initial_manifest.get("files", {})) - additional_files = set(additional_manifest.get("files", {})) - - assert initial_files.isdisjoint(additional_files), ( - f"{initial} and {additional} are declared multi-install safe but both manage " - f"these files: {sorted(initial_files & additional_files)}" + files = manifest.get("files", {}) + assert isinstance(files, dict), f"{key} manifest files must be an object" + manifests[key] = set(files.keys()) + + for first, second in _multi_install_safe_pairs(): + overlap = manifests[first] & manifests[second] + assert not overlap, ( + f"{first} and {second} are declared multi-install safe but both manage " + f"these files: {sorted(overlap)}" ) From 73f77c200f568eea34c2544fd1d571c567c005c9 Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:54:31 +0800 Subject: [PATCH 016/134] feat(scripts): add Python check-prerequisites PoC (#3302) * feat(scripts): add Python check-prerequisites PoC * fix(scripts): address check-prerequisites parity feedback * test(scripts): label PowerShell prerequisite parity cases --------- Co-authored-by: root --- scripts/python/check_prerequisites.py | 207 +++++++++++ scripts/python/common.py | 210 +++++++++++ .../test_check_prerequisites_python_parity.py | 339 ++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 scripts/python/check_prerequisites.py create mode 100644 scripts/python/common.py create mode 100644 tests/test_check_prerequisites_python_parity.py diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py new file mode 100644 index 0000000000..50c31cb513 --- /dev/null +++ b/scripts/python/check_prerequisites.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Consolidated prerequisite checking script.""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + from common import FeaturePaths, format_speckit_command, get_feature_paths +except ImportError: # pragma: no cover - direct execution from unusual cwd + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from common import FeaturePaths, format_speckit_command, get_feature_paths + + +def _json_line(payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + + +HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check_prerequisites.py --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check_prerequisites.py --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check_prerequisites.py --paths-only + +""" + + +@dataclass(frozen=True) +class Args: + json_mode: bool = False + require_tasks: bool = False + include_tasks: bool = False + paths_only: bool = False + + +def _parse_args(argv: list[str]) -> Args: + json_mode = False + require_tasks = False + include_tasks = False + paths_only = False + + for arg in argv: + if arg == "--json": + json_mode = True + elif arg == "--require-tasks": + require_tasks = True + elif arg == "--include-tasks": + include_tasks = True + elif arg == "--paths-only": + paths_only = True + elif arg in {"--help", "-h"}: + sys.stdout.write(HELP_TEXT) + raise SystemExit(0) + else: + print( + f"ERROR: Unknown option '{arg}'. Use --help for usage information.", + file=sys.stderr, + ) + raise SystemExit(1) + + return Args( + json_mode=json_mode, + require_tasks=require_tasks, + include_tasks=include_tasks, + paths_only=paths_only, + ) + + +def _dir_has_entries(path: Path) -> bool: + try: + return path.is_dir() and any(path.iterdir()) + except OSError: + return False + + +def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]: + docs: list[str] = [] + if paths.research.is_file(): + docs.append("research.md") + if paths.data_model.is_file(): + docs.append("data-model.md") + if _dir_has_entries(paths.contracts_dir): + docs.append("contracts/") + if paths.quickstart.is_file(): + docs.append("quickstart.md") + if include_tasks and paths.tasks.is_file(): + docs.append("tasks.md") + return docs + + +def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None: + if json_mode: + sys.stdout.write( + _json_line( + { + "REPO_ROOT": str(paths.repo_root), + "BRANCH": paths.current_branch, + "FEATURE_DIR": str(paths.feature_dir), + "FEATURE_SPEC": str(paths.feature_spec), + "IMPL_PLAN": str(paths.impl_plan), + "TASKS": str(paths.tasks), + } + ) + ) + return + + print(f"REPO_ROOT: {paths.repo_root}") + print(f"BRANCH: {paths.current_branch}") + print(f"FEATURE_DIR: {paths.feature_dir}") + print(f"FEATURE_SPEC: {paths.feature_spec}") + print(f"IMPL_PLAN: {paths.impl_plan}") + print(f"TASKS: {paths.tasks}") + + +def _check_file(path: Path, description: str) -> None: + marker = "✓" if path.is_file() else "✗" + print(f" {marker} {description}") + + +def _check_dir(path: Path, description: str) -> None: + marker = "✓" if _dir_has_entries(path) else "✗" + print(f" {marker} {description}") + + +def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None: + print(f"FEATURE_DIR:{paths.feature_dir}") + print("AVAILABLE_DOCS:") + _check_file(paths.research, "research.md") + _check_file(paths.data_model, "data-model.md") + _check_dir(paths.contracts_dir, "contracts/") + _check_file(paths.quickstart, "quickstart.md") + if include_tasks: + _check_file(paths.tasks, "tasks.md") + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(list(argv if argv is not None else sys.argv[1:])) + + try: + paths = get_feature_paths( + no_persist=args.paths_only, + script_file=Path(__file__), + ) + except SystemExit as exc: + if exc.code == 0: + return 0 + print("ERROR: Failed to resolve feature paths", file=sys.stderr) + return int(exc.code) if isinstance(exc.code, int) else 1 + + if args.paths_only: + _print_paths_only(paths, args.json_mode) + return 0 + + if not paths.feature_dir.is_dir(): + print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.", + file=sys.stderr, + ) + return 1 + + if not paths.impl_plan.is_file(): + print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.", + file=sys.stderr, + ) + return 1 + + if args.require_tasks and not paths.tasks.is_file(): + print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.", + file=sys.stderr, + ) + return 1 + + docs = _available_docs(paths, args.include_tasks) + if args.json_mode: + sys.stdout.write( + _json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs}) + ) + else: + _print_text_results(paths, args.include_tasks) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/python/common.py b/scripts/python/common.py new file mode 100644 index 0000000000..77c13eefbb --- /dev/null +++ b/scripts/python/common.py @@ -0,0 +1,210 @@ +"""Shared helpers for Spec Kit Python scripts.""" + +from __future__ import annotations + +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path + + +def _trim_trailing_separators(value: Path) -> str: + text = str(value) + while len(text) > 1 and text.endswith((os.sep, "/")): + text = text[:-1] + return text + + +def find_specify_root(start_dir: Path | None = None) -> Path | None: + current = (start_dir or Path.cwd()).resolve() + while True: + if (current / ".specify").is_dir(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def resolve_specify_init_dir() -> Path: + raw = os.environ.get("SPECIFY_INIT_DIR", "") + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = Path.cwd() / candidate + try: + init_root = candidate.resolve(strict=True) + except OSError: + print( + f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}", + file=sys.stderr, + ) + raise SystemExit(1) + if not init_root.is_dir(): + print( + f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}", + file=sys.stderr, + ) + raise SystemExit(1) + if not (init_root / ".specify").is_dir(): + print( + "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project " + f"(no .specify/ directory): {init_root}", + file=sys.stderr, + ) + raise SystemExit(1) + return init_root + + +def get_repo_root(script_file: Path | None = None) -> Path: + if os.environ.get("SPECIFY_INIT_DIR"): + return resolve_specify_init_dir() + + specify_root = find_specify_root() + if specify_root is not None: + return specify_root + + if script_file is not None: + script_root = find_specify_root(script_file.resolve().parent) + if script_root is not None: + return script_root + + # Installed scripts live at .specify/scripts/python/