feat: Git extension stage 1 — bundled extensions/git with hooks on all core commands (#1941)

* feat: add git extension with hooks on all core commands

- Create extensions/git/ with 5 commands: initialize, feature,
  validate, remote, commit
- 18 hooks covering before/after for all 9 core commands
- Scripts: create-new-feature, initialize-repo, auto-commit,
  git-common (bash + powershell)
- Configurable: branch_numbering, init_commit_message,
  per-command auto-commit with custom messages
- Add hooks to analyze, checklist, clarify, constitution,
  taskstoissues command templates
- Allow hooks-only extensions (no commands required)
- Bundle extension in wheel via pyproject.toml force-include
- Resolve bundled extensions locally before catalog lookup
- Remove planned-but-unimplemented before/after_commit hook refs
- Update extension docs (API ref, dev guide, user guide)
- 37 new tests covering manifest, install, all scripts (bash+pwsh),
  config reading, graceful degradation

Stage 1: opt-in via 'specify extension add git'. No auto-install,
no changes to specify.md or core git init code.

Refs: #841, #1382, #1066, #1791, #1191

* fix: set git identity env vars in extension tests for CI runners

* fix: address PR review comments

- Fix commands property KeyError for hooks-only extensions
- Fix has_git() operator precedence in git-common.sh
- Align default commit message to '[Spec Kit] Initial commit' across
  config-template, extension.yml defaults, and both init scripts
- Update README to reflect all 5 commands and 18 hooks

* fix: address second round of PR review comments

- Add type validation for provides.commands (must be list) and hooks
  (must be dict) in manifest _validate()
- Tighten malformed timestamp detection in git-common.sh to catch
  7-digit dates without trailing slug (e.g. 2026031-143022)
- Pass REPO_ROOT to has_git/Test-HasGit in create-new-feature scripts
- Fix initialize command docs: surface errors on git failures, only
  skip when git is not installed
- Fix commit command docs: 'skips with a warning' not 'silently'
- Add tests for commands:null and hooks:list rejection

* fix: address third round of PR review comments

- Remove scripts frontmatter from command files (CommandRegistrar
  rewrites ../../scripts/ to .specify/scripts/ which points at core
  scripts, not extension scripts)
- Update speckit.git.commit command to derive event name from hook
  context rather than using a static example
- Clarify that hook argument passthrough works via AI agent context
  (the agent carries conversation state including user's original
  feature description)

* fix: address fourth round of PR review comments

- Validate extension_id against ^[a-z0-9-]+$ in _locate_bundled_extension
  to prevent path traversal (security fix)
- Move defaults under config.defaults in extension.yml to match
  ConfigManager._get_extension_defaults() schema
- Ship git-config.yml in extension directory so it's copied during
  install (provides.config template isn't materialized by ExtensionManager)
- Condition handling in hook templates: intentionally matches existing
  pattern from specify/plan/tasks/implement templates (not a new issue)

* fix: add --allow-empty to git commit in initialize-repo scripts

Ensures git init succeeds even on empty repos where nothing has been
staged yet.

* fix: resolve display names to bundled extensions before catalog download

When 'specify extension add "Git Branching Workflow"' is used with a
display name instead of the ID, the catalog resolver now runs first to
map the name to an ID, then checks bundled extensions again with the
resolved ID before falling back to network download.

Also noted: EXECUTE_COMMAND_INVOCATION and condition handling match the
existing pattern in specify/plan/tasks/implement templates (pre-existing,
not introduced by this PR).

* fix: handle before_/after_ prefixes in auto-commit message derivation

- Strip both before_ and after_ prefixes when deriving command name
  (fixes misleading 'Auto-commit after before_plan' messages)
- Include phase (before/after) in default commit messages
- Clarify README config example is an override, not default behavior

* fix: use portable grep -qw for word boundary in create-new-feature.sh

BSD grep (macOS) doesn't support \b as a word boundary. Replace with
grep -qw which is POSIX-portable.

* fix: validate hook values, numeric --number, and PS warning routing

- Validate each hook value is a dict with a 'command' field during
  manifest _validate() (prevents crash at install time)
- Validate --number is a non-negative integer in bash create-new-feature
  (clear error instead of cryptic shell arithmetic failure)
- Route PowerShell no-git warning to stderr in JSON mode so stdout
  stays valid JSON

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
This commit is contained in:
Copilot
2026-04-07 08:39:35 -05:00
committed by GitHub
parent aad6f68ae5
commit 1a9e4d1d8d
33 changed files with 3108 additions and 54 deletions

View File

@@ -108,7 +108,7 @@ defaults: # Optional, default configuration values
#### `hooks`
- **Type**: object
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`)
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`)
- **Description**: Hooks that execute at lifecycle events
- **Events**: Defined by core spec-kit commands
@@ -559,8 +559,16 @@ Standard events (defined by core):
- `after_tasks` - After task generation
- `before_implement` - Before implementation
- `after_implement` - After implementation
- `before_commit` - Before git commit *(planned - not yet wired into core templates)*
- `after_commit` - After git commit *(planned - not yet wired into core templates)*
- `before_analyze` - Before cross-artifact analysis
- `after_analyze` - After cross-artifact analysis
- `before_checklist` - Before checklist generation
- `after_checklist` - After checklist generation
- `before_clarify` - Before spec clarification
- `after_clarify` - After spec clarification
- `before_constitution` - Before constitution update
- `after_constitution` - After constitution update
- `before_taskstoissues` - Before tasks-to-issues conversion
- `after_taskstoissues` - After tasks-to-issues conversion
### Hook Configuration

View File

@@ -177,9 +177,9 @@ Compatibility requirements.
What the extension provides.
**Required sub-fields**:
**Optional sub-fields**:
- `commands`: Array of command objects (must have at least one)
- `commands`: Array of command objects (at least one command or hook is required)
**Command object**:
@@ -196,12 +196,19 @@ Integration hooks for automatic execution.
Available hook points:
- `after_tasks`: After `/speckit.tasks` completes
- `after_implement`: After `/speckit.implement` completes (future)
- `before_specify` / `after_specify`: Before/after specification generation
- `before_plan` / `after_plan`: Before/after implementation planning
- `before_tasks` / `after_tasks`: Before/after task generation
- `before_implement` / `after_implement`: Before/after implementation
- `before_analyze` / `after_analyze`: Before/after cross-artifact analysis
- `before_checklist` / `after_checklist`: Before/after checklist generation
- `before_clarify` / `after_clarify`: Before/after spec clarification
- `before_constitution` / `after_constitution`: Before/after constitution update
- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion
Hook object:
- `command`: Command to execute (must be in `provides.commands`)
- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command)
- `optional`: If true, prompt user before executing
- `prompt`: Prompt text for optional hooks
- `description`: Hook description

