Skip to content

Commit f827045

Browse files
committed
Tests and readme update
1 parent 854b557 commit f827045

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ The `specify` command supports the following options:
221221
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
222222
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
223223
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
224+
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |
224225

225226
### Examples
226227

@@ -292,6 +293,9 @@ specify init my-project --ai claude --ai-skills
292293
# Initialize in current directory with agent skills
293294
specify init --here --ai gemini --ai-skills
294295

296+
# Use timestamp-based branch numbering (useful for distributed teams)
297+
specify init my-project --ai claude --branch-numbering timestamp
298+
295299
# Check system requirements
296300
specify check
297301
```

tests/test_branch_numbering.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
Unit tests for branch numbering options (sequential vs timestamp).
3+
4+
Tests cover:
5+
- Persisting branch_numbering in init-options.json
6+
- Default value when branch_numbering is None
7+
- Validation of branch_numbering values
8+
"""
9+
10+
import json
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
from specify_cli import save_init_options, load_init_options
16+
17+
18+
class TestSaveBranchNumbering:
19+
"""Tests for save_init_options with branch_numbering."""
20+
21+
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
22+
opts = {"branch_numbering": "timestamp", "ai": "claude"}
23+
save_init_options(tmp_path, opts)
24+
25+
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
26+
assert saved["branch_numbering"] == "timestamp"
27+
28+
def test_save_branch_numbering_sequential(self, tmp_path: Path):
29+
opts = {"branch_numbering": "sequential", "ai": "claude"}
30+
save_init_options(tmp_path, opts)
31+
32+
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
33+
assert saved["branch_numbering"] == "sequential"
34+
35+
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
36+
branch_numbering = None
37+
opts = {"branch_numbering": branch_numbering or "sequential"}
38+
save_init_options(tmp_path, opts)
39+
40+
saved = load_init_options(tmp_path)
41+
assert saved["branch_numbering"] == "sequential"
42+
43+
44+
class TestBranchNumberingValidation:
45+
"""Tests for branch_numbering validation logic."""
46+
47+
VALID_CHOICES = {"sequential", "timestamp"}
48+
49+
def test_validation_rejects_invalid(self):
50+
assert "foobar" not in self.VALID_CHOICES
51+
52+
def test_validation_accepts_valid(self):
53+
assert "sequential" in self.VALID_CHOICES
54+
assert "timestamp" in self.VALID_CHOICES

tests/test_timestamp_branches.sh

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
#!/usr/bin/env bash
2+
# Tests for timestamp-based branch naming in create-new-feature.sh and common.sh
3+
set -euo pipefail
4+
5+
PASS_COUNT=0
6+
FAIL_COUNT=0
7+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
9+
CREATE_FEATURE="$PROJECT_ROOT/scripts/bash/create-new-feature.sh"
10+
COMMON_SH="$PROJECT_ROOT/scripts/bash/common.sh"
11+
12+
# ── Helpers ──────────────────────────────────────────────────────────────────
13+
14+
pass() { PASS_COUNT=$((PASS_COUNT + 1)); echo " PASS: $1"; }
15+
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); echo " FAIL: $1"; }
16+
17+
setup_temp_repo() {
18+
local dir
19+
dir=$(mktemp -d)
20+
git -C "$dir" init -q
21+
git -C "$dir" commit --allow-empty -m "init" -q
22+
# Copy scripts so the script can source common.sh
23+
mkdir -p "$dir/scripts/bash"
24+
cp "$CREATE_FEATURE" "$dir/scripts/bash/create-new-feature.sh"
25+
cp "$COMMON_SH" "$dir/scripts/bash/common.sh"
26+
# Create .specify dir so template resolution doesn't fail hard
27+
mkdir -p "$dir/.specify/templates"
28+
echo "$dir"
29+
}
30+
31+
setup_temp_dir_no_git() {
32+
local dir
33+
dir=$(mktemp -d)
34+
mkdir -p "$dir/scripts/bash"
35+
cp "$CREATE_FEATURE" "$dir/scripts/bash/create-new-feature.sh"
36+
cp "$COMMON_SH" "$dir/scripts/bash/common.sh"
37+
mkdir -p "$dir/.specify/templates"
38+
echo "$dir"
39+
}
40+
41+
cleanup() { rm -rf "$1"; }
42+
43+
# ── Tests ────────────────────────────────────────────────────────────────────
44+
45+
test_1_timestamp_creates_branch() {
46+
echo "Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix"
47+
local repo; repo=$(setup_temp_repo)
48+
local output
49+
output=$(cd "$repo" && bash scripts/bash/create-new-feature.sh --timestamp --short-name user-auth 'Add user auth' 2>/dev/null)
50+
local branch
51+
branch=$(echo "$output" | grep "BRANCH_NAME:" | sed 's/BRANCH_NAME: //')
52+
if [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}-user-auth$ ]]; then
53+
pass "timestamp branch matches expected pattern"
54+
else
55+
fail "expected YYYYMMDD-HHMMSS-user-auth, got: $branch"
56+
fi
57+
cleanup "$repo"
58+
}
59+
60+
test_2_sequential_default_with_existing_specs() {
61+
echo "Test 2: Sequential default with existing specs"
62+
local repo; repo=$(setup_temp_repo)
63+
mkdir -p "$repo/specs/001-first-feat" "$repo/specs/002-second-feat"
64+
local output
65+
output=$(cd "$repo" && bash scripts/bash/create-new-feature.sh --short-name new-feat 'New feature' 2>/dev/null)
66+
local branch
67+
branch=$(echo "$output" | grep "BRANCH_NAME:" | sed 's/BRANCH_NAME: //')
68+
if [[ "$branch" =~ ^[0-9]{3}-new-feat$ ]]; then
69+
pass "sequential branch uses correct numbering"
70+
else
71+
fail "expected NNN-new-feat, got: $branch"
72+
fi
73+
cleanup "$repo"
74+
}
75+
76+
test_3_number_and_timestamp_warns() {
77+
echo "Test 3: --number + --timestamp warns and uses timestamp"
78+
local repo; repo=$(setup_temp_repo)
79+
local stderr_output
80+
stderr_output=$(cd "$repo" && bash scripts/bash/create-new-feature.sh --timestamp --number 42 --short-name feat 'Feature' 2>&1 >/dev/null)
81+
if echo "$stderr_output" | grep -q "Warning.*--number.*ignored"; then
82+
pass "warning emitted for conflicting flags"
83+
else
84+
fail "expected warning about --number being ignored, got: $stderr_output"
85+
fi
86+
cleanup "$repo"
87+
}
88+
89+
test_4_json_output_keys() {
90+
echo "Test 4: JSON output contains expected keys"
91+
local repo; repo=$(setup_temp_repo)
92+
local output
93+
output=$(cd "$repo" && bash scripts/bash/create-new-feature.sh --json --timestamp --short-name api 'API feature' 2>/dev/null)
94+
local has_all_keys=true
95+
for key in BRANCH_NAME SPEC_FILE FEATURE_NUM; do
96+
if ! echo "$output" | grep -q "\"$key\""; then
97+
has_all_keys=false
98+
break
99+
fi
100+
done
101+
if $has_all_keys; then
102+
pass "JSON output has BRANCH_NAME, SPEC_FILE, FEATURE_NUM"
103+
else
104+
fail "JSON missing expected keys: $output"
105+
fi
106+
cleanup "$repo"
107+
}
108+
109+
test_5_long_name_truncation() {
110+
echo "Test 5: Long branch name is truncated to <= 244 chars"
111+
local repo; repo=$(setup_temp_repo)
112+
# Generate a very long name (300+ chars)
113+
local long_name
114+
long_name=$(python3 -c "print('a-' * 150 + 'end')")
115+
local output
116+
output=$(cd "$repo" && bash scripts/bash/create-new-feature.sh --timestamp --short-name "$long_name" 'Long feature' 2>/dev/null)
117+
local branch
118+
branch=$(echo "$output" | grep "BRANCH_NAME:" | sed 's/BRANCH_NAME: //')
119+
if [[ ${#branch} -le 244 ]] && [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
120+
pass "branch truncated to ${#branch} chars with timestamp prefix"
121+
else
122+
fail "branch length=${#branch}, expected <= 244 starting with timestamp"
123+
fi
124+
cleanup "$repo"
125+
}
126+
127+
test_6_check_feature_branch_accepts_timestamp() {
128+
echo "Test 6: check_feature_branch accepts timestamp branch"
129+
source "$COMMON_SH"
130+
if check_feature_branch "20260319-143022-feat" "true" 2>/dev/null; then
131+
pass "timestamp branch accepted"
132+
else
133+
fail "timestamp branch rejected"
134+
fi
135+
}
136+
137+
test_7_check_feature_branch_accepts_sequential() {
138+
echo "Test 7: check_feature_branch accepts sequential branch"
139+
source "$COMMON_SH"
140+
if check_feature_branch "004-feat" "true" 2>/dev/null; then
141+
pass "sequential branch accepted"
142+
else
143+
fail "sequential branch rejected"
144+
fi
145+
}
146+
147+
test_8_check_feature_branch_rejects_main() {
148+
echo "Test 8: check_feature_branch rejects main"
149+
source "$COMMON_SH"
150+
if check_feature_branch "main" "true" 2>/dev/null; then
151+
fail "main branch should be rejected"
152+
else
153+
pass "main branch correctly rejected"
154+
fi
155+
}
156+
157+
test_9_check_feature_branch_rejects_partial_timestamp() {
158+
echo "Test 9: check_feature_branch rejects 7-digit date"
159+
source "$COMMON_SH"
160+
if check_feature_branch "2026031-143022-feat" "true" 2>/dev/null; then
161+
fail "7-digit date should be rejected"
162+
else
163+
pass "partial timestamp correctly rejected"
164+
fi
165+
}
166+
167+
test_10_find_feature_dir_by_prefix_timestamp() {
168+
echo "Test 10: find_feature_dir_by_prefix with timestamp branch"
169+
source "$COMMON_SH"
170+
local dir; dir=$(mktemp -d)
171+
mkdir -p "$dir/specs/20260319-143022-user-auth"
172+
local result
173+
result=$(find_feature_dir_by_prefix "$dir" "20260319-143022-user-auth")
174+
if [[ "$result" == "$dir/specs/20260319-143022-user-auth" ]]; then
175+
pass "found correct spec dir for timestamp branch"
176+
else
177+
fail "expected $dir/specs/20260319-143022-user-auth, got: $result"
178+
fi
179+
cleanup "$dir"
180+
}
181+
182+
test_11_find_feature_dir_by_prefix_cross_branch() {
183+
echo "Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)"
184+
source "$COMMON_SH"
185+
local dir; dir=$(mktemp -d)
186+
mkdir -p "$dir/specs/20260319-143022-original-feat"
187+
local result
188+
result=$(find_feature_dir_by_prefix "$dir" "20260319-143022-different-name")
189+
if [[ "$result" == "$dir/specs/20260319-143022-original-feat" ]]; then
190+
pass "cross-branch prefix lookup found existing dir"
191+
else
192+
fail "expected $dir/specs/20260319-143022-original-feat, got: $result"
193+
fi
194+
cleanup "$dir"
195+
}
196+
197+
test_12_get_current_branch_env_var() {
198+
echo "Test 12: get_current_branch returns SPECIFY_FEATURE env var"
199+
source "$COMMON_SH"
200+
local result
201+
result=$(SPECIFY_FEATURE="my-custom-branch" get_current_branch)
202+
if [[ "$result" == "my-custom-branch" ]]; then
203+
pass "SPECIFY_FEATURE env var used"
204+
else
205+
fail "expected my-custom-branch, got: $result"
206+
fi
207+
}
208+
209+
test_13_no_git_timestamp() {
210+
echo "Test 13: No-git repo + timestamp creates spec dir with warning"
211+
local dir; dir=$(setup_temp_dir_no_git)
212+
local stderr_output
213+
stderr_output=$(cd "$dir" && bash scripts/bash/create-new-feature.sh --timestamp --short-name no-git-feat 'No git feature' 2>&1 >/dev/null)
214+
# Check spec dir was created
215+
local spec_dirs
216+
spec_dirs=$(ls "$dir/specs/" 2>/dev/null || echo "")
217+
if [[ -n "$spec_dirs" ]] && echo "$stderr_output" | grep -qi "git.*not detected\|warning"; then
218+
pass "spec dir created and warning emitted for no-git"
219+
else
220+
fail "spec_dirs='$spec_dirs', stderr='$stderr_output'"
221+
fi
222+
cleanup "$dir"
223+
}
224+
225+
test_14_e2e_timestamp_flow() {
226+
echo "Test 14: E2E timestamp flow"
227+
local repo; repo=$(setup_temp_repo)
228+
source "$COMMON_SH"
229+
# Create feature with timestamp
230+
cd "$repo"
231+
bash scripts/bash/create-new-feature.sh --timestamp --short-name e2e-ts 'E2E timestamp test' 2>/dev/null >/dev/null
232+
# Check branch was created
233+
local current_branch
234+
current_branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD)
235+
if [[ "$current_branch" =~ ^[0-9]{8}-[0-9]{6}-e2e-ts$ ]]; then
236+
# Check spec dir exists
237+
if [[ -d "$repo/specs/$current_branch" ]]; then
238+
# Check branch passes validation
239+
if check_feature_branch "$current_branch" "true" 2>/dev/null; then
240+
pass "E2E timestamp: branch, dir, and validation all pass"
241+
else
242+
fail "E2E timestamp: branch validation failed for $current_branch"
243+
fi
244+
else
245+
fail "E2E timestamp: spec dir not found for $current_branch"
246+
fi
247+
else
248+
fail "E2E timestamp: branch doesn't match pattern, got: $current_branch"
249+
fi
250+
cleanup "$repo"
251+
}
252+
253+
test_15_e2e_sequential_flow() {
254+
echo "Test 15: E2E sequential flow (regression guard)"
255+
local repo; repo=$(setup_temp_repo)
256+
source "$COMMON_SH"
257+
cd "$repo"
258+
bash scripts/bash/create-new-feature.sh --short-name seq-feat 'Sequential feature' 2>/dev/null >/dev/null
259+
local current_branch
260+
current_branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD)
261+
if [[ "$current_branch" =~ ^[0-9]{3}-seq-feat$ ]]; then
262+
if [[ -d "$repo/specs/$current_branch" ]]; then
263+
if check_feature_branch "$current_branch" "true" 2>/dev/null; then
264+
pass "E2E sequential: branch, dir, and validation all pass"
265+
else
266+
fail "E2E sequential: branch validation failed for $current_branch"
267+
fi
268+
else
269+
fail "E2E sequential: spec dir not found for $current_branch"
270+
fi
271+
else
272+
fail "E2E sequential: branch doesn't match pattern, got: $current_branch"
273+
fi
274+
cleanup "$repo"
275+
}
276+
277+
# ── Run all tests ────────────────────────────────────────────────────────────
278+
279+
echo "=== Timestamp Branch Naming Tests ==="
280+
echo ""
281+
282+
test_1_timestamp_creates_branch
283+
test_2_sequential_default_with_existing_specs
284+
test_3_number_and_timestamp_warns
285+
test_4_json_output_keys
286+
test_5_long_name_truncation
287+
test_6_check_feature_branch_accepts_timestamp
288+
test_7_check_feature_branch_accepts_sequential
289+
test_8_check_feature_branch_rejects_main
290+
test_9_check_feature_branch_rejects_partial_timestamp
291+
test_10_find_feature_dir_by_prefix_timestamp
292+
test_11_find_feature_dir_by_prefix_cross_branch
293+
test_12_get_current_branch_env_var
294+
test_13_no_git_timestamp
295+
test_14_e2e_timestamp_flow
296+
test_15_e2e_sequential_flow
297+
298+
echo ""
299+
echo "=== Results: $PASS_COUNT passed, $FAIL_COUNT failed ==="
300+
301+
if [[ $FAIL_COUNT -gt 0 ]]; then
302+
exit 1
303+
fi
304+
exit 0

0 commit comments

Comments
 (0)