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.
Files changed (75) hide show
  1. package/.cursor/rules/agents-workflow.mdc +50 -0
  2. package/.cursor/rules/ai-ui-copy.mdc +12 -0
  3. package/.cursor/rules/duaer-spec.mdc +33 -0
  4. package/.cursor/skills/duaer-analyze/SKILL.md +259 -0
  5. package/.cursor/skills/duaer-checklist/SKILL.md +383 -0
  6. package/.cursor/skills/duaer-clarify/SKILL.md +291 -0
  7. package/.cursor/skills/duaer-constitution/SKILL.md +177 -0
  8. package/.cursor/skills/duaer-converge/SKILL.md +277 -0
  9. package/.cursor/skills/duaer-git-commit/SKILL.md +68 -0
  10. package/.cursor/skills/duaer-git-feature/SKILL.md +94 -0
  11. package/.cursor/skills/duaer-git-initialize/SKILL.md +54 -0
  12. package/.cursor/skills/duaer-git-remote/SKILL.md +50 -0
  13. package/.cursor/skills/duaer-git-validate/SKILL.md +54 -0
  14. package/.cursor/skills/duaer-implement/SKILL.md +226 -0
  15. package/.cursor/skills/duaer-plan/SKILL.md +166 -0
  16. package/.cursor/skills/duaer-specify/SKILL.md +345 -0
  17. package/.cursor/skills/duaer-tasks/SKILL.md +214 -0
  18. package/.cursor/skills/duaer-taskstoissues/SKILL.md +109 -0
  19. package/.duaer/extensions/.registry +23 -0
  20. package/.duaer/extensions/git/README.md +119 -0
  21. package/.duaer/extensions/git/commands/duaer.git.commit.md +63 -0
  22. package/.duaer/extensions/git/commands/duaer.git.feature.md +82 -0
  23. package/.duaer/extensions/git/commands/duaer.git.initialize.md +49 -0
  24. package/.duaer/extensions/git/commands/duaer.git.remote.md +45 -0
  25. package/.duaer/extensions/git/commands/duaer.git.validate.md +49 -0
  26. package/.duaer/extensions/git/config-template.yml +79 -0
  27. package/.duaer/extensions/git/extension.yml +142 -0
  28. package/.duaer/extensions/git/git-config.yml +79 -0
  29. package/.duaer/extensions/git/scripts/bash/auto-commit.sh +211 -0
  30. package/.duaer/extensions/git/scripts/bash/create-new-feature-branch.sh +626 -0
  31. package/.duaer/extensions/git/scripts/bash/git-common.sh +56 -0
  32. package/.duaer/extensions/git/scripts/bash/initialize-repo.sh +54 -0
  33. package/.duaer/extensions/git/scripts/powershell/auto-commit.ps1 +230 -0
  34. package/.duaer/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +592 -0
  35. package/.duaer/extensions/git/scripts/powershell/git-common.ps1 +52 -0
  36. package/.duaer/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
  37. package/.duaer/extensions/git/scripts/python/auto_commit.py +195 -0
  38. package/.duaer/extensions/git/scripts/python/create_new_feature_branch.py +634 -0
  39. package/.duaer/extensions/git/scripts/python/git_common.py +81 -0
  40. package/.duaer/extensions/git/scripts/python/initialize_repo.py +89 -0
  41. package/.duaer/extensions.yml +167 -0
  42. package/.duaer/init-options.json +9 -0
  43. package/.duaer/integration.json +15 -0
  44. package/.duaer/integrations/cursor-agent.manifest.json +17 -0
  45. package/.duaer/integrations/duaer.manifest.json +19 -0
  46. package/.duaer/memory/.constitution-template.json +4 -0
  47. package/.duaer/memory/constitution.md +37 -0
  48. package/.duaer/memory/project-context.md +24 -0
  49. package/.duaer/memory/testing.md +14 -0
  50. package/.duaer/scripts/bash/check-prerequisites.sh +243 -0
  51. package/.duaer/scripts/bash/common.sh +926 -0
  52. package/.duaer/scripts/bash/create-new-feature.sh +407 -0
  53. package/.duaer/scripts/bash/resolve-template.sh +57 -0
  54. package/.duaer/scripts/bash/setup-plan.sh +85 -0
  55. package/.duaer/scripts/bash/setup-tasks.sh +94 -0
  56. package/.duaer/templates/checklist-template.md +45 -0
  57. package/.duaer/templates/constitution-template.md +50 -0
  58. package/.duaer/templates/plan-template.md +113 -0
  59. package/.duaer/templates/spec-template.md +131 -0
  60. package/.duaer/templates/tasks-template.md +252 -0
  61. package/.duaer/workflows/duaer/workflow.yml +78 -0
  62. package/.duaer/workflows/workflow-registry.json +13 -0
  63. package/ADOPT.md +75 -0
  64. package/AGENTS.md +290 -0
  65. package/CHANGELOG.md +28 -0
  66. package/DUADER.md +57 -0
  67. package/LICENSE +21 -0
  68. package/README.md +74 -0
  69. package/bin/duaer.mjs +298 -0
  70. package/docs/agent/README.md +12 -0
  71. package/docs/agent/change-checklist.md +130 -0
  72. package/docs/agent/e2e-test-plan.md +33 -0
  73. package/docs/agent/workflow.md +127 -0
  74. package/docs/baseline.md +10 -0
  75. package/package.json +49 -0
