diff --git a/extensions/git/README.md b/extensions/git/README.md index 9ec18cb54..a6986dee3 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -32,20 +32,6 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`: ```yaml # Branch numbering strategy: "sequential" or "timestamp" branch_numbering: sequential - -# Branch name template -branch_template: "{number}-{short_name}" - -# Whether to fetch remotes before computing next branch number -auto_fetch: true -``` - -### Environment Variable Override - -Set `SPECKIT_GIT_BRANCH_NUMBERING` to override the `branch_numbering` config value: - -```bash -export SPECKIT_GIT_BRANCH_NUMBERING=timestamp ``` ## Installation diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index 95b3960d2..9873689ed 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -3,10 +3,3 @@ # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) branch_numbering: sequential - -# Branch name template (used with sequential numbering) -# Available placeholders: {number}, {short_name} -branch_template: "{number}-{short_name}" - -# Whether to run `git fetch --all --prune` before computing the next branch number -auto_fetch: true diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index ab5b68ea1..dab006fa8 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -45,5 +45,3 @@ tags: defaults: branch_numbering: sequential - branch_template: "{number}-{short_name}" - auto_fetch: true diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index b2d06564a..584b33e61 100644 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -176,21 +176,39 @@ clean_branch_name() { # were initialised with --no-git. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Source common.sh: try the core scripts directory first (standard layout), -# then fall back to the extension's sibling copy. +# Source common.sh using the following priority: +# 1. common.sh next to this script (source checkout layout) +# 2. .specify/scripts/bash/common.sh under the project root (installed project) +# 3. scripts/bash/common.sh under the project root (source checkout fallback) +# 4. git-common.sh next to this script (minimal fallback) +_common_loaded=false + if [ -f "$SCRIPT_DIR/common.sh" ]; then source "$SCRIPT_DIR/common.sh" + _common_loaded=true else # When running from an extension install (.specify/extensions/git/scripts/bash/), - # resolve common.sh from the project's core scripts directory. - _ext_repo_root="$(cd "$SCRIPT_DIR/../../../../.." 2>/dev/null && pwd)" - if [ -f "$_ext_repo_root/scripts/bash/common.sh" ]; then - source "$_ext_repo_root/scripts/bash/common.sh" + # resolve to .specify/ (4 levels up), then to the project root (5 levels up). + _dot_specify="$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)" + _project_root="$(cd "$SCRIPT_DIR/../../../../.." 2>/dev/null && pwd)" + + if [ -n "$_dot_specify" ] && [ -f "$_dot_specify/scripts/bash/common.sh" ]; then + source "$_dot_specify/scripts/bash/common.sh" + _common_loaded=true + elif [ -n "$_project_root" ] && [ -f "$_project_root/scripts/bash/common.sh" ]; then + source "$_project_root/scripts/bash/common.sh" + _common_loaded=true elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true fi fi +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + if git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) HAS_GIT=true diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 76831d3ae..a2cd6ee02 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -146,20 +146,37 @@ if (-not $fallbackRoot) { } # Load common functions (includes Resolve-Template). -# Try the core scripts directory first (standard layout), then fall back -# to the extension's sibling copy. +# Search locations in priority order: +# 1. common.ps1 next to this script (source checkout layout) +# 2. .specify/scripts/powershell/common.ps1 under the project root (installed project) +# 3. scripts/powershell/common.ps1 under the project root (source checkout fallback) +# 4. git-common.ps1 next to this script (minimal fallback) +$commonLoaded = $false + if (Test-Path "$PSScriptRoot/common.ps1") { . "$PSScriptRoot/common.ps1" + $commonLoaded = $true } else { - # When running from an extension install (.specify/extensions/git/scripts/powershell/), - # resolve common.ps1 from the project's core scripts directory. - $extRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../../../../..") -ErrorAction SilentlyContinue) - $coreCommon = if ($extRepoRoot) { Join-Path $extRepoRoot "scripts/powershell/common.ps1" } else { "" } - if ($coreCommon -and (Test-Path $coreCommon)) { - . $coreCommon - } elseif (Test-Path "$PSScriptRoot/git-common.ps1") { - . "$PSScriptRoot/git-common.ps1" + $coreCommonCandidates = @() + + if ($fallbackRoot) { + $coreCommonCandidates += (Join-Path $fallbackRoot ".specify/scripts/powershell/common.ps1") + $coreCommonCandidates += (Join-Path $fallbackRoot "scripts/powershell/common.ps1") } + + $coreCommonCandidates += "$PSScriptRoot/git-common.ps1" + + foreach ($candidate in $coreCommonCandidates) { + if ($candidate -and (Test-Path $candidate)) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." } try { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e6a7fde58..d8e408297 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1195,7 +1195,7 @@ def _install_bundled_git_extension(project_path: Path) -> bool: return False try: - from .extensions import ExtensionManager, ExtensionError + from .extensions import ExtensionManager manager = ExtensionManager(project_path) # Skip if already installed (e.g. via preset) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 537876185..d0bbdfb95 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -73,14 +73,28 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: +2. **Create the feature branch** (unless already handled by a `before_specify` hook — see Pre-Execution Checks above). If a mandatory `before_specify` hook for `speckit.git.feature` already executed and created the branch, **skip this step entirely** and use the branch/spec information from the hook result. Otherwise: **Git extension check**: Before running the branch creation script, check if the git extension is enabled: - Check if `.specify/extensions/.registry` exists (a single JSON file tracking all extensions) - If it exists, read the JSON and look for `extensions.git`; verify its `"enabled"` field is `true` (or not explicitly `false`) - - If the git extension is **disabled** (explicitly `"enabled": false`), **skip branch creation entirely** — proceed directly to step 3 using a spec directory name derived from the short name (e.g., `specs//`) + - If the git extension is **disabled** (explicitly `"enabled": false`), **skip branch creation entirely** — do **not** run the branch creation script. Instead: + - Derive a spec directory name from the short name, e.g. `specs//` + - Explicitly set the following variables so later steps can use them: + - `FEATURE_DIR="specs/"` + - `SPEC_FILE="$FEATURE_DIR/spec.md"` + - Ensure the directory and spec file exist: + - Bash: + - `mkdir -p "$FEATURE_DIR"` + - `: > "$SPEC_FILE"` + - PowerShell: + - `New-Item -ItemType Directory -Path $env:FEATURE_DIR -Force | Out-Null` + - `New-Item -ItemType File -Path $env:SPEC_FILE -Force | Out-Null` + - Then proceed directly to step 3 using `FEATURE_DIR` and `SPEC_FILE` - If the registry file does not exist, proceed with branch creation using the default behavior (backward compatibility) + Run the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: + **Branch numbering mode**: Before running the script, determine the branch numbering strategy: 1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value (extension config takes precedence) 2. If not found, check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) @@ -89,8 +103,8 @@ Given that feature description, do this: - If `"sequential"` or absent, do not add any extra flag (default behavior) **Script resolution**: Use the extension's bundled scripts when available, falling back to core scripts: - - If `.specify/extensions/git/scripts/bash/create-new-feature.sh` exists, use it - - Otherwise, fall back to `{SCRIPT}` + - **Bash**: If `.specify/extensions/git/scripts/bash/create-new-feature.sh` exists, use it; otherwise, fall back to `{SCRIPT}` + - **PowerShell**: If `.specify/extensions/git/scripts/powershell/create-new-feature.ps1` exists, use it; otherwise, fall back to `{SCRIPT}` - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index 74eadf22e..d521d311a 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -87,3 +87,54 @@ class TestBranchNumberingValidation: result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") + + +class TestGitExtensionAutoInstall: + """Tests for bundled git extension auto-install during specify init.""" + + def test_git_extension_installed_during_init(self, tmp_path: Path, monkeypatch): + """verify that `specify init` auto-installs the bundled git extension.""" + from typer.testing import CliRunner + from specify_cli import app + + def _fake_download(project_path, *args, **kwargs): + Path(project_path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) + + project_dir = tmp_path / "proj" + runner = CliRunner() + result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"]) + assert result.exit_code == 0 + + # Extension files should exist + ext_dir = project_dir / ".specify" / "extensions" / "git" + assert ext_dir.is_dir(), "git extension directory not created" + assert (ext_dir / "extension.yml").is_file(), "extension.yml not installed" + + # Registry should contain the git extension + registry_file = project_dir / ".specify" / "extensions" / ".registry" + assert registry_file.is_file(), "extension registry not created" + registry = json.loads(registry_file.read_text()) + assert "git" in registry.get("extensions", {}), "git not in registry" + assert registry["extensions"]["git"]["enabled"] is True + + def test_git_extension_noop_when_already_installed(self, tmp_path: Path): + """_install_bundled_git_extension should no-op if git is already installed.""" + from specify_cli import _install_bundled_git_extension + from specify_cli.extensions import ExtensionManager + + project_dir = tmp_path / "proj" + (project_dir / ".specify").mkdir(parents=True) + + # First install + result1 = _install_bundled_git_extension(project_dir) + assert result1 is True + + # Second install should also succeed (no-op) + result2 = _install_bundled_git_extension(project_dir) + assert result2 is True + + # Only one entry in registry + manager = ExtensionManager(project_dir) + assert manager.registry.is_installed("git")