feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117)

* feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940)

- Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1
  for exact branch naming (bypasses all prefix/suffix generation)
- Fix --force flag for 'specify init <dir>' into existing directories
- Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip,
  commands registered)
- Add TestFeatureDirectoryResolution tests (env var, feature.json,
  priority, branch fallback)
- Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md

* fix: remove unused Tuple import (ruff F401)

* fix: address Copilot review feedback (#2117)

- Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic
  numeric prefix in both bash and PowerShell
- Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte
  truncation logic works correctly
- Add 244-byte length check for GIT_BRANCH_NAME in PowerShell
- Use existing_items for non-empty dir warning with --force
- Skip git extension install if already installed (idempotent --force)
- Wrap PowerShell feature.json parsing in try/catch for malformed JSON
- Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir'
- Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template

* fix: address second round of Copilot review feedback (#2117)

- Guard shutil.rmtree on init failure: skip cleanup when --force merged
  into a pre-existing directory (prevents data loss)
- Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation
- Fix malformed numbered list in specify.md (restore missing step 1)
- Add claude_skills.exists() assert before iterdir() in test

* fix: use UTF-8 byte count for 244-byte branch name limit (#2117)

- Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR}
- PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead
  of .Length (UTF-16 code units)

* fix: address third round of review feedback (#2117)

- Update --dry-run help text in bash and PowerShell (branch name only)
- Fix specify.md JSON example: use concrete path, not literal variable
- Add TestForceExistingDirectory tests (merge + error without --force)
- Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json)

* fix: normalize relative paths and fix Test-HasGit compat (#2117)

- Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json
  relative paths to absolute under repo root
- PowerShell common.ps1: same normalization using IsPathRooted + Join-Path
- PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot
  for compatibility with core common.ps1 (no param) and git-common.ps1
  (optional param with default)

* test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117)

- TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix,
  timestamp prefix, overlong rejection, dry-run)
- TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential
  prefix, timestamp prefix, overlong rejection)
- Tests use extension scripts (not core) via new ext_git_repo and
  ext_ps_git_repo fixtures

