diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 88a5559460..b244ea7a5b 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -78,13 +78,12 @@ done SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -# Get feature paths and validate branch +# Get feature paths _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } eval "$_paths_output" unset _paths_output -check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 -# If paths-only mode, output paths and exit (support JSON + paths-only combined) +# If paths-only mode, output paths and exit (no validation) if $PATHS_ONLY; then if $JSON_MODE; then # Minimal JSON paths payload (no validation performed) @@ -112,6 +111,9 @@ if $PATHS_ONLY; then exit 0 fi +# Validate branch name +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + # Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index f2d2f6e6fc..945385c643 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -40,15 +40,31 @@ fi # Ensure the feature directory exists mkdir -p "$FEATURE_DIR" -# Copy plan template if it exists -TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true -if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then - cp "$TEMPLATE" "$IMPL_PLAN" - echo "Copied plan template to $IMPL_PLAN" +# Copy plan template if plan doesn't already exist +if [[ -f "$IMPL_PLAN" ]]; then + if $JSON_MODE; then + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 + else + echo "Plan already exists at $IMPL_PLAN, skipping template copy" + fi else - echo "Warning: Plan template not found" - # Create a basic plan file if template doesn't exist - touch "$IMPL_PLAN" + TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true + if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + if $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" >&2 + else + echo "Copied plan template to $IMPL_PLAN" + fi + else + if $JSON_MODE; then + echo "Warning: Plan template not found" >&2 + else + echo "Warning: Plan template not found" + fi + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" + fi fi # Output results diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 91667e9ef1..0620fa0e70 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -56,14 +56,10 @@ EXAMPLES: # Source common functions . "$PSScriptRoot/common.ps1" -# Get feature paths and validate branch +# Get feature paths $paths = Get-FeaturePathsEnv -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { - exit 1 -} - -# If paths-only mode, output paths and exit (support combined -Json -PathsOnly) +# If paths-only mode, output paths and exit (no validation) if ($PathsOnly) { if ($Json) { [PSCustomObject]@{ @@ -85,6 +81,11 @@ if ($PathsOnly) { exit 0 } +# Validate branch name +if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { + exit 1 +} + # Validate required directories and files if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index cd9cf8c426..d9eecdf75c 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -33,17 +33,25 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe # Ensure the feature directory exists 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)) { - # 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) +# Copy plan template if plan doesn't already exist +if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { + if ($Json) { + [Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy") + } else { + Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" + } } else { - Write-Warning "Plan template not found" - # Create a basic plan file if template doesn't exist - New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null + $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT + if ($template -and (Test-Path $template)) { + # 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 + New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null + } } # Output results diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py new file mode 100644 index 0000000000..2e03028001 --- /dev/null +++ b/tests/test_check_prerequisites_paths_only.py @@ -0,0 +1,205 @@ +"""Tests for check-prerequisites --paths-only skipping branch validation (#2653).""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(CHECK_PREREQS_SH, d / "check-prerequisites.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1") + + +def _clean_env() -> dict[str, str]: + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True + ) + + +@pytest.fixture +def prereq_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + (repo / ".specify").mkdir() + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# ── Bash tests ──────────────────────────────────────────────────────────── + + +@requires_bash +def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: + """--paths-only must return paths without branch validation (main branch).""" + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--json", "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "REPO_ROOT" in data + assert "BRANCH" in data + assert "FEATURE_DIR" in data + + +@requires_bash +def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: + """--paths-only must also work on a properly named spec branch.""" + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=prereq_repo, + check=True, + ) + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--json", "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "FEATURE_DIR" in data + assert "001-my-feature" in data.get("BRANCH", "") + + +@requires_bash +def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None: + """--paths-only without --json must return text paths on a non-spec branch.""" + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + assert "REPO_ROOT:" in result.stdout + assert "FEATURE_DIR:" in result.stdout + + +@requires_bash +def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: + """Without --paths-only, branch validation must still fail on main.""" + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +# ── PowerShell tests ────────────────────────────────────────────────────── + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: + """-PathsOnly must return paths without branch validation (main branch).""" + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "REPO_ROOT" in data + assert "BRANCH" in data + assert "FEATURE_DIR" in data + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: + """-PathsOnly must also work on a properly named spec branch.""" + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=prereq_repo, + check=True, + ) + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "FEATURE_DIR" in data + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: + """Without -PathsOnly, branch validation must still fail on main.""" + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py new file mode 100644 index 0000000000..f29a629294 --- /dev/null +++ b/tests/test_setup_plan_no_overwrite.py @@ -0,0 +1,216 @@ +"""Tests for setup-plan preserving existing plan.md (#2653).""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +SETUP_PLAN_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1" +PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(SETUP_PLAN_SH, d / "setup-plan.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(SETUP_PLAN_PS, d / "setup-plan.ps1") + + +def _minimal_templates(repo: Path) -> None: + tdir = repo / ".specify" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") + + +def _clean_env() -> dict[str, str]: + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True + ) + + +@pytest.fixture +def plan_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=repo, + check=True, + ) + (repo / ".specify").mkdir() + _minimal_templates(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# ── Bash tests ──────────────────────────────────────────────────────────── + + +@requires_bash +def test_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None: + """First run must create plan.md from the template.""" + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + plan_path = Path(data["IMPL_PLAN"]) + assert plan_path.is_file() + # Template content should be present + content = plan_path.read_text(encoding="utf-8") + assert len(content) > 0 + + +@requires_bash +def test_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: + """Rerun must not overwrite an existing plan.md.""" + feat = plan_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + existing_content = "# My carefully authored plan\n\nDo not overwrite me.\n" + (feat / "plan.md").write_text(existing_content, encoding="utf-8") + + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + # Plan must be unchanged + assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content + + +@requires_bash +def test_setup_plan_skip_message_on_stderr_in_json_mode(plan_repo: Path) -> None: + """In --json mode, status messages must go to stderr, not stdout.""" + feat = plan_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + (feat / "plan.md").write_text("# existing\n", encoding="utf-8") + + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + # stdout must be valid JSON (no status messages mixed in) + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + # The skip message should be on stderr + assert "already exists" in result.stderr + + +@requires_bash +def test_setup_plan_json_parseable_on_first_run(plan_repo: Path) -> None: + """In --json mode, first-run stdout must be parseable JSON (no status on stdout).""" + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + assert "Copied plan template" in result.stderr + + +# ── PowerShell tests ────────────────────────────────────────────────────── + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None: + """First run must create plan.md from the template.""" + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + plan_path = Path(data["IMPL_PLAN"]) + assert plan_path.is_file() + content = plan_path.read_text(encoding="utf-8") + assert len(content) > 0 + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: + """Rerun must not overwrite an existing plan.md.""" + feat = plan_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + existing_content = "# My carefully authored plan\n\nDo not overwrite me.\n" + (feat / "plan.md").write_text(existing_content, encoding="utf-8") + + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content + # stdout must be valid JSON (no status messages mixed in) + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + # The skip message should be on stderr + assert "already exists" in result.stderr