Skip to content

Commit b44ffc0

Browse files
authored
feat(scripts): add --dry-run flag to create-new-feature (#1998)
* feat(scripts): add --dry-run flag to create-new-feature scripts Add a --dry-run / -DryRun flag to both bash and PowerShell create-new-feature scripts that computes the next branch name, spec file path, and feature number without creating any branches, directories, or files. This enables external tools to query the next available name before running the full specify workflow. When combined with --json, the output includes a DRY_RUN field. Without --dry-run, behavior is completely unchanged. Closes #1931 Assisted-By: 🤖 Claude Code * fix(scripts): gate specs/ dir creation behind dry-run check Dry-run was unconditionally creating the root specs/ directory via mkdir -p / New-Item before the dry-run guard. This violated the documented contract of zero side effects. Also adds returncode assertion on git branch --list in tests and adds PowerShell dry-run test coverage (skipped when pwsh unavailable). Addresses review comments on #1998. Assisted-By: 🤖 Claude Code * fix: address PR review feedback - Gate `mkdir -p $SPECS_DIR` behind DRY_RUN check (bash + PowerShell) so dry-run creates zero directories - Add returncode assertion on `git branch --list` in test - Strengthen spec dir test to verify root `specs/` is not created - Add PowerShell dry-run test class (5 tests, skipped without pwsh) - Fix run_ps_script to use temp repo copy instead of project root Assisted-By: 🤖 Claude Code * fix: use git ls-remote for remote-aware dry-run numbering Dry-run now queries remote branches via `git ls-remote --heads` (read-only, no fetch) to account for remote-only branches when computing the next sequential number. This prevents dry-run from returning a number that already exists on a remote. Added test verifying dry-run sees remote-only higher-numbered branches and adjusts numbering accordingly. Assisted-By: 🤖 Claude Code * fix(scripts): deduplicate number extraction and branch scanning logic Extract shared _extract_highest_number helper (bash) and Get-HighestNumberFromNames (PowerShell) to eliminate duplicated number extraction patterns between local branch and remote ref scanning. Add SkipFetch/skip_fetch parameter to check_existing_branches / Get-NextBranchNumber so dry-run reuses the same function instead of inlining duplicate max-of-branches-and-specs logic. Assisted-By: 🤖 Claude Code * fix(tests): use isolated paths for remote branch test Move remote.git and second_clone directories under git_repo instead of git_repo.parent to prevent path collisions with parallel test workers. Assisted-By: 🤖 Claude Code * fix: address PR review feedback - Set GIT_TERMINAL_PROMPT=0 for git ls-remote calls to prevent credential prompts from blocking dry-run in automation scenarios - Add returncode assertion to test_dry_run_with_timestamp git branch --list check Assisted-By: 🤖 Claude Code
1 parent 8e14ab1 commit b44ffc0

File tree

3 files changed

+593
-147
lines changed

3 files changed

+593
-147
lines changed

scripts/bash/create-new-feature.sh

Lines changed: 118 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
set -e
44

55
JSON_MODE=false
6+
DRY_RUN=false
67
ALLOW_EXISTING=false
78
SHORT_NAME=""
89
BRANCH_NUMBER=""
@@ -15,6 +16,9 @@ while [ $i -le $# ]; do
1516
--json)
1617
JSON_MODE=true
1718
;;
19+
--dry-run)
20+
DRY_RUN=true
21+
;;
1822
--allow-existing-branch)
1923
ALLOW_EXISTING=true
2024
;;
@@ -49,10 +53,11 @@ while [ $i -le $# ]; do
4953
USE_TIMESTAMP=true
5054
;;
5155
--help|-h)
52-
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
56+
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
5357
echo ""
5458
echo "Options:"
5559
echo " --json Output in JSON format"
60+
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
5661
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
5762
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
5863
echo " --number N Specify branch number manually (overrides auto-detection)"
@@ -74,7 +79,7 @@ done
7479