@@ -0,0 +1,626 @@
1
+ #!/usr/bin/env bash
2
+ # Git extension: create-new-feature-branch.sh
3
+ # Creates a git feature branch only. The feature directory and spec file
4
+ # are created by the core create-new-feature.sh script.
5
+ # Sources common.sh from the project's installed scripts, falling back to
6
+ # git-common.sh for minimal git helpers.
7
+
8
+ set -e
9
+
10
+ JSON_MODE=false
11
+ DRY_RUN=false
12
+ ALLOW_EXISTING=false
13
+ SHORT_NAME=""
14
+ BRANCH_NUMBER=""
15
+ USE_TIMESTAMP=false
16
+ ARGS=()
17
+ i=1
18
+ while [ $i -le $# ]; do
19
+ arg="${!i}"
20
+ case "$arg" in
21
+ --json)
22
+ JSON_MODE=true
23
+ ;;
24
+ --dry-run)
25
+ DRY_RUN=true
26
+ ;;
27
+ --allow-existing-branch)
28
+ ALLOW_EXISTING=true
29
+ ;;
30
+ --short-name)
31
+ if [ $((i + 1)) -gt $# ]; then
32
+ echo 'Error: --short-name requires a value' >&2
33
+ exit 1
34
+ fi
35
+ i=$((i + 1))
36
+ next_arg="${!i}"
37
+ if [[ "$next_arg" == --* ]]; then
38
+ echo 'Error: --short-name requires a value' >&2
39
+ exit 1
40
+ fi
41
+ SHORT_NAME="$next_arg"
42
+ ;;
43
+ --number)
44
+ if [ $((i + 1)) -gt $# ]; then
45
+ echo 'Error: --number requires a value' >&2
46
+ exit 1
47
+ fi
48
+ i=$((i + 1))
49
+ next_arg="${!i}"
50
+ if [[ "$next_arg" == --* ]]; then
51
+ echo 'Error: --number requires a value' >&2
52
+ exit 1
53
+ fi
54
+ BRANCH_NUMBER="$next_arg"
55
+ if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
56
+ echo 'Error: --number must be a non-negative integer' >&2
57
+ exit 1
58
+ fi
59
+ ;;
60
+ --timestamp)
61
+ USE_TIMESTAMP=true
62
+ ;;
63
+ --help|-h)
64
+ echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
65
+ echo ""
66
+ echo "Options:"
67
+ echo " --json Output in JSON format"
68
+ echo " --dry-run Compute branch name without creating the branch"
69
+ echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
70
+ echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
71
+ echo " --number N Specify branch number manually (overrides auto-detection)"
72
+ echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
73
+ echo " --help, -h Show this help message"
74
+ echo ""
75
+ echo "Environment variables:"
76
+ echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
77
+ echo ""
78
+ echo "Configuration:"
79
+ echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
80
+ echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
81
+ echo ""
82
+ echo "Examples:"
83
+ echo " $0 'Add user authentication system' --short-name 'user-auth'"
84
+ echo " $0 'Implement OAuth2 integration for API' --number 5"
85
+ echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
86
+ echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
87
+ exit 0
88
+ ;;
89
+ *)
90
+ ARGS+=("$arg")
91
+ ;;
92
+ esac
93
+ i=$((i + 1))
94
+ done
95
+
96
+ FEATURE_DESCRIPTION="${ARGS[*]}"
97
+ if [ -z "$FEATURE_DESCRIPTION" ]; then
98
+ echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
99
+ exit 1
100
+ fi
101
+
102
+ # Trim whitespace and validate description is not empty
103
+ FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
104
+ if [ -z "$FEATURE_DESCRIPTION" ]; then
105
+ echo "Error: Feature description cannot be empty or contain only whitespace" >&2
106
+ exit 1
107
+ fi
108
+
109
+ # Function to get highest number from specs directory
110
+ get_highest_from_specs() {
111
+ local specs_dir="$1"
112
+ local highest=0
113
+
114
+ if [ -d "$specs_dir" ]; then
115
+ for dir in "$specs_dir"/*; do
116
+ [ -d "$dir" ] || continue
117
+ dirname=$(basename "$dir")
118
+ # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
119
+ if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
120
+ number=$(echo "$dirname" | grep -Eo '^[0-9]+')
121
+ number=$((10#$number))
122
+ if [ "$number" -gt "$highest" ]; then
123
+ highest=$number
124
+ fi
125
+ fi
126
+ done
127
+ fi
128
+
129
+ echo "$highest"
130
+ }
131
+
132
+ # Function to get highest number from git branches
133
+ get_highest_from_branches() {
134
+ local scope_prefix="${1:-}"
135
+ git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
136
+ }
137
+
138
+ # Extract the highest sequential feature number from a list of ref names (one per line).
139
+ _extract_highest_number() {
140
+ local scope_prefix="${1:-}"
141
+ local highest=0
142
+ while IFS= read -r name; do
143
+ [ -z "$name" ] && continue
144
+ if [ -n "$scope_prefix" ]; then
145
+ case "$name" in
146
+ "$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
147
+ *) continue ;;
148
+ esac
149
+ fi
150
+ name="${name##*/}"
151
+ if echo "$name" | grep -Eq '^[0-9]{3,}-' \
152
+ && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
153
+ && ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
154
+ && ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
155
+ number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
156
+ number=$((10#$number))
157
+ if [ "$number" -gt "$highest" ]; then
158
+ highest=$number
159
+ fi
160
+ fi
161
+ done
162
+ echo "$highest"
163
+ }
164
+
165
+ # Function to get highest number from remote branches without fetching (side-effect-free)
166
+ get_highest_from_remote_refs() {
167
+ local scope_prefix="${1:-}"
168
+ local highest=0
169
+
170
+ for remote in $(git remote 2>/dev/null); do
171
+ local remote_highest
172
+ remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
173
+ if [ "$remote_highest" -gt "$highest" ]; then
174
+ highest=$remote_highest
175
+ fi
176
+ done
177
+
178
+ echo "$highest"
179
+ }
180
+
181
+ # Function to check existing branches and return next available number.
182
+ check_existing_branches() {
183
+ local specs_dir="$1"
184
+ local skip_fetch="${2:-false}"
185
+ local scope_prefix="${3:-}"
186
+
187
+ if [ "$skip_fetch" = true ]; then
188
+ local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
189
+ local highest_branch=$(get_highest_from_branches "$scope_prefix")
190
+ if [ "$highest_remote" -gt "$highest_branch" ]; then
191
+ highest_branch=$highest_remote
192
+ fi
193
+ else
194
+ git fetch --all --prune >/dev/null 2>&1 || true
195
+ local highest_branch=$(get_highest_from_branches "$scope_prefix")
196
+ fi
197
+
198
+ local highest_spec=$(get_highest_from_specs "$specs_dir")
199
+
200
+ local max_num=$highest_branch
201
+ if [ "$highest_spec" -gt "$max_num" ]; then
202
+ max_num=$highest_spec
203
+ fi
204
+
205
+ echo $((max_num + 1))
206
+ }
207
+
208
+ # Function to clean and format a branch name
209
+ clean_branch_name() {
210
+ local name="$1"
211
+ echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
212
+ }
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
216
+ #
217
+ # Search locations in priority order:
218
+ # 1. .duaer/scripts/bash/common.sh under the project root (installed project)
219
+ # 2. scripts/bash/common.sh under the project root (source checkout fallback)
220
+ # 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
221
+ # ---------------------------------------------------------------------------
222
+ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
223
+
224
+ # Find project root by walking up from the script location
225
+ _find_project_root() {
226
+ local dir="$1"
227
+ while [ "$dir" != "/" ]; do
228
+ if [ -d "$dir/.duaer" ] || [ -d "$dir/.git" ]; then
229
+ echo "$dir"
230
+ return 0
231
+ fi
232
+ dir="$(dirname "$dir")"
233
+ done
234
+ return 1
235
+ }
236
+
237
+ _common_loaded=false
238
+ _PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
239
+
240
+ if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.duaer/scripts/bash/common.sh" ]; then
241
+ source "$_PROJECT_ROOT/.duaer/scripts/bash/common.sh"
242
+ _common_loaded=true
243
+ elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
244
+ source "$_PROJECT_ROOT/scripts/bash/common.sh"
245
+ _common_loaded=true
246
+ elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
247
+ source "$SCRIPT_DIR/git-common.sh"
248
+ _common_loaded=true
249
+ fi
250
+
251
+ if [ "$_common_loaded" != "true" ]; then
252
+ echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
253
+ exit 1
254
+ fi
255
+
256
+ # SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
257
+ # minimal git-common.sh was loaded, or an older core common.sh without the
258
+ # resolver was loaded, refuse rather than silently falling back to the wrong root.
259
+ if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
260
+ echo "Error: SPECIFY_INIT_DIR requires updated Duaer core scripts (common.sh with resolve_specify_init_dir), which were not found." >&2
261
+ exit 1
262
+ fi
263
+
264
+ # Resolve repository root. When the core scripts are present, get_repo_root
265
+ # honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
266
+ # CI use) and hard-fails on an invalid value with no silent fallback.
267
+ if type get_repo_root >/dev/null 2>&1; then
268
+ REPO_ROOT=$(get_repo_root) || exit 1
269
+ elif git rev-parse --show-toplevel >/dev/null 2>&1; then
270
+ REPO_ROOT=$(git rev-parse --show-toplevel)
271
+ elif [ -n "$_PROJECT_ROOT" ]; then
272
+ REPO_ROOT="$_PROJECT_ROOT"
273
+ else
274
+ echo "Error: Could not determine repository root." >&2
275
+ exit 1
276
+ fi
277
+
278
+ # Check if git is available at this repo root
279
+ if type has_git >/dev/null 2>&1; then
280
+ if has_git "$REPO_ROOT"; then
281
+ HAS_GIT=true
282
+ else
283
+ HAS_GIT=false
284
+ fi
285
+ elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
286
+ HAS_GIT=true
287
+ else
288
+ HAS_GIT=false
289
+ fi
290
+
291
+ cd "$REPO_ROOT"
292
+
293
+ SPECS_DIR="$REPO_ROOT/specs"
294
+ CONFIG_FILE="$REPO_ROOT/.duaer/extensions/git/git-config.yml"
295
+
296
+ read_git_config_value() {
297
+ local key="$1"
298
+ [ -f "$CONFIG_FILE" ] || return 0
299
+ grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
300
+ | head -n 1 \
301
+ | sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
302
+ | sed -E 's/[[:space:]]+#.*$//' \
303
+ | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
304
+ | sed -E 's/^"//; s/"$//' \
305
+ | sed -E "s/^'//; s/'$//"
306
+ }
307
+
308
+ branch_token() {
309
+ local value="$1"
310
+ local fallback="$2"
311
+ local cleaned
312
+ cleaned=$(clean_branch_name "$value")
313
+ if [ -n "$cleaned" ]; then
314
+ printf '%s\n' "$cleaned"
315
+ else
316
+ printf '%s\n' "$fallback"
317
+ fi
318
+ }
319
+
320
+ get_author_token() {
321
+ local author=""
322
+ if command -v git >/dev/null 2>&1; then
323
+ author=$(git config user.name 2>/dev/null || true)
324
+ if [ -z "$author" ]; then
325
+ author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
326
+ fi
327
+ fi
328
+ if [ -z "$author" ]; then
329
+ author="${USER:-unknown}"
330
+ fi
331
+ branch_token "$author" "unknown"
332
+ }
333
+
334
+ get_app_token() {
335
+ branch_token "$(basename "$REPO_ROOT")" "app"
336
+ }
337
+
338
+ resolve_branch_template() {
339
+ local template
340
+ local prefix
341
+ template=$(read_git_config_value "branch_template")
342
+ if [ -n "$template" ]; then
343
+ printf '%s\n' "$template"
344
+ return
345
+ fi
346
+
347
+ prefix=$(read_git_config_value "branch_prefix")
348
+ if [ -z "$prefix" ]; then
349
+ printf '%s\n' ""
350
+ return
351
+ fi
352
+ case "$prefix" in
353
+ */) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
354
+ *) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
355
+ esac
356
+ }
357
+
358
+ render_branch_template() {
359
+ local template="$1"
360
+ local feature_num="$2"
361
+ local branch_suffix="$3"
362
+ local rendered="$template"
363
+ rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
364
+ rendered=${rendered//\{app\}/$APP_TOKEN}
365
+ rendered=${rendered//\{number\}/$feature_num}
366
+ rendered=${rendered//\{slug\}/$branch_suffix}
367
+ printf '%s\n' "$rendered"
368
+ }
369
+
370
+ validate_branch_template() {
371
+ local template="$1"
372
+ [ -n "$template" ] || return 0
373
+ local feature_segment
374
+ feature_segment="${template##*/}"
375
+ case "$template" in
376
+ *"{number}"*) ;;
377
+ *)
378
+ >&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
379
+ exit 1
380
+ ;;
381
+ esac
382
+ case "$template" in
383
+ *"{slug}"*"{number}"*)
384
+ >&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
385
+ exit 1
386
+ ;;
387
+ esac
388
+ case "$feature_segment" in
389
+ "{number}-"*) ;;
390
+ *)
391
+ >&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
392
+ exit 1
393
+ ;;
394
+ esac
395
+ }
396
+
397
+ build_branch_name() {
398
+ local feature_num="$1"
399
+ local branch_suffix="$2"
400
+ if [ -n "$BRANCH_TEMPLATE" ]; then
401
+ render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
402
+ else
403
+ printf '%s-%s\n' "$feature_num" "$branch_suffix"
404
+ fi
405
+ }
406
+
407
+ branch_scope_prefix() {
408
+ local template="$1"
409
+ local prefix="$template"
410
+ [ -n "$prefix" ] || return 0
411
+ case "$prefix" in
412
+ *"{number}"*) prefix="${prefix%%\{number\}*}" ;;
413
+ *"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
414
+ *) return 0 ;;
415
+ esac
416
+ render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
417
+ }
418
+
419
+ extract_feature_num_from_branch() {
420
+ local branch_name="$1"
421
+ local feature_segment="${branch_name##*/}"
422
+ local match
423
+ match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
424
+ if [ -n "$match" ]; then
425
+ printf '%s\n' "$match" | sed -E 's/-$//'
426
+ return
427
+ fi
428
+ match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
429
+ if [ -n "$match" ]; then
430
+ printf '%s\n' "$match" | sed -E 's/-$//'
431
+ return
432
+ fi
433
+ printf '%s\n' "$branch_name"
434
+ }
435
+
436
+ AUTHOR_TOKEN=$(get_author_token)
437
+ APP_TOKEN=$(get_app_token)
438
+ BRANCH_TEMPLATE=$(resolve_branch_template)
439
+ validate_branch_template "$BRANCH_TEMPLATE"
440
+
441
+ # Function to generate branch name with stop word filtering
442
+ generate_branch_name() {
443
+ local description="$1"
444
+
445
+ local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
446
+
447
+ local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
448
+
449
+ local meaningful_words=()
450
+ for word in $clean_name; do
451
+ [ -z "$word" ] && continue
452
+ if ! echo "$word" | grep -qiE "$stop_words"; then
453
+ if [ ${#word} -ge 3 ]; then
454
+ meaningful_words+=("$word")
455
+ # Uppercase via tr (portable) rather than bash's 4+ "^^" case
456
+ # expansion, which breaks on macOS's default bash 3.2 (bad substitution).
457
+ elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then
458
+ meaningful_words+=("$word")
459
+ fi
460
+ fi
461
+ done
462
+
463
+ if [ ${#meaningful_words[@]} -gt 0 ]; then
464
+ local max_words=3
465
+ if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
466
+
467
+ local result=""
468
+ local count=0
469
+ for word in "${meaningful_words[@]}"; do
470
+ if [ $count -ge $max_words ]; then break; fi
471
+ if [ -n "$result" ]; then result="$result-"; fi
472
+ result="$result$word"
473
+ count=$((count + 1))
474
+ done
475
+ echo "$result"
476
+ else
477
+ local cleaned=$(clean_branch_name "$description")
478
+ echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
479
+ fi
480
+ }
481
+
482
+ # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
483
+ if [ -n "${GIT_BRANCH_NAME:-}" ]; then
484
+ BRANCH_NAME="$GIT_BRANCH_NAME"
485
+ FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
486
+ BRANCH_SUFFIX="$BRANCH_NAME"
487
+ else
488
+ # Generate branch name
489
+ if [ -n "$SHORT_NAME" ]; then
490
+ BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
491
+ else
492
+ BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
493
+ fi
494
+
495
+ # Warn if --number and --timestamp are both specified
496
+ if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
497
+ >&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
498
+ BRANCH_NUMBER=""
499
+ fi
500
+
501
+ # Determine branch prefix
502
+ if [ "$USE_TIMESTAMP" = true ]; then
503
+ FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
504
+ BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
505
+ else
506
+ BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
507
+ if [ -z "$BRANCH_NUMBER" ]; then
508
+ if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
509
+ BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
510
+ elif [ "$DRY_RUN" = true ]; then
511
+ HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
512
+ BRANCH_NUMBER=$((HIGHEST + 1))
513
+ elif [ "$HAS_GIT" = true ]; then
514
+ BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
515
+ else
516
+ HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
517
+ BRANCH_NUMBER=$((HIGHEST + 1))
518
+ fi
519
+ fi
520
+
521
+ FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
522
+ BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
523
+ fi
524
+ fi
525
+
526
+ # GitHub enforces a 244-byte limit on branch names
527
+ MAX_BRANCH_LENGTH=244
528
+ _byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
529
+ BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
530
+ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
531
+ >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
532
+ exit 1
533
+ elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
534
+ ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
535
+ TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
536
+ while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
537
+ TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
538
+ TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
539
+ BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
540
+ done
541
+ if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
542
+ >&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
543
+ exit 1
544
+ fi
545
+
546
+ >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
547
+ ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
548
+ TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
549
+ >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
550
+ >&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
551
+ fi
552
+
553
+ if [ "$DRY_RUN" != true ]; then
554
+ if [ "$HAS_GIT" = true ]; then
555
+ branch_create_error=""
556
+ if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
557
+ current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
558
+ if git branch --list "$BRANCH_NAME" | grep -q .; then
559
+ if [ "$ALLOW_EXISTING" = true ]; then
560
+ if [ "$current_branch" = "$BRANCH_NAME" ]; then
561
+ :
562
+ elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
563
+ >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
564
+ if [ -n "$switch_branch_error" ]; then
565
+ >&2 printf '%s\n' "$switch_branch_error"
566
+ fi
567
+ exit 1
568
+ fi
569
+ elif [ "$USE_TIMESTAMP" = true ]; then
570
+ >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
571
+ exit 1
572
+ else
573
+ >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
574
+ exit 1
575
+ fi
576
+ else
577
+ >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
578
+ if [ -n "$branch_create_error" ]; then
579
+ >&2 printf '%s\n' "$branch_create_error"
580
+ else
581
+ >&2 echo "Please check your git configuration and try again."
582
+ fi
583
+ exit 1
584
+ fi
585
+ fi
586
+ else
587
+ >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
588
+ fi
589
+
590
+ printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
591
+ fi
592
+
593
+ if $JSON_MODE; then
594
+ if command -v jq >/dev/null 2>&1; then
595
+ if [ "$DRY_RUN" = true ]; then
596
+ jq -cn \
597
+ --arg branch_name "$BRANCH_NAME" \
598
+ --arg feature_num "$FEATURE_NUM" \
599
+ '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
600
+ else
601
+ jq -cn \
602
+ --arg branch_name "$BRANCH_NAME" \
603
+ --arg feature_num "$FEATURE_NUM" \
604
+ '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
605
+ fi
606
+ else
607
+ if type json_escape >/dev/null 2>&1; then
608
+ _je_branch=$(json_escape "$BRANCH_NAME")
609
+ _je_num=$(json_escape "$FEATURE_NUM")
610
+ else
611
+ _je_branch="$BRANCH_NAME"
612
+ _je_num="$FEATURE_NUM"
613
+ fi
614
+ if [ "$DRY_RUN" = true ]; then
615
+ printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
616
+ else
617
+ printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
618
+ fi
619
+ fi
620
+ else
621
+ echo "BRANCH_NAME: $BRANCH_NAME"
622
+ echo "FEATURE_NUM: $FEATURE_NUM"
623
+ if [ "$DRY_RUN" != true ]; then
624
+ printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
625
+ fi
626
+ fi
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # Git-specific common functions for the git extension.
3
+ # Extracted from scripts/bash/common.sh — contains only git-specific
4
+ # branch validation and detection logic.
5
+
6
+ # Check if we have git available at the repo root
7
+ has_git() {
8
+ local repo_root="${1:-$(pwd)}"
9
+ { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
10
+ command -v git >/dev/null 2>&1 && \
11
+ git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
12
+ }
13
+
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
+
25
+ # Validate that a branch name matches the expected feature branch pattern.
26
+ # Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
27
+ # either at the start of the branch or after path-style namespace prefixes.
28
+ # Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
29
+ check_feature_branch() {
30
+ local raw="$1"
31
+ local has_git_repo="$2"
32
+
33
+ # For non-git repos, we can't enforce branch naming but still provide output
34
+ if [[ "$has_git_repo" != "true" ]]; then
35
+ echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
36
+ return 0
37
+ fi
38
+
39
+ local branch
40
+ branch=$(spec_kit_effective_branch_name "$raw")
41
+ local feature_segment="${branch##*/}"
42
+
43
+ # Accept sequential prefix (3+ digits) but exclude malformed timestamps
44
+ # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
45
+ local is_sequential=false
46
+ if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
47
+ is_sequential=true
48
+ fi
49
+ if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
50
+ echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
51
+ echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
52
+ return 1
53
+ fi
54
+
55
+ return 0
56
+ }