Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions extensions/git/scripts/bash/git-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
35 changes: 18 additions & 17 deletions extensions/git/scripts/powershell/git-common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}-') {
Write-Output "ERROR: Not on a feature branch. Current branch: $raw"
Write-Output "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
}
23 changes: 18 additions & 5 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -124,28 +135,30 @@ 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
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: $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

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)
Expand Down
61 changes: 53 additions & 8 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -138,22 +148,57 @@ 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 "ERROR: Not on a feature branch. Current branch: $raw"
Write-Output "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/<feature-dir> 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) {
$escaped = [regex]::Escape($prefix)
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "^$escaped-" })
}

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 ' '
Write-Output "ERROR: Multiple spec directories found with prefix '$prefix': $names"
Write-Output "Please ensure only one spec directory exists per prefix."
throw "Multiple spec directories for prefix '$prefix'"
}

function Get-FeaturePathsEnv {
Expand All @@ -164,7 +209,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
Expand All @@ -182,13 +227,13 @@ function Get-FeaturePathsEnv {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
$featureDir = Find-FeatureDirByPrefix -RepoRoot $repoRoot -Branch $currentBranch
}
} catch {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
$featureDir = Find-FeatureDirByPrefix -RepoRoot $repoRoot -Branch $currentBranch
}
} else {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
$featureDir = Find-FeatureDirByPrefix -RepoRoot $repoRoot -Branch $currentBranch
}

[PSCustomObject]@{
Expand Down
37 changes: 37 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading