mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
Compare commits
1 Commits
v0.10.0
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38a0d96fa |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,20 +2,6 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
## [0.10.0] - 2026-06-09
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
|
|
||||||
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
|
|
||||||
- test(workflows): cover executable override fallback preflight (#2843)
|
|
||||||
- Add GitHub Copilot CLI guidance to readme (#2891)
|
|
||||||
- Update Security Review extension to v1.5.3 (#2898)
|
|
||||||
- Update Architecture Guard extension to v1.8.17 (#2897)
|
|
||||||
- feat(extensions): per-event hook lists with priority ordering (#2798)
|
|
||||||
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
|
|
||||||
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
|
|
||||||
|
|
||||||
## [0.9.5] - 2026-06-05
|
## [0.9.5] - 2026-06-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
|
|||||||
|
|
||||||
### 3. Establish project principles
|
### 3. Establish project principles
|
||||||
|
|
||||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
|
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||||
|
|
||||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
|||||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||||
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||||
- [Python 3.11+](https://www.python.org/downloads/)
|
- [Python 3.11+](https://www.python.org/downloads/)
|
||||||
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ rm -rf .venv dist build *.egg-info
|
|||||||
|---------|-----|
|
|---------|-----|
|
||||||
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
|
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
|
||||||
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
|
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
|
||||||
| Git commands unavailable | Install the git extension with `specify extension add git` |
|
| Git step skipped | You passed `--no-git` or Git not installed |
|
||||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
||||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ specify init [<project_name>]
|
|||||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||||
| `--here` | Initialize in the current directory instead of creating a new one |
|
| `--here` | Initialize in the current directory instead of creating a new one |
|
||||||
| `--force` | Force merge/overwrite when initializing in an existing directory |
|
| `--force` | Force merge/overwrite when initializing in an existing directory |
|
||||||
|
| `--no-git` | Skip git repository initialization |
|
||||||
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
|
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
|
||||||
| `--preset <id>` | Install a preset during initialization |
|
| `--preset <id>` | Install a preset during initialization |
|
||||||
|
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
|
||||||
|
|
||||||
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
|
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Git repository initialization and branching are managed by the **git extension**, which is not installed by default. Run `specify extension add git` after init to enable git workflows.
|
> The git extension is currently enabled by default during `specify init`.
|
||||||
|
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
|
||||||
|
|
||||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||||
|
|
||||||
@@ -42,8 +45,14 @@ specify init --here --force --integration copilot
|
|||||||
# Use PowerShell scripts (Windows/cross-platform)
|
# Use PowerShell scripts (Windows/cross-platform)
|
||||||
specify init my-project --integration copilot --script ps
|
specify init my-project --integration copilot --script ps
|
||||||
|
|
||||||
|
# Skip git initialization
|
||||||
|
specify init my-project --integration copilot --no-git
|
||||||
|
|
||||||
# Install a preset during initialization
|
# Install a preset during initialization
|
||||||
specify init my-project --integration copilot --preset compliance
|
specify init my-project --integration copilot --preset compliance
|
||||||
|
|
||||||
|
# Use timestamp-based branch numbering (useful for distributed teams)
|
||||||
|
specify init my-project --integration copilot --branch-numbering timestamp
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
@@ -58,7 +67,7 @@ specify init my-project --integration copilot --preset compliance
|
|||||||
specify check
|
specify check
|
||||||
```
|
```
|
||||||
|
|
||||||
Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.
|
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
|
||||||
|
|
||||||
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.
|
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.
|
||||||
|
|
||||||
|
|||||||
@@ -257,38 +257,70 @@ rm speckit.old-command-name.md
|
|||||||
# Restart your IDE
|
# Restart your IDE
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scenario 4: "I don't want the git extension"
|
### Scenario 4: "I'm working on a project without Git"
|
||||||
|
|
||||||
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
|
If you initialized your project with `--no-git`, you can still upgrade:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Manually back up files you customized
|
# Manually back up files you customized
|
||||||
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
|
cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||||
|
|
||||||
# Run upgrade
|
# Run upgrade
|
||||||
specify init --here --force --integration copilot
|
specify init --here --force --integration copilot --no-git
|
||||||
|
|
||||||
# Restore customizations
|
# Restore customizations
|
||||||
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
|
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||||
```
|
```
|
||||||
|
|
||||||
If you later decide you want the git extension's commands and hooks, install it explicitly:
|
The `--no-git` flag skips git initialization but doesn't affect file updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using `--no-git` Flag
|
||||||
|
|
||||||
|
The `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:
|
||||||
|
|
||||||
|
- You manage version control differently (Mercurial, SVN, etc.)
|
||||||
|
- Your project is part of a larger monorepo with existing git setup
|
||||||
|
- You're experimenting and don't want version control yet
|
||||||
|
|
||||||
|
**During initial setup:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
specify extension add git
|
specify init my-project --integration copilot --no-git
|
||||||
```
|
```
|
||||||
|
|
||||||
Projects that do not use Git can still work with Spec Kit by setting `SPECIFY_FEATURE_DIRECTORY` to the feature directory path before planning commands:
|
**During upgrade:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
specify init --here --force --integration copilot --no-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### What `--no-git` does NOT do
|
||||||
|
|
||||||
|
❌ Does NOT prevent file updates
|
||||||
|
❌ Does NOT skip slash command installation
|
||||||
|
❌ Does NOT affect template merging
|
||||||
|
|
||||||
|
It **only** skips running `git init` and creating the initial commit.
|
||||||
|
|
||||||
|
### Working without Git
|
||||||
|
|
||||||
|
If you use `--no-git`, you'll need to manage feature directories manually:
|
||||||
|
|
||||||
|
**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Bash/Zsh
|
# Bash/Zsh
|
||||||
export SPECIFY_FEATURE_DIRECTORY="specs/001-my-feature"
|
export SPECIFY_FEATURE="001-my-feature"
|
||||||
|
|
||||||
# PowerShell
|
# PowerShell
|
||||||
$env:SPECIFY_FEATURE_DIRECTORY = "specs/001-my-feature"
|
$env:SPECIFY_FEATURE = "001-my-feature"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, run the `/speckit.specify` command which creates `.specify/feature.json` automatically.
|
This tells Spec Kit which feature directory to use when creating specs, plans, and tasks.
|
||||||
|
|
||||||
|
**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-06-08T00:00:00Z",
|
"updated_at": "2026-06-04T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -242,11 +242,11 @@
|
|||||||
"id": "architecture-guard",
|
"id": "architecture-guard",
|
||||||
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
|
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
|
||||||
"author": "DyanGalih",
|
"author": "DyanGalih",
|
||||||
"version": "1.8.17",
|
"version": "1.8.9",
|
||||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
|
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.9.zip",
|
||||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
|
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
|
||||||
"changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases",
|
"changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-05-05T07:26:00Z",
|
"created_at": "2026-05-05T07:26:00Z",
|
||||||
"updated_at": "2026-06-08T00:00:00Z"
|
"updated_at": "2026-05-27T00:00:00Z"
|
||||||
},
|
},
|
||||||
"archive": {
|
"archive": {
|
||||||
"name": "Archive Extension",
|
"name": "Archive Extension",
|
||||||
@@ -2554,9 +2554,9 @@
|
|||||||
"name": "Security Review",
|
"name": "Security Review",
|
||||||
"id": "security-review",
|
"id": "security-review",
|
||||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||||
"author": "Spec-Kit Security Team",
|
"author": "DyanGalih",
|
||||||
"version": "1.5.3",
|
"version": "1.5.0",
|
||||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.3.zip",
|
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.0.zip",
|
||||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||||
@@ -2580,7 +2580,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-04-03T03:24:03Z",
|
"created_at": "2026-04-03T03:24:03Z",
|
||||||
"updated_at": "2026-06-08T00:00:00Z"
|
"updated_at": "2026-05-11T14:58:00Z"
|
||||||
},
|
},
|
||||||
"sf": {
|
"sf": {
|
||||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ When Git is not installed or the directory is not a Git repository:
|
|||||||
|
|
||||||
The extension bundles cross-platform scripts:
|
The extension bundles cross-platform scripts:
|
||||||
|
|
||||||
- `scripts/bash/create-new-feature-branch.sh` — Bash implementation (branch creation only)
|
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||||
- `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
|
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl
|
|||||||
Determine the branch numbering strategy by checking configuration in this order:
|
Determine the branch numbering strategy by checking configuration in this order:
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||||
2. Check `.specify/init-options.json` for `feature_numbering` value (inherit from core)
|
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||||
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
|
3. Default to `sequential` if neither exists
|
||||||
4. Default to `sequential` if none of the above exist
|
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
|
||||||
@@ -44,10 +43,10 @@ Generate a concise short name (2-4 words) for the branch:
|
|||||||
|
|
||||||
Run the appropriate script based on your platform:
|
Run the appropriate script based on your platform:
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --short-name "<short-name>" "<feature description>"`
|
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||||
|
|
||||||
**IMPORTANT**:
|
**IMPORTANT**:
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Git extension: create-new-feature-branch.sh
|
# Git extension: create-new-feature.sh
|
||||||
# Creates a git feature branch only. The feature directory and spec file
|
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
||||||
# are created by the core create-new-feature.sh script.
|
|
||||||
# Sources common.sh from the project's installed scripts, falling back to
|
# Sources common.sh from the project's installed scripts, falling back to
|
||||||
# git-common.sh for minimal git helpers.
|
# git-common.sh for minimal git helpers.
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env pwsh
|
#!/usr/bin/env pwsh
|
||||||
# Git extension: create-new-feature-branch.ps1
|
# Git extension: create-new-feature.ps1
|
||||||
# Creates a git feature branch only. The feature directory and spec file
|
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
||||||
# are created by the core create-new-feature.ps1 script.
|
|
||||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
# Sources common.ps1 from the project's installed scripts, falling back to
|
||||||
# git-common.ps1 for minimal git helpers.
|
# git-common.ps1 for minimal git helpers.
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
@@ -20,7 +19,7 @@ param(
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
if ($Help) {
|
if ($Help) {
|
||||||
Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Options:"
|
Write-Host "Options:"
|
||||||
Write-Host " -Json Output in JSON format"
|
Write-Host " -Json Output in JSON format"
|
||||||
@@ -38,7 +37,7 @@ if ($Help) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||||
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,11 +224,11 @@
|
|||||||
"fiction-book-writing": {
|
"fiction-book-writing": {
|
||||||
"name": "Fiction Book Writing",
|
"name": "Fiction Book Writing",
|
||||||
"id": "fiction-book-writing",
|
"id": "fiction-book-writing",
|
||||||
"version": "1.9.0",
|
"version": "1.8.1",
|
||||||
"description": "Spec-Driven Development for novel and long-form fiction. 34 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, illustrations, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
||||||
"author": "Andreas Daumann",
|
"author": "Andreas Daumann",
|
||||||
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.9.0.zip",
|
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip",
|
||||||
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||||
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
|
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -236,8 +236,8 @@
|
|||||||
"speckit_version": ">=0.5.0"
|
"speckit_version": ">=0.5.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"templates": 26,
|
"templates": 25,
|
||||||
"commands": 34,
|
"commands": 33,
|
||||||
"scripts": 2
|
"scripts": 2
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
"language-support"
|
"language-support"
|
||||||
],
|
],
|
||||||
"created_at": "2026-04-09T08:00:00Z",
|
"created_at": "2026-04-09T08:00:00Z",
|
||||||
"updated_at": "2026-06-02T08:00:00Z"
|
"updated_at": "2026-05-24T08:00:00Z"
|
||||||
},
|
},
|
||||||
"game-narrative-writing": {
|
"game-narrative-writing": {
|
||||||
"name": "Game Narrative Writing",
|
"name": "Game Narrative Writing",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.10.0"
|
version = "0.9.6.dev0"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ if $PATHS_ONLY; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Validate branch name
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
|
||||||
# Validate required directories and files
|
# Validate required directories and files
|
||||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ find_specify_root() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get repository root, prioritizing .specify directory
|
# Get repository root, prioritizing .specify directory over git
|
||||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||||
get_repo_root() {
|
get_repo_root() {
|
||||||
# First, look for .specify directory (spec-kit's own marker)
|
# First, look for .specify directory (spec-kit's own marker)
|
||||||
local specify_root
|
local specify_root
|
||||||
@@ -34,24 +34,123 @@ get_repo_root() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Final fallback to script location
|
# Fallback to git if no .specify found
|
||||||
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final fallback to script location for non-git repos
|
||||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
(cd "$script_dir/../../.." && pwd)
|
(cd "$script_dir/../../.." && pwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get current feature name from explicit state only.
|
# Get current branch, with fallback for non-git repositories
|
||||||
# Returns the feature identifier or empty string if none is set.
|
|
||||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
|
||||||
# the git extension) or implicitly via .specify/feature.json.
|
|
||||||
get_current_branch() {
|
get_current_branch() {
|
||||||
|
# First check if SPECIFY_FEATURE environment variable is set
|
||||||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||||
echo "$SPECIFY_FEATURE"
|
echo "$SPECIFY_FEATURE"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# No explicit feature set — caller must handle this via feature.json
|
# Then check git if available at the spec-kit root (not parent)
|
||||||
# in get_feature_paths(). Return empty to signal "unknown".
|
local repo_root=$(get_repo_root)
|
||||||
echo ""
|
if has_git; then
|
||||||
|
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For non-git repos, try to find the latest feature directory
|
||||||
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
|
if [[ -d "$specs_dir" ]]; then
|
||||||
|
local latest_feature=""
|
||||||
|
local highest=0
|
||||||
|
local latest_timestamp=""
|
||||||
|
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
local dirname=$(basename "$dir")
|
||||||
|
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
# Timestamp-based branch: compare lexicographically
|
||||||
|
local ts="${BASH_REMATCH[1]}"
|
||||||
|
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||||
|
latest_timestamp="$ts"
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
|
||||||
|
local number=${BASH_REMATCH[1]}
|
||||||
|
number=$((10#$number))
|
||||||
|
if [[ "$number" -gt "$highest" ]]; then
|
||||||
|
highest=$number
|
||||||
|
# Only update if no timestamp branch found yet
|
||||||
|
if [[ -z "$latest_timestamp" ]]; then
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$latest_feature" ]]; then
|
||||||
|
echo "$latest_feature"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "main" # Final fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we have git available at the spec-kit root level
|
||||||
|
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||||
|
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||||
|
has_git() {
|
||||||
|
# First check if git command is available (before calling get_repo_root which may use git)
|
||||||
|
command -v git >/dev/null 2>&1 || return 1
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
# Check if .git exists (directory or file for worktrees/submodules)
|
||||||
|
[ -e "$repo_root/.git" ] || return 1
|
||||||
|
# Verify it's actually a valid git work tree
|
||||||
|
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||||
|
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||||
|
spec_kit_effective_branch_name() {
|
||||||
|
local raw="$1"
|
||||||
|
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||||
|
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$raw"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_feature_branch() {
|
||||||
|
local raw="$1"
|
||||||
|
local has_git_repo="$2"
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if [[ "$has_git_repo" != "true" ]]; then
|
||||||
|
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(spec_kit_effective_branch_name "$raw")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
is_sequential=true
|
||||||
|
fi
|
||||||
|
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||||
|
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||||
|
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Safely read .specify/feature.json's "feature_directory" value.
|
# Safely read .specify/feature.json's "feature_directory" value.
|
||||||
@@ -86,66 +185,105 @@ read_feature_json_feature_directory() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Persist a feature_directory value to .specify/feature.json.
|
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||||
# Writes only when the file is missing or the value differs from what's stored.
|
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||||
# Accepts the raw (possibly relative) path — callers should pass the original
|
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||||
# user-supplied value, not the normalized absolute path.
|
feature_json_matches_feature_dir() {
|
||||||
_persist_feature_json() {
|
|
||||||
local repo_root="$1"
|
local repo_root="$1"
|
||||||
local feature_dir_value="$2"
|
local active_feature_dir="$2"
|
||||||
local fj="$repo_root/.specify/feature.json"
|
|
||||||
|
|
||||||
# Strip repo_root prefix if the value is absolute and under repo_root
|
local _fd
|
||||||
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read current value (if any) and skip write when unchanged
|
[[ -n "$_fd" ]] || return 1
|
||||||
local current_val
|
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||||
current_val=$(read_feature_json_feature_directory "$repo_root")
|
[[ -d "$_fd" ]] || return 1
|
||||||
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure .specify/ directory exists
|
local norm_json norm_active
|
||||||
mkdir -p "$repo_root/.specify"
|
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||||
|
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||||
|
|
||||||
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
[[ "$norm_json" == "$norm_active" ]]
|
||||||
if command -v jq >/dev/null 2>&1; then
|
}
|
||||||
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
|
||||||
|
# Find feature directory by numeric prefix instead of exact branch match
|
||||||
|
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||||
|
find_feature_dir_by_prefix() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local branch_name
|
||||||
|
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||||
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
|
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||||
|
local prefix=""
|
||||||
|
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
else
|
else
|
||||||
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||||
|
echo "$specs_dir/$branch_name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Search for directories in specs/ that start with this prefix
|
||||||
|
local matches=()
|
||||||
|
if [[ -d "$specs_dir" ]]; then
|
||||||
|
for dir in "$specs_dir"/"$prefix"-*; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
matches+=("$(basename "$dir")")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handle results
|
||||||
|
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||||
|
# No match found - return the branch name path (will fail later with clear error)
|
||||||
|
echo "$specs_dir/$branch_name"
|
||||||
|
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||||
|
# Exactly one match - perfect!
|
||||||
|
echo "$specs_dir/${matches[0]}"
|
||||||
|
else
|
||||||
|
# Multiple matches - this shouldn't happen with proper naming convention
|
||||||
|
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||||
|
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
get_feature_paths() {
|
get_feature_paths() {
|
||||||
local repo_root=$(get_repo_root)
|
local repo_root=$(get_repo_root)
|
||||||
local current_branch=$(get_current_branch)
|
local current_branch=$(get_current_branch)
|
||||||
|
local has_git_repo="false"
|
||||||
|
|
||||||
|
if has_git; then
|
||||||
|
has_git_repo="true"
|
||||||
|
fi
|
||||||
|
|
||||||
# Resolve feature directory. Priority:
|
# Resolve feature directory. Priority:
|
||||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||||
# 3. Error — no feature context available
|
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||||
local feature_dir
|
local feature_dir
|
||||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||||
# Normalize relative paths to absolute under repo root
|
# Normalize relative paths to absolute under repo root
|
||||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
# Persist to feature.json so future sessions without the env var still work
|
|
||||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
|
||||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||||
|
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||||
|
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||||
local _fd
|
local _fd
|
||||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
if [[ -n "$_fd" ]]; then
|
if [[ -n "$_fd" ]]; then
|
||||||
feature_dir="$_fd"
|
feature_dir="$_fd"
|
||||||
# Normalize relative paths to absolute under repo root
|
# Normalize relative paths to absolute under repo root
|
||||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
else
|
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
|
echo "ERROR: Failed to resolve feature directory" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
|
echo "ERROR: Failed to resolve feature directory" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -153,6 +291,7 @@ get_feature_paths() {
|
|||||||
# via crafted branch names or paths containing special characters
|
# via crafted branch names or paths containing special characters
|
||||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||||
|
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --json Output in JSON format"
|
echo " --json Output in JSON format"
|
||||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
|
||||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
echo " --help, -h Show this help message"
|
echo " --help, -h Show this help message"
|
||||||
@@ -113,18 +113,94 @@ get_highest_from_specs() {
|
|||||||
echo "$highest"
|
echo "$highest"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from git branches
|
||||||
|
get_highest_from_branches() {
|
||||||
|
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||||
|
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
|
||||||
|
_extract_highest_number() {
|
||||||
|
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")
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||||
|
get_highest_from_remote_refs() {
|
||||||
|
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)
|
||||||
|
if [ "$remote_highest" -gt "$highest" ]; then
|
||||||
|
highest=$remote_highest
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check existing branches (local and remote) and return next available number.
|
||||||
|
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
|
||||||
|
check_existing_branches() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local skip_fetch="${2:-false}"
|
||||||
|
|
||||||
|
if [ "$skip_fetch" = true ]; then
|
||||||
|
# Side-effect-free: query remotes via ls-remote
|
||||||
|
local highest_remote=$(get_highest_from_remote_refs)
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||||
|
highest_branch=$highest_remote
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
|
git fetch --all --prune >/dev/null 2>&1 || true
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get highest number from ALL specs (not just matching short name)
|
||||||
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||||
|
|
||||||
|
# Take the maximum of both
|
||||||
|
local max_num=$highest_branch
|
||||||
|
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||||
|
max_num=$highest_spec
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return next number
|
||||||
|
echo $((max_num + 1))
|
||||||
|
}
|
||||||
|
|
||||||
# Function to clean and format a branch name
|
# Function to clean and format a branch name
|
||||||
clean_branch_name() {
|
clean_branch_name() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve repository root using common.sh functions which prioritize .specify
|
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
REPO_ROOT=$(get_repo_root)
|
REPO_ROOT=$(get_repo_root)
|
||||||
|
|
||||||
|
# Check if git is available at this repo root (not a parent)
|
||||||
|
if has_git; then
|
||||||
|
HAS_GIT=true
|
||||||
|
else
|
||||||
|
HAS_GIT=false
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
SPECS_DIR="$REPO_ROOT/specs"
|
SPECS_DIR="$REPO_ROOT/specs"
|
||||||
@@ -200,10 +276,23 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
|||||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
else
|
else
|
||||||
# Determine branch number from existing feature directories
|
# Determine branch number
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||||
|
elif [ "$DRY_RUN" = true ]; then
|
||||||
|
# Dry-run without git: local spec dirs only
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
elif [ "$HAS_GIT" = true ]; then
|
||||||
|
# Check existing branches on remotes
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
|
else
|
||||||
|
# Fall back to local directory check
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||||
@@ -237,13 +326,43 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
|||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
|
|
||||||
if [ "$DRY_RUN" != true ]; then
|
if [ "$DRY_RUN" != true ]; then
|
||||||
if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
|
if [ "$HAS_GIT" = true ]; then
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
branch_create_error=""
|
||||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
|
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||||
else
|
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
|
# Check if branch already exists
|
||||||
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
|
if [ "$ALLOW_EXISTING" = true ]; then
|
||||||
|
# If we're already on the branch, continue without another checkout.
|
||||||
|
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||||
|
:
|
||||||
|
# Otherwise switch to the existing branch instead of failing.
|
||||||
|
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||||
|
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||||
|
if [ -n "$switch_branch_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$switch_branch_error"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||||
|
if [ -n "$branch_create_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$branch_create_error"
|
||||||
|
else
|
||||||
|
>&2 echo "Please check your git configuration and try again."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
exit 1
|
else
|
||||||
|
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
@@ -258,12 +377,8 @@ if [ "$DRY_RUN" != true ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
|
||||||
|
|
||||||
# Inform the user how to set feature state in their own shell
|
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $JSON_MODE; then
|
if $JSON_MODE; then
|
||||||
@@ -294,6 +409,5 @@ else
|
|||||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||||
if [ "$DRY_RUN" != true ]; then
|
if [ "$DRY_RUN" != true ]; then
|
||||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
|||||||
eval "$_paths_output"
|
eval "$_paths_output"
|
||||||
unset _paths_output
|
unset _paths_output
|
||||||
|
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure the feature directory exists
|
# Ensure the feature directory exists
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
@@ -70,15 +75,17 @@ if $JSON_MODE; then
|
|||||||
--arg impl_plan "$IMPL_PLAN" \
|
--arg impl_plan "$IMPL_PLAN" \
|
||||||
--arg specs_dir "$FEATURE_DIR" \
|
--arg specs_dir "$FEATURE_DIR" \
|
||||||
--arg branch "$CURRENT_BRANCH" \
|
--arg branch "$CURRENT_BRANCH" \
|
||||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
|
--arg has_git "$HAS_GIT" \
|
||||||
|
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||||
else
|
else
|
||||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
|
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
|
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||||
echo "SPECS_DIR: $FEATURE_DIR"
|
echo "SPECS_DIR: $FEATURE_DIR"
|
||||||
echo "BRANCH: $CURRENT_BRANCH"
|
echo "BRANCH: $CURRENT_BRANCH"
|
||||||
|
echo "HAS_GIT: $HAS_GIT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
|||||||
eval "$_paths_output"
|
eval "$_paths_output"
|
||||||
unset _paths_output
|
unset _paths_output
|
||||||
|
|
||||||
# Validate required files
|
# Validate branch
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ if ($PathsOnly) {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Validate branch name
|
||||||
|
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Validate required directories and files
|
# Validate required directories and files
|
||||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get repository root, prioritizing .specify directory
|
# Get repository root, prioritizing .specify directory over git
|
||||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||||
function Get-RepoRoot {
|
function Get-RepoRoot {
|
||||||
# First, look for .specify directory (spec-kit's own marker)
|
# First, look for .specify directory (spec-kit's own marker)
|
||||||
$specifyRoot = Find-SpecifyRoot
|
$specifyRoot = Find-SpecifyRoot
|
||||||
@@ -33,81 +33,263 @@ function Get-RepoRoot {
|
|||||||
return $specifyRoot
|
return $specifyRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
# Final fallback to script location
|
# Fallback to git if no .specify found
|
||||||
|
try {
|
||||||
|
$result = git rev-parse --show-toplevel 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Git command failed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final fallback to script location for non-git repos
|
||||||
# Use -LiteralPath to handle paths with wildcard characters
|
# Use -LiteralPath to handle paths with wildcard characters
|
||||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-CurrentBranch {
|
function Get-CurrentBranch {
|
||||||
# Return feature name from explicit state only.
|
# First check if SPECIFY_FEATURE environment variable is set
|
||||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
|
||||||
# the git extension) or implicitly via .specify/feature.json.
|
|
||||||
if ($env:SPECIFY_FEATURE) {
|
if ($env:SPECIFY_FEATURE) {
|
||||||
return $env:SPECIFY_FEATURE
|
return $env:SPECIFY_FEATURE
|
||||||
}
|
}
|
||||||
|
|
||||||
# No explicit feature set - return empty to signal "unknown".
|
# Then check git if available at the spec-kit root (not parent)
|
||||||
return ""
|
$repoRoot = Get-RepoRoot
|
||||||
}
|
if (Test-HasGit) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Persist a feature_directory value to .specify/feature.json.
|
|
||||||
# Writes only when the file is missing or the value differs from what's stored.
|
|
||||||
function Save-FeatureJson {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
|
||||||
[Parameter(Mandatory = $true)][string]$FeatureDirectory
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strip repo root prefix if the value is absolute and under repo root.
|
|
||||||
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
|
|
||||||
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
|
|
||||||
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
|
|
||||||
if ($onWin) {
|
|
||||||
$cmp = [System.StringComparison]::OrdinalIgnoreCase
|
|
||||||
} else {
|
|
||||||
$cmp = [System.StringComparison]::Ordinal
|
|
||||||
}
|
|
||||||
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
|
|
||||||
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
|
|
||||||
}
|
|
||||||
|
|
||||||
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
|
||||||
|
|
||||||
# Read current value and skip write when unchanged
|
|
||||||
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
|
|
||||||
try {
|
try {
|
||||||
$raw = Get-Content -LiteralPath $fjPath -Raw
|
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
|
||||||
$cfg = $raw | ConvertFrom-Json
|
if ($LASTEXITCODE -eq 0) {
|
||||||
if ($cfg.feature_directory -eq $FeatureDirectory) {
|
return $result
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
# File is corrupt or unreadable - overwrite it
|
# Git command failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ensure .specify/ directory exists
|
# For non-git repos, try to find the latest feature directory
|
||||||
$specifyDir = Join-Path $RepoRoot '.specify'
|
$specsDir = Join-Path $repoRoot "specs"
|
||||||
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
|
|
||||||
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
|
if (Test-Path $specsDir) {
|
||||||
|
$latestFeature = ""
|
||||||
|
$highest = 0
|
||||||
|
$latestTimestamp = ""
|
||||||
|
|
||||||
|
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||||
|
if ($_.Name -match '^(\d{8}-\d{6})-') {
|
||||||
|
# Timestamp-based branch: compare lexicographically
|
||||||
|
$ts = $matches[1]
|
||||||
|
if ($ts -gt $latestTimestamp) {
|
||||||
|
$latestTimestamp = $ts
|
||||||
|
$latestFeature = $_.Name
|
||||||
|
}
|
||||||
|
} elseif ($_.Name -match '^(\d{3,})-') {
|
||||||
|
$num = [long]$matches[1]
|
||||||
|
if ($num -gt $highest) {
|
||||||
|
$highest = $num
|
||||||
|
# Only update if no timestamp branch found yet
|
||||||
|
if (-not $latestTimestamp) {
|
||||||
|
$latestFeature = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestFeature) {
|
||||||
|
return $latestFeature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final fallback
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we have git available at the spec-kit root level
|
||||||
|
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||||
|
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||||
|
function Test-HasGit {
|
||||||
|
# First check if git command is available (before calling Get-RepoRoot which may use git)
|
||||||
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$repoRoot = Get-RepoRoot
|
||||||
|
# Check if .git exists (directory or file for worktrees/submodules)
|
||||||
|
# Use -LiteralPath to handle paths with wildcard characters
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
# Verify it's actually a valid git work tree
|
||||||
|
try {
|
||||||
|
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||||
|
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||||
|
function Get-SpecKitEffectiveBranchName {
|
||||||
|
param([string]$Branch)
|
||||||
|
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||||
|
return $Matches[2]
|
||||||
|
}
|
||||||
|
return $Branch
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-FeatureBranch {
|
||||||
|
param(
|
||||||
|
[string]$Branch,
|
||||||
|
[bool]$HasGit = $true
|
||||||
|
)
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if (-not $HasGit) {
|
||||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||||
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write feature.json
|
$raw = $Branch
|
||||||
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
|
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
||||||
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||||
|
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||||
|
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||||
|
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||||
|
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||||
|
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||||
|
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||||
|
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||||
|
function Test-FeatureJsonMatchesFeatureDir {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
|
||||||
|
)
|
||||||
|
|
||||||
|
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||||
|
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$raw = Get-Content -LiteralPath $featureJson -Raw
|
||||||
|
$cfg = $raw | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$fd = $cfg.feature_directory
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($fd)) {
|
||||||
|
$fd = Join-Path $RepoRoot $fd
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
|
||||||
|
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
|
||||||
|
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
|
||||||
|
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
|
||||||
|
if ($resolvedJson) {
|
||||||
|
$normJson = $resolvedJson.Path
|
||||||
|
} else {
|
||||||
|
$normJson = [System.IO.Path]::GetFullPath($fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
|
||||||
|
if ($resolvedActive) {
|
||||||
|
$normActive = $resolvedActive.Path
|
||||||
|
} else {
|
||||||
|
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
|
||||||
|
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
|
||||||
|
# absence as "we're on Windows".
|
||||||
|
if ($null -ne $IsWindows) {
|
||||||
|
$onWindows = $IsWindows
|
||||||
|
} else {
|
||||||
|
$onWindows = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($onWindows) {
|
||||||
|
$comparison = [System.StringComparison]::OrdinalIgnoreCase
|
||||||
|
} else {
|
||||||
|
$comparison = [System.StringComparison]::Ordinal
|
||||||
|
}
|
||||||
|
|
||||||
|
return [string]::Equals($normJson, $normActive, $comparison)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
|
||||||
|
function Find-FeatureDirByPrefix {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Branch
|
||||||
|
)
|
||||||
|
$specsDir = Join-Path $RepoRoot 'specs'
|
||||||
|
$branchName = Get-SpecKitEffectiveBranchName $Branch
|
||||||
|
|
||||||
|
$prefix = $null
|
||||||
|
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||||
|
$prefix = $Matches[1]
|
||||||
|
} elseif ($branchName -match '^(\d{3,})-') {
|
||||||
|
$prefix = $Matches[1]
|
||||||
|
} else {
|
||||||
|
return (Join-Path $specsDir $branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
$dirMatches = @()
|
||||||
|
if (Test-Path -LiteralPath $specsDir -PathType Container) {
|
||||||
|
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dirMatches.Count -eq 0) {
|
||||||
|
return (Join-Path $specsDir $branchName)
|
||||||
|
}
|
||||||
|
if ($dirMatches.Count -eq 1) {
|
||||||
|
return $dirMatches[0].FullName
|
||||||
|
}
|
||||||
|
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
|
||||||
|
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
|
||||||
|
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
|
||||||
|
function Get-FeatureDirFromBranchPrefixOrExit {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||||
|
[Parameter(Mandatory = $true)][string]$CurrentBranch
|
||||||
|
)
|
||||||
|
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
|
||||||
|
if ($null -eq $resolved) {
|
||||||
|
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-FeaturePathsEnv {
|
function Get-FeaturePathsEnv {
|
||||||
$repoRoot = Get-RepoRoot
|
$repoRoot = Get-RepoRoot
|
||||||
$currentBranch = Get-CurrentBranch
|
$currentBranch = Get-CurrentBranch
|
||||||
|
$hasGit = Test-HasGit
|
||||||
|
|
||||||
# Resolve feature directory. Priority:
|
# Resolve feature directory. Priority:
|
||||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||||
# 3. Error - no feature context available
|
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||||
@@ -115,8 +297,6 @@ function Get-FeaturePathsEnv {
|
|||||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||||
$featureDir = Join-Path $repoRoot $featureDir
|
$featureDir = Join-Path $repoRoot $featureDir
|
||||||
}
|
}
|
||||||
# Persist to feature.json so future sessions without the env var still work
|
|
||||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
|
||||||
} elseif (Test-Path $featureJson) {
|
} elseif (Test-Path $featureJson) {
|
||||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||||
try {
|
try {
|
||||||
@@ -132,17 +312,16 @@ function Get-FeaturePathsEnv {
|
|||||||
$featureDir = Join-Path $repoRoot $featureDir
|
$featureDir = Join-Path $repoRoot $featureDir
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
REPO_ROOT = $repoRoot
|
REPO_ROOT = $repoRoot
|
||||||
CURRENT_BRANCH = $currentBranch
|
CURRENT_BRANCH = $currentBranch
|
||||||
|
HAS_GIT = $hasGit
|
||||||
FEATURE_DIR = $featureDir
|
FEATURE_DIR = $featureDir
|
||||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ if ($Help) {
|
|||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Options:"
|
Write-Host "Options:"
|
||||||
Write-Host " -Json Output in JSON format"
|
Write-Host " -Json Output in JSON format"
|
||||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
|
||||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
Write-Host " -Help Show this help message"
|
Write-Host " -Help Show this help message"
|
||||||
@@ -67,17 +67,111 @@ function Get-HighestNumberFromSpecs {
|
|||||||
return $highest
|
return $highest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract the highest sequential feature number from a list of branch/ref names.
|
||||||
|
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
|
||||||
|
function Get-HighestNumberFromNames {
|
||||||
|
param([string[]]$Names)
|
||||||
|
|
||||||
|
[long]$highest = 0
|
||||||
|
foreach ($name in $Names) {
|
||||||
|
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||||
|
[long]$num = 0
|
||||||
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||||
|
$highest = $num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromBranches {
|
||||||
|
param()
|
||||||
|
|
||||||
|
try {
|
||||||
|
$branches = git branch -a 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||||
|
$cleanNames = $branches | ForEach-Object {
|
||||||
|
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
|
}
|
||||||
|
return Get-HighestNumberFromNames -Names $cleanNames
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Verbose "Could not check Git branches: $_"
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HighestNumberFromRemoteRefs {
|
||||||
|
[long]$highest = 0
|
||||||
|
try {
|
||||||
|
$remotes = git remote 2>$null
|
||||||
|
if ($remotes) {
|
||||||
|
foreach ($remote in $remotes) {
|
||||||
|
$env:GIT_TERMINAL_PROMPT = '0'
|
||||||
|
$refs = git ls-remote --heads $remote 2>$null
|
||||||
|
$env:GIT_TERMINAL_PROMPT = $null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||||
|
$refNames = $refs | ForEach-Object {
|
||||||
|
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||||
|
} | Where-Object { $_ }
|
||||||
|
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||||
|
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Verbose "Could not query remote refs: $_"
|
||||||
|
}
|
||||||
|
return $highest
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return next available branch number. When SkipFetch is true, queries remotes
|
||||||
|
# via ls-remote (read-only) instead of fetching.
|
||||||
|
function Get-NextBranchNumber {
|
||||||
|
param(
|
||||||
|
[string]$SpecsDir,
|
||||||
|
[switch]$SkipFetch
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($SkipFetch) {
|
||||||
|
# Side-effect-free: query remotes via ls-remote
|
||||||
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
|
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||||
|
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||||
|
} else {
|
||||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
|
try {
|
||||||
|
git fetch --all --prune 2>$null | Out-Null
|
||||||
|
} catch {
|
||||||
|
# Ignore fetch errors
|
||||||
|
}
|
||||||
|
$highestBranch = Get-HighestNumberFromBranches
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get highest number from ALL specs (not just matching short name)
|
||||||
|
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||||
|
|
||||||
|
# Take the maximum of both
|
||||||
|
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||||
|
|
||||||
|
# Return next number
|
||||||
|
return $maxNum + 1
|
||||||
|
}
|
||||||
|
|
||||||
function ConvertTo-CleanBranchName {
|
function ConvertTo-CleanBranchName {
|
||||||
param([string]$Name)
|
param([string]$Name)
|
||||||
|
|
||||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||||
}
|
}
|
||||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
|
||||||
. "$PSScriptRoot/common.ps1"
|
. "$PSScriptRoot/common.ps1"
|
||||||
|
|
||||||
# Use common.ps1 functions which prioritize .specify
|
# Use common.ps1 functions which prioritize .specify over git
|
||||||
$repoRoot = Get-RepoRoot
|
$repoRoot = Get-RepoRoot
|
||||||
|
|
||||||
|
# Check if git is available at this repo root (not a parent)
|
||||||
|
$hasGit = Test-HasGit
|
||||||
|
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
||||||
$specsDir = Join-Path $repoRoot 'specs'
|
$specsDir = Join-Path $repoRoot 'specs'
|
||||||
@@ -150,9 +244,21 @@ if ($Timestamp) {
|
|||||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
$branchName = "$featureNum-$branchSuffix"
|
||||||
} else {
|
} else {
|
||||||
# Determine branch number from existing feature directories
|
# Determine branch number
|
||||||
if ($Number -eq 0) {
|
if ($Number -eq 0) {
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
if ($DryRun -and $hasGit) {
|
||||||
|
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||||
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||||
|
} elseif ($DryRun) {
|
||||||
|
# Dry-run without git: local spec dirs only
|
||||||
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
|
} elseif ($hasGit) {
|
||||||
|
# Check existing branches on remotes
|
||||||
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||||
|
} else {
|
||||||
|
# Fall back to local directory check
|
||||||
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
$featureNum = ('{0:000}' -f $Number)
|
||||||
@@ -185,13 +291,58 @@ $featureDir = Join-Path $specsDir $branchName
|
|||||||
$specFile = Join-Path $featureDir 'spec.md'
|
$specFile = Join-Path $featureDir 'spec.md'
|
||||||
|
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
if ($hasGit) {
|
||||||
if ($Timestamp) {
|
$branchCreated = $false
|
||||||
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
$branchCreateError = ''
|
||||||
} else {
|
try {
|
||||||
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$branchCreated = $true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$branchCreateError = $_.Exception.Message
|
||||||
}
|
}
|
||||||
exit 1
|
|
||||||
|
if (-not $branchCreated) {
|
||||||
|
$currentBranch = ''
|
||||||
|
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||||
|
# Check if branch already exists
|
||||||
|
$existingBranch = git branch --list $branchName 2>$null
|
||||||
|
if ($existingBranch) {
|
||||||
|
if ($AllowExistingBranch) {
|
||||||
|
# If we're already on the branch, continue without another checkout.
|
||||||
|
if ($currentBranch -eq $branchName) {
|
||||||
|
# Already on the target branch -- nothing to do
|
||||||
|
} else {
|
||||||
|
# Otherwise switch to the existing branch instead of failing.
|
||||||
|
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
if ($switchBranchError) {
|
||||||
|
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($Timestamp) {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($branchCreateError) {
|
||||||
|
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||||
@@ -208,12 +359,8 @@ if (-not $DryRun) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
|
||||||
|
|
||||||
# Set environment variables for the current session
|
|
||||||
$env:SPECIFY_FEATURE = $branchName
|
$env:SPECIFY_FEATURE = $branchName
|
||||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Json) {
|
if ($Json) {
|
||||||
@@ -221,6 +368,7 @@ if ($Json) {
|
|||||||
BRANCH_NAME = $branchName
|
BRANCH_NAME = $branchName
|
||||||
SPEC_FILE = $specFile
|
SPEC_FILE = $specFile
|
||||||
FEATURE_NUM = $featureNum
|
FEATURE_NUM = $featureNum
|
||||||
|
HAS_GIT = $hasGit
|
||||||
}
|
}
|
||||||
if ($DryRun) {
|
if ($DryRun) {
|
||||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||||
@@ -230,8 +378,8 @@ if ($Json) {
|
|||||||
Write-Output "BRANCH_NAME: $branchName"
|
Write-Output "BRANCH_NAME: $branchName"
|
||||||
Write-Output "SPEC_FILE: $specFile"
|
Write-Output "SPEC_FILE: $specFile"
|
||||||
Write-Output "FEATURE_NUM: $featureNum"
|
Write-Output "FEATURE_NUM: $featureNum"
|
||||||
|
Write-Output "HAS_GIT: $hasGit"
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ if ($Help) {
|
|||||||
# Get all paths and variables from common functions
|
# Get all paths and variables from common functions
|
||||||
$paths = Get-FeaturePathsEnv
|
$paths = Get-FeaturePathsEnv
|
||||||
|
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||||
|
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Ensure the feature directory exists
|
# Ensure the feature directory exists
|
||||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||||
|
|
||||||
@@ -54,6 +61,7 @@ if ($Json) {
|
|||||||
IMPL_PLAN = $paths.IMPL_PLAN
|
IMPL_PLAN = $paths.IMPL_PLAN
|
||||||
SPECS_DIR = $paths.FEATURE_DIR
|
SPECS_DIR = $paths.FEATURE_DIR
|
||||||
BRANCH = $paths.CURRENT_BRANCH
|
BRANCH = $paths.CURRENT_BRANCH
|
||||||
|
HAS_GIT = $paths.HAS_GIT
|
||||||
}
|
}
|
||||||
$result | ConvertTo-Json -Compress
|
$result | ConvertTo-Json -Compress
|
||||||
} else {
|
} else {
|
||||||
@@ -61,4 +69,5 @@ if ($Json) {
|
|||||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||||
|
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ if ($Help) {
|
|||||||
# Source common functions
|
# Source common functions
|
||||||
. "$PSScriptRoot/common.ps1"
|
. "$PSScriptRoot/common.ps1"
|
||||||
|
|
||||||
# Get feature paths
|
# Get feature paths and validate branch
|
||||||
$paths = Get-FeaturePathsEnv
|
$paths = Get-FeaturePathsEnv
|
||||||
|
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||||
|
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ from ._utils import (
|
|||||||
_display_project_path,
|
_display_project_path,
|
||||||
check_tool as check_tool,
|
check_tool as check_tool,
|
||||||
handle_vscode_settings as handle_vscode_settings,
|
handle_vscode_settings as handle_vscode_settings,
|
||||||
|
init_git_repo as init_git_repo,
|
||||||
|
is_git_repo as is_git_repo,
|
||||||
merge_json_files as merge_json_files,
|
merge_json_files as merge_json_files,
|
||||||
run_command as run_command,
|
run_command as run_command,
|
||||||
)
|
)
|
||||||
@@ -451,6 +453,9 @@ def check():
|
|||||||
|
|
||||||
tracker = StepTracker("Check Available Tools")
|
tracker = StepTracker("Check Available Tools")
|
||||||
|
|
||||||
|
tracker.add("git", "Git version control")
|
||||||
|
git_ok = check_tool("git", tracker=tracker)
|
||||||
|
|
||||||
agent_results = {}
|
agent_results = {}
|
||||||
for agent_key, agent_config in AGENT_CONFIG.items():
|
for agent_key, agent_config in AGENT_CONFIG.items():
|
||||||
if agent_key == "generic":
|
if agent_key == "generic":
|
||||||
@@ -478,6 +483,9 @@ def check():
|
|||||||
|
|
||||||
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
|
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
|
||||||
|
|
||||||
|
if not git_ok:
|
||||||
|
console.print("[dim]Tip: Install git for repository management[/dim]")
|
||||||
|
|
||||||
if not any(agent_results.values()):
|
if not any(agent_results.values()):
|
||||||
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,51 @@ def check_tool(tool: str, tracker=None) -> bool:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_repo(path: Path | None = None) -> bool:
|
||||||
|
"""Check if the specified path is inside a git repository."""
|
||||||
|
if path is None:
|
||||||
|
path = Path.cwd()
|
||||||
|
|
||||||
|
if not path.is_dir():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "rev-parse", "--is-inside-work-tree"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
cwd=path,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
|
||||||
|
"""Initialize a git repository in the specified path."""
|
||||||
|
try:
|
||||||
|
original_cwd = Path.cwd()
|
||||||
|
os.chdir(project_path)
|
||||||
|
if not quiet:
|
||||||
|
console.print("[cyan]Initializing git repository...[/cyan]")
|
||||||
|
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
|
||||||
|
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
|
||||||
|
if not quiet:
|
||||||
|
console.print("[green]✓[/green] Git repository initialized")
|
||||||
|
return True, None
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
|
||||||
|
if e.stderr:
|
||||||
|
error_msg += f"\nError: {e.stderr.strip()}"
|
||||||
|
elif e.stdout:
|
||||||
|
error_msg += f"\nOutput: {e.stdout.strip()}"
|
||||||
|
if not quiet:
|
||||||
|
console.print(f"[red]Error initializing git repository:[/red] {e}")
|
||||||
|
return False, error_msg
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
|
||||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||||
"""Handle merging or copying of .vscode/settings.json files.
|
"""Handle merging or copying of .vscode/settings.json files.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from .._assets import (
|
|||||||
get_speckit_version,
|
get_speckit_version,
|
||||||
)
|
)
|
||||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||||
from .._utils import check_tool
|
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||||
|
|
||||||
|
|
||||||
def _stdin_is_interactive() -> bool:
|
def _stdin_is_interactive() -> bool:
|
||||||
@@ -71,6 +71,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||||
|
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||||
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
||||||
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
||||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||||
@@ -78,6 +79,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||||
|
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||||
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
||||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||||
):
|
):
|
||||||
@@ -89,16 +91,18 @@ def register(app: typer.Typer) -> None:
|
|||||||
match the installed CLI version.
|
match the installed CLI version.
|
||||||
|
|
||||||
This command will:
|
This command will:
|
||||||
1. Check that required tools are installed
|
1. Check that required tools are installed (git is optional)
|
||||||
2. Let you choose your coding agent integration, or default to Copilot
|
2. Let you choose your coding agent integration, or default to Copilot
|
||||||
in non-interactive sessions
|
in non-interactive sessions
|
||||||
3. Install bundled Spec Kit templates, scripts, workflow, and shared
|
3. Install bundled Spec Kit templates, scripts, workflow, and shared
|
||||||
project infrastructure
|
project infrastructure
|
||||||
4. Set up coding agent integration commands and optional presets
|
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||||
|
5. Set up coding agent integration commands and optional presets
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
specify init my-project
|
specify init my-project
|
||||||
specify init my-project --integration claude
|
specify init my-project --integration claude
|
||||||
|
specify init my-project --integration copilot --no-git
|
||||||
specify init --ignore-agent-tools my-project
|
specify init --ignore-agent-tools my-project
|
||||||
specify init . --integration claude # Initialize in current directory
|
specify init . --integration claude # Initialize in current directory
|
||||||
specify init . # Initialize in current directory (interactive integration selection)
|
specify init . # Initialize in current directory (interactive integration selection)
|
||||||
@@ -138,6 +142,13 @@ def register(app: typer.Typer) -> None:
|
|||||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if no_git:
|
||||||
|
console.print(
|
||||||
|
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
|
||||||
|
"[yellow]The git extension will no longer be enabled by default "
|
||||||
|
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
if project_name == ".":
|
if project_name == ".":
|
||||||
here = True
|
here = True
|
||||||
project_name = None
|
project_name = None
|
||||||
@@ -150,7 +161,10 @@ def register(app: typer.Typer) -> None:
|
|||||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||||
|
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||||
|
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
dir_existed_before = False
|
dir_existed_before = False
|
||||||
if here:
|
if here:
|
||||||
@@ -239,6 +253,12 @@ def register(app: typer.Typer) -> None:
|
|||||||
|
|
||||||
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
||||||
|
|
||||||
|
should_init_git = False
|
||||||
|
if not no_git:
|
||||||
|
should_init_git = check_tool("git")
|
||||||
|
if not should_init_git:
|
||||||
|
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
||||||
|
|
||||||
if not ignore_agent_tools:
|
if not ignore_agent_tools:
|
||||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||||
if agent_config and agent_config["requires_cli"]:
|
if agent_config and agent_config["requires_cli"]:
|
||||||
@@ -288,12 +308,15 @@ def register(app: typer.Typer) -> None:
|
|||||||
for key, label in [
|
for key, label in [
|
||||||
("chmod", "Ensure scripts executable"),
|
("chmod", "Ensure scripts executable"),
|
||||||
("constitution", "Constitution setup"),
|
("constitution", "Constitution setup"),
|
||||||
|
("git", "Install git extension"),
|
||||||
("workflow", "Install bundled workflow"),
|
("workflow", "Install bundled workflow"),
|
||||||
("agent-context", "Install agent-context extension"),
|
("agent-context", "Install agent-context extension"),
|
||||||
("final", "Finalize"),
|
("final", "Finalize"),
|
||||||
]:
|
]:
|
||||||
tracker.add(key, label)
|
tracker.add(key, label)
|
||||||
|
|
||||||
|
git_default_notice = False
|
||||||
|
|
||||||
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
||||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||||
try:
|
try:
|
||||||
@@ -346,6 +369,55 @@ def register(app: typer.Typer) -> None:
|
|||||||
|
|
||||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||||
|
|
||||||
|
if not no_git:
|
||||||
|
tracker.start("git")
|
||||||
|
git_messages = []
|
||||||
|
git_has_error = False
|
||||||
|
if is_git_repo(project_path):
|
||||||
|
git_messages.append("existing repo detected")
|
||||||
|
elif should_init_git:
|
||||||
|
success, error_msg = init_git_repo(project_path, quiet=True)
|
||||||
|
if success:
|
||||||
|
git_messages.append("initialized")
|
||||||
|
else:
|
||||||
|
git_has_error = True
|
||||||
|
if error_msg:
|
||||||
|
sanitized = error_msg.replace('\n', ' ').strip()
|
||||||
|
git_messages.append(f"init failed: {sanitized[:120]}")
|
||||||
|
else:
|
||||||
|
git_messages.append("init failed")
|
||||||
|
else:
|
||||||
|
git_messages.append("git not available")
|
||||||
|
try:
|
||||||
|
from ..extensions import ExtensionManager
|
||||||
|
bundled_path = _locate_bundled_extension("git")
|
||||||
|
if bundled_path:
|
||||||
|
manager = ExtensionManager(project_path)
|
||||||
|
if manager.registry.is_installed("git"):
|
||||||
|
git_messages.append("extension already installed")
|
||||||
|
else:
|
||||||
|
manager.install_from_directory(
|
||||||
|
bundled_path, get_speckit_version()
|
||||||
|
)
|
||||||
|
git_default_notice = True
|
||||||
|
git_messages.append("extension installed")
|
||||||
|
else:
|
||||||
|
git_has_error = True
|
||||||
|
git_messages.append("bundled extension not found")
|
||||||
|
except Exception as ext_err:
|
||||||
|
git_has_error = True
|
||||||
|
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
|
||||||
|
git_messages.append(
|
||||||
|
f"extension install failed: {sanitized_ext[:120]}"
|
||||||
|
)
|
||||||
|
summary = "; ".join(git_messages)
|
||||||
|
if git_has_error:
|
||||||
|
tracker.error("git", summary)
|
||||||
|
else:
|
||||||
|
tracker.complete("git", summary)
|
||||||
|
else:
|
||||||
|
tracker.skip("git", "--no-git flag")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bundled_wf = _locate_bundled_workflow("speckit")
|
bundled_wf = _locate_bundled_workflow("speckit")
|
||||||
if bundled_wf:
|
if bundled_wf:
|
||||||
@@ -379,9 +451,9 @@ def register(app: typer.Typer) -> None:
|
|||||||
init_opts = {
|
init_opts = {
|
||||||
"ai": selected_ai,
|
"ai": selected_ai,
|
||||||
"integration": resolved_integration.key,
|
"integration": resolved_integration.key,
|
||||||
|
"branch_numbering": branch_numbering or "sequential",
|
||||||
"here": here,
|
"here": here,
|
||||||
"script": selected_script,
|
"script": selected_script,
|
||||||
"feature_numbering": "sequential",
|
|
||||||
"speckit_version": get_speckit_version(),
|
"speckit_version": get_speckit_version(),
|
||||||
}
|
}
|
||||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||||
@@ -524,6 +596,18 @@ def register(app: typer.Typer) -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(security_notice)
|
console.print(security_notice)
|
||||||
|
|
||||||
|
if git_default_notice:
|
||||||
|
default_change_notice = Panel(
|
||||||
|
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||||
|
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
|
||||||
|
"Use [bold]specify extension add git[/bold] after init when needed.",
|
||||||
|
title="[yellow]Notice: Git Default Changing[/yellow]",
|
||||||
|
border_style="yellow",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
console.print(default_change_notice)
|
||||||
|
|
||||||
steps_lines = []
|
steps_lines = []
|
||||||
if not here:
|
if not here:
|
||||||
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
||||||
|
|||||||
@@ -83,12 +83,11 @@ Given that feature description, do this:
|
|||||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||||
2. Otherwise, auto-generate it under `specs/`:
|
2. Otherwise, auto-generate it under `specs/`:
|
||||||
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
|
- Check `.specify/init-options.json` for `branch_numbering`
|
||||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||||
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
|
|
||||||
|
|
||||||
**Create the directory and spec file**:
|
**Create the directory and spec file**:
|
||||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Tests for the bundled git extension (extensions/git/).
|
|||||||
|
|
||||||
Validates:
|
Validates:
|
||||||
- extension.yml manifest
|
- extension.yml manifest
|
||||||
- Bash scripts (create-new-feature-branch.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
||||||
- PowerShell scripts (where pwsh is available)
|
- PowerShell scripts (where pwsh is available)
|
||||||
- Config reading from git-config.yml
|
- Config reading from git-config.yml
|
||||||
- Extension install via ExtensionManager
|
- Extension install via ExtensionManager
|
||||||
@@ -193,11 +193,11 @@ class TestGitExtensionInstall:
|
|||||||
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
|
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
|
||||||
|
|
||||||
ext_installed = tmp_path / ".specify" / "extensions" / "git"
|
ext_installed = tmp_path / ".specify" / "extensions" / "git"
|
||||||
assert (ext_installed / "scripts" / "bash" / "create-new-feature-branch.sh").is_file()
|
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file()
|
||||||
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
|
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
|
||||||
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
|
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
|
||||||
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
|
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
|
||||||
assert (ext_installed / "scripts" / "powershell" / "create-new-feature-branch.ps1").is_file()
|
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file()
|
||||||
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
|
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
|
||||||
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
|
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
|
||||||
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
|
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
|
||||||
@@ -270,16 +270,16 @@ class TestInitializeRepoPowerShell:
|
|||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
# ── create-new-feature-branch.sh Tests ──────────────────────────────────────────────
|
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
class TestCreateFeatureBash:
|
class TestCreateFeatureBash:
|
||||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||||
"""Extension create-new-feature-branch.sh creates sequential branch."""
|
"""Extension create-new-feature.sh creates sequential branch."""
|
||||||
project = _setup_project(tmp_path)
|
project = _setup_project(tmp_path)
|
||||||
result = _run_bash(
|
result = _run_bash(
|
||||||
"create-new-feature-branch.sh", project,
|
"create-new-feature.sh", project,
|
||||||
"--json", "--short-name", "user-auth", "Add user authentication",
|
"--json", "--short-name", "user-auth", "Add user authentication",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -288,10 +288,10 @@ class TestCreateFeatureBash:
|
|||||||
assert data["FEATURE_NUM"] == "001"
|
assert data["FEATURE_NUM"] == "001"
|
||||||
|
|
||||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||||
"""Extension create-new-feature-branch.sh creates timestamp branch."""
|
"""Extension create-new-feature.sh creates timestamp branch."""
|
||||||
project = _setup_project(tmp_path)
|
project = _setup_project(tmp_path)
|
||||||
result = _run_bash(
|
result = _run_bash(
|
||||||
"create-new-feature-branch.sh", project,
|
"create-new-feature.sh", project,
|
||||||
"--json", "--timestamp", "--short-name", "feat", "Feature",
|
"--json", "--timestamp", "--short-name", "feat", "Feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -305,7 +305,7 @@ class TestCreateFeatureBash:
|
|||||||
(project / "specs" / "002-second").mkdir(parents=True)
|
(project / "specs" / "002-second").mkdir(parents=True)
|
||||||
|
|
||||||
result = _run_bash(
|
result = _run_bash(
|
||||||
"create-new-feature-branch.sh", project,
|
"create-new-feature.sh", project,
|
||||||
"--json", "--short-name", "third", "Third feature",
|
"--json", "--short-name", "third", "Third feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -313,10 +313,10 @@ class TestCreateFeatureBash:
|
|||||||
assert data["FEATURE_NUM"] == "003"
|
assert data["FEATURE_NUM"] == "003"
|
||||||
|
|
||||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||||
"""create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
|
"""create-new-feature.sh works without git (outputs branch name, skips branch creation)."""
|
||||||
project = _setup_project(tmp_path, git=False)
|
project = _setup_project(tmp_path, git=False)
|
||||||
result = _run_bash(
|
result = _run_bash(
|
||||||
"create-new-feature-branch.sh", project,
|
"create-new-feature.sh", project,
|
||||||
"--json", "--short-name", "no-git", "No git feature",
|
"--json", "--short-name", "no-git", "No git feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -329,7 +329,7 @@ class TestCreateFeatureBash:
|
|||||||
"""--dry-run computes branch name without creating anything."""
|
"""--dry-run computes branch name without creating anything."""
|
||||||
project = _setup_project(tmp_path)
|
project = _setup_project(tmp_path)
|
||||||
result = _run_bash(
|
result = _run_bash(
|
||||||
"create-new-feature-branch.sh", project,
|
"create-new-feature.sh", project,
|
||||||
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
|
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -341,10 +341,10 @@ class TestCreateFeatureBash:
|
|||||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||||
class TestCreateFeaturePowerShell:
|
class TestCreateFeaturePowerShell:
|
||||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||||
"""Extension create-new-feature-branch.ps1 creates sequential branch."""
|
"""Extension create-new-feature.ps1 creates sequential branch."""
|
||||||
project = _setup_project(tmp_path)
|
project = _setup_project(tmp_path)
|
||||||
result = _run_pwsh(
|
result = _run_pwsh(
|
||||||
"create-new-feature-branch.ps1", project,
|
"create-new-feature.ps1", project,
|
||||||
"-Json", "-ShortName", "user-auth", "Add user authentication",
|
"-Json", "-ShortName", "user-auth", "Add user authentication",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -352,10 +352,10 @@ class TestCreateFeaturePowerShell:
|
|||||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||||
|
|
||||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||||
"""Extension create-new-feature-branch.ps1 creates timestamp branch."""
|
"""Extension create-new-feature.ps1 creates timestamp branch."""
|
||||||
project = _setup_project(tmp_path)
|
project = _setup_project(tmp_path)
|
||||||
result = _run_pwsh(
|
result = _run_pwsh(
|
||||||
"create-new-feature-branch.ps1", project,
|
"create-new-feature.ps1", project,
|
||||||
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
|
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -363,10 +363,10 @@ class TestCreateFeaturePowerShell:
|
|||||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||||
|
|
||||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||||
"""create-new-feature-branch.ps1 works without git."""
|
"""create-new-feature.ps1 works without git."""
|
||||||
project = _setup_project(tmp_path, git=False)
|
project = _setup_project(tmp_path, git=False)
|
||||||
result = _run_pwsh(
|
result = _run_pwsh(
|
||||||
"create-new-feature-branch.ps1", project,
|
"create-new-feature.ps1", project,
|
||||||
"-Json", "-ShortName", "no-git", "No git feature",
|
"-Json", "-ShortName", "no-git", "No git feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class TestInitIntegrationFlag:
|
|||||||
try:
|
try:
|
||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -111,7 +111,7 @@ class TestInitIntegrationFlag:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
project = tmp_path / "noninteractive"
|
project = tmp_path / "noninteractive"
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", str(project), "--script", "sh", "--ignore-agent-tools",
|
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
@@ -131,7 +131,7 @@ class TestInitIntegrationFlag:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -160,6 +160,7 @@ class TestInitIntegrationFlag:
|
|||||||
"copilot",
|
"copilot",
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--preset",
|
"--preset",
|
||||||
"lean",
|
"lean",
|
||||||
],
|
],
|
||||||
@@ -191,7 +192,7 @@ class TestInitIntegrationFlag:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
|
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -632,6 +633,7 @@ class TestInitIntegrationFlag:
|
|||||||
"init", "--here", "--force",
|
"init", "--here", "--force",
|
||||||
"--integration", "copilot",
|
"--integration", "copilot",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -661,6 +663,7 @@ class TestInitIntegrationFlag:
|
|||||||
"init", "--here",
|
"init", "--here",
|
||||||
"--integration", "copilot",
|
"--integration", "copilot",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
], input="y\n", catch_exceptions=False)
|
], input="y\n", catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -689,7 +692,7 @@ class TestForceExistingDirectory:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", str(target), "--integration", "copilot", "--force",
|
"init", str(target), "--integration", "copilot", "--force",
|
||||||
"--script", "sh",
|
"--no-git", "--script", "sh",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
||||||
@@ -712,22 +715,22 @@ class TestForceExistingDirectory:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", str(target), "--integration", "copilot",
|
"init", str(target), "--integration", "copilot",
|
||||||
"--script", "sh",
|
"--no-git", "--script", "sh",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "already exists" in _normalize_cli_output(result.output)
|
assert "already exists" in _normalize_cli_output(result.output)
|
||||||
|
|
||||||
|
|
||||||
class TestGitExtensionOptIn:
|
class TestGitExtensionAutoInstall:
|
||||||
"""Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
|
"""Tests for auto-installation of the git extension during specify init."""
|
||||||
|
|
||||||
def test_git_extension_not_auto_installed(self, tmp_path):
|
def test_git_extension_auto_installed(self, tmp_path):
|
||||||
"""Git extension is NOT installed automatically during init."""
|
"""Without --no-git, the git extension is installed during init."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
project = tmp_path / "git-opt-in"
|
project = tmp_path / "git-auto"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -742,16 +745,30 @@ class TestGitExtensionOptIn:
|
|||||||
|
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
|
|
||||||
# Git extension directory should NOT be present after init
|
# Check that the tracker didn't report a git error
|
||||||
ext_dir = project / ".specify" / "extensions" / "git"
|
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
|
||||||
assert not ext_dir.exists(), "git extension should not be auto-installed"
|
|
||||||
|
|
||||||
def test_no_git_flag_is_rejected(self, tmp_path):
|
# Git extension files should be installed
|
||||||
"""--no-git flag has been removed; passing it should fail."""
|
ext_dir = project / ".specify" / "extensions" / "git"
|
||||||
|
assert ext_dir.exists(), "git extension directory not installed"
|
||||||
|
assert (ext_dir / "extension.yml").exists()
|
||||||
|
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
|
||||||
|
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
|
||||||
|
|
||||||
|
# Hooks should be registered
|
||||||
|
extensions_yml = project / ".specify" / "extensions.yml"
|
||||||
|
assert extensions_yml.exists(), "extensions.yml not created"
|
||||||
|
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
|
||||||
|
assert "hooks" in hooks_data
|
||||||
|
assert "before_specify" in hooks_data["hooks"]
|
||||||
|
assert "before_constitution" in hooks_data["hooks"]
|
||||||
|
|
||||||
|
def test_no_git_skips_extension(self, tmp_path):
|
||||||
|
"""With --no-git, the git extension is NOT installed."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
project = tmp_path / "no-git-rejected"
|
project = tmp_path / "no-git"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -760,19 +777,75 @@ class TestGitExtensionOptIn:
|
|||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||||
"--no-git", "--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
])
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
|
||||||
|
|
||||||
def test_git_extension_commands_not_registered_by_default(self, tmp_path):
|
# Git extension should NOT be installed
|
||||||
"""Git extension commands are NOT registered with the agent during default init."""
|
ext_dir = project / ".specify" / "extensions" / "git"
|
||||||
|
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
||||||
|
|
||||||
|
def test_no_git_emits_deprecation_warning(self, tmp_path):
|
||||||
|
"""Using --no-git emits a visible deprecation warning."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
project = tmp_path / "git-cmds-absent"
|
project = tmp_path / "no-git-warn"
|
||||||
|
project.mkdir()
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(project)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||||
|
"--no-git", "--ignore-agent-tools",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
|
normalized_output = _normalize_cli_output(result.output)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "--no-git" in normalized_output
|
||||||
|
assert "deprecated" in normalized_output
|
||||||
|
assert "0.10.0" in normalized_output
|
||||||
|
assert "specify extension" in normalized_output
|
||||||
|
assert "will be removed" in normalized_output
|
||||||
|
assert "git extension will no longer be enabled by default" in normalized_output
|
||||||
|
|
||||||
|
def test_default_git_auto_enable_emits_notice(self, tmp_path):
|
||||||
|
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project = tmp_path / "git-default-notice"
|
||||||
|
project.mkdir()
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(project)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||||
|
"--ignore-agent-tools",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
|
normalized_output = _normalize_cli_output(result.output)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Check for key message components (notice may have box-drawing chars)
|
||||||
|
assert "git extension is currently enabled by default" in normalized_output
|
||||||
|
assert "v0.10.0" in normalized_output
|
||||||
|
assert "explicit opt-in" in normalized_output
|
||||||
|
assert "specify extension add git" in normalized_output
|
||||||
|
|
||||||
|
def test_git_extension_commands_registered(self, tmp_path):
|
||||||
|
"""Git extension commands are registered with the agent during init."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project = tmp_path / "git-cmds"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -787,11 +860,11 @@ class TestGitExtensionOptIn:
|
|||||||
|
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
|
|
||||||
# Git extension skill commands should NOT be present
|
# Git extension commands should be registered with the agent
|
||||||
claude_skills = project / ".claude" / "skills"
|
claude_skills = project / ".claude" / "skills"
|
||||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||||
assert len(git_skills) == 0, "git extension commands should not be registered by default"
|
assert len(git_skills) > 0, "no git extension commands registered"
|
||||||
|
|
||||||
|
|
||||||
class TestSharedInfraCommandRefs:
|
class TestSharedInfraCommandRefs:
|
||||||
@@ -910,6 +983,7 @@ class TestSharedInfraCommandRefs:
|
|||||||
"init", str(project),
|
"init", str(project),
|
||||||
"--integration", "claude",
|
"--integration", "claude",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -940,6 +1014,7 @@ class TestSharedInfraCommandRefs:
|
|||||||
"init", str(project),
|
"init", str(project),
|
||||||
"--integration", "copilot",
|
"--integration", "copilot",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -971,6 +1046,7 @@ class TestSharedInfraCommandRefs:
|
|||||||
"--integration", "copilot",
|
"--integration", "copilot",
|
||||||
"--integration-options", "--skills",
|
"--integration-options", "--skills",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class TestAgyInitFlow:
|
|||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
||||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
@@ -52,7 +52,7 @@ class TestAgyInitFlow:
|
|||||||
# Click >= 8.2 separates stdout and stderr natively
|
# Click >= 8.2 separates stdout and stderr natively
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj2"
|
target = tmp_path / "test-proj2"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class MarkdownIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -213,7 +213,7 @@ class MarkdownIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -238,7 +238,7 @@ class MarkdownIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -321,13 +321,13 @@ class MarkdownIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(p.relative_to(project).as_posix()
|
actual = sorted(p.relative_to(project).as_posix()
|
||||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
for p in project.rglob("*") if p.is_file())
|
||||||
expected = self._expected_files("sh")
|
expected = self._expected_files("sh")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||||
@@ -346,13 +346,13 @@ class MarkdownIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||||
"--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(p.relative_to(project).as_posix()
|
actual = sorted(p.relative_to(project).as_posix()
|
||||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
for p in project.rglob("*") if p.is_file())
|
||||||
expected = self._expected_files("ps")
|
expected = self._expected_files("ps")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ class SkillsIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -346,7 +346,7 @@ class SkillsIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -369,7 +369,7 @@ class SkillsIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -471,15 +471,15 @@ class SkillsIntegrationTests:
|
|||||||
try:
|
try:
|
||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY,
|
||||||
"--ignore-agent-tools",
|
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix()
|
p.relative_to(project).as_posix()
|
||||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = self._expected_files("sh")
|
expected = self._expected_files("sh")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
@@ -498,15 +498,15 @@ class SkillsIntegrationTests:
|
|||||||
try:
|
try:
|
||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
"init", "--here", "--integration", self.KEY,
|
||||||
"--ignore-agent-tools",
|
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix()
|
p.relative_to(project).as_posix()
|
||||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = self._expected_files("ps")
|
expected = self._expected_files("ps")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ class TomlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -439,6 +440,7 @@ class TomlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -467,7 +469,7 @@ class TomlIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -578,6 +580,7 @@ class TomlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -586,7 +589,7 @@ class TomlIntegrationTests:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = self._expected_files("sh")
|
expected = self._expected_files("sh")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
@@ -613,6 +616,7 @@ class TomlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"ps",
|
"ps",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -621,7 +625,7 @@ class TomlIntegrationTests:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = self._expected_files("ps")
|
expected = self._expected_files("ps")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ class YamlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -318,6 +319,7 @@ class YamlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -346,7 +348,7 @@ class YamlIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -457,6 +459,7 @@ class YamlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -465,7 +468,7 @@ class YamlIntegrationTests:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = self._expected_files("sh")
|
expected = self._expected_files("sh")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
@@ -492,6 +495,7 @@ class YamlIntegrationTests:
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"ps",
|
"ps",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -500,7 +504,7 @@ class YamlIntegrationTests:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = self._expected_files("ps")
|
expected = self._expected_files("ps")
|
||||||
assert actual == expected, (
|
assert actual == expected, (
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ class TestIntegrationListCatalog:
|
|||||||
"init", "--here",
|
"init", "--here",
|
||||||
"--integration", "copilot",
|
"--integration", "copilot",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -555,6 +556,7 @@ class TestIntegrationUpgrade:
|
|||||||
"init", "--here",
|
"init", "--here",
|
||||||
"--integration", integration,
|
"--integration", integration,
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class TestClaudeIntegration:
|
|||||||
"claude",
|
"claude",
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -174,6 +175,7 @@ class TestClaudeIntegration:
|
|||||||
"claude",
|
"claude",
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -206,6 +208,7 @@ class TestClaudeIntegration:
|
|||||||
"--here",
|
"--here",
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
@@ -240,7 +243,7 @@ class TestClaudeIntegration:
|
|||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
|
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
|||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class TestCodexInitFlow:
|
|||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
||||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|||||||
@@ -186,12 +186,12 @@ class TestCopilotIntegration:
|
|||||||
try:
|
try:
|
||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||||
expected = sorted([
|
expected = sorted([
|
||||||
".github/agents/speckit.agent-context.update.agent.md",
|
".github/agents/speckit.agent-context.update.agent.md",
|
||||||
".github/agents/speckit.analyze.agent.md",
|
".github/agents/speckit.analyze.agent.md",
|
||||||
@@ -256,12 +256,12 @@ class TestCopilotIntegration:
|
|||||||
try:
|
try:
|
||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot", "--script", "ps",
|
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||||
expected = sorted([
|
expected = sorted([
|
||||||
".github/agents/speckit.agent-context.update.agent.md",
|
".github/agents/speckit.agent-context.update.agent.md",
|
||||||
".github/agents/speckit.analyze.agent.md",
|
".github/agents/speckit.analyze.agent.md",
|
||||||
@@ -622,7 +622,7 @@ class TestCopilotSkillsMode:
|
|||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot",
|
"init", "--here", "--integration", "copilot",
|
||||||
"--integration-options", "--skills",
|
"--integration-options", "--skills",
|
||||||
"--script", "sh",
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -648,12 +648,12 @@ class TestCopilotSkillsMode:
|
|||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot",
|
"init", "--here", "--integration", "copilot",
|
||||||
"--integration-options", "--skills",
|
"--integration-options", "--skills",
|
||||||
"--script", "sh",
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||||
expected = sorted([
|
expected = sorted([
|
||||||
# Skill files (core + extension-installed agent-context command)
|
# Skill files (core + extension-installed agent-context command)
|
||||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||||
@@ -775,6 +775,7 @@ class TestCopilotSkillsMode:
|
|||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot",
|
"init", "--here", "--integration", "copilot",
|
||||||
"--integration-options", "--skills",
|
"--integration-options", "--skills",
|
||||||
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class TestCursorAgentInitFlow:
|
|||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
||||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TestDevinInitFlow:
|
|||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
|
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ class TestGenericIntegration:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||||||
|
"--script", "sh", "--no-git",
|
||||||
])
|
])
|
||||||
# Generic requires --commands-dir via --integration-options
|
# Generic requires --commands-dir via --integration-options
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
@@ -269,7 +270,7 @@ class TestGenericIntegration:
|
|||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "generic",
|
"init", "--here", "--integration", "generic",
|
||||||
"--integration-options=--commands-dir .myagent/commands",
|
"--integration-options=--commands-dir .myagent/commands",
|
||||||
"--script", "sh",
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -291,14 +292,14 @@ class TestGenericIntegration:
|
|||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "generic",
|
"init", "--here", "--integration", "generic",
|
||||||
"--integration-options=--commands-dir .myagent/commands",
|
"--integration-options=--commands-dir .myagent/commands",
|
||||||
"--script", "sh",
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix()
|
p.relative_to(project).as_posix()
|
||||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = sorted([
|
expected = sorted([
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
@@ -355,14 +356,14 @@ class TestGenericIntegration:
|
|||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "generic",
|
"init", "--here", "--integration", "generic",
|
||||||
"--integration-options=--commands-dir .myagent/commands",
|
"--integration-options=--commands-dir .myagent/commands",
|
||||||
"--script", "ps",
|
"--script", "ps", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
actual = sorted(
|
actual = sorted(
|
||||||
p.relative_to(project).as_posix()
|
p.relative_to(project).as_posix()
|
||||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
for p in project.rglob("*") if p.is_file()
|
||||||
)
|
)
|
||||||
expected = sorted([
|
expected = sorted([
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY,
|
"init", "--here", "--integration", self.KEY,
|
||||||
"--script", "sh", "--ignore-agent-tools",
|
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -270,7 +270,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY,
|
"init", "--here", "--integration", self.KEY,
|
||||||
"--script", "ps", "--ignore-agent-tools",
|
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -342,6 +342,7 @@ class TestHermesInitFlow:
|
|||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", str(target),
|
"init", str(target),
|
||||||
"--integration", "hermes",
|
"--integration", "hermes",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class TestKimiNextSteps:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "kimi",
|
"init", "--here", "--integration", "kimi", "--no-git",
|
||||||
"--ignore-agent-tools", "--script", "sh",
|
"--ignore-agent-tools", "--script", "sh",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class TestKiroIntegration:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "kiro-cli",
|
"init", "--here", "--integration", "kiro-cli",
|
||||||
"--ignore-agent-tools", "--script", "sh",
|
"--ignore-agent-tools", "--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ def _run_init(project, *flags: str) -> Result:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
return CliRunner().invoke(
|
return CliRunner().invoke(
|
||||||
app,
|
app,
|
||||||
["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
|
["init", "--here", *flags, "--script", "sh",
|
||||||
|
"--no-git", "--ignore-agent-tools"],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def _init_project(tmp_path, integration="copilot"):
|
|||||||
"init", "--here",
|
"init", "--here",
|
||||||
"--integration", integration,
|
"--integration", integration,
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -960,7 +961,7 @@ class TestIntegrationSwitch:
|
|||||||
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
||||||
"""Switching refreshes managed shared scripts to the target command style."""
|
"""Switching refreshes managed shared scripts to the target command style."""
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||||
assert shared_script.exists()
|
assert shared_script.exists()
|
||||||
shared_content = shared_script.read_text(encoding="utf-8")
|
shared_content = shared_script.read_text(encoding="utf-8")
|
||||||
assert "/speckit-plan" in shared_content
|
assert "/speckit-plan" in shared_content
|
||||||
@@ -986,7 +987,7 @@ class TestIntegrationSwitch:
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Simulate a stale vendored script: write truncated content as bytes
|
# Simulate a stale vendored script: write truncated content as bytes
|
||||||
@@ -998,7 +999,7 @@ class TestIntegrationSwitch:
|
|||||||
|
|
||||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
|
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
|
||||||
hashlib.sha256(stale_bytes).hexdigest()
|
hashlib.sha256(stale_bytes).hexdigest()
|
||||||
)
|
)
|
||||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||||
@@ -1047,7 +1048,7 @@ class TestIntegrationSwitch:
|
|||||||
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
||||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||||
rendered_bytes = shared_script.read_bytes()
|
rendered_bytes = shared_script.read_bytes()
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class TestMultiInstallSafeContracts:
|
|||||||
initial,
|
initial,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
|
|||||||
@@ -1,24 +1,74 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests verifying --branch-numbering removal (v0.10.0).
|
Unit tests for branch numbering options (sequential vs timestamp).
|
||||||
|
|
||||||
Branch numbering is now managed entirely by the git extension's config.
|
Tests cover:
|
||||||
The --branch-numbering flag was removed from `specify init`.
|
- Persisting branch_numbering in init-options.json
|
||||||
|
- Default value when branch_numbering is None
|
||||||
|
- Validation of branch_numbering values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from specify_cli import save_init_options
|
||||||
|
|
||||||
class TestBranchNumberingFlagRemoved:
|
|
||||||
"""--branch-numbering flag was removed in v0.10.0."""
|
|
||||||
|
|
||||||
def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
|
class TestSaveBranchNumbering:
|
||||||
|
"""Tests for save_init_options with branch_numbering."""
|
||||||
|
|
||||||
|
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
|
||||||
|
opts = {"branch_numbering": "timestamp", "ai": "claude"}
|
||||||
|
save_init_options(tmp_path, opts)
|
||||||
|
|
||||||
|
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||||
|
assert saved["branch_numbering"] == "timestamp"
|
||||||
|
|
||||||
|
def test_save_branch_numbering_sequential(self, tmp_path: Path):
|
||||||
|
opts = {"branch_numbering": "sequential", "ai": "claude"}
|
||||||
|
save_init_options(tmp_path, opts)
|
||||||
|
|
||||||
|
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||||
|
assert saved["branch_numbering"] == "sequential"
|
||||||
|
|
||||||
|
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project_dir = tmp_path / "proj"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||||
|
assert saved["branch_numbering"] == "sequential"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchNumberingValidation:
|
||||||
|
"""Tests for branch_numbering CLI validation via CliRunner."""
|
||||||
|
|
||||||
|
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||||
"init", str(tmp_path / "proj"), "--integration", "claude",
|
assert result.exit_code == 1
|
||||||
"--branch-numbering", "sequential", "--ignore-agent-tools",
|
assert "Invalid --branch-numbering" in result.output
|
||||||
])
|
|
||||||
assert result.exit_code != 0, "--branch-numbering should be rejected"
|
def test_valid_branch_numbering_sequential(self, tmp_path: Path):
|
||||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|
||||||
|
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|||||||
@@ -34,15 +34,6 @@ def _install_ps_scripts(repo: Path) -> None:
|
|||||||
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
|
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
|
||||||
|
|
||||||
|
|
||||||
def _write_feature_json(
|
|
||||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
|
||||||
) -> None:
|
|
||||||
(repo / ".specify" / "feature.json").write_text(
|
|
||||||
json.dumps({"feature_directory": feature_directory}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_env() -> dict[str, str]:
|
def _clean_env() -> dict[str, str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
for key in list(env):
|
for key in list(env):
|
||||||
@@ -78,10 +69,7 @@ def prereq_repo(tmp_path: Path) -> Path:
|
|||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||||
"""--paths-only must return paths when feature.json pins the feature dir."""
|
"""--paths-only must return paths without branch validation (main branch)."""
|
||||||
feat = prereq_repo / "specs" / "001-my-feature"
|
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
|
||||||
_write_feature_json(prereq_repo)
|
|
||||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script), "--json", "--paths-only"],
|
["bash", str(script), "--json", "--paths-only"],
|
||||||
@@ -100,20 +88,20 @@ def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
|||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||||
"""--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
|
"""--paths-only must also work on a properly named spec branch."""
|
||||||
feat = prereq_repo / "specs" / "001-my-feature"
|
subprocess.run(
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||||
_write_feature_json(prereq_repo)
|
cwd=prereq_repo,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
env = _clean_env()
|
|
||||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script), "--json", "--paths-only"],
|
["bash", str(script), "--json", "--paths-only"],
|
||||||
cwd=prereq_repo,
|
cwd=prereq_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
env=env,
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
@@ -123,10 +111,7 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
|||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||||
"""--paths-only without --json must return text paths from feature.json."""
|
"""--paths-only without --json must return text paths on a non-spec branch."""
|
||||||
feat = prereq_repo / "specs" / "001-my-feature"
|
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
|
||||||
_write_feature_json(prereq_repo)
|
|
||||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script), "--paths-only"],
|
["bash", str(script), "--paths-only"],
|
||||||
@@ -143,7 +128,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
|||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||||
"""Without --paths-only, feature directory validation must still fail on main."""
|
"""Without --paths-only, branch validation must still fail on main."""
|
||||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script), "--json"],
|
["bash", str(script), "--json"],
|
||||||
@@ -154,7 +139,7 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
|||||||
env=_clean_env(),
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert "Feature directory not found" in result.stderr
|
assert "Not on a feature branch" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||||
@@ -162,10 +147,7 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
|||||||
|
|
||||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||||
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||||
"""-PathsOnly must return paths when feature.json pins the feature dir."""
|
"""-PathsOnly must return paths without branch validation (main branch)."""
|
||||||
feat = prereq_repo / "specs" / "001-my-feature"
|
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
|
||||||
_write_feature_json(prereq_repo)
|
|
||||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -185,26 +167,21 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
|||||||
|
|
||||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||||
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
|
"""-PathsOnly must also work on a properly named spec branch."""
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||||
cwd=prereq_repo,
|
cwd=prereq_repo,
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
feat = prereq_repo / "specs" / "001-my-feature"
|
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
|
||||||
_write_feature_json(prereq_repo)
|
|
||||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
env = _clean_env()
|
|
||||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||||
cwd=prereq_repo,
|
cwd=prereq_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
env=env,
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
@@ -213,7 +190,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
|||||||
|
|
||||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||||
"""Without -PathsOnly, feature directory validation must still fail on main."""
|
"""Without -PathsOnly, branch validation must still fail on main."""
|
||||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -225,5 +202,4 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
|||||||
env=_clean_env(),
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
combined = result.stdout + result.stderr
|
assert "Not on a feature branch" in result.stderr
|
||||||
assert "Feature directory not found" in combined
|
|
||||||
|
|||||||
@@ -41,13 +41,6 @@ def _minimal_templates(repo: Path) -> None:
|
|||||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||||
|
|
||||||
|
|
||||||
def _write_feature_json(repo: Path, feature_directory: str) -> None:
|
|
||||||
(repo / ".specify" / "feature.json").write_text(
|
|
||||||
json.dumps({"feature_directory": feature_directory}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_env() -> dict[str, str]:
|
def _clean_env() -> dict[str, str]:
|
||||||
"""Return a copy of the current environment with any SPECIFY_* vars removed.
|
"""Return a copy of the current environment with any SPECIFY_* vars removed.
|
||||||
|
|
||||||
@@ -96,7 +89,10 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
|
|||||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||||
feat.mkdir(parents=True)
|
feat.mkdir(parents=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
(plan_repo / ".specify" / "feature.json").write_text(
|
||||||
|
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script)],
|
["bash", str(script)],
|
||||||
@@ -111,8 +107,12 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
|
|||||||
|
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
|
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None:
|
||||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-plan must error."""
|
subprocess.run(
|
||||||
|
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||||
|
cwd=plan_repo,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script)],
|
["bash", str(script)],
|
||||||
@@ -123,14 +123,13 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
|
|||||||
env=_clean_env(),
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert "Feature directory not found" in result.stderr
|
assert "Not on a feature branch" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_setup_plan_numbered_branch_works_with_feature_json(
|
def test_setup_plan_numbered_branch_unchanged_without_feature_json(
|
||||||
plan_repo: Path,
|
plan_repo: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A numbered branch still works when feature.json explicitly pins the spec dir."""
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
|
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
|
||||||
cwd=plan_repo,
|
cwd=plan_repo,
|
||||||
@@ -139,7 +138,6 @@ def test_setup_plan_numbered_branch_works_with_feature_json(
|
|||||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||||
feat.mkdir(parents=True)
|
feat.mkdir(parents=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
|
||||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script)],
|
["bash", str(script)],
|
||||||
@@ -163,7 +161,10 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
|||||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||||
feat.mkdir(parents=True)
|
feat.mkdir(parents=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
(plan_repo / ".specify" / "feature.json").write_text(
|
||||||
|
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -179,9 +180,14 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||||
def test_setup_plan_ps_errors_without_feature_context(
|
def test_setup_plan_ps_fails_custom_branch_without_feature_json(
|
||||||
plan_repo: Path,
|
plan_repo: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||||
|
cwd=plan_repo,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -192,6 +198,5 @@ def test_setup_plan_ps_errors_without_feature_context(
|
|||||||
check=False,
|
check=False,
|
||||||
env=_clean_env(),
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
combined = result.stderr + result.stdout
|
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert "Feature directory not found" in combined
|
assert "Not on a feature branch" in result.stderr
|
||||||
|
|||||||
@@ -41,15 +41,6 @@ def _minimal_templates(repo: Path) -> None:
|
|||||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||||
|
|
||||||
|
|
||||||
def _write_feature_json(
|
|
||||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
|
||||||
) -> None:
|
|
||||||
(repo / ".specify" / "feature.json").write_text(
|
|
||||||
json.dumps({"feature_directory": feature_directory}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_env() -> dict[str, str]:
|
def _clean_env() -> dict[str, str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
for key in list(env):
|
for key in list(env):
|
||||||
@@ -83,7 +74,6 @@ def plan_repo(tmp_path: Path) -> Path:
|
|||||||
_minimal_templates(repo)
|
_minimal_templates(repo)
|
||||||
_install_bash_scripts(repo)
|
_install_bash_scripts(repo)
|
||||||
_install_ps_scripts(repo)
|
_install_ps_scripts(repo)
|
||||||
_write_feature_json(repo)
|
|
||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
|
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -50,15 +50,6 @@ def _install_core_tasks_template(repo: Path) -> None:
|
|||||||
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
|
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
|
||||||
|
|
||||||
|
|
||||||
def _write_feature_json(
|
|
||||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
|
||||||
) -> None:
|
|
||||||
(repo / ".specify" / "feature.json").write_text(
|
|
||||||
json.dumps({"feature_directory": feature_directory}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_feature(repo: Path) -> Path:
|
def _minimal_feature(repo: Path) -> Path:
|
||||||
"""
|
"""
|
||||||
Create a numbered branch-style feature directory with spec.md and plan.md
|
Create a numbered branch-style feature directory with spec.md and plan.md
|
||||||
@@ -69,7 +60,6 @@ def _minimal_feature(repo: Path) -> Path:
|
|||||||
feat.mkdir(parents=True, exist_ok=True)
|
feat.mkdir(parents=True, exist_ok=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||||
_write_feature_json(repo)
|
|
||||||
return feat
|
return feat
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +85,7 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
|
|||||||
def _clean_env() -> dict[str, str]:
|
def _clean_env() -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Return os.environ with all SPECIFY_* variables stripped so the scripts
|
Return os.environ with all SPECIFY_* variables stripped so the scripts
|
||||||
rely purely on feature.json and on-disk feature directories set up by each fixture.
|
rely purely on git branch + feature.json state set up by each fixture.
|
||||||
"""
|
"""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
for key in list(env):
|
for key in list(env):
|
||||||
@@ -163,8 +153,7 @@ def tasks_repo(tmp_path: Path) -> Path:
|
|||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
_git_init(repo)
|
_git_init(repo)
|
||||||
|
|
||||||
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
|
# Switch to a numbered branch so branch validation passes without feature.json
|
||||||
# feature directories from repository state rather than validating git branches.
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||||
cwd=repo,
|
cwd=repo,
|
||||||
@@ -503,7 +492,6 @@ def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -
|
|||||||
feat = tasks_repo / "specs" / "001-my-feature"
|
feat = tasks_repo / "specs" / "001-my-feature"
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
feat.mkdir(parents=True, exist_ok=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
_write_feature_json(tasks_repo)
|
|
||||||
|
|
||||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||||
|
|
||||||
@@ -562,7 +550,11 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
|||||||
feat.mkdir(parents=True, exist_ok=True)
|
feat.mkdir(parents=True, exist_ok=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||||
_write_feature_json(tasks_repo)
|
|
||||||
|
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||||
|
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||||
|
|
||||||
@@ -579,17 +571,21 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
|||||||
|
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_setup_tasks_bash_errors_without_feature_context(
|
def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||||
tasks_repo: Path,
|
tasks_repo: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
|
"""
|
||||||
main_feat = tasks_repo / "specs" / "main"
|
On a non-standard branch with no feature.json, setup-tasks.sh must fail
|
||||||
main_feat.mkdir(parents=True, exist_ok=True)
|
and report that we are not on a feature branch.
|
||||||
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
"""
|
||||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
subprocess.run(
|
||||||
|
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||||
|
cwd=tasks_repo,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script), "--json"],
|
["bash", str(script), "--json"],
|
||||||
cwd=tasks_repo,
|
cwd=tasks_repo,
|
||||||
@@ -600,7 +596,7 @@ def test_setup_tasks_bash_errors_without_feature_context(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert "Feature directory not found" in result.stderr
|
assert "Not on a feature branch" in result.stderr
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# POWERSHELL TESTS
|
# POWERSHELL TESTS
|
||||||
@@ -735,7 +731,6 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
|
|||||||
feat = tasks_repo / "specs" / "001-my-feature"
|
feat = tasks_repo / "specs" / "001-my-feature"
|
||||||
feat.mkdir(parents=True, exist_ok=True)
|
feat.mkdir(parents=True, exist_ok=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
_write_feature_json(tasks_repo)
|
|
||||||
|
|
||||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
@@ -798,7 +793,11 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
|||||||
feat.mkdir(parents=True, exist_ok=True)
|
feat.mkdir(parents=True, exist_ok=True)
|
||||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||||
_write_feature_json(tasks_repo)
|
|
||||||
|
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||||
|
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
@@ -816,18 +815,22 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||||
def test_setup_tasks_ps_errors_without_feature_context(
|
def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||||
tasks_repo: Path,
|
tasks_repo: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
|
"""
|
||||||
main_feat = tasks_repo / "specs" / "main"
|
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail
|
||||||
main_feat.mkdir(parents=True, exist_ok=True)
|
and report that we are not on a feature branch.
|
||||||
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
"""
|
||||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
subprocess.run(
|
||||||
|
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||||
|
cwd=tasks_repo,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||||
cwd=tasks_repo,
|
cwd=tasks_repo,
|
||||||
@@ -836,7 +839,6 @@ def test_setup_tasks_ps_errors_without_feature_context(
|
|||||||
check=False,
|
check=False,
|
||||||
env=_clean_env(),
|
env=_clean_env(),
|
||||||
)
|
)
|
||||||
|
|
||||||
output = result.stderr + result.stdout
|
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert "Feature directory not found" in output
|
assert "Not on a feature branch" in result.stderr
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|||||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||||
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||||
EXT_CREATE_FEATURE = (
|
EXT_CREATE_FEATURE = (
|
||||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||||
)
|
)
|
||||||
EXT_CREATE_FEATURE_PS = (
|
EXT_CREATE_FEATURE_PS = (
|
||||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||||
)
|
)
|
||||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||||
|
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||||
|
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||||
|
|
||||||
HAS_PWSH = shutil.which("pwsh") is not None
|
HAS_PWSH = shutil.which("pwsh") is not None
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ def ext_git_repo(tmp_path: Path) -> Path:
|
|||||||
# Copy extension script
|
# Copy extension script
|
||||||
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
||||||
ext_dir.mkdir(parents=True)
|
ext_dir.mkdir(parents=True)
|
||||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
|
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
|
||||||
# Also copy git-common.sh if it exists
|
# Also copy git-common.sh if it exists
|
||||||
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||||
if git_common.exists():
|
if git_common.exists():
|
||||||
@@ -104,7 +106,7 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
|
|||||||
# Copy extension script
|
# Copy extension script
|
||||||
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
||||||
ext_ps.mkdir(parents=True)
|
ext_ps.mkdir(parents=True)
|
||||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
|
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
|
||||||
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||||
if git_common_ps.exists():
|
if git_common_ps.exists():
|
||||||
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
|
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
|
||||||
@@ -277,13 +279,64 @@ class TestSequentialBranchPowerShell:
|
|||||||
|
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
class TestCoreCommonRemovesGitHelpers:
|
class TestCheckFeatureBranch:
|
||||||
def test_check_feature_branch_removed(self):
|
def test_accepts_timestamp_branch(self):
|
||||||
result = source_and_call('declare -F check_feature_branch >/dev/null')
|
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||||
|
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_accepts_sequential_branch(self):
|
||||||
|
"""Test 7: check_feature_branch accepts sequential branch."""
|
||||||
|
result = source_and_call('check_feature_branch "004-feat" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_rejects_main(self):
|
||||||
|
"""Test 8: check_feature_branch rejects main."""
|
||||||
|
result = source_and_call('check_feature_branch "main" "true"')
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
|
|
||||||
def test_has_git_removed(self):
|
def test_accepts_four_digit_sequential_branch(self):
|
||||||
result = source_and_call('declare -F has_git >/dev/null')
|
"""check_feature_branch accepts 4+ digit sequential branch."""
|
||||||
|
result = source_and_call('check_feature_branch "1234-feat" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_rejects_partial_timestamp(self):
|
||||||
|
"""Test 9: check_feature_branch rejects 7-digit date."""
|
||||||
|
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_rejects_timestamp_without_slug(self):
|
||||||
|
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
|
||||||
|
result = source_and_call('check_feature_branch "20260319-143022" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_rejects_7digit_timestamp_without_slug(self):
|
||||||
|
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
|
||||||
|
result = source_and_call('check_feature_branch "2026031-143022" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_accepts_single_prefix_sequential(self):
|
||||||
|
"""Optional gitflow-style prefix: one segment + sequential feature name."""
|
||||||
|
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_accepts_single_prefix_timestamp(self):
|
||||||
|
"""Optional prefix + timestamp-style feature name."""
|
||||||
|
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_rejects_invalid_suffix_with_single_prefix(self):
|
||||||
|
result = source_and_call('check_feature_branch "feat/main" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "feat/main" in result.stderr
|
||||||
|
|
||||||
|
def test_rejects_two_level_prefix_before_feature(self):
|
||||||
|
"""More than one slash: no stripping; whole name must match (fails)."""
|
||||||
|
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_rejects_malformed_timestamp_with_prefix(self):
|
||||||
|
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
@@ -291,11 +344,50 @@ class TestCoreCommonRemovesGitHelpers:
|
|||||||
|
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
class TestFindFeatureDirByPrefixRemoved:
|
class TestFindFeatureDirByPrefix:
|
||||||
def test_find_feature_dir_by_prefix_removed(self):
|
def test_timestamp_branch(self, tmp_path: Path):
|
||||||
"""Directory scanning helper is removed from core common.sh."""
|
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||||
result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
|
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
|
||||||
assert result.returncode != 0
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
|
||||||
|
|
||||||
|
def test_cross_branch_prefix(self, tmp_path: Path):
|
||||||
|
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
|
||||||
|
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
|
||||||
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
|
||||||
|
|
||||||
|
def test_four_digit_sequential_prefix(self, tmp_path: Path):
|
||||||
|
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
|
||||||
|
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
|
||||||
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
|
||||||
|
|
||||||
|
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
|
||||||
|
"""Strip one optional prefix segment before prefix directory lookup."""
|
||||||
|
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
|
||||||
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
|
||||||
|
|
||||||
|
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
|
||||||
|
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
|
||||||
|
result = source_and_call(
|
||||||
|
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
|
||||||
|
|
||||||
|
|
||||||
# ── get_feature_paths + single-prefix integration ───────────────────────────
|
# ── get_feature_paths + single-prefix integration ───────────────────────────
|
||||||
@@ -303,29 +395,26 @@ class TestFindFeatureDirByPrefixRemoved:
|
|||||||
|
|
||||||
class TestGetFeaturePathsSinglePrefix:
|
class TestGetFeaturePathsSinglePrefix:
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
|
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
|
||||||
self, tmp_path: Path
|
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
|
||||||
):
|
|
||||||
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
|
|
||||||
(tmp_path / ".specify").mkdir()
|
(tmp_path / ".specify").mkdir()
|
||||||
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
|
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
|
||||||
cmd = (
|
cmd = (
|
||||||
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
|
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
|
||||||
f'source "{COMMON_SH}" && get_feature_paths'
|
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
|
||||||
)
|
)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", "-c", cmd],
|
["bash", "-c", cmd],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
assert result.returncode != 0
|
assert result.returncode == 0, result.stderr
|
||||||
assert "Feature directory not found" in result.stderr
|
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||||
def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
|
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||||
self, git_repo: Path
|
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||||
):
|
|
||||||
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
|
|
||||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||||
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
|
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
|
||||||
spec_dir.mkdir(parents=True)
|
spec_dir.mkdir(parents=True)
|
||||||
@@ -337,8 +426,14 @@ class TestGetFeaturePathsSinglePrefix:
|
|||||||
text=True,
|
text=True,
|
||||||
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
|
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
|
||||||
)
|
)
|
||||||
assert result.returncode != 0
|
assert result.returncode == 0, result.stderr
|
||||||
assert "Feature directory not found" in (result.stderr + result.stdout)
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("FEATURE_DIR="):
|
||||||
|
val = line.split("=", 1)[1].strip()
|
||||||
|
assert val == str(spec_dir)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||||
|
|
||||||
|
|
||||||
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
||||||
@@ -358,11 +453,12 @@ class TestGetCurrentBranch:
|
|||||||
@requires_bash
|
@requires_bash
|
||||||
class TestNoGitTimestamp:
|
class TestNoGitTimestamp:
|
||||||
def test_no_git_timestamp(self, no_git_dir: Path):
|
def test_no_git_timestamp(self, no_git_dir: Path):
|
||||||
"""Test 13: Timestamp mode works without git and creates a spec dir."""
|
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
|
||||||
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
|
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
|
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
|
||||||
assert len(spec_dirs) > 0, "spec dir not created"
|
assert len(spec_dirs) > 0, "spec dir not created"
|
||||||
|
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||||
@@ -371,65 +467,32 @@ class TestNoGitTimestamp:
|
|||||||
@requires_bash
|
@requires_bash
|
||||||
class TestE2EFlow:
|
class TestE2EFlow:
|
||||||
def test_e2e_timestamp(self, git_repo: Path):
|
def test_e2e_timestamp(self, git_repo: Path):
|
||||||
"""Test 14: E2E timestamp flow creates only a feature directory."""
|
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||||
before = subprocess.run(
|
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||||
|
branch = subprocess.run(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
cwd=git_repo,
|
cwd=git_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
|
||||||
assert result.returncode == 0, result.stderr
|
assert (git_repo / "specs" / branch).is_dir()
|
||||||
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||||
branch_name = None
|
assert val.returncode == 0
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if line.startswith("BRANCH_NAME:"):
|
|
||||||
branch_name = line.split(":", 1)[1].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
assert branch_name is not None
|
|
||||||
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
|
|
||||||
assert (git_repo / "specs" / branch_name).is_dir()
|
|
||||||
|
|
||||||
after = subprocess.run(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
cwd=git_repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
assert after == before
|
|
||||||
|
|
||||||
def test_e2e_sequential(self, git_repo: Path):
|
def test_e2e_sequential(self, git_repo: Path):
|
||||||
"""Test 15: E2E sequential flow creates only a feature directory."""
|
"""Test 15: E2E sequential flow (regression guard)."""
|
||||||
before = subprocess.run(
|
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||||
|
branch = subprocess.run(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
cwd=git_repo,
|
cwd=git_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}"
|
||||||
assert result.returncode == 0, result.stderr
|
assert (git_repo / "specs" / branch).is_dir()
|
||||||
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||||
branch_name = None
|
assert val.returncode == 0
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if line.startswith("BRANCH_NAME:"):
|
|
||||||
branch_name = line.split(":", 1)[1].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
assert branch_name == "001-seq-feat"
|
|
||||||
assert (git_repo / "specs" / branch_name).is_dir()
|
|
||||||
|
|
||||||
after = subprocess.run(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
cwd=git_repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
assert after == before
|
|
||||||
|
|
||||||
|
|
||||||
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
||||||
@@ -437,22 +500,67 @@ class TestE2EFlow:
|
|||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
class TestAllowExistingBranch:
|
class TestAllowExistingBranch:
|
||||||
def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
|
def test_allow_existing_switches_to_branch(self, git_repo: Path):
|
||||||
"""T006: Existing feature directory can be reused when the flag is set."""
|
"""T006: Pre-create branch, verify script switches to it."""
|
||||||
feature_dir = git_repo / "specs" / "004-pre-exist"
|
subprocess.run(
|
||||||
feature_dir.mkdir(parents=True)
|
["git", "checkout", "-b", "004-pre-exist"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
|
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
|
||||||
"--number", "4", "Pre-existing feature",
|
"--number", "4", "Pre-existing feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
assert feature_dir.is_dir()
|
current = subprocess.run(
|
||||||
assert (feature_dir / "spec.md").exists()
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=git_repo, capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
|
||||||
|
|
||||||
|
def test_allow_existing_already_on_branch(self, git_repo: Path):
|
||||||
|
"""T007: Verify success when already on the target branch."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "005-already-on"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "already-on",
|
||||||
|
"--number", "5", "Already on branch",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
|
||||||
|
"""T008: Verify spec directory created on existing branch."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "006-spec-dir"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
|
||||||
|
"--number", "6", "Spec dir feature",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert (git_repo / "specs" / "006-spec-dir").is_dir()
|
||||||
|
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
|
||||||
|
|
||||||
def test_without_flag_still_errors(self, git_repo: Path):
|
def test_without_flag_still_errors(self, git_repo: Path):
|
||||||
"""T009: Existing feature directories still fail without the flag."""
|
"""T009: Verify backwards compatibility (error without flag)."""
|
||||||
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "007-no-flag"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
|
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
|
||||||
)
|
)
|
||||||
@@ -461,11 +569,18 @@ class TestAllowExistingBranch:
|
|||||||
|
|
||||||
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
|
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
|
||||||
"""T010: Pre-create spec.md with content, verify it is preserved."""
|
"""T010: Pre-create spec.md with content, verify it is preserved."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "008-no-overwrite"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
spec_dir = git_repo / "specs" / "008-no-overwrite"
|
spec_dir = git_repo / "specs" / "008-no-overwrite"
|
||||||
spec_dir.mkdir(parents=True)
|
spec_dir.mkdir(parents=True)
|
||||||
spec_file = spec_dir / "spec.md"
|
spec_file = spec_dir / "spec.md"
|
||||||
spec_file.write_text("# My custom spec content\n")
|
spec_file.write_text("# My custom spec content\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
|
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
|
||||||
"--number", "8", "No overwrite feature",
|
"--number", "8", "No overwrite feature",
|
||||||
@@ -473,20 +588,31 @@ class TestAllowExistingBranch:
|
|||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
assert spec_file.read_text() == "# My custom spec content\n"
|
assert spec_file.read_text() == "# My custom spec content\n"
|
||||||
|
|
||||||
def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
|
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
|
||||||
"""T011: Verify normal directory creation when the feature dir does not exist."""
|
"""T011: Verify normal creation when branch doesn't exist."""
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
|
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
|
||||||
"New branch feature",
|
"New branch feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
assert (git_repo / "specs" / "001-new-branch").is_dir()
|
current = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=git_repo, capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
assert "new-branch" in current
|
||||||
|
|
||||||
def test_allow_existing_with_json(self, git_repo: Path):
|
def test_allow_existing_with_json(self, git_repo: Path):
|
||||||
"""T012: Verify JSON output is correct."""
|
"""T012: Verify JSON output is correct."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
(git_repo / "specs" / "009-json-test").mkdir(parents=True)
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "009-json-test"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
|
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
|
||||||
"--number", "9", "JSON test",
|
"--number", "9", "JSON test",
|
||||||
@@ -496,26 +622,64 @@ class TestAllowExistingBranch:
|
|||||||
assert data["BRANCH_NAME"] == "009-json-test"
|
assert data["BRANCH_NAME"] == "009-json-test"
|
||||||
|
|
||||||
def test_allow_existing_no_git(self, no_git_dir: Path):
|
def test_allow_existing_no_git(self, no_git_dir: Path):
|
||||||
"""T013: Verify flag also works in non-git repos."""
|
"""T013: Verify flag is silently ignored in non-git repos."""
|
||||||
result = run_script(
|
result = run_script(
|
||||||
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
|
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
|
||||||
"No git feature",
|
"No git feature",
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
|
||||||
|
"""Checkout failures on an existing branch should include Git's stderr."""
|
||||||
|
shared_file = git_repo / "shared.txt"
|
||||||
|
shared_file.write_text("base\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "add", "shared.txt"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-m", "add shared file", "-q"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "010-checkout-failure"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
shared_file.write_text("branch version\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-am", "branch change", "-q"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
shared_file.write_text("uncommitted main change\n")
|
||||||
|
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
|
||||||
|
"--number", "10", "Checkout failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode != 0, "checkout should fail with conflicting local changes"
|
||||||
|
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
|
||||||
|
assert "would be overwritten by checkout" in result.stderr
|
||||||
|
assert "shared.txt" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
class TestAllowExistingBranchPowerShell:
|
class TestAllowExistingBranchPowerShell:
|
||||||
def test_powershell_supports_allow_existing_branch_flag(self):
|
def test_powershell_supports_allow_existing_branch_flag(self):
|
||||||
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
|
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
|
||||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||||
assert "-AllowExistingBranch" in contents
|
assert "-AllowExistingBranch" in contents
|
||||||
|
# Ensure the flag is referenced in script logic, not just declared
|
||||||
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
||||||
|
|
||||||
def test_powershell_reuses_existing_feature_dir(self):
|
def test_powershell_surfaces_checkout_errors(self):
|
||||||
"""Static guard: PS script handles existing feature directories without git."""
|
"""Static guard: PS script preserves checkout stderr on existing-branch failures."""
|
||||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||||
assert "Feature directory '$featureDir' already exists" in contents
|
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||||
assert "-not $AllowExistingBranch" in contents
|
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||||
|
|
||||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
@@ -590,27 +754,20 @@ class TestDryRun:
|
|||||||
branch = line.split(":", 1)[1].strip()
|
branch = line.split(":", 1)[1].strip()
|
||||||
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
|
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
|
||||||
|
|
||||||
def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
|
def test_dry_run_no_branch_created(self, git_repo: Path):
|
||||||
"""T010: Dry-run leaves the current git branch untouched."""
|
"""T010: Dry-run does not create a git branch."""
|
||||||
before = subprocess.run(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
cwd=git_repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
|
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
after = subprocess.run(
|
branches = subprocess.run(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
["git", "branch", "--list", "*no-branch*"],
|
||||||
cwd=git_repo,
|
cwd=git_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
)
|
||||||
).stdout.strip()
|
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||||
assert after == before
|
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
||||||
|
|
||||||
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
|
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
|
||||||
"""T011: Dry-run does not create any directories (including root specs/)."""
|
"""T011: Dry-run does not create any directories (including root specs/)."""
|
||||||
@@ -675,22 +832,50 @@ class TestDryRun:
|
|||||||
real_branch = line.split(":", 1)[1].strip()
|
real_branch = line.split(":", 1)[1].strip()
|
||||||
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
|
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
|
||||||
|
|
||||||
def test_dry_run_ignores_git_branches(self, git_repo: Path):
|
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
|
||||||
"""Dry-run uses only spec directories for numbering."""
|
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
|
||||||
(git_repo / "specs" / "001-existing").mkdir(parents=True)
|
(git_repo / "specs" / "001-existing").mkdir(parents=True)
|
||||||
|
|
||||||
|
# Set up a bare remote and push (use subdirs of git_repo for isolation)
|
||||||
|
remote_dir = git_repo / "test-remote.git"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "checkout", "-b", "005-git-only"],
|
["git", "init", "--bare", str(remote_dir)],
|
||||||
cwd=git_repo,
|
check=True, capture_output=True,
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "checkout", "-"],
|
["git", "remote", "add", "origin", str(remote_dir)],
|
||||||
cwd=git_repo,
|
check=True, cwd=git_repo, capture_output=True,
|
||||||
check=True,
|
)
|
||||||
capture_output=True,
|
subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", "HEAD"],
|
||||||
|
check=True, cwd=git_repo, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clone into a second copy, create a higher-numbered branch, push it
|
||||||
|
second_clone = git_repo / "test-second-clone"
|
||||||
|
subprocess.run(
|
||||||
|
["git", "clone", str(remote_dir), str(second_clone)],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.email", "test@example.com"],
|
||||||
|
cwd=second_clone, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.name", "Test User"],
|
||||||
|
cwd=second_clone, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
# Create branch 005 on the remote (higher than local 001)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "005-remote-only"],
|
||||||
|
cwd=second_clone, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "push", "origin", "005-remote-only"],
|
||||||
|
cwd=second_clone, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Primary repo: dry-run should see 005 via ls-remote and return 006
|
||||||
dry_result = run_script(
|
dry_result = run_script(
|
||||||
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
|
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
|
||||||
)
|
)
|
||||||
@@ -699,7 +884,7 @@ class TestDryRun:
|
|||||||
for line in dry_result.stdout.splitlines():
|
for line in dry_result.stdout.splitlines():
|
||||||
if line.startswith("BRANCH_NAME:"):
|
if line.startswith("BRANCH_NAME:"):
|
||||||
dry_branch = line.split(":", 1)[1].strip()
|
dry_branch = line.split(":", 1)[1].strip()
|
||||||
assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
|
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
|
||||||
|
|
||||||
def test_dry_run_json_includes_field(self, git_repo: Path):
|
def test_dry_run_json_includes_field(self, git_repo: Path):
|
||||||
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
|
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
|
||||||
@@ -725,14 +910,7 @@ class TestDryRun:
|
|||||||
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
||||||
|
|
||||||
def test_dry_run_with_timestamp(self, git_repo: Path):
|
def test_dry_run_with_timestamp(self, git_repo: Path):
|
||||||
"""T017: Dry-run works with --timestamp flag without mutating git state."""
|
"""T017: Dry-run works with --timestamp flag."""
|
||||||
before = subprocess.run(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
cwd=git_repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
result = run_script(
|
result = run_script(
|
||||||
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
|
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
|
||||||
)
|
)
|
||||||
@@ -743,14 +921,15 @@ class TestDryRun:
|
|||||||
branch = line.split(":", 1)[1].strip()
|
branch = line.split(":", 1)[1].strip()
|
||||||
assert branch is not None, "no BRANCH_NAME in output"
|
assert branch is not None, "no BRANCH_NAME in output"
|
||||||
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
|
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
|
||||||
after = subprocess.run(
|
# Verify no side effects
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
branches = subprocess.run(
|
||||||
|
["git", "branch", "--list", "*ts-feat*"],
|
||||||
cwd=git_repo,
|
cwd=git_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
)
|
||||||
).stdout.strip()
|
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||||
assert after == before
|
assert branches.stdout.strip() == ""
|
||||||
|
|
||||||
def test_dry_run_with_number(self, git_repo: Path):
|
def test_dry_run_with_number(self, git_repo: Path):
|
||||||
"""T018: Dry-run works with --number flag."""
|
"""T018: Dry-run works with --number flag."""
|
||||||
@@ -810,27 +989,20 @@ class TestPowerShellDryRun:
|
|||||||
branch = line.split(":", 1)[1].strip()
|
branch = line.split(":", 1)[1].strip()
|
||||||
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
|
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
|
||||||
|
|
||||||
def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
|
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
|
||||||
"""PowerShell -DryRun leaves the current git branch untouched."""
|
"""PowerShell -DryRun does not create a git branch."""
|
||||||
before = subprocess.run(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
cwd=ps_git_repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
result = run_ps_script(
|
result = run_ps_script(
|
||||||
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
|
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
after = subprocess.run(
|
branches = subprocess.run(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
["git", "branch", "--list", "*no-ps-branch*"],
|
||||||
cwd=ps_git_repo,
|
cwd=ps_git_repo,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
)
|
||||||
).stdout.strip()
|
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||||
assert after == before
|
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
||||||
|
|
||||||
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
|
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
|
||||||
"""PowerShell -DryRun does not create specs/ directory."""
|
"""PowerShell -DryRun does not create specs/ directory."""
|
||||||
@@ -874,10 +1046,10 @@ class TestPowerShellDryRun:
|
|||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
class TestGitBranchNameOverrideBash:
|
class TestGitBranchNameOverrideBash:
|
||||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
|
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||||
|
|
||||||
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
|
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
|
||||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||||
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
|
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
|
||||||
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
|
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
|
||||||
env={**os.environ, **env_extras})
|
env={**os.environ, **env_extras})
|
||||||
@@ -929,10 +1101,10 @@ class TestGitBranchNameOverrideBash:
|
|||||||
|
|
||||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||||
class TestGitBranchNameOverridePowerShell:
|
class TestGitBranchNameOverridePowerShell:
|
||||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
|
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
|
||||||
|
|
||||||
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
|
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
|
||||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
|
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
|
||||||
cwd=ext_ps_git_repo, capture_output=True, text=True,
|
cwd=ext_ps_git_repo, capture_output=True, text=True,
|
||||||
@@ -1058,8 +1230,9 @@ class TestFeatureDirectoryResolution:
|
|||||||
pytest.fail("FEATURE_DIR not found in output")
|
pytest.fail("FEATURE_DIR not found in output")
|
||||||
|
|
||||||
@requires_bash
|
@requires_bash
|
||||||
def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
|
def test_fallback_to_branch_lookup(self, git_repo: Path):
|
||||||
"""Without env var or feature.json, get_feature_paths now errors."""
|
"""Without env var or feature.json, falls back to branch-based lookup."""
|
||||||
|
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
|
||||||
spec_dir = git_repo / "specs" / "001-test-feat"
|
spec_dir = git_repo / "specs" / "001-test-feat"
|
||||||
spec_dir.mkdir(parents=True)
|
spec_dir.mkdir(parents=True)
|
||||||
|
|
||||||
@@ -1069,8 +1242,14 @@ class TestFeatureDirectoryResolution:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
assert result.returncode != 0
|
assert result.returncode == 0, result.stderr
|
||||||
assert "Feature directory not found" in result.stderr
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("FEATURE_DIR="):
|
||||||
|
val = line.split("=", 1)[1].strip("'\"")
|
||||||
|
assert val == str(spec_dir)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pytest.fail("FEATURE_DIR not found in output")
|
||||||
|
|
||||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||||
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
|
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||||
@@ -1164,7 +1343,7 @@ class TestDescriptionQuoting:
|
|||||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||||
)
|
)
|
||||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||||
"""Extension create-new-feature-branch.sh succeeds with special characters in description."""
|
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||||
script = (
|
script = (
|
||||||
ext_git_repo
|
ext_git_repo
|
||||||
/ ".specify"
|
/ ".specify"
|
||||||
@@ -1172,7 +1351,7 @@ class TestDescriptionQuoting:
|
|||||||
/ "git"
|
/ "git"
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
/ "bash"
|
/ "bash"
|
||||||
/ "create-new-feature-branch.sh"
|
/ "create-new-feature.sh"
|
||||||
)
|
)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
||||||
from specify_cli import (
|
from specify_cli import (
|
||||||
check_tool, merge_json_files,
|
check_tool, is_git_repo, merge_json_files,
|
||||||
get_speckit_version,
|
get_speckit_version,
|
||||||
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
||||||
)
|
)
|
||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
def test_utils_symbols_importable():
|
def test_utils_symbols_importable():
|
||||||
assert callable(check_tool)
|
assert callable(check_tool)
|
||||||
assert callable(merge_json_files)
|
assert callable(merge_json_files)
|
||||||
|
assert callable(is_git_repo)
|
||||||
|
|
||||||
def test_get_speckit_version_returns_string():
|
def test_get_speckit_version_returns_string():
|
||||||
version = get_speckit_version()
|
version = get_speckit_version()
|
||||||
|
|||||||
@@ -658,47 +658,6 @@ class TestCommandStep:
|
|||||||
# Claude is a SkillsIntegration so uses /speckit-specify
|
# Claude is a SkillsIntegration so uses /speckit-specify
|
||||||
assert "/speckit-specify login" in call_args[0][0][2]
|
assert "/speckit-specify login" in call_args[0][0][2]
|
||||||
|
|
||||||
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
|
|
||||||
"""Command preflight falls back to build_exec_args() argv[0]."""
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from specify_cli.workflows.steps.command import CommandStep
|
|
||||||
from specify_cli.workflows.base import StepContext, StepStatus
|
|
||||||
|
|
||||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
|
||||||
seen_which: list[str] = []
|
|
||||||
|
|
||||||
def fake_which(name: str) -> str | None:
|
|
||||||
seen_which.append(name)
|
|
||||||
return name if name == "/opt/claude" else None
|
|
||||||
|
|
||||||
step = CommandStep()
|
|
||||||
ctx = StepContext(
|
|
||||||
inputs={"name": "login"},
|
|
||||||
default_integration="claude",
|
|
||||||
project_root=str(tmp_path),
|
|
||||||
)
|
|
||||||
config = {
|
|
||||||
"id": "test",
|
|
||||||
"command": "speckit.specify",
|
|
||||||
"input": {"args": "{{ inputs.name }}"},
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = '{"result": "done"}'
|
|
||||||
mock_result.stderr = ""
|
|
||||||
|
|
||||||
with patch("specify_cli.workflows.steps.command.shutil.which", side_effect=fake_which), \
|
|
||||||
patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
result = step.execute(config, ctx)
|
|
||||||
|
|
||||||
assert result.status == StepStatus.COMPLETED
|
|
||||||
assert result.output["dispatched"] is True
|
|
||||||
assert seen_which[:2] == ["claude", "/opt/claude"]
|
|
||||||
call_args = mock_run.call_args
|
|
||||||
assert call_args[0][0][0] == "/opt/claude"
|
|
||||||
assert "/speckit-specify login" in call_args[0][0][2]
|
|
||||||
|
|
||||||
def test_dispatch_failure_returns_failed_status(self, tmp_path):
|
def test_dispatch_failure_returns_failed_status(self, tmp_path):
|
||||||
"""When the CLI exits non-zero, the step should fail."""
|
"""When the CLI exits non-zero, the step should fail."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
@@ -851,46 +810,6 @@ class TestPromptStep:
|
|||||||
assert result.output["dispatched"] is True
|
assert result.output["dispatched"] is True
|
||||||
assert result.output["exit_code"] == 0
|
assert result.output["exit_code"] == 0
|
||||||
|
|
||||||
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
|
|
||||||
"""Prompt preflight falls back to build_exec_args() argv[0]."""
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from specify_cli.workflows.steps.prompt import PromptStep
|
|
||||||
from specify_cli.workflows.base import StepContext, StepStatus
|
|
||||||
|
|
||||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
|
||||||
seen_which: list[str] = []
|
|
||||||
|
|
||||||
def fake_which(name: str) -> str | None:
|
|
||||||
seen_which.append(name)
|
|
||||||
return name if name == "/opt/claude" else None
|
|
||||||
|
|
||||||
step = PromptStep()
|
|
||||||
ctx = StepContext(
|
|
||||||
default_integration="claude",
|
|
||||||
project_root=str(tmp_path),
|
|
||||||
)
|
|
||||||
config = {
|
|
||||||
"id": "ask",
|
|
||||||
"type": "prompt",
|
|
||||||
"prompt": "Explain this code",
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "Here is the explanation"
|
|
||||||
mock_result.stderr = ""
|
|
||||||
|
|
||||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", side_effect=fake_which), \
|
|
||||||
patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
result = step.execute(config, ctx)
|
|
||||||
|
|
||||||
assert result.status == StepStatus.COMPLETED
|
|
||||||
assert result.output["dispatched"] is True
|
|
||||||
assert seen_which[:2] == ["claude", "/opt/claude"]
|
|
||||||
call_args = mock_run.call_args
|
|
||||||
assert call_args[0][0][0] == "/opt/claude"
|
|
||||||
assert call_args[0][0][2] == "Explain this code"
|
|
||||||
|
|
||||||
def test_validate_missing_prompt(self):
|
def test_validate_missing_prompt(self):
|
||||||
from specify_cli.workflows.steps.prompt import PromptStep
|
from specify_cli.workflows.steps.prompt import PromptStep
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user