mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* feat(scripts): optional single-segment branch prefix for gitflow - Add spec_kit_effective_branch_name / Get-SpecKitEffectiveBranchName: when branch matches prefix/rest with exactly one slash, validate and resolve specs/ using only the rest (e.g. feat/001-my-feature). - Wire into check_feature_branch, find_feature_dir_by_prefix (bash) and Test-FeatureBranch, Find-FeatureDirByPrefix + Get-FeaturePathsEnv (PS). - Align git extension git-common with core validation; remove unused get_feature_dir / Get-FeatureDir helpers. - Extend tests in test_timestamp_branches.py and test_git_extension.py. Made-with: Cursor * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(ps): align feature-dir resolution errors with bash (no throw under Stop) Find-FeatureDirByPrefix: on ambiguous prefix matches, write errors to stderr and return $null instead of throwing, matching find_feature_dir_by_prefix. Get-FeaturePathsEnv: narrow try/catch to ConvertFrom-Json only; add Get-FeatureDirFromBranchPrefixOrExit to mirror bash get_feature_paths (stderr + exit 1) when prefix lookup fails, avoiding unhandled terminating errors under $ErrorActionPreference = 'Stop' in check-prerequisites, setup-plan, and update-agent-context. Made-with: Cursor * Update tests/test_timestamp_branches.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/git/scripts/powershell/git-common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
627 lines
26 KiB
Python
627 lines
26 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 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_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 (outputs branch name, skips branch creation)."""
|
|
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)
|
|
assert "BRANCH_NAME" in data
|
|
assert "FEATURE_NUM" in data
|
|
|
|
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 "BRANCH_NAME" in data
|
|
assert "FEATURE_NUM" in data
|
|
|
|
|
|
# ── 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
|
|
|
|
def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path):
|
|
"""git-common check_feature_branch matches core: one optional path prefix."""
|
|
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 "feat/001-my-feature" "true"'],
|
|
capture_output=True, text=True,
|
|
)
|
|
assert result.returncode == 0
|
|
|
|
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
|
|
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 "feat/fix/001-x" "true"'],
|
|
capture_output=True, text=True,
|
|
)
|
|
assert result.returncode != 0
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
|
class TestGitCommonPowerShell:
|
|
def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path):
|
|
project = _setup_project(tmp_path)
|
|
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
|
result = subprocess.run(
|
|
[
|
|
"pwsh",
|
|
"-NoProfile",
|
|
"-Command",
|
|
f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0
|