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:
Manfred Riem
2026-04-08 13:48:36 -05:00
committed by GitHub
parent 838bd0fedc
commit 2972dec85c
10 changed files with 805 additions and 257 deletions

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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"

View File

@@ -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")