From d0a83890d5507cd5da7bd74e3251aef459152d40 Mon Sep 17 00:00:00 2001 From: Dhruv Rastogi Date: Thu, 23 Jul 2026 21:15:18 +0530 Subject: [PATCH] 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 (bash) / -MessageFile (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> --- extensions/git/README.md | 7 +- extensions/git/commands/speckit.git.commit.md | 23 +- extensions/git/config-template.yml | 7 + extensions/git/git-config.yml | 7 + extensions/git/scripts/bash/auto-commit.sh | 70 ++- .../git/scripts/powershell/auto-commit.ps1 | 65 +- tests/extensions/git/test_git_extension.py | 554 ++++++++++++++++++ 7 files changed, 724 insertions(+), 9 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index b5df3e31e..c0cb7b5d0 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -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: diff --git a/extensions/git/commands/speckit.git.commit.md b/extensions/git/commands/speckit.git.commit.md index e606f911d..3270eb27c 100644 --- a/extensions/git/commands/speckit.git.commit.md +++ b/extensions/git/commands/speckit.git.commit.md @@ -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 ` 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 ` -- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh [--message-file ]` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 [-MessageFile ]` -Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). +Replace `` 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 ` (Bash) or `-MessageFile ` (PowerShell). +- If `fixed` or absent: run the script with just ``; 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 diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index 99e3d3169..2ea347174 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -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. diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index 99e3d3169..2ea347174 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -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. diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh index 4955352f0..17fec66f0 100755 --- a/extensions/git/scripts/bash/auto-commit.sh +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -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 +# Usage: auto-commit.sh [generated_message] +# auto-commit.sh --message-file # 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 " >&2 + echo "Usage: $0 [generated_message | --message-file ]" >&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..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 , 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_//') diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index 34767f8a3..d27b484ba 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -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 +# Usage: auto-commit.ps1 [generated_message] +# auto-commit.ps1 -MessageFile # 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 , 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' } diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 1354af394..f6be51caf 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -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) ────────────────────────