mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* 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>
212 lines
8.2 KiB
Bash
Executable File
212 lines
8.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Git extension: auto-commit.sh
|
|
# Automatically commit changes after a Spec Kit command completes.
|
|
# Checks per-command config keys in git-config.yml before committing.
|
|
#
|
|
# Usage: auto-commit.sh <event_name> [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> [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)"
|
|
|
|
_find_project_root() {
|
|
local dir="$1"
|
|
while [ "$dir" != "/" ]; do
|
|
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
echo "$dir"
|
|
return 0
|
|
fi
|
|
dir="$(dirname "$dir")"
|
|
done
|
|
return 1
|
|
}
|
|
|
|
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
# Check if git is available
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
|
exit 0
|
|
fi
|
|
|
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Read per-command config from git-config.yml
|
|
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
|
_enabled=false
|
|
_commit_msg=""
|
|
_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.
|
|
_in_auto_commit=false
|
|
_in_event=false
|
|
_default_enabled=false
|
|
|
|
while IFS= read -r _line; do
|
|
# Detect auto_commit: section
|
|
if echo "$_line" | grep -q '^auto_commit:'; then
|
|
_in_auto_commit=true
|
|
_in_event=false
|
|
continue
|
|
fi
|
|
|
|
# Exit auto_commit section on next top-level key
|
|
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
|
break
|
|
fi
|
|
|
|
if $_in_auto_commit; then
|
|
# Check default key
|
|
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
|
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
[ "$_val" = "true" ] && _default_enabled=true
|
|
fi
|
|
|
|
# Detect our event subsection
|
|
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
|
_in_event=true
|
|
continue
|
|
fi
|
|
|
|
# Inside our event subsection
|
|
if $_in_event; then
|
|
# Exit on next sibling key (same indent level as event name)
|
|
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
|
_in_event=false
|
|
continue
|
|
fi
|
|
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
|
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
[ "$_val" = "true" ] && _enabled=true
|
|
[ "$_val" = "false" ] && _enabled=false
|
|
fi
|
|
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
|
# Trim trailing whitespace before stripping the closing quote:
|
|
# a value like `message: "Done" ` (trailing spaces after the
|
|
# quote) would otherwise leave the quote dangling (`Done" `),
|
|
# since the closing-quote strip is anchored to end-of-string.
|
|
# The PowerShell twin .Trim()s first; match it for parity.
|
|
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
|
fi
|
|
fi
|
|
fi
|
|
done < "$_config_file"
|
|
|
|
# If event-specific key not found, use default
|
|
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
|
# Only use default if the event wasn't explicitly set to false
|
|
# Check if event section existed at all
|
|
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
|
_enabled=true
|
|
fi
|
|
fi
|
|
else
|
|
# No config file — auto-commit disabled by default
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$_enabled" != "true" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Check if there are changes to commit
|
|
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
|
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# 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_//')
|
|
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
|
|
|
# Use custom message if configured, otherwise default
|
|
if [ -z "$_commit_msg" ]; then
|
|
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
|
fi
|
|
|
|
# Stage and commit
|
|
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
|
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
|
|
|
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|