Skip to content

Commit 2972dec

Browse files
authored
feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117)
* feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) - Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1 for exact branch naming (bypasses all prefix/suffix generation) - Fix --force flag for 'specify init <dir>' into existing directories - Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip, commands registered) - Add TestFeatureDirectoryResolution tests (env var, feature.json, priority, branch fallback) - Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md * fix: remove unused Tuple import (ruff F401) * fix: address Copilot review feedback (#2117) - Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic numeric prefix in both bash and PowerShell - Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte truncation logic works correctly - Add 244-byte length check for GIT_BRANCH_NAME in PowerShell - Use existing_items for non-empty dir warning with --force - Skip git extension install if already installed (idempotent --force) - Wrap PowerShell feature.json parsing in try/catch for malformed JSON - Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir' - Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template * fix: address second round of Copilot review feedback (#2117) - Guard shutil.rmtree on init failure: skip cleanup when --force merged into a pre-existing directory (prevents data loss) - Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation - Fix malformed numbered list in specify.md (restore missing step 1) - Add claude_skills.exists() assert before iterdir() in test * fix: use UTF-8 byte count for 244-byte branch name limit (#2117) - Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR} - PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead of .Length (UTF-16 code units) * fix: address third round of review feedback (#2117) - Update --dry-run help text in bash and PowerShell (branch name only) - Fix specify.md JSON example: use concrete path, not literal variable - Add TestForceExistingDirectory tests (merge + error without --force) - Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json) * fix: normalize relative paths and fix Test-HasGit compat (#2117) - Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json relative paths to absolute under repo root - PowerShell common.ps1: same normalization using IsPathRooted + Join-Path - PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot for compatibility with core common.ps1 (no param) and git-common.ps1 (optional param with default) * test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117) - TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix, timestamp prefix, overlong rejection, dry-run) - TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential prefix, timestamp prefix, overlong rejection) - Tests use extension scripts (not core) via new ext_git_repo and ext_ps_git_repo fixtures * fix: restore git init during specify init + review fixes (#2117) - Restore is_git_repo() and init_git_repo() functions removed in stage 2 - specify init now runs git init AND installs git extension (not just extension install alone) - Add is_dir() guard for non-here path to prevent uncontrolled error when target exists but is a file - Add python3 JSON fallback in common.sh for multi-line feature.json (grep pipeline fails on pretty-printed JSON without jq) * fix: use init_git_repo error_msg in failure output (#2117) * fix: ensure_executable_scripts also covers .specify/extensions/ (#2117) Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh) may lack execute bits after install. Scan both .specify/scripts/ and .specify/extensions/ for permission fixing. * fix: move chmod after extension install + sanitize error_msg (#2117) - ensure_executable_scripts() now runs after git extension install so extension .sh files get execute bits in the same init run - Sanitize init_git_repo error_msg to single line (replace newlines, truncate to 120 chars) to prevent garbled StepTracker output * fix: use tracker.error for git init/extension failures (#2117) Git init failure and extension install failure were reported as tracker.complete (showing green) even on error. Now track a git_has_error flag and call tracker.error when any step fails, so the UI correctly reflects the failure state. * fix: sanitize ext_err in git step tracker for consistent rendering (#2117)
1 parent 838bd0f commit 2972dec

File tree

10 files changed

+801
-253
lines changed

10 files changed

+801
-253
lines changed

extensions/git/commands/speckit.git.feature.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering"
44

55
# Create Feature Branch
66

7-
Create a new feature branch for the given specification.
7+
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
88

99
## User Input
1010

@@ -14,10 +14,17 @@ $ARGUMENTS
1414

1515
You **MUST** consider the user input before proceeding (if not empty).
1616

17+
## Environment Variable Override
18+
19+
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
20+
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
21+
- `--short-name`, `--number`, and `--timestamp` flags are ignored
22+
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
23+
1724
## Prerequisites
1825

1926
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
20-
- If Git is not available, warn the user and skip branch creation (spec directory will still be created)
27+
- If Git is not available, warn the user and skip branch creation
2128

2229
## Branch Numbering Mode
2330

@@ -45,22 +52,16 @@ Run the appropriate script based on your platform:
4552
- Do NOT pass `--number` — the script determines the correct next number automatically
4653
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
4754
- You must only ever run this script once per feature
48-
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
49-
50-
If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to:
51-
- **Bash**: `scripts/bash/create-new-feature.sh`
52-
- **PowerShell**: `scripts/powershell/create-new-feature.ps1`
55+
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
5356

5457
## Graceful Degradation
5558

5659
If Git is not installed or the current directory is not a Git repository:
57-
- The script will still create the spec directory under `specs/`
58-
- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation`
59-
- The workflow continues normally without branch creation
60+
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
61+
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
6062

6163
## Output
6264

6365
The script outputs JSON with:
64-
- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
65-
- `SPEC_FILE`: Path to the created spec file
66+
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
6667
- `FEATURE_NUM`: The numeric or timestamp prefix used

extensions/git/scripts/bash/create-new-feature.sh

Lines changed: 61 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,21 @@ while [ $i -le $# ]; do
6464
echo ""
6565
echo "Options:"
6666
echo " --json Output in JSON format"
67-
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
67+
echo " --dry-run Compute branch name without creating the branch"
6868
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
6969
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
7070
echo " --number N Specify branch number manually (overrides auto-detection)"
7171
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
7272
echo " --help, -h Show this help message"
7373
echo ""
74+
echo "Environment variables:"
75+
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
76+
echo ""
7477
echo "Examples:"
7578
echo " $0 'Add user authentication system' --short-name 'user-auth'"
7679
echo " $0 'Implement OAuth2 integration for API' --number 5"
7780
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
81+
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
7882
exit 0
7983
;;
8084
*)
@@ -258,9 +262,6 @@ fi
258262
cd "$REPO_ROOT"
259263

260264
SPECS_DIR="$REPO_ROOT/specs"
261-
if [ "$DRY_RUN" != true ]; then
262-
mkdir -p "$SPECS_DIR"
263-
fi
264265

265266
# Function to generate branch name with stop word filtering
266267
generate_branch_name() {
@@ -301,45 +302,67 @@ generate_branch_name() {
301302
fi
302303
}
303304

304-
# Generate branch name
305-
if [ -n "$SHORT_NAME" ]; then
306-
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
305+
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
306+
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
307+
BRANCH_NAME="$GIT_BRANCH_NAME"
308+
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
309+
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
310+
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
311+
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
312+
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
313+
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
314+
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
315+
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
316+
else
317+
FEATURE_NUM="$BRANCH_NAME"
318+
BRANCH_SUFFIX="$BRANCH_NAME"
319+
fi
307320
else
308-
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
309-
fi
321+
# Generate branch name
322+
if [ -n "$SHORT_NAME" ]; then
323+
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
324+
else
325+
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
326+
fi
310327

311-
# Warn if --number and --timestamp are both specified
312-
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
313-
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
314-
BRANCH_NUMBER=""
315-
fi
328+
# Warn if --number and --timestamp are both specified
329+
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
330+
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
331+
BRANCH_NUMBER=""
332+
fi
316333

317-
# Determine branch prefix
318-
if [ "$USE_TIMESTAMP" = true ]; then
319-
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
320-
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
321-
else
322-
if [ -z "$BRANCH_NUMBER" ]; then
323-
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
324-
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
325-
elif [ "$DRY_RUN" = true ]; then
326-
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
327-
BRANCH_NUMBER=$((HIGHEST + 1))
328-
elif [ "$HAS_GIT" = true ]; then
329-
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
330-
else
331-
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
332-
BRANCH_NUMBER=$((HIGHEST + 1))
334+
# Determine branch prefix
335+
if [ "$USE_TIMESTAMP" = true ]; then
336+
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
337+
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
338+
else
339+
if [ -z "$BRANCH_NUMBER" ]; then
340+
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
341+
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
342+
elif [ "$DRY_RUN" = true ]; then
343+
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
344+
BRANCH_NUMBER=$((HIGHEST + 1))
345+
elif [ "$HAS_GIT" = true ]; then
346+
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
347+
else
348+
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
349+
BRANCH_NUMBER=$((HIGHEST + 1))
350+
fi
333351
fi
334-
fi
335352

336-
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
337-
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
353+
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
354+
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
355+
fi
338356
fi
339357

340358
# GitHub enforces a 244-byte limit on branch names
341359
MAX_BRANCH_LENGTH=244
342-
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
360+
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
361+
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
362+
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
363+
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
364+
exit 1
365+
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
343366
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
344367
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
345368

@@ -354,9 +377,6 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
354377
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
355378
fi
356379

357-
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
358-
SPEC_FILE="$FEATURE_DIR/spec.md"
359-
360380
if [ "$DRY_RUN" != true ]; then
361381
if [ "$HAS_GIT" = true ]; then
362382
branch_create_error=""
@@ -394,22 +414,6 @@ if [ "$DRY_RUN" != true ]; then
394414
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
395415
fi
396416

397-
mkdir -p "$FEATURE_DIR"
398-
399-
if [ ! -f "$SPEC_FILE" ]; then
400-
if type resolve_template >/dev/null 2>&1; then
401-
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
402-
else
403-
TEMPLATE=""
404-
fi
405-
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
406-
cp "$TEMPLATE" "$SPEC_FILE"
407-
else
408-
echo "Warning: Spec template not found; created empty spec file" >&2
409-
touch "$SPEC_FILE"
410-
fi
411-
fi
412-
413417
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
414418
fi
415419

@@ -418,35 +422,30 @@ if $JSON_MODE; then
418422
if [ "$DRY_RUN" = true ]; then
419423
jq -cn \
420424
--arg branch_name "$BRANCH_NAME" \
421-
--arg spec_file "$SPEC_FILE" \
422425
--arg feature_num "$FEATURE_NUM" \
423-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
426+
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
424427
else
425428
jq -cn \
426429
--arg branch_name "$BRANCH_NAME" \
427-
--arg spec_file "$SPEC_FILE" \
428430
--arg feature_num "$FEATURE_NUM" \
429-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
431+
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
430432
fi
431433
else
432434
if type json_escape >/dev/null 2>&1; then
433435
_je_branch=$(json_escape "$BRANCH_NAME")
434-
_je_spec=$(json_escape "$SPEC_FILE")
435436
_je_num=$(json_escape "$FEATURE_NUM")
436437
else
437438
_je_branch="$BRANCH_NAME"
438-
_je_spec="$SPEC_FILE"
439439
_je_num="$FEATURE_NUM"
440440
fi
441441
if [ "$DRY_RUN" = true ]; then
442-
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num"
442+
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
443443
else
444-
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num"
444+
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
445445
fi
446446
fi
447447
else
448448
echo "BRANCH_NAME: $BRANCH_NAME"
449-
echo "SPEC_FILE: $SPEC_FILE"
450449
echo "FEATURE_NUM: $FEATURE_NUM"
451450
if [ "$DRY_RUN" != true ]; then
452451
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"

0 commit comments

Comments
 (0)