View File

@@ -403,8 +403,10 @@ settings:
# Hook configuration
# Available events: before_specify, after_specify, before_plan, after_plan,
# before_tasks, after_tasks, before_implement, after_implement
# Planned (not yet wired into core templates): before_commit, after_commit
# before_tasks, after_tasks, before_implement, after_implement,
# before_analyze, after_analyze, before_checklist, after_checklist,
# before_clarify, after_clarify, before_constitution, after_constitution,
# before_taskstoissues, after_taskstoissues
hooks:
after_tasks:
- extension: jira

View File

@@ -1,8 +1,23 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-04-06T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"extensions": {
"git": {
"name": "Git Branching Workflow",
"id": "git",
"version": "1.0.0",
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
"tags": [
"git",
"branching",
"workflow",
"core"
]
},
"selftest": {
"name": "Spec Kit Self-Test Utility",
"id": "selftest",

100
extensions/git/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Git Branching Workflow Extension
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
## Overview
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
## Commands
| Command | Description |
|---------|-------------|
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
## Hooks
| Event | Command | Optional | Description |
|-------|---------|----------|-------------|
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
## Configuration
Configuration is stored in `.specify/extensions/git/git-config.yml`:
```yaml
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:
default: false
after_specify:
enabled: true
message: "[Spec Kit] Add specification"
```
## Installation
```bash
# Install the bundled git extension (no network required)
specify extension add git
```
## Disabling
```bash
# Disable the git extension (spec creation continues without branching)
specify extension disable git
# Re-enable it
specify extension enable git
```
## Graceful Degradation
When Git is not installed or the directory is not a Git repository:
- Spec directories are still created under `specs/`
- Branch creation is skipped with a warning
- Branch validation is skipped with a warning
- Remote detection returns empty results
## Scripts
The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@@ -0,0 +1,48 @@
---
description: "Auto-commit changes after a Spec Kit command completes"
---
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message

View File

@@ -0,0 +1,66 @@
---
description: "Create a feature branch with sequential or timestamp numbering"
---
# Create Feature Branch
Create a new feature branch for the given specification.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation (spec directory will still be created)
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **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.sh --json --timestamp --short-name "<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.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to:
- **Bash**: `scripts/bash/create-new-feature.sh`
- **PowerShell**: `scripts/powershell/create-new-feature.ps1`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- The script will still create the spec directory under `specs/`
- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation`
- The workflow continues normally without branch creation
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `SPEC_FILE`: Path to the created spec file
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -0,0 +1,49 @@
---
description: "Initialize a Git repository with an initial commit"
---
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- `✓ Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository

View File

@@ -0,0 +1,45 @@
---
description: "Detect Git remote URL for GitHub integration"
---
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View File

@@ -0,0 +1,49 @@
---
description: "Validate current branch follows feature branch naming conventions"
---
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View File

@@ -0,0 +1,62 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View File

@@ -0,0 +1,140 @@
schema_version: "1.0"
extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
requires:
speckit_version: ">=0.2.0"
tools:
- name: git
required: false
provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
- name: speckit.git.remote
file: commands/speckit.git.remote.md
description: "Detect Git remote URL for GitHub integration"
- name: speckit.git.initialize
file: commands/speckit.git.initialize.md
description: "Initialize a Git repository with an initial commit"
- name: speckit.git.commit
file: commands/speckit.git.commit.md
description: "Auto-commit changes after a Spec Kit command completes"
config:
- name: "git-config.yml"
template: "config-template.yml"
description: "Git branching configuration"
required: false
hooks:
before_constitution:
command: speckit.git.initialize
optional: false
description: "Initialize Git repository before constitution setup"
before_specify:
command: speckit.git.feature
optional: false
description: "Create feature branch before specification"
before_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before clarification?"
description: "Auto-commit before spec clarification"
before_plan:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before planning?"
description: "Auto-commit before implementation planning"
before_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before task generation?"
description: "Auto-commit before task generation"
before_implement:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before implementation?"
description: "Auto-commit before implementation"
before_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before checklist?"
description: "Auto-commit before checklist generation"
before_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before analysis?"
description: "Auto-commit before analysis"
before_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before issue sync?"
description: "Auto-commit before tasks-to-issues conversion"
after_constitution:
command: speckit.git.commit
optional: true
prompt: "Commit constitution changes?"
description: "Auto-commit after constitution update"
after_specify:
command: speckit.git.commit
optional: true
prompt: "Commit specification changes?"
description: "Auto-commit after specification"
after_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit clarification changes?"
description: "Auto-commit after spec clarification"
after_plan:
command: speckit.git.commit
optional: true
prompt: "Commit plan changes?"
description: "Auto-commit after implementation planning"
after_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit task changes?"
description: "Auto-commit after task generation"
after_implement:
command: speckit.git.commit
optional: true
prompt: "Commit implementation changes?"
description: "Auto-commit after implementation"
after_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit checklist changes?"
description: "Auto-commit after checklist generation"
after_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit analysis results?"
description: "Auto-commit after analysis"
after_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit after syncing issues?"
description: "Auto-commit after tasks-to-issues conversion"
tags:
- "git"
- "branching"
- "workflow"
config:
defaults:
branch_numbering: sequential
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -0,0 +1,62 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Git extension: auto-commit.sh
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.sh <event_name>
# e.g.: auto-commit.sh after_specify
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
exit 1
fi
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
exit 0
fi
# Read per-command config from git-config.yml
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
if [ -f "$_config_file" ]; then
# Parse the auto_commit section for this event.
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
_in_auto_commit=false
_in_event=false
_default_enabled=false
while IFS= read -r _line; do
# Detect auto_commit: section
if echo "$_line" | grep -q '^auto_commit:'; then
_in_auto_commit=true
_in_event=false
continue
fi
# Exit auto_commit section on next top-level key
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
break
fi
if $_in_auto_commit; then
# Check default key
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _default_enabled=true
fi
# Detect our event subsection
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
_in_event=true
continue
fi
# Inside our event subsection
if $_in_event; then
# Exit on next sibling key (same indent level as event name)
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
_in_event=false
continue
fi
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _enabled=true
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi
done < "$_config_file"
# If event-specific key not found, use default
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
# Only use default if the event wasn't explicitly set to false
# Check if event section existed at all
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
_enabled=true
fi
fi
else
# No config file — auto-commit disabled by default
exit 0
fi
if [ "$_enabled" != "true" ]; then
exit 0
fi
# Check if there are changes to commit
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
echo "[specify] No changes to commit after $EVENT_NAME" >&2
exit 0
fi
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
# Use custom message if configured, otherwise default
if [ -z "$_commit_msg" ]; then
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
fi
# Stage and commit
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "✓ Changes committed ${_phase} ${_command_name}" >&2

View File

@@ -0,0 +1,451 @@
#!/usr/bin/env bash
# Git extension: create-new-feature.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
# Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers.
set -e
JSON_MODE=false
DRY_RUN=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--dry-run)
DRY_RUN=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
echo 'Error: --number must be a non-negative integer' >&2
exit 1
fi
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
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 branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
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).
_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 and return next available number.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# ---------------------------------------------------------------------------
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
#
# Search locations in priority order:
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root by walking up from the script location
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
_common_loaded=false
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
_common_loaded=true
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/scripts/bash/common.sh"
_common_loaded=true
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
source "$SCRIPT_DIR/git-common.sh"
_common_loaded=true
fi
if [ "$_common_loaded" != "true" ]; then
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
exit 1
fi
# Resolve repository root
if type get_repo_root >/dev/null 2>&1; then
REPO_ROOT=$(get_repo_root)
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
elif [ -n "$_PROJECT_ROOT" ]; then
REPO_ROOT="$_PROJECT_ROOT"
else
echo "Error: Could not determine repository root." >&2
exit 1
fi
# Check if git is available at this repo root
if type has_git >/dev/null 2>&1; then
if has_git "$REPO_ROOT"; then
HAS_GIT=true
else
HAS_GIT=false
fi
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
if [ "$DRY_RUN" != true ]; then
mkdir -p "$SPECS_DIR"
fi
# Function to generate branch name with stop word filtering
generate_branch_name() {
local description="$1"
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
local meaningful_words=()
for word in $clean_name; do
[ -z "$word" ] && continue
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -qw -- "${word^^}"; then
meaningful_words+=("$word")
fi
fi
done
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
# GitHub enforces a 244-byte limit on branch names
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
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
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
mkdir -p "$FEATURE_DIR"
if [ ! -f "$SPEC_FILE" ]; then
if type resolve_template >/dev/null 2>&1; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
else
TEMPLATE=""
fi
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
fi
fi
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
if [ "$DRY_RUN" = true ]; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
else
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
fi
else
if type json_escape >/dev/null 2>&1; then
_je_branch=$(json_escape "$BRANCH_NAME")
_je_spec=$(json_escape "$SPEC_FILE")
_je_num=$(json_escape "$FEATURE_NUM")
else
_je_branch="$BRANCH_NAME"
_je_spec="$SPEC_FILE"
_je_num="$FEATURE_NUM"
fi
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num"
else
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num"
fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
fi

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Git-specific common functions for the git extension.
# Extracted from scripts/bash/common.sh — contains only git-specific
# branch validation and detection logic.
# Check if we have git available at the repo root
has_git() {
local repo_root="${1:-$(pwd)}"
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
command -v git >/dev/null 2>&1 && \
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
check_feature_branch() {
local branch="$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
# Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug)
if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
return 1
fi
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
return 0
fi
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
return 1
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Git extension: initialize-repo.sh
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
set -e
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Read commit message from extension config, fall back to default
COMMIT_MSG="[Spec Kit] Initial commit"
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
if [ -f "$_config_file" ]; then
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
if [ -n "$_msg" ]; then
COMMIT_MSG="$_msg"
fi
fi
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
exit 0
fi
# Check if already a git repo
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Git repository already initialized; skipping" >&2
exit 0
fi
# Initialize
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "✓ Git repository initialized" >&2

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env pwsh
# Git extension: auto-commit.ps1
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.ps1 <event_name>
# e.g.: auto-commit.ps1 after_specify
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
)
$ErrorActionPreference = 'Stop'
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
exit 0
}
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "not a repo" }
} catch {
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
exit 0
}
# Read per-command config from git-config.yml
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
if (Test-Path $configFile) {
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
$defaultEnabled = $false
foreach ($line in Get-Content $configFile) {
# Detect auto_commit: section
if ($line -match '^auto_commit:') {
$inAutoCommit = $true
$inEvent = $false
continue
}
# Exit auto_commit section on next top-level key
if ($inAutoCommit -and $line -match '^[a-z]') {
break
}
if ($inAutoCommit) {
# Check default key
if ($line -match '^\s+default:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $defaultEnabled = $true }
}
# Detect our event subsection
if ($line -match "^\s+${EventName}:") {
$inEvent = $true
continue
}
# Inside our event subsection
if ($inEvent) {
# Exit on next sibling key (2-space indent, not 4+)
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
$inEvent = $false
continue
}
if ($line -match '\s+enabled:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $enabled = $true }
if ($val -eq 'false') { $enabled = $false }
}
if ($line -match '\s+message:\s*(.+)$') {
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
}
}
}
}
# If event-specific key not found, use default
if (-not $enabled -and $defaultEnabled) {
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
if (-not $hasEventKey) {
$enabled = $true
}
}
} else {
# No config file — auto-commit disabled by default
exit 0
}
if (-not $enabled) {
exit 0
}
# Check if there are changes to commit
$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
$untracked = git ls-files --others --exclude-standard 2>$null
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
exit 0
}
# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
# Use custom message if configured, otherwise default
if (-not $commitMsg) {
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
}
# Stage and commit
try {
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
}
Write-Host "✓ Changes committed $phase $commandName"

