Files
github-spec-kit/extensions/git/scripts/python/auto_commit.py
Marsel Safin 91839fba50 feat(extensions): port git extension scripts to Python (#3400)
* 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>
2026-07-14 09:56:10 -05:00

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:]))