mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* feat(extensions): port git extension scripts to Python Ports git-common, initialize-repo, auto-commit, and create-new-feature-branch to extensions/git/scripts/python/, mirroring the bash/PowerShell twins. Parity tests run each bash script and its Python twin in identical projects and compare output, exit codes, and resulting git state. Fixes #3282 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: match bash error message for whitespace-only descriptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle unreadable git-config.yml and assert stderr parity An unreadable config file raised OSError with a full traceback from _parse_auto_commit_config. Treat it like a missing config: auto-commit stays disabled. Covered by a chmod-000 test (skipped on non-POSIX and as root). _assert_parity now also compares stderr so warning or usage-text regressions between the bash and Python twins fail the suite. All existing parity tests pass with the stricter assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions/git): pass script path to core.get_repo_root for cwd-outside-repo callers Without script_file, core.get_repo_root() falls back to Path.cwd() when SPECIFY_INIT_DIR is unset and no .specify root is found upward — the bash twin instead falls back to the script's install location (.specify/scripts/...). Pass script_file so both twins resolve the same repo_root; TypeError fallback keeps older cores working. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: exercise SPECIFY_INIT_DIR from outside the project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions/git): handle UnicodeDecodeError and USER/USERNAME fallback - Catch (OSError, UnicodeDecodeError) when reading git-config.yml in create_new_feature_branch.py, initialize_repo.py, and auto_commit.py so invalid UTF-8 config falls back to defaults instead of crashing with a traceback. - Fall back to USERNAME (then "unknown") when USER is unset when deriving the branch author token, matching the PowerShell twin's Windows-friendly fallback chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions/git): platform-aware persist hint and stronger SPECIFY_INIT_DIR test - Add a shared _persist_hint() helper in create_new_feature_branch.py and use it for both the JSON-mode stderr hint and the human-readable stdout hint, so there is a single place emitting the SPECIFY_FEATURE persistence guidance. On Windows (os.name == "nt") it prints PowerShell $env:VAR = "..." syntax; elsewhere it keeps the existing POSIX export VAR=... syntax (parity with the bash twin). - Rework test_specify_init_dir_resolves_target_project so SPECIFY_INIT_DIR is the only thing that can produce the observed result: the script now runs from a separate host_proj (no existing specs, so script/cwd-based discovery would yield 001) while SPECIFY_INIT_DIR points at a different target_proj that already has an existing spec (007-existing, so the override must yield 008). The old version pointed SPECIFY_INIT_DIR at the same project the script was installed in, so it passed even if the env var were ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): tolerate missing Git executable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): quote PowerShell persist hint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(git): match bash persist hint escaping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(git): ignore unterminated config record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(git): handle Windows persist hint parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(init): install Python shared scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(git): normalize Windows persistence hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
635 lines
23 KiB
Python
635 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Git extension: create_new_feature_branch.py
|
|
|
|
Creates a git feature branch only. The feature directory and spec file are
|
|
created by the core create-new-feature script. Python port of
|
|
``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``.
|
|
|
|
Loads the core Python helpers from the project's installed scripts when
|
|
available, falling back to the minimal git helpers next to this script.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names
|
|
|
|
USAGE = (
|
|
"Usage: create_new_feature_branch.py [--json] [--dry-run] "
|
|
"[--allow-existing-branch] [--short-name <name>] [--number N] "
|
|
"[--timestamp] <feature_description>"
|
|
)
|
|
|
|
HELP_TEXT = f"""{USAGE}
|
|
|
|
Options:
|
|
--json Output in JSON format
|
|
--dry-run Compute branch name without creating the branch
|
|
--allow-existing-branch Switch to branch if it already exists instead of failing
|
|
--short-name <name> Provide a custom short name (2-4 words) for the branch
|
|
--number N Specify branch number manually (overrides auto-detection)
|
|
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
|
--help, -h Show this help message
|
|
|
|
Environment variables:
|
|
GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation
|
|
|
|
Configuration:
|
|
branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}}
|
|
branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}}
|
|
|
|
Examples:
|
|
create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth'
|
|
create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5
|
|
create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication'
|
|
GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description'
|
|
"""
|
|
|
|
STOP_WORDS = frozenset(
|
|
"i a an the to for of in on at by with from is are was were be been being "
|
|
"have has had do does did will would should could can may might must shall "
|
|
"this that these those my your our their want need add get set".split()
|
|
)
|
|
|
|
|
|
def _err(message: str) -> None:
|
|
print(message, file=sys.stderr)
|
|
|
|
|
|
def _persist_hint(var_name: str, value: str) -> str:
|
|
"""Shell-appropriate guidance for persisting an env var in the caller's shell."""
|
|
if os.name == "nt":
|
|
escaped_value = value.replace("'", "''")
|
|
return f"$env:{var_name} = '{escaped_value}'"
|
|
escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
|
|
return f"export {var_name}={escaped_value}"
|
|
|
|
|
|
@dataclass
|
|
class Args:
|
|
json_mode: bool = False
|
|
dry_run: bool = False
|
|
allow_existing: bool = False
|
|
short_name: str = ""
|
|
branch_number: str = ""
|
|
use_timestamp: bool = False
|
|
description_parts: list[str] = field(default_factory=list)
|
|
|
|
|
|
def parse_args(argv: list[str]) -> Args:
|
|
args = Args()
|
|
i = 0
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg == "--json":
|
|
args.json_mode = True
|
|
elif arg == "--dry-run":
|
|
args.dry_run = True
|
|
elif arg == "--allow-existing-branch":
|
|
args.allow_existing = True
|
|
elif arg == "--short-name":
|
|
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
|
_err("Error: --short-name requires a value")
|
|
raise SystemExit(1)
|
|
i += 1
|
|
args.short_name = argv[i]
|
|
elif arg == "--number":
|
|
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
|
_err("Error: --number requires a value")
|
|
raise SystemExit(1)
|
|
i += 1
|
|
args.branch_number = argv[i]
|
|
if not re.fullmatch(r"[0-9]+", args.branch_number):
|
|
_err("Error: --number must be a non-negative integer")
|
|
raise SystemExit(1)
|
|
elif arg == "--timestamp":
|
|
args.use_timestamp = True
|
|
elif arg in ("--help", "-h"):
|
|
print(HELP_TEXT)
|
|
raise SystemExit(0)
|
|
else:
|
|
args.description_parts.append(arg)
|
|
i += 1
|
|
return args
|
|
|
|
|
|
# ── Core helpers loading ─────────────────────────────────────────────────────
|
|
|
|
|
|
def _find_project_root(start: Path) -> Path | None:
|
|
current = start
|
|
while True:
|
|
if (current / ".specify").is_dir() or (current / ".git").exists():
|
|
return current
|
|
if current.parent == current:
|
|
return None
|
|
current = current.parent
|
|
|
|
|
|
def _load_core_common(project_root: Path | None):
|
|
"""Load the core common.py from the project's installed scripts.
|
|
|
|
Search locations in priority order, mirroring the bash script:
|
|
1. .specify/scripts/python/common.py (installed project)
|
|
2. scripts/python/common.py (source checkout fallback)
|
|
Returns the loaded module or None.
|
|
"""
|
|
if project_root is None:
|
|
return None
|
|
for relative in (".specify/scripts/python/common.py", "scripts/python/common.py"):
|
|
candidate = project_root / relative
|
|
if candidate.is_file():
|
|
spec = importlib.util.spec_from_file_location("speckit_core_common", candidate)
|
|
if spec is None or spec.loader is None:
|
|
continue
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
return None
|
|
|
|
|
|
def _local_has_git(repo_root: Path) -> bool:
|
|
git_marker = repo_root / ".git"
|
|
if not (git_marker.is_dir() or git_marker.is_file()):
|
|
return False
|
|
if shutil.which("git") is None:
|
|
return False
|
|
return (
|
|
subprocess.run(
|
|
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
|
|
capture_output=True,
|
|
text=True,
|
|
).returncode
|
|
== 0
|
|
)
|
|
|
|
|
|
# ── Numbering ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def get_highest_from_specs(specs_dir: Path) -> int:
|
|
highest = 0
|
|
if specs_dir.is_dir():
|
|
for entry in specs_dir.iterdir():
|
|
if not entry.is_dir():
|
|
continue
|
|
name = entry.name
|
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
|
if re.match(r"^[0-9]{3,}-", name) and not re.match(
|
|
r"^[0-9]{8}-[0-9]{6}-", name
|
|
):
|
|
number = int(re.match(r"^[0-9]+", name).group(0))
|
|
highest = max(highest, number)
|
|
return highest
|
|
|
|
|
|
def _extract_highest_number(names: list[str], scope_prefix: str) -> int:
|
|
"""Extract the highest sequential feature number from a list of ref names."""
|
|
highest = 0
|
|
for name in names:
|
|
if not name:
|
|
continue
|
|
if scope_prefix:
|
|
if not name.startswith(scope_prefix):
|
|
continue
|
|
name = name[len(scope_prefix) :]
|
|
name = name.rsplit("/", 1)[-1]
|
|
if (
|
|
re.match(r"^[0-9]{3,}-", name)
|
|
and not re.match(r"^[0-9]{8}-[0-9]{6}-", name)
|
|
and not re.match(r"^[0-9]{7}-[0-9]{6}-", name)
|
|
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name)
|
|
):
|
|
match = re.match(r"^([0-9]{3,})-", name)
|
|
number = int(match.group(1)) if match else 0
|
|
highest = max(highest, number)
|
|
return highest
|
|
|
|
|
|
def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]:
|
|
if shutil.which("git") is None:
|
|
return []
|
|
env = {**os.environ, **(env_extra or {})}
|
|
result = subprocess.run(
|
|
["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
return result.stdout.splitlines()
|
|
|
|
|
|
def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int:
|
|
names = []
|
|
for line in _git_lines(repo_root, "branch", "-a"):
|
|
line = re.sub(r"^[+*]\s+", "", line)
|
|
line = line.lstrip()
|
|
line = re.sub(r"^remotes/[^/]*/", "", line)
|
|
names.append(line)
|
|
return _extract_highest_number(names, scope_prefix)
|
|
|
|
|
|
def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int:
|
|
"""Highest number from remote branches without fetching (side-effect-free)."""
|
|
highest = 0
|
|
for remote in _git_lines(repo_root, "remote"):
|
|
refs = _git_lines(
|
|
repo_root,
|
|
"ls-remote",
|
|
"--heads",
|
|
remote,
|
|
env_extra={"GIT_TERMINAL_PROMPT": "0"},
|
|
)
|
|
names = [re.sub(r".*refs/heads/", "", ref) for ref in refs]
|
|
highest = max(highest, _extract_highest_number(names, scope_prefix))
|
|
return highest
|
|
|
|
|
|
def check_existing_branches(
|
|
repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str
|
|
) -> int:
|
|
"""Check existing branches and return the next available number."""
|
|
if skip_fetch:
|
|
highest_branch = max(
|
|
get_highest_from_remote_refs(repo_root, scope_prefix),
|
|
get_highest_from_branches(repo_root, scope_prefix),
|
|
)
|
|
else:
|
|
subprocess.run(
|
|
["git", "fetch", "--all", "--prune"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
highest_branch = get_highest_from_branches(repo_root, scope_prefix)
|
|
|
|
return max(highest_branch, get_highest_from_specs(specs_dir)) + 1
|
|
|
|
|
|
# ── Branch naming ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def clean_branch_name(name: str) -> str:
|
|
name = re.sub(r"[^a-z0-9]", "-", name.lower())
|
|
name = re.sub(r"-+", "-", name)
|
|
return name.strip("-")
|
|
|
|
|
|
def generate_branch_name(description: str) -> str:
|
|
"""Generate a branch suffix from the description with stop word filtering."""
|
|
clean_name = re.sub(r"[^a-z0-9]", " ", description.lower())
|
|
|
|
meaningful_words = []
|
|
for word in clean_name.split():
|
|
if word in STOP_WORDS:
|
|
continue
|
|
if len(word) >= 3:
|
|
meaningful_words.append(word)
|
|
# Keep short words only when they appear uppercased in the original
|
|
# description (acronyms like "API" or "DB").
|
|
elif re.search(rf"\b{re.escape(word.upper())}\b", description):
|
|
meaningful_words.append(word)
|
|
|
|
if meaningful_words:
|
|
max_words = 4 if len(meaningful_words) == 4 else 3
|
|
return "-".join(meaningful_words[:max_words])
|
|
|
|
cleaned = clean_branch_name(description)
|
|
return "-".join([part for part in cleaned.split("-") if part][:3])
|
|
|
|
|
|
def branch_token(value: str, fallback: str) -> str:
|
|
cleaned = clean_branch_name(value)
|
|
return cleaned if cleaned else fallback
|
|
|
|
|
|
def get_author_token(repo_root: Path) -> str:
|
|
author = ""
|
|
if shutil.which("git") is not None:
|
|
lines = _git_lines(repo_root, "config", "user.name")
|
|
author = lines[0] if lines else ""
|
|
if not author:
|
|
lines = _git_lines(repo_root, "config", "user.email")
|
|
email = lines[0] if lines else ""
|
|
author = email.split("@")[0]
|
|
if not author:
|
|
author = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
|
|
return branch_token(author, "unknown")
|
|
|
|
|
|
def get_app_token(repo_root: Path) -> str:
|
|
return branch_token(repo_root.name, "app")
|
|
|
|
|
|
def read_git_config_value(config_file: Path, key: str) -> str:
|
|
if not config_file.is_file():
|
|
return ""
|
|
try:
|
|
lines = config_file.read_text(encoding="utf-8").splitlines()
|
|
except (OSError, UnicodeDecodeError):
|
|
return ""
|
|
for line in lines:
|
|
if re.match(rf"^\s*{re.escape(key)}:", line):
|
|
value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line)
|
|
value = re.sub(r"\s+#.*$", "", value)
|
|
value = value.strip()
|
|
value = re.sub(r'^"|"$', "", value)
|
|
value = re.sub(r"^'|'$", "", value)
|
|
return value
|
|
return ""
|
|
|
|
|
|
def resolve_branch_template(config_file: Path) -> str:
|
|
template = read_git_config_value(config_file, "branch_template")
|
|
if template:
|
|
return template
|
|
|
|
prefix = read_git_config_value(config_file, "branch_prefix")
|
|
if not prefix:
|
|
return ""
|
|
if prefix.endswith("/"):
|
|
return f"{prefix}{{number}}-{{slug}}"
|
|
return f"{prefix}/{{number}}-{{slug}}"
|
|
|
|
|
|
def validate_branch_template(template: str) -> None:
|
|
if not template:
|
|
return
|
|
if "{number}" not in template:
|
|
_err(
|
|
"Error: branch_template must include the {number} token so generated "
|
|
"branches remain valid feature branches."
|
|
)
|
|
raise SystemExit(1)
|
|
slug_index = template.find("{slug}")
|
|
if slug_index != -1 and "{number}" in template[slug_index:]:
|
|
_err(
|
|
"Error: branch_template must not place {slug} before {number}; "
|
|
"use {slug} only in the final feature segment."
|
|
)
|
|
raise SystemExit(1)
|
|
feature_segment = template.rsplit("/", 1)[-1]
|
|
if not feature_segment.startswith("{number}-"):
|
|
_err(
|
|
"Error: branch_template must put {number}- at the start of the final "
|
|
"path segment so generated branches remain valid feature branches."
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def render_branch_template(
|
|
template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str
|
|
) -> str:
|
|
rendered = template
|
|
rendered = rendered.replace("{author}", author_token)
|
|
rendered = rendered.replace("{app}", app_token)
|
|
rendered = rendered.replace("{number}", feature_num)
|
|
rendered = rendered.replace("{slug}", branch_suffix)
|
|
return rendered
|
|
|
|
|
|
def extract_feature_num_from_branch(branch_name: str) -> str:
|
|
feature_segment = branch_name.rsplit("/", 1)[-1]
|
|
match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)
|
|
if match:
|
|
return match.group(0).rstrip("-")
|
|
match = re.match(r"^[0-9]+-", feature_segment)
|
|
if match:
|
|
return match.group(0).rstrip("-")
|
|
return branch_name
|
|
|
|
|
|
def _byte_length(value: str) -> int:
|
|
return len(value.encode("utf-8"))
|
|
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
args = parse_args(argv)
|
|
|
|
feature_description = " ".join(args.description_parts)
|
|
if not feature_description:
|
|
_err(USAGE)
|
|
return 1
|
|
feature_description = feature_description.strip()
|
|
if not feature_description:
|
|
_err("Error: Feature description cannot be empty or contain only whitespace")
|
|
return 1
|
|
|
|
project_root = _find_project_root(SCRIPT_DIR)
|
|
core = _load_core_common(project_root)
|
|
|
|
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the
|
|
# core helpers were not found, refuse rather than silently falling back to
|
|
# the wrong root.
|
|
if os.environ.get("SPECIFY_INIT_DIR") and (
|
|
core is None or not hasattr(core, "resolve_specify_init_dir")
|
|
):
|
|
_err(
|
|
"Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts "
|
|
"(common.py with resolve_specify_init_dir), which were not found."
|
|
)
|
|
return 1
|
|
|
|
if core is not None and hasattr(core, "get_repo_root"):
|
|
# Pass script path so cwd-outside-repo callers land on the same
|
|
# fallback the bash twin does. Older cores don't accept the kwarg —
|
|
# fall back to the no-arg call for compatibility.
|
|
try:
|
|
repo_root = core.get_repo_root(script_file=Path(__file__))
|
|
except TypeError:
|
|
repo_root = core.get_repo_root()
|
|
else:
|
|
toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel")
|
|
if toplevel:
|
|
repo_root = Path(toplevel[0])
|
|
elif project_root is not None:
|
|
repo_root = project_root
|
|
else:
|
|
_err("Error: Could not determine repository root.")
|
|
return 1
|
|
repo_root = Path(repo_root)
|
|
|
|
has_git_repo = _local_has_git(repo_root)
|
|
|
|
specs_dir = repo_root / "specs"
|
|
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
|
|
|
|
author_token = get_author_token(repo_root)
|
|
app_token = get_app_token(repo_root)
|
|
branch_template = resolve_branch_template(config_file)
|
|
validate_branch_template(branch_template)
|
|
|
|
def build_branch_name(feature_num: str, branch_suffix: str) -> str:
|
|
if branch_template:
|
|
return render_branch_template(
|
|
branch_template, feature_num, branch_suffix, author_token, app_token
|
|
)
|
|
return f"{feature_num}-{branch_suffix}"
|
|
|
|
branch_number = args.branch_number
|
|
|
|
# Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix)
|
|
env_branch_name = os.environ.get("GIT_BRANCH_NAME", "")
|
|
if env_branch_name:
|
|
branch_name = env_branch_name
|
|
feature_num = extract_feature_num_from_branch(branch_name)
|
|
branch_suffix = branch_name
|
|
else:
|
|
if args.short_name:
|
|
branch_suffix = clean_branch_name(args.short_name)
|
|
else:
|
|
branch_suffix = generate_branch_name(feature_description)
|
|
|
|
if args.use_timestamp and branch_number:
|
|
_err("[specify] Warning: --number is ignored when --timestamp is used")
|
|
branch_number = ""
|
|
|
|
if args.use_timestamp:
|
|
feature_num = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
branch_name = build_branch_name(feature_num, branch_suffix)
|
|
else:
|
|
scope_prefix = ""
|
|
if branch_template:
|
|
prefix_template = branch_template.split("{number}")[0]
|
|
scope_prefix = render_branch_template(
|
|
prefix_template, "", branch_suffix, author_token, app_token
|
|
)
|
|
if not branch_number:
|
|
if args.dry_run and has_git_repo:
|
|
branch_number = check_existing_branches(
|
|
repo_root, specs_dir, True, scope_prefix
|
|
)
|
|
elif args.dry_run:
|
|
branch_number = get_highest_from_specs(specs_dir) + 1
|
|
elif has_git_repo:
|
|
branch_number = check_existing_branches(
|
|
repo_root, specs_dir, False, scope_prefix
|
|
)
|
|
else:
|
|
branch_number = get_highest_from_specs(specs_dir) + 1
|
|
|
|
feature_num = f"{int(branch_number):03d}"
|
|
branch_name = build_branch_name(feature_num, branch_suffix)
|
|
|
|
branch_byte_len = _byte_length(branch_name)
|
|
if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH:
|
|
_err(
|
|
"Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. "
|
|
f"Provided value is {branch_byte_len} bytes."
|
|
)
|
|
return 1
|
|
if branch_byte_len > MAX_BRANCH_LENGTH:
|
|
original_branch_name = branch_name
|
|
truncated_suffix = branch_suffix
|
|
while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix:
|
|
truncated_suffix = truncated_suffix[:-1]
|
|
truncated_suffix = truncated_suffix.rstrip("-")
|
|
branch_name = build_branch_name(feature_num, truncated_suffix)
|
|
if _byte_length(branch_name) > MAX_BRANCH_LENGTH:
|
|
_err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.")
|
|
return 1
|
|
|
|
_err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
|
_err(
|
|
f"[specify] Original: {original_branch_name} "
|
|
f"({_byte_length(original_branch_name)} bytes)"
|
|
)
|
|
_err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)")
|
|
|
|
if not args.dry_run:
|
|
if has_git_repo:
|
|
create = subprocess.run(
|
|
["git", "checkout", "-q", "-b", branch_name],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if create.returncode != 0:
|
|
current_branch_lines = _git_lines(
|
|
repo_root, "rev-parse", "--abbrev-ref", "HEAD"
|
|
)
|
|
current_branch = current_branch_lines[0] if current_branch_lines else ""
|
|
branch_exists = bool(
|
|
_git_lines(repo_root, "branch", "--list", branch_name)
|
|
)
|
|
if branch_exists:
|
|
if args.allow_existing:
|
|
if current_branch != branch_name:
|
|
switch = subprocess.run(
|
|
["git", "checkout", "-q", branch_name],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if switch.returncode != 0:
|
|
_err(
|
|
f"Error: Failed to switch to existing branch '{branch_name}'. "
|
|
"Please resolve any local changes or conflicts and try again."
|
|
)
|
|
if switch.stderr.strip():
|
|
_err(switch.stderr.strip())
|
|
return 1
|
|
elif args.use_timestamp:
|
|
_err(
|
|
f"Error: Branch '{branch_name}' already exists. Rerun to get "
|
|
"a new timestamp or use a different --short-name."
|
|
)
|
|
return 1
|
|
else:
|
|
_err(
|
|
f"Error: Branch '{branch_name}' already exists. Please use a "
|
|
"different feature name or specify a different number with --number."
|
|
)
|
|
return 1
|
|
else:
|
|
_err(f"Error: Failed to create git branch '{branch_name}'.")
|
|
if create.stderr.strip():
|
|
_err(create.stderr.strip())
|
|
else:
|
|
_err("Please check your git configuration and try again.")
|
|
return 1
|
|
else:
|
|
_err(
|
|
"[specify] Warning: Git repository not detected; skipped branch "
|
|
f"creation for {branch_name}"
|
|
)
|
|
|
|
_err(f"# To persist: {_persist_hint('SPECIFY_FEATURE', branch_name)}")
|
|
|
|
if args.json_mode:
|
|
payload: dict[str, object] = {
|
|
"BRANCH_NAME": branch_name,
|
|
"FEATURE_NUM": feature_num,
|
|
}
|
|
if args.dry_run:
|
|
payload["DRY_RUN"] = True
|
|
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
else:
|
|
print(f"BRANCH_NAME: {branch_name}")
|
|
print(f"FEATURE_NUM: {feature_num}")
|
|
if not args.dry_run:
|
|
print(
|
|
"# To persist in your shell: "
|
|
f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
|
|
)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|