duaer-spec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/agents-workflow.mdc +50 -0
- package/.cursor/rules/ai-ui-copy.mdc +12 -0
- package/.cursor/rules/duaer-spec.mdc +33 -0
- package/.cursor/skills/duaer-analyze/SKILL.md +259 -0
- package/.cursor/skills/duaer-checklist/SKILL.md +383 -0
- package/.cursor/skills/duaer-clarify/SKILL.md +291 -0
- package/.cursor/skills/duaer-constitution/SKILL.md +177 -0
- package/.cursor/skills/duaer-converge/SKILL.md +277 -0
- package/.cursor/skills/duaer-git-commit/SKILL.md +68 -0
- package/.cursor/skills/duaer-git-feature/SKILL.md +94 -0
- package/.cursor/skills/duaer-git-initialize/SKILL.md +54 -0
- package/.cursor/skills/duaer-git-remote/SKILL.md +50 -0
- package/.cursor/skills/duaer-git-validate/SKILL.md +54 -0
- package/.cursor/skills/duaer-implement/SKILL.md +226 -0
- package/.cursor/skills/duaer-plan/SKILL.md +166 -0
- package/.cursor/skills/duaer-specify/SKILL.md +345 -0
- package/.cursor/skills/duaer-tasks/SKILL.md +214 -0
- package/.cursor/skills/duaer-taskstoissues/SKILL.md +109 -0
- package/.duaer/extensions/.registry +23 -0
- package/.duaer/extensions/git/README.md +119 -0
- package/.duaer/extensions/git/commands/duaer.git.commit.md +63 -0
- package/.duaer/extensions/git/commands/duaer.git.feature.md +82 -0
- package/.duaer/extensions/git/commands/duaer.git.initialize.md +49 -0
- package/.duaer/extensions/git/commands/duaer.git.remote.md +45 -0
- package/.duaer/extensions/git/commands/duaer.git.validate.md +49 -0
- package/.duaer/extensions/git/config-template.yml +79 -0
- package/.duaer/extensions/git/extension.yml +142 -0
- package/.duaer/extensions/git/git-config.yml +79 -0
- package/.duaer/extensions/git/scripts/bash/auto-commit.sh +211 -0
- package/.duaer/extensions/git/scripts/bash/create-new-feature-branch.sh +626 -0
- package/.duaer/extensions/git/scripts/bash/git-common.sh +56 -0
- package/.duaer/extensions/git/scripts/bash/initialize-repo.sh +54 -0
- package/.duaer/extensions/git/scripts/powershell/auto-commit.ps1 +230 -0
- package/.duaer/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +592 -0
- package/.duaer/extensions/git/scripts/powershell/git-common.ps1 +52 -0
- package/.duaer/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
- package/.duaer/extensions/git/scripts/python/auto_commit.py +195 -0
- package/.duaer/extensions/git/scripts/python/create_new_feature_branch.py +634 -0
- package/.duaer/extensions/git/scripts/python/git_common.py +81 -0
- package/.duaer/extensions/git/scripts/python/initialize_repo.py +89 -0
- package/.duaer/extensions.yml +167 -0
- package/.duaer/init-options.json +9 -0
- package/.duaer/integration.json +15 -0
- package/.duaer/integrations/cursor-agent.manifest.json +17 -0
- package/.duaer/integrations/duaer.manifest.json +19 -0
- package/.duaer/memory/.constitution-template.json +4 -0
- package/.duaer/memory/constitution.md +37 -0
- package/.duaer/memory/project-context.md +24 -0
- package/.duaer/memory/testing.md +14 -0
- package/.duaer/scripts/bash/check-prerequisites.sh +243 -0
- package/.duaer/scripts/bash/common.sh +926 -0
- package/.duaer/scripts/bash/create-new-feature.sh +407 -0
- package/.duaer/scripts/bash/resolve-template.sh +57 -0
- package/.duaer/scripts/bash/setup-plan.sh +85 -0
- package/.duaer/scripts/bash/setup-tasks.sh +94 -0
- package/.duaer/templates/checklist-template.md +45 -0
- package/.duaer/templates/constitution-template.md +50 -0
- package/.duaer/templates/plan-template.md +113 -0
- package/.duaer/templates/spec-template.md +131 -0
- package/.duaer/templates/tasks-template.md +252 -0
- package/.duaer/workflows/duaer/workflow.yml +78 -0
- package/.duaer/workflows/workflow-registry.json +13 -0
- package/ADOPT.md +75 -0
- package/AGENTS.md +290 -0
- package/CHANGELOG.md +28 -0
- package/DUADER.md +57 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/bin/duaer.mjs +298 -0
- package/docs/agent/README.md +12 -0
- package/docs/agent/change-checklist.md +130 -0
- package/docs/agent/e2e-test-plan.md +33 -0
- package/docs/agent/workflow.md +127 -0
- package/docs/baseline.md +10 -0
- package/package.json +49 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
# Git extension: create-new-feature-branch.ps1
|
|
3
|
+
# Creates a git feature branch only. The feature directory and spec file
|
|
4
|
+
# are created by the core create-new-feature.ps1 script.
|
|
5
|
+
# Sources common.ps1 from the project's installed scripts, falling back to
|
|
6
|
+
# git-common.ps1 for minimal git helpers.
|
|
7
|
+
[CmdletBinding()]
|
|
8
|
+
param(
|
|
9
|
+
[switch]$Json,
|
|
10
|
+
[switch]$AllowExistingBranch,
|
|
11
|
+
[switch]$DryRun,
|
|
12
|
+
[string]$ShortName,
|
|
13
|
+
[Parameter()]
|
|
14
|
+
[long]$Number = 0,
|
|
15
|
+
[switch]$Timestamp,
|
|
16
|
+
[switch]$Help,
|
|
17
|
+
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
|
18
|
+
[string[]]$FeatureDescription
|
|
19
|
+
)
|
|
20
|
+
$ErrorActionPreference = 'Stop'
|
|
21
|
+
|
|
22
|
+
if ($Help) {
|
|
23
|
+
Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
24
|
+
Write-Host ""
|
|
25
|
+
Write-Host "Options:"
|
|
26
|
+
Write-Host " -Json Output in JSON format"
|
|
27
|
+
Write-Host " -DryRun Compute branch name without creating the branch"
|
|
28
|
+
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
|
29
|
+
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
|
30
|
+
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
|
31
|
+
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
32
|
+
Write-Host " -Help Show this help message"
|
|
33
|
+
Write-Host ""
|
|
34
|
+
Write-Host "Environment variables:"
|
|
35
|
+
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
|
36
|
+
Write-Host ""
|
|
37
|
+
Write-Host "Configuration:"
|
|
38
|
+
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
|
39
|
+
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
|
40
|
+
Write-Host ""
|
|
41
|
+
exit 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# -Number is [long], so PowerShell binds "-5" as -5 rather than rejecting it
|
|
45
|
+
# the way the bash/Python twins do (`^[0-9]+$`). A negative value would format
|
|
46
|
+
# via '{0:000}' to e.g. "-005" and produce a branch name starting with "-",
|
|
47
|
+
# which git refuses (refs cannot begin with a dash). Reject it here, before the
|
|
48
|
+
# description check, matching the bash twin's parse-time validation order.
|
|
49
|
+
if ($Number -lt 0) {
|
|
50
|
+
Write-Error 'Error: --number must be a non-negative integer'
|
|
51
|
+
exit 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
55
|
+
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
56
|
+
exit 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
60
|
+
|
|
61
|
+
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|
62
|
+
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
|
63
|
+
exit 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Get-HighestNumberFromSpecs {
|
|
67
|
+
param([string]$SpecsDir)
|
|
68
|
+
|
|
69
|
+
[long]$highest = 0
|
|
70
|
+
if (Test-Path $SpecsDir) {
|
|
71
|
+
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
72
|
+
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
|
73
|
+
[long]$num = 0
|
|
74
|
+
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
75
|
+
$highest = $num
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return $highest
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function Get-HighestNumberFromNames {
|
|
84
|
+
param(
|
|
85
|
+
[string[]]$Names,
|
|
86
|
+
[string]$ScopePrefix = ''
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
[long]$highest = 0
|
|
90
|
+
foreach ($name in $Names) {
|
|
91
|
+
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if ($ScopePrefix) {
|
|
95
|
+
$name = $name.Substring($ScopePrefix.Length)
|
|
96
|
+
}
|
|
97
|
+
$name = ($name -split '/')[-1]
|
|
98
|
+
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
|
|
99
|
+
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
|
|
100
|
+
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
|
|
101
|
+
[long]$num = 0
|
|
102
|
+
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
103
|
+
$highest = $num
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return $highest
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Get-HighestNumberFromBranches {
|
|
111
|
+
param([string]$ScopePrefix = '')
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
$branches = git branch -a 2>$null
|
|
115
|
+
if ($LASTEXITCODE -eq 0 -and $branches) {
|
|
116
|
+
$cleanNames = $branches | ForEach-Object {
|
|
117
|
+
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
|
118
|
+
}
|
|
119
|
+
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
Write-Verbose "Could not check Git branches: $_"
|
|
123
|
+
}
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Get-HighestNumberFromRemoteRefs {
|
|
128
|
+
param([string]$ScopePrefix = '')
|
|
129
|
+
|
|
130
|
+
[long]$highest = 0
|
|
131
|
+
try {
|
|
132
|
+
$remotes = git remote 2>$null
|
|
133
|
+
if ($remotes) {
|
|
134
|
+
foreach ($remote in $remotes) {
|
|
135
|
+
$env:GIT_TERMINAL_PROMPT = '0'
|
|
136
|
+
$refs = git ls-remote --heads $remote 2>$null
|
|
137
|
+
$env:GIT_TERMINAL_PROMPT = $null
|
|
138
|
+
if ($LASTEXITCODE -eq 0 -and $refs) {
|
|
139
|
+
$refNames = $refs | ForEach-Object {
|
|
140
|
+
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
|
141
|
+
} | Where-Object { $_ }
|
|
142
|
+
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
|
|
143
|
+
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
Write-Verbose "Could not query remote refs: $_"
|
|
149
|
+
}
|
|
150
|
+
return $highest
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function Get-NextBranchNumber {
|
|
154
|
+
param(
|
|
155
|
+
[string]$SpecsDir,
|
|
156
|
+
[switch]$SkipFetch,
|
|
157
|
+
[string]$ScopePrefix = ''
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if ($SkipFetch) {
|
|
161
|
+
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
|
162
|
+
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
|
163
|
+
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
|
164
|
+
} else {
|
|
165
|
+
try {
|
|
166
|
+
git fetch --all --prune 2>$null | Out-Null
|
|
167
|
+
} catch { }
|
|
168
|
+
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
|
172
|
+
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
|
173
|
+
return $maxNum + 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ConvertTo-CleanBranchName {
|
|
177
|
+
param([string]$Name)
|
|
178
|
+
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Source common.ps1 from the project's installed scripts.
|
|
183
|
+
# Search locations in priority order:
|
|
184
|
+
# 1. .duaer/scripts/powershell/common.ps1 under the project root
|
|
185
|
+
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
|
|
186
|
+
# 3. git-common.ps1 next to this script (minimal fallback)
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
function Find-ProjectRoot {
|
|
189
|
+
param([string]$StartDir)
|
|
190
|
+
$current = Resolve-Path $StartDir
|
|
191
|
+
while ($true) {
|
|
192
|
+
foreach ($marker in @('.duaer', '.git')) {
|
|
193
|
+
if (Test-Path (Join-Path $current $marker)) {
|
|
194
|
+
return $current
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
$parent = Split-Path $current -Parent
|
|
198
|
+
if ($parent -eq $current) { return $null }
|
|
199
|
+
$current = $parent
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
204
|
+
$commonLoaded = $false
|
|
205
|
+
|
|
206
|
+
if ($projectRoot) {
|
|
207
|
+
$candidates = @(
|
|
208
|
+
(Join-Path $projectRoot ".duaer/scripts/powershell/common.ps1"),
|
|
209
|
+
(Join-Path $projectRoot "scripts/powershell/common.ps1")
|
|
210
|
+
)
|
|
211
|
+
foreach ($candidate in $candidates) {
|
|
212
|
+
if (Test-Path $candidate) {
|
|
213
|
+
. $candidate
|
|
214
|
+
$commonLoaded = $true
|
|
215
|
+
break
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
|
|
221
|
+
. "$PSScriptRoot/git-common.ps1"
|
|
222
|
+
$commonLoaded = $true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (-not $commonLoaded) {
|
|
226
|
+
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
|
|
230
|
+
# minimal git-common.ps1 was loaded, or an older core common.ps1 without the
|
|
231
|
+
# resolver was loaded, refuse rather than silently falling back to the wrong root.
|
|
232
|
+
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) {
|
|
233
|
+
throw "SPECIFY_INIT_DIR requires updated Duaer core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found."
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Resolve repository root. When the core scripts are present, Get-RepoRoot
|
|
237
|
+
# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
|
|
238
|
+
# CI use) and hard-fails on an invalid value with no silent fallback.
|
|
239
|
+
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
|
240
|
+
$repoRoot = Get-RepoRoot
|
|
241
|
+
} elseif ($projectRoot) {
|
|
242
|
+
$repoRoot = $projectRoot
|
|
243
|
+
} else {
|
|
244
|
+
throw "Could not determine repository root."
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# Check if git is available
|
|
248
|
+
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
|
249
|
+
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
|
250
|
+
# and git-common.ps1 (has -RepoRoot param with default).
|
|
251
|
+
$hasGit = Test-HasGit
|
|
252
|
+
} else {
|
|
253
|
+
try {
|
|
254
|
+
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
255
|
+
$hasGit = ($LASTEXITCODE -eq 0)
|
|
256
|
+
} catch {
|
|
257
|
+
$hasGit = $false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
Set-Location $repoRoot
|
|
262
|
+
|
|
263
|
+
$specsDir = Join-Path $repoRoot 'specs'
|
|
264
|
+
$configFile = Join-Path $repoRoot ".duaer/extensions/git/git-config.yml"
|
|
265
|
+
|
|
266
|
+
function Read-GitConfigValue {
|
|
267
|
+
param([string]$Key)
|
|
268
|
+
|
|
269
|
+
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
|
|
270
|
+
$escapedKey = [regex]::Escape($Key)
|
|
271
|
+
foreach ($line in Get-Content -LiteralPath $configFile) {
|
|
272
|
+
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
|
|
273
|
+
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
|
|
274
|
+
$val = $val -replace '^["'']', '' -replace '["'']$', ''
|
|
275
|
+
return $val
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return ''
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ConvertTo-BranchToken {
|
|
282
|
+
param(
|
|
283
|
+
[string]$Value,
|
|
284
|
+
[string]$Fallback
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
$cleaned = ConvertTo-CleanBranchName -Name $Value
|
|
288
|
+
if ($cleaned) { return $cleaned }
|
|
289
|
+
return $Fallback
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function Get-GitAuthorToken {
|
|
293
|
+
$author = ''
|
|
294
|
+
if (Get-Command git -ErrorAction SilentlyContinue) {
|
|
295
|
+
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
|
|
296
|
+
if (-not $author) {
|
|
297
|
+
try {
|
|
298
|
+
$email = (git config user.email 2>$null | Out-String).Trim()
|
|
299
|
+
if ($email) { $author = ($email -split '@')[0] }
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
|
|
304
|
+
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function Get-AppToken {
|
|
308
|
+
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function Resolve-BranchTemplate {
|
|
312
|
+
$template = Read-GitConfigValue -Key 'branch_template'
|
|
313
|
+
if ($template) { return $template }
|
|
314
|
+
|
|
315
|
+
$prefix = Read-GitConfigValue -Key 'branch_prefix'
|
|
316
|
+
if (-not $prefix) { return '' }
|
|
317
|
+
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
|
|
318
|
+
return "$prefix/{number}-{slug}"
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function Expand-BranchTemplate {
|
|
322
|
+
param(
|
|
323
|
+
[string]$Template,
|
|
324
|
+
[string]$FeatureNum,
|
|
325
|
+
[string]$BranchSuffix
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
$rendered = $Template.Replace('{author}', $authorToken)
|
|
329
|
+
$rendered = $rendered.Replace('{app}', $appToken)
|
|
330
|
+
$rendered = $rendered.Replace('{number}', $FeatureNum)
|
|
331
|
+
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
|
|
332
|
+
return $rendered
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function Assert-BranchTemplateValid {
|
|
336
|
+
param([string]$Template)
|
|
337
|
+
|
|
338
|
+
if ($Template -and -not $Template.Contains('{number}')) {
|
|
339
|
+
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
|
|
340
|
+
}
|
|
341
|
+
if ($Template) {
|
|
342
|
+
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
|
343
|
+
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
|
344
|
+
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
|
|
345
|
+
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
|
346
|
+
}
|
|
347
|
+
$featureSegment = ($Template -split '/')[-1]
|
|
348
|
+
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
|
|
349
|
+
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function New-BranchName {
|
|
355
|
+
param(
|
|
356
|
+
[string]$FeatureNum,
|
|
357
|
+
[string]$BranchSuffix
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if ($branchTemplate) {
|
|
361
|
+
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
|
|
362
|
+
}
|
|
363
|
+
return "$FeatureNum-$BranchSuffix"
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function Get-BranchScopePrefix {
|
|
367
|
+
param(
|
|
368
|
+
[string]$Template,
|
|
369
|
+
[string]$BranchSuffix
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if (-not $Template) { return '' }
|
|
373
|
+
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
|
374
|
+
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
|
375
|
+
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
|
|
376
|
+
if (-not $indexes) { return '' }
|
|
377
|
+
$prefix = $Template.Substring(0, $indexes[0])
|
|
378
|
+
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function Get-FeatureNumberFromBranchName {
|
|
382
|
+
param([string]$BranchName)
|
|
383
|
+
|
|
384
|
+
$featureSegment = ($BranchName -split '/')[-1]
|
|
385
|
+
if ($featureSegment -match '^(\d{8}-\d{6})-') {
|
|
386
|
+
return $matches[1]
|
|
387
|
+
}
|
|
388
|
+
if ($featureSegment -match '^(\d+)-') {
|
|
389
|
+
return $matches[1]
|
|
390
|
+
}
|
|
391
|
+
return $BranchName
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function Get-Utf8ByteCount {
|
|
395
|
+
param([string]$Value)
|
|
396
|
+
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
$authorToken = Get-GitAuthorToken
|
|
400
|
+
$appToken = Get-AppToken
|
|
401
|
+
$branchTemplate = Resolve-BranchTemplate
|
|
402
|
+
Assert-BranchTemplateValid -Template $branchTemplate
|
|
403
|
+
|
|
404
|
+
function Get-BranchName {
|
|
405
|
+
param([string]$Description)
|
|
406
|
+
|
|
407
|
+
$stopWords = @(
|
|
408
|
+
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
|
409
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
410
|
+
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
|
411
|
+
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
|
412
|
+
'want', 'need', 'add', 'get', 'set'
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
|
416
|
+
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
|
417
|
+
|
|
418
|
+
$meaningfulWords = @()
|
|
419
|
+
foreach ($word in $words) {
|
|
420
|
+
if ($stopWords -contains $word) { continue }
|
|
421
|
+
if ($word.Length -ge 3) {
|
|
422
|
+
$meaningfulWords += $word
|
|
423
|
+
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
|
424
|
+
# Case-sensitive (-cmatch) to mirror the bash twin's case-sensitive
|
|
425
|
+
# whole-word acronym match: keep a short word only when its UPPERCASE
|
|
426
|
+
# form appears in the original (an acronym). -match is case-insensitive
|
|
427
|
+
# and would keep every short word.
|
|
428
|
+
$meaningfulWords += $word
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if ($meaningfulWords.Count -gt 0) {
|
|
433
|
+
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
|
434
|
+
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
|
435
|
+
return $result
|
|
436
|
+
} else {
|
|
437
|
+
$result = ConvertTo-CleanBranchName -Name $Description
|
|
438
|
+
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
|
439
|
+
return [string]::Join('-', $fallbackWords)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
|
444
|
+
if ($env:GIT_BRANCH_NAME) {
|
|
445
|
+
$branchName = $env:GIT_BRANCH_NAME
|
|
446
|
+
# Check 244-byte limit (UTF-8) for override names
|
|
447
|
+
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
|
|
448
|
+
if ($branchNameUtf8ByteCount -gt 244) {
|
|
449
|
+
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
|
450
|
+
}
|
|
451
|
+
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
|
|
452
|
+
} else {
|
|
453
|
+
if ($ShortName) {
|
|
454
|
+
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
|
455
|
+
} else {
|
|
456
|
+
$branchSuffix = Get-BranchName -Description $featureDesc
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
|
|
460
|
+
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
|
461
|
+
# `[ -n "$BRANCH_NUMBER" ]` check.
|
|
462
|
+
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
|
463
|
+
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
|
464
|
+
$Number = 0
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if ($Timestamp) {
|
|
468
|
+
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
469
|
+
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
|
470
|
+
} else {
|
|
471
|
+
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
|
472
|
+
# Auto-detect the next number only when -Number was not supplied; an
|
|
473
|
+
# explicit value (including 0) is honored, matching the bash twin's
|
|
474
|
+
# `[ -z "$BRANCH_NUMBER" ]` check.
|
|
475
|
+
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
|
476
|
+
if ($DryRun -and $hasGit) {
|
|
477
|
+
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
|
478
|
+
} elseif ($DryRun) {
|
|
479
|
+
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
480
|
+
} elseif ($hasGit) {
|
|
481
|
+
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
|
482
|
+
} else {
|
|
483
|
+
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
$featureNum = ('{0:000}' -f $Number)
|
|
488
|
+
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
$maxBranchLength = 244
|
|
493
|
+
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
|
494
|
+
$originalBranchName = $branchName
|
|
495
|
+
$truncatedSuffix = $branchSuffix
|
|
496
|
+
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
|
|
497
|
+
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
|
|
498
|
+
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
|
|
499
|
+
}
|
|
500
|
+
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
|
501
|
+
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
|
505
|
+
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
|
506
|
+
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (-not $DryRun) {
|
|
510
|
+
if ($hasGit) {
|
|
511
|
+
$branchCreated = $false
|
|
512
|
+
$branchCreateError = ''
|
|
513
|
+
try {
|
|
514
|
+
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
|
515
|
+
if ($LASTEXITCODE -eq 0) {
|
|
516
|
+
$branchCreated = $true
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
$branchCreateError = $_.Exception.Message
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (-not $branchCreated) {
|
|
523
|
+
$currentBranch = ''
|
|
524
|
+
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
|
525
|
+
$existingBranch = git branch --list $branchName 2>$null
|
|
526
|
+
if ($existingBranch) {
|
|
527
|
+
if ($AllowExistingBranch) {
|
|
528
|
+
if ($currentBranch -eq $branchName) {
|
|
529
|
+
# Already on the target branch
|
|
530
|
+
} else {
|
|
531
|
+
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
|
532
|
+
if ($LASTEXITCODE -ne 0) {
|
|
533
|
+
if ($switchBranchError) {
|
|
534
|
+
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
|
535
|
+
} else {
|
|
536
|
+
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
|
537
|
+
}
|
|
538
|
+
exit 1
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} elseif ($Timestamp) {
|
|
542
|
+
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
|
543
|
+
exit 1
|
|
544
|
+
} else {
|
|
545
|
+
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
|
546
|
+
exit 1
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
if ($branchCreateError) {
|
|
550
|
+
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
|
551
|
+
} else {
|
|
552
|
+
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
|
553
|
+
}
|
|
554
|
+
exit 1
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} else {
|
|
558
|
+
if ($Json) {
|
|
559
|
+
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
|
|
560
|
+
} else {
|
|
561
|
+
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
$env:SPECIFY_FEATURE = $branchName
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# Build the PowerShell-idiomatic persist hint, mirroring the core
|
|
569
|
+
# create-new-feature.ps1 twin (and the bash/python twins of this script), which
|
|
570
|
+
# all emit "# To persist in your shell: ...".
|
|
571
|
+
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
|
572
|
+
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
|
573
|
+
|
|
574
|
+
if ($Json) {
|
|
575
|
+
$obj = [PSCustomObject]@{
|
|
576
|
+
BRANCH_NAME = $branchName
|
|
577
|
+
FEATURE_NUM = $featureNum
|
|
578
|
+
}
|
|
579
|
+
# $hasGit is computed for branch-creation logic only; it is intentionally not
|
|
580
|
+
# emitted so this output contract matches the bash twin: BRANCH_NAME and
|
|
581
|
+
# FEATURE_NUM, plus DRY_RUN (added just below) on dry runs.
|
|
582
|
+
if ($DryRun) {
|
|
583
|
+
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
|
584
|
+
}
|
|
585
|
+
$obj | ConvertTo-Json -Compress
|
|
586
|
+
} else {
|
|
587
|
+
Write-Output "BRANCH_NAME: $branchName"
|
|
588
|
+
Write-Output "FEATURE_NUM: $featureNum"
|
|
589
|
+
if (-not $DryRun) {
|
|
590
|
+
Write-Output "# To persist in your shell: $featureAssignment"
|
|
591
|
+
}
|
|
592
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
# Git-specific common functions for the git extension.
|
|
3
|
+
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
|
|
4
|
+
# branch validation and detection logic.
|
|
5
|
+
|
|
6
|
+
function Test-HasGit {
|
|
7
|
+
param([string]$RepoRoot = (Get-Location))
|
|
8
|
+
try {
|
|
9
|
+
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
|
|
10
|
+
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
|
|
11
|
+
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
12
|
+
return ($LASTEXITCODE -eq 0)
|
|
13
|
+
} catch {
|
|
14
|
+
return $false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Get-SpecKitEffectiveBranchName {
|
|
19
|
+
param([string]$Branch)
|
|
20
|
+
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
|
21
|
+
return $Matches[2]
|
|
22
|
+
}
|
|
23
|
+
return $Branch
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Test-FeatureBranch {
|
|
27
|
+
param(
|
|
28
|
+
[string]$Branch,
|
|
29
|
+
[bool]$HasGit = $true
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# For non-git repos, we can't enforce branch naming but still provide output
|
|
33
|
+
if (-not $HasGit) {
|
|
34
|
+
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
|
35
|
+
return $true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
$raw = $Branch
|
|
39
|
+
$Branch = Get-SpecKitEffectiveBranchName $raw
|
|
40
|
+
$featureSegment = ($Branch -split '/')[-1]
|
|
41
|
+
|
|
42
|
+
# Accept sequential prefix (3+ digits), at the start or after namespace
|
|
43
|
+
# segments, but exclude malformed timestamps.
|
|
44
|
+
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
|
|
45
|
+
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
|
46
|
+
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
|
|
47
|
+
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
|
48
|
+
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
|
|
49
|
+
return $false
|
|
50
|
+
}
|
|
51
|
+
return $true
|
|
52
|
+
}
|