View File

@@ -0,0 +1,397 @@
#!/usr/bin/env pwsh
# Git extension: create-new-feature.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
# Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers.
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
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 branch"
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 " -Help Show this help message"
exit 0
}
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
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-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
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
$maxNum = [Math]::Max($highestBranch, $highestSpec)
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# ---------------------------------------------------------------------------
# Source common.ps1 from the project's installed scripts.
# Search locations in priority order:
# 1. .specify/scripts/powershell/common.ps1 under the project root
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
# 3. git-common.ps1 next to this script (minimal fallback)
# ---------------------------------------------------------------------------
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
$commonLoaded = $false
if ($projectRoot) {
$candidates = @(
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
(Join-Path $projectRoot "scripts/powershell/common.ps1")
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
. $candidate
$commonLoaded = $true
break
}
}
}
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
. "$PSScriptRoot/git-common.ps1"
$commonLoaded = $true
}
if (-not $commonLoaded) {
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
}
# Resolve repository root
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
$repoRoot = Get-RepoRoot
} elseif ($projectRoot) {
$repoRoot = $projectRoot
} else {
throw "Could not determine repository root."
}
# Check if git is available
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
$hasGit = Test-HasGit -RepoRoot $repoRoot
} else {
try {
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
$hasGit = ($LASTEXITCODE -eq 0)
} catch {
$hasGit = $false
}
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
if (-not $DryRun) {
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
}
function Get-BranchName {
param([string]$Description)
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
$meaningfulWords = @()
foreach ($word in $words) {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
$meaningfulWords += $word
}
}
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
$branchSuffix = Get-BranchName -Description $featureDesc
}
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
}
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
$featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) {
if ($hasGit) {
$branchCreated = $false
$branchCreateError = ''
try {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
}
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
if ($currentBranch -eq $branchName) {
# Already on the target branch
} else {
git checkout -q $branchName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
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 {
if ($Json) {
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
if (-not (Test-Path -PathType Leaf $specFile)) {
if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
} else {
$template = $null
}
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
} else {
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
}
$env:SPECIFY_FEATURE = $branchName
}
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env pwsh
# Git-specific common functions for the git extension.
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
# branch validation and detection logic.
function Test-HasGit {
param([string]$RepoRoot = (Get-Location))
try {
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
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
}
# Reject malformed timestamps (7-digit date or no trailing slug)
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or
($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
if ($hasMalformedTimestamp) {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
return $false
}
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
$isTimestamp = $Branch -match '^\d{8}-\d{6}-'
if ($isSequential -or $isTimestamp) {
return $true
}
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
return $false
}

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env pwsh
# Git extension: initialize-repo.ps1
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
$ErrorActionPreference = 'Stop'
# Find project root
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Read commit message from extension config, fall back to default
$commitMsg = "[Spec Kit] Initial commit"
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
if (Test-Path $configFile) {
foreach ($line in Get-Content $configFile) {
if ($line -match '^init_commit_message:\s*(.+)$') {
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
if ($val) { $commitMsg = $val }
break
}
}
}
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
exit 0
}
# Check if already a git repo
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Warning "[specify] Git repository already initialized; skipping"
exit 0
}
} catch { }
# Initialize
try {
$out = git init -q 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
}
Write-Host "✓ Git repository initialized"

View File

@@ -39,6 +39,8 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
# Bundled extensions (installable via `specify extension add <name>`)
"extensions/git" = "specify_cli/core_pack/extensions/git"
[project.optional-dependencies]
test = [

View File

@@ -604,6 +604,31 @@ def _locate_core_pack() -> Path | None:
return None
def _locate_bundled_extension(extension_id: str) -> Path | None:
"""Return the path to a bundled extension, or None.
Checks the wheel's core_pack first, then falls back to the
source-checkout ``extensions/<id>/`` directory.
"""
import re as _re
if not _re.match(r'^[a-z0-9-]+$', extension_id):
return None
core = _locate_core_pack()
if core is not None:
candidate = core / "extensions" / extension_id
if (candidate / "extension.yml").is_file():
return candidate
# Source-checkout / editable install: look relative to repo root
repo_root = Path(__file__).parent.parent.parent
candidate = repo_root / "extensions" / extension_id
if (candidate / "extension.yml").is_file():
return candidate
return None
def _install_shared_infra(
project_path: Path,
script_type: str,
@@ -3024,45 +3049,58 @@ def extension_add(
zip_path.unlink()
else:
# Install from catalog
catalog = ExtensionCatalog(project_root)
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
if bundled_path is not None:
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
else:
# Install from catalog (also resolves display names to IDs)
catalog = ExtensionCatalog(project_root)
# Check if extension exists in catalog (supports both ID and display name)
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
if catalog_error:
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
raise typer.Exit(1)
if not ext_info:
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
console.print("\nSearch available extensions:")
console.print(" specify extension search")
raise typer.Exit(1)
# Check if extension exists in catalog (supports both ID and display name)
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
if catalog_error:
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
raise typer.Exit(1)
if not ext_info:
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
console.print("\nSearch available extensions:")
console.print(" specify extension search")
raise typer.Exit(1)
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
console.print(
f"[red]Error:[/red] '{extension}' is available in the "
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
)
console.print(
f"\nTo enable installation, add '{extension}' to an approved catalog "
f"(install_allowed: true) in .specify/extension-catalogs.yml."
)
raise typer.Exit(1)
# If catalog resolved a display name to an ID, check bundled again
resolved_id = ext_info['id']
if resolved_id != extension:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
# Download extension ZIP (use resolved ID, not original argument which may be display name)
extension_id = ext_info['id']
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
zip_path = catalog.download_extension(extension_id)
if bundled_path is None:
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
console.print(
f"[red]Error:[/red] '{extension}' is available in the "
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
)
console.print(
f"\nTo enable installation, add '{extension}' to an approved catalog "
f"(install_allowed: true) in .specify/extension-catalogs.yml."
)
raise typer.Exit(1)
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
# Download extension ZIP (use resolved ID, not original argument which may be display name)
extension_id = ext_info['id']
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
zip_path = catalog.download_extension(extension_id)
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")

View File

@@ -183,11 +183,40 @@ class ExtensionManifest:
# Validate provides section
provides = self.data["provides"]
if "commands" not in provides or not provides["commands"]:
raise ValidationError("Extension must provide at least one command")
commands = provides.get("commands", [])
hooks = self.data.get("hooks")
# Validate commands
for cmd in provides["commands"]:
if "commands" in provides and not isinstance(commands, list):
raise ValidationError(
"Invalid provides.commands: expected a list"
)
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError(
"Invalid hooks: expected a mapping"
)
has_commands = bool(commands)
has_hooks = bool(hooks)
if not has_commands and not has_hooks:
raise ValidationError(
"Extension must provide at least one command or hook"
)
# Validate hook values (if present)
if hooks:
for hook_name, hook_config in hooks.items():
if not isinstance(hook_config, dict):
raise ValidationError(
f"Invalid hook '{hook_name}': expected a mapping"
)
if not hook_config.get("command"):
raise ValidationError(
f"Hook '{hook_name}' missing required 'command' field"
)
# Validate commands (if present)
for cmd in commands:
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")
@@ -226,7 +255,7 @@ class ExtensionManifest:
@property
def commands(self) -> List[Dict[str, Any]]:
"""Get list of provided commands."""
return self.data["provides"]["commands"]
return self.data.get("provides", {}).get("commands", [])
@property
def hooks(self) -> Dict[str, Any]:

View File

@@ -13,6 +13,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before analysis)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Goal.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
@@ -165,6 +199,37 @@ At end of report, output a concise Next Actions block:
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
### 9. Check for extension hooks
After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Operating Principles
### Context Efficiency

View File

@@ -34,6 +34,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before checklist generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Execution Steps.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Execution Steps
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
@@ -296,3 +330,35 @@ Sample items:
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"
## Post-Execution Checks
**Check for extension hooks (after checklist generation)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -17,6 +17,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before clarification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_clarify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
@@ -182,3 +216,35 @@ Behavior rules:
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: {ARGS}
## Post-Execution Checks
**Check for extension hooks (after clarification)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_clarify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -14,6 +14,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before constitution update)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_constitution` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
@@ -82,3 +116,35 @@ If the user supplies partial updates (e.g., only one principle revision), still
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
## Post-Execution Checks
**Check for extension hooks (after constitution update)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_constitution` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -14,6 +14,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before tasks-to-issues conversion)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
@@ -31,3 +65,35 @@ git config --get remote.origin.url
> [!CAUTION]
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
## Post-Execution Checks
**Check for extension hooks (after tasks-to-issues conversion)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -0,0 +1 @@
"""Extensions test package."""

View File

@@ -0,0 +1 @@
"""Tests for the bundled git extension."""

View File

@@ -0,0 +1,601 @@
"""
Tests for the bundled git extension (extensions/git/).
Validates:
- extension.yml manifest
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
- PowerShell scripts (where pwsh is available)
- Config reading from git-config.yml
- Extension install via ExtensionManager
"""
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "git"
EXT_BASH = EXT_DIR / "scripts" / "bash"
EXT_PS = EXT_DIR / "scripts" / "powershell"
CORE_COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CORE_COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
HAS_PWSH = shutil.which("pwsh") is not None
# ── Helpers ──────────────────────────────────────────────────────────────────
def _init_git(path: Path) -> None:
"""Initialize a git repo with a dummy commit."""
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "seed", "-q"],
cwd=path,
check=True,
)
def _setup_project(tmp_path: Path, *, git: bool = True) -> Path:
"""Create a project directory with core scripts and .specify."""
# Core scripts (needed by extension scripts that source common.sh)
bash_dir = tmp_path / "scripts" / "bash"
bash_dir.mkdir(parents=True)
shutil.copy(CORE_COMMON_SH, bash_dir / "common.sh")
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CORE_COMMON_PS, ps_dir / "common.ps1")
# .specify structure
(tmp_path / ".specify" / "templates").mkdir(parents=True)
# Extension scripts (as if installed)
ext_bash = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_bash.mkdir(parents=True)
for f in EXT_BASH.iterdir():
dest = ext_bash / f.name
shutil.copy(f, dest)
dest.chmod(0o755)
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True)
for f in EXT_PS.iterdir():
shutil.copy(f, ext_ps / f.name)
# Copy extension.yml
shutil.copy(EXT_DIR / "extension.yml", tmp_path / ".specify" / "extensions" / "git" / "extension.yml")
if git:
_init_git(tmp_path)
return tmp_path
def _write_config(project: Path, content: str) -> Path:
"""Write git-config.yml into the extension config directory."""
config_path = project / ".specify" / "extensions" / "git" / "git-config.yml"
config_path.write_text(content, encoding="utf-8")
return config_path
# Git identity env vars for CI runners without global git config
_GIT_ENV = {
"GIT_AUTHOR_NAME": "Test User",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test User",
"GIT_COMMITTER_EMAIL": "test@example.com",
}
def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
"""Run an extension bash script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "bash" / script_name
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
return subprocess.run(
["bash", str(script), *args],
cwd=cwd,
capture_output=True,
text=True,
env=env,
)
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run an extension PowerShell script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
env = {**os.environ, **_GIT_ENV}
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), *args],
cwd=cwd,
capture_output=True,
text=True,
env=env,
)
# ── Manifest Tests ───────────────────────────────────────────────────────────
class TestGitExtensionManifest:
def test_manifest_validates(self):
"""extension.yml passes manifest validation."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
assert m.id == "git"
assert m.version == "1.0.0"
def test_manifest_commands(self):
"""Manifest declares expected commands."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
names = [c["name"] for c in m.commands]
assert "speckit.git.feature" in names
assert "speckit.git.validate" in names
assert "speckit.git.remote" in names
assert "speckit.git.initialize" in names
assert "speckit.git.commit" in names
def test_manifest_hooks(self):
"""Manifest declares expected hooks."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
assert "before_constitution" in m.hooks
assert "before_specify" in m.hooks
assert "after_specify" in m.hooks
assert "after_implement" in m.hooks
assert m.hooks["before_constitution"]["command"] == "speckit.git.initialize"
assert m.hooks["before_specify"]["command"] == "speckit.git.feature"
def test_manifest_command_files_exist(self):
"""All command files referenced in the manifest exist."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
for cmd in m.commands:
cmd_path = EXT_DIR / cmd["file"]
assert cmd_path.is_file(), f"Missing command file: {cmd['file']}"
# ── Install Tests ────────────────────────────────────────────────────────────
class TestGitExtensionInstall:
def test_install_from_directory(self, tmp_path: Path):
"""Extension installs via ExtensionManager.install_from_directory."""
from specify_cli.extensions import ExtensionManager
(tmp_path / ".specify").mkdir()
manager = ExtensionManager(tmp_path)
manifest = manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
assert manifest.id == "git"
assert manager.registry.is_installed("git")
def test_install_copies_scripts(self, tmp_path: Path):
"""Extension install copies script files."""
from specify_cli.extensions import ExtensionManager
(tmp_path / ".specify").mkdir()
manager = ExtensionManager(tmp_path)
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
ext_installed = tmp_path / ".specify" / "extensions" / "git"
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" / "auto-commit.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "git-common.sh").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" / "auto-commit.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
def test_bundled_extension_locator(self):
"""_locate_bundled_extension finds the git extension."""
from specify_cli import _locate_bundled_extension
path = _locate_bundled_extension("git")
assert path is not None
assert (path / "extension.yml").is_file()
# ── initialize-repo.sh Tests ─────────────────────────────────────────────────
class TestInitializeRepoBash:
def test_initializes_git_repo(self, tmp_path: Path):
"""initialize-repo.sh creates a git repo with initial commit."""
project = _setup_project(tmp_path, git=False)
result = _run_bash("initialize-repo.sh", project)
assert result.returncode == 0, result.stderr
# Verify git repo exists
assert (project / ".git").exists()
# Verify at least one commit exists
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert log.returncode == 0
def test_skips_if_already_git_repo(self, tmp_path: Path):
"""initialize-repo.sh skips if already a git repo."""
project = _setup_project(tmp_path, git=True)
result = _run_bash("initialize-repo.sh", project)
assert result.returncode == 0
assert "already initialized" in result.stderr.lower()
def test_custom_commit_message(self, tmp_path: Path):
"""initialize-repo.sh reads custom commit message from config."""
project = _setup_project(tmp_path, git=False)
_write_config(project, 'init_commit_message: "Custom init message"\n')
result = _run_bash("initialize-repo.sh", project)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "Custom init message" in log.stdout
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestInitializeRepoPowerShell:
def test_initializes_git_repo(self, tmp_path: Path):
"""initialize-repo.ps1 creates a git repo with initial commit."""
project = _setup_project(tmp_path, git=False)
result = _run_pwsh("initialize-repo.ps1", project)
assert result.returncode == 0, result.stderr
assert (project / ".git").exists()
def test_skips_if_already_git_repo(self, tmp_path: Path):
"""initialize-repo.ps1 skips if already a git repo."""
project = _setup_project(tmp_path, git=True)
result = _run_pwsh("initialize-repo.ps1", project)
assert result.returncode == 0
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
class TestCreateFeatureBash:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.sh creates sequential branch."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "user-auth", "Add user authentication",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
assert "SPEC_FILE" in data
assert data["FEATURE_NUM"] == "001"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.sh creates timestamp branch."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--timestamp", "--short-name", "feat", "Feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_creates_spec_dir(self, tmp_path: Path):
"""create-new-feature.sh creates specs directory and spec.md."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "test-feat", "Test feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.exists(), f"spec.md not created at {spec_file}"
def test_increments_from_existing_specs(self, tmp_path: Path):
"""Sequential numbering increments past existing spec directories."""
project = _setup_project(tmp_path)
(project / "specs" / "001-first").mkdir(parents=True)
(project / "specs" / "002-second").mkdir(parents=True)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "third", "Third feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "003"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.sh works without git (creates spec dir only)."""
project = _setup_project(tmp_path, git=False)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "no-git", "No git feature",
)
assert result.returncode == 0, result.stderr
assert "Warning" in result.stderr
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.exists()
def test_dry_run(self, tmp_path: Path):
"""--dry-run computes branch name without creating anything."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data.get("DRY_RUN") is True
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates sequential branch."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature.ps1", project,
"-Json", "-ShortName", "user-auth", "Add user authentication",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates timestamp branch."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature.ps1", project,
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.ps1 works without git."""
project = _setup_project(tmp_path, git=False)
result = _run_pwsh(
"create-new-feature.ps1", project,
"-Json", "-ShortName", "no-git", "No git feature",
)
assert result.returncode == 0, result.stderr
# pwsh may prefix warnings to stdout; find the JSON line
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
assert json_line, f"No JSON in output: {result.stdout}"
data = json.loads(json_line[-1])
assert Path(data["SPEC_FILE"]).exists()
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
class TestAutoCommitBash:
def test_disabled_by_default(self, tmp_path: Path):
"""auto-commit.sh exits silently when config is all false."""
project = _setup_project(tmp_path)
_write_config(project, "auto_commit:\n default: false\n")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
# Should not have created any new commits
log = subprocess.run(
["git", "log", "--oneline"],
cwd=project, capture_output=True, text=True,
)
assert log.stdout.strip().count("\n") == 0 # only the seed commit
def test_enabled_per_command(self, tmp_path: Path):
"""auto-commit.sh commits when per-command key is enabled."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "test commit after specify"\n'
))
# Create a file to commit
(project / "specs" / "001-test" / "spec.md").parent.mkdir(parents=True)
(project / "specs" / "001-test" / "spec.md").write_text("test spec")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "test commit after specify" in log.stdout
def test_custom_message(self, tmp_path: Path):
"""auto-commit.sh uses the per-command message."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_plan:\n"
" enabled: true\n"
' message: "[Project] Plan complete"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_plan")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Project] Plan complete" in log.stdout
def test_default_true_with_no_event_key(self, tmp_path: Path):
"""auto-commit.sh uses default: true when event key is absent."""
project = _setup_project(tmp_path)
_write_config(project, "auto_commit:\n default: true\n")
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_tasks")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "Auto-commit after tasks" in log.stdout
def test_no_changes_skips(self, tmp_path: Path):
"""auto-commit.sh skips when there are no changes."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "should not appear"\n'
))
# Commit all existing files so nothing is dirty
subprocess.run(["git", "add", "."], cwd=project, check=True)
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "No changes" in result.stderr
def test_no_config_file_skips(self, tmp_path: Path):
"""auto-commit.sh exits silently when no config file exists."""
project = _setup_project(tmp_path)
# Remove config if it was copied
config = project / ".specify" / "extensions" / "git" / "git-config.yml"
config.unlink(missing_ok=True)
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
def test_no_git_repo_skips(self, tmp_path: Path):
"""auto-commit.sh skips when not in a git repo."""
project = _setup_project(tmp_path, git=False)
_write_config(project, "auto_commit:\n default: true\n")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "not a Git repository" in result.stderr.lower() or "Warning" in result.stderr
def test_requires_event_name_argument(self, tmp_path: Path):
"""auto-commit.sh fails without event name argument."""
project = _setup_project(tmp_path)
result = _run_bash("auto-commit.sh", project)
assert result.returncode != 0
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
def test_disabled_by_default(self, tmp_path: Path):
"""auto-commit.ps1 exits silently when config is all false."""
project = _setup_project(tmp_path)
_write_config(project, "auto_commit:\n default: false\n")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
def test_enabled_per_command(self, tmp_path: Path):
"""auto-commit.ps1 commits when per-command key is enabled."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "ps commit"\n'
))
(project / "specs" / "001-test").mkdir(parents=True)
(project / "specs" / "001-test" / "spec.md").write_text("test")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "ps commit" in log.stdout
# ── git-common.sh Tests ──────────────────────────────────────────────────────
class TestGitCommonBash:
def test_has_git_true(self, tmp_path: Path):
"""has_git returns 0 in a git repo."""
project = _setup_project(tmp_path, git=True)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && has_git "{project}"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_has_git_false(self, tmp_path: Path):
"""has_git returns non-zero outside a git repo."""
project = _setup_project(tmp_path, git=False)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && has_git "{project}"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_sequential(self, tmp_path: Path):
"""check_feature_branch accepts sequential branch names."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "001-my-feature" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_timestamp(self, tmp_path: Path):
"""check_feature_branch accepts timestamp branch names."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "20260319-143022-feat" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_rejects_main(self, tmp_path: Path):
"""check_feature_branch rejects non-feature branch names."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "main" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path):
"""check_feature_branch rejects malformed timestamps (7-digit date)."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "2026031-143022-feat" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0

View File

@@ -255,17 +255,66 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid command name"):
ExtensionManifest(manifest_path)
def test_no_commands(self, temp_dir, valid_manifest_data):
"""Test manifest with no commands provided."""
def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data):
"""Test manifest with no commands and no hooks provided."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data.pop("hooks", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must provide at least one command"):
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
ExtensionManifest(manifest_path)
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
"""Test manifest with hooks but no commands is valid."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data["hooks"] = {
"after_specify": {
"command": "speckit.test-ext.notify",
"optional": True,
"prompt": "Run notification?",
}
}
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.id == valid_manifest_data["extension"]["id"]
assert len(manifest.commands) == 0
assert len(manifest.hooks) == 1
def test_commands_null_rejected(self, temp_dir, valid_manifest_data):
"""Test manifest with commands: null is rejected."""
import yaml
valid_manifest_data["provides"]["commands"] = None
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid provides.commands"):
ExtensionManifest(manifest_path)
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
"""Test manifest with hooks as a list is rejected."""
import yaml
valid_manifest_data["hooks"] = ["not", "a", "dict"]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid hooks"):
ExtensionManifest(manifest_path)
def test_manifest_hash(self, extension_dir):