From c2525775ca5719e2ca5683261a596cfc1993313e Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:27:58 +0200 Subject: [PATCH 1/3] 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> --- extensions/git/scripts/python/auto_commit.py | 179 +++++ .../python/create_new_feature_branch.py | 614 ++++++++++++++++++ extensions/git/scripts/python/git_common.py | 81 +++ .../git/scripts/python/initialize_repo.py | 89 +++ .../git/test_git_extension_python_parity.py | 498 ++++++++++++++ 5 files changed, 1461 insertions(+) create mode 100644 extensions/git/scripts/python/auto_commit.py create mode 100644 extensions/git/scripts/python/create_new_feature_branch.py create mode 100644 extensions/git/scripts/python/git_common.py create mode 100644 extensions/git/scripts/python/initialize_repo.py create mode 100644 tests/extensions/git/test_git_extension_python_parity.py diff --git a/extensions/git/scripts/python/auto_commit.py b/extensions/git/scripts/python/auto_commit.py new file mode 100644 index 0000000000..bba1552ae4 --- /dev/null +++ b/extensions/git/scripts/python/auto_commit.py @@ -0,0 +1,179 @@ +#!/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 + 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..enabled + and .message, with auto_commit.default as fallback. + """ + enabled = False + commit_msg = "" + default_enabled = False + in_auto_commit = False + in_event = False + + content = config_file.read_text(encoding="utf-8") + for line in content.splitlines(): + 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} ", 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:])) diff --git a/extensions/git/scripts/python/create_new_feature_branch.py b/extensions/git/scripts/python/create_new_feature_branch.py new file mode 100644 index 0000000000..d9989586f4 --- /dev/null +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +"""Git extension: create_new_feature_branch.py + +Creates a git feature branch only. The feature directory and spec file are +created by the core create-new-feature script. Python port of +``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``. + +Loads the core Python helpers from the project's installed scripts when +available, falling back to the minimal git helpers next to this script. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names + +USAGE = ( + "Usage: create_new_feature_branch.py [--json] [--dry-run] " + "[--allow-existing-branch] [--short-name ] [--number N] " + "[--timestamp] " +) + +HELP_TEXT = f"""{USAGE} + +Options: + --json Output in JSON format + --dry-run Compute branch name without creating the branch + --allow-existing-branch Switch to branch if it already exists instead of failing + --short-name Provide a custom short name (2-4 words) for the branch + --number N Specify branch number manually (overrides auto-detection) + --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering + --help, -h Show this help message + +Environment variables: + GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation + +Configuration: + branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}} + branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}} + +Examples: + create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth' + create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5 + create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication' + GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description' +""" + +STOP_WORDS = frozenset( + "i a an the to for of in on at by with from is are was were be been being " + "have has had do does did will would should could can may might must shall " + "this that these those my your our their want need add get set".split() +) + + +def _err(message: str) -> None: + print(message, file=sys.stderr) + + +@dataclass +class Args: + json_mode: bool = False + dry_run: bool = False + allow_existing: bool = False + short_name: str = "" + branch_number: str = "" + use_timestamp: bool = False + description_parts: list[str] = field(default_factory=list) + + +def parse_args(argv: list[str]) -> Args: + args = Args() + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--json": + args.json_mode = True + elif arg == "--dry-run": + args.dry_run = True + elif arg == "--allow-existing-branch": + args.allow_existing = True + elif arg == "--short-name": + if i + 1 >= len(argv) or argv[i + 1].startswith("--"): + _err("Error: --short-name requires a value") + raise SystemExit(1) + i += 1 + args.short_name = argv[i] + elif arg == "--number": + if i + 1 >= len(argv) or argv[i + 1].startswith("--"): + _err("Error: --number requires a value") + raise SystemExit(1) + i += 1 + args.branch_number = argv[i] + if not re.fullmatch(r"[0-9]+", args.branch_number): + _err("Error: --number must be a non-negative integer") + raise SystemExit(1) + elif arg == "--timestamp": + args.use_timestamp = True + elif arg in ("--help", "-h"): + print(HELP_TEXT) + raise SystemExit(0) + else: + args.description_parts.append(arg) + i += 1 + return args + + +# ── Core helpers loading ───────────────────────────────────────────────────── + + +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 _load_core_common(project_root: Path | None): + """Load the core common.py from the project's installed scripts. + + Search locations in priority order, mirroring the bash script: + 1. .specify/scripts/python/common.py (installed project) + 2. scripts/python/common.py (source checkout fallback) + Returns the loaded module or None. + """ + if project_root is None: + return None + for relative in (".specify/scripts/python/common.py", "scripts/python/common.py"): + candidate = project_root / relative + if candidate.is_file(): + spec = importlib.util.spec_from_file_location("speckit_core_common", candidate) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + return None + + +def _local_has_git(repo_root: Path) -> bool: + git_marker = repo_root / ".git" + if not (git_marker.is_dir() or git_marker.is_file()): + return False + if shutil.which("git") is None: + return False + return ( + subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + ).returncode + == 0 + ) + + +# ── Numbering ──────────────────────────────────────────────────────────────── + + +def get_highest_from_specs(specs_dir: Path) -> int: + highest = 0 + if specs_dir.is_dir(): + for entry in specs_dir.iterdir(): + if not entry.is_dir(): + continue + name = entry.name + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if re.match(r"^[0-9]{3,}-", name) and not re.match( + r"^[0-9]{8}-[0-9]{6}-", name + ): + number = int(re.match(r"^[0-9]+", name).group(0)) + highest = max(highest, number) + return highest + + +def _extract_highest_number(names: list[str], scope_prefix: str) -> int: + """Extract the highest sequential feature number from a list of ref names.""" + highest = 0 + for name in names: + if not name: + continue + if scope_prefix: + if not name.startswith(scope_prefix): + continue + name = name[len(scope_prefix) :] + name = name.rsplit("/", 1)[-1] + if ( + re.match(r"^[0-9]{3,}-", name) + and not re.match(r"^[0-9]{8}-[0-9]{6}-", name) + and not re.match(r"^[0-9]{7}-[0-9]{6}-", name) + and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name) + ): + match = re.match(r"^([0-9]{3,})-", name) + number = int(match.group(1)) if match else 0 + highest = max(highest, number) + return highest + + +def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]: + env = {**os.environ, **(env_extra or {})} + result = subprocess.run( + ["git", *args], cwd=repo_root, capture_output=True, text=True, env=env + ) + if result.returncode != 0: + return [] + return result.stdout.splitlines() + + +def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int: + names = [] + for line in _git_lines(repo_root, "branch", "-a"): + line = re.sub(r"^[+*]\s+", "", line) + line = line.lstrip() + line = re.sub(r"^remotes/[^/]*/", "", line) + names.append(line) + return _extract_highest_number(names, scope_prefix) + + +def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int: + """Highest number from remote branches without fetching (side-effect-free).""" + highest = 0 + for remote in _git_lines(repo_root, "remote"): + refs = _git_lines( + repo_root, + "ls-remote", + "--heads", + remote, + env_extra={"GIT_TERMINAL_PROMPT": "0"}, + ) + names = [re.sub(r".*refs/heads/", "", ref) for ref in refs] + highest = max(highest, _extract_highest_number(names, scope_prefix)) + return highest + + +def check_existing_branches( + repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str +) -> int: + """Check existing branches and return the next available number.""" + if skip_fetch: + highest_branch = max( + get_highest_from_remote_refs(repo_root, scope_prefix), + get_highest_from_branches(repo_root, scope_prefix), + ) + else: + subprocess.run( + ["git", "fetch", "--all", "--prune"], + cwd=repo_root, + capture_output=True, + text=True, + ) + highest_branch = get_highest_from_branches(repo_root, scope_prefix) + + return max(highest_branch, get_highest_from_specs(specs_dir)) + 1 + + +# ── Branch naming ──────────────────────────────────────────────────────────── + + +def clean_branch_name(name: str) -> str: + name = re.sub(r"[^a-z0-9]", "-", name.lower()) + name = re.sub(r"-+", "-", name) + return name.strip("-") + + +def generate_branch_name(description: str) -> str: + """Generate a branch suffix from the description with stop word filtering.""" + clean_name = re.sub(r"[^a-z0-9]", " ", description.lower()) + + meaningful_words = [] + for word in clean_name.split(): + if word in STOP_WORDS: + continue + if len(word) >= 3: + meaningful_words.append(word) + # Keep short words only when they appear uppercased in the original + # description (acronyms like "API" or "DB"). + elif re.search(rf"\b{re.escape(word.upper())}\b", description): + meaningful_words.append(word) + + if meaningful_words: + max_words = 4 if len(meaningful_words) == 4 else 3 + return "-".join(meaningful_words[:max_words]) + + cleaned = clean_branch_name(description) + return "-".join([part for part in cleaned.split("-") if part][:3]) + + +def branch_token(value: str, fallback: str) -> str: + cleaned = clean_branch_name(value) + return cleaned if cleaned else fallback + + +def get_author_token(repo_root: Path) -> str: + author = "" + if shutil.which("git") is not None: + lines = _git_lines(repo_root, "config", "user.name") + author = lines[0] if lines else "" + if not author: + lines = _git_lines(repo_root, "config", "user.email") + email = lines[0] if lines else "" + author = email.split("@")[0] + if not author: + author = os.environ.get("USER", "unknown") or "unknown" + return branch_token(author, "unknown") + + +def get_app_token(repo_root: Path) -> str: + return branch_token(repo_root.name, "app") + + +def read_git_config_value(config_file: Path, key: str) -> str: + if not config_file.is_file(): + return "" + try: + lines = config_file.read_text(encoding="utf-8").splitlines() + except OSError: + return "" + for line in lines: + if re.match(rf"^\s*{re.escape(key)}:", line): + value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line) + value = re.sub(r"\s+#.*$", "", value) + value = value.strip() + value = re.sub(r'^"|"$', "", value) + value = re.sub(r"^'|'$", "", value) + return value + return "" + + +def resolve_branch_template(config_file: Path) -> str: + template = read_git_config_value(config_file, "branch_template") + if template: + return template + + prefix = read_git_config_value(config_file, "branch_prefix") + if not prefix: + return "" + if prefix.endswith("/"): + return f"{prefix}{{number}}-{{slug}}" + return f"{prefix}/{{number}}-{{slug}}" + + +def validate_branch_template(template: str) -> None: + if not template: + return + if "{number}" not in template: + _err( + "Error: branch_template must include the {number} token so generated " + "branches remain valid feature branches." + ) + raise SystemExit(1) + slug_index = template.find("{slug}") + if slug_index != -1 and "{number}" in template[slug_index:]: + _err( + "Error: branch_template must not place {slug} before {number}; " + "use {slug} only in the final feature segment." + ) + raise SystemExit(1) + feature_segment = template.rsplit("/", 1)[-1] + if not feature_segment.startswith("{number}-"): + _err( + "Error: branch_template must put {number}- at the start of the final " + "path segment so generated branches remain valid feature branches." + ) + raise SystemExit(1) + + +def render_branch_template( + template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str +) -> str: + rendered = template + rendered = rendered.replace("{author}", author_token) + rendered = rendered.replace("{app}", app_token) + rendered = rendered.replace("{number}", feature_num) + rendered = rendered.replace("{slug}", branch_suffix) + return rendered + + +def extract_feature_num_from_branch(branch_name: str) -> str: + feature_segment = branch_name.rsplit("/", 1)[-1] + match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment) + if match: + return match.group(0).rstrip("-") + match = re.match(r"^[0-9]+-", feature_segment) + if match: + return match.group(0).rstrip("-") + return branch_name + + +def _byte_length(value: str) -> int: + return len(value.encode("utf-8")) + + +# ── Main ───────────────────────────────────────────────────────────────────── + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + + feature_description = " ".join(args.description_parts).strip() + if not feature_description: + _err(USAGE) + return 1 + + project_root = _find_project_root(SCRIPT_DIR) + core = _load_core_common(project_root) + + # SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the + # core helpers were not found, refuse rather than silently falling back to + # the wrong root. + if os.environ.get("SPECIFY_INIT_DIR") and ( + core is None or not hasattr(core, "resolve_specify_init_dir") + ): + _err( + "Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts " + "(common.py with resolve_specify_init_dir), which were not found." + ) + return 1 + + if core is not None and hasattr(core, "get_repo_root"): + repo_root = core.get_repo_root() + else: + toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel") + if toplevel: + repo_root = Path(toplevel[0]) + elif project_root is not None: + repo_root = project_root + else: + _err("Error: Could not determine repository root.") + return 1 + repo_root = Path(repo_root) + + has_git_repo = _local_has_git(repo_root) + + specs_dir = repo_root / "specs" + config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml" + + author_token = get_author_token(repo_root) + app_token = get_app_token(repo_root) + branch_template = resolve_branch_template(config_file) + validate_branch_template(branch_template) + + def build_branch_name(feature_num: str, branch_suffix: str) -> str: + if branch_template: + return render_branch_template( + branch_template, feature_num, branch_suffix, author_token, app_token + ) + return f"{feature_num}-{branch_suffix}" + + branch_number = args.branch_number + + # Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix) + env_branch_name = os.environ.get("GIT_BRANCH_NAME", "") + if env_branch_name: + branch_name = env_branch_name + feature_num = extract_feature_num_from_branch(branch_name) + branch_suffix = branch_name + else: + if args.short_name: + branch_suffix = clean_branch_name(args.short_name) + else: + branch_suffix = generate_branch_name(feature_description) + + if args.use_timestamp and branch_number: + _err("[specify] Warning: --number is ignored when --timestamp is used") + branch_number = "" + + if args.use_timestamp: + feature_num = datetime.now().strftime("%Y%m%d-%H%M%S") + branch_name = build_branch_name(feature_num, branch_suffix) + else: + scope_prefix = "" + if branch_template: + prefix_template = branch_template.split("{number}")[0] + scope_prefix = render_branch_template( + prefix_template, "", branch_suffix, author_token, app_token + ) + if not branch_number: + if args.dry_run and has_git_repo: + branch_number = check_existing_branches( + repo_root, specs_dir, True, scope_prefix + ) + elif args.dry_run: + branch_number = get_highest_from_specs(specs_dir) + 1 + elif has_git_repo: + branch_number = check_existing_branches( + repo_root, specs_dir, False, scope_prefix + ) + else: + branch_number = get_highest_from_specs(specs_dir) + 1 + + feature_num = f"{int(branch_number):03d}" + branch_name = build_branch_name(feature_num, branch_suffix) + + branch_byte_len = _byte_length(branch_name) + if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH: + _err( + "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. " + f"Provided value is {branch_byte_len} bytes." + ) + return 1 + if branch_byte_len > MAX_BRANCH_LENGTH: + original_branch_name = branch_name + truncated_suffix = branch_suffix + while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix: + truncated_suffix = truncated_suffix[:-1] + truncated_suffix = truncated_suffix.rstrip("-") + branch_name = build_branch_name(feature_num, truncated_suffix) + if _byte_length(branch_name) > MAX_BRANCH_LENGTH: + _err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.") + return 1 + + _err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit") + _err( + f"[specify] Original: {original_branch_name} " + f"({_byte_length(original_branch_name)} bytes)" + ) + _err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)") + + if not args.dry_run: + if has_git_repo: + create = subprocess.run( + ["git", "checkout", "-q", "-b", branch_name], + cwd=repo_root, + capture_output=True, + text=True, + ) + if create.returncode != 0: + current_branch_lines = _git_lines( + repo_root, "rev-parse", "--abbrev-ref", "HEAD" + ) + current_branch = current_branch_lines[0] if current_branch_lines else "" + branch_exists = bool( + _git_lines(repo_root, "branch", "--list", branch_name) + ) + if branch_exists: + if args.allow_existing: + if current_branch != branch_name: + switch = subprocess.run( + ["git", "checkout", "-q", branch_name], + cwd=repo_root, + capture_output=True, + text=True, + ) + if switch.returncode != 0: + _err( + f"Error: Failed to switch to existing branch '{branch_name}'. " + "Please resolve any local changes or conflicts and try again." + ) + if switch.stderr.strip(): + _err(switch.stderr.strip()) + return 1 + elif args.use_timestamp: + _err( + f"Error: Branch '{branch_name}' already exists. Rerun to get " + "a new timestamp or use a different --short-name." + ) + return 1 + else: + _err( + f"Error: Branch '{branch_name}' already exists. Please use a " + "different feature name or specify a different number with --number." + ) + return 1 + else: + _err(f"Error: Failed to create git branch '{branch_name}'.") + if create.stderr.strip(): + _err(create.stderr.strip()) + else: + _err("Please check your git configuration and try again.") + return 1 + else: + _err( + "[specify] Warning: Git repository not detected; skipped branch " + f"creation for {branch_name}" + ) + + _err(f"# To persist: export SPECIFY_FEATURE={shlex.quote(branch_name)}") + + if args.json_mode: + payload: dict[str, object] = { + "BRANCH_NAME": branch_name, + "FEATURE_NUM": feature_num, + } + if args.dry_run: + payload["DRY_RUN"] = True + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + else: + print(f"BRANCH_NAME: {branch_name}") + print(f"FEATURE_NUM: {feature_num}") + if not args.dry_run: + print( + "# To persist in your shell: export " + f"SPECIFY_FEATURE={shlex.quote(branch_name)}" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/extensions/git/scripts/python/git_common.py b/extensions/git/scripts/python/git_common.py new file mode 100644 index 0000000000..9b23fbaa06 --- /dev/null +++ b/extensions/git/scripts/python/git_common.py @@ -0,0 +1,81 @@ +#!/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 " + "/001-feature-name", + file=sys.stderr, + ) + return False + + return True diff --git a/extensions/git/scripts/python/initialize_repo.py b/extensions/git/scripts/python/initialize_repo.py new file mode 100644 index 0000000000..1003021d17 --- /dev/null +++ b/extensions/git/scripts/python/initialize_repo.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Git extension: initialize_repo.py + +Initialize a Git repository with an initial commit. +Python port of ``initialize-repo.sh`` / ``initialize-repo.ps1``. +Customizable — replace this script to add .gitignore templates, +default branch config, git-flow, LFS, signing, etc. +""" + +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 _read_commit_message(repo_root: Path) -> str: + """Read init_commit_message from git-config.yml, mirroring the bash sed pipeline.""" + default = "[Spec Kit] Initial commit" + config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml" + if not config_file.is_file(): + return default + try: + lines = config_file.read_text(encoding="utf-8").splitlines() + except OSError: + return default + for line in lines: + if line.startswith("init_commit_message:"): + value = re.sub(r"^init_commit_message:\s*", "", line) + value = re.sub(r"^[\"']", "", value) + value = re.sub(r"[\"']*$", "", value) + if value: + return value + return default + + +def main() -> int: + script_dir = Path(__file__).resolve().parent + repo_root = _find_project_root(script_dir) or Path.cwd() + + commit_msg = _read_commit_message(repo_root) + + if shutil.which("git") is None: + print( + "[specify] Warning: Git not found; skipped repository initialization", + 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] Git repository already initialized; skipping", file=sys.stderr) + return 0 + + steps = [ + (["git", "init", "-q"], "git init"), + (["git", "add", "."], "git add"), + (["git", "commit", "--allow-empty", "-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("[OK] Git repository initialized", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py new file mode 100644 index 0000000000..270dc152b2 --- /dev/null +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -0,0 +1,498 @@ +""" +Parity tests for the Python port of the git extension scripts (extensions/git/scripts/python/). + +Each test runs the bash script and its Python twin in identical twin projects +and asserts matching output, exit codes, and resulting git state. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash +from tests.extensions.git.test_git_extension import ( + _GIT_ENV, + _init_git, + _run_bash, + _setup_project, + _write_config, +) + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +EXT_PY = PROJECT_ROOT / "extensions" / "git" / "scripts" / "python" +CORE_COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py" + +PY_SCRIPTS = { + "create-new-feature-branch": "create_new_feature_branch.py", + "initialize-repo": "initialize_repo.py", + "auto-commit": "auto_commit.py", +} + + +def _setup_py_project(tmp_path: Path, *, git: bool = True) -> Path: + """Twin of _setup_project that also installs the Python scripts.""" + project = _setup_project(tmp_path, git=git) + + py_core = project / "scripts" / "python" + py_core.mkdir(parents=True, exist_ok=True) + shutil.copy(CORE_COMMON_PY, py_core / "common.py") + + ext_py = project / ".specify" / "extensions" / "git" / "scripts" / "python" + ext_py.mkdir(parents=True, exist_ok=True) + for f in EXT_PY.iterdir(): + if f.suffix == ".py": + shutil.copy(f, ext_py / f.name) + return project + + +def _run_py(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: + """Run an extension Python script.""" + script = ( + cwd / ".specify" / "extensions" / "git" / "scripts" / "python" / PY_SCRIPTS[script_name] + ) + env = {**os.environ, **_GIT_ENV, **(env_extra or {})} + return subprocess.run( + [sys.executable, str(script), *args], + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +def _twin_projects(tmp_path: Path, *, git: bool = True) -> tuple[Path, Path]: + """Two identically named projects so {app} tokens match.""" + bash_proj = _setup_py_project(tmp_path / "bash" / "proj", git=git) + py_proj = _setup_py_project(tmp_path / "py" / "proj", git=git) + return bash_proj, py_proj + + +def _assert_parity( + bash_result: subprocess.CompletedProcess, + py_result: subprocess.CompletedProcess, + *, + stdout: bool = True, +) -> None: + assert py_result.returncode == bash_result.returncode, ( + f"exit codes diverge: bash={bash_result.returncode} py={py_result.returncode}\n" + f"bash stderr: {bash_result.stderr}\npy stderr: {py_result.stderr}" + ) + if stdout: + assert py_result.stdout == bash_result.stdout + + +@requires_bash +class TestCreateFeatureBranchParity: + def test_sequential_branch_json(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "Add user authentication") + p = _run_py("create-new-feature-branch", py_proj, "--json", "Add user authentication") + _assert_parity(b, p) + data = json.loads(p.stdout) + assert data == {"BRANCH_NAME": "001-user-authentication", "FEATURE_NUM": "001"} + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=py_proj, + capture_output=True, + text=True, + ).stdout.strip() + assert branch == "001-user-authentication" + + def test_slug_generation_stop_words_and_acronyms(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + description = "I want to add DB caching for the API layer" + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", description) + p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", description) + _assert_parity(b, p) + + def test_short_name_cleaning(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + # Single separator runs only: the bash twin's collapse step + # (sed 's/-\+/-/g') is a GNU-ism that BSD sed treats literally. + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, + "--json", "--dry-run", "--short-name", "User_Auth!", "desc", + ) + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--dry-run", "--short-name", "User_Auth!", "desc", + ) + _assert_parity(b, p) + assert json.loads(p.stdout)["BRANCH_NAME"] == "001-user-auth" + + def test_numbering_from_specs_and_branches(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + (proj / "specs" / "007-existing").mkdir(parents=True) + (proj / "specs" / "20260101-120000-timestamped").mkdir(parents=True) + subprocess.run(["git", "branch", "012-in-branch"], cwd=proj, check=True) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next feature") + p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next feature") + _assert_parity(b, p) + assert json.loads(p.stdout)["FEATURE_NUM"] == "013" + + def test_explicit_number(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--number", "42", "some feature") + p = _run_py("create-new-feature-branch", py_proj, "--json", "--number", "42", "some feature") + _assert_parity(b, p) + assert json.loads(p.stdout)["FEATURE_NUM"] == "042" + + def test_timestamp_mode_format(self, tmp_path: Path): + _, py_proj = _twin_projects(tmp_path) + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--timestamp", "--short-name", "user-auth", "desc", + ) + assert p.returncode == 0 + data = json.loads(p.stdout) + assert re.fullmatch(r"[0-9]{8}-[0-9]{6}", data["FEATURE_NUM"]) + assert data["BRANCH_NAME"] == f"{data['FEATURE_NUM']}-user-auth" + + def test_timestamp_with_number_warns(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, + "--json", "--dry-run", "--timestamp", "--number", "5", "desc word", + ) + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--dry-run", "--timestamp", "--number", "5", "desc word", + ) + assert p.returncode == b.returncode == 0 + warning = "[specify] Warning: --number is ignored when --timestamp is used" + assert warning in b.stderr + assert warning in p.stderr + + def test_branch_template_author_app(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow") + p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow") + _assert_parity(b, p) + assert json.loads(p.stdout)["BRANCH_NAME"] == "test-user/proj/001-new-payment-flow" + + def test_branch_prefix_shorthand(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, "branch_prefix: feat\n") + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow") + p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow") + _assert_parity(b, p) + assert json.loads(p.stdout)["BRANCH_NAME"] == "feat/001-new-payment-flow" + + def test_template_scopes_existing_branch_numbers(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, 'branch_template: "{author}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "test-user/008-scoped"], cwd=proj, check=True) + subprocess.run(["git", "branch", "other-user/030-unscoped"], cwd=proj, check=True) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next thing") + p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next thing") + _assert_parity(b, p) + assert json.loads(p.stdout)["FEATURE_NUM"] == "009" + + @pytest.mark.parametrize( + "template", + [ + 'branch_template: "feat/{slug}"\n', + 'branch_template: "{slug}/{number}-x"\n', + 'branch_template: "{number}/{slug}-x"\n', + ], + ) + def test_invalid_template_rejected(self, tmp_path: Path, template: str): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, template) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "desc word") + p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "desc word") + assert b.returncode == p.returncode == 1 + assert p.stderr.strip() == b.stderr.strip() + + def test_git_branch_name_override(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + env = {"GIT_BRANCH_NAME": "team/042-exact-name"} + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "desc word", env_extra=env) + p = _run_py("create-new-feature-branch", py_proj, "--json", "desc word", env_extra=env) + _assert_parity(b, p) + assert json.loads(p.stdout) == {"BRANCH_NAME": "team/042-exact-name", "FEATURE_NUM": "042"} + + def test_long_branch_name_truncated_to_244_bytes(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + long_name = "-".join(["word"] * 60) + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, + "--json", "--dry-run", "--short-name", long_name, "desc", + ) + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--dry-run", "--short-name", long_name, "desc", + ) + _assert_parity(b, p) + assert len(json.loads(p.stdout)["BRANCH_NAME"].encode()) <= 244 + + def test_existing_branch_errors_without_flag(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True) + args = ("--json", "--number", "1", "--short-name", "user-auth", "desc") + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + assert b.returncode == p.returncode == 1 + assert p.stderr.strip() == b.stderr.strip() + + def test_existing_branch_switches_with_allow_flag(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True) + args = ("--json", "--number", "1", "--short-name", "user-auth", "--allow-existing-branch", "desc") + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + for proj in (bash_proj, py_proj): + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=proj, + capture_output=True, + text=True, + ).stdout.strip() + assert branch == "001-user-auth" + + def test_no_git_graceful_degradation(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path, git=False) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "offline feature") + p = _run_py("create-new-feature-branch", py_proj, "--json", "offline feature") + _assert_parity(b, p) + assert "skipped branch creation" in p.stderr + + def test_empty_description_errors(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", " ") + p = _run_py("create-new-feature-branch", py_proj, "--json", " ") + assert b.returncode == p.returncode == 1 + + def test_specify_init_dir_resolves_target_project(self, tmp_path: Path): + _, py_proj = _twin_projects(tmp_path) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--dry-run", "init dir feature", + env_extra={"SPECIFY_INIT_DIR": str(py_proj)}, + ) + assert p.returncode == 0 + assert json.loads(p.stdout)["FEATURE_NUM"] == "001" + + def test_specify_init_dir_without_core_errors(self, tmp_path: Path): + _, py_proj = _twin_projects(tmp_path) + (py_proj / "scripts" / "python" / "common.py").unlink() + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "desc word", + env_extra={"SPECIFY_INIT_DIR": str(py_proj)}, + ) + assert p.returncode == 1 + assert "SPECIFY_INIT_DIR requires updated Spec Kit core scripts" in p.stderr + + +@requires_bash +class TestInitializeRepoParity: + def test_initializes_repo_with_default_message(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path, git=False) + b = _run_bash("initialize-repo.sh", bash_proj) + p = _run_py("initialize-repo", py_proj) + _assert_parity(b, p) + assert p.stderr.strip() == b.stderr.strip() + for proj in (bash_proj, py_proj): + message = subprocess.run( + ["git", "log", "-1", "--format=%s"], + cwd=proj, + capture_output=True, + text=True, + ).stdout.strip() + assert message == "[Spec Kit] Initial commit" + + def test_custom_commit_message(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path, git=False) + for proj in (bash_proj, py_proj): + _write_config(proj, 'init_commit_message: "Custom initial commit"\n') + b = _run_bash("initialize-repo.sh", bash_proj) + p = _run_py("initialize-repo", py_proj) + _assert_parity(b, p) + for proj in (bash_proj, py_proj): + message = subprocess.run( + ["git", "log", "-1", "--format=%s"], + cwd=proj, + capture_output=True, + text=True, + ).stdout.strip() + assert message == "Custom initial commit" + + def test_skips_existing_repo(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash("initialize-repo.sh", bash_proj) + p = _run_py("initialize-repo", py_proj) + _assert_parity(b, p) + assert p.stderr.strip() == b.stderr.strip() + assert "already initialized" in p.stderr + + +@requires_bash +class TestAutoCommitParity: + def _dirty(self, proj: Path) -> None: + (proj / "change.txt").write_text("dirty\n", encoding="utf-8") + + def _last_message(self, proj: Path) -> str: + return subprocess.run( + ["git", "log", "-1", "--format=%s"], + cwd=proj, + capture_output=True, + text=True, + ).stdout.strip() + + def test_disabled_by_default(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, "auto_commit:\n after_specify:\n enabled: false\n") + self._dirty(proj) + b = _run_bash("auto-commit.sh", bash_proj, "after_specify") + p = _run_py("auto-commit", py_proj, "after_specify") + _assert_parity(b, p) + assert self._last_message(py_proj) == "seed" + + def test_enabled_per_command_with_custom_message(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + config = ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "spec done"\n' + ) + for proj in (bash_proj, py_proj): + _write_config(proj, config) + self._dirty(proj) + b = _run_bash("auto-commit.sh", bash_proj, "after_specify") + p = _run_py("auto-commit", py_proj, "after_specify") + _assert_parity(b, p) + assert p.stderr.strip() == b.stderr.strip() + assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done" + + def test_default_true_applies_to_unlisted_event(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, "auto_commit:\n default: true\n") + self._dirty(proj) + b = _run_bash("auto-commit.sh", bash_proj, "after_plan") + p = _run_py("auto-commit", py_proj, "after_plan") + _assert_parity(b, p) + expected = "[Spec Kit] Auto-commit after plan" + assert self._last_message(bash_proj) == self._last_message(py_proj) == expected + + def test_explicit_false_beats_default_true(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + config = "auto_commit:\n default: true\n after_specify:\n enabled: false\n" + for proj in (bash_proj, py_proj): + _write_config(proj, config) + self._dirty(proj) + b = _run_bash("auto-commit.sh", bash_proj, "after_specify") + p = _run_py("auto-commit", py_proj, "after_specify") + _assert_parity(b, p) + assert self._last_message(py_proj) == "seed" + + def test_before_event_message(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, "auto_commit:\n before_plan:\n enabled: true\n") + self._dirty(proj) + b = _run_bash("auto-commit.sh", bash_proj, "before_plan") + p = _run_py("auto-commit", py_proj, "before_plan") + _assert_parity(b, p) + expected = "[Spec Kit] Auto-commit before plan" + assert self._last_message(bash_proj) == self._last_message(py_proj) == expected + + def test_no_changes_skips(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, "auto_commit:\n after_specify:\n enabled: true\n") + subprocess.run(["git", "add", "-A"], cwd=proj, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "clean"], + cwd=proj, + check=True, + env={**os.environ, **_GIT_ENV}, + ) + b = _run_bash("auto-commit.sh", bash_proj, "after_specify") + p = _run_py("auto-commit", py_proj, "after_specify") + _assert_parity(b, p) + assert p.stderr.strip() == b.stderr.strip() + assert "No changes to commit" in p.stderr + + def test_no_config_file_skips(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + self._dirty(proj) + b = _run_bash("auto-commit.sh", bash_proj, "after_specify") + p = _run_py("auto-commit", py_proj, "after_specify") + _assert_parity(b, p) + assert self._last_message(py_proj) == "seed" + + def test_missing_event_argument_errors(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash("auto-commit.sh", bash_proj) + p = _run_py("auto-commit", py_proj) + assert b.returncode == p.returncode == 1 + + def test_not_a_repo_skips(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path, git=False) + b = _run_bash("auto-commit.sh", bash_proj, "after_specify") + p = _run_py("auto-commit", py_proj, "after_specify") + _assert_parity(b, p) + assert "Not a Git repository" in p.stderr + + +class TestGitCommonPython: + """Unit tests for git_common.py (imported directly).""" + + @pytest.fixture() + def git_common(self): + sys.path.insert(0, str(EXT_PY)) + try: + import git_common + + yield git_common + finally: + sys.path.remove(str(EXT_PY)) + sys.modules.pop("git_common", None) + + def test_has_git(self, git_common, tmp_path: Path): + assert git_common.has_git(tmp_path) is False + _init_git(tmp_path) + assert git_common.has_git(tmp_path) is True + + @pytest.mark.parametrize( + ("branch", "expected"), + [ + ("001-feature-name", True), + ("1234-feature-name", True), + ("20260319-143022-feature-name", True), + ("feat/004-name", True), + ("main", False), + ("2026031-143022", False), + ("20260319-143022", False), + ("2026031-143022-slug", False), + ], + ) + def test_check_feature_branch(self, git_common, branch: str, expected: bool, capsys): + assert git_common.check_feature_branch(branch, True) is expected + + def test_check_feature_branch_no_git_warns_but_passes(self, git_common, capsys): + assert git_common.check_feature_branch("main", False) is True + assert "skipped branch validation" in capsys.readouterr().err From ee4f9c8b78c55bb573be77e1b5001f9b1a4b1474 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:34:05 +0200 Subject: [PATCH 2/3] fix: match bash error message for whitespace-only descriptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/git/scripts/python/create_new_feature_branch.py | 6 +++++- tests/extensions/git/test_git_extension_python_parity.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/git/scripts/python/create_new_feature_branch.py b/extensions/git/scripts/python/create_new_feature_branch.py index d9989586f4..bf7485a030 100644 --- a/extensions/git/scripts/python/create_new_feature_branch.py +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -410,10 +410,14 @@ def _byte_length(value: str) -> int: def main(argv: list[str]) -> int: args = parse_args(argv) - feature_description = " ".join(args.description_parts).strip() + feature_description = " ".join(args.description_parts) if not feature_description: _err(USAGE) return 1 + feature_description = feature_description.strip() + if not feature_description: + _err("Error: Feature description cannot be empty or contain only whitespace") + return 1 project_root = _find_project_root(SCRIPT_DIR) core = _load_core_common(project_root) diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 270dc152b2..5b64e8d719 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -277,6 +277,8 @@ def test_empty_description_errors(self, tmp_path: Path): b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", " ") p = _run_py("create-new-feature-branch", py_proj, "--json", " ") assert b.returncode == p.returncode == 1 + assert p.stderr.strip() == b.stderr.strip() + assert "cannot be empty or contain only whitespace" in p.stderr def test_specify_init_dir_resolves_target_project(self, tmp_path: Path): _, py_proj = _twin_projects(tmp_path) From 5241529dff13a2cdf88fa8cac3b57f76a0de4832 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:12:35 +0200 Subject: [PATCH 3/3] 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> --- extensions/git/scripts/python/auto_commit.py | 7 +++++- .../git/test_git_extension_python_parity.py | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extensions/git/scripts/python/auto_commit.py b/extensions/git/scripts/python/auto_commit.py index bba1552ae4..7828aa7db8 100644 --- a/extensions/git/scripts/python/auto_commit.py +++ b/extensions/git/scripts/python/auto_commit.py @@ -52,7 +52,12 @@ def _parse_auto_commit_config( in_auto_commit = False in_event = False - content = config_file.read_text(encoding="utf-8") + try: + content = config_file.read_text(encoding="utf-8") + except OSError: + # Unreadable config is treated like a missing one: auto-commit + # stays disabled instead of crashing with a traceback. + return False, "" for line in content.splitlines(): if line.startswith("auto_commit:"): in_auto_commit = True diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 5b64e8d719..1937a58820 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -78,6 +78,7 @@ def _assert_parity( py_result: subprocess.CompletedProcess, *, stdout: bool = True, + stderr: bool = True, ) -> None: assert py_result.returncode == bash_result.returncode, ( f"exit codes diverge: bash={bash_result.returncode} py={py_result.returncode}\n" @@ -85,6 +86,8 @@ def _assert_parity( ) if stdout: assert py_result.stdout == bash_result.stdout + if stderr: + assert py_result.stderr == bash_result.stderr @requires_bash @@ -446,6 +449,25 @@ def test_no_config_file_skips(self, tmp_path: Path): _assert_parity(b, p) assert self._last_message(py_proj) == "seed" + @pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions") + def test_unreadable_config_skips_auto_commit(self, tmp_path: Path): + """An unreadable config behaves like a missing one: no traceback, no commit.""" + if os.geteuid() == 0: + pytest.skip("root bypasses file permissions") + proj = _setup_py_project(tmp_path / "proj") + config = _write_config( + proj, "auto_commit:\n after_specify:\n enabled: true\n" + ) + self._dirty(proj) + config.chmod(0o000) + try: + p = _run_py("auto-commit", proj, "after_specify") + finally: + config.chmod(0o644) + assert p.returncode == 0 + assert "Traceback" not in p.stderr + assert self._last_message(proj) == "seed" + def test_missing_event_argument_errors(self, tmp_path: Path): bash_proj, py_proj = _twin_projects(tmp_path) b = _run_bash("auto-commit.sh", bash_proj)