Support namespaced git feature branch templates (#3293)

* test: cover namespaced git branch templates

Assisted-by: Codex (model: GPT-5, autonomous)

* feat: support namespaced git branch templates

Assisted-by: Codex (model: GPT-5, autonomous)

* test: cover git branch template edge cases

Assisted-by: Codex (model: GPT-5, autonomous)

* fix: harden git branch template parsing

Assisted-by: Codex (model: GPT-5, autonomous)

* 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)

* 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)

* 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)
This commit is contained in:
Pascal THUET
2026-07-06 22:41:58 +02:00
committed by GitHub
parent 92b7cf7658
commit f494a8e33e
11 changed files with 868 additions and 90 deletions

View File

@@ -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,
@@ -320,6 +320,15 @@ class TestCreateFeatureBash:
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
@@ -363,6 +372,183 @@ class TestCreateFeatureBash:
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_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_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")
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_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_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}/feature-{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_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")
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_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_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")
@@ -484,6 +670,15 @@ class TestCreateFeaturePowerShell:
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)."""
@@ -525,6 +720,176 @@ class TestCreateFeaturePowerShell:
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_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_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")
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_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_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}/feature-{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_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")
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_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_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)
@@ -1011,13 +1376,31 @@ class TestGitCommonBash:
)
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
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
@@ -1037,3 +1420,33 @@ class TestGitCommonPowerShell:
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
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