Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(scripts): gate specs/ dir creation behind dry-run check
Dry-run was unconditionally creating the root specs/ directory via
mkdir -p / New-Item before the dry-run guard. This violated the
documented contract of zero side effects. Also adds returncode
assertion on git branch --list in tests and adds PowerShell dry-run
test coverage (skipped when pwsh unavailable).

Addresses review comments on #1998.

Assisted-By: 🤖 Claude Code
  • Loading branch information
rhuss committed Mar 28, 2026
commit 4378a1e7eb5e8a93494a8e989feea6914d003eec
4 changes: 3 additions & 1 deletion scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ fi
cd "$REPO_ROOT"

SPECS_DIR="$REPO_ROOT/specs"
mkdir -p "$SPECS_DIR"
if [ "$DRY_RUN" != true ]; then
mkdir -p "$SPECS_DIR"
fi

# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
Expand Down
4 changes: 3 additions & 1 deletion scripts/powershell/create-new-feature.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ $hasGit = Test-HasGit
Set-Location $repoRoot

$specsDir = Join-Path $repoRoot 'specs'
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
if (-not $DryRun) {
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
}

# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
Expand Down
124 changes: 117 additions & 7 deletions tests/test_timestamp_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,20 +444,21 @@ def test_dry_run_no_branch_created(self, git_repo: Path):
capture_output=True,
text=True,
)
Comment thread
mnriem marked this conversation as resolved.
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == "", "branch should not exist after dry-run"

def test_dry_run_no_spec_dir_created(self, git_repo: Path):
"""T011: Dry-run does not create a spec directory."""
"""T011: Dry-run does not create any directories (including root specs/)."""
specs_root = git_repo / "specs"
if specs_root.exists():
shutil.rmtree(specs_root)
assert not specs_root.exists(), "specs/ should not exist before dry-run"

result = run_script(
git_repo, "--dry-run", "--short-name", "no-dir", "No dir feature"
)
assert result.returncode == 0, result.stderr
spec_dirs = [
d.name
for d in (git_repo / "specs").iterdir()
if d.is_dir() and "no-dir" in d.name
] if (git_repo / "specs").exists() else []
assert len(spec_dirs) == 0, f"spec dir should not exist: {spec_dirs}"
assert not specs_root.exists(), "specs/ should not be created during dry-run"

def test_dry_run_empty_repo(self, git_repo: Path):
"""T012: Dry-run returns 001 prefix when no existing specs or branches."""
Expand Down Expand Up @@ -584,3 +585,112 @@ def test_dry_run_no_git(self, no_git_dir: Path):
if d.is_dir() and "no-git-dry" in d.name
]
assert len(spec_dirs) == 0


# ── PowerShell Dry-Run Tests ─────────────────────────────────────────────────


def _has_pwsh() -> bool:
"""Check if pwsh is available."""
try:
subprocess.run(["pwsh", "--version"], capture_output=True, check=True)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False


def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run create-new-feature.ps1 with given args."""
cmd = ["pwsh", "-NoProfile", "-File", str(CREATE_FEATURE_PS), *args]
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)


@pytest.fixture
def ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell scripts and .specify dir."""
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,
)
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
(tmp_path / ".specify" / "templates").mkdir(parents=True)
Comment thread
rhuss marked this conversation as resolved.
return tmp_path


@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestPowerShellDryRun:
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
"""PowerShell -DryRun computes correct branch name."""
(ps_git_repo / "specs" / "001-first").mkdir(parents=True)
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "ps-feat", "PS feature"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"

def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create a git branch."""
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
)
assert result.returncode == 0, result.stderr
branches = subprocess.run(
["git", "branch", "--list", "*no-ps-branch*"],
cwd=ps_git_repo,
capture_output=True,
text=True,
)
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
assert branches.stdout.strip() == "", "branch should not exist after dry-run"

def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create specs/ directory."""
specs_root = ps_git_repo / "specs"
if specs_root.exists():
shutil.rmtree(specs_root)
assert not specs_root.exists()

result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-dir", "No dir"
)
assert result.returncode == 0, result.stderr
assert not specs_root.exists(), "specs/ should not be created during dry-run"

def test_ps_dry_run_json_includes_field(self, ps_git_repo: Path):
"""PowerShell -DryRun JSON output includes DRY_RUN field."""
import json

result = run_ps_script(
ps_git_repo, "-DryRun", "-Json", "-ShortName", "ps-json", "JSON test"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
assert data["DRY_RUN"] is True

def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path):
"""PowerShell normal JSON output does NOT include DRY_RUN field."""
import json

result = run_ps_script(
ps_git_repo, "-Json", "-ShortName", "ps-no-dry", "No dry run"
)
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}"