mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117)
* feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) - Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1 for exact branch naming (bypasses all prefix/suffix generation) - Fix --force flag for 'specify init <dir>' into existing directories - Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip, commands registered) - Add TestFeatureDirectoryResolution tests (env var, feature.json, priority, branch fallback) - Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md * fix: remove unused Tuple import (ruff F401) * fix: address Copilot review feedback (#2117) - Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic numeric prefix in both bash and PowerShell - Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte truncation logic works correctly - Add 244-byte length check for GIT_BRANCH_NAME in PowerShell - Use existing_items for non-empty dir warning with --force - Skip git extension install if already installed (idempotent --force) - Wrap PowerShell feature.json parsing in try/catch for malformed JSON - Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir' - Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template * fix: address second round of Copilot review feedback (#2117) - Guard shutil.rmtree on init failure: skip cleanup when --force merged into a pre-existing directory (prevents data loss) - Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation - Fix malformed numbered list in specify.md (restore missing step 1) - Add claude_skills.exists() assert before iterdir() in test * fix: use UTF-8 byte count for 244-byte branch name limit (#2117) - Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR} - PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead of .Length (UTF-16 code units) * fix: address third round of review feedback (#2117) - Update --dry-run help text in bash and PowerShell (branch name only) - Fix specify.md JSON example: use concrete path, not literal variable - Add TestForceExistingDirectory tests (merge + error without --force) - Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json) * fix: normalize relative paths and fix Test-HasGit compat (#2117) - Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json relative paths to absolute under repo root - PowerShell common.ps1: same normalization using IsPathRooted + Join-Path - PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot for compatibility with core common.ps1 (no param) and git-common.ps1 (optional param with default) * test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117) - TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix, timestamp prefix, overlong rejection, dry-run) - TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential prefix, timestamp prefix, overlong rejection) - Tests use extension scripts (not core) via new ext_git_repo and ext_ps_git_repo fixtures * fix: restore git init during specify init + review fixes (#2117) - Restore is_git_repo() and init_git_repo() functions removed in stage 2 - specify init now runs git init AND installs git extension (not just extension install alone) - Add is_dir() guard for non-here path to prevent uncontrolled error when target exists but is a file - Add python3 JSON fallback in common.sh for multi-line feature.json (grep pipeline fails on pretty-printed JSON without jq) * fix: use init_git_repo error_msg in failure output (#2117) * fix: ensure_executable_scripts also covers .specify/extensions/ (#2117) Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh) may lack execute bits after install. Scan both .specify/scripts/ and .specify/extensions/ for permission fixing. * fix: move chmod after extension install + sanitize error_msg (#2117) - ensure_executable_scripts() now runs after git extension install so extension .sh files get execute bits in the same init run - Sanitize init_git_repo error_msg to single line (replace newlines, truncate to 120 chars) to prevent garbled StepTracker output * fix: use tracker.error for git init/extension failures (#2117) Git init failure and extension install failure were reported as tracker.complete (showing green) even on error. Now track a git_has_error flag and call tracker.error when any step fails, so the UI correctly reflects the failure state. * fix: sanitize ext_err in git step tracker for consistent rendering (#2117)
This commit is contained in:
@@ -280,7 +280,6 @@ class TestCreateFeatureBash:
|
||||
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):
|
||||
@@ -294,18 +293,6 @@ class TestCreateFeatureBash:
|
||||
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)
|
||||
@@ -321,7 +308,7 @@ class TestCreateFeatureBash:
|
||||
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)."""
|
||||
"""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,
|
||||
@@ -330,8 +317,8 @@ class TestCreateFeatureBash:
|
||||
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()
|
||||
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."""
|
||||
@@ -382,7 +369,8 @@ class TestCreateFeaturePowerShell:
|
||||
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()
|
||||
assert "BRANCH_NAME" in data
|
||||
assert "FEATURE_NUM" in data
|
||||
|
||||
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class TestInitIntegrationFlag:
|
||||
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
|
||||
@@ -147,3 +149,142 @@ class TestInitIntegrationFlag:
|
||||
# Other shared files should still be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
|
||||
class TestForceExistingDirectory:
|
||||
"""Tests for --force merging into an existing named directory."""
|
||||
|
||||
def test_force_merges_into_existing_dir(self, tmp_path):
|
||||
"""specify init <dir> --force succeeds when the directory already exists."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "existing-proj"
|
||||
target.mkdir()
|
||||
# Place a pre-existing file to verify it survives the merge
|
||||
marker = target / "user-file.txt"
|
||||
marker.write_text("keep me", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot", "--force",
|
||||
"--no-git", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
||||
|
||||
# Pre-existing file should survive
|
||||
assert marker.read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
# Spec Kit files should be installed
|
||||
assert (target / ".specify" / "init-options.json").exists()
|
||||
assert (target / ".specify" / "templates" / "spec-template.md").exists()
|
||||
|
||||
def test_without_force_errors_on_existing_dir(self, tmp_path):
|
||||
"""specify init <dir> without --force errors when directory exists."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "existing-proj"
|
||||
target.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot",
|
||||
"--no-git", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
"""Tests for auto-installation of the git extension during specify init."""
|
||||
|
||||
def test_git_extension_auto_installed(self, tmp_path):
|
||||
"""Without --no-git, the git extension is installed during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-auto"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Check that the tracker didn't report a git error
|
||||
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
|
||||
|
||||
# Git extension files should be installed
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "git extension directory not installed"
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
|
||||
|
||||
# Hooks should be registered
|
||||
extensions_yml = project / ".specify" / "extensions.yml"
|
||||
assert extensions_yml.exists(), "extensions.yml not created"
|
||||
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
|
||||
assert "hooks" in hooks_data
|
||||
assert "before_specify" in hooks_data["hooks"]
|
||||
assert "before_constitution" in hooks_data["hooks"]
|
||||
|
||||
def test_no_git_skips_extension(self, tmp_path):
|
||||
"""With --no-git, the git extension is NOT installed."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Git extension should NOT be installed
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
||||
|
||||
def test_git_extension_commands_registered(self, tmp_path):
|
||||
"""Git extension commands are registered with the agent during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-cmds"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Git extension commands should be registered with the agent
|
||||
claude_skills = project / ".claude" / "skills"
|
||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||
assert len(git_skills) > 0, "no git extension commands registered"
|
||||
|
||||
@@ -4,6 +4,7 @@ Pytest tests for timestamp-based branch naming in create-new-feature.sh and comm
|
||||
Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -22,6 +23,8 @@ EXT_CREATE_FEATURE_PS = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
)
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -47,6 +50,62 @@ def git_repo(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests)."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
|
||||
# Extension script needs common.sh at .specify/scripts/bash/
|
||||
specify_scripts = tmp_path / ".specify" / "scripts" / "bash"
|
||||
specify_scripts.mkdir(parents=True)
|
||||
shutil.copy(COMMON_SH, specify_scripts / "common.sh")
|
||||
# Also install core scripts for compatibility
|
||||
core_scripts = tmp_path / "scripts" / "bash"
|
||||
core_scripts.mkdir(parents=True)
|
||||
shutil.copy(COMMON_SH, core_scripts / "common.sh")
|
||||
# Copy extension script
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
||||
ext_dir.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
|
||||
# Also copy git-common.sh if it exists
|
||||
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
if git_common.exists():
|
||||
shutil.copy(git_common, ext_dir / "git-common.sh")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "specs").mkdir(exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell extension scripts."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
|
||||
# Install core PS scripts
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
# Also install at .specify/scripts/powershell/ for extension resolution
|
||||
specify_ps = tmp_path / ".specify" / "scripts" / "powershell"
|
||||
specify_ps.mkdir(parents=True)
|
||||
shutil.copy(common_ps, specify_ps / "common.ps1")
|
||||
# Copy extension script
|
||||
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
||||
ext_ps.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
|
||||
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
if git_common_ps.exists():
|
||||
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "specs").mkdir(exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_git_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temp directory without git, but with scripts."""
|
||||
@@ -837,3 +896,262 @@ class TestPowerShellDryRun:
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
||||
|
||||
|
||||
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGitBranchNameOverrideBash:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||
|
||||
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
|
||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
|
||||
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
|
||||
env={**os.environ, **env_extras})
|
||||
|
||||
def test_exact_name_no_prefix(self, ext_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "my-exact-branch"
|
||||
assert data["FEATURE_NUM"] == "my-exact-branch"
|
||||
|
||||
def test_sequential_prefix_extraction(self, ext_git_repo: Path):
|
||||
"""FEATURE_NUM extracted from sequential-style prefix (digits before dash)."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_timestamp_prefix_extraction(self, ext_git_repo: Path):
|
||||
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "20260407-143022-my-feature"
|
||||
assert data["FEATURE_NUM"] == "20260407-143022"
|
||||
|
||||
def test_overlong_name_rejected(self, ext_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error."""
|
||||
long_name = "a" * 245
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name})
|
||||
assert result.returncode != 0
|
||||
assert "244" in result.stderr
|
||||
|
||||
def test_dry_run_with_override(self, ext_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME works with --dry-run (no branch created)."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run")
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "dry-run-override"
|
||||
assert data.get("DRY_RUN") is True
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "dry-run-override"],
|
||||
cwd=ext_git_repo, capture_output=True, text=True,
|
||||
)
|
||||
assert "dry-run-override" not in branches.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
class TestGitBranchNameOverridePowerShell:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
|
||||
|
||||
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
|
||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
|
||||
cwd=ext_ps_git_repo, capture_output=True, text=True,
|
||||
env={**os.environ, **env_extras},
|
||||
)
|
||||
|
||||
def test_exact_name_no_prefix(self, ext_ps_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "ps-exact-branch"
|
||||
assert data["FEATURE_NUM"] == "ps-exact-branch"
|
||||
|
||||
def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path):
|
||||
"""FEATURE_NUM extracted from sequential-style prefix."""
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "099-ps-numbered"
|
||||
assert data["FEATURE_NUM"] == "099"
|
||||
|
||||
def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path):
|
||||
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "20260407-143022-ps-feature"
|
||||
assert data["FEATURE_NUM"] == "20260407-143022"
|
||||
|
||||
def test_overlong_name_rejected(self, ext_ps_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected."""
|
||||
long_name = "a" * 245
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name})
|
||||
assert result.returncode != 0
|
||||
assert "244" in result.stderr
|
||||
|
||||
|
||||
# ── Feature Directory Resolution Tests ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatureDirectoryResolution:
|
||||
"""Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution."""
|
||||
|
||||
def test_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup."""
|
||||
custom_dir = git_repo / "my-custom-specs" / "my-feature"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert str(custom_dir) in result.stdout
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
def test_feature_json_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""feature.json feature_directory takes priority over branch-based lookup."""
|
||||
custom_dir = git_repo / "specs" / "custom-feature"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):
|
||||
"""Env var wins over feature.json."""
|
||||
env_dir = git_repo / "specs" / "env-feature"
|
||||
env_dir.mkdir(parents=True)
|
||||
json_dir = git_repo / "specs" / "json-feature"
|
||||
json_dir.mkdir(parents=True)
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{json_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(env_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
def test_fallback_to_branch_lookup(self, git_repo: Path):
|
||||
"""Without env var or feature.json, falls back to branch-based lookup."""
|
||||
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
|
||||
spec_dir = git_repo / "specs" / "001-test-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(spec_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
custom_dir = git_repo / "my-custom-specs" / "ps-feature"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-Command", ps_cmd],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""PowerShell: feature.json takes priority over branch-based lookup."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
custom_dir = git_repo / "specs" / "ps-json-feature"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-Command", ps_cmd],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
Reference in New Issue
Block a user