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>
82 lines
2.6 KiB
Python
82 lines
2.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Git-specific common helpers for the git extension.
|
|
|
|
Python port of ``git-common.sh`` / ``git-common.ps1`` — contains only
|
|
git-specific branch validation and detection logic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def has_git(repo_root: Path | None = None) -> bool:
|
|
"""Check if we have git available at the repo root."""
|
|
root = Path(repo_root) if repo_root is not None else Path.cwd()
|
|
git_marker = root / ".git"
|
|
if not (git_marker.is_dir() or git_marker.is_file()):
|
|
return False
|
|
if shutil.which("git") is None:
|
|
return False
|
|
result = subprocess.run(
|
|
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def effective_branch_name(raw: str) -> str:
|
|
"""Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
|
|
|
Only when the full name is exactly two slash-free segments; otherwise
|
|
returns the raw name.
|
|
"""
|
|
match = re.fullmatch(r"([^/]+)/([^/]+)", raw)
|
|
if match:
|
|
return match.group(2)
|
|
return raw
|
|
|
|
|
|
def check_feature_branch(raw: str, has_git_repo: bool) -> bool:
|
|
"""Validate that a branch name matches the expected feature branch pattern.
|
|
|
|
Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*)
|
|
formats, either at the start of the branch or after path-style namespace
|
|
prefixes. Logic aligned with the bash/PowerShell twins.
|
|
"""
|
|
if not has_git_repo:
|
|
print(
|
|
"[specify] Warning: Git repository not detected; skipped branch validation",
|
|
file=sys.stderr,
|
|
)
|
|
return True
|
|
|
|
branch = effective_branch_name(raw)
|
|
feature_segment = branch.rsplit("/", 1)[-1]
|
|
|
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps:
|
|
# 7-or-8 digit date + 6-digit time with no trailing slug.
|
|
is_sequential = bool(
|
|
re.match(r"^[0-9]{3,}-", feature_segment)
|
|
and not re.match(r"^[0-9]{7}-[0-9]{6}-", feature_segment)
|
|
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", feature_segment)
|
|
)
|
|
is_timestamp = bool(re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment))
|
|
|
|
if not is_sequential and not is_timestamp:
|
|
print(f"ERROR: Not on a feature branch. Current branch: {raw}", file=sys.stderr)
|
|
print(
|
|
"Feature branches should be named like: 001-feature-name, "
|
|
"1234-feature-name, 20260319-143022-feature-name, or "
|
|
"<prefix>/001-feature-name",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
|
|
return True
|