Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 4 additions & 1 deletion scripts/powershell/create-new-feature.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,10 @@ if (-not $DryRun) {
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
Comment thread
mnriem marked this conversation as resolved.
} else {
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
Expand Down
6 changes: 4 additions & 2 deletions scripts/powershell/setup-plan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if it exists, otherwise note it or create empty file
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
Copy-Item $template $paths.IMPL_PLAN -Force
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
} else {
Write-Warning "Plan template not found"
# Create a basic plan file if template doesn't exist
Expand Down
113 changes: 83 additions & 30 deletions tests/test_timestamp_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,36 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
return tmp_path


@pytest.fixture
def ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell scripts and a BOM-prefixed template."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(
Comment thread
mnriem marked this conversation as resolved.
["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")
templates_dir = tmp_path / ".specify" / "templates"
templates_dir.mkdir(parents=True)
# Write a BOM-prefixed template to ensure the WriteAllText fix is actually exercised.
# If WriteAllText regresses, the output file will contain the BOM.
bom = b"\xef\xbb\xbf"
template_content = "# Feature Spec\n\nDescribe the feature here.\n"
(templates_dir / "spec-template.md").write_bytes(bom + template_content.encode("utf-8"))
return tmp_path


@pytest.fixture
def no_git_dir(tmp_path: Path) -> Path:
"""Create a temp directory without git, but with scripts."""
Expand Down Expand Up @@ -381,6 +411,7 @@ def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")


@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
Expand Down Expand Up @@ -650,6 +681,45 @@ def test_powershell_surfaces_checkout_errors(self):
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents

@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif(
os.name != "nt" or shutil.which("powershell.exe") is None,
reason="Windows PowerShell not installed",
)
def test_ps_spec_file_written_without_bom(self, ps_git_repo: Path):
"""spec.md generated from a BOM-prefixed template must not contain a UTF-8 BOM."""
result = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(CREATE_FEATURE_PS),
"-ShortName",
"bom-check",
"BOM check feature",
],
Comment thread
mnriem marked this conversation as resolved.
cwd=ps_git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr

spec_file = next((ps_git_repo / "specs").rglob("spec.md"), None)
assert spec_file is not None, (
f"spec.md was not created.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)

raw = spec_file.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), (
f"spec.md must not start with a UTF-8 BOM — got first 3 bytes: {raw[:3]!r}"
)
# Verify template content was copied (not just an empty New-Item fallback)
assert "Feature Spec" in raw.decode("utf-8"), (
"spec.md does not contain template content — WriteAllText path was not exercised"
)
Comment thread
mnriem marked this conversation as resolved.


class TestGitExtensionParity:
def test_bash_extension_surfaces_checkout_errors(self):
Expand Down Expand Up @@ -904,30 +974,6 @@ def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
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)
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):
Expand Down Expand Up @@ -1259,23 +1305,23 @@ def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path):
pytest.fail("FEATURE_DIR not found in PowerShell output")



# ── Description Quoting Tests (issue #2339) ──────────────────────────────────


@requires_bash
class TestDescriptionQuoting:
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.

Regression tests for https://github.com/github/spec-kit/issues/2339
"""

@pytest.mark.parametrize(
"description",
[
"Add user's profile page",
"Fix the \"login\" bug",
'Fix the "login" bug',
"Handle path\\with\\backslashes",
"It's a \"complex\" feature\\here",
'It\'s a "complex" feature\\here',
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
Expand All @@ -1290,16 +1336,22 @@ def test_core_script_handles_special_chars(self, git_repo: Path, description: st
"description",
[
"Add user's profile page",
"Fix the \"login\" bug",
'Fix the "login" bug',
"Handle path\\with\\backslashes",
"It's a \"complex\" feature\\here",
'It\'s a "complex" feature\\here',
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
"""Extension create-new-feature.sh succeeds with special characters in description."""
script = (
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
ext_git_repo
/ ".specify"
/ "extensions"
/ "git"
/ "scripts"
/ "bash"
/ "create-new-feature.sh"
)
result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description],
Expand All @@ -1321,3 +1373,4 @@ def test_plain_description_still_works(self, git_repo: Path):
"""Plain description without special characters continues to work."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
assert result.returncode == 0, result.stderr

Loading