From dbe92037653a106ac00c220e5262ceb2fda29d00 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 1 Jul 2026 23:31:06 +0200 Subject: [PATCH 1/7] test: cover namespaced git branch templates Assisted-by: Codex (model: GPT-5, autonomous) --- tests/extensions/git/test_git_extension.py | 141 ++++++++++++++++++++- 1 file changed, 138 insertions(+), 3 deletions(-) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 2f53854d82..d616a85cc6 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = ) -def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess: +def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: """Run an extension PowerShell script.""" script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name - env = {**os.environ, **_GIT_ENV} + env = {**os.environ, **_GIT_ENV, **(env_extra or {})} return subprocess.run( ["pwsh", "-NoProfile", "-File", str(script), *args], cwd=cwd, @@ -363,6 +363,69 @@ def test_increments_from_existing_specs(self, tmp_path: Path): data = json.loads(result.stdout) assert data["FEATURE_NUM"] == "003" + def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path): + """branch_template namespaces generated branch names for monorepos.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path): + """branch_prefix expands to a namespace before the default branch shape.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_prefix: "features/{app}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): + """Templated branch numbering ignores branches outside the current namespace.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): + """GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" + project = _setup_project(tmp_path / "app-a") + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): """Branches checked out in sibling worktrees still reserve their prefix.""" project = _setup_project(tmp_path / "project") @@ -525,6 +588,54 @@ def test_creates_branch_timestamp(self, tmp_path: Path): data = json.loads(result.stdout) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) + def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path): + """PowerShell supports branch_template namespaces.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): + """PowerShell templated numbering ignores branches outside the namespace.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): + """PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" + project = _setup_project(tmp_path / "app-a") + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.ps1 works without git.""" project = _setup_project(tmp_path, git=False) @@ -1011,13 +1122,22 @@ def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): ) assert result.returncode == 0 - def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path): + def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path): project = _setup_project(tmp_path) script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" result = subprocess.run( ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'], capture_output=True, text=True, ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'], + capture_output=True, text=True, + ) assert result.returncode != 0 @@ -1037,3 +1157,18 @@ def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): text=True, ) assert result.returncode == 0 + + def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 From f48178dccc831b084f688378e8ef2030f5e2ca9b Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 1 Jul 2026 23:32:06 +0200 Subject: [PATCH 2/7] feat: support namespaced git branch templates Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/git/README.md | 11 +- .../git/commands/speckit.git.feature.md | 18 +- .../git/commands/speckit.git.validate.md | 12 +- extensions/git/config-template.yml | 5 + extensions/git/extension.yml | 5 +- extensions/git/git-config.yml | 5 + .../scripts/bash/create-new-feature-branch.sh | 184 ++++++++++++++--- extensions/git/scripts/bash/git-common.sh | 9 +- .../powershell/create-new-feature-branch.ps1 | 192 ++++++++++++++---- .../git/scripts/powershell/git-common.ps1 | 12 +- 10 files changed, 368 insertions(+), 85 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index e2c53fb769..5404cb37b1 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti This extension provides Git operations as an optional, self-contained module. It manages: - **Repository initialization** with configurable commit messages -- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces - **Branch validation** to ensure branches follow naming conventions - **Git remote detection** for GitHub integration (e.g., issue creation) - **Auto-commit** after core commands (configurable per-command with custom messages) @@ -53,6 +53,11 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`: # Branch numbering strategy: "sequential" or "timestamp" branch_numbering: sequential +# Optional branch name template. Leave empty for the default "{number}-{slug}". +# Supported tokens: {author}, {app}, {number}, {slug} +# Example for monorepos: "{author}/{app}/{number}-{slug}" +branch_template: "" + # Custom commit message for git init init_commit_message: "[Spec Kit] Initial commit" @@ -65,6 +70,10 @@ auto_commit: message: "[Spec Kit] Add specification" ``` +`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. + +For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `/{number}-{slug}`. + ## Installation ```bash diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 27fdbd5f72..a19971c471 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). 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: - The script uses the exact value as the branch name, bypassing all prefix/suffix generation - `--short-name`, `--number`, and `--timestamp` flags are ignored -- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name +- `FEATURE_NUM` is extracted from the first numeric or timestamp segment (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name ## Prerequisites @@ -35,6 +35,19 @@ Determine the branch numbering strategy by checking configuration in this order: 3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release) 4. Default to `sequential` if none of the above exist +## Branch Name Template + +Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, the script expands these tokens: + +- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part) +- `{app}`: sanitized Spec Kit init directory name +- `{number}`: sequential number or timestamp +- `{slug}`: generated short branch slug + +For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering. + +The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `/{number}-{slug}`. + ## Execution Generate a concise short name (2-4 words) for the branch: @@ -54,6 +67,7 @@ Run the appropriate script based on your platform: - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature - The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` +- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently ## Graceful Degradation @@ -64,5 +78,5 @@ If Git is not installed or the current directory is not a Git repository: ## Output The script outputs JSON with: -- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`) - `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md index dd84618cb8..83f26061fa 100644 --- a/extensions/git/commands/speckit.git.validate.md +++ b/extensions/git/commands/speckit.git.validate.md @@ -22,24 +22,24 @@ Get the current branch name: git rev-parse --abbrev-ref HEAD ``` -The branch name must match one of these patterns: +The branch name must contain one of these feature markers either at the start or after one or more namespace path segments: -1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) -2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) +1. **Sequential**: `(^|/)[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`) +2. **Timestamp**: `(^|/)[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`) ## Execution If on a feature branch (matches either pattern): - Output: `✓ On feature branch: ` - Check if the corresponding spec directory exists under `specs/`: - - For sequential branches, look for `specs/-*` where prefix matches the numeric portion - - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion, regardless of branch namespace prefixes + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes - If spec directory exists: `✓ Spec directory found: ` - If spec directory missing: `⚠ No spec directory found for prefix ` If NOT on a feature branch: - Output: `✗ Not on a feature branch. Current branch: ` -- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` +- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or /001-feature-name` ## Graceful Degradation diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index 8c414babe6..05b9f3e9e3 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -4,6 +4,11 @@ # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) branch_numbering: sequential +# Optional branch name template. Leave empty for the default "{number}-{slug}". +# Supported tokens: {author}, {app}, {number}, {slug} +# Example for monorepos: "{author}/{app}/{number}-{slug}" +branch_template: "" + # Commit message used by `git commit` during repository initialization init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 13c1977ea1..fc29387242 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -4,7 +4,7 @@ extension: id: git name: "Git Branching Workflow" version: "1.0.0" - description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit license: MIT @@ -19,7 +19,7 @@ provides: commands: - name: speckit.git.feature file: commands/speckit.git.feature.md - description: "Create a feature branch with sequential or timestamp numbering" + description: "Create a feature branch with sequential or timestamp numbering and optional templates" - name: speckit.git.validate file: commands/speckit.git.validate.md description: "Validate current branch follows feature branch naming conventions" @@ -137,4 +137,5 @@ tags: config: defaults: branch_numbering: sequential + branch_template: "" init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index 8c414babe6..05b9f3e9e3 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -4,6 +4,11 @@ # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) branch_numbering: sequential +# Optional branch name template. Leave empty for the default "{number}-{slug}". +# Supported tokens: {author}, {app}, {number}, {slug} +# Example for monorepos: "{author}/{app}/{number}-{slug}" +branch_template: "" + # Commit message used by `git commit` during repository initialization init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index c6e4e0668f..fd08489256 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -75,6 +75,9 @@ while [ $i -le $# ]; do echo "Environment variables:" echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" echo "" + echo "Configuration:" + echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" + echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" @@ -127,16 +130,24 @@ get_highest_from_specs() { # Function to get highest number from git branches get_highest_from_branches() { - git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number + local scope_prefix="${1:-}" + git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix" } # Extract the highest sequential feature number from a list of ref names (one per line). _extract_highest_number() { + local scope_prefix="${1:-}" local highest=0 while IFS= read -r name; do [ -z "$name" ] && continue - if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + if [ -n "$scope_prefix" ]; then + case "$name" in + "$scope_prefix"*) ;; + *) continue ;; + esac + fi + if echo "$name" | grep -Eq '(^|/)[0-9]{3,}-' && ! echo "$name" | grep -Eq '(^|/)[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '(^|/)[0-9]{3,}-' | head -n 1 | sed -E 's|^/||; s/-$//' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -148,11 +159,12 @@ _extract_highest_number() { # Function to get highest number from remote branches without fetching (side-effect-free) get_highest_from_remote_refs() { + local scope_prefix="${1:-}" local highest=0 for remote in $(git remote 2>/dev/null); do local remote_highest - remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix") if [ "$remote_highest" -gt "$highest" ]; then highest=$remote_highest fi @@ -165,16 +177,17 @@ get_highest_from_remote_refs() { check_existing_branches() { local specs_dir="$1" local skip_fetch="${2:-false}" + local scope_prefix="${3:-}" if [ "$skip_fetch" = true ]; then - local highest_remote=$(get_highest_from_remote_refs) - local highest_branch=$(get_highest_from_branches) + local highest_remote=$(get_highest_from_remote_refs "$scope_prefix") + local highest_branch=$(get_highest_from_branches "$scope_prefix") if [ "$highest_remote" -gt "$highest_branch" ]; then highest_branch=$highest_remote fi else git fetch --all --prune >/dev/null 2>&1 || true - local highest_branch=$(get_highest_from_branches) + local highest_branch=$(get_highest_from_branches "$scope_prefix") fi local highest_spec=$(get_highest_from_specs "$specs_dir") @@ -273,6 +286,123 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" +CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml" + +read_git_config_value() { + local key="$1" + [ -f "$CONFIG_FILE" ] || return 0 + grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \ + | sed -E 's/[[:space:]]+#.*$//' \ + | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \ + | sed -E 's/^"//; s/"$//' \ + | sed -E "s/^'//; s/'$//" +} + +branch_token() { + local value="$1" + local fallback="$2" + local cleaned + cleaned=$(clean_branch_name "$value") + if [ -n "$cleaned" ]; then + printf '%s\n' "$cleaned" + else + printf '%s\n' "$fallback" + fi +} + +get_author_token() { + local author="" + if command -v git >/dev/null 2>&1; then + author=$(git config user.name 2>/dev/null || true) + if [ -z "$author" ]; then + author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true) + fi + fi + if [ -z "$author" ]; then + author="${USER:-unknown}" + fi + branch_token "$author" "unknown" +} + +get_app_token() { + branch_token "$(basename "$REPO_ROOT")" "app" +} + +resolve_branch_template() { + local template + local prefix + template=$(read_git_config_value "branch_template") + if [ -n "$template" ]; then + printf '%s\n' "$template" + return + fi + + prefix=$(read_git_config_value "branch_prefix") + if [ -z "$prefix" ]; then + printf '%s\n' "" + return + fi + case "$prefix" in + */) printf '%s%s\n' "$prefix" "{number}-{slug}" ;; + *) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;; + esac +} + +render_branch_template() { + local template="$1" + local feature_num="$2" + local branch_suffix="$3" + local rendered="$template" + rendered=${rendered//\{author\}/$AUTHOR_TOKEN} + rendered=${rendered//\{app\}/$APP_TOKEN} + rendered=${rendered//\{number\}/$feature_num} + rendered=${rendered//\{slug\}/$branch_suffix} + printf '%s\n' "$rendered" +} + +build_branch_name() { + local feature_num="$1" + local branch_suffix="$2" + if [ -n "$BRANCH_TEMPLATE" ]; then + render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix" + else + printf '%s-%s\n' "$feature_num" "$branch_suffix" + fi +} + +branch_scope_prefix() { + local template="$1" + local prefix="$template" + [ -n "$prefix" ] || return 0 + case "$prefix" in + *"{number}"*) prefix="${prefix%%\{number\}*}" ;; + *"{slug}"*) prefix="${prefix%%\{slug\}*}" ;; + *) return 0 ;; + esac + render_branch_template "$prefix" "" "$BRANCH_SUFFIX" +} + +extract_feature_num_from_branch() { + local branch_name="$1" + local match + match=$(printf '%s\n' "$branch_name" | grep -Eo '(^|/)[0-9]{8}-[0-9]{6}-' | head -n 1 || true) + if [ -n "$match" ]; then + printf '%s\n' "$match" | sed -E 's|^/||; s/-$//' + return + fi + match=$(printf '%s\n' "$branch_name" | grep -Eo '(^|/)[0-9]+-' | head -n 1 || true) + if [ -n "$match" ]; then + printf '%s\n' "$match" | sed -E 's|^/||; s/-$//' + return + fi + printf '%s\n' "$branch_name" +} + +AUTHOR_TOKEN=$(get_author_token) +APP_TOKEN=$(get_app_token) +BRANCH_TEMPLATE=$(resolve_branch_template) # Function to generate branch name with stop word filtering generate_branch_name() { @@ -318,18 +448,8 @@ generate_branch_name() { # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) if [ -n "${GIT_BRANCH_NAME:-}" ]; then BRANCH_NAME="$GIT_BRANCH_NAME" - # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix - # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern - if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') - BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" - elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then - FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') - BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" - else - FEATURE_NUM="$BRANCH_NAME" - BRANCH_SUFFIX="$BRANCH_NAME" - fi + FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME") + BRANCH_SUFFIX="$BRANCH_NAME" else # Generate branch name if [ -n "$SHORT_NAME" ]; then @@ -347,16 +467,17 @@ else # Determine branch prefix if [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") else + BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE") if [ -z "$BRANCH_NUMBER" ]; then if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX") elif [ "$DRY_RUN" = true ]; then HIGHEST=$(get_highest_from_specs "$SPECS_DIR") BRANCH_NUMBER=$((HIGHEST + 1)) elif [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX") else HIGHEST=$(get_highest_from_specs "$SPECS_DIR") BRANCH_NUMBER=$((HIGHEST + 1)) @@ -364,7 +485,7 @@ else fi FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") fi fi @@ -376,14 +497,17 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." exit 1 elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then - PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) - - TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) - TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + TRUNCATED_SUFFIX="$BRANCH_SUFFIX" + while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do + TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}" + TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX") + done + if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then + >&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit." + exit 1 + fi >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index b78356d1c6..0d55e890e2 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -23,7 +23,8 @@ spec_kit_effective_branch_name() { } # Validate that a branch name matches the expected feature branch pattern. -# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats, +# either at the start of the branch or after path-style namespace prefixes. # Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. check_feature_branch() { local raw="$1" @@ -41,12 +42,12 @@ check_feature_branch() { # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") local is_sequential=false - if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + if [[ "$branch" =~ (^|/)[0-9]{3,}- ]] && [[ ! "$branch" =~ (^|/)[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ (^|/)[0-9]{7,8}-[0-9]{6}$ ]]; then is_sequential=true fi - if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ (^|/)[0-9]{8}-[0-9]{6}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 - echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name" >&2 return 1 fi diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 0439ec80ad..a93c96e477 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -34,6 +34,9 @@ if ($Help) { Write-Host "Environment variables:" Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" Write-Host "" + Write-Host "Configuration:" + Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" + Write-Host "" exit 0 } @@ -67,13 +70,19 @@ function Get-HighestNumberFromSpecs { } function Get-HighestNumberFromNames { - param([string[]]$Names) + param( + [string[]]$Names, + [string]$ScopePrefix = '' + ) [long]$highest = 0 foreach ($name in $Names) { - if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) { + continue + } + if ($name -match '(^|/)(\d{3,})-' -and $name -notmatch '(^|/)\d{8}-\d{6}-') { [long]$num = 0 - if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + if ([long]::TryParse($matches[2], [ref]$num) -and $num -gt $highest) { $highest = $num } } @@ -82,7 +91,7 @@ function Get-HighestNumberFromNames { } function Get-HighestNumberFromBranches { - param() + param([string]$ScopePrefix = '') try { $branches = git branch -a 2>$null @@ -90,7 +99,7 @@ function Get-HighestNumberFromBranches { $cleanNames = $branches | ForEach-Object { $_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', '' } - return Get-HighestNumberFromNames -Names $cleanNames + return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix } } catch { Write-Verbose "Could not check Git branches: $_" @@ -99,6 +108,8 @@ function Get-HighestNumberFromBranches { } function Get-HighestNumberFromRemoteRefs { + param([string]$ScopePrefix = '') + [long]$highest = 0 try { $remotes = git remote 2>$null @@ -111,7 +122,7 @@ function Get-HighestNumberFromRemoteRefs { $refNames = $refs | ForEach-Object { if ($_ -match 'refs/heads/(.+)$') { $matches[1] } } | Where-Object { $_ } - $remoteHighest = Get-HighestNumberFromNames -Names $refNames + $remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix if ($remoteHighest -gt $highest) { $highest = $remoteHighest } } } @@ -125,18 +136,19 @@ function Get-HighestNumberFromRemoteRefs { function Get-NextBranchNumber { param( [string]$SpecsDir, - [switch]$SkipFetch + [switch]$SkipFetch, + [string]$ScopePrefix = '' ) if ($SkipFetch) { - $highestBranch = Get-HighestNumberFromBranches - $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix + $highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix $highestBranch = [Math]::Max($highestBranch, $highestRemote) } else { try { git fetch --all --prune 2>$null | Out-Null } catch { } - $highestBranch = Get-HighestNumberFromBranches + $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix } $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir @@ -232,6 +244,124 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" + +function Read-GitConfigValue { + param([string]$Key) + + if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' } + $escapedKey = [regex]::Escape($Key) + foreach ($line in Get-Content -LiteralPath $configFile) { + if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") { + $val = ($matches[1] -replace '\s+#.*$', '').Trim() + $val = $val -replace '^["'']', '' -replace '["'']$', '' + return $val + } + } + return '' +} + +function ConvertTo-BranchToken { + param( + [string]$Value, + [string]$Fallback + ) + + $cleaned = ConvertTo-CleanBranchName -Name $Value + if ($cleaned) { return $cleaned } + return $Fallback +} + +function Get-GitAuthorToken { + $author = '' + if (Get-Command git -ErrorAction SilentlyContinue) { + try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {} + if (-not $author) { + try { + $email = (git config user.email 2>$null | Out-String).Trim() + if ($email) { $author = ($email -split '@')[0] } + } catch {} + } + } + if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } } + return ConvertTo-BranchToken -Value $author -Fallback 'unknown' +} + +function Get-AppToken { + return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app' +} + +function Resolve-BranchTemplate { + $template = Read-GitConfigValue -Key 'branch_template' + if ($template) { return $template } + + $prefix = Read-GitConfigValue -Key 'branch_prefix' + if (-not $prefix) { return '' } + if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" } + return "$prefix/{number}-{slug}" +} + +function Expand-BranchTemplate { + param( + [string]$Template, + [string]$FeatureNum, + [string]$BranchSuffix + ) + + $rendered = $Template.Replace('{author}', $authorToken) + $rendered = $rendered.Replace('{app}', $appToken) + $rendered = $rendered.Replace('{number}', $FeatureNum) + $rendered = $rendered.Replace('{slug}', $BranchSuffix) + return $rendered +} + +function New-BranchName { + param( + [string]$FeatureNum, + [string]$BranchSuffix + ) + + if ($branchTemplate) { + return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix + } + return "$FeatureNum-$BranchSuffix" +} + +function Get-BranchScopePrefix { + param( + [string]$Template, + [string]$BranchSuffix + ) + + if (-not $Template) { return '' } + $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal) + $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal) + $indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object + if (-not $indexes) { return '' } + $prefix = $Template.Substring(0, $indexes[0]) + return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix +} + +function Get-FeatureNumberFromBranchName { + param([string]$BranchName) + + if ($BranchName -match '(?:^|/)(\d{8}-\d{6})-') { + return $matches[1] + } + if ($BranchName -match '(?:^|/)(\d+)-') { + return $matches[1] + } + return $BranchName +} + +function Get-Utf8ByteCount { + param([string]$Value) + return [System.Text.Encoding]::UTF8.GetByteCount($Value) +} + +$authorToken = Get-GitAuthorToken +$appToken = Get-AppToken +$branchTemplate = Resolve-BranchTemplate function Get-BranchName { param([string]$Description) @@ -276,19 +406,11 @@ function Get-BranchName { if ($env:GIT_BRANCH_NAME) { $branchName = $env:GIT_BRANCH_NAME # Check 244-byte limit (UTF-8) for override names - $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + $branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName if ($branchNameUtf8ByteCount -gt 244) { throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." } - # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix - # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern - if ($branchName -match '^(\d{8}-\d{6})-') { - $featureNum = $matches[1] - } elseif ($branchName -match '^(\d+)-') { - $featureNum = $matches[1] - } else { - $featureNum = $branchName - } + $featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName } else { if ($ShortName) { $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName @@ -303,39 +425,41 @@ if ($env:GIT_BRANCH_NAME) { if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } else { + $branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix if ($Number -eq 0) { if ($DryRun -and $hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix } elseif ($DryRun) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } elseif ($hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir + $Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix } else { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } } $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } } $maxBranchLength = 244 -if ($branchName.Length -gt $maxBranchLength) { - $prefixLength = $featureNum.Length + 1 - $maxSuffixLength = $maxBranchLength - $prefixLength - - $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) - $truncatedSuffix = $truncatedSuffix -replace '-$', '' - +if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" + $truncatedSuffix = $branchSuffix + while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) { + $truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', '' + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix + } + if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { + throw "Branch template prefix exceeds GitHub's 244-byte branch name limit." + } Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" - Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" - Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" + Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)" } if (-not $DryRun) { diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 13ea7542c4..c54f8eecde 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -38,13 +38,13 @@ function Test-FeatureBranch { $raw = $Branch $Branch = Get-SpecKitEffectiveBranchName $raw - # Accept sequential prefix (3+ digits) but exclude malformed timestamps - # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") - $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') - $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + # Accept sequential prefix (3+ digits), at the start or after namespace + # segments, but exclude malformed timestamps. + $hasMalformedTimestamp = ($Branch -match '(^|/)[0-9]{7}-[0-9]{6}-') -or ($Branch -match '(^|/)(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '(^|/)[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '(^|/)\d{8}-\d{6}-') { [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") - [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name") return $false } return $true From ee0574b56a25f1d49b4d4af6d1ce93e6ef61c810 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 2 Jul 2026 01:40:12 +0200 Subject: [PATCH 3/7] test: cover git branch template edge cases Assisted-by: Codex (model: GPT-5, autonomous) --- tests/extensions/git/test_git_extension.py | 129 +++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index d616a85cc6..3f9e240fae 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -394,6 +394,23 @@ def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path): assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour" assert data["FEATURE_NUM"] == "001" + def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path): + """Numeric-looking namespace segments must not be parsed as feature numbers.""" + project = _setup_project(tmp_path / "2026-app") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next" + assert data["FEATURE_NUM"] == "008" + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): """Templated branch numbering ignores branches outside the current namespace.""" project = _setup_project(tmp_path / "app-a") @@ -412,6 +429,20 @@ def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" assert data["FEATURE_NUM"] == "008" + def test_branch_template_requires_number_token(self, tmp_path: Path): + """Configured templates must include {number} so generated branches validate.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/{slug}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}" in result.stderr + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") @@ -426,6 +457,20 @@ def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch" assert data["FEATURE_NUM"] == "042" + def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path): + """GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments.""" + project = _setup_project(tmp_path / "2026-app") + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): """Branches checked out in sibling worktrees still reserve their prefix.""" project = _setup_project(tmp_path / "project") @@ -604,6 +649,38 @@ def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path): assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour" assert data["FEATURE_NUM"] == "001" + def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path): + """PowerShell supports branch_prefix shorthand namespaces.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_prefix: "features/{app}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour" + assert data["FEATURE_NUM"] == "001" + + def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path): + """PowerShell ignores numeric-looking namespace segments when numbering.""" + project = _setup_project(tmp_path / "2026-app") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next" + assert data["FEATURE_NUM"] == "008" + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): """PowerShell templated numbering ignores branches outside the namespace.""" project = _setup_project(tmp_path / "app-a") @@ -622,6 +699,20 @@ def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" assert data["FEATURE_NUM"] == "008" + def test_branch_template_requires_number_token(self, tmp_path: Path): + """PowerShell rejects templates without {number}.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/{slug}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}" in result.stderr + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") @@ -636,6 +727,20 @@ def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch" assert data["FEATURE_NUM"] == "042" + def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path): + """PowerShell GIT_BRANCH_NAME ignores numeric namespace segments.""" + project = _setup_project(tmp_path / "2026-app") + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" + assert data["FEATURE_NUM"] == "042" + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.ps1 works without git.""" project = _setup_project(tmp_path, git=False) @@ -1140,6 +1245,15 @@ def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_pat ) assert result.returncode != 0 + def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestGitCommonPowerShell: @@ -1172,3 +1286,18 @@ def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path): text=True, ) assert result.returncode == 0 + + def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode != 0 From 7a2a8b28edba5a0b44d6a8e6c485f3c86ded5e5d Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 2 Jul 2026 10:28:24 +0200 Subject: [PATCH 4/7] fix: harden git branch template parsing Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/git/README.md | 8 +++-- .../git/commands/speckit.git.feature.md | 2 +- extensions/git/config-template.yml | 4 +++ extensions/git/extension.yml | 1 + extensions/git/git-config.yml | 4 +++ .../scripts/bash/create-new-feature-branch.sh | 29 ++++++++++++++----- extensions/git/scripts/bash/git-common.sh | 5 ++-- .../powershell/create-new-feature-branch.ps1 | 22 +++++++++++--- .../git/scripts/powershell/git-common.ps1 | 7 +++-- 9 files changed, 63 insertions(+), 19 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index 5404cb37b1..a0001d13a1 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -54,10 +54,14 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`: branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". -# Supported tokens: {author}, {app}, {number}, {slug} +# Supported tokens: {author}, {app}, {number}, {slug}; custom templates must include {number} # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" +# Optional shorthand namespace. Leave empty to use branch_template/default behavior. +# Example: "features/{app}" expands to "features/{app}/{number}-{slug}" +branch_prefix: "" + # Custom commit message for git init init_commit_message: "[Spec Kit] Initial commit" @@ -70,7 +74,7 @@ auto_commit: message: "[Spec Kit] Add specification" ``` -`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. +`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. `{number}` is required for custom templates so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `/{number}-{slug}`. diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index a19971c471..69f19c7e04 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -37,7 +37,7 @@ Determine the branch numbering strategy by checking configuration in this order: ## Branch Name Template -Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, the script expands these tokens: +Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, it must include `{number}` and the script expands these tokens: - `{author}`: sanitized Git config author (`user.name`, falling back to the email local part) - `{app}`: sanitized Spec Kit init directory name diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index 05b9f3e9e3..f222fa02e6 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -9,6 +9,10 @@ branch_numbering: sequential # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" +# Optional shorthand namespace. Leave empty to use branch_template/default behavior. +# Example: "features/{app}" expands to "features/{app}/{number}-{slug}" +branch_prefix: "" + # Commit message used by `git commit` during repository initialization init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index fc29387242..c92322d8b1 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -138,4 +138,5 @@ config: defaults: branch_numbering: sequential branch_template: "" + branch_prefix: "" init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index 05b9f3e9e3..f222fa02e6 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -9,6 +9,10 @@ branch_numbering: sequential # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" +# Optional shorthand namespace. Leave empty to use branch_template/default behavior. +# Example: "features/{app}" expands to "features/{app}/{number}-{slug}" +branch_prefix: "" + # Commit message used by `git commit` during repository initialization init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index fd08489256..6bb99d3f14 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -142,12 +142,13 @@ _extract_highest_number() { [ -z "$name" ] && continue if [ -n "$scope_prefix" ]; then case "$name" in - "$scope_prefix"*) ;; + "$scope_prefix"*) name="${name#"$scope_prefix"}" ;; *) continue ;; esac fi - if echo "$name" | grep -Eq '(^|/)[0-9]{3,}-' && ! echo "$name" | grep -Eq '(^|/)[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$name" | grep -Eo '(^|/)[0-9]{3,}-' | head -n 1 | sed -E 's|^/||; s/-$//' || echo "0") + name="${name##*/}" + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -362,6 +363,18 @@ render_branch_template() { printf '%s\n' "$rendered" } +validate_branch_template() { + local template="$1" + [ -n "$template" ] || return 0 + case "$template" in + *"{number}"*) ;; + *) + >&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches." + exit 1 + ;; + esac +} + build_branch_name() { local feature_num="$1" local branch_suffix="$2" @@ -386,15 +399,16 @@ branch_scope_prefix() { extract_feature_num_from_branch() { local branch_name="$1" + branch_name="${branch_name##*/}" local match - match=$(printf '%s\n' "$branch_name" | grep -Eo '(^|/)[0-9]{8}-[0-9]{6}-' | head -n 1 || true) + match=$(printf '%s\n' "$branch_name" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true) if [ -n "$match" ]; then - printf '%s\n' "$match" | sed -E 's|^/||; s/-$//' + printf '%s\n' "$match" | sed -E 's/-$//' return fi - match=$(printf '%s\n' "$branch_name" | grep -Eo '(^|/)[0-9]+-' | head -n 1 || true) + match=$(printf '%s\n' "$branch_name" | grep -Eo '^[0-9]+-' | head -n 1 || true) if [ -n "$match" ]; then - printf '%s\n' "$match" | sed -E 's|^/||; s/-$//' + printf '%s\n' "$match" | sed -E 's/-$//' return fi printf '%s\n' "$branch_name" @@ -403,6 +417,7 @@ extract_feature_num_from_branch() { AUTHOR_TOKEN=$(get_author_token) APP_TOKEN=$(get_app_token) BRANCH_TEMPLATE=$(resolve_branch_template) +validate_branch_template "$BRANCH_TEMPLATE" # Function to generate branch name with stop word filtering generate_branch_name() { diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 0d55e890e2..351cf6fdc7 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -38,14 +38,15 @@ check_feature_branch() { local branch branch=$(spec_kit_effective_branch_name "$raw") + local feature_segment="${branch##*/}" # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") local is_sequential=false - if [[ "$branch" =~ (^|/)[0-9]{3,}- ]] && [[ ! "$branch" =~ (^|/)[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ (^|/)[0-9]{7,8}-[0-9]{6}$ ]]; then + if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then is_sequential=true fi - if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ (^|/)[0-9]{8}-[0-9]{6}- ]]; then + if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name" >&2 return 1 diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index a93c96e477..6fa0eeddc9 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -80,9 +80,13 @@ function Get-HighestNumberFromNames { if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) { continue } - if ($name -match '(^|/)(\d{3,})-' -and $name -notmatch '(^|/)\d{8}-\d{6}-') { + if ($ScopePrefix) { + $name = $name.Substring($ScopePrefix.Length) + } + $name = ($name -split '/')[-1] + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { [long]$num = 0 - if ([long]::TryParse($matches[2], [ref]$num) -and $num -gt $highest) { + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { $highest = $num } } @@ -315,6 +319,14 @@ function Expand-BranchTemplate { return $rendered } +function Assert-BranchTemplateValid { + param([string]$Template) + + if ($Template -and -not $Template.Contains('{number}')) { + throw "branch_template must include the {number} token so generated branches remain valid feature branches." + } +} + function New-BranchName { param( [string]$FeatureNum, @@ -345,10 +357,11 @@ function Get-BranchScopePrefix { function Get-FeatureNumberFromBranchName { param([string]$BranchName) - if ($BranchName -match '(?:^|/)(\d{8}-\d{6})-') { + $featureSegment = ($BranchName -split '/')[-1] + if ($featureSegment -match '^(\d{8}-\d{6})-') { return $matches[1] } - if ($BranchName -match '(?:^|/)(\d+)-') { + if ($featureSegment -match '^(\d+)-') { return $matches[1] } return $BranchName @@ -362,6 +375,7 @@ function Get-Utf8ByteCount { $authorToken = Get-GitAuthorToken $appToken = Get-AppToken $branchTemplate = Resolve-BranchTemplate +Assert-BranchTemplateValid -Template $branchTemplate function Get-BranchName { param([string]$Description) diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index c54f8eecde..a7ea724a31 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -37,12 +37,13 @@ function Test-FeatureBranch { $raw = $Branch $Branch = Get-SpecKitEffectiveBranchName $raw + $featureSegment = ($Branch -split '/')[-1] # Accept sequential prefix (3+ digits), at the start or after namespace # segments, but exclude malformed timestamps. - $hasMalformedTimestamp = ($Branch -match '(^|/)[0-9]{7}-[0-9]{6}-') -or ($Branch -match '(^|/)(?:\d{7}|\d{8})-\d{6}$') - $isSequential = ($Branch -match '(^|/)[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - if (-not $isSequential -and $Branch -notmatch '(^|/)\d{8}-\d{6}-') { + $hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') { [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name") return $false From 8c5f2d71ae813cef592c398ed48127e4e824a76f Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 2 Jul 2026 16:03:00 +0200 Subject: [PATCH 5/7] fix: address git branch template review feedback Address Copilot review feedback for branch_prefix help text, namespaced GIT_BRANCH_NAME fallback behavior, final-segment validation docs, and Bash UTF-8 byte reporting. Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/git/README.md | 5 +- .../git/commands/speckit.git.feature.md | 4 +- .../git/commands/speckit.git.validate.md | 6 +- extensions/git/config-template.yml | 1 + extensions/git/git-config.yml | 1 + .../scripts/bash/create-new-feature-branch.sh | 22 +++-- .../powershell/create-new-feature-branch.ps1 | 7 ++ tests/extensions/git/test_git_extension.py | 81 +++++++++++++++++++ 8 files changed, 115 insertions(+), 12 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index a0001d13a1..40ca9b9b33 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -54,7 +54,8 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`: branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". -# Supported tokens: {author}, {app}, {number}, {slug}; custom templates must include {number} +# Supported tokens: {author}, {app}, {number}, {slug}; the final path segment +# must start with {number}- so generated names remain valid feature branches. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" @@ -74,7 +75,7 @@ auto_commit: message: "[Spec Kit] Add specification" ``` -`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. `{number}` is required for custom templates so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. +`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `/{number}-{slug}`. diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 69f19c7e04..f2bdcc0ec2 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). 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: - The script uses the exact value as the branch name, bypassing all prefix/suffix generation - `--short-name`, `--number`, and `--timestamp` flags are ignored -- `FEATURE_NUM` is extracted from the first numeric or timestamp segment (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name +- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name ## Prerequisites @@ -37,7 +37,7 @@ Determine the branch numbering strategy by checking configuration in this order: ## Branch Name Template -Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, it must include `{number}` and the script expands these tokens: +Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, its final path segment must start with `{number}-` and the script expands these tokens: - `{author}`: sanitized Git config author (`user.name`, falling back to the email local part) - `{app}`: sanitized Spec Kit init directory name diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md index 83f26061fa..c7feeb2600 100644 --- a/extensions/git/commands/speckit.git.validate.md +++ b/extensions/git/commands/speckit.git.validate.md @@ -22,10 +22,10 @@ Get the current branch name: git rev-parse --abbrev-ref HEAD ``` -The branch name must contain one of these feature markers either at the start or after one or more namespace path segments: +The branch name's final path segment must start with one of these feature markers: -1. **Sequential**: `(^|/)[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`) -2. **Timestamp**: `(^|/)[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`) +1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`) +2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`) ## Execution diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index f222fa02e6..f0ef3ac453 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -6,6 +6,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug} +# Final path segment must start with {number}- so generated branches validate. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index f222fa02e6..f0ef3ac453 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -6,6 +6,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug} +# Final path segment must start with {number}- so generated branches validate. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 6bb99d3f14..dabd3fa9c2 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -77,6 +77,7 @@ while [ $i -le $# ]; do echo "" echo "Configuration:" echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" + echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}" echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" @@ -366,6 +367,8 @@ render_branch_template() { validate_branch_template() { local template="$1" [ -n "$template" ] || return 0 + local feature_segment + feature_segment="${template##*/}" case "$template" in *"{number}"*) ;; *) @@ -373,6 +376,13 @@ validate_branch_template() { exit 1 ;; esac + case "$feature_segment" in + "{number}-"*) ;; + *) + >&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches." + exit 1 + ;; + esac } build_branch_name() { @@ -399,14 +409,14 @@ branch_scope_prefix() { extract_feature_num_from_branch() { local branch_name="$1" - branch_name="${branch_name##*/}" + local feature_segment="${branch_name##*/}" local match - match=$(printf '%s\n' "$branch_name" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true) + match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true) if [ -n "$match" ]; then printf '%s\n' "$match" | sed -E 's/-$//' return fi - match=$(printf '%s\n' "$branch_name" | grep -Eo '^[0-9]+-' | head -n 1 || true) + match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true) if [ -n "$match" ]; then printf '%s\n' "$match" | sed -E 's/-$//' return @@ -525,8 +535,10 @@ elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then fi >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" - >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" - >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" + ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME") + TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)" fi if [ "$DRY_RUN" != true ]; then diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 6fa0eeddc9..d93d9bfca4 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -36,6 +36,7 @@ if ($Help) { Write-Host "" Write-Host "Configuration:" Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" + Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}" Write-Host "" exit 0 } @@ -325,6 +326,12 @@ function Assert-BranchTemplateValid { if ($Template -and -not $Template.Contains('{number}')) { throw "branch_template must include the {number} token so generated branches remain valid feature branches." } + if ($Template) { + $featureSegment = ($Template -split '/')[-1] + if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) { + throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches." + } + } } function New-BranchName { diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 3f9e240fae..4addef58e6 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -320,6 +320,15 @@ def test_output_omits_has_git_for_parity(self, tmp_path: Path): assert rt.returncode == 0, rt.stderr assert "HAS_GIT" not in rt.stdout + def test_help_documents_branch_prefix(self, tmp_path: Path): + """--help documents both template config knobs.""" + project = _setup_project(tmp_path) + result = _run_bash("create-new-feature-branch.sh", project, "--help") + + assert result.returncode == 0 + assert "branch_template" in result.stdout + assert "branch_prefix" in result.stdout + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): """A short word is dropped from the derived branch name unless it appears as an acronym in UPPERCASE in the description (case-sensitive, must match the @@ -443,6 +452,20 @@ def test_branch_template_requires_number_token(self, tmp_path: Path): assert "branch_template" in result.stderr assert "{number}" in result.stderr + def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path): + """Templates must render a final path segment that validation accepts.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/{slug}-{number}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}-" in result.stderr + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") @@ -471,6 +494,27 @@ def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_p assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" assert data["FEATURE_NUM"] == "042" + def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path): + """GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM.""" + project = _setup_project(tmp_path / "app-a") + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch" + assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch" + + def test_truncation_warning_reports_utf8_bytes(self): + """Bash truncation warnings should use the same byte counter as enforcement.""" + source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8") + + assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source + assert '_byte_length "$BRANCH_NAME"' in source + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): """Branches checked out in sibling worktrees still reserve their prefix.""" project = _setup_project(tmp_path / "project") @@ -592,6 +636,15 @@ def test_output_omits_has_git_to_match_bash(self, tmp_path: Path): assert rt.returncode == 0, rt.stderr assert "HAS_GIT" not in rt.stdout + def test_help_documents_branch_prefix(self, tmp_path: Path): + """-Help documents both template config knobs.""" + project = _setup_project(tmp_path) + result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help") + + assert result.returncode == 0 + assert "branch_template" in result.stdout + assert "branch_prefix" in result.stdout + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): """PowerShell must match the bash twin: a short word is dropped unless it appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match).""" @@ -713,6 +766,20 @@ def test_branch_template_requires_number_token(self, tmp_path: Path): assert "branch_template" in result.stderr assert "{number}" in result.stderr + def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path): + """PowerShell rejects templates whose final segment cannot validate.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{app}/{slug}-{number}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{number}-" in result.stderr + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") @@ -741,6 +808,20 @@ def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_p assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" assert data["FEATURE_NUM"] == "042" + def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path): + """PowerShell keeps the full override name when no feature marker exists.""" + project = _setup_project(tmp_path / "app-a") + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "Ignored description", + env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch" + assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch" + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.ps1 works without git.""" project = _setup_project(tmp_path, git=False) From b0a56169eb26abc1a43f9e57b815cf3c694c8d60 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 2 Jul 2026 18:52:22 +0200 Subject: [PATCH 6/7] fix: reject slug-scoped branch templates Reject branch templates that place {slug} before {number}, because that makes namespace scanning depend on the generated feature slug and can reset numbering per feature name. Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/git/README.md | 6 ++-- .../git/commands/speckit.git.feature.md | 2 +- extensions/git/config-template.yml | 2 +- extensions/git/git-config.yml | 2 +- .../scripts/bash/create-new-feature-branch.sh | 6 ++++ .../powershell/create-new-feature-branch.ps1 | 5 +++ tests/extensions/git/test_git_extension.py | 34 +++++++++++++++++-- 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index 40ca9b9b33..b5df3e31ee 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -54,8 +54,8 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`: branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". -# Supported tokens: {author}, {app}, {number}, {slug}; the final path segment -# must start with {number}- so generated names remain valid feature branches. +# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear +# before {number}, and the final path segment must start with {number}-. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" @@ -75,7 +75,7 @@ auto_commit: message: "[Spec Kit] Add specification" ``` -`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. +`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`. For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `/{number}-{slug}`. diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index f2bdcc0ec2..01f664f84b 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -37,7 +37,7 @@ Determine the branch numbering strategy by checking configuration in this order: ## Branch Name Template -Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, its final path segment must start with `{number}-` and the script expands these tokens: +Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens: - `{author}`: sanitized Git config author (`user.name`, falling back to the email local part) - `{app}`: sanitized Spec Kit init directory name diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index f0ef3ac453..99e3d31692 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -6,7 +6,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug} -# Final path segment must start with {number}- so generated branches validate. +# {slug} must not appear before {number}; final path segment must start with {number}-. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index f0ef3ac453..99e3d31692 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -6,7 +6,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug} -# Final path segment must start with {number}- so generated branches validate. +# {slug} must not appear before {number}; final path segment must start with {number}-. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index dabd3fa9c2..d0ac336072 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -376,6 +376,12 @@ validate_branch_template() { exit 1 ;; esac + case "$template" in + *"{slug}"*"{number}"*) + >&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment." + exit 1 + ;; + esac case "$feature_segment" in "{number}-"*) ;; *) diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index d93d9bfca4..49e8fc7f54 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -327,6 +327,11 @@ function Assert-BranchTemplateValid { throw "branch_template must include the {number} token so generated branches remain valid feature branches." } if ($Template) { + $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal) + $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal) + if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) { + throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment." + } $featureSegment = ($Template -split '/')[-1] if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) { throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches." diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 4addef58e6..503c9bb222 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -455,7 +455,7 @@ def test_branch_template_requires_number_token(self, tmp_path: Path): def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path): """Templates must render a final path segment that validation accepts.""" project = _setup_project(tmp_path / "app-a") - _write_config(project, 'branch_template: "{author}/{app}/{slug}-{number}"\n') + _write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n') result = _run_bash( "create-new-feature-branch.sh", project, @@ -466,6 +466,21 @@ def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp assert "branch_template" in result.stderr assert "{number}-" in result.stderr + def test_branch_template_rejects_slug_before_number(self, tmp_path: Path): + """{slug} before {number} would make branch-number scanning slug-specific.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n') + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{slug}" in result.stderr + assert "{number}" in result.stderr + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") @@ -769,7 +784,7 @@ def test_branch_template_requires_number_token(self, tmp_path: Path): def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path): """PowerShell rejects templates whose final segment cannot validate.""" project = _setup_project(tmp_path / "app-a") - _write_config(project, 'branch_template: "{author}/{app}/{slug}-{number}"\n') + _write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n') result = _run_pwsh( "create-new-feature-branch.ps1", project, @@ -780,6 +795,21 @@ def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp assert "branch_template" in result.stderr assert "{number}-" in result.stderr + def test_branch_template_rejects_slug_before_number(self, tmp_path: Path): + """PowerShell rejects templates where {slug} scopes number scanning.""" + project = _setup_project(tmp_path / "app-a") + _write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n') + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour", + ) + + assert result.returncode != 0 + assert "branch_template" in result.stderr + assert "{slug}" in result.stderr + assert "{number}" in result.stderr + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") From bbb51b1a909dbefd16c269ffbe91f935d5ddb91c Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 2 Jul 2026 22:24:09 +0200 Subject: [PATCH 7/7] fix: ignore malformed timestamp refs when numbering Align branch-number scanning with feature-branch validation so malformed timestamp-looking refs do not inflate sequential numbering. Also updates the stale git-common comment called out in review. Assisted-by: Codex (model: GPT-5, autonomous) --- .../scripts/bash/create-new-feature-branch.sh | 5 ++- extensions/git/scripts/bash/git-common.sh | 2 +- .../powershell/create-new-feature-branch.ps1 | 4 +- tests/extensions/git/test_git_extension.py | 38 +++++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index d0ac336072..856cb0bec4 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -148,7 +148,10 @@ _extract_highest_number() { esac fi name="${name##*/}" - if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + if echo "$name" | grep -Eq '^[0-9]{3,}-' \ + && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \ + && ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \ + && ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 351cf6fdc7..60bb79c867 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -25,7 +25,7 @@ spec_kit_effective_branch_name() { # Validate that a branch name matches the expected feature branch pattern. # Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats, # either at the start of the branch or after path-style namespace prefixes. -# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin. check_feature_branch() { local raw="$1" local has_git_repo="$2" diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 49e8fc7f54..68983db63c 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -85,7 +85,9 @@ function Get-HighestNumberFromNames { $name = $name.Substring($ScopePrefix.Length) } $name = ($name -split '/')[-1] - if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + $hasTimestampPrefix = $name -match '^\d{8}-\d{6}-' + $hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$') + if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { $highest = $num diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 503c9bb222..71dfd02223 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -420,6 +420,25 @@ def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_pat assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next" assert data["FEATURE_NUM"] == "008" + def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path): + """Malformed timestamp-looking branches must not inflate sequential numbering.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): """Templated branch numbering ignores branches outside the current namespace.""" project = _setup_project(tmp_path / "app-a") @@ -749,6 +768,25 @@ def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_pat assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next" assert data["FEATURE_NUM"] == "008" + def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path): + """PowerShell skips malformed timestamp-looking refs during sequential numbering.""" + project = _setup_project(tmp_path / "app-a") + subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True) + _write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True) + subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "jdoe/app-a/008-next" + assert data["FEATURE_NUM"] == "008" + def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path): """PowerShell templated numbering ignores branches outside the namespace.""" project = _setup_project(tmp_path / "app-a")