From f00bec77385b4b3219b5516fac78f336ab60cb88 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Fri, 27 Feb 2026 10:29:42 -0500 Subject: [PATCH] feat: initial scripts --- README.md | 80 +++++ gh-commit | 17 + gh-commit.tar.gz | Bin 0 -> 9399 bytes smartcommit.py | 855 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 952 insertions(+) create mode 100644 README.md create mode 100644 gh-commit create mode 100644 gh-commit.tar.gz create mode 100644 smartcommit.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..073e132 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# gh-commit + +AI-powered scoped git commits. Groups changes by project area, generates commit messages with Claude, and pushes — all in one command. + +## Install + +```sh +gh extension install prdlk/gh-commit +``` + +### Requirements + +- [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically) +- [mods](https://github.com/charmbracelet/mods) — LLM CLI for commit message generation +- [claude](https://docs.anthropic.com/en/docs/claude-code) — Claude Code CLI for scope generation + +## Usage + +```sh +# Initialize scopes for your repo (uses Claude to analyze structure) +gh commit init + +# Commit changes grouped by scope +gh commit + +# Auto-confirm + auto-push +gh commit --auto --push + +# Refresh scopes after structural changes +gh commit refresh + +# Sync scopes as GitHub labels +gh commit sync +``` + +## How it works + +1. **`gh commit init`** — Claude analyzes your repo structure and generates scope definitions (e.g., `core → src/`, `docs → docs/, README.md`, `ci → .github/workflows/`) +2. **`gh commit`** — Groups dirty files by scope, generates a commit message per scope via `mods`, and commits each group separately +3. Remaining unscoped files are handled in a final pass +4. Unpushed commits are offered for push + +Scopes are stored in a local DuckDB database (`~/.local/share/gh-commit/gh-commit.db`) — no config files in your repo. + +## Commands + +| Command | Description | +|---------|-------------| +| `gh commit` | Commit changes grouped by scope | +| `gh commit init` | Generate scopes for current repo | +| `gh commit refresh` | Update scopes from current structure | +| `gh commit sync` | Sync scopes → GitHub labels | +| `gh commit list` | List all configured repositories | +| `gh commit remove` | Remove current repo from database | +| `gh commit db-path` | Print database file path | +| `gh commit version` | Print version | +| `gh commit help` | Show help | + +## Flags + +| Flag | Description | +|------|-------------| +| `--auto` | Skip all confirmation prompts | +| `--push` | Auto-push after committing | + +## Environment + +| Variable | Description | +|----------|-------------| +| `GH_COMMIT_AUTO=1` | Skip all confirmation prompts | +| `GH_COMMIT_PUSH=1` | Auto-push after commits | +| `GH_COMMIT_MODS_CMD` | Override the mods command string | + +## Migration + +Existing `.github/Repo.toml` or `.github/scopes.json` files are automatically detected and migrated to DuckDB on first run. + +## License + +MIT diff --git a/gh-commit b/gh-commit new file mode 100644 index 0000000..6200b63 --- /dev/null +++ b/gh-commit @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# gh-commit: AI-powered scoped git commits +# Install: gh extension install prdlk/gh-commit +# Requires: uv (https://docs.astral.sh/uv) + +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="${EXTENSION_DIR}/smartcommit.py" + +if ! command -v uv &>/dev/null; then + echo "error: 'uv' is required but not installed" + echo "install: curl -LsSf https://astral.sh/uv/install.sh | sh" + exit 1 +fi + +exec uv run --script "${SCRIPT}" "$@" diff --git a/gh-commit.tar.gz b/gh-commit.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..ec1ae8309a1ff6e26a3d04f68add5cd209da82c1 GIT binary patch literal 9399 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 literal 0 HcmV?d00001 diff --git a/smartcommit.py b/smartcommit.py new file mode 100644 index 0000000..01d0362 --- /dev/null +++ b/smartcommit.py @@ -0,0 +1,855 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "duckdb>=1.0.0", +# "rich>=13.0.0", +# "questionary>=2.0.0", +# ] +# /// +"""gh-commit - AI-powered scoped git commits with DuckDB storage.""" + +import hashlib +import json +import os +import re +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + +import duckdb +import questionary +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +console = Console() + +# ── Config ──────────────────────────────────────────────────────────────────── + +DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit" +DB_PATH = DB_DIR / "gh-commit.db" + +MODS_CMD = os.environ.get( + "GH_COMMIT_MODS_CMD", + "mods --mcp-disable github --mcp-disable context7 -R write-commit", +).split() + +AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1" +AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1" + +VERSION = "1.0.0" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +@dataclass +class Repo: + id: int + path: str + name: str + + +def run(cmd: list[str], capture: bool = True, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=capture, text=True, check=check) + + +def git(*args: str) -> str: + result = run(["git", *args], check=False) + return result.stdout.strip() if result.returncode == 0 else "" + + +def is_git_repo() -> bool: + return run(["git", "rev-parse", "--is-inside-work-tree"], check=False).returncode == 0 + + +def get_repo_root() -> Optional[Path]: + root = git("rev-parse", "--show-toplevel") + return Path(root) if root else None + + +def confirm(msg: str) -> bool: + if AUTO_CONFIRM: + return True + return questionary.confirm(msg, default=True).ask() or False + + +def require_git() -> Path: + """Assert we're in a git repo and return the root path.""" + if not is_git_repo(): + console.print("[red]Not in a git repository[/]") + sys.exit(1) + return get_repo_root() + + +# ── Database ────────────────────────────────────────────────────────────────── + +def init_db(): + DB_DIR.mkdir(parents=True, exist_ok=True) + conn = duckdb.connect(str(DB_PATH)) + conn.execute(""" + CREATE TABLE IF NOT EXISTS repositories ( + id INTEGER PRIMARY KEY, + path TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS scopes ( + id INTEGER PRIMARY KEY, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(repo_id, name) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS scope_paths ( + id INTEGER PRIMARY KEY, + scope_id INTEGER NOT NULL, + path TEXT NOT NULL, + UNIQUE(scope_id, path) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS github_labels ( + id INTEGER PRIMARY KEY, + scope_id INTEGER NOT NULL, + label_name TEXT NOT NULL, + color TEXT, + synced_at TIMESTAMP, + UNIQUE(scope_id) + ) + """) + conn.close() + + +def get_db() -> duckdb.DuckDBPyConnection: + return duckdb.connect(str(DB_PATH)) + + +def repo_has_scopes(repo_path: str) -> bool: + conn = get_db() + result = conn.execute(""" + SELECT COUNT(*) FROM scopes s + JOIN repositories r ON s.repo_id = r.id + WHERE r.path = ? + """, [repo_path]).fetchone() + conn.close() + return result[0] > 0 if result else False + + +def get_repo_scopes(repo_path: str) -> dict[str, list[str]]: + conn = get_db() + result = conn.execute(""" + SELECT s.name, sp.path + FROM scopes s + JOIN repositories r ON s.repo_id = r.id + JOIN scope_paths sp ON sp.scope_id = s.id + WHERE r.path = ? + ORDER BY s.name, sp.path + """, [repo_path]).fetchall() + conn.close() + scopes: dict[str, list[str]] = {} + for scope_name, path in result: + scopes.setdefault(scope_name, []).append(path) + return scopes + + +def _cascade_delete_repo(conn, repo_id: int): + """Delete all child records for a repository.""" + scope_ids = conn.execute("SELECT id FROM scopes WHERE repo_id = ?", [repo_id]).fetchall() + for (scope_id,) in scope_ids: + conn.execute("DELETE FROM scope_paths WHERE scope_id = ?", [scope_id]) + conn.execute("DELETE FROM github_labels WHERE scope_id = ?", [scope_id]) + conn.execute("DELETE FROM scopes WHERE repo_id = ?", [repo_id]) + + +def save_scopes(repo_path: str, repo_name: str, scopes: dict[str, list[str]]): + conn = get_db() + result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone() + if result: + repo_id = result[0] + _cascade_delete_repo(conn, repo_id) + else: + conn.execute("INSERT INTO repositories (path, name) VALUES (?, ?)", [repo_path, repo_name]) + repo_id = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()[0] + + for scope_name, paths in scopes.items(): + conn.execute("INSERT INTO scopes (repo_id, name) VALUES (?, ?)", [repo_id, scope_name]) + scope_id = conn.execute( + "SELECT id FROM scopes WHERE repo_id = ? AND name = ?", + [repo_id, scope_name], + ).fetchone()[0] + for path in (paths if isinstance(paths, list) else [paths]): + conn.execute("INSERT INTO scope_paths (scope_id, path) VALUES (?, ?)", [scope_id, path]) + + conn.execute("UPDATE repositories SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", [repo_id]) + conn.close() + + +def list_repos() -> list[tuple]: + conn = get_db() + result = conn.execute(""" + SELECT r.name, r.path, COUNT(DISTINCT s.id) as scope_count, r.updated_at + FROM repositories r + LEFT JOIN scopes s ON s.repo_id = r.id + GROUP BY r.id, r.name, r.path, r.updated_at + ORDER BY r.updated_at DESC + """).fetchall() + conn.close() + return result + + +def delete_repo(repo_path: str): + conn = get_db() + result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone() + if result: + repo_id = result[0] + _cascade_delete_repo(conn, repo_id) + conn.execute("DELETE FROM repositories WHERE id = ?", [repo_id]) + conn.close() + + +# ── Migration ───────────────────────────────────────────────────────────────── + +def migrate_toml(repo_path: Path, repo_name: str, toml_path: Path) -> bool: + console.print("[yellow]↻ Migrating .github/Repo.toml → DuckDB[/]") + try: + data = tomllib.loads(toml_path.read_text()) + save_scopes(str(repo_path), repo_name, data.get("scopes", {})) + backup = toml_path.with_suffix(f".toml.migrated.{datetime.now():%Y%m%d_%H%M%S}") + toml_path.rename(backup) + console.print(f"[dim] Archived: {backup}[/]") + return True + except Exception as e: + console.print(f"[red]Migration failed: {e}[/]") + return False + + +def migrate_json(repo_path: Path, repo_name: str, json_path: Path) -> bool: + console.print("[yellow]↻ Migrating .github/scopes.json → DuckDB[/]") + try: + data = json.loads(json_path.read_text()) + scopes: dict[str, list[str]] = {} + for item in data: + scopes.setdefault(item["scope"], []).append(item["path"]) + save_scopes(str(repo_path), repo_name, scopes) + backup = json_path.with_suffix(f".json.migrated.{datetime.now():%Y%m%d_%H%M%S}") + json_path.rename(backup) + console.print(f"[dim] Archived: {backup}[/]") + return True + except Exception as e: + console.print(f"[red]Migration failed: {e}[/]") + return False + + +def auto_migrate(repo_path: Path) -> bool: + repo_name = repo_path.name + toml_path = repo_path / ".github" / "Repo.toml" + if toml_path.exists(): + return migrate_toml(repo_path, repo_name, toml_path) + json_path = repo_path / ".github" / "scopes.json" + if json_path.exists(): + return migrate_json(repo_path, repo_name, json_path) + return False + + +# ── Diff filtering ──────────────────────────────────────────────────────────── + +LOCK_PATTERN = re.compile( + r"(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|" + r"go\.sum|go\.mod|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|" + r".*\.min\.(js|css)|.*\.bundle\.js|.*\.map|" + r"dist/.*|build/.*|\.next/.*|node_modules/.*|vendor/.*|__pycache__/.*|\.pyc$|target/.*)" +) + +MAX_DIFF_LINES = 200 +MAX_JSON_LINES = 50 + + +def filter_diff(diff: str) -> str: + lines = [] + in_filtered = False + in_json = False + line_count = 0 + json_count = 0 + + for line in diff.split("\n"): + if line.startswith("diff --git"): + line_count = 0 + json_count = 0 + match = re.search(r"b/([^ ]+)", line) + filename = match.group(1) if match else "" + + if LOCK_PATTERN.search(filename): + in_filtered, in_json = True, False + lines.append(line) + continue + elif filename.endswith(".json"): + in_filtered, in_json = False, True + lines.append(line) + continue + else: + in_filtered, in_json = False, False + + if in_filtered: + if re.match(r"^(index|---|\+\+\+|@@)", line): + lines.append(line) + if line.startswith("@@"): + lines.append("[Generated/lock file - content filtered]") + in_filtered = False + continue + + if in_json: + if re.match(r"^(index|---|\+\+\+|@@)", line): + lines.append(line) + continue + if line.startswith(("+", "-")): + json_count += 1 + if json_count <= MAX_JSON_LINES: + lines.append(line) + elif json_count == MAX_JSON_LINES + 1: + lines.append(f"[... JSON truncated after {MAX_JSON_LINES} lines ...]") + else: + lines.append(line) + continue + + if line_count < MAX_DIFF_LINES: + lines.append(line) + line_count += 1 + elif line_count == MAX_DIFF_LINES: + lines.append(f"[... truncated after {MAX_DIFF_LINES} lines ...]") + line_count += 1 + + return "\n".join(lines) + + +# ── AI integration ──────────────────────────────────────────────────────────── + +def generate_commit_message(diff: str, scope: Optional[str] = None) -> Optional[str]: + filtered = filter_diff(diff) + context = f"### Git Diff\n{filtered}" + if scope: + context += f"\n\n### Scope\n{scope}" + try: + result = subprocess.run( + MODS_CMD, input=context, capture_output=True, text=True, check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + console.print(f"[red]Failed to generate commit message: {e}[/]") + return None + + +def generate_scopes_with_claude(repo_path: Path, existing_toml: Optional[str] = None) -> Optional[str]: + if existing_toml: + prompt = f"""Analyze this git repository and UPDATE the existing scopes configuration. + +Current scopes: +{existing_toml} + +Your task: +1. Review the current scopes and their paths +2. Analyze the current repository directory structure +3. Update the scopes to reflect any new directories or structural changes +4. Keep existing scopes that are still relevant +5. Remove scopes for paths that no longer exist +6. Add new scopes for new directories or logical areas + +Output ONLY the updated TOML content (no markdown, no explanation, just the raw TOML).""" + else: + prompt = """Analyze this git repository and generate a TOML scopes configuration. + +The output should: +1. Have a [scopes] section +2. Map logical project areas to arrays of directory paths +3. Each scope can have MULTIPLE paths +4. Use scope names that work well in conventional commits (e.g., 'docs', 'tests', 'config', 'ci', 'scripts') +5. Cover the main directories in the repository +6. Group related paths under the same scope when appropriate + +Example format: +[scopes] +ci = [".github/workflows"] +config = [".github", ".config", "package.json"] +docs = ["docs", "README.md"] +tests = ["tests", "__tests__"] +scripts = ["scripts", "bin"] +core = ["src"] + +Please analyze the repository structure and output ONLY the TOML content (no markdown, no explanation, just the raw TOML).""" + + try: + with console.status("[magenta]Analyzing repository with Claude...[/]"): + result = subprocess.run( + ["claude", "-p"], input=prompt, + capture_output=True, text=True, check=True, cwd=repo_path, + ) + output = result.stdout.strip() + if "[scopes]" in output: + output = output[output.find("[scopes]"):] + if "```" in output: + output = output[: output.find("```")] + return output.strip() + except subprocess.CalledProcessError as e: + console.print(f"[red]Claude failed: {e.stderr}[/]") + return None + except FileNotFoundError: + console.print("[red]Error: 'claude' command not found. Install Claude Code:[/]") + console.print("[cyan] npm install -g @anthropic-ai/claude-code[/]") + return None + + +def parse_toml_scopes(toml_str: str) -> Optional[dict[str, list[str]]]: + try: + return tomllib.loads(toml_str).get("scopes", {}) + except Exception: + return None + + +def scopes_to_toml(scopes: dict[str, list[str]]) -> str: + lines = ["[scopes]"] + for name, paths in sorted(scopes.items()): + paths_str = ", ".join(f'"{p}"' for p in paths) + lines.append(f'{name} = [{paths_str}]') + return "\n".join(lines) + + +def display_scopes(scopes: dict[str, list[str]]): + for name, paths in sorted(scopes.items()): + console.print(f" [cyan]•[/] [bold]{name}[/]: {', '.join(paths)}") + + +# ── Git operations ──────────────────────────────────────────────────────────── + +def get_changed_files() -> list[str]: + staged = git("diff", "--cached", "--name-only").split("\n") + unstaged = git("diff", "--name-only").split("\n") + untracked = git("ls-files", "--others", "--exclude-standard").split("\n") + return sorted(set(f for f in staged + unstaged + untracked if f)) + + +def get_files_in_scope(files: list[str], paths: list[str]) -> list[str]: + matched = [] + for f in files: + for path in paths: + if f.startswith(path): + matched.append(f) + break + return matched + + +def stage_files(files: list[str]): + if files: + subprocess.run(["git", "add", *files], check=True) + + +def reset_staging(): + subprocess.run(["git", "reset", "HEAD", "--", "."], capture_output=True) + + +def do_commit(message: str): + subprocess.run(["git", "commit", "-m", message], check=True) + + +def get_unpushed_commits() -> list[str]: + output = git("log", "--branches", "--not", "--remotes", "--oneline") + return [line for line in output.split("\n") if line] + + +def push_to_origin(): + branch = git("branch", "--show-current") + with console.status(f"[magenta]Pushing to origin/{branch}...[/]"): + subprocess.run(["git", "push", "origin", branch], check=True) + console.print(f"[green]✓ Pushed to origin/{branch}[/]") + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_version(): + print(f"gh-commit {VERSION}") + + +def cmd_help(): + console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits\n") + console.print("[cyan]Usage:[/]") + console.print(" gh commit Commit changes grouped by scope") + console.print(" gh commit --auto Auto-confirm all prompts") + console.print(" gh commit --push Auto-push after committing") + console.print(" gh commit --auto --push Both") + console.print(" gh commit init Generate scopes for this repo") + console.print(" gh commit refresh Update scopes from current structure") + console.print(" gh commit sync Sync scopes → GitHub labels") + console.print(" gh commit list List all configured repositories") + console.print(" gh commit remove Remove current repo from database") + console.print(" gh commit db-path Print database file path") + console.print(" gh commit version Print version") + console.print(" gh commit help Show this help\n") + console.print("[cyan]Database:[/]") + console.print(f" {DB_PATH}\n") + console.print("[cyan]Environment:[/]") + console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts") + console.print(" GH_COMMIT_PUSH=1 Auto-push after commits") + console.print(" GH_COMMIT_MODS_CMD=... Override mods command\n") + console.print("[cyan]Migration:[/]") + console.print(" Existing .github/Repo.toml or scopes.json are auto-migrated on first run") + + +def cmd_db_path(): + print(DB_PATH) + + +def cmd_list(): + console.print("[magenta bold]Repositories[/]\n") + repos = list_repos() + if not repos: + console.print("[dim]No repositories configured yet[/]") + console.print("\n[cyan]Run 'gh commit init' in a git repository to get started[/]") + return + table = Table(show_header=True) + table.add_column("Name", style="bold") + table.add_column("Path") + table.add_column("Scopes", justify="right") + for name, path, scope_count, _ in repos: + table.add_row(name, path, str(scope_count)) + console.print(table) + + +def cmd_remove(): + repo_path = require_git() + if not repo_has_scopes(str(repo_path)): + console.print(f"[dim]Repository not in database: {repo_path.name}[/]") + return 0 + if confirm(f"Remove {repo_path.name} from database?"): + delete_repo(str(repo_path)) + console.print(f"[green]✓ Removed {repo_path.name}[/]") + else: + console.print("[dim]Cancelled[/]") + return 0 + + +def cmd_init(): + repo_path = require_git() + repo_name = repo_path.name + + # Check for legacy files + toml_path = repo_path / ".github" / "Repo.toml" + json_path = repo_path / ".github" / "scopes.json" + if toml_path.exists() or json_path.exists(): + console.print("[yellow]Found legacy config file(s)[/]") + if confirm("Migrate to DuckDB?"): + if auto_migrate(repo_path): + console.print("[green]✓ Migration complete[/]") + return 0 + + if repo_has_scopes(str(repo_path)): + console.print("[yellow]⚠ Repository already configured[/]") + if not confirm("Overwrite existing scopes?"): + console.print("[dim]Cancelled[/]") + return 0 + + console.print(f"[magenta bold]Generating scopes for {repo_name}...[/]\n") + toml_output = generate_scopes_with_claude(repo_path) + if not toml_output: + return 1 + + scopes = parse_toml_scopes(toml_output) + if not scopes: + console.print("[red]Error: Generated output is not valid TOML[/]") + console.print(f"[dim]{toml_output}[/]") + return 1 + + save_scopes(str(repo_path), repo_name, scopes) + console.print("[green]✓ Saved scopes to database[/]\n") + console.print("[magenta]Generated scopes:[/]") + display_scopes(scopes) + console.print("\n[dim]Run 'gh commit' to use these scopes[/]") + return 0 + + +def cmd_refresh(): + repo_path = require_git() + repo_name = repo_path.name + + if not repo_has_scopes(str(repo_path)): + console.print("[red]Repository not configured — run 'gh commit init' first[/]") + return 1 + + existing_scopes = get_repo_scopes(str(repo_path)) + console.print(f"[magenta bold]Refreshing scopes for {repo_name}...[/]\n") + console.print("[cyan]Current scopes:[/]") + display_scopes(existing_scopes) + console.print() + + if not confirm("Refresh scopes based on current structure?"): + console.print("[dim]Cancelled[/]") + return 0 + + toml_output = generate_scopes_with_claude(repo_path, scopes_to_toml(existing_scopes)) + if not toml_output: + return 1 + + scopes = parse_toml_scopes(toml_output) + if not scopes: + console.print("[red]Error: Generated output is not valid TOML[/]") + return 1 + + console.print("\n[green]✓ Generated updated scopes[/]\n") + console.print("[magenta]Updated scopes:[/]") + display_scopes(scopes) + console.print() + + if confirm("Apply these changes?"): + save_scopes(str(repo_path), repo_name, scopes) + console.print("[green]✓ Updated scopes[/]") + else: + console.print("[dim]Changes not applied[/]") + return 0 + + +def cmd_sync(): + repo_path = require_git() + + if not repo_has_scopes(str(repo_path)): + console.print("[red]Repository not configured — run 'gh commit init' first[/]") + return 1 + + if subprocess.run(["which", "gh"], capture_output=True).returncode != 0: + console.print("[red]Error: 'gh' command not found[/]") + return 1 + if subprocess.run(["gh", "repo", "view"], capture_output=True).returncode != 0: + console.print("[red]Error: Not a GitHub repository or not authenticated[/]") + return 1 + + console.print("[magenta bold]Syncing scopes → GitHub labels...[/]\n") + scopes = get_repo_scopes(str(repo_path)) + created = updated = failed = 0 + + for scope_name, paths in scopes.items(): + desc = f"Changes to: {', '.join(paths)}" + color = hashlib.md5(scope_name.encode()).hexdigest()[:6] + + result = subprocess.run( + ["gh", "label", "create", scope_name, "--description", desc, "--color", color], + capture_output=True, + ) + if result.returncode == 0: + console.print(f" [green]✓[/] Created: {scope_name}") + created += 1 + else: + result = subprocess.run( + ["gh", "label", "edit", scope_name, "--description", desc, "--color", color], + capture_output=True, + ) + if result.returncode == 0: + console.print(f" [yellow]↻[/] Updated: {scope_name}") + updated += 1 + else: + console.print(f" [red]✗[/] Failed: {scope_name}") + failed += 1 + + console.print(f"\n[green bold]Sync complete![/] Created: {created} | Updated: {updated} | Failed: {failed}") + return 0 + + +def cmd_commit(): + repo_path = require_git() + + auto_migrate(repo_path) + + if not repo_has_scopes(str(repo_path)): + console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n") + console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n") + if confirm("Generate scopes now using Claude?"): + return cmd_init() + else: + console.print("\n[dim]Run 'gh commit init' to configure scopes[/]") + return 1 + + console.print("[magenta]Finding scopes with changes...[/]") + changed_files = get_changed_files() + scopes = get_repo_scopes(str(repo_path)) + + scopes_with_changes = [ + name for name, paths in scopes.items() + if get_files_in_scope(changed_files, paths) + ] + + if not scopes_with_changes: + console.print("[dim]No scoped changes found[/]") + else: + console.print(f"[cyan]Scopes with changes: {' '.join(scopes_with_changes)}[/]\n") + + # Process each scope + for scope in scopes_with_changes: + console.print(f"[magenta bold]Processing scope: {scope}[/]") + paths = scopes[scope] + console.print(f"[cyan] Paths: {', '.join(paths)}[/]") + + reset_staging() + + scope_files = [] + status_output = git("status", "--porcelain") + for line in status_output.split("\n"): + if not line: + continue + filename = line[3:] if len(line) > 3 else line.split()[-1] + for path in paths: + if filename.startswith(path): + scope_files.append(filename) + break + + if not scope_files: + console.print("[dim] No files found in scope paths[/]") + continue + + console.print("[dim] Files to stage:[/]") + for f in scope_files: + console.print(f"[dim] {f}[/]") + + stage_files(scope_files) + diff = git("diff", "--cached") + if not diff: + console.print(f"[dim] No changes to commit for {scope}[/]") + continue + + with console.status("[magenta]Generating commit message...[/]"): + message = generate_commit_message(diff, scope) + if not message: + continue + + console.print(Panel(message, border_style="magenta")) + if confirm(f"Commit changes for {scope}?"): + do_commit(message) + console.print(f"[green]✓ Committed changes for {scope}[/]") + else: + console.print(f"[dim] Skipped {scope}[/]") + console.print() + + reset_staging() + + # Remaining files + remaining = git("status", "--porcelain") + if remaining: + console.print("\n[yellow]Processing remaining files outside any scope...[/]") + + tracked_unstaged = git("diff", "--name-only") + if tracked_unstaged: + files = [f for f in tracked_unstaged.split("\n") if f] + console.print("\n[magenta]Tracked unstaged files:[/]") + for f in files: + console.print(f"[dim] {f}[/]") + if confirm("Commit tracked unstaged files?"): + stage_files(files) + diff = git("diff", "--cached") + with console.status("[magenta]Generating commit message...[/]"): + message = generate_commit_message(diff) + if message: + console.print(Panel(message, border_style="magenta")) + do_commit(message) + console.print("[green]✓ Committed tracked unstaged files[/]") + + untracked = git("ls-files", "--others", "--exclude-standard") + if untracked: + files = [f for f in untracked.split("\n") if f] + console.print("\n[magenta]Untracked files:[/]") + for f in files: + console.print(f"[dim] {f}[/]") + if confirm("Commit untracked files?"): + stage_files(files) + diff = git("diff", "--cached") + with console.status("[magenta]Generating commit message...[/]"): + message = generate_commit_message(diff) + if message: + console.print(Panel(message, border_style="magenta")) + do_commit(message) + console.print("[green]✓ Committed untracked files[/]") + + # Push + unpushed = get_unpushed_commits() + if unpushed: + console.print("\n[magenta bold]Unpushed Commits[/]") + console.print(f"[cyan]{len(unpushed)} commit(s) ready to push:[/]\n") + for line in unpushed: + parts = line.split(" ", 1) + hash_val = parts[0] + msg = parts[1] if len(parts) > 1 else "" + console.print(f" [bold]{hash_val}[/] {msg}") + console.print() + if AUTO_PUSH or confirm("Push commits to origin?"): + push_to_origin() + else: + console.print("[dim]Skipped push[/]") + else: + console.print("\n[dim]No unpushed commits[/]") + + console.print("\n[green bold]✓ Done![/]") + return 0 + + +# ── Entrypoint ──────────────────────────────────────────────────────────────── + +COMMANDS = { + "init": cmd_init, + "refresh": cmd_refresh, + "sync": cmd_sync, + "list": cmd_list, + "remove": cmd_remove, + "db-path": cmd_db_path, + "version": cmd_version, + "help": cmd_help, +} + + +def main(): + global AUTO_CONFIRM, AUTO_PUSH + + args = sys.argv[1:] + + # Parse flags + while args and args[0].startswith("--"): + flag = args.pop(0) + if flag == "--auto": + AUTO_CONFIRM = True + elif flag == "--push": + AUTO_PUSH = True + elif flag in ("--help", "-h"): + cmd_help() + return + elif flag == "--version": + cmd_version() + return + else: + # Unknown flag — might be a legacy --init style command + legacy = flag.lstrip("-") + if legacy in COMMANDS: + args.insert(0, legacy) + break + console.print(f"[red]Unknown flag: {flag}[/]") + console.print("[dim]Use 'gh commit help' for usage[/]") + sys.exit(1) + + init_db() + + cmd = args[0] if args else None + if cmd is None: + sys.exit(cmd_commit()) + elif cmd in COMMANDS: + result = COMMANDS[cmd]() + if isinstance(result, int): + sys.exit(result) + else: + console.print(f"[red]Unknown command: {cmd}[/]") + console.print("[dim]Use 'gh commit help' for usage[/]") + sys.exit(1) + + +if __name__ == "__main__": + main()