feat: add timestamp-based branch naming option for specify init (#1911)

* feat: add timestamp-based branch naming option for specify init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Copilot feedback

* Fix test

* Copilot feedback

* Update tests/test_branch_numbering.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Adam Weiss
2026-03-20 09:53:42 -04:00
committed by GitHub
parent d2559d7025
commit 65ecd5321d
9 changed files with 491 additions and 62 deletions

View File

@@ -0,0 +1,89 @@
"""
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
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, 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 = json.loads((project_dir / ".specify/init-options.json").read_text())
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, 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)
runner = CliRunner()
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 "")
def test_valid_branch_numbering_timestamp(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)
runner = CliRunner()
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 "")

View File

@@ -0,0 +1,252 @@
"""
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) -> 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 result.returncode == 0, result.stderr
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."""
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 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."""
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")
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()
# ── 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