Skip to content

Commit 4687c33

Browse files
feat(scripts): optional single-segment branch prefix for gitflow (#2202)
* feat(scripts): optional single-segment branch prefix for gitflow - Add spec_kit_effective_branch_name / Get-SpecKitEffectiveBranchName: when branch matches prefix/rest with exactly one slash, validate and resolve specs/ using only the rest (e.g. feat/001-my-feature). - Wire into check_feature_branch, find_feature_dir_by_prefix (bash) and Test-FeatureBranch, Find-FeatureDirByPrefix + Get-FeaturePathsEnv (PS). - Align git extension git-common with core validation; remove unused get_feature_dir / Get-FeatureDir helpers. - Extend tests in test_timestamp_branches.py and test_git_extension.py. Made-with: Cursor * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(ps): align feature-dir resolution errors with bash (no throw under Stop) Find-FeatureDirByPrefix: on ambiguous prefix matches, write errors to stderr and return $null instead of throwing, matching find_feature_dir_by_prefix. Get-FeaturePathsEnv: narrow try/catch to ConvertFrom-Json only; add Get-FeatureDirFromBranchPrefixOrExit to mirror bash get_feature_paths (stderr + exit 1) when prefix lookup fails, avoiding unhandled terminating errors under $ErrorActionPreference = 'Stop' in check-prerequisites, setup-plan, and update-agent-context. Made-with: Cursor * Update tests/test_timestamp_branches.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/git/scripts/powershell/git-common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent de93528 commit 4687c33

File tree

6 files changed

+268
-62
lines changed

6 files changed

+268
-62
lines changed

extensions/git/scripts/bash/git-common.sh

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,22 @@ has_git() {
1111
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
1212
}
1313

14+
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
15+
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
16+
spec_kit_effective_branch_name() {
17+
local raw="$1"
18+
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
19+
printf '%s\n' "${BASH_REMATCH[2]}"
20+
else
21+
printf '%s\n' "$raw"
22+
fi
23+
}
24+
1425
# Validate that a branch name matches the expected feature branch pattern.
1526
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
27+
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
1628
check_feature_branch() {
17-
local branch="$1"
29+
local raw="$1"
1830
local has_git_repo="$2"
1931

2032
# For non-git repos, we can't enforce branch naming but still provide output
@@ -23,19 +35,20 @@ check_feature_branch() {
2335
return 0
2436
fi
2537

26-
# Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug)
27-
if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then
28-
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
29-
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
30-
return 1
31-
fi
38+
local branch
39+
branch=$(spec_kit_effective_branch_name "$raw")
3240

33-
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
34-
if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
35-
return 0
41+
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
42+
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
43+
local is_sequential=false
44+
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
45+
is_sequential=true
46+
fi
47+
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
48+
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
49+
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
50+
return 1
3651
fi
3752

38-
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
39-
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
40-
return 1
53+
return 0
4154
}

extensions/git/scripts/powershell/git-common.ps1

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ function Test-HasGit {
1515
}
1616
}
1717

18+
function Get-SpecKitEffectiveBranchName {
19+
param([string]$Branch)
20+
if ($Branch -match '^([^/]+)/([^/]+)$') {
21+
return $Matches[2]
22+
}
23+
return $Branch
24+
}
25+
1826
function Test-FeatureBranch {
1927
param(
2028
[string]$Branch,
@@ -27,24 +35,17 @@ function Test-FeatureBranch {
2735
return $true
2836
}
2937

30-
# Reject malformed timestamps (7-digit date or no trailing slug)
31-
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or
32-
($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
33-
if ($hasMalformedTimestamp) {
34-
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
35-
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
36-
return $false
37-
}
38+
$raw = $Branch
39+
$Branch = Get-SpecKitEffectiveBranchName $raw
3840

39-
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
41+
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
42+
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
43+
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
4044
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
41-
$isTimestamp = $Branch -match '^\d{8}-\d{6}-'
42-
43-
if ($isSequential -or $isTimestamp) {
44-
return $true
45+
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
46+
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
47+
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
48+
return $false
4549
}
46-
47-
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
48-
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
49-
return $false
50+
return $true
5051
}

scripts/bash/common.sh

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,19 @@ has_git() {
114114
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
115115
}
116116

117+
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
118+
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
119+
spec_kit_effective_branch_name() {
120+
local raw="$1"
121+
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
122+
printf '%s\n' "${BASH_REMATCH[2]}"
123+
else
124+
printf '%s\n' "$raw"
125+
fi
126+
}
127+
117128
check_feature_branch() {
118-
local branch="$1"
129+
local raw="$1"
119130
local has_git_repo="$2"
120131

121132
# For non-git repos, we can't enforce branch naming but still provide output
@@ -124,28 +135,30 @@ check_feature_branch() {
124135
return 0
125136
fi
126137

138+
local branch
139+
branch=$(spec_kit_effective_branch_name "$raw")
140+
127141
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
128142
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
129143
local is_sequential=false
130144
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
131145
is_sequential=true
132146
fi
133147
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
134-
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
148+
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
135149
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
136150
return 1
137151
fi
138152

139153
return 0
140154
}
141155

142-
get_feature_dir() { echo "$1/specs/$2"; }
143-
144156
# Find feature directory by numeric prefix instead of exact branch match
145157
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
146158
find_feature_dir_by_prefix() {
147159
local repo_root="$1"
148-
local branch_name="$2"
160+
local branch_name
161+
branch_name=$(spec_kit_effective_branch_name "$2")
149162
local specs_dir="$repo_root/specs"
150163

151164
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)

scripts/powershell/common.ps1

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,16 @@ function Test-HasGit {
127127
}
128128
}
129129

130+
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
131+
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
132+
function Get-SpecKitEffectiveBranchName {
133+
param([string]$Branch)
134+
if ($Branch -match '^([^/]+)/([^/]+)$') {
135+
return $Matches[2]
136+
}
137+
return $Branch
138+
}
139+
130140
function Test-FeatureBranch {
131141
param(
132142
[string]$Branch,
@@ -138,22 +148,69 @@ function Test-FeatureBranch {
138148
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
139149
return $true
140150
}
151+
152+
$raw = $Branch
153+
$Branch = Get-SpecKitEffectiveBranchName $raw
141154

142155
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
143156
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
144157
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
145158
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
146159
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
147-
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
148-
Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
160+
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
161+
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
149162
return $false
150163
}
151164
return $true
152165
}
153166

154-
function Get-FeatureDir {
155-
param([string]$RepoRoot, [string]$Branch)
156-
Join-Path $RepoRoot "specs/$Branch"
167+
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
168+
function Find-FeatureDirByPrefix {
169+
param(
170+
[Parameter(Mandatory = $true)][string]$RepoRoot,
171+
[Parameter(Mandatory = $true)][string]$Branch
172+
)
173+
$specsDir = Join-Path $RepoRoot 'specs'
174+
$branchName = Get-SpecKitEffectiveBranchName $Branch
175+
176+
$prefix = $null
177+
if ($branchName -match '^(\d{8}-\d{6})-') {
178+
$prefix = $Matches[1]
179+
} elseif ($branchName -match '^(\d{3,})-') {
180+
$prefix = $Matches[1]
181+
} else {
182+
return (Join-Path $specsDir $branchName)
183+
}
184+
185+
$dirMatches = @()
186+
if (Test-Path -LiteralPath $specsDir -PathType Container) {
187+
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
188+
}
189+
190+
if ($dirMatches.Count -eq 0) {
191+
return (Join-Path $specsDir $branchName)
192+
}
193+
if ($dirMatches.Count -eq 1) {
194+
return $dirMatches[0].FullName
195+
}
196+
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
197+
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
198+
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
199+
return $null
200+
}
201+
202+
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
203+
function Get-FeatureDirFromBranchPrefixOrExit {
204+
param(
205+
[Parameter(Mandatory = $true)][string]$RepoRoot,
206+
[Parameter(Mandatory = $true)][string]$CurrentBranch
207+
)
208+
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
209+
if ($null -eq $resolved) {
210+
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
211+
exit 1
212+
}
213+
return $resolved
157214
}
158215

159216
function Get-FeaturePathsEnv {
@@ -164,7 +221,7 @@ function Get-FeaturePathsEnv {
164221
# Resolve feature directory. Priority:
165222
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
166223
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
167-
# 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback)
224+
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
168225
$featureJson = Join-Path $repoRoot '.specify/feature.json'
169226
if ($env:SPECIFY_FEATURE_DIRECTORY) {
170227
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
@@ -173,22 +230,24 @@ function Get-FeaturePathsEnv {
173230
$featureDir = Join-Path $repoRoot $featureDir
174231
}
175232
} elseif (Test-Path $featureJson) {
233+
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
176234
try {
177-
$featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json
178-
if ($featureConfig.feature_directory) {
179-
$featureDir = $featureConfig.feature_directory
180-
# Normalize relative paths to absolute under repo root
181-
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
182-
$featureDir = Join-Path $repoRoot $featureDir
183-
}
184-
} else {
185-
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
186-
}
235+
$featureConfig = $featureJsonRaw | ConvertFrom-Json
187236
} catch {
188-
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
237+
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
238+
exit 1
239+
}
240+
if ($featureConfig.feature_directory) {
241+
$featureDir = $featureConfig.feature_directory
242+
# Normalize relative paths to absolute under repo root
243+
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
244+
$featureDir = Join-Path $repoRoot $featureDir
245+
}
246+
} else {
247+
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
189248
}
190249
} else {
191-
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
250+
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
192251
}
193252

194253
[PSCustomObject]@{

tests/extensions/git/test_git_extension.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,3 +587,40 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path):
587587
capture_output=True, text=True,
588588
)
589589
assert result.returncode != 0
590+
591+
def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path):
592+
"""git-common check_feature_branch matches core: one optional path prefix."""
593+
project = _setup_project(tmp_path)
594+
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
595+
result = subprocess.run(
596+
["bash", "-c", f'source "{script}" && check_feature_branch "feat/001-my-feature" "true"'],
597+
capture_output=True, text=True,
598+
)
599+
assert result.returncode == 0
600+
601+
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
602+
project = _setup_project(tmp_path)
603+
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
604+
result = subprocess.run(
605+
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
606+
capture_output=True, text=True,
607+
)
608+
assert result.returncode != 0
609+
610+
611+
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
612+
class TestGitCommonPowerShell:
613+
def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path):
614+
project = _setup_project(tmp_path)
615+
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
616+
result = subprocess.run(
617+
[
618+
"pwsh",
619+
"-NoProfile",
620+
"-Command",
621+
f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
622+
],
623+
capture_output=True,
624+
text=True,
625+
)
626+
assert result.returncode == 0

0 commit comments

Comments
 (0)