mirror of
https://github.com/github/spec-kit
synced 2026-06-21 13:51:39 +00:00
927f54feea
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0 - Remove --no-git parameter from specify init command - Remove git extension auto-installation from init flow - Git repository initialization (git init) still runs when git is available - Remove --no-git from all test invocations across the test suite - Update docs to reflect opt-in git extension behavior - Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests BREAKING CHANGE: specify init no longer auto-installs the git extension. Use `specify extension add git` to install it explicitly. The --no-git flag has been removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove git operations from core scripts Git functionality is now entirely managed by the git extension. Core scripts only handle directory-based feature creation and numbering. - Remove has_git(), check_feature_branch(), git branch creation from core - Simplify number detection to use only spec directory scanning - Remove HAS_GIT output from get_feature_paths() - Remove git remote fetching and branch querying - Keep BRANCH_NAME output key for backward compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove all git operations from core - Remove is_git_repo() and init_git_repo() dead code from _utils.py - Remove --branch-numbering from init command - Remove git from 'specify check' (now extension-only) - Update docs: git is optional prerequisite, check command description - Fix tests to reflect no-git-in-core reality (fallback to main) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove directory scanning and branch fallback from core Core scripts now resolve feature context exclusively from: 1. SPECIFY_FEATURE env var (set by git extension) 2. .specify/feature.json (persisted by specify command) Removed find_feature_dir_by_prefix() and directory scanning heuristics — these are the git extension's responsibility. Scripts error clearly when no feature context is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce feature_numbering, deprecate branch_numbering in init-options - specify command template now reads feature_numbering (preferred) with fallback to branch_numbering (deprecated) from init-options.json - Git extension reads git-config.yml > feature_numbering > branch_numbering - init now writes feature_numbering: sequential to init-options.json - Deprecation warning emitted when branch_numbering is used as fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove trailing whitespace in common.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the value to .specify/feature.json so future sessions without the env var can still resolve the feature directory. The write is idempotent — it skips when the file already contains the same value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback — error messages and docs - Update error messages in common.sh and common.ps1 to reference SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer resolves feature directories) - Fix get_current_branch comment (returns empty string, not error) - Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct example paths - Update local-development.md troubleshooting: replace stale 'Git step skipped' row with actionable git extension guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden feature.json persistence - Use json_escape in printf fallback when jq is unavailable (common.sh) - Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell 5.1 compatibility (common.ps1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove dead feature_json_matches_feature_dir functions These guards are no longer needed since the branch-name validation they protected against has been removed from check-prerequisites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(git-ext): rename create-new-feature to create-new-feature-branch The git extension's script only creates the git branch — rename it to reflect that responsibility. The core create-new-feature.sh/.ps1 handles feature directory creation and feature.json persistence. Also includes fixes from review feedback: - common.sh: _persist_feature_json uses json_escape fallback - common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat - common.ps1: case-sensitive path stripping on non-Windows - create-new-feature.sh/ps1: output both SPECIFY_FEATURE and SPECIFY_FEATURE_DIRECTORY - setup-tasks.sh: fix stale 'Validate branch' comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): update references to renamed git extension scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove duplicate EXT_CREATE_FEATURE assignments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
241 lines
9.3 KiB
Python
241 lines
9.3 KiB
Python
"""System utilities: subprocess, tool detection, file operations."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import json5
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from ._console import console
|
|
|
|
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
|
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
|
|
|
|
|
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
|
|
"""Run a shell command and optionally capture output."""
|
|
try:
|
|
if capture:
|
|
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
|
|
return result.stdout.strip()
|
|
else:
|
|
subprocess.run(cmd, check=check_return, shell=shell)
|
|
return None
|
|
except subprocess.CalledProcessError as e:
|
|
if check_return:
|
|
console.print(f"[red]Error running command:[/red] {' '.join(cmd)}")
|
|
console.print(f"[red]Exit code:[/red] {e.returncode}")
|
|
if hasattr(e, 'stderr') and e.stderr:
|
|
console.print(f"[red]Error output:[/red] {e.stderr}")
|
|
raise
|
|
return None
|
|
|
|
|
|
def check_tool(tool: str, tracker=None) -> bool:
|
|
"""Check if a tool is installed. Optionally update tracker.
|
|
|
|
Args:
|
|
tool: Name of the tool to check
|
|
tracker: StepTracker | None to update with results
|
|
|
|
Returns:
|
|
True if tool is found, False otherwise
|
|
"""
|
|
# Special handling for Claude CLI local installs
|
|
# See: https://github.com/github/spec-kit/issues/123
|
|
# See: https://github.com/github/spec-kit/issues/550
|
|
# Claude Code can be installed in two local paths:
|
|
# 1. ~/.claude/local/claude (after `claude migrate-installer`)
|
|
# 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
|
|
# Neither path may be on the system PATH, so we check them explicitly.
|
|
if tool == "claude":
|
|
if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
|
|
if tracker:
|
|
tracker.complete(tool, "available")
|
|
return True
|
|
|
|
# Per-integration executable resolution.
|
|
if tool == "kiro-cli":
|
|
# Kiro currently supports both executable names. Prefer kiro-cli and
|
|
# accept kiro as a compatibility fallback.
|
|
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
|
|
elif tool == "rovodev":
|
|
found = shutil.which("acli") is not None
|
|
else:
|
|
found = shutil.which(tool) is not None
|
|
|
|
if tracker:
|
|
if found:
|
|
tracker.complete(tool, "available")
|
|
else:
|
|
tracker.error(tool, "not found")
|
|
|
|
return found
|
|
|
|
|
|
|
|
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
|
"""Handle merging or copying of .vscode/settings.json files.
|
|
|
|
Note: when merge produces changes, rewritten output is normalized JSON and
|
|
existing JSONC comments/trailing commas are not preserved.
|
|
"""
|
|
def log(message, color="green"):
|
|
if verbose and not tracker:
|
|
console.print(f"[{color}]{message}[/] {rel_path}")
|
|
|
|
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
|
|
"""Atomically write JSON while preserving existing mode bits when possible."""
|
|
temp_path: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode='w',
|
|
encoding='utf-8',
|
|
dir=target_file.parent,
|
|
prefix=f"{target_file.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as f:
|
|
temp_path = Path(f.name)
|
|
json.dump(payload, f, indent=4)
|
|
f.write('\n')
|
|
|
|
if target_file.exists():
|
|
try:
|
|
existing_stat = target_file.stat()
|
|
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
|
|
if hasattr(os, "chown"):
|
|
try:
|
|
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
|
|
except PermissionError:
|
|
# Best-effort owner/group preservation without requiring elevated privileges.
|
|
pass
|
|
except OSError:
|
|
# Best-effort metadata preservation; data safety is prioritized.
|
|
pass
|
|
|
|
os.replace(temp_path, target_file)
|
|
except Exception:
|
|
if temp_path and temp_path.exists():
|
|
temp_path.unlink()
|
|
raise
|
|
|
|
try:
|
|
with open(sub_item, 'r', encoding='utf-8') as f:
|
|
# json5 natively supports comments and trailing commas (JSONC)
|
|
new_settings = json5.load(f)
|
|
|
|
if dest_file.exists():
|
|
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
|
if merged is not None:
|
|
atomic_write_json(dest_file, merged)
|
|
log("Merged:", "green")
|
|
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
|
|
else:
|
|
log("Skipped merge (preserved existing settings)", "yellow")
|
|
else:
|
|
shutil.copy2(sub_item, dest_file)
|
|
log("Copied (no existing settings.json):", "blue")
|
|
|
|
except Exception as e:
|
|
log(f"Warning: Could not merge settings: {e}", "yellow")
|
|
if not dest_file.exists():
|
|
shutil.copy2(sub_item, dest_file)
|
|
|
|
|
|
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> dict[str, Any] | None:
|
|
"""Merge new JSON content into existing JSON file.
|
|
|
|
Performs a polite deep merge where:
|
|
- New keys are added
|
|
- Existing keys are preserved (not overwritten) unless both values are dictionaries
|
|
- Nested dictionaries are merged recursively only when both sides are dictionaries
|
|
- Lists and other values are preserved from base if they exist
|
|
|
|
Args:
|
|
existing_path: Path to existing JSON file
|
|
new_content: New JSON content to merge in
|
|
verbose: Whether to print merge details
|
|
|
|
Returns:
|
|
Merged JSON content as dict, or None if the existing file should be left untouched.
|
|
"""
|
|
# Load existing content first to have a safe fallback
|
|
existing_content = None
|
|
exists = existing_path.exists()
|
|
|
|
if exists:
|
|
try:
|
|
with open(existing_path, 'r', encoding='utf-8') as f:
|
|
# Handle comments (JSONC) natively with json5
|
|
# Note: json5 handles BOM automatically
|
|
existing_content = json5.load(f)
|
|
except FileNotFoundError:
|
|
# Handle race condition where file is deleted after exists() check
|
|
exists = False
|
|
except Exception as e:
|
|
if verbose:
|
|
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
|
|
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
|
|
return None
|
|
|
|
# Validate template content
|
|
if not isinstance(new_content, dict):
|
|
if verbose:
|
|
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
|
|
return None
|
|
|
|
if not exists:
|
|
return new_content
|
|
|
|
# If existing content parsed but is not a dict, skip merge to avoid data loss
|
|
if not isinstance(existing_content, dict):
|
|
if verbose:
|
|
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
|
|
return None
|
|
|
|
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
|
"""Recursively merge update dict into base dict, preserving base values."""
|
|
result = base.copy()
|
|
for key, value in update.items():
|
|
if key not in result:
|
|
# Add new key
|
|
result[key] = value
|
|
elif isinstance(result[key], dict) and isinstance(value, dict):
|
|
# Recursively merge nested dictionaries
|
|
result[key] = deep_merge_polite(result[key], value)
|
|
else:
|
|
# Key already exists and values are not both dicts; preserve existing value.
|
|
# This ensures user settings aren't overwritten by template defaults.
|
|
pass
|
|
return result
|
|
|
|
merged = deep_merge_polite(existing_content, new_content)
|
|
|
|
# Detect if anything actually changed. If not, return None so the caller
|
|
# can skip rewriting the file (preserving user's comments/formatting).
|
|
if merged == existing_content:
|
|
return None
|
|
|
|
if verbose:
|
|
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
|
|
|
return merged
|
|
|
|
|
|
def _display_project_path(project_root: Path, path: str | Path) -> str:
|
|
"""Return a stable POSIX-style display path for paths under a project."""
|
|
path_obj = Path(path)
|
|
try:
|
|
rel_path = path_obj.relative_to(project_root) if path_obj.is_absolute() else path_obj
|
|
except ValueError:
|
|
try:
|
|
rel_path = path_obj.resolve().relative_to(project_root.resolve())
|
|
except (OSError, ValueError):
|
|
return path_obj.as_posix()
|
|
return rel_path.as_posix()
|