diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 721624ab81..ce0ff1775c 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -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 diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 4eb7626d8f..dfc1125228 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -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 diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 190e263af2..595985d955 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -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 diff --git a/extensions/catalog.json b/extensions/catalog.json index f06cfe5744..a039883ba2 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -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", diff --git a/extensions/git/README.md b/extensions/git/README.md new file mode 100644 index 0000000000..31ba75c30f --- /dev/null +++ b/extensions/git/README.md @@ -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) diff --git a/extensions/git/commands/speckit.git.commit.md b/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 0000000000..e606f911df --- /dev/null +++ b/extensions/git/commands/speckit.git.commit.md @@ -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 ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` 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 diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 0000000000..13a7d0784d --- /dev/null +++ b/extensions/git/commands/speckit.git.feature.md @@ -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 "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**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 diff --git a/extensions/git/commands/speckit.git.initialize.md b/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 0000000000..4451ee6b77 --- /dev/null +++ b/extensions/git/commands/speckit.git.initialize.md @@ -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 diff --git a/extensions/git/commands/speckit.git.remote.md b/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 0000000000..712a3e8b8c --- /dev/null +++ b/extensions/git/commands/speckit.git.remote.md @@ -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//.git` +- SSH: `git@github.com:/.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 diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 0000000000..dd84618cb8 --- /dev/null +++ b/extensions/git/commands/speckit.git.validate.md @@ -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: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## 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 diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml new file mode 100644 index 0000000000..8c414babe6 --- /dev/null +++ b/extensions/git/config-template.yml @@ -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" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml new file mode 100644 index 0000000000..13c1977ea1 --- /dev/null +++ b/extensions/git/extension.yml @@ -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" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml new file mode 100644 index 0000000000..8c414babe6 --- /dev/null +++ b/extensions/git/git-config.yml @@ -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" diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh new file mode 100755 index 0000000000..49c32fe634 --- /dev/null +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -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 +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&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..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 diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000000..dfae29df73 --- /dev/null +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -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 ] [--number N] [--timestamp] " + 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 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 ] [--number N] [--timestamp] " >&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 diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh new file mode 100755 index 0000000000..882a385e28 --- /dev/null +++ b/extensions/git/scripts/bash/git-common.sh @@ -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 +} diff --git a/extensions/git/scripts/bash/initialize-repo.sh b/extensions/git/scripts/bash/initialize-repo.sh new file mode 100755 index 0000000000..296e363b94 --- /dev/null +++ b/extensions/git/scripts/bash/initialize-repo.sh @@ -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 diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 0000000000..e9777ff9be --- /dev/null +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -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 +# 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" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 0000000000..75a4e69814 --- /dev/null +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -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 ] [-Number N] [-Timestamp] " + 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 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 ] [-Number N] [-Timestamp] " + 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" + } +} diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 0000000000..8a9c4fd6cc --- /dev/null +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -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 +} diff --git a/extensions/git/scripts/powershell/initialize-repo.ps1 b/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 0000000000..324240a3e7 --- /dev/null +++ b/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 02a4cdd7dc..9e46b0a14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 `) +"extensions/git" = "specify_cli/core_pack/extensions/git" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1c62a4a3be..146d8e6e12 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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//`` 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) + + 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) - # 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) + # 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() + 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})") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3420a7651b..6d7b7c1199 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -183,11 +183,40 @@ def _validate(self): # 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 @@ def requires_speckit_version(self) -> str: @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]: diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index b3174338d9..43e6d225b1 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -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 diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index a79131a204..533046566b 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -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 diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 26efb5aedb..d6d6bbe910 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -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 diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 63d4f662ae..29ae9a09e2 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -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(): 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 diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index d6aa3bbf55..77db7be130 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -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 diff --git a/tests/extensions/__init__.py b/tests/extensions/__init__.py new file mode 100644 index 0000000000..97d2f6978b --- /dev/null +++ b/tests/extensions/__init__.py @@ -0,0 +1 @@ +"""Extensions test package.""" diff --git a/tests/extensions/git/__init__.py b/tests/extensions/git/__init__.py new file mode 100644 index 0000000000..bec5daeccc --- /dev/null +++ b/tests/extensions/git/__init__.py @@ -0,0 +1 @@ +"""Tests for the bundled git extension.""" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py new file mode 100644 index 0000000000..721bd999f2 --- /dev/null +++ b/tests/extensions/git/test_git_extension.py @@ -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 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6ebb39789a..9d4df6a9a1 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -255,17 +255,66 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): 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):