7580
FEATURE_DESCRIPTION="${ARGS[*]}"
7681
if [ -z "$FEATURE_DESCRIPTION" ]; then
77-
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
82+
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
7883
exit 1
7984
fi
8085

@@ -110,39 +115,59 @@ get_highest_from_specs() {
110115

111116
# Function to get highest number from git branches
112117
get_highest_from_branches() {
118+
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
119+
}
120+
121+
# Extract the highest sequential feature number from a list of ref names (one per line).
122+
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
123+
_extract_highest_number() {
113124
local highest=0
114-
115-
# Get all branches (local and remote)
116-
branches=$(git branch -a 2>/dev/null || echo "")
117-
118-
if [ -n "$branches" ]; then
119-
while IFS= read -r branch; do
120-
# Clean branch name: remove leading markers and remote prefixes
121-
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
122-
123-
# Extract sequential feature number (>=3 digits), skip timestamp branches.
124-
if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
125-
number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0")
126-
number=$((10#$number))
127-
if [ "$number" -gt "$highest" ]; then
128-
highest=$number
129-
fi
125+
while IFS= read -r name; do
126+
[ -z "$name" ] && continue
127+
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
128+
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
129+
number=$((10#$number))
130+
if [ "$number" -gt "$highest" ]; then
131+
highest=$number
130132
fi
131-
done <<< "$branches"
132-
fi
133-
133+
fi
134+
done
134135
echo "$highest"
135136
}
136137

137-
# Function to check existing branches (local and remote) and return next available number
138+
# Function to get highest number from remote branches without fetching (side-effect-free)
139+
get_highest_from_remote_refs() {
140+
local highest=0
141+
142+
for remote in $(git remote 2>/dev/null); do
143+
local remote_highest
144+
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
145+
if [ "$remote_highest" -gt "$highest" ]; then
146+
highest=$remote_highest
147+
fi
148+
done
149+
150+
echo "$highest"
151+
}
152+
153+
# Function to check existing branches (local and remote) and return next available number.
154+
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
138155
check_existing_branches() {
139156
local specs_dir="$1"
157+
local skip_fetch="${2:-false}"
140158

141-
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
142-
git fetch --all --prune >/dev/null 2>&1 || true
143-
144-
# Get highest number from ALL branches (not just matching short name)
145-
local highest_branch=$(get_highest_from_branches)
159+
if [ "$skip_fetch" = true ]; then
160+
# Side-effect-free: query remotes via ls-remote
161+
local highest_remote=$(get_highest_from_remote_refs)
162+
local highest_branch=$(get_highest_from_branches)
163+
if [ "$highest_remote" -gt "$highest_branch" ]; then
164+
highest_branch=$highest_remote
165+
fi
166+
else
167+
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
168+
git fetch --all --prune >/dev/null 2>&1 || true
169+
local highest_branch=$(get_highest_from_branches)
170+
fi
146171

147172
# Get highest number from ALL specs (not just matching short name)
148173
local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -179,7 +204,9 @@ fi
179204
cd "$REPO_ROOT"
180205

181206
SPECS_DIR="$REPO_ROOT/specs"
182-
mkdir -p "$SPECS_DIR"
207+
if [ "$DRY_RUN" != true ]; then
208+
mkdir -p "$SPECS_DIR"
209+
fi
183210

184211
# Function to generate branch name with stop word filtering and length filtering
185212
generate_branch_name() {
@@ -251,7 +278,14 @@ if [ "$USE_TIMESTAMP" = true ]; then
251278
else
252279
# Determine branch number
253280
if [ -z "$BRANCH_NUMBER" ]; then
254-
if [ "$HAS_GIT" = true ]; then
281+
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
282+
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
283+
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
284+
elif [ "$DRY_RUN" = true ]; then
285+
# Dry-run without git: local spec dirs only
286+
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
287+
BRANCH_NUMBER=$((HIGHEST + 1))
288+
elif [ "$HAS_GIT" = true ]; then
255289
# Check existing branches on remotes
256290
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
257291
else
@@ -288,62 +322,79 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
288322
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
289323
fi
290324

291-
if [ "$HAS_GIT" = true ]; then
292-
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
293-
# Check if branch already exists
294-
if git branch --list "$BRANCH_NAME" | grep -q .; then
295-
if [ "$ALLOW_EXISTING" = true ]; then
296-
# Switch to the existing branch instead of failing
297-
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
298-
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
325+
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
326+
SPEC_FILE="$FEATURE_DIR/spec.md"
327+
328+
if [ "$DRY_RUN" != true ]; then
329+
if [ "$HAS_GIT" = true ]; then
330+
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
331+
# Check if branch already exists
332+
if git branch --list "$BRANCH_NAME" | grep -q .; then
333+
if [ "$ALLOW_EXISTING" = true ]; then
334+
# Switch to the existing branch instead of failing
335+
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
336+
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
337+
exit 1
338+
fi
339+
elif [ "$USE_TIMESTAMP" = true ]; then
340+
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
341+
exit 1
342+
else
343+
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
299344
exit 1
300345
fi
301-
elif [ "$USE_TIMESTAMP" = true ]; then
302-
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
303-
exit 1
304346
else
305-
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
347+
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
306348
exit 1
307349
fi
308-
else
309-
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
310-
exit 1
311350
fi
351+
else
352+
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
312353
fi
313-
else
314-
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
315-
fi
316354

317-
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
318-
mkdir -p "$FEATURE_DIR"
355+
mkdir -p "$FEATURE_DIR"
319356

320-
SPEC_FILE="$FEATURE_DIR/spec.md"
321-
if [ ! -f "$SPEC_FILE" ]; then
322-
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
323-
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
324-
cp "$TEMPLATE" "$SPEC_FILE"
325-
else
326-
echo "Warning: Spec template not found; created empty spec file" >&2
327-
touch "$SPEC_FILE"
357+
if [ ! -f "$SPEC_FILE" ]; then
358+
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
359+
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
360+
cp "$TEMPLATE" "$SPEC_FILE"
361+
else
362+
echo "Warning: Spec template not found; created empty spec file" >&2
363+
touch "$SPEC_FILE"
364+
fi
328365
fi
329-
fi
330366

331-
# Inform the user how to persist the feature variable in their own shell
332-
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
367+
# Inform the user how to persist the feature variable in their own shell
368+
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
369+
fi
333370

334371
if $JSON_MODE; then
335372
if command -v jq >/dev/null 2>&1; then
336-
jq -cn \
337-
--arg branch_name "$BRANCH_NAME" \
338-
--arg spec_file "$SPEC_FILE" \
339-
--arg feature_num "$FEATURE_NUM" \
340-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
373+
if [ "$DRY_RUN" = true ]; then
374+
jq -cn \
375+
--arg branch_name "$BRANCH_NAME" \
376+
--arg spec_file "$SPEC_FILE" \
377+
--arg feature_num "$FEATURE_NUM" \
378+
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
379+
else
380+
jq -cn \
381+
--arg branch_name "$BRANCH_NAME" \
382+
--arg spec_file "$SPEC_FILE" \
383+
--arg feature_num "$FEATURE_NUM" \
384+
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
385+
fi
341386
else
342-
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
387+
if [ "$DRY_RUN" = true ]; then
388+
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
389+
else
390+
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
391+
fi
343392
fi
344393
else
345394
echo "BRANCH_NAME: $BRANCH_NAME"
346395
echo "SPEC_FILE: $SPEC_FILE"
347396
echo "FEATURE_NUM: $FEATURE_NUM"
348-
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
397+
if [ "$DRY_RUN" != true ]; then
398+
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
399+
fi
349400
fi

0 commit comments

Comments
 (0)