Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion extensions/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
- **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)
- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)

## Commands

Expand Down Expand Up @@ -66,6 +66,11 @@ branch_prefix: ""
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"

# Commit message style for auto-commit hooks: "fixed" (default) uses the
# messages below; "conventional" asks the agent to generate a Conventional
# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
commit_style: fixed

# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:
Expand Down
23 changes: 19 additions & 4 deletions extensions/git/commands/speckit.git.commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,37 @@ This command is invoked as a hook after (or before) core commands. It:
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
5. Determines the commit message based on `commit_style` (see below)
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`

## Commit Message Styles

Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:

- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Pass this message as the script's second argument. The configured `message` values are ignored in this mode.

## 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>`
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> ["<generated_message>"]`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> ["<generated_message>"]`

Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass `<generated_message>` when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:

- If `conventional`: inspect the diff, generate a Conventional Commit message, and pass it as the second argument.
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.

## Configuration

In `.specify/extensions/git/git-config.yml`:

```yaml
# "fixed" (default) uses the messages below; "conventional" asks the agent
# to generate a Conventional Commit message from the diff instead.
commit_style: fixed

auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
Expand All @@ -46,3 +60,4 @@ auto_commit:
- 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
- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format
7 changes: 7 additions & 0 deletions extensions/git/config-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

# Commit message style used by auto-commit hooks (speckit.git.commit):
# "fixed" - default; use the configured/static messages below.
# "conventional" - ask the agent to inspect the diff and generate a
# Conventional Commit message (e.g. "feat: add OAuth spec")
# instead of using the messages configured below.
commit_style: fixed

# 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.
Expand Down
7 changes: 7 additions & 0 deletions extensions/git/git-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

# Commit message style used by auto-commit hooks (speckit.git.commit):
# "fixed" - default; use the configured/static messages below.
# "conventional" - ask the agent to inspect the diff and generate a
# Conventional Commit message (e.g. "feat: add OAuth spec")
# instead of using the messages configured below.
commit_style: fixed

# 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.
Expand Down
25 changes: 23 additions & 2 deletions extensions/git/scripts/bash/auto-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@
# 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>
# Usage: auto-commit.sh <event_name> [generated_message]
# e.g.: auto-commit.sh after_specify
# e.g.: auto-commit.sh after_specify "feat: add OAuth specification" (commit_style: conventional)

set -e

EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
echo "Usage: $0 <event_name> [generated_message]" >&2
exit 1
fi

# Optional second argument: an agent-generated commit message (used when
# commit_style: conventional is configured).
GENERATED_MESSAGE="${2:-}"

SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

_find_project_root() {
Expand Down Expand Up @@ -46,8 +51,13 @@ fi
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
_commit_style="fixed"

if [ -f "$_config_file" ]; then
# Top-level scalar key: commit_style (fixed | conventional)
_style_val=$(grep '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ -n "$_style_val" ] && _commit_style="$_style_val"

# Parse the auto_commit section for this event.
Comment on lines 56 to 61
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
Expand Down Expand Up @@ -123,6 +133,17 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
exit 0
fi

# In conventional mode, the commit message must be supplied by the agent
# (via the generated_message argument); never fall back to the fixed message.
if [ "$_commit_style" = "conventional" ]; then
if [ -n "$GENERATED_MESSAGE" ]; then
_commit_msg="$GENERATED_MESSAGE"
else
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; skipped auto-commit" >&2
exit 1
fi
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_//')
Expand Down
30 changes: 28 additions & 2 deletions extensions/git/scripts/powershell/auto-commit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
# 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>
# Usage: auto-commit.ps1 <event_name> [generated_message]
# e.g.: auto-commit.ps1 after_specify
# e.g.: auto-commit.ps1 after_specify "feat: add OAuth specification" (commit_style: conventional)
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
[string]$EventName,

# Optional agent-generated commit message (used when commit_style: conventional is configured).
[Parameter(Position = 1, Mandatory = $false)]
[string]$GeneratedMessage = ""
)
$ErrorActionPreference = 'Stop'

Expand Down Expand Up @@ -55,8 +60,18 @@ if (-not $isRepo) {
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
$commitStyle = "fixed"

if (Test-Path $configFile) {
# Top-level scalar key: commit_style (fixed | conventional)
foreach ($line in Get-Content $configFile) {
if ($line -match '^commit_style:\s*(.+)$') {
$styleVal = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
if ($styleVal) { $commitStyle = $styleVal.ToLower() }
break
}
}
Comment on lines +66 to +73

# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
Expand Down Expand Up @@ -140,6 +155,17 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
exit 0
}

# In conventional mode, the commit message must be supplied by the agent
# (via the GeneratedMessage argument); never fall back to the fixed message.
if ($commitStyle -eq 'conventional') {
if ($GeneratedMessage) {
$commitMsg = $GeneratedMessage
} else {
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; skipped auto-commit"
exit 1
}
}

# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
Expand Down
156 changes: 156 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,93 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"


@requires_bash
class TestAutoCommitBashCommitStyle:
"""Tests for the `commit_style: conventional` option (issue #3390)."""

def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
"""Omitting commit_style preserves the fixed/static message behavior."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout

def test_conventional_uses_generated_message(self, tmp_path: Path):
"""commit_style: conventional uses the generated_message argument as the commit message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout

def test_conventional_without_generated_message_fails(self, tmp_path: Path):
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode != 0
assert "conventional" in result.stderr.lower()

# No commit should have been made, and the fixed message must not be used.
log = subprocess.run(
["git", "log", "--oneline"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" not in log.stdout

def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
"""No pending changes short-circuits before the missing-message failure."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
subprocess.run(["git", "add", "."], cwd=project, check=True)
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)

result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "No changes" in result.stderr


@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
def test_disabled_by_default(self, tmp_path: Path):
Expand Down Expand Up @@ -1151,6 +1238,75 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"


@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShellCommitStyle:
"""Tests for the `commit_style: conventional` option (issue #3390)."""

def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
"""Omitting commit_style preserves the fixed/static message behavior."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout

def test_conventional_uses_generated_message(self, tmp_path: Path):
"""commit_style: conventional uses the generated_message argument as the commit message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout

def test_conventional_without_generated_message_fails(self, tmp_path: Path):
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode != 0
assert "conventional" in result.stderr.lower()

log = subprocess.run(
["git", "log", "--oneline"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" not in log.stdout


# ── auto-commit.ps1 CRLF warning tests (issue #2253) ────────────────────────


Expand Down
Loading