* fix: restore git init during specify init + review fixes (#2117)

- Restore is_git_repo() and init_git_repo() functions removed in stage 2
- specify init now runs git init AND installs git extension (not just
  extension install alone)
- Add is_dir() guard for non-here path to prevent uncontrolled error
  when target exists but is a file
- Add python3 JSON fallback in common.sh for multi-line feature.json
  (grep pipeline fails on pretty-printed JSON without jq)

* fix: use init_git_repo error_msg in failure output (#2117)

* fix: ensure_executable_scripts also covers .specify/extensions/ (#2117)

Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh)
may lack execute bits after install. Scan both .specify/scripts/ and
.specify/extensions/ for permission fixing.

* fix: move chmod after extension install + sanitize error_msg (#2117)

- ensure_executable_scripts() now runs after git extension install so
  extension .sh files get execute bits in the same init run
- Sanitize init_git_repo error_msg to single line (replace newlines,
  truncate to 120 chars) to prevent garbled StepTracker output

* fix: use tracker.error for git init/extension failures (#2117)

Git init failure and extension install failure were reported as
tracker.complete (showing green) even on error. Now track a
git_has_error flag and call tracker.error when any step fails,
so the UI correctly reflects the failure state.

* fix: sanitize ext_err in git step tracker for consistent rendering (#2117)
This commit is contained in:
Manfred Riem
2026-04-08 13:48:36 -05:00
committed by GitHub
parent 838bd0fedc
commit 2972dec85c
10 changed files with 805 additions and 257 deletions

View File

@@ -35,7 +35,7 @@ import json5
import stat
import yaml
from pathlib import Path
from typing import Any, Optional, Tuple
from typing import Any, Optional
import typer
from rich.console import Console
@@ -384,6 +384,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool:
return found
def is_git_repo(path: Path = None) -> bool:
"""Check if the specified path is inside a git repository."""
if path is None:
@@ -393,7 +394,6 @@ def is_git_repo(path: Path = None) -> bool:
return False
try:
# Use git command to check if inside a work tree
subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
check=True,
@@ -404,16 +404,9 @@ def is_git_repo(path: Path = None) -> bool:
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]:
"""Initialize a git repository in the specified path.
Args:
project_path: Path to initialize git repository in
quiet: if True suppress console output (tracker handles status)
Returns:
Tuple of (success: bool, error_message: Optional[str])
"""
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]:
"""Initialize a git repository in the specified path."""
try:
original_cwd = Path.cwd()
os.chdir(project_path)
@@ -425,20 +418,19 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
if not quiet:
console.print("[green]✓[/green] Git repository initialized")
return True, None
except subprocess.CalledProcessError as e:
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
if e.stderr:
error_msg += f"\nError: {e.stderr.strip()}"
elif e.stdout:
error_msg += f"\nOutput: {e.stdout.strip()}"
if not quiet:
console.print(f"[red]Error initializing git repository:[/red] {e}")
return False, error_msg
finally:
os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files.
@@ -708,41 +700,45 @@ def _install_shared_infra(
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
"""Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows)."""
if os.name == "nt":
return # Windows: skip silently
scripts_root = project_path / ".specify" / "scripts"
if not scripts_root.is_dir():
return
scan_roots = [
project_path / ".specify" / "scripts",
project_path / ".specify" / "extensions",
]
failures: list[str] = []
updated = 0
for script in scripts_root.rglob("*.sh"):
try:
if script.is_symlink() or not script.is_file():
continue
for scripts_root in scan_roots:
if not scripts_root.is_dir():
continue
for script in scripts_root.rglob("*.sh"):
try:
with script.open("rb") as f:
if f.read(2) != b"#!":
continue
except Exception:
continue
st = script.stat()
mode = st.st_mode
if mode & 0o111:
continue
new_mode = mode
if mode & 0o400:
new_mode |= 0o100
if mode & 0o040:
new_mode |= 0o010
if mode & 0o004:
new_mode |= 0o001
if not (new_mode & 0o100):
new_mode |= 0o100
os.chmod(script, new_mode)
updated += 1
except Exception as e:
failures.append(f"{script.relative_to(scripts_root)}: {e}")
if script.is_symlink() or not script.is_file():
continue
try:
with script.open("rb") as f:
if f.read(2) != b"#!":
continue
except Exception:
continue
st = script.stat()
mode = st.st_mode
if mode & 0o111:
continue
new_mode = mode
if mode & 0o400:
new_mode |= 0o100
if mode & 0o040:
new_mode |= 0o010
if mode & 0o004:
new_mode |= 0o001
if not (new_mode & 0o100):
new_mode |= 0o100
os.chmod(script, new_mode)
updated += 1
except Exception as e:
failures.append(f"{script.relative_to(project_path)}: {e}")
if tracker:
detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "")
tracker.add("chmod", "Set script permissions recursively")
@@ -993,9 +989,11 @@ def init(
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
raise typer.Exit(1)
dir_existed_before = False
if here:
project_name = Path.cwd().name
project_path = Path.cwd()
dir_existed_before = True
existing_items = list(project_path.iterdir())
if existing_items:
@@ -1010,17 +1008,29 @@ def init(
raise typer.Exit(0)
else:
project_path = Path(project_name).resolve()
dir_existed_before = project_path.exists()
if project_path.exists():
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
"Please choose a different project name or remove the existing directory.",
title="[red]Directory Conflict[/red]",
border_style="red",
padding=(1, 2)
)
console.print()
console.print(error_panel)
raise typer.Exit(1)
if not project_path.is_dir():
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
raise typer.Exit(1)
existing_items = list(project_path.iterdir())
if force:
if existing_items:
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
else:
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
"Please choose a different project name or remove the existing directory.\n"
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
border_style="red",
padding=(1, 2)
)
console.print()
console.print(error_panel)
raise typer.Exit(1)
if ai_assistant:
if ai_assistant not in AGENT_CONFIG:
@@ -1123,14 +1133,11 @@ def init(
for key, label in [
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("git", "Initialize git repository"),
("git", "Install git extension"),
("final", "Finalize"),
]:
tracker.add(key, label)
# Track git error message outside Live context so it persists
git_error_message = None
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
tracker.attach_refresh(lambda: live.update(tracker.render()))
try:
@@ -1177,26 +1184,62 @@ def init(
_install_shared_infra(project_path, selected_script, tracker=tracker)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_executable_scripts(project_path, tracker=tracker)
ensure_constitution_from_template(project_path, tracker=tracker)
if not no_git:
tracker.start("git")
git_messages = []
git_has_error = False
# Step 1: Initialize git repo if needed
if is_git_repo(project_path):
tracker.complete("git", "existing repo detected")
git_messages.append("existing repo detected")
elif should_init_git:
success, error_msg = init_git_repo(project_path, quiet=True)
if success:
tracker.complete("git", "initialized")
git_messages.append("initialized")
else:
tracker.error("git", "init failed")
git_error_message = error_msg
git_has_error = True
# Sanitize multi-line error_msg to single line for tracker
if error_msg:
sanitized = error_msg.replace('\n', ' ').strip()
git_messages.append(f"init failed: {sanitized[:120]}")
else:
git_messages.append("init failed")
else:
tracker.skip("git", "git not available")
git_messages.append("git not available")
# Step 2: Install bundled git extension
try:
from .extensions import ExtensionManager
bundled_path = _locate_bundled_extension("git")
if bundled_path:
manager = ExtensionManager(project_path)
if manager.registry.is_installed("git"):
git_messages.append("extension already installed")
else:
manager.install_from_directory(
bundled_path, get_speckit_version()
)
git_messages.append("extension installed")
else:
git_has_error = True
git_messages.append("bundled extension not found")
except Exception as ext_err:
git_has_error = True
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
git_messages.append(
f"extension install failed: {sanitized_ext[:120]}"
)
summary = "; ".join(git_messages)
if git_has_error:
tracker.error("git", summary)
else:
tracker.complete("git", summary)
else:
tracker.skip("git", "--no-git flag")
# Fix permissions after all installs (scripts + extensions)
ensure_executable_scripts(project_path, tracker=tracker)
# Persist the CLI options so later operations (e.g. preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE preset install so _get_skills_dir() works.
@@ -1262,7 +1305,7 @@ def init(
_label_width = max(len(k) for k, _ in _env_pairs)
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
if not here and project_path.exists():
if not here and project_path.exists() and not dir_existed_before:
shutil.rmtree(project_path)
raise typer.Exit(1)
finally:
@@ -1271,23 +1314,6 @@ def init(
console.print(tracker.render())
console.print("\n[bold green]Project ready.[/bold green]")
# Show git error details if initialization failed
if git_error_message:
console.print()
git_error_panel = Panel(
f"[yellow]Warning:[/yellow] Git repository initialization failed\n\n"
f"{git_error_message}\n\n"
f"[dim]You can initialize git manually later with:[/dim]\n"
f"[cyan]cd {project_path if not here else '.'}[/cyan]\n"
f"[cyan]git init[/cyan]\n"
f"[cyan]git add .[/cyan]\n"
f"[cyan]git commit -m \"Initial commit\"[/cyan]",
title="[red]Git Initialization Failed[/red]",
border_style="red",
padding=(1, 2)
)
console.print(git_error_panel)
# Agent folder security notice
agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config: