From 77cf88bf070ab47961e20111c98869177e1e4163 Mon Sep 17 00:00:00 2001 From: Adam Weiss Date: Thu, 19 Mar 2026 16:04:33 -0400 Subject: [PATCH 1/5] feat: add timestamp-based branch naming option for specify init Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 + scripts/bash/common.sh | 32 ++- scripts/bash/create-new-feature.sh | 72 ++++--- scripts/powershell/common.ps1 | 23 +- scripts/powershell/create-new-feature.ps1 | 46 ++-- src/specify_cli/__init__.py | 7 + templates/commands/specify.md | 8 +- tests/test_branch_numbering.py | 70 ++++++ tests/test_timestamp_branches.py | 247 ++++++++++++++++++++++ 9 files changed, 453 insertions(+), 56 deletions(-) create mode 100644 tests/test_branch_numbering.py create mode 100644 tests/test_timestamp_branches.py diff --git a/README.md b/README.md index a7dc5af64c..7f2175482e 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ The `specify` command supports the following options: | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | | `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) | +| `--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 | ### Examples @@ -296,6 +297,9 @@ specify init my-project --ai claude --ai-skills # Initialize in current directory with agent skills specify init --here --ai gemini --ai-skills +# Use timestamp-based branch numbering (useful for distributed teams) +specify init my-project --ai claude --branch-numbering timestamp + # Check system requirements specify check ``` diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 40f1c96e7d..9e28290765 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -33,16 +33,27 @@ get_current_branch() { if [[ -d "$specs_dir" ]]; then local latest_feature="" local highest=0 + local latest_timestamp="" for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then local dirname=$(basename "$dir") - if [[ "$dirname" =~ ^([0-9]{3})- ]]; then + if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + # Timestamp-based branch: compare lexicographically + local ts="${BASH_REMATCH[1]}" + if [[ "$ts" > "$latest_timestamp" ]]; then + latest_timestamp="$ts" + latest_feature=$dirname + fi + elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then local number=${BASH_REMATCH[1]} number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then highest=$number - latest_feature=$dirname + # Only update if no timestamp branch found yet + if [[ -z "$latest_timestamp" ]]; then + latest_feature=$dirname + fi fi fi fi @@ -72,9 +83,9 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then + if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 return 1 fi @@ -90,15 +101,18 @@ find_feature_dir_by_prefix() { local branch_name="$2" local specs_dir="$repo_root/specs" - # Extract numeric prefix from branch (e.g., "004" from "004-whatever") - if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then - # If branch doesn't have numeric prefix, fall back to exact match + # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) + local prefix="" + if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + prefix="${BASH_REMATCH[1]}" + elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then + prefix="${BASH_REMATCH[1]}" + else + # If branch doesn't have a recognized prefix, fall back to exact match echo "$specs_dir/$branch_name" return fi - local prefix="${BASH_REMATCH[1]}" - # Search for directories in specs/ that start with this prefix local matches=() if [[ -d "$specs_dir" ]]; then diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 58c5c86c48..91758bb120 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -5,13 +5,14 @@ set -e JSON_MODE=false SHORT_NAME="" BRANCH_NUMBER="" +USE_TIMESTAMP=false ARGS=() i=1 while [ $i -le $# ]; do arg="${!i}" case "$arg" in - --json) - JSON_MODE=true + --json) + JSON_MODE=true ;; --short-name) if [ $((i + 1)) -gt $# ]; then @@ -40,22 +41,27 @@ while [ $i -le $# ]; do fi BRANCH_NUMBER="$next_arg" ;; - --help|-h) - echo "Usage: $0 [--json] [--short-name ] [--number N] " + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" exit 0 ;; - *) - ARGS+=("$arg") + *) + ARGS+=("$arg") ;; esac i=$((i + 1)) @@ -96,10 +102,13 @@ get_highest_from_specs() { for dir in "$specs_dir"/*; do [ -d "$dir" ] || continue dirname=$(basename "$dir") - number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number + # Only match sequential prefixes (###-*), skip timestamp dirs + if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$dirname" | grep -o '^[0-9]\{3\}') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi fi done fi @@ -242,29 +251,42 @@ else BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") fi -# Determine branch number -if [ -z "$BRANCH_NUMBER" ]; then - if [ "$HAS_GIT" = true ]; then - # Check existing branches on remotes - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") - else - # Fall back to local directory check - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) - fi +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" fi -# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) -FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") -BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary MAX_BRANCH_LENGTH=244 if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4)) + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) # Truncate suffix at word boundary if possible TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 3d6a77f295..1595bd8a09 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -38,17 +38,28 @@ function Get-CurrentBranch { if (Test-Path $specsDir) { $latestFeature = "" $highest = 0 - + $latestTimestamp = "" + Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { + if ($_.Name -match '^(\d{8}-\d{6})-') { + # Timestamp-based branch: compare lexicographically + $ts = $matches[1] + if ($ts -gt $latestTimestamp) { + $latestTimestamp = $ts + $latestFeature = $_.Name + } + } elseif ($_.Name -match '^(\d{3})-') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num - $latestFeature = $_.Name + # Only update if no timestamp branch found yet + if (-not $latestTimestamp) { + $latestFeature = $_.Name + } } } } - + if ($latestFeature) { return $latestFeature } @@ -79,9 +90,9 @@ function Test-FeatureBranch { return $true } - if ($Branch -notmatch '^[0-9]{3}-') { + if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') { Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name" + Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" return $false } return $true diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 17e61bb845..9c9ad03066 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -6,6 +6,7 @@ param( [string]$ShortName, [Parameter()] [int]$Number = 0, + [switch]$Timestamp, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -14,17 +15,19 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" Write-Host " -Help Show this help message" Write-Host "" Write-Host "Examples:" Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" + Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" exit 0 } @@ -72,7 +75,7 @@ function Get-HighestNumberFromSpecs { $highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d+)') { + if ($_.Name -match '^(\d{3})-') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num } } @@ -216,27 +219,40 @@ if ($ShortName) { $branchSuffix = Get-BranchName -Description $featureDesc } -# Determine branch number -if ($Number -eq 0) { - if ($hasGit) { - # Check existing branches on remotes - $Number = Get-NextBranchNumber -SpecsDir $specsDir - } else { - # Fall back to local directory check - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } +# Warn if -Number and -Timestamp are both specified +if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 } -$featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" +# Determine branch prefix +if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" +} else { + # Determine branch number + if ($Number -eq 0) { + if ($hasGit) { + # Check existing branches on remotes + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + # Fall back to local directory check + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" +} # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index caca381a50..da96c07e8a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1479,6 +1479,7 @@ def init( github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), + branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), ): """ Initialize a new Specify project from the latest template. @@ -1546,6 +1547,11 @@ def init( console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") raise typer.Exit(1) + BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} + if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES: + console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") + raise typer.Exit(1) + if here: project_name = Path.cwd().name project_path = Path.cwd() @@ -1781,6 +1787,7 @@ def init( "ai": selected_ai, "ai_skills": ai_skills, "ai_commands_dir": ai_commands_dir, + "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, "script": selected_script, diff --git a/templates/commands/specify.md b/templates/commands/specify.md index eeca4b58ca..a81b8f12f1 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -73,10 +73,16 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories): +2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: + + **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. + - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation + - If `"sequential"` or absent, do not add any extra flag (default behavior) - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` + - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` + - PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"` **IMPORTANT**: - Do NOT pass `--number` — the script determines the correct next number automatically diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py new file mode 100644 index 0000000000..0590dbd614 --- /dev/null +++ b/tests/test_branch_numbering.py @@ -0,0 +1,70 @@ +""" +Unit tests for branch numbering options (sequential vs timestamp). + +Tests cover: +- Persisting branch_numbering in init-options.json +- Default value when branch_numbering is None +- Validation of branch_numbering values +""" + +import json +from pathlib import Path + +from specify_cli import save_init_options, load_init_options + + +class TestSaveBranchNumbering: + """Tests for save_init_options with branch_numbering.""" + + def test_save_branch_numbering_timestamp(self, tmp_path: Path): + opts = {"branch_numbering": "timestamp", "ai": "claude"} + save_init_options(tmp_path, opts) + + saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) + assert saved["branch_numbering"] == "timestamp" + + def test_save_branch_numbering_sequential(self, tmp_path: Path): + opts = {"branch_numbering": "sequential", "ai": "claude"} + save_init_options(tmp_path, opts) + + saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) + assert saved["branch_numbering"] == "sequential" + + def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path): + branch_numbering = None + opts = {"branch_numbering": branch_numbering or "sequential"} + save_init_options(tmp_path, opts) + + saved = load_init_options(tmp_path) + assert saved["branch_numbering"] == "sequential" + + +class TestBranchNumberingValidation: + """Tests for branch_numbering CLI validation via CliRunner.""" + + def test_invalid_branch_numbering_rejected(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"]) + assert result.exit_code == 1 + assert "Invalid --branch-numbering" in result.output + + def test_valid_branch_numbering_sequential(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential"]) + # Should not fail on validation (may fail later due to network/template fetch) + assert "Invalid --branch-numbering" not in (result.output or "") + + def test_valid_branch_numbering_timestamp(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp"]) + # Should not fail on validation (may fail later due to network/template fetch) + assert "Invalid --branch-numbering" not in (result.output or "") diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py new file mode 100644 index 0000000000..d97f97c961 --- /dev/null +++ b/tests/test_timestamp_branches.py @@ -0,0 +1,247 @@ +""" +Pytest tests for timestamp-based branch naming in create-new-feature.sh and common.sh. + +Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" + + +@pytest.fixture +def git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with scripts and .specify dir.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], + cwd=tmp_path, + check=True, + ) + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def no_git_dir(tmp_path: Path) -> Path: + """Create a temp directory without git, but with scripts.""" + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + return tmp_path + + +def run_script(cwd: Path, *args: str, capture_stderr: bool = False) -> subprocess.CompletedProcess: + """Run create-new-feature.sh with given args.""" + cmd = ["bash", "scripts/bash/create-new-feature.sh", *args] + return subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + ) + + +def source_and_call(func_call: str, env: dict | None = None) -> subprocess.CompletedProcess: + """Source common.sh and call a function.""" + cmd = f'source "{COMMON_SH}" && {func_call}' + return subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + env={**os.environ, **(env or {})}, + ) + + +# ── Timestamp Branch Tests ─────────────────────────────────────────────────── + + +class TestTimestampBranch: + def test_timestamp_creates_branch(self, git_repo: Path): + """Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix.""" + result = run_script(git_repo, "--timestamp", "--short-name", "user-auth", "Add user auth") + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch is not None + assert re.match(r"^\d{8}-\d{6}-user-auth$", branch), f"unexpected branch: {branch}" + + def test_number_and_timestamp_warns(self, git_repo: Path): + """Test 3: --number + --timestamp warns and uses timestamp.""" + result = run_script(git_repo, "--timestamp", "--number", "42", "--short-name", "feat", "Feature") + assert "Warning" in result.stderr and "--number" in result.stderr + + def test_json_output_keys(self, git_repo: Path): + """Test 4: JSON output contains expected keys.""" + result = run_script(git_repo, "--json", "--timestamp", "--short-name", "api", "API feature") + assert result.returncode == 0, result.stderr + for key in ("BRANCH_NAME", "SPEC_FILE", "FEATURE_NUM"): + assert f'"{key}"' in result.stdout, f"missing {key} in JSON: {result.stdout}" + + def test_long_name_truncation(self, git_repo: Path): + """Test 5: Long branch name is truncated to <= 244 chars.""" + long_name = "a-" * 150 + "end" + result = run_script(git_repo, "--timestamp", "--short-name", long_name, "Long feature") + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch is not None + assert len(branch) <= 244 + assert re.match(r"^\d{8}-\d{6}-", branch) + + +# ── Sequential Branch Tests ────────────────────────────────────────────────── + + +class TestSequentialBranch: + def test_sequential_default_with_existing_specs(self, git_repo: Path): + """Test 2: Sequential default with existing specs.""" + (git_repo / "specs" / "001-first-feat").mkdir(parents=True) + (git_repo / "specs" / "002-second-feat").mkdir(parents=True) + result = run_script(git_repo, "--short-name", "new-feat", "New feature") + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch is not None + assert re.match(r"^\d{3}-new-feat$", branch), f"unexpected branch: {branch}" + + def test_sequential_ignores_timestamp_dirs(self, git_repo: Path): + """Sequential numbering skips timestamp dirs when computing next number.""" + (git_repo / "specs" / "002-first-feat").mkdir(parents=True) + (git_repo / "specs" / "20260319-143022-ts-feat").mkdir(parents=True) + result = run_script(git_repo, "--short-name", "next-feat", "Next feature") + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}" + + +# ── check_feature_branch Tests ─────────────────────────────────────────────── + + +class TestCheckFeatureBranch: + def test_accepts_timestamp_branch(self): + """Test 6: check_feature_branch accepts timestamp branch.""" + result = source_and_call('check_feature_branch "20260319-143022-feat" "true"') + assert result.returncode == 0 + + def test_accepts_sequential_branch(self): + """Test 7: check_feature_branch accepts sequential branch.""" + result = source_and_call('check_feature_branch "004-feat" "true"') + assert result.returncode == 0 + + def test_rejects_main(self): + """Test 8: check_feature_branch rejects main.""" + result = source_and_call('check_feature_branch "main" "true"') + assert result.returncode != 0 + + def test_rejects_partial_timestamp(self): + """Test 9: check_feature_branch rejects 7-digit date.""" + result = source_and_call('check_feature_branch "2026031-143022-feat" "true"') + assert result.returncode != 0 + + +# ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── + + +class TestFindFeatureDirByPrefix: + def test_timestamp_branch(self, tmp_path: Path): + """Test 10: find_feature_dir_by_prefix with timestamp branch.""" + (tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth" + + def test_cross_branch_prefix(self, tmp_path: Path): + """Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp).""" + (tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat" + + +# ── get_current_branch Tests ───────────────────────────────────────────────── + + +class TestGetCurrentBranch: + def test_env_var(self): + """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" + result = source_and_call("get_current_branch", env={"SPECIFY_FEATURE": "my-custom-branch"}) + assert result.stdout.strip() == "my-custom-branch" + + +# ── No-git Tests ───────────────────────────────────────────────────────────── + + +class TestNoGitTimestamp: + def test_no_git_timestamp(self, no_git_dir: Path): + """Test 13: No-git repo + timestamp creates spec dir with warning.""" + result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature") + spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else [] + assert len(spec_dirs) > 0, "spec dir not created" + assert "git" in result.stderr.lower() or "warning" in result.stderr.lower() + + +# ── E2E Flow Tests ─────────────────────────────────────────────────────────── + + +class TestE2EFlow: + def test_e2e_timestamp(self, git_repo: Path): + """Test 14: E2E timestamp flow — branch, dir, validation.""" + run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test") + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, + capture_output=True, + text=True, + ).stdout.strip() + assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}" + assert (git_repo / "specs" / branch).is_dir() + val = source_and_call(f'check_feature_branch "{branch}" "true"') + assert val.returncode == 0 + + def test_e2e_sequential(self, git_repo: Path): + """Test 15: E2E sequential flow (regression guard).""" + run_script(git_repo, "--short-name", "seq-feat", "Sequential feature") + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, + capture_output=True, + text=True, + ).stdout.strip() + assert re.match(r"^\d{3}-seq-feat$", branch), f"branch: {branch}" + assert (git_repo / "specs" / branch).is_dir() + val = source_and_call(f'check_feature_branch "{branch}" "true"') + assert val.returncode == 0 From 6971796d820431391c067b568dc453313f266bc4 Mon Sep 17 00:00:00 2001 From: Adam Weiss Date: Thu, 19 Mar 2026 16:22:29 -0400 Subject: [PATCH 2/5] Copilot feedback --- scripts/bash/common.sh | 2 +- scripts/bash/create-new-feature.sh | 2 +- scripts/powershell/create-new-feature.ps1 | 4 ++-- tests/test_branch_numbering.py | 12 ++++++++---- tests/test_timestamp_branches.py | 4 +++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 9e28290765..c332ceb882 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -133,7 +133,7 @@ find_feature_dir_by_prefix() { else # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 - echo "Please ensure only one spec directory exists per numeric prefix." >&2 + echo "Please ensure only one spec directory exists per prefix." >&2 return 1 fi } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 91758bb120..fa83c804cf 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -69,7 +69,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--short-name ] [--number N] " >&2 + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " >&2 exit 1 fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 9c9ad03066..2f66ec8f0e 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -33,7 +33,7 @@ if ($Help) { # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } @@ -96,7 +96,7 @@ function Get-HighestNumberFromBranches { $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' # Extract feature number if branch matches pattern ###-* - if ($cleanBranch -match '^(\d+)-') { + if ($cleanBranch -match '^(\d{3})-') { $num = [int]$matches[1] if ($num -gt $highest) { $highest = $num } } diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index 0590dbd614..e574550c9a 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -51,20 +51,24 @@ def test_invalid_branch_numbering_rejected(self, tmp_path: Path): assert result.exit_code == 1 assert "Invalid --branch-numbering" in result.output - def test_valid_branch_numbering_sequential(self, tmp_path: Path): + def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch): from typer.testing import CliRunner from specify_cli import app + monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) + runner = CliRunner() result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential"]) - # Should not fail on validation (may fail later due to network/template fetch) + assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") - def test_valid_branch_numbering_timestamp(self, tmp_path: Path): + def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch): from typer.testing import CliRunner from specify_cli import app + monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) + runner = CliRunner() result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp"]) - # Should not fail on validation (may fail later due to network/template fetch) + assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index d97f97c961..bc77b5b9fb 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -51,7 +51,7 @@ def no_git_dir(tmp_path: Path) -> Path: return tmp_path -def run_script(cwd: Path, *args: str, capture_stderr: bool = False) -> subprocess.CompletedProcess: +def run_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: """Run create-new-feature.sh with given args.""" cmd = ["bash", "scripts/bash/create-new-feature.sh", *args] return subprocess.run( @@ -91,6 +91,7 @@ def test_timestamp_creates_branch(self, git_repo: Path): def test_number_and_timestamp_warns(self, git_repo: Path): """Test 3: --number + --timestamp warns and uses timestamp.""" result = run_script(git_repo, "--timestamp", "--number", "42", "--short-name", "feat", "Feature") + assert result.returncode == 0, result.stderr assert "Warning" in result.stderr and "--number" in result.stderr def test_json_output_keys(self, git_repo: Path): @@ -209,6 +210,7 @@ class TestNoGitTimestamp: def test_no_git_timestamp(self, no_git_dir: Path): """Test 13: No-git repo + timestamp creates spec dir with warning.""" result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature") + assert result.returncode == 0, result.stderr spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else [] assert len(spec_dirs) > 0, "spec dir not created" assert "git" in result.stderr.lower() or "warning" in result.stderr.lower() From 4c102f25a17fb8302fbb9ebb5b769063ecafc5ba Mon Sep 17 00:00:00 2001 From: Adam Weiss Date: Thu, 19 Mar 2026 16:40:02 -0400 Subject: [PATCH 3/5] Fix test --- tests/test_branch_numbering.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index e574550c9a..58771e598b 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -55,10 +55,13 @@ def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch): from typer.testing import CliRunner from specify_cli import app - monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) + def _fake_download(project_path, *args, **kwargs): + Path(project_path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") @@ -66,9 +69,12 @@ def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch): from typer.testing import CliRunner from specify_cli import app - monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) + def _fake_download(project_path, *args, **kwargs): + Path(project_path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") From 68300d65c3ff1e0d7efeae659a76df85b954bc17 Mon Sep 17 00:00:00 2001 From: Adam Weiss Date: Thu, 19 Mar 2026 17:11:27 -0400 Subject: [PATCH 4/5] Copilot feedback --- scripts/bash/create-new-feature.sh | 6 +++++- scripts/powershell/create-new-feature.ps1 | 6 +++++- tests/test_branch_numbering.py | 19 ++++++++++++++----- tests/test_timestamp_branches.py | 5 ++++- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index fa83c804cf..0df4adc3ef 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -305,7 +305,11 @@ if [ "$HAS_GIT" = true ]; then if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then # Check if branch already exists if git branch --list "$BRANCH_NAME" | grep -q .; then - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + if [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + fi exit 1 else >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f66ec8f0e..473c925b9e 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -282,7 +282,11 @@ if ($hasGit) { # Check if branch already exists $existingBranch = git branch --list $branchName 2>$null if ($existingBranch) { - Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + if ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + } exit 1 } else { Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index 58771e598b..b7de7be4bb 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -30,12 +30,21 @@ def test_save_branch_numbering_sequential(self, tmp_path: Path): saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) assert saved["branch_numbering"] == "sequential" - def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path): - branch_numbering = None - opts = {"branch_numbering": branch_numbering or "sequential"} - save_init_options(tmp_path, opts) + def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + def _fake_download(project_path, *args, **kwargs): + Path(project_path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) + + project_dir = tmp_path / "proj" + runner = CliRunner() + result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"]) + assert result.exit_code == 0 - saved = load_init_options(tmp_path) + saved = json.loads((project_dir / ".specify/init-options.json").read_text()) assert saved["branch_numbering"] == "sequential" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index bc77b5b9fb..0cf631e963 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -96,10 +96,13 @@ def test_number_and_timestamp_warns(self, git_repo: Path): def test_json_output_keys(self, git_repo: Path): """Test 4: JSON output contains expected keys.""" + import json result = run_script(git_repo, "--json", "--timestamp", "--short-name", "api", "API feature") assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) for key in ("BRANCH_NAME", "SPEC_FILE", "FEATURE_NUM"): - assert f'"{key}"' in result.stdout, f"missing {key} in JSON: {result.stdout}" + assert key in data, f"missing {key} in JSON: {data}" + assert re.match(r"^\d{8}-\d{6}$", data["FEATURE_NUM"]) def test_long_name_truncation(self, git_repo: Path): """Test 5: Long branch name is truncated to <= 244 chars.""" From 2a413155e26a8741af590542e8d01ae64b50ac10 Mon Sep 17 00:00:00 2001 From: Adam Weiss Date: Fri, 20 Mar 2026 09:10:02 -0400 Subject: [PATCH 5/5] Update tests/test_branch_numbering.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_branch_numbering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index b7de7be4bb..74eadf22ef 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -10,7 +10,7 @@ import json from pathlib import Path -from specify_cli import save_init_options, load_init_options +from specify_cli import save_init_options class TestSaveBranchNumbering: