diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54ba1dbf5..36ea53799 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,6 +3,7 @@ set -e JSON_MODE=false +DRY_RUN=false ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" @@ -15,6 +16,9 @@ while [ $i -le $# ]; do --json) JSON_MODE=true ;; + --dry-run) + DRY_RUN=true + ;; --allow-existing-branch) ALLOW_EXISTING=true ;; @@ -49,10 +53,11 @@ while [ $i -le $# ]; do USE_TIMESTAMP=true ;; --help|-h) - echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" @@ -74,7 +79,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 exit 1 fi @@ -110,39 +115,59 @@ get_highest_from_specs() { # Function to get highest number from git branches get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +# Shared by get_highest_from_branches and get_highest_from_remote_refs. +_extract_highest_number() { local highest=0 - - # Get all branches (local and remote) - branches=$(git branch -a 2>/dev/null || echo "") - - if [ -n "$branches" ]; then - while IFS= read -r branch; do - # Clean branch name: remove leading markers and remote prefixes - clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') - - # Extract sequential feature number (>=3 digits), skip timestamp branches. - if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number - fi + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number fi - done <<< "$branches" - fi - + fi + done echo "$highest" } -# Function to check existing branches (local and remote) and return next available number +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number. +# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching. check_existing_branches() { local specs_dir="$1" + local skip_fetch="${2:-false}" - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - git fetch --all --prune >/dev/null 2>&1 || true - - # Get highest number from ALL branches (not just matching short name) - local highest_branch=$(get_highest_from_branches) + if [ "$skip_fetch" = true ]; then + # Side-effect-free: query remotes via ls-remote + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi # Get highest number from ALL specs (not just matching short name) local highest_spec=$(get_highest_from_specs "$specs_dir") @@ -179,7 +204,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() { @@ -251,7 +278,14 @@ if [ "$USE_TIMESTAMP" = true ]; then else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then - if [ "$HAS_GIT" = true ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + # Dry-run without git: local spec dirs only + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then # Check existing branches on remotes BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") else @@ -288,62 +322,79 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -if [ "$HAS_GIT" = true ]; then - if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then - # Check if branch already exists - if git branch --list "$BRANCH_NAME" | grep -q .; then - if [ "$ALLOW_EXISTING" = true ]; then - # Switch to the existing branch instead of failing - if ! git checkout "$BRANCH_NAME" 2>/dev/null; then - >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + # Switch to the existing branch instead of failing + if ! git checkout "$BRANCH_NAME" 2>/dev/null; then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." exit 1 fi - elif [ "$USE_TIMESTAMP" = true ]; then - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." - exit 1 else - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." exit 1 fi - else - >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." - exit 1 fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi -else - >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" -fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -mkdir -p "$FEATURE_DIR" + mkdir -p "$FEATURE_DIR" -SPEC_FILE="$FEATURE_DIR/spec.md" -if [ ! -f "$SPEC_FILE" ]; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi fi -fi -# Inform the user how to persist the feature variable in their own shell -printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 + # Inform the user how to persist the feature variable in their own shell + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi if $JSON_MODE; then if command -v jq >/dev/null 2>&1; then - jq -cn \ - --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ - --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" - printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 3708ea2db..2cfa35139 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -4,6 +4,7 @@ param( [switch]$Json, [switch]$AllowExistingBranch, + [switch]$DryRun, [string]$ShortName, [Parameter()] [long]$Number = 0, @@ -16,10 +17,11 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" @@ -35,7 +37,7 @@ if ($Help) { # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } @@ -49,7 +51,7 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) { function Get-HighestNumberFromSpecs { param([string]$SpecsDir) - + [long]$highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { @@ -65,48 +67,87 @@ function Get-HighestNumberFromSpecs { return $highest } +# Extract the highest sequential feature number from a list of branch/ref names. +# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs. +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + function Get-HighestNumberFromBranches { param() - - [long]$highest = 0 + try { $branches = git branch -a 2>$null - if ($LASTEXITCODE -eq 0) { - foreach ($branch in $branches) { - # Clean branch name: remove leading markers and remote prefixes - $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - - # Extract sequential feature number (>=3 digits), skip timestamp branches. - if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') { - [long]$num = 0 - if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { - $highest = $num - } - } + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' } + return Get-HighestNumberFromNames -Names $cleanNames } } catch { - # If git command fails, return 0 Write-Verbose "Could not check Git branches: $_" } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } return $highest } +# Return next available branch number. When SkipFetch is true, queries remotes +# via ls-remote (read-only) instead of fetching. function Get-NextBranchNumber { param( - [string]$SpecsDir + [string]$SpecsDir, + [switch]$SkipFetch ) - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - try { - git fetch --all --prune 2>$null | Out-Null - } catch { - # Ignore fetch errors + if ($SkipFetch) { + # Side-effect-free: query remotes via ls-remote + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + try { + git fetch --all --prune 2>$null | Out-Null + } catch { + # Ignore fetch errors + } + $highestBranch = Get-HighestNumberFromBranches } - # Get highest number from ALL branches (not just matching short name) - $highestBranch = Get-HighestNumberFromBranches - # Get highest number from ALL specs (not just matching short name) $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir @@ -119,7 +160,7 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } # Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) @@ -134,12 +175,14 @@ $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 { param([string]$Description) - + # Common stop words to filter out $stopWords = @( 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', @@ -148,17 +191,17 @@ function Get-BranchName { 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', 'want', 'need', 'add', 'get', 'set' ) - + # Convert to lowercase and extract words (alphanumeric only) $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' $words = $cleanName -split '\s+' | Where-Object { $_ } - + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) $meaningfulWords = @() foreach ($word in $words) { # Skip stop words if ($stopWords -contains $word) { continue } - + # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word @@ -167,7 +210,7 @@ function Get-BranchName { $meaningfulWords += $word } } - + # If we have meaningful words, use first 3-4 of them if ($meaningfulWords.Count -gt 0) { $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } @@ -203,7 +246,13 @@ if ($Timestamp) { } else { # Determine branch number if ($Number -eq 0) { - if ($hasGit) { + if ($DryRun -and $hasGit) { + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + # Dry-run without git: local spec dirs only + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { # Check existing branches on remotes $Number = Get-NextBranchNumber -SpecsDir $specsDir } else { @@ -224,86 +273,94 @@ if ($branchName.Length -gt $maxBranchLength) { # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 $prefixLength = $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength - + # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) # Remove trailing hyphen if truncation created one $truncatedSuffix = $truncatedSuffix -replace '-$', '' - + $originalBranchName = $branchName $branchName = "$featureNum-$truncatedSuffix" - + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -if ($hasGit) { - $branchCreated = $false - try { - git checkout -q -b $branchName 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { - $branchCreated = $true +$featureDir = Join-Path $specsDir $branchName +$specFile = Join-Path $featureDir 'spec.md' + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + try { + git checkout -q -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + # Exception during git command } - } catch { - # Exception during git command - } - if (-not $branchCreated) { - # Check if branch already exists - $existingBranch = git branch --list $branchName 2>$null - if ($existingBranch) { - if ($AllowExistingBranch) { - # Switch to the existing branch instead of failing - git checkout -q $branchName 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + if (-not $branchCreated) { + # Check if branch already exists + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + # Switch to the existing branch instead of failing + git checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + exit 1 + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." exit 1 } - } elseif ($Timestamp) { - Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." - exit 1 } else { - Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." exit 1 } - } else { - Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." - exit 1 } + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" } -} else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" -} -$featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null -$specFile = Join-Path $featureDir 'spec.md' -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 - } else { - New-Item -ItemType File -Path $specFile | Out-Null + 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 + } else { + New-Item -ItemType File -Path $specFile -Force | Out-Null + } } -} -# Set the SPECIFY_FEATURE environment variable for the current session -$env:SPECIFY_FEATURE = $branchName + # Set the SPECIFY_FEATURE environment variable for the current session + $env:SPECIFY_FEATURE = $branchName +} if ($Json) { - $obj = [PSCustomObject]@{ + $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 0c9eb07b4..120d0b725 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -412,3 +412,341 @@ def test_powershell_supports_allow_existing_branch_flag(self): assert "-AllowExistingBranch" in contents # Ensure the flag is referenced in script logic, not just declared assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") + + +# ── Dry-Run Tests ──────────────────────────────────────────────────────────── + + +class TestDryRun: + def test_dry_run_sequential_outputs_name(self, git_repo: Path): + """T009: Dry-run computes correct branch name with existing specs.""" + (git_repo / "specs" / "001-first-feat").mkdir(parents=True) + (git_repo / "specs" / "002-second-feat").mkdir(parents=True) + result = run_script( + git_repo, "--dry-run", "--short-name", "new-feat", "New 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 == "003-new-feat", f"expected 003-new-feat, got: {branch}" + + def test_dry_run_no_branch_created(self, git_repo: Path): + """T010: Dry-run does not create a git branch.""" + result = run_script( + git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature" + ) + assert result.returncode == 0, result.stderr + branches = subprocess.run( + ["git", "branch", "--list", "*no-branch*"], + cwd=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_dry_run_no_spec_dir_created(self, git_repo: Path): + """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 + 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.""" + result = run_script( + git_repo, "--dry-run", "--short-name", "first", "First 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 == "001-first", f"expected 001-first, got: {branch}" + + def test_dry_run_with_short_name(self, git_repo: Path): + """T013: Dry-run with --short-name produces expected name.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + (git_repo / "specs" / "002-existing").mkdir(parents=True) + (git_repo / "specs" / "003-existing").mkdir(parents=True) + result = run_script( + git_repo, "--dry-run", "--short-name", "user-auth", "Add user authentication" + ) + 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 == "004-user-auth", f"expected 004-user-auth, got: {branch}" + + def test_dry_run_then_real_run_match(self, git_repo: Path): + """T014: Dry-run name matches subsequent real creation.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + # Dry-run first + dry_result = run_script( + git_repo, "--dry-run", "--short-name", "match-test", "Match test" + ) + assert dry_result.returncode == 0, dry_result.stderr + dry_branch = None + for line in dry_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + dry_branch = line.split(":", 1)[1].strip() + # Real run + real_result = run_script( + git_repo, "--short-name", "match-test", "Match test" + ) + assert real_result.returncode == 0, real_result.stderr + real_branch = None + for line in real_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + real_branch = line.split(":", 1)[1].strip() + assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}" + + def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): + """Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + + # Set up a bare remote and push (use subdirs of git_repo for isolation) + remote_dir = git_repo / "test-remote.git" + subprocess.run( + ["git", "init", "--bare", str(remote_dir)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", str(remote_dir)], + check=True, cwd=git_repo, capture_output=True, + ) + subprocess.run( + ["git", "push", "-u", "origin", "HEAD"], + check=True, cwd=git_repo, capture_output=True, + ) + + # Clone into a second copy, create a higher-numbered branch, push it + second_clone = git_repo / "test-second-clone" + subprocess.run( + ["git", "clone", str(remote_dir), str(second_clone)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=second_clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=second_clone, check=True, capture_output=True, + ) + # Create branch 005 on the remote (higher than local 001) + subprocess.run( + ["git", "checkout", "-b", "005-remote-only"], + cwd=second_clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "push", "origin", "005-remote-only"], + cwd=second_clone, check=True, capture_output=True, + ) + + # Primary repo: dry-run should see 005 via ls-remote and return 006 + dry_result = run_script( + git_repo, "--dry-run", "--short-name", "remote-test", "Remote test" + ) + assert dry_result.returncode == 0, dry_result.stderr + dry_branch = None + for line in dry_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + dry_branch = line.split(":", 1)[1].strip() + assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}" + + def test_dry_run_json_includes_field(self, git_repo: Path): + """T015: JSON output includes DRY_RUN field when --dry-run is active.""" + import json + + result = run_script( + git_repo, "--dry-run", "--json", "--short-name", "json-test", "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_dry_run_json_absent_without_flag(self, git_repo: Path): + """T016: Normal JSON output does NOT include DRY_RUN field.""" + import json + + result = run_script( + git_repo, "--json", "--short-name", "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}" + + def test_dry_run_with_timestamp(self, git_repo: Path): + """T017: Dry-run works with --timestamp flag.""" + result = run_script( + git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp 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 is not None, "no BRANCH_NAME in output" + assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" + # Verify no side effects + branches = subprocess.run( + ["git", "branch", "--list", f"*ts-feat*"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "" + + def test_dry_run_with_number(self, git_repo: Path): + """T018: Dry-run works with --number flag.""" + result = run_script( + git_repo, "--dry-run", "--number", "42", "--short-name", "num-feat", "Number 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 == "042-num-feat", f"expected 042-num-feat, got: {branch}" + + def test_dry_run_no_git(self, no_git_dir: Path): + """T019: Dry-run works in non-git directory.""" + (no_git_dir / "specs" / "001-existing").mkdir(parents=True) + result = run_script( + no_git_dir, "--dry-run", "--short-name", "no-git-dry", "No git dry run" + ) + 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-no-git-dry", f"expected 002-no-git-dry, got: {branch}" + # Verify no spec dir created + spec_dirs = [ + d.name + for d in (no_git_dir / "specs").iterdir() + 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 from the temp repo's scripts directory.""" + script = cwd / "scripts" / "powershell" / "create-new-feature.ps1" + cmd = ["pwsh", "-NoProfile", "-File", str(script), *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) + 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}"