mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* feat(extensions): port git extension scripts to Python Ports git-common, initialize-repo, auto-commit, and create-new-feature-branch to extensions/git/scripts/python/, mirroring the bash/PowerShell twins. Parity tests run each bash script and its Python twin in identical projects and compare output, exit codes, and resulting git state. Fixes #3282 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: match bash error message for whitespace-only descriptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle unreadable git-config.yml and assert stderr parity An unreadable config file raised OSError with a full traceback from _parse_auto_commit_config. Treat it like a missing config: auto-commit stays disabled. Covered by a chmod-000 test (skipped on non-POSIX and as root). _assert_parity now also compares stderr so warning or usage-text regressions between the bash and Python twins fail the suite. All existing parity tests pass with the stricter assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions/git): pass script path to core.get_repo_root for cwd-outside-repo callers Without script_file, core.get_repo_root() falls back to Path.cwd() when SPECIFY_INIT_DIR is unset and no .specify root is found upward — the bash twin instead falls back to the script's install location (.specify/scripts/...). Pass script_file so both twins resolve the same repo_root; TypeError fallback keeps older cores working. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: exercise SPECIFY_INIT_DIR from outside the project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions/git): handle UnicodeDecodeError and USER/USERNAME fallback - Catch (OSError, UnicodeDecodeError) when reading git-config.yml in create_new_feature_branch.py, initialize_repo.py, and auto_commit.py so invalid UTF-8 config falls back to defaults instead of crashing with a traceback. - Fall back to USERNAME (then "unknown") when USER is unset when deriving the branch author token, matching the PowerShell twin's Windows-friendly fallback chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions/git): platform-aware persist hint and stronger SPECIFY_INIT_DIR test - Add a shared _persist_hint() helper in create_new_feature_branch.py and use it for both the JSON-mode stderr hint and the human-readable stdout hint, so there is a single place emitting the SPECIFY_FEATURE persistence guidance. On Windows (os.name == "nt") it prints PowerShell $env:VAR = "..." syntax; elsewhere it keeps the existing POSIX export VAR=... syntax (parity with the bash twin). - Rework test_specify_init_dir_resolves_target_project so SPECIFY_INIT_DIR is the only thing that can produce the observed result: the script now runs from a separate host_proj (no existing specs, so script/cwd-based discovery would yield 001) while SPECIFY_INIT_DIR points at a different target_proj that already has an existing spec (007-existing, so the override must yield 008). The old version pointed SPECIFY_INIT_DIR at the same project the script was installed in, so it passed even if the env var were ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): tolerate missing Git executable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): quote PowerShell persist hint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(git): match bash persist hint escaping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(git): ignore unterminated config record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(git): handle Windows persist hint parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(init): install Python shared scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(git): normalize Windows persistence hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
188 lines
6.0 KiB
Python
188 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Git extension: auto_commit.py
|
|
|
|
Automatically commit changes after a Spec Kit command completes.
|
|
Python port of ``auto-commit.sh`` / ``auto-commit.ps1``.
|
|
Checks per-command config keys in git-config.yml before committing.
|
|
|
|
Usage: auto_commit.py <event_name>
|
|
e.g.: auto_commit.py after_specify
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _find_project_root(start: Path) -> Path | None:
|
|
current = start
|
|
while True:
|
|
if (current / ".specify").is_dir() or (current / ".git").exists():
|
|
return current
|
|
if current.parent == current:
|
|
return None
|
|
current = current.parent
|
|
|
|
|
|
def _value_after_colon(line: str) -> str:
|
|
return re.sub(r"^[^:]*:\s*", "", line)
|
|
|
|
|
|
def _strip_quotes(value: str) -> str:
|
|
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
|
|
value = re.sub(r"^[\"']", "", value)
|
|
return re.sub(r"[\"']*$", "", value)
|
|
|
|
|
|
def _parse_auto_commit_config(
|
|
config_file: Path, event_name: str
|
|
) -> tuple[bool, str]:
|
|
"""Parse the auto_commit section for this event, mirroring the bash line parser.
|
|
|
|
Returns (enabled, commit_msg). Looks for auto_commit.<event_name>.enabled
|
|
and .message, with auto_commit.default as fallback.
|
|
"""
|
|
enabled = False
|
|
commit_msg = ""
|
|
default_enabled = False
|
|
in_auto_commit = False
|
|
in_event = False
|
|
|
|
try:
|
|
content = config_file.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
# Unreadable or non-UTF-8 config is treated like a missing one:
|
|
# auto-commit stays disabled instead of crashing with a traceback.
|
|
return False, ""
|
|
for record in content.splitlines(keepends=True):
|
|
if not record.endswith("\n"):
|
|
break
|
|
line = record[:-1]
|
|
if line.startswith("auto_commit:"):
|
|
in_auto_commit = True
|
|
in_event = False
|
|
continue
|
|
|
|
# Exit auto_commit section on next top-level key
|
|
if in_auto_commit and re.match(r"^[a-z]", line):
|
|
break
|
|
|
|
if not in_auto_commit:
|
|
continue
|
|
|
|
if re.match(r"^\s+default:\s", line):
|
|
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
|
|
if value == "true":
|
|
default_enabled = True
|
|
|
|
if re.match(rf"^\s+{re.escape(event_name)}:", line):
|
|
in_event = True
|
|
continue
|
|
|
|
if in_event:
|
|
# Exit on next sibling key (same indent level as event name)
|
|
if re.match(r"^\s{2}[a-z]", line) and not re.match(r"^\s{4}", line):
|
|
in_event = False
|
|
continue
|
|
if re.search(r"\s+enabled:", line):
|
|
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
|
|
if value == "true":
|
|
enabled = True
|
|
elif value == "false":
|
|
enabled = False
|
|
if re.search(r"\s+message:", line):
|
|
commit_msg = _strip_quotes(_value_after_colon(line))
|
|
|
|
# If event-specific key not found, use default — but only if the event
|
|
# section didn't exist at all (an explicit false must win).
|
|
if not enabled and default_enabled:
|
|
if not re.search(rf"^\s*{re.escape(event_name)}:", content, re.MULTILINE):
|
|
enabled = True
|
|
|
|
return enabled, commit_msg
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
event_name = argv[0] if argv else ""
|
|
if not event_name:
|
|
print(f"Usage: {Path(sys.argv[0]).name} <event_name>", file=sys.stderr)
|
|
return 1
|
|
|
|
script_dir = Path(__file__).resolve().parent
|
|
repo_root = _find_project_root(script_dir) or Path.cwd()
|
|
|
|
if shutil.which("git") is None:
|
|
print("[specify] Warning: Git not found; skipped auto-commit", file=sys.stderr)
|
|
return 0
|
|
|
|
probe = subprocess.run(
|
|
["git", "rev-parse", "--is-inside-work-tree"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if probe.returncode != 0:
|
|
print(
|
|
"[specify] Warning: Not a Git repository; skipped auto-commit",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
|
|
if not config_file.is_file():
|
|
# No config file — auto-commit disabled by default
|
|
return 0
|
|
|
|
enabled, commit_msg = _parse_auto_commit_config(config_file, event_name)
|
|
if not enabled:
|
|
return 0
|
|
|
|
# Check if there are changes to commit
|
|
def _quiet(*args: str) -> bool:
|
|
return (
|
|
subprocess.run(
|
|
["git", *args], cwd=repo_root, capture_output=True, text=True
|
|
).returncode
|
|
== 0
|
|
)
|
|
|
|
untracked = subprocess.run(
|
|
["git", "ls-files", "--others", "--exclude-standard"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
if _quiet("diff", "--quiet", "HEAD") and _quiet("diff", "--cached", "--quiet") and not untracked:
|
|
print(f"[specify] No changes to commit after {event_name}", file=sys.stderr)
|
|
return 0
|
|
|
|
# Derive a human-readable command name from the event
|
|
# e.g., after_specify -> specify, before_plan -> plan
|
|
command_name = re.sub(r"^(after_|before_)", "", event_name)
|
|
phase = "before" if event_name.startswith("before_") else "after"
|
|
|
|
if not commit_msg:
|
|
commit_msg = f"[Spec Kit] Auto-commit {phase} {command_name}"
|
|
|
|
steps = [
|
|
(["git", "add", "."], "git add"),
|
|
(["git", "commit", "-q", "-m", commit_msg], "git commit"),
|
|
]
|
|
for cmd, label in steps:
|
|
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
output = (result.stdout + result.stderr).strip()
|
|
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"[OK] Changes committed {phase} {command_name}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|