chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# 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 and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
|
||||
## 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
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
|
||||
# before {number}, and the final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# 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"
|
||||
```
|
||||
|
||||
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
|
||||
|
||||
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
|
||||
|
||||
## Installation
|
||||
|
||||
```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-branch.sh` — Bash implementation (branch creation only)
|
||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||
- `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
|
||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: "Auto-commit changes after a Spec Kit command completes"
|
||||
---
|
||||
|
||||
# Auto-Commit Changes
|
||||
|
||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||
|
||||
## Behavior
|
||||
|
||||
This command is invoked as a hook after (or before) core commands. It:
|
||||
|
||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
enabled: true # Override per-command
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
---
|
||||
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Environment Variable Override
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 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
|
||||
|
||||
## 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 `feature_numbering` value (inherit from core)
|
||||
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
|
||||
4. Default to `sequential` if none of the above exist
|
||||
|
||||
## Branch Name Template
|
||||
|
||||
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
|
||||
|
||||
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
|
||||
- `{app}`: sanitized Spec Kit init directory name
|
||||
- `{number}`: sequential number or timestamp
|
||||
- `{slug}`: generated short branch slug
|
||||
|
||||
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
|
||||
|
||||
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
|
||||
|
||||
## 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-branch.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**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 `FEATURE_NUM`
|
||||
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the current directory is not a Git repository:
|
||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
@@ -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:
|
||||
- `[OK] 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
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: "Detect Git remote URL for GitHub integration"
|
||||
---
|
||||
|
||||
# Detect Git Remote URL
|
||||
|
||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and return empty:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||
```
|
||||
|
||||
## Execution
|
||||
|
||||
Run the following command to get the remote URL:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Parse the remote URL and determine:
|
||||
|
||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||
|
||||
Supported URL formats:
|
||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||
- Return an empty result
|
||||
- Do NOT error — other workflows should continue without Git remote information
|
||||
@@ -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's final path segment must start with one of these feature markers:
|
||||
|
||||
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`)
|
||||
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-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
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}
|
||||
# {slug} must not appear before {number}; final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# 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"
|
||||
@@ -0,0 +1,142 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: git
|
||||
name: "Git Branching Workflow"
|
||||
version: "1.0.0"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
|
||||
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 and optional templates"
|
||||
- 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
|
||||
branch_template: ""
|
||||
branch_prefix: ""
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}
|
||||
# {slug} must not appear before {number}; final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# 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"
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: auto-commit.sh
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if git is available
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read per-command config from git-config.yml
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
_in_auto_commit=false
|
||||
_in_event=false
|
||||
_default_enabled=false
|
||||
|
||||
while IFS= read -r _line; do
|
||||
# Detect auto_commit: section
|
||||
if echo "$_line" | grep -q '^auto_commit:'; then
|
||||
_in_auto_commit=true
|
||||
_in_event=false
|
||||
continue
|
||||
fi
|
||||
|
||||
# Exit auto_commit section on next top-level key
|
||||
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
||||
break
|
||||
fi
|
||||
|
||||
if $_in_auto_commit; then
|
||||
# Check default key
|
||||
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
[ "$_val" = "true" ] && _default_enabled=true
|
||||
fi
|
||||
|
||||
# Detect our event subsection
|
||||
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
||||
_in_event=true
|
||||
continue
|
||||
fi
|
||||
|
||||
# Inside our event subsection
|
||||
if $_in_event; then
|
||||
# Exit on next sibling key (same indent level as event name)
|
||||
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
||||
_in_event=false
|
||||
continue
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
[ "$_val" = "true" ] && _enabled=true
|
||||
[ "$_val" = "false" ] && _enabled=false
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done < "$_config_file"
|
||||
|
||||
# If event-specific key not found, use default
|
||||
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
||||
# Only use default if the event wasn't explicitly set to false
|
||||
# Check if event section existed at all
|
||||
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
||||
_enabled=true
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# No config file — auto-commit disabled by default
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$_enabled" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
||||
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
||||
|
||||
# Use custom message if configured, otherwise default
|
||||
if [ -z "$_commit_msg" ]; then
|
||||
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
||||
fi
|
||||
|
||||
# Stage and commit
|
||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||
|
||||
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: create-new-feature-branch.sh
|
||||
# Creates a git feature branch only. The feature directory and spec file
|
||||
# are created by the core create-new-feature.sh script.
|
||||
# Sources common.sh from the project's installed scripts, falling back to
|
||||
# git-common.sh for minimal git helpers.
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
DRY_RUN=false
|
||||
ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
--allow-existing-branch)
|
||||
ALLOW_EXISTING=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo 'Error: --number must be a non-negative integer' >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute branch name without creating the branch"
|
||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
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() {
|
||||
local scope_prefix="${1:-}"
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
_extract_highest_number() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if [ -n "$scope_prefix" ]; then
|
||||
case "$name" in
|
||||
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
name="${name##*/}"
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
|
||||
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}"
|
||||
local scope_prefix="${3:-}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
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
|
||||
|
||||
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
|
||||
# minimal git-common.sh was loaded, or an older core common.sh without the
|
||||
# resolver was loaded, refuse rather than silently falling back to the wrong root.
|
||||
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
|
||||
echo "Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.sh with resolve_specify_init_dir), which were not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve repository root. When the core scripts are present, get_repo_root
|
||||
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
|
||||
# CI use) and hard-fails on an invalid value with no silent fallback.
|
||||
if type get_repo_root >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(get_repo_root) || exit 1
|
||||
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"
|
||||
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
|
||||
read_git_config_value() {
|
||||
local key="$1"
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
|
||||
| head -n 1 \
|
||||
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
|
||||
| sed -E 's/[[:space:]]+#.*$//' \
|
||||
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
|
||||
| sed -E 's/^"//; s/"$//' \
|
||||
| sed -E "s/^'//; s/'$//"
|
||||
}
|
||||
|
||||
branch_token() {
|
||||
local value="$1"
|
||||
local fallback="$2"
|
||||
local cleaned
|
||||
cleaned=$(clean_branch_name "$value")
|
||||
if [ -n "$cleaned" ]; then
|
||||
printf '%s\n' "$cleaned"
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
get_author_token() {
|
||||
local author=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
author=$(git config user.name 2>/dev/null || true)
|
||||
if [ -z "$author" ]; then
|
||||
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
|
||||
fi
|
||||
fi
|
||||
if [ -z "$author" ]; then
|
||||
author="${USER:-unknown}"
|
||||
fi
|
||||
branch_token "$author" "unknown"
|
||||
}
|
||||
|
||||
get_app_token() {
|
||||
branch_token "$(basename "$REPO_ROOT")" "app"
|
||||
}
|
||||
|
||||
resolve_branch_template() {
|
||||
local template
|
||||
local prefix
|
||||
template=$(read_git_config_value "branch_template")
|
||||
if [ -n "$template" ]; then
|
||||
printf '%s\n' "$template"
|
||||
return
|
||||
fi
|
||||
|
||||
prefix=$(read_git_config_value "branch_prefix")
|
||||
if [ -z "$prefix" ]; then
|
||||
printf '%s\n' ""
|
||||
return
|
||||
fi
|
||||
case "$prefix" in
|
||||
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_branch_template() {
|
||||
local template="$1"
|
||||
local feature_num="$2"
|
||||
local branch_suffix="$3"
|
||||
local rendered="$template"
|
||||
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
|
||||
rendered=${rendered//\{app\}/$APP_TOKEN}
|
||||
rendered=${rendered//\{number\}/$feature_num}
|
||||
rendered=${rendered//\{slug\}/$branch_suffix}
|
||||
printf '%s\n' "$rendered"
|
||||
}
|
||||
|
||||
validate_branch_template() {
|
||||
local template="$1"
|
||||
[ -n "$template" ] || return 0
|
||||
local feature_segment
|
||||
feature_segment="${template##*/}"
|
||||
case "$template" in
|
||||
*"{number}"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$template" in
|
||||
*"{slug}"*"{number}"*)
|
||||
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$feature_segment" in
|
||||
"{number}-"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
if [ -n "$BRANCH_TEMPLATE" ]; then
|
||||
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
|
||||
else
|
||||
printf '%s-%s\n' "$feature_num" "$branch_suffix"
|
||||
fi
|
||||
}
|
||||
|
||||
branch_scope_prefix() {
|
||||
local template="$1"
|
||||
local prefix="$template"
|
||||
[ -n "$prefix" ] || return 0
|
||||
case "$prefix" in
|
||||
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
|
||||
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
|
||||
}
|
||||
|
||||
extract_feature_num_from_branch() {
|
||||
local branch_name="$1"
|
||||
local feature_segment="${branch_name##*/}"
|
||||
local match
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$branch_name"
|
||||
}
|
||||
|
||||
AUTHOR_TOKEN=$(get_author_token)
|
||||
APP_TOKEN=$(get_app_token)
|
||||
BRANCH_TEMPLATE=$(resolve_branch_template)
|
||||
validate_branch_template "$BRANCH_TEMPLATE"
|
||||
|
||||
# Function to generate branch name with stop word filtering
|
||||
generate_branch_name() {
|
||||
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=$(printf '%s' "$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")
|
||||
# Uppercase via tr (portable) rather than bash's 4+ "^^" case
|
||||
# expansion, which breaks on macOS's default bash 3.2 (bad substitution).
|
||||
elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; 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
|
||||
}
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
else
|
||||
# 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=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
else
|
||||
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
fi
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
MAX_BRANCH_LENGTH=244
|
||||
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
|
||||
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||
exit 1
|
||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
|
||||
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
|
||||
done
|
||||
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
|
||||
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
|
||||
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
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 ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
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 feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||
else
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
|
||||
fi
|
||||
else
|
||||
if type json_escape >/dev/null 2>&1; then
|
||||
_je_branch=$(json_escape "$BRANCH_NAME")
|
||||
_je_num=$(json_escape "$FEATURE_NUM")
|
||||
else
|
||||
_je_branch="$BRANCH_NAME"
|
||||
_je_num="$FEATURE_NUM"
|
||||
fi
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
|
||||
else
|
||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
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
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
spec_kit_effective_branch_name() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||
else
|
||||
printf '%s\n' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
# Validate that a branch name matches the expected feature branch pattern.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
|
||||
# either at the start of the branch or after path-style namespace prefixes.
|
||||
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
local feature_segment="${branch##*/}"
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
+54
@@ -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 "[OK] Git repository initialized" >&2
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: auto-commit.ps1
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||
Set-Location $repoRoot
|
||||
|
||||
# Check if git is available
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Temporarily relax ErrorActionPreference so git stderr warnings
|
||||
# (e.g. CRLF notices on Windows) do not become terminating errors.
|
||||
$savedEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
$isRepo = $LASTEXITCODE -eq 0
|
||||
} finally {
|
||||
$ErrorActionPreference = $savedEAP
|
||||
}
|
||||
if (-not $isRepo) {
|
||||
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
|
||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
|
||||
$savedEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
|
||||
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
|
||||
$untracked = git ls-files --others --exclude-standard 2>$null
|
||||
} finally {
|
||||
$ErrorActionPreference = $savedEAP
|
||||
}
|
||||
|
||||
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
|
||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
|
||||
# while still allowing redirected error output to be captured for diagnostics.
|
||||
$savedEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
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
|
||||
} finally {
|
||||
$ErrorActionPreference = $savedEAP
|
||||
}
|
||||
|
||||
Write-Host "[OK] Changes committed $phase $commandName"
|
||||
@@ -0,0 +1,576 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: create-new-feature-branch.ps1
|
||||
# Creates a git feature branch only. The feature directory and spec file
|
||||
# are created by the core create-new-feature.ps1 script.
|
||||
# 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-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute branch name without creating the branch"
|
||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "Environment variables:"
|
||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
Write-Host ""
|
||||
Write-Host "Configuration:"
|
||||
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
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,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
|
||||
continue
|
||||
}
|
||||
if ($ScopePrefix) {
|
||||
$name = $name.Substring($ScopePrefix.Length)
|
||||
}
|
||||
$name = ($name -split '/')[-1]
|
||||
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
|
||||
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
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 -ScopePrefix $ScopePrefix
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
[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 -ScopePrefix $ScopePrefix
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not query remote refs: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
|
||||
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
|
||||
# minimal git-common.ps1 was loaded, or an older core common.ps1 without the
|
||||
# resolver was loaded, refuse rather than silently falling back to the wrong root.
|
||||
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) {
|
||||
throw "SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found."
|
||||
}
|
||||
|
||||
# Resolve repository root. When the core scripts are present, Get-RepoRoot
|
||||
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
|
||||
# CI use) and hard-fails on an invalid value with no silent fallback.
|
||||
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) {
|
||||
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
||||
# and git-common.ps1 (has -RepoRoot param with default).
|
||||
$hasGit = Test-HasGit
|
||||
} 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'
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
|
||||
function Read-GitConfigValue {
|
||||
param([string]$Key)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
|
||||
$escapedKey = [regex]::Escape($Key)
|
||||
foreach ($line in Get-Content -LiteralPath $configFile) {
|
||||
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
|
||||
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
|
||||
$val = $val -replace '^["'']', '' -replace '["'']$', ''
|
||||
return $val
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function ConvertTo-BranchToken {
|
||||
param(
|
||||
[string]$Value,
|
||||
[string]$Fallback
|
||||
)
|
||||
|
||||
$cleaned = ConvertTo-CleanBranchName -Name $Value
|
||||
if ($cleaned) { return $cleaned }
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
function Get-GitAuthorToken {
|
||||
$author = ''
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
|
||||
if (-not $author) {
|
||||
try {
|
||||
$email = (git config user.email 2>$null | Out-String).Trim()
|
||||
if ($email) { $author = ($email -split '@')[0] }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
|
||||
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
|
||||
}
|
||||
|
||||
function Get-AppToken {
|
||||
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
|
||||
}
|
||||
|
||||
function Resolve-BranchTemplate {
|
||||
$template = Read-GitConfigValue -Key 'branch_template'
|
||||
if ($template) { return $template }
|
||||
|
||||
$prefix = Read-GitConfigValue -Key 'branch_prefix'
|
||||
if (-not $prefix) { return '' }
|
||||
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
|
||||
return "$prefix/{number}-{slug}"
|
||||
}
|
||||
|
||||
function Expand-BranchTemplate {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$rendered = $Template.Replace('{author}', $authorToken)
|
||||
$rendered = $rendered.Replace('{app}', $appToken)
|
||||
$rendered = $rendered.Replace('{number}', $FeatureNum)
|
||||
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
|
||||
return $rendered
|
||||
}
|
||||
|
||||
function Assert-BranchTemplateValid {
|
||||
param([string]$Template)
|
||||
|
||||
if ($Template -and -not $Template.Contains('{number}')) {
|
||||
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
}
|
||||
if ($Template) {
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
|
||||
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
}
|
||||
$featureSegment = ($Template -split '/')[-1]
|
||||
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
|
||||
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-BranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if ($branchTemplate) {
|
||||
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
|
||||
}
|
||||
return "$FeatureNum-$BranchSuffix"
|
||||
}
|
||||
|
||||
function Get-BranchScopePrefix {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if (-not $Template) { return '' }
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
|
||||
if (-not $indexes) { return '' }
|
||||
$prefix = $Template.Substring(0, $indexes[0])
|
||||
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
|
||||
}
|
||||
|
||||
function Get-FeatureNumberFromBranchName {
|
||||
param([string]$BranchName)
|
||||
|
||||
$featureSegment = ($BranchName -split '/')[-1]
|
||||
if ($featureSegment -match '^(\d{8}-\d{6})-') {
|
||||
return $matches[1]
|
||||
}
|
||||
if ($featureSegment -match '^(\d+)-') {
|
||||
return $matches[1]
|
||||
}
|
||||
return $BranchName
|
||||
}
|
||||
|
||||
function Get-Utf8ByteCount {
|
||||
param([string]$Value)
|
||||
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
|
||||
}
|
||||
|
||||
$authorToken = Get-GitAuthorToken
|
||||
$appToken = Get-AppToken
|
||||
$branchTemplate = Resolve-BranchTemplate
|
||||
Assert-BranchTemplateValid -Template $branchTemplate
|
||||
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
|
||||
$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 -cmatch "\b$($word.ToUpper())\b") {
|
||||
# Case-sensitive (-cmatch) to mirror the bash twin's case-sensitive
|
||||
# whole-word acronym match: keep a short word only when its UPPERCASE
|
||||
# form appears in the original (an acronym). -match is case-insensitive
|
||||
# and would keep every short word.
|
||||
$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)
|
||||
}
|
||||
}
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
|
||||
if ($branchNameUtf8ByteCount -gt 244) {
|
||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||
}
|
||||
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
} else {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
|
||||
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
||||
# `[ -n "$BRANCH_NUMBER" ]` check.
|
||||
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
}
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
# Auto-detect the next number only when -Number was not supplied; an
|
||||
# explicit value (including 0) is honored, matching the bash twin's
|
||||
# `[ -z "$BRANCH_NUMBER" ]` check.
|
||||
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
$originalBranchName = $branchName
|
||||
$truncatedSuffix = $branchSuffix
|
||||
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
|
||||
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
|
||||
}
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
}
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
||||
}
|
||||
|
||||
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 {
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
FEATURE_NUM = $featureNum
|
||||
}
|
||||
# $hasGit is computed for branch-creation logic only; it is intentionally not
|
||||
# emitted so this output contract matches the bash twin: BRANCH_NAME and
|
||||
# FEATURE_NUM, plus DRY_RUN (added just below) on dry runs.
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
}
|
||||
$obj | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/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 Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
}
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
$featureSegment = ($Branch -split '/')[-1]
|
||||
|
||||
# Accept sequential prefix (3+ digits), at the start or after namespace
|
||||
# segments, but exclude malformed timestamps.
|
||||
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
@@ -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 "[OK] Git repository initialized"
|
||||
Reference in New Issue
Block a user