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>
This commit is contained in:
Marsel Safin
2026-07-14 16:56:10 +02:00
committed by GitHub
parent ab82571999
commit 91839fba50
6 changed files with 1650 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
#!/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:]))

View File

@@ -0,0 +1,634 @@
#!/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 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 <name>] [--number N] "
"[--timestamp] <feature_description>"
)
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 <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)
def _persist_hint(var_name: str, value: str) -> str:
"""Shell-appropriate guidance for persisting an env var in the caller's shell."""
if os.name == "nt":
escaped_value = value.replace("'", "''")
return f"$env:{var_name} = '{escaped_value}'"
escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
return f"export {var_name}={escaped_value}"
@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]:
if shutil.which("git") is None:
return []
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") or os.environ.get("USERNAME") 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, UnicodeDecodeError):
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)
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)
# 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"):
# Pass script path so cwd-outside-repo callers land on the same
# fallback the bash twin does. Older cores don't accept the kwarg —
# fall back to the no-arg call for compatibility.
try:
repo_root = core.get_repo_root(script_file=Path(__file__))
except TypeError:
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: {_persist_hint('SPECIFY_FEATURE', 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: "
f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -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 "
"<prefix>/001-feature-name",
file=sys.stderr,
)
return False
return True

View File

@@ -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, UnicodeDecodeError):
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())

View File

@@ -0,0 +1,647 @@
"""
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 runpy
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 / ".specify" / "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,
run_cwd: Path | None = None,
) -> subprocess.CompletedProcess:
"""Run an extension Python script.
``run_cwd`` overrides the working directory while the script path is
still resolved against ``cwd``, for tests that invoke a project's script
from outside that project.
"""
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=run_cwd or 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,
stderr: 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
if stderr:
py_stderr = py_result.stderr
bash_stderr = bash_result.stderr
if os.name == "nt":
py_stderr = _without_persist_hint(py_stderr)
bash_stderr = _without_persist_hint(bash_stderr)
assert py_stderr == bash_stderr
def _without_persist_hint(stderr: str) -> str:
return "".join(
line
for line in stderr.splitlines(keepends=True)
if not line.startswith("# To persist: ")
)
@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_git_branch_name_override_persist_hint_matches_bash(
self, tmp_path: Path
):
bash_proj, py_proj = _twin_projects(tmp_path)
env = {"GIT_BRANCH_NAME": "feature/$value's-\"quoted\""}
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)
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_missing_git_executable_gracefully_degrades(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
monkeypatch.setenv("PATH", "")
assert module["_git_lines"](tmp_path, "status") == []
def test_windows_persist_hint_quotes_branch_name(
self, monkeypatch: pytest.MonkeyPatch
):
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
monkeypatch.setattr(module["os"], "name", "nt")
result = module["_persist_hint"](
"GIT_BRANCH_NAME", "feature/$value's-\"quoted\""
)
assert (
result
== "$env:GIT_BRANCH_NAME = 'feature/$value''s-\"quoted\"'"
)
def test_shell_specific_persist_hint_can_be_ignored_for_parity(self):
bash_stderr = (
"[specify] Warning\n"
"# To persist: export SPECIFY_FEATURE=feature/name\n"
)
windows_stderr = (
"[specify] Warning\n"
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
)
assert _without_persist_hint(bash_stderr) == _without_persist_hint(
windows_stderr
)
def test_assert_parity_ignores_windows_persist_hint(
self, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(os, "name", "nt")
bash_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=(
"[specify] Warning\n"
"# To persist: export SPECIFY_FEATURE=feature/name\n"
)
)
py_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=(
"[specify] Warning\n"
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
)
)
_assert_parity(bash_result, py_result)
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
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):
# The script is installed under host_proj, so script_file-based
# discovery (and cwd-based discovery, since we run from elsewhere)
# would resolve host_proj, not target_proj. host_proj has no specs
# (next number 001); target_proj already has 007-existing (next
# number 008). Only honoring SPECIFY_INIT_DIR produces 008, so this
# proves the env var -- not script location or cwd -- controls
# resolution.
host_proj = _setup_py_project(tmp_path / "host")
target_proj = _setup_py_project(tmp_path / "target")
(target_proj / "specs" / "007-existing").mkdir(parents=True)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
p = _run_py(
"create-new-feature-branch", host_proj,
"--json", "--dry-run", "init dir feature",
env_extra={"SPECIFY_INIT_DIR": str(target_proj)},
run_cwd=elsewhere,
)
assert p.returncode == 0
assert json.loads(p.stdout)["FEATURE_NUM"] == "008"
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
_, py_proj = _twin_projects(tmp_path)
(
py_proj / ".specify" / "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_ignores_unterminated_final_config_line(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = "auto_commit:\n after_specify:\n enabled: true"
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_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"
@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)
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):
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

View File

@@ -314,6 +314,18 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_installs_python_scripts_for_py(self, tmp_path):
from specify_cli import _install_shared_infra
project = tmp_path / "python-scripts"
project.mkdir()
_install_shared_infra(project, "py")
assert (
project / ".specify" / "scripts" / "python" / "common.py"
).exists()
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
"""A managed script the core no longer ships (e.g. the legacy
update-agent-context.sh, superseded by the agent-context extension) is