feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)

* feat(git-extension): add configurable Conventional Commit support

Adds a commit_style option (fixed | conventional) to the git
extension's auto-commit config. When set to conventional, the
speckit.git.commit hook instructs the agent to generate a Conventional
Commit message from the diff and pass it to auto-commit.sh /
auto-commit.ps1 as an explicit argument. If no message is supplied in
conventional mode, the scripts fail loudly (stderr + exit 1) instead of
silently falling back to the fixed message, but still short-circuit
cleanly when there are no changes to commit.

- extensions/git/config-template.yml, git-config.yml: new
  commit_style: fixed (default) / conventional option.
- extensions/git/scripts/bash/auto-commit.sh: optional
  [generated_message] arg, commit_style parsing, conventional-mode
  enforcement.
- extensions/git/scripts/powershell/auto-commit.ps1: mirrored
  PowerShell implementation.
- extensions/git/commands/speckit.git.commit.md: documents commit
  message styles and updated execution/config guidance.
- extensions/git/README.md: documents the new option.
- tests/extensions/git/test_git_extension.py: regression tests for
  fixed default, conventional success, conventional missing-message
  failure, and no-changes short-circuit (bash + PowerShell).

Fixes #3390

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(tests): check combined stdout+stderr for conventional commit_style failure test

Write-Warning output stream placement is not deterministic across pwsh
versions/platforms (observed failing on macOS CI). Match the existing
pattern used elsewhere in this file (e.g.
test_not_a_repo_still_detected_with_autocrlf) by asserting against the
combined stdout+stderr instead of stderr alone.

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)

* fix(git-extension): strip YAML inline comments from commit_style value

Copilot review feedback on PR #3413 identified that commit_style parsing
didn't strip trailing YAML inline comments (e.g. "commit_style: conventional
# team standard"), causing the value to retain a trailing comment fragment
and silently skip conventional-mode enforcement.

- bash: fix the inline-comment strip regex to use a proper {1,} interval
  so multiple spaces before '#' are consumed together with the comment,
  preventing a stray trailing quote character from surviving quote-strip
  when the value is quoted (e.g. commit_style: "conventional"  # x).
- powershell: already handled this correctly via \s+#.*$ + Trim(); no
  behavior change needed there.
- tests: add regression coverage for commit_style values with trailing
  inline comments (bash + pwsh), and a pwsh regression test for the
  no-changes short-circuit ordering, per additional Copilot suggestion.

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)

* fix(git-extension): validate commit_style and reword abort message per review

Address Copilot review feedback on PR #3413:
- Validate commit_style against the documented fixed/conventional values;
  an unrecognized value now warns and falls back to fixed instead of
  silently mis-parsing.
- Reword the conventional-mode-without-generated-message message from
  'skipped auto-commit' to 'aborting auto-commit' since the script exits
  1 (a failure, not a skip), and include the actionable remediation.
- Add regression tests (bash + pwsh) covering the unknown commit_style
  fallback.

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(git-extension): limit commit_style parsing to first match in config

Address Copilot review feedback on PR #3413: grep '^commit_style:' without
-m1 could concatenate values if a config file accidentally contains
multiple commit_style lines (e.g. from a bad merge/manual edit), causing
an unexpected fallback to 'fixed'. Limit to the first match and add a
regression test covering duplicate commit_style lines.

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(git-extension): avoid shell interpolation of generated commit messages

- Remove 	r -d '[:space:]' from commit_style parsing in auto-commit.sh:
  it stripped ALL whitespace (not just leading/trailing), so
  commit_style: con ventional was silently normalized to conventional
  instead of being rejected as unknown (PowerShell version already
  rejected it correctly).
- Add a file-based message-passing channel to both auto-commit scripts:
  --message-file <path> (bash) / -MessageFile <path> (PowerShell).
  Agent-generated commit messages may contain quotes, $(...), or
  backticks; passing them as a shell argument risked command injection
  if ever inlined into a shell command string. The new flag reads the
  message from a file instead, so untrusted content never touches a
  shell command line. The raw positional-argument form is kept for
  backward compatibility.
- Update speckit.git.commit.md to instruct the agent to write the
  generated message to a temp file (via its file-editing tool) and pass
  the file path, explicitly warning against inlining the message into a
  shell command string.
- Add test coverage: explicit commit_style: fixed (previously only the
  absent-key default was tested), --message-file/-MessageFile success
  path (including injection-shaped content), and missing-file error path,
  for both bash and PowerShell suites.

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(git-extension): exclude --message-file transport file from staging

