diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 882a385e2..b78356d1c 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -11,10 +11,22 @@ has_git() { git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + # Validate that a branch name matches the expected feature branch pattern. # Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -23,19 +35,20 @@ check_feature_branch() { return 0 fi - # Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug) - if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 - fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) - if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - return 0 + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 fi - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 + return 0 } diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 8a9c4fd6c..82210000b 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -15,6 +15,14 @@ function Test-HasGit { } } +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -27,24 +35,17 @@ function Test-FeatureBranch { return $true } - # Reject malformed timestamps (7-digit date or no trailing slug) - $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or - ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') - if ($hasMalformedTimestamp) { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false - } + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - $isTimestamp = $Branch -match '^\d{8}-\d{6}-' - - if ($isSequential -or $isTimestamp) { - return $true + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false } - - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false + return $true } diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 04af7d794..b41d17dec 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -114,8 +114,19 @@ has_git() { git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -124,6 +135,9 @@ check_feature_branch() { return 0 fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") + # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") local is_sequential=false @@ -131,7 +145,7 @@ check_feature_branch() { is_sequential=true fi if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 return 1 fi @@ -139,13 +153,12 @@ check_feature_branch() { return 0 } -get_feature_dir() { echo "$1/specs/$2"; } - # Find feature directory by numeric prefix instead of exact branch match # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) find_feature_dir_by_prefix() { local repo_root="$1" - local branch_name="$2" + local branch_name + branch_name=$(spec_kit_effective_branch_name "$2") local specs_dir="$repo_root/specs" # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 35ed884f0..0d6544aaf 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -127,6 +127,16 @@ function Test-HasGit { } } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -138,22 +148,69 @@ function Test-FeatureBranch { Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" return $true } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") return $false } return $true } -function Get-FeatureDir { - param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" +# Resolve specs/ by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix). +function Find-FeatureDirByPrefix { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$Branch + ) + $specsDir = Join-Path $RepoRoot 'specs' + $branchName = Get-SpecKitEffectiveBranchName $Branch + + $prefix = $null + if ($branchName -match '^(\d{8}-\d{6})-') { + $prefix = $Matches[1] + } elseif ($branchName -match '^(\d{3,})-') { + $prefix = $Matches[1] + } else { + return (Join-Path $specsDir $branchName) + } + + $dirMatches = @() + if (Test-Path -LiteralPath $specsDir -PathType Container) { + $dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue) + } + + if ($dirMatches.Count -eq 0) { + return (Join-Path $specsDir $branchName) + } + if ($dirMatches.Count -eq 1) { + return $dirMatches[0].FullName + } + $names = ($dirMatches | ForEach-Object { $_.Name }) -join ' ' + [Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names") + [Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.') + return $null +} + +# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1). +function Get-FeatureDirFromBranchPrefixOrExit { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$CurrentBranch + ) + $resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch + if ($null -eq $resolved) { + [Console]::Error.WriteLine('ERROR: Failed to resolve feature directory') + exit 1 + } + return $resolved } function Get-FeaturePathsEnv { @@ -164,7 +221,7 @@ function Get-FeaturePathsEnv { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) - # 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback) + # 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) $featureJson = Join-Path $repoRoot '.specify/feature.json' if ($env:SPECIFY_FEATURE_DIRECTORY) { $featureDir = $env:SPECIFY_FEATURE_DIRECTORY @@ -173,22 +230,24 @@ function Get-FeaturePathsEnv { $featureDir = Join-Path $repoRoot $featureDir } } elseif (Test-Path $featureJson) { + $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw try { - $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json - if ($featureConfig.feature_directory) { - $featureDir = $featureConfig.feature_directory - # Normalize relative paths to absolute under repo root - if (-not [System.IO.Path]::IsPathRooted($featureDir)) { - $featureDir = Join-Path $repoRoot $featureDir - } - } else { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch - } + $featureConfig = $featureJsonRaw | ConvertFrom-Json } catch { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_") + exit 1 + } + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch } } else { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch } [PSCustomObject]@{ diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 098caf53b..50ab9c7b6 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -587,3 +587,40 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): capture_output=True, text=True, ) assert result.returncode != 0 + + def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): + """git-common check_feature_branch matches core: one optional path prefix.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/001-my-feature" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestGitCommonPowerShell: + def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2161d2893..b258fa98d 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -26,6 +26,13 @@ EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" +HAS_PWSH = shutil.which("pwsh") is not None + + +def _has_pwsh() -> bool: + """Check if pwsh is available.""" + return HAS_PWSH + @pytest.fixture def git_repo(tmp_path: Path) -> Path: @@ -271,6 +278,30 @@ def test_rejects_7digit_timestamp_without_slug(self): result = source_and_call('check_feature_branch "2026031-143022" "true"') assert result.returncode != 0 + def test_accepts_single_prefix_sequential(self): + """Optional gitflow-style prefix: one segment + sequential feature name.""" + result = source_and_call('check_feature_branch "feat/004-my-feature" "true"') + assert result.returncode == 0 + + def test_accepts_single_prefix_timestamp(self): + """Optional prefix + timestamp-style feature name.""" + result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"') + assert result.returncode == 0 + + def test_rejects_invalid_suffix_with_single_prefix(self): + result = source_and_call('check_feature_branch "feat/main" "true"') + assert result.returncode != 0 + assert "feat/main" in result.stderr + + def test_rejects_two_level_prefix_before_feature(self): + """More than one slash: no stripping; whole name must match (fails).""" + result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"') + assert result.returncode != 0 + + def test_rejects_malformed_timestamp_with_prefix(self): + result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"') + assert result.returncode != 0 + # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── @@ -303,6 +334,67 @@ def test_four_digit_sequential_prefix(self, tmp_path: Path): assert result.returncode == 0 assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat" + def test_sequential_with_single_path_prefix(self, tmp_path: Path): + """Strip one optional prefix segment before prefix directory lookup.""" + (tmp_path / "specs" / "004-only-dir").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir" + + def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): + (tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical" + + +# ── get_feature_paths + single-prefix integration ─────────────────────────── + + +class TestGetFeaturePathsSinglePrefix: + def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): + """get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" + (tmp_path / ".specify").mkdir() + (tmp_path / "specs" / "001-target-spec").mkdir(parents=True) + cmd = ( + f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && ' + f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"' + ) + result = subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): + """PowerShell Get-FeaturePathsEnv: same prefix stripping as bash.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + spec_dir = git_repo / "specs" / "001-ps-prefix-spec" + spec_dir.mkdir(parents=True) + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip() + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + # ── get_current_branch Tests ───────────────────────────────────────────────── @@ -791,15 +883,6 @@ def test_dry_run_no_git(self, no_git_dir: Path): # ── 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"