Files
github-spec-kit/tests/extensions/git/test_git_extension.py
Copilot 1a9e4d1d8d feat: Git extension stage 1 — bundled extensions/git with hooks on all core commands (#1941)
* feat: add git extension with hooks on all core commands

- Create extensions/git/ with 5 commands: initialize, feature,
  validate, remote, commit
- 18 hooks covering before/after for all 9 core commands
- Scripts: create-new-feature, initialize-repo, auto-commit,
  git-common (bash + powershell)
- Configurable: branch_numbering, init_commit_message,
  per-command auto-commit with custom messages
- Add hooks to analyze, checklist, clarify, constitution,
  taskstoissues command templates
- Allow hooks-only extensions (no commands required)
- Bundle extension in wheel via pyproject.toml force-include
- Resolve bundled extensions locally before catalog lookup
- Remove planned-but-unimplemented before/after_commit hook refs
- Update extension docs (API ref, dev guide, user guide)
- 37 new tests covering manifest, install, all scripts (bash+pwsh),
  config reading, graceful degradation

Stage 1: opt-in via 'specify extension add git'. No auto-install,
no changes to specify.md or core git init code.

Refs: #841, #1382, #1066, #1791, #1191

* fix: set git identity env vars in extension tests for CI runners

* fix: address PR review comments

- Fix commands property KeyError for hooks-only extensions
- Fix has_git() operator precedence in git-common.sh
- Align default commit message to '[Spec Kit] Initial commit' across
  config-template, extension.yml defaults, and both init scripts
- Update README to reflect all 5 commands and 18 hooks

* fix: address second round of PR review comments

- Add type validation for provides.commands (must be list) and hooks
  (must be dict) in manifest _validate()
- Tighten malformed timestamp detection in git-common.sh to catch
  7-digit dates without trailing slug (e.g. 2026031-143022)
- Pass REPO_ROOT to has_git/Test-HasGit in create-new-feature scripts
- Fix initialize command docs: surface errors on git failures, only
  skip when git is not installed
- Fix commit command docs: 'skips with a warning' not 'silently'
- Add tests for commands:null and hooks:list rejection

* fix: address third round of PR review comments

- Remove scripts frontmatter from command files (CommandRegistrar
  rewrites ../../scripts/ to .specify/scripts/ which points at core
  scripts, not extension scripts)
- Update speckit.git.commit command to derive event name from hook
  context rather than using a static example
- Clarify that hook argument passthrough works via AI agent context
  (the agent carries conversation state including user's original
  feature description)

* fix: address fourth round of PR review comments

- Validate extension_id against ^[a-z0-9-]+$ in _locate_bundled_extension
  to prevent path traversal (security fix)
- Move defaults under config.defaults in extension.yml to match
  ConfigManager._get_extension_defaults() schema
- Ship git-config.yml in extension directory so it's copied during
  install (provides.config template isn't materialized by ExtensionManager)
- Condition handling in hook templates: intentionally matches existing
  pattern from specify/plan/tasks/implement templates (not a new issue)

* fix: add --allow-empty to git commit in initialize-repo scripts

Ensures git init succeeds even on empty repos where nothing has been
staged yet.

* fix: resolve display names to bundled extensions before catalog download

When 'specify extension add "Git Branching Workflow"' is used with a
display name instead of the ID, the catalog resolver now runs first to
map the name to an ID, then checks bundled extensions again with the
resolved ID before falling back to network download.

Also noted: EXECUTE_COMMAND_INVOCATION and condition handling match the
existing pattern in specify/plan/tasks/implement templates (pre-existing,
not introduced by this PR).

* fix: handle before_/after_ prefixes in auto-commit message derivation

- Strip both before_ and after_ prefixes when deriving command name
  (fixes misleading 'Auto-commit after before_plan' messages)
- Include phase (before/after) in default commit messages
- Clarify README config example is an override, not default behavior

* fix: use portable grep -qw for word boundary in create-new-feature.sh

BSD grep (macOS) doesn't support \b as a word boundary. Replace with
grep -qw which is POSIX-portable.

* fix: validate hook values, numeric --number, and PS warning routing

- Validate each hook value is a dict with a 'command' field during
  manifest _validate() (prevents crash at install time)
- Validate --number is a non-negative integer in bash create-new-feature
  (clear error instead of cryptic shell arithmetic failure)
- Route PowerShell no-git warning to stderr in JSON mode so stdout
  stays valid JSON

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-04-07 08:39:35 -05:00

602 lines
25 KiB
Python

"""
Tests for the bundled git extension (extensions/git/).
Validates:
- extension.yml manifest
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
- PowerShell scripts (where pwsh is available)
- Config reading from git-config.yml
- Extension install via ExtensionManager
"""
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "git"
EXT_BASH = EXT_DIR / "scripts" / "bash"
EXT_PS = EXT_DIR / "scripts" / "powershell"
CORE_COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CORE_COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
HAS_PWSH = shutil.which("pwsh") is not None
# ── Helpers ──────────────────────────────────────────────────────────────────
def _init_git(path: Path) -> None:
"""Initialize a git repo with a dummy commit."""
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "seed", "-q"],
cwd=path,
check=True,
)
def _setup_project(tmp_path: Path, *, git: bool = True) -> Path:
"""Create a project directory with core scripts and .specify."""
# Core scripts (needed by extension scripts that source common.sh)
bash_dir = tmp_path / "scripts" / "bash"
bash_dir.mkdir(parents=True)
shutil.copy(CORE_COMMON_SH, bash_dir / "common.sh")
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CORE_COMMON_PS, ps_dir / "common.ps1")
# .specify structure
(tmp_path / ".specify" / "templates").mkdir(parents=True)
# Extension scripts (as if installed)
ext_bash = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_bash.mkdir(parents=True)
for f in EXT_BASH.iterdir():
dest = ext_bash / f.name
shutil.copy(f, dest)
dest.chmod(0o755)
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True)
for f in EXT_PS.iterdir():
shutil.copy(f, ext_ps / f.name)
# Copy extension.yml
shutil.copy(EXT_DIR / "extension.yml", tmp_path / ".specify" / "extensions" / "git" / "extension.yml")
if git:
_init_git(tmp_path)
return tmp_path
def _write_config(project: Path, content: str) -> Path:
"""Write git-config.yml into the extension config directory."""
config_path = project / ".specify" / "extensions" / "git" / "git-config.yml"
config_path.write_text(content, encoding="utf-8")
return config_path
# Git identity env vars for CI runners without global git config
_GIT_ENV = {
"GIT_AUTHOR_NAME": "Test User",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test User",
"GIT_COMMITTER_EMAIL": "test@example.com",
}
def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
"""Run an extension bash script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "bash" / script_name
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
return subprocess.run(
["bash", str(script), *args],
cwd=cwd,
capture_output=True,
text=True,
env=env,
)
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run an extension PowerShell script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
env = {**os.environ, **_GIT_ENV}
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), *args],
cwd=cwd,
capture_output=True,
text=True,
env=env,
)
# ── Manifest Tests ───────────────────────────────────────────────────────────
class TestGitExtensionManifest:
def test_manifest_validates(self):
"""extension.yml passes manifest validation."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
assert m.id == "git"
assert m.version == "1.0.0"
def test_manifest_commands(self):
"""Manifest declares expected commands."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
names = [c["name"] for c in m.commands]
assert "speckit.git.feature" in names
assert "speckit.git.validate" in names
assert "speckit.git.remote" in names
assert "speckit.git.initialize" in names
assert "speckit.git.commit" in names
def test_manifest_hooks(self):
"""Manifest declares expected hooks."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
assert "before_constitution" in m.hooks
assert "before_specify" in m.hooks
assert "after_specify" in m.hooks
assert "after_implement" in m.hooks
assert m.hooks["before_constitution"]["command"] == "speckit.git.initialize"
assert m.hooks["before_specify"]["command"] == "speckit.git.feature"
def test_manifest_command_files_exist(self):
"""All command files referenced in the manifest exist."""
from specify_cli.extensions import ExtensionManifest
m = ExtensionManifest(EXT_DIR / "extension.yml")
for cmd in m.commands:
cmd_path = EXT_DIR / cmd["file"]
assert cmd_path.is_file(), f"Missing command file: {cmd['file']}"
# ── Install Tests ────────────────────────────────────────────────────────────
class TestGitExtensionInstall:
def test_install_from_directory(self, tmp_path: Path):
"""Extension installs via ExtensionManager.install_from_directory."""
from specify_cli.extensions import ExtensionManager
(tmp_path / ".specify").mkdir()
manager = ExtensionManager(tmp_path)
manifest = manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
assert manifest.id == "git"
assert manager.registry.is_installed("git")
def test_install_copies_scripts(self, tmp_path: Path):
"""Extension install copies script files."""
from specify_cli.extensions import ExtensionManager
(tmp_path / ".specify").mkdir()
manager = ExtensionManager(tmp_path)
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
ext_installed = tmp_path / ".specify" / "extensions" / "git"
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
def test_bundled_extension_locator(self):
"""_locate_bundled_extension finds the git extension."""
from specify_cli import _locate_bundled_extension
path = _locate_bundled_extension("git")
assert path is not None
assert (path / "extension.yml").is_file()
# ── initialize-repo.sh Tests ─────────────────────────────────────────────────
class TestInitializeRepoBash:
def test_initializes_git_repo(self, tmp_path: Path):
"""initialize-repo.sh creates a git repo with initial commit."""
project = _setup_project(tmp_path, git=False)
result = _run_bash("initialize-repo.sh", project)
assert result.returncode == 0, result.stderr
# Verify git repo exists
assert (project / ".git").exists()
# Verify at least one commit exists
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert log.returncode == 0
def test_skips_if_already_git_repo(self, tmp_path: Path):
"""initialize-repo.sh skips if already a git repo."""
project = _setup_project(tmp_path, git=True)
result = _run_bash("initialize-repo.sh", project)
assert result.returncode == 0
assert "already initialized" in result.stderr.lower()
def test_custom_commit_message(self, tmp_path: Path):
"""initialize-repo.sh reads custom commit message from config."""
project = _setup_project(tmp_path, git=False)
_write_config(project, 'init_commit_message: "Custom init message"\n')
result = _run_bash("initialize-repo.sh", project)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "Custom init message" in log.stdout
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestInitializeRepoPowerShell:
def test_initializes_git_repo(self, tmp_path: Path):
"""initialize-repo.ps1 creates a git repo with initial commit."""
project = _setup_project(tmp_path, git=False)
result = _run_pwsh("initialize-repo.ps1", project)
assert result.returncode == 0, result.stderr
assert (project / ".git").exists()
def test_skips_if_already_git_repo(self, tmp_path: Path):
"""initialize-repo.ps1 skips if already a git repo."""
project = _setup_project(tmp_path, git=True)
result = _run_pwsh("initialize-repo.ps1", project)
assert result.returncode == 0
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
class TestCreateFeatureBash:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.sh creates sequential branch."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "user-auth", "Add user authentication",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
assert "SPEC_FILE" in data
assert data["FEATURE_NUM"] == "001"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.sh creates timestamp branch."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--timestamp", "--short-name", "feat", "Feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_creates_spec_dir(self, tmp_path: Path):
"""create-new-feature.sh creates specs directory and spec.md."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "test-feat", "Test feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.exists(), f"spec.md not created at {spec_file}"
def test_increments_from_existing_specs(self, tmp_path: Path):
"""Sequential numbering increments past existing spec directories."""
project = _setup_project(tmp_path)
(project / "specs" / "001-first").mkdir(parents=True)
(project / "specs" / "002-second").mkdir(parents=True)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "third", "Third feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "003"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.sh works without git (creates spec dir only)."""
project = _setup_project(tmp_path, git=False)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "no-git", "No git feature",
)
assert result.returncode == 0, result.stderr
assert "Warning" in result.stderr
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.exists()
def test_dry_run(self, tmp_path: Path):
"""--dry-run computes branch name without creating anything."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data.get("DRY_RUN") is True
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates sequential branch."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature.ps1", project,
"-Json", "-ShortName", "user-auth", "Add user authentication",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates timestamp branch."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature.ps1", project,
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.ps1 works without git."""
project = _setup_project(tmp_path, git=False)
result = _run_pwsh(
"create-new-feature.ps1", project,
"-Json", "-ShortName", "no-git", "No git feature",
)
assert result.returncode == 0, result.stderr
# pwsh may prefix warnings to stdout; find the JSON line
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
assert json_line, f"No JSON in output: {result.stdout}"
data = json.loads(json_line[-1])
assert Path(data["SPEC_FILE"]).exists()
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
class TestAutoCommitBash:
def test_disabled_by_default(self, tmp_path: Path):
"""auto-commit.sh exits silently when config is all false."""
project = _setup_project(tmp_path)
_write_config(project, "auto_commit:\n default: false\n")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
# Should not have created any new commits
log = subprocess.run(
["git", "log", "--oneline"],
cwd=project, capture_output=True, text=True,
)
assert log.stdout.strip().count("\n") == 0 # only the seed commit
def test_enabled_per_command(self, tmp_path: Path):
"""auto-commit.sh commits when per-command key is enabled."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "test commit after specify"\n'
))
# Create a file to commit
(project / "specs" / "001-test" / "spec.md").parent.mkdir(parents=True)
(project / "specs" / "001-test" / "spec.md").write_text("test spec")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "test commit after specify" in log.stdout
def test_custom_message(self, tmp_path: Path):
"""auto-commit.sh uses the per-command message."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_plan:\n"
" enabled: true\n"
' message: "[Project] Plan complete"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_plan")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Project] Plan complete" in log.stdout
def test_default_true_with_no_event_key(self, tmp_path: Path):
"""auto-commit.sh uses default: true when event key is absent."""
project = _setup_project(tmp_path)
_write_config(project, "auto_commit:\n default: true\n")
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_tasks")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "Auto-commit after tasks" in log.stdout
def test_no_changes_skips(self, tmp_path: Path):
"""auto-commit.sh skips when there are no changes."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "should not appear"\n'
))
# Commit all existing files so nothing is dirty
subprocess.run(["git", "add", "."], cwd=project, check=True)
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "No changes" in result.stderr
def test_no_config_file_skips(self, tmp_path: Path):
"""auto-commit.sh exits silently when no config file exists."""
project = _setup_project(tmp_path)
# Remove config if it was copied
config = project / ".specify" / "extensions" / "git" / "git-config.yml"
config.unlink(missing_ok=True)
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
def test_no_git_repo_skips(self, tmp_path: Path):
"""auto-commit.sh skips when not in a git repo."""
project = _setup_project(tmp_path, git=False)
_write_config(project, "auto_commit:\n default: true\n")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "not a Git repository" in result.stderr.lower() or "Warning" in result.stderr
def test_requires_event_name_argument(self, tmp_path: Path):
"""auto-commit.sh fails without event name argument."""
project = _setup_project(tmp_path)
result = _run_bash("auto-commit.sh", project)
assert result.returncode != 0
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
def test_disabled_by_default(self, tmp_path: Path):
"""auto-commit.ps1 exits silently when config is all false."""
project = _setup_project(tmp_path)
_write_config(project, "auto_commit:\n default: false\n")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
def test_enabled_per_command(self, tmp_path: Path):
"""auto-commit.ps1 commits when per-command key is enabled."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "ps commit"\n'
))
(project / "specs" / "001-test").mkdir(parents=True)
(project / "specs" / "001-test" / "spec.md").write_text("test")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "ps commit" in log.stdout
# ── git-common.sh Tests ──────────────────────────────────────────────────────
class TestGitCommonBash:
def test_has_git_true(self, tmp_path: Path):
"""has_git returns 0 in a git repo."""
project = _setup_project(tmp_path, git=True)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && has_git "{project}"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_has_git_false(self, tmp_path: Path):
"""has_git returns non-zero outside a git repo."""
project = _setup_project(tmp_path, git=False)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && has_git "{project}"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_sequential(self, tmp_path: Path):
"""check_feature_branch accepts sequential branch names."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "001-my-feature" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_timestamp(self, tmp_path: Path):
"""check_feature_branch accepts timestamp branch names."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "20260319-143022-feat" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_rejects_main(self, tmp_path: Path):
"""check_feature_branch rejects non-feature branch names."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "main" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path):
"""check_feature_branch rejects malformed timestamps (7-digit date)."""
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "2026031-143022-feat" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0