The temp file passed via --message-file / -MessageFile was read but left
in the worktree. If written inside the project (as an agent's file-editing
tool would naturally do), git add . staged it into the commit, and its
mere presence as an untracked file could also defeat the no-changes
short-circuit, causing a spurious commit containing only that file.

Remove the file immediately after its content is captured, before the
change-detection check and before staging. Add bash + pwsh regression
tests covering both scenarios.

Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dhruv Rastogi
2026-07-23 21:15:18 +05:30
committed by GitHub
parent 88b3230e2e
commit d0a83890d5
7 changed files with 724 additions and 9 deletions

View File

@@ -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
@@ -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:

View File

@@ -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. Write this message to a temporary file and pass the file's path to the script (see Execution below). 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> [--message-file <path>]`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
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 a 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 and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
- 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:
@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -3,16 +3,57 @@
# 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]
# auto-commit.sh <event_name> --message-file <path>
# e.g.: auto-commit.sh after_specify
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
#
# --message-file is the preferred way to supply an agent-generated commit
# message: it reads the message from a file instead of a shell argument,
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
# is never interpolated into a shell command line.
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
exit 1
fi
shift || true
# Optional second argument: an agent-generated commit message (used when
# commit_style: conventional is configured). Prefer --message-file over
# passing the message directly as a shell argument.
GENERATED_MESSAGE=""
while [ $# -gt 0 ]; do
case "$1" in
--message-file)
_message_file="${2:-}"
if [ -z "$_message_file" ]; then
echo "[specify] Error: --message-file requires a path argument" >&2
exit 1
fi
if [ ! -f "$_message_file" ]; then
echo "[specify] Error: message file '$_message_file' not found" >&2
exit 1
fi
GENERATED_MESSAGE="$(cat "$_message_file")"
# The message file is a transport-only artifact: its content is
# now captured above, so remove it immediately. Otherwise, if it
# was written inside the worktree, it would be picked up as an
# untracked change by both the "any changes?" check below and by
# `git add .`, polluting the commit or defeating the no-changes
# short-circuit even when nothing else changed.
rm -f "$_message_file"
shift 2
;;
*)
GENERATED_MESSAGE="$1"
shift
;;
esac
done
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -46,8 +87,22 @@ 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 -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
if [ -n "$_style_val" ]; then
case "$_style_val" in
fixed|conventional)
_commit_style="$_style_val"
;;
*)
echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
;;
esac
fi
# Parse the auto_commit section for this event.
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
@@ -128,6 +183,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; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&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_//')

View File

@@ -3,14 +3,47 @@
# 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]
# auto-commit.ps1 <event_name> -MessageFile <path>
# e.g.: auto-commit.ps1 after_specify
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
#
# -MessageFile is the preferred way to supply an agent-generated commit
# message: it reads the message from a file instead of a shell argument,
# so message content (which may contain quotes, $(...), backticks, etc.)
# is never interpolated into a shell command line.
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
[string]$EventName,
# Optional agent-generated commit message (used when commit_style: conventional is configured).
# Prefer -MessageFile over passing the message directly as a shell argument.
[Parameter(Position = 1, Mandatory = $false)]
[string]$GeneratedMessage = "",
[Parameter(Mandatory = $false)]
[string]$MessageFile = ""
)
$ErrorActionPreference = 'Stop'
if ($MessageFile) {
if (-not (Test-Path $MessageFile -PathType Leaf)) {
Write-Warning "[specify] Error: message file '$MessageFile' not found"
exit 1
}
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
if ($null -ne $GeneratedMessage) {
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
}
# The message file is a transport-only artifact: its content is now
# captured above, so remove it immediately. Otherwise, if it was written
# inside the worktree, it would be picked up as an untracked change by
# both the "any changes?" check below and by `git add .`, polluting the
# commit or defeating the no-changes short-circuit even when nothing
# else changed.
Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
}
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
@@ -55,8 +88,25 @@ 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] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
if ($styleVal) {
$styleVal = $styleVal.ToLower()
if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
$commitStyle = $styleVal
} else {
Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
}
}
break
}
}
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
@@ -140,6 +190,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; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
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' }

View File

