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,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
|
+
}
|