From 42243ef3336f7e4e2dbe46c5dda0639e1ecfbc4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 27 Mar 2026 18:15:50 +0100 Subject: [PATCH 1/7] feat(scripts): add --dry-run flag to create-new-feature scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a --dry-run / -DryRun flag to both bash and PowerShell create-new-feature scripts that computes the next branch name, spec file path, and feature number without creating any branches, directories, or files. This enables external tools to query the next available name before running the full specify workflow. When combined with --json, the output includes a DRY_RUN field. Without --dry-run, behavior is completely unchanged. Closes #1931 Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 116 +++++++++------ scripts/powershell/create-new-feature.ps1 | 132 ++++++++++------- tests/test_timestamp_branches.py | 172 ++++++++++++++++++++++ 3 files changed, 322 insertions(+), 98 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54ba1dbf5..1b72ad718 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 @@ -251,7 +256,19 @@ if [ "$USE_TIMESTAMP" = true ]; then else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then - if [ "$HAS_GIT" = true ]; then + if [ "$DRY_RUN" = true ]; then + # Dry-run: use locally available data only (skip git fetch) + _highest_branch=0 + if [ "$HAS_GIT" = true ]; then + _highest_branch=$(get_highest_from_branches) + fi + _highest_spec=$(get_highest_from_specs "$SPECS_DIR") + _max_num=$_highest_branch + if [ "$_highest_spec" -gt "$_max_num" ]; then + _max_num=$_highest_spec + fi + BRANCH_NUMBER=$((_max_num + 1)) + elif [ "$HAS_GIT" = true ]; then # Check existing branches on remotes BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") else @@ -288,62 +305,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..ccc612249 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 { @@ -67,7 +69,7 @@ function Get-HighestNumberFromSpecs { function Get-HighestNumberFromBranches { param() - + [long]$highest = 0 try { $branches = git branch -a 2>$null @@ -75,7 +77,7 @@ function Get-HighestNumberFromBranches { 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 @@ -119,7 +121,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) @@ -139,7 +141,7 @@ 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 +150,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 +169,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 +205,15 @@ if ($Timestamp) { } else { # Determine branch number if ($Number -eq 0) { - if ($hasGit) { + if ($DryRun) { + # Dry-run: use locally available data only (skip git fetch) + $highestBranch = 0 + if ($hasGit) { + $highestBranch = Get-HighestNumberFromBranches + } + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $specsDir + $Number = [Math]::Max($highestBranch, $highestSpec) + 1 + } elseif ($hasGit) { # Check existing branches on remotes $Number = Get-NextBranchNumber -SpecsDir $specsDir } else { @@ -224,86 +234,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..9613d9246 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -412,3 +412,175 @@ 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.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.""" + 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}" + + 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_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.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 From 4378a1e7eb5e8a93494a8e989feea6914d003eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 27 Mar 2026 18:25:56 +0100 Subject: [PATCH 2/7] fix(scripts): gate specs/ dir creation behind dry-run check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/bash/create-new-feature.sh | 4 +- scripts/powershell/create-new-feature.ps1 | 4 +- tests/test_timestamp_branches.py | 124 ++++++++++++++++++++-- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 1b72ad718..3fe3dff2c 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -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() { diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index ccc612249..c9da4be52 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -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 { diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 9613d9246..d5185dbe7 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -444,20 +444,21 @@ def test_dry_run_no_branch_created(self, git_repo: Path): 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 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.""" @@ -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) + 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}" From ca4f930158362eb841dbcbb340d2bbbfe1d4ef52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 27 Mar 2026 18:43:19 +0100 Subject: [PATCH 3/7] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate `mkdir -p $SPECS_DIR` behind DRY_RUN check (bash + PowerShell) so dry-run creates zero directories - Add returncode assertion on `git branch --list` in test - Strengthen spec dir test to verify root `specs/` is not created - Add PowerShell dry-run test class (5 tests, skipped without pwsh) - Fix run_ps_script to use temp repo copy instead of project root Assisted-By: 🤖 Claude Code --- tests/test_timestamp_branches.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index d5185dbe7..08ab45e6d 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -600,8 +600,9 @@ def _has_pwsh() -> bool: 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] + """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) From 71e0b8fff3e5779c19bcebf968ece5d3c5fb9a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 28 Mar 2026 06:50:32 +0100 Subject: [PATCH 4/7] fix: use git ls-remote for remote-aware dry-run numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dry-run now queries remote branches via `git ls-remote --heads` (read-only, no fetch) to account for remote-only branches when computing the next sequential number. This prevents dry-run from returning a number that already exists on a remote. Added test verifying dry-run sees remote-only higher-numbered branches and adjusts numbering accordingly. Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 28 +++++++++++- scripts/powershell/create-new-feature.ps1 | 33 +++++++++++++- tests/test_timestamp_branches.py | 54 +++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 3fe3dff2c..039b3aa64 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -139,6 +139,28 @@ get_highest_from_branches() { echo "$highest" } +# 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 + while IFS= read -r line; do + [ -z "$line" ] && continue + # Extract ref name from ls-remote output (hash\trefs/heads/branch-name) + ref="${line##*/}" + if echo "$ref" | grep -Eq '^[0-9]{3,}-' && ! echo "$ref" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$ref" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done <<< "$(git ls-remote --heads "$remote" 2>/dev/null || echo "")" + done + + echo "$highest" +} + # Function to check existing branches (local and remote) and return next available number check_existing_branches() { local specs_dir="$1" @@ -259,10 +281,14 @@ else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then if [ "$DRY_RUN" = true ]; then - # Dry-run: use locally available data only (skip git fetch) + # Dry-run: query remote refs without fetching (side-effect-free) _highest_branch=0 if [ "$HAS_GIT" = true ]; then _highest_branch=$(get_highest_from_branches) + _highest_remote=$(get_highest_from_remote_refs) + if [ "$_highest_remote" -gt "$_highest_branch" ]; then + _highest_branch=$_highest_remote + fi fi _highest_spec=$(get_highest_from_specs "$SPECS_DIR") _max_num=$_highest_branch diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index c9da4be52..225af09f1 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -94,6 +94,35 @@ function Get-HighestNumberFromBranches { return $highest } +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $refs = git ls-remote --heads $remote 2>$null + if ($LASTEXITCODE -eq 0 -and $refs) { + foreach ($line in $refs) { + # Extract branch name from refs/heads/branch-name + if ($line -match 'refs/heads/(.+)$') { + $ref = $matches[1] + if ($ref -match '^(\d{3,})-' -and $ref -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + function Get-NextBranchNumber { param( [string]$SpecsDir @@ -208,10 +237,12 @@ if ($Timestamp) { # Determine branch number if ($Number -eq 0) { if ($DryRun) { - # Dry-run: use locally available data only (skip git fetch) + # Dry-run: query remote refs without fetching (side-effect-free) $highestBranch = 0 if ($hasGit) { $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) } $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $specsDir $Number = [Math]::Max($highestBranch, $highestSpec) + 1 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 08ab45e6d..e1a2a4b96 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -510,6 +510,60 @@ def test_dry_run_then_real_run_match(self, git_repo: Path): 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 + remote_dir = git_repo.parent / "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.parent / "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 From 8d4710dc8b981ac13890eea7e7fe49d59e42afc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 28 Mar 2026 07:09:55 +0100 Subject: [PATCH 5/7] fix(scripts): deduplicate number extraction and branch scanning logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared _extract_highest_number helper (bash) and Get-HighestNumberFromNames (PowerShell) to eliminate duplicated number extraction patterns between local branch and remote ref scanning. Add SkipFetch/skip_fetch parameter to check_existing_branches / Get-NextBranchNumber so dry-run reuses the same function instead of inlining duplicate max-of-branches-and-specs logic. Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 97 ++++++++++------------- scripts/powershell/create-new-feature.ps1 | 96 +++++++++++----------- 2 files changed, 93 insertions(+), 100 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 039b3aa64..ac4244395 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -115,27 +115,23 @@ 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" } @@ -144,32 +140,34 @@ get_highest_from_remote_refs() { local highest=0 for remote in $(git remote 2>/dev/null); do - while IFS= read -r line; do - [ -z "$line" ] && continue - # Extract ref name from ls-remote output (hash\trefs/heads/branch-name) - ref="${line##*/}" - if echo "$ref" | grep -Eq '^[0-9]{3,}-' && ! echo "$ref" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$ref" | grep -Eo '^[0-9]+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number - fi - fi - done <<< "$(git ls-remote --heads "$remote" 2>/dev/null || echo "")" + local remote_highest + remote_highest=$(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 +# 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" - - # 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) + local skip_fetch="${2:-false}" + + 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") @@ -280,22 +278,13 @@ if [ "$USE_TIMESTAMP" = true ]; then else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then - if [ "$DRY_RUN" = true ]; then - # Dry-run: query remote refs without fetching (side-effect-free) - _highest_branch=0 - if [ "$HAS_GIT" = true ]; then - _highest_branch=$(get_highest_from_branches) - _highest_remote=$(get_highest_from_remote_refs) - if [ "$_highest_remote" -gt "$_highest_branch" ]; then - _highest_branch=$_highest_remote - fi - fi - _highest_spec=$(get_highest_from_specs "$SPECS_DIR") - _max_num=$_highest_branch - if [ "$_highest_spec" -gt "$_max_num" ]; then - _max_num=$_highest_spec - fi - BRANCH_NUMBER=$((_max_num + 1)) + 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") diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 225af09f1..7c3664407 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -67,31 +67,38 @@ 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 $highest + return 0 } function Get-HighestNumberFromRemoteRefs { @@ -102,18 +109,11 @@ function Get-HighestNumberFromRemoteRefs { foreach ($remote in $remotes) { $refs = git ls-remote --heads $remote 2>$null if ($LASTEXITCODE -eq 0 -and $refs) { - foreach ($line in $refs) { - # Extract branch name from refs/heads/branch-name - if ($line -match 'refs/heads/(.+)$') { - $ref = $matches[1] - if ($ref -match '^(\d{3,})-' -and $ref -notmatch '^\d{8}-\d{6}-') { - [long]$num = 0 - if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { - $highest = $num - } - } - } - } + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } } } } @@ -123,21 +123,29 @@ function Get-HighestNumberFromRemoteRefs { 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 @@ -236,16 +244,12 @@ if ($Timestamp) { } else { # Determine branch number if ($Number -eq 0) { - if ($DryRun) { - # Dry-run: query remote refs without fetching (side-effect-free) - $highestBranch = 0 - if ($hasGit) { - $highestBranch = Get-HighestNumberFromBranches - $highestRemote = Get-HighestNumberFromRemoteRefs - $highestBranch = [Math]::Max($highestBranch, $highestRemote) - } - $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $specsDir - $Number = [Math]::Max($highestBranch, $highestSpec) + 1 + 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 From 594f6325bd8826e0d9a9d2540969704d39c5a285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 28 Mar 2026 07:11:19 +0100 Subject: [PATCH 6/7] fix(tests): use isolated paths for remote branch test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move remote.git and second_clone directories under git_repo instead of git_repo.parent to prevent path collisions with parallel test workers. Assisted-By: 🤖 Claude Code --- tests/test_timestamp_branches.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index e1a2a4b96..41b6c5007 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -514,8 +514,8 @@ 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 - remote_dir = git_repo.parent / "remote.git" + # 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, @@ -530,7 +530,7 @@ def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): ) # Clone into a second copy, create a higher-numbered branch, push it - second_clone = git_repo.parent / "second_clone" + second_clone = git_repo / "test-second-clone" subprocess.run( ["git", "clone", str(remote_dir), str(second_clone)], check=True, capture_output=True, From 4da0da613f7fc1691fb04810810340d50774dc2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 28 Mar 2026 07:19:48 +0100 Subject: [PATCH 7/7] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set GIT_TERMINAL_PROMPT=0 for git ls-remote calls to prevent credential prompts from blocking dry-run in automation scenarios - Add returncode assertion to test_dry_run_with_timestamp git branch --list check Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 2 +- scripts/powershell/create-new-feature.ps1 | 2 ++ tests/test_timestamp_branches.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index ac4244395..36ea53799 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -141,7 +141,7 @@ get_highest_from_remote_refs() { for remote in $(git remote 2>/dev/null); do local remote_highest - remote_highest=$(git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + 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 diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 7c3664407..2cfa35139 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -107,7 +107,9 @@ function Get-HighestNumberFromRemoteRefs { $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] } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 41b6c5007..120d0b725 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -606,6 +606,7 @@ def test_dry_run_with_timestamp(self, git_repo: Path): 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):