@@ -1167,6 +1167,295 @@ class TestAutoCommitBash:
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_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
"""commit_style: fixed (explicit) still uses the configured static message,
not just the absent-key default."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: fixed\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: this should be ignored"
)
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
assert "this should be ignored" not in log.stdout
def test_conventional_message_file_used(self, tmp_path: Path):
"""--message-file reads the generated message from a file instead of argv,
avoiding shell interpolation of agent-controlled content."""
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")
# Write the message file inside the worktree (as an agent invoking
# this from a working directory tool naturally would) to exercise
# the exclusion-from-staging behavior below.
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add $(dangerous) `injection` test" in log.stdout
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
"""--message-file written inside the worktree must never be staged or
committed itself, and must be removed once its content is consumed."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: real change\n")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
)
assert result.returncode == 0
assert not msg_file.exists()
show = subprocess.run(
["git", "show", "--stat", "--oneline", "HEAD"],
cwd=project, capture_output=True, text=True,
)
assert "new-file.txt" in show.stdout
assert "commit-msg.txt" not in show.stdout
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
"""If the message file is the only 'change' in the worktree (no real
edits), auto-commit must still report no changes rather than
committing the transport file by itself."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
# Baseline-commit the scaffolding (and config) so the tree is
# genuinely clean before introducing the message file — otherwise
# the untracked scaffold files would mask whether the message file
# alone is enough to (incorrectly) trigger a commit.
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-q", "-m", "baseline"],
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
)
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: no real changes\n")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
)
assert result.returncode == 0
assert "No changes to commit" in result.stderr
assert not msg_file.exists()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "baseline" in log.stdout
def test_message_file_missing_fails(self, tmp_path: Path):
"""--message-file pointing at a nonexistent file fails clearly."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
missing = tmp_path / "does-not-exist.txt"
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(missing)
)
assert result.returncode != 0
assert "not found" in result.stderr.lower()
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
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
"""commit_style value with a trailing YAML inline comment is still recognized."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional # team standard\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_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
instead of silently mis-parsing or crashing."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventonal\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 "unknown commit_style" in result.stderr.lower()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
def test_duplicate_commit_style_lines_use_first_match(self, tmp_path: Path):
"""A config with multiple `commit_style:` lines (e.g. from a bad merge) uses only
the first match instead of concatenating values into an unrecognized style."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"commit_style: fixed\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
assert "unknown commit_style" not in result.stderr.lower()
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
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
def test_disabled_by_default(self, tmp_path: Path):
@@ -1227,6 +1516,271 @@ class TestAutoCommitPowerShell:
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_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
"""commit_style: fixed (explicit) still uses the configured static message,
not just the absent-key default."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: fixed\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: this should be ignored"
)
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
assert "this should be ignored" not in log.stdout
def test_conventional_message_file_used(self, tmp_path: Path):
"""-MessageFile reads the generated message from a file instead of argv,
avoiding shell interpolation of agent-controlled content."""
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")
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add $(dangerous) `injection` test" in log.stdout
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
"""-MessageFile written inside the worktree must never be staged or
committed itself, and must be removed once its content is consumed."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: real change\n")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
)
assert result.returncode == 0
assert not msg_file.exists()
show = subprocess.run(
["git", "show", "--stat", "--oneline", "HEAD"],
cwd=project, capture_output=True, text=True,
)
assert "new-file.txt" in show.stdout
assert "commit-msg.txt" not in show.stdout
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
"""If the message file is the only 'change' in the worktree (no real
edits), auto-commit must still report no changes rather than
committing the transport file by itself."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
# Baseline-commit the scaffolding (and config) so the tree is
# genuinely clean before introducing the message file — otherwise
# the untracked scaffold files would mask whether the message file
# alone is enough to (incorrectly) trigger a commit.
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-q", "-m", "baseline"],
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
)
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: no real changes\n")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
)
assert result.returncode == 0
assert "No changes to commit" in (result.stdout + result.stderr)
assert not msg_file.exists()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "baseline" in log.stdout
def test_message_file_missing_fails(self, tmp_path: Path):
"""-MessageFile pointing at a nonexistent file fails clearly."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
missing = tmp_path / "does-not-exist.txt"
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
)
assert result.returncode != 0
assert "not found" in (result.stdout + result.stderr).lower()
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
# Write-Warning output placement (stdout vs. stderr) is not deterministic
# across pwsh versions/platforms, so check the combined stream like the
# other pwsh tests above (e.g. test_not_a_repo_still_detected_with_autocrlf).
combined = result.stdout + result.stderr
assert "conventional" in combined.lower()
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_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
combined = result.stdout + result.stderr
assert "No changes" in combined
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
"""commit_style value with a trailing YAML inline comment is still recognized."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional # team standard\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_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
instead of silently mis-parsing or crashing."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventonal\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
combined = (result.stdout or "") + (result.stderr or "")
assert "unknown commit_style" in combined.lower()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
# ── auto-commit.ps1 CRLF warning tests (issue #2253) ────────────────────────