mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-08-02 17:31:40 +00:00
update: use claude acp
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/.omc
|
||||||
|
/__pycache__
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# gh-commit
|
# gh-commit
|
||||||
|
|
||||||
AI-powered scoped git commits. Groups changes by project area, generates commit messages with Claude, and pushes — all in one command.
|
AI-powered scoped git commits. Groups changes by project area, generates commit messages with Claude, and pushes — all in one command. A single Claude agent, reached over the **Agent Client Protocol (ACP)**, handles both scope generation and commit-message writing.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -11,8 +11,10 @@ gh extension install prdlk/gh-commit
|
|||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically)
|
- [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically)
|
||||||
- [mods](https://github.com/charmbracelet/mods) — LLM CLI for commit message generation
|
- [Node.js](https://nodejs.org) — runs the Claude ACP bridge via `npx`
|
||||||
- [claude](https://docs.anthropic.com/en/docs/claude-code) — Claude Code CLI for scope generation
|
- [claude](https://docs.anthropic.com/en/docs/claude-code) — authenticated Claude Code CLI (the ACP bridge drives it)
|
||||||
|
|
||||||
|
> The ACP bridge defaults to `npx --yes @zed-industries/claude-code-acp`. Override it with `GH_COMMIT_ACP_CMD` if you prefer another bridge.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ gh commit
|
|||||||
# Auto-confirm + auto-push
|
# Auto-confirm + auto-push
|
||||||
gh commit --auto --push
|
gh commit --auto --push
|
||||||
|
|
||||||
# Refresh scopes after structural changes
|
# Manually refresh scopes after structural changes
|
||||||
gh commit refresh
|
gh commit refresh
|
||||||
|
|
||||||
# Sync scopes as GitHub labels
|
# Sync scopes as GitHub labels
|
||||||
@@ -35,13 +37,18 @@ gh commit sync
|
|||||||
|
|
||||||
## How it works
|
## 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/`)
|
1. **`gh commit init`** — A Claude agent (over ACP) 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
|
2. **`gh commit`** — Groups dirty files by scope, generates a commit message per scope via the same Claude agent, and commits each group separately
|
||||||
3. Remaining unscoped files are handled in a final pass
|
3. **Auto-refresh** — Whenever your `.gitignore` changes, scopes are automatically regenerated before committing (a content hash of `.gitignore` is tracked per repo)
|
||||||
4. Unpushed commits are offered for push
|
4. Remaining unscoped files are handled in a final pass
|
||||||
|
5. 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.
|
Scopes are stored in a local DuckDB database (`~/.local/share/gh-commit/gh-commit.db`) — no config files in your repo.
|
||||||
|
|
||||||
|
### Why ACP?
|
||||||
|
|
||||||
|
gh-commit talks to Claude through the [Agent Client Protocol](https://agentclientprotocol.com) — the same protocol editors like Zed use to drive coding agents. One Claude backend handles everything (no separate `mods`/LLM CLI), the bridge process is reused across a run, and each generation runs in its own session so context never bleeds between commits.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -69,7 +76,11 @@ Scopes are stored in a local DuckDB database (`~/.local/share/gh-commit/gh-commi
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `GH_COMMIT_AUTO=1` | Skip all confirmation prompts |
|
| `GH_COMMIT_AUTO=1` | Skip all confirmation prompts |
|
||||||
| `GH_COMMIT_PUSH=1` | Auto-push after commits |
|
| `GH_COMMIT_PUSH=1` | Auto-push after commits |
|
||||||
| `GH_COMMIT_MODS_CMD` | Override the mods command string |
|
| `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes |
|
||||||
|
| `GH_COMMIT_ACP_CMD` | Override the Claude ACP bridge command (default `npx --yes @zed-industries/claude-code-acp`) |
|
||||||
|
| `GH_COMMIT_ACP_PERMISSION` | ACP permission mode (default `bypassPermissions`) |
|
||||||
|
| `GH_COMMIT_ACP_TIMEOUT` | Per-prompt timeout in seconds (default `300`) |
|
||||||
|
| `GH_COMMIT_DEBUG=1` | Show ACP bridge stderr and parse diagnostics |
|
||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# gh-commit: AI-powered scoped git commits
|
# gh-commit: AI-powered scoped git commits, driven by Claude over ACP
|
||||||
# Install: gh extension install prdlk/gh-commit
|
# Install: gh extension install prdlk/gh-commit
|
||||||
# Requires: uv (https://docs.astral.sh/uv)
|
# Requires: uv (https://docs.astral.sh/uv), Node.js (for the Claude ACP bridge),
|
||||||
|
# and an authenticated `claude` (https://docs.anthropic.com/en/docs/claude-code)
|
||||||
|
|
||||||
EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)"
|
EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
SCRIPT="${EXTENSION_DIR}/smartcommit.py"
|
SCRIPT="${EXTENSION_DIR}/smartcommit.py"
|
||||||
@@ -14,4 +15,9 @@ if ! command -v uv &>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! command -v npx &>/dev/null; then
|
||||||
|
echo "warning: 'npx' (Node.js) not found — the Claude ACP bridge needs it."
|
||||||
|
echo "install Node.js, or set GH_COMMIT_ACP_CMD to a working ACP bridge command."
|
||||||
|
fi
|
||||||
|
|
||||||
exec uv run --script "${SCRIPT}" "$@"
|
exec uv run --script "${SCRIPT}" "$@"
|
||||||
|
|||||||
+450
-180
@@ -7,16 +7,23 @@
|
|||||||
# "questionary>=2.0.0",
|
# "questionary>=2.0.0",
|
||||||
# ]
|
# ]
|
||||||
# ///
|
# ///
|
||||||
"""gh-commit - AI-powered scoped git commits with DuckDB storage."""
|
"""gh-commit - AI-powered scoped git commits, driven entirely by Claude over ACP.
|
||||||
|
|
||||||
|
A single Claude agent (reached through the Agent Client Protocol) handles both
|
||||||
|
scope generation and commit-message writing. Scopes live in a local DuckDB and
|
||||||
|
auto-regenerate whenever the repository's .gitignore changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -34,15 +41,20 @@ console = Console()
|
|||||||
DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit"
|
DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit"
|
||||||
DB_PATH = DB_DIR / "gh-commit.db"
|
DB_PATH = DB_DIR / "gh-commit.db"
|
||||||
|
|
||||||
MODS_CMD = os.environ.get(
|
# The ACP bridge that exposes Claude Code as an Agent Client Protocol server.
|
||||||
"GH_COMMIT_MODS_CMD",
|
ACP_CMD = os.environ.get(
|
||||||
"mods --mcp-disable github --mcp-disable context7 -R write-commit",
|
"GH_COMMIT_ACP_CMD",
|
||||||
|
"npx --yes @zed-industries/claude-code-acp",
|
||||||
).split()
|
).split()
|
||||||
|
ACP_PERMISSION_MODE = os.environ.get("GH_COMMIT_ACP_PERMISSION", "bypassPermissions")
|
||||||
|
ACP_TIMEOUT = int(os.environ.get("GH_COMMIT_ACP_TIMEOUT", "300"))
|
||||||
|
|
||||||
AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1"
|
AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1"
|
||||||
AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1"
|
AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1"
|
||||||
|
NO_AUTO_REFRESH = os.environ.get("GH_COMMIT_NO_AUTO_REFRESH", "0") == "1"
|
||||||
|
DEBUG = os.environ.get("GH_COMMIT_DEBUG", "0") == "1"
|
||||||
|
|
||||||
VERSION = "1.0.0"
|
VERSION = "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -86,6 +98,210 @@ def require_git() -> Path:
|
|||||||
return get_repo_root()
|
return get_repo_root()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Claude ACP client ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ACPError(RuntimeError):
|
||||||
|
"""Raised when the ACP bridge or the underlying Claude agent fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class ACPClient:
|
||||||
|
"""Minimal Agent Client Protocol client speaking newline-delimited JSON-RPC
|
||||||
|
to a Claude Code ACP bridge subprocess.
|
||||||
|
|
||||||
|
One bridge process is reused for the life of the run; every prompt gets a
|
||||||
|
fresh session so individual generations never bleed context into each other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cwd: Path):
|
||||||
|
self.cwd = str(cwd)
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._pending: dict[int, dict] = {}
|
||||||
|
self._next_id = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._chunks: list[str] = []
|
||||||
|
self._active_session: Optional[str] = None
|
||||||
|
|
||||||
|
# -- lifecycle --------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_started(self):
|
||||||
|
if self._proc is not None:
|
||||||
|
return
|
||||||
|
env = dict(os.environ)
|
||||||
|
# The bridge launches a nested Claude Code; clear markers that would make
|
||||||
|
# it refuse to start when gh-commit itself is run from inside Claude Code.
|
||||||
|
for key in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SSE_PORT"):
|
||||||
|
env.pop(key, None)
|
||||||
|
env.setdefault("ACP_PERMISSION_MODE", ACP_PERMISSION_MODE)
|
||||||
|
|
||||||
|
stderr = None if DEBUG else subprocess.DEVNULL
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
ACP_CMD,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=stderr,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
cwd=self.cwd,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise ACPError(
|
||||||
|
f"Could not launch ACP bridge ({ACP_CMD[0]!r} not found). "
|
||||||
|
"Install Node.js, or set GH_COMMIT_ACP_CMD to a working bridge."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
threading.Thread(target=self._reader, daemon=True).start()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
# -- transport --------------------------------------------------------------
|
||||||
|
|
||||||
|
def _write(self, msg: dict):
|
||||||
|
if not self._proc or not self._proc.stdin:
|
||||||
|
raise ACPError("ACP bridge is not running")
|
||||||
|
self._proc.stdin.write(json.dumps(msg) + "\n")
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
def _send_request(self, method: str, params: dict) -> int:
|
||||||
|
with self._lock:
|
||||||
|
rid = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
self._pending[rid] = {"event": threading.Event(), "result": None, "error": None}
|
||||||
|
self._write({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||||
|
return rid
|
||||||
|
|
||||||
|
def _respond(self, rid, result=None, error=None):
|
||||||
|
msg = {"jsonrpc": "2.0", "id": rid}
|
||||||
|
if error is not None:
|
||||||
|
msg["error"] = error
|
||||||
|
else:
|
||||||
|
msg["result"] = result if result is not None else {}
|
||||||
|
self._write(msg)
|
||||||
|
|
||||||
|
def _reader(self):
|
||||||
|
proc = self._proc
|
||||||
|
if not proc or not proc.stdout:
|
||||||
|
return
|
||||||
|
for line in proc.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
self._dispatch(msg)
|
||||||
|
# Stream closed: unblock anything still waiting.
|
||||||
|
for p in self._pending.values():
|
||||||
|
if p["error"] is None and p["result"] is None:
|
||||||
|
p["error"] = {"message": "ACP bridge closed the connection"}
|
||||||
|
p["event"].set()
|
||||||
|
|
||||||
|
def _dispatch(self, msg: dict):
|
||||||
|
# Response to one of our requests.
|
||||||
|
if "id" in msg and ("result" in msg or "error" in msg):
|
||||||
|
p = self._pending.get(msg["id"])
|
||||||
|
if p:
|
||||||
|
p["result"], p["error"] = msg.get("result"), msg.get("error")
|
||||||
|
p["event"].set()
|
||||||
|
return
|
||||||
|
# Request from the agent that needs a reply.
|
||||||
|
if "method" in msg and "id" in msg:
|
||||||
|
self._on_agent_request(msg)
|
||||||
|
return
|
||||||
|
# One-way notification.
|
||||||
|
if msg.get("method") == "session/update":
|
||||||
|
self._on_update(msg.get("params", {}))
|
||||||
|
|
||||||
|
def _on_agent_request(self, msg: dict):
|
||||||
|
method = msg["method"]
|
||||||
|
params = msg.get("params", {})
|
||||||
|
if method == "session/request_permission":
|
||||||
|
options = params.get("options", [])
|
||||||
|
choice = next(
|
||||||
|
(o["optionId"] for o in options if "allow" in o.get("optionId", "").lower()),
|
||||||
|
options[0]["optionId"] if options else "allow",
|
||||||
|
)
|
||||||
|
self._respond(msg["id"], {"outcome": {"outcome": "selected", "optionId": choice}})
|
||||||
|
elif method == "fs/read_text_file":
|
||||||
|
try:
|
||||||
|
self._respond(msg["id"], {"content": Path(params["path"]).read_text()})
|
||||||
|
except Exception as e:
|
||||||
|
self._respond(msg["id"], error={"code": -32603, "message": str(e)})
|
||||||
|
else:
|
||||||
|
# We advertise no client-side capabilities; decline anything else.
|
||||||
|
self._respond(msg["id"], error={"code": -32601, "message": "unsupported"})
|
||||||
|
|
||||||
|
def _on_update(self, params: dict):
|
||||||
|
if params.get("sessionId") and params["sessionId"] != self._active_session:
|
||||||
|
return
|
||||||
|
update = params.get("update", {})
|
||||||
|
kind = update.get("sessionUpdate") or update.get("type")
|
||||||
|
if kind == "agent_message_chunk":
|
||||||
|
content = update.get("content", {})
|
||||||
|
if content.get("type") == "text":
|
||||||
|
self._chunks.append(content.get("text", ""))
|
||||||
|
|
||||||
|
def _call(self, method: str, params: dict, timeout: int) -> dict:
|
||||||
|
rid = self._send_request(method, params)
|
||||||
|
p = self._pending[rid]
|
||||||
|
if not p["event"].wait(timeout):
|
||||||
|
self._pending.pop(rid, None)
|
||||||
|
raise ACPError(f"ACP request timed out: {method}")
|
||||||
|
self._pending.pop(rid, None)
|
||||||
|
if p["error"]:
|
||||||
|
raise ACPError(p["error"].get("message", str(p["error"])))
|
||||||
|
return p["result"] or {}
|
||||||
|
|
||||||
|
# -- protocol ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _initialize(self):
|
||||||
|
self._call("initialize", {
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"clientCapabilities": {
|
||||||
|
"fs": {"readTextFile": False, "writeTextFile": False},
|
||||||
|
"terminal": False,
|
||||||
|
},
|
||||||
|
"clientInfo": {"name": "gh-commit", "version": VERSION},
|
||||||
|
}, timeout=60)
|
||||||
|
|
||||||
|
def prompt(self, text: str) -> str:
|
||||||
|
"""Run one prompt in a fresh session and return the agent's text reply."""
|
||||||
|
self._ensure_started()
|
||||||
|
session = self._call("session/new", {"cwd": self.cwd, "mcpServers": []}, timeout=120)
|
||||||
|
sid = session.get("sessionId")
|
||||||
|
if not sid:
|
||||||
|
raise ACPError("ACP bridge did not return a session id")
|
||||||
|
with self._lock:
|
||||||
|
self._chunks = []
|
||||||
|
self._active_session = sid
|
||||||
|
self._call("session/prompt", {
|
||||||
|
"sessionId": sid,
|
||||||
|
"prompt": [{"type": "text", "text": text}],
|
||||||
|
}, timeout=ACP_TIMEOUT)
|
||||||
|
return "".join(self._chunks).strip()
|
||||||
|
|
||||||
|
|
||||||
|
_acp: Optional[ACPClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def acp_prompt(text: str, cwd: Path) -> str:
|
||||||
|
"""Send a prompt to the shared Claude ACP client, starting it on first use."""
|
||||||
|
global _acp
|
||||||
|
if _acp is None:
|
||||||
|
_acp = ACPClient(cwd)
|
||||||
|
atexit.register(_acp.close)
|
||||||
|
return _acp.prompt(text)
|
||||||
|
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -100,6 +316,7 @@ def init_db():
|
|||||||
id INTEGER DEFAULT nextval('seq_repositories') PRIMARY KEY,
|
id INTEGER DEFAULT nextval('seq_repositories') PRIMARY KEY,
|
||||||
path TEXT UNIQUE NOT NULL,
|
path TEXT UNIQUE NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
gitignore_hash TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
@@ -131,6 +348,15 @@ def init_db():
|
|||||||
UNIQUE(scope_id)
|
UNIQUE(scope_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# Migrate older databases that predate gitignore tracking.
|
||||||
|
cols = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'repositories'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if "gitignore_hash" not in cols:
|
||||||
|
conn.execute("ALTER TABLE repositories ADD COLUMN gitignore_hash TEXT")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -176,6 +402,7 @@ def _cascade_delete_repo(conn, repo_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def save_scopes(repo_path: str, repo_name: str, scopes: dict[str, list[str]]):
|
def save_scopes(repo_path: str, repo_name: str, scopes: dict[str, list[str]]):
|
||||||
|
"""Persist scopes for a repo and snapshot the current .gitignore hash."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()
|
result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()
|
||||||
if result:
|
if result:
|
||||||
@@ -194,10 +421,20 @@ def save_scopes(repo_path: str, repo_name: str, scopes: dict[str, list[str]]):
|
|||||||
for path in (paths if isinstance(paths, list) else [paths]):
|
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("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.execute(
|
||||||
|
"UPDATE repositories SET updated_at = CURRENT_TIMESTAMP, gitignore_hash = ? WHERE id = ?",
|
||||||
|
[current_gitignore_hash(Path(repo_path)), repo_id],
|
||||||
|
)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stored_gitignore_hash(repo_path: str) -> Optional[str]:
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT gitignore_hash FROM repositories WHERE path = ?", [repo_path]).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
def list_repos() -> list[tuple]:
|
def list_repos() -> list[tuple]:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
result = conn.execute("""
|
result = conn.execute("""
|
||||||
@@ -221,6 +458,16 @@ def delete_repo(repo_path: str):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── .gitignore tracking ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def current_gitignore_hash(repo_root: Path) -> str:
|
||||||
|
"""SHA-256 of the repo's .gitignore, or "" when there is none."""
|
||||||
|
gitignore = repo_root / ".gitignore"
|
||||||
|
if not gitignore.exists():
|
||||||
|
return ""
|
||||||
|
return hashlib.sha256(gitignore.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# ── Migration ─────────────────────────────────────────────────────────────────
|
# ── Migration ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def migrate_toml(repo_path: Path, repo_name: str, toml_path: Path) -> bool:
|
def migrate_toml(repo_path: Path, repo_name: str, toml_path: Path) -> bool:
|
||||||
@@ -335,95 +582,122 @@ def filter_diff(diff: str) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# ── AI integration ────────────────────────────────────────────────────────────
|
# ── AI integration (Claude over ACP) ─────────────────────────────────────────────
|
||||||
|
|
||||||
def generate_commit_message(diff: str, scope: Optional[str] = None) -> Optional[str]:
|
COMMIT_PROMPT = """\
|
||||||
filtered = filter_diff(diff)
|
Write a single Conventional Commits message for the staged changes shown below.
|
||||||
context = f"### Git Diff\n{filtered}"
|
|
||||||
if scope:
|
Requirements:
|
||||||
context += f"\n\n### Scope\n{scope}"
|
- Format: type(scope): summary (e.g. feat, fix, docs, refactor, chore, test, ci)
|
||||||
try:
|
- Keep the summary under ~70 characters, imperative mood, no trailing period.
|
||||||
result = subprocess.run(
|
- Optionally add a blank line then a concise body explaining the "why" (wrap ~72 cols).
|
||||||
MODS_CMD, input=context, capture_output=True, text=True, check=True,
|
- Base the message ONLY on the diff below — do not invoke any tools.
|
||||||
)
|
- Output ONLY the commit message. No code fences, no preamble, no commentary.
|
||||||
return result.stdout.strip()
|
{scope_hint}
|
||||||
except subprocess.CalledProcessError as e:
|
### Git Diff
|
||||||
console.print(f"[red]Failed to generate commit message: {e}[/]")
|
{diff}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCOPE_PROMPT_NEW = """\
|
||||||
|
You are configuring commit "scopes" for this git repository at {cwd}.
|
||||||
|
|
||||||
|
Inspect the repository's directory structure and key files, then map logical
|
||||||
|
project areas to the paths they cover.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Scope names must read well in Conventional Commits: e.g. core, api, ui, cli,
|
||||||
|
docs, tests, ci, config, scripts, deps.
|
||||||
|
- Each scope maps to an array of repo-relative path prefixes (directories or files).
|
||||||
|
- Cover the meaningful source areas and group related paths together.
|
||||||
|
- Respect .gitignore: never create scopes for ignored, generated, or vendored paths.
|
||||||
|
- Output ONLY a single JSON object of the form {{"scope": ["path", ...], ...}}.
|
||||||
|
No prose, no markdown fences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCOPE_PROMPT_UPDATE = """\
|
||||||
|
You are updating the commit "scopes" for this git repository at {cwd}.
|
||||||
|
|
||||||
|
Current scopes (JSON):
|
||||||
|
{existing}
|
||||||
|
|
||||||
|
Re-inspect the repository's current structure (it may have changed, e.g. its
|
||||||
|
.gitignore was edited) and produce an updated mapping.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep scopes that still make sense; drop ones whose paths no longer exist.
|
||||||
|
- Add scopes for new meaningful areas.
|
||||||
|
- Scope names must read well in Conventional Commits.
|
||||||
|
- Respect .gitignore: never create scopes for ignored, generated, or vendored paths.
|
||||||
|
- Output ONLY a single JSON object of the form {{"scope": ["path", ...], ...}}.
|
||||||
|
No prose, no markdown fences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Optional[str]:
|
||||||
|
"""Return the first balanced top-level {...} object found in text."""
|
||||||
|
depth = 0
|
||||||
|
start = None
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
if ch == "{":
|
||||||
|
if start is None:
|
||||||
|
start = i
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}" and start is not None:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start:i + 1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_scopes_with_claude(repo_path: Path, existing_toml: Optional[str] = None) -> Optional[str]:
|
def parse_scopes_response(text: str) -> Optional[dict[str, list[str]]]:
|
||||||
if existing_toml:
|
blob = _extract_json_object(text)
|
||||||
prompt = f"""Analyze this git repository and UPDATE the existing scopes configuration.
|
if not blob:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(blob)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
scopes: dict[str, list[str]] = {}
|
||||||
|
for name, paths in data.items():
|
||||||
|
if isinstance(paths, str):
|
||||||
|
scopes[str(name)] = [paths]
|
||||||
|
elif isinstance(paths, list):
|
||||||
|
scopes[str(name)] = [str(p) for p in paths if str(p).strip()]
|
||||||
|
return scopes or None
|
||||||
|
|
||||||
Current scopes:
|
|
||||||
{existing_toml}
|
|
||||||
|
|
||||||
Your task:
|
def generate_commit_message(diff: str, repo_path: Path, scope: Optional[str] = None) -> Optional[str]:
|
||||||
1. Review the current scopes and their paths
|
scope_hint = f"- Use this scope: {scope}\n" if scope else ""
|
||||||
2. Analyze the current repository directory structure
|
prompt = COMMIT_PROMPT.format(scope_hint=scope_hint, diff=filter_diff(diff))
|
||||||
3. Update the scopes to reflect any new directories or structural changes
|
try:
|
||||||
4. Keep existing scopes that are still relevant
|
message = acp_prompt(prompt, repo_path)
|
||||||
5. Remove scopes for paths that no longer exist
|
except ACPError as e:
|
||||||
6. Add new scopes for new directories or logical areas
|
console.print(f"[red]Claude (ACP) failed to generate a commit message: {e}[/]")
|
||||||
|
return None
|
||||||
|
# Strip stray code fences if the model added them anyway.
|
||||||
|
message = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", message.strip()).strip()
|
||||||
|
return message or None
|
||||||
|
|
||||||
Output ONLY the updated TOML content (no markdown, no explanation, just the raw TOML)."""
|
|
||||||
|
def generate_scopes(repo_path: Path, existing: Optional[dict[str, list[str]]] = None) -> Optional[dict[str, list[str]]]:
|
||||||
|
if existing:
|
||||||
|
prompt = SCOPE_PROMPT_UPDATE.format(cwd=repo_path, existing=json.dumps(existing, indent=2))
|
||||||
else:
|
else:
|
||||||
prompt = """Analyze this git repository and generate a TOML scopes configuration.
|
prompt = SCOPE_PROMPT_NEW.format(cwd=repo_path)
|
||||||
|
|
||||||
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:
|
try:
|
||||||
with console.status("[magenta]Analyzing repository with Claude...[/]"):
|
with console.status("[magenta]Analyzing repository with Claude (ACP)...[/]"):
|
||||||
result = subprocess.run(
|
output = acp_prompt(prompt, repo_path)
|
||||||
["claude", "-p"], input=prompt,
|
except ACPError as e:
|
||||||
capture_output=True, text=True, check=True, cwd=repo_path,
|
console.print(f"[red]Claude (ACP) failed: {e}[/]")
|
||||||
)
|
|
||||||
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
|
return None
|
||||||
except FileNotFoundError:
|
scopes = parse_scopes_response(output)
|
||||||
console.print("[red]Error: 'claude' command not found. Install Claude Code:[/]")
|
if not scopes:
|
||||||
console.print("[cyan] npm install -g @anthropic-ai/claude-code[/]")
|
console.print("[red]Could not parse scopes from Claude's response[/]")
|
||||||
return None
|
if DEBUG:
|
||||||
|
console.print(f"[dim]{output}[/]")
|
||||||
|
return scopes
|
||||||
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]]):
|
def display_scopes(scopes: dict[str, list[str]]):
|
||||||
@@ -443,10 +717,8 @@ def get_changed_files() -> list[str]:
|
|||||||
def get_files_in_scope(files: list[str], paths: list[str]) -> list[str]:
|
def get_files_in_scope(files: list[str], paths: list[str]) -> list[str]:
|
||||||
matched = []
|
matched = []
|
||||||
for f in files:
|
for f in files:
|
||||||
for path in paths:
|
if any(f.startswith(p) for p in paths):
|
||||||
if f.startswith(path):
|
|
||||||
matched.append(f)
|
matched.append(f)
|
||||||
break
|
|
||||||
return matched
|
return matched
|
||||||
|
|
||||||
|
|
||||||
@@ -475,6 +747,63 @@ def push_to_origin():
|
|||||||
console.print(f"[green]✓ Pushed to origin/{branch}[/]")
|
console.print(f"[green]✓ Pushed to origin/{branch}[/]")
|
||||||
|
|
||||||
|
|
||||||
|
def commit_group(repo_path: Path, files: list[str], scope: Optional[str] = None) -> bool:
|
||||||
|
"""Stage `files`, generate a message, and commit them as one group."""
|
||||||
|
files = [f for f in files if f]
|
||||||
|
if not files:
|
||||||
|
return False
|
||||||
|
|
||||||
|
reset_staging()
|
||||||
|
stage_files(files)
|
||||||
|
diff = git("diff", "--cached")
|
||||||
|
if not diff:
|
||||||
|
reset_staging()
|
||||||
|
return False
|
||||||
|
|
||||||
|
with console.status("[magenta]Generating commit message...[/]"):
|
||||||
|
message = generate_commit_message(diff, repo_path, scope)
|
||||||
|
if not message:
|
||||||
|
reset_staging()
|
||||||
|
return False
|
||||||
|
|
||||||
|
console.print(Panel(message, border_style="magenta"))
|
||||||
|
label = scope or "these changes"
|
||||||
|
if confirm(f"Commit {label}?"):
|
||||||
|
do_commit(message)
|
||||||
|
console.print(f"[green]✓ Committed {label}[/]\n")
|
||||||
|
return True
|
||||||
|
reset_staging()
|
||||||
|
console.print(f"[dim] Skipped {label}[/]\n")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scope auto-refresh ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def maybe_auto_refresh_scopes(repo_path: Path, repo_name: str):
|
||||||
|
"""Regenerate scopes automatically when .gitignore has changed since last save."""
|
||||||
|
if NO_AUTO_REFRESH:
|
||||||
|
return
|
||||||
|
current = current_gitignore_hash(repo_path)
|
||||||
|
stored = get_stored_gitignore_hash(str(repo_path))
|
||||||
|
# First time we've seen this repo's .gitignore — record a baseline, don't refresh.
|
||||||
|
if stored is None:
|
||||||
|
save_scopes(str(repo_path), repo_name, get_repo_scopes(str(repo_path)))
|
||||||
|
return
|
||||||
|
if current == stored:
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print("[yellow]↻ .gitignore changed — regenerating scopes with Claude...[/]")
|
||||||
|
existing = get_repo_scopes(str(repo_path))
|
||||||
|
scopes = generate_scopes(repo_path, existing)
|
||||||
|
if not scopes:
|
||||||
|
console.print("[dim] Keeping existing scopes (regeneration failed)[/]\n")
|
||||||
|
return
|
||||||
|
save_scopes(str(repo_path), repo_name, scopes)
|
||||||
|
console.print("[green]✓ Scopes updated:[/]")
|
||||||
|
display_scopes(scopes)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
# ── Commands ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def cmd_version():
|
def cmd_version():
|
||||||
@@ -482,7 +811,7 @@ def cmd_version():
|
|||||||
|
|
||||||
|
|
||||||
def cmd_help():
|
def cmd_help():
|
||||||
console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits\n")
|
console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (Claude ACP)\n")
|
||||||
console.print("[cyan]Usage:[/]")
|
console.print("[cyan]Usage:[/]")
|
||||||
console.print(" gh commit Commit changes grouped by scope")
|
console.print(" gh commit Commit changes grouped by scope")
|
||||||
console.print(" gh commit --auto Auto-confirm all prompts")
|
console.print(" gh commit --auto Auto-confirm all prompts")
|
||||||
@@ -501,9 +830,12 @@ def cmd_help():
|
|||||||
console.print("[cyan]Environment:[/]")
|
console.print("[cyan]Environment:[/]")
|
||||||
console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts")
|
console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts")
|
||||||
console.print(" GH_COMMIT_PUSH=1 Auto-push after commits")
|
console.print(" GH_COMMIT_PUSH=1 Auto-push after commits")
|
||||||
console.print(" GH_COMMIT_MODS_CMD=... Override mods command\n")
|
console.print(" GH_COMMIT_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change")
|
||||||
console.print("[cyan]Migration:[/]")
|
console.print(" GH_COMMIT_ACP_CMD=... Override the Claude ACP bridge command")
|
||||||
console.print(" Existing .github/Repo.toml or scopes.json are auto-migrated on first run")
|
console.print(" GH_COMMIT_ACP_PERMISSION= ACP permission mode (default bypassPermissions)")
|
||||||
|
console.print(" GH_COMMIT_DEBUG=1 Show ACP bridge stderr / parse diagnostics\n")
|
||||||
|
console.print("[cyan]Scopes auto-refresh whenever .gitignore changes.[/]")
|
||||||
|
console.print("[cyan]Legacy .github/Repo.toml or scopes.json are auto-migrated on first run.[/]")
|
||||||
|
|
||||||
|
|
||||||
def cmd_db_path():
|
def cmd_db_path():
|
||||||
@@ -560,14 +892,8 @@ def cmd_init():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
console.print(f"[magenta bold]Generating scopes for {repo_name}...[/]\n")
|
console.print(f"[magenta bold]Generating scopes for {repo_name}...[/]\n")
|
||||||
toml_output = generate_scopes_with_claude(repo_path)
|
scopes = generate_scopes(repo_path)
|
||||||
if not toml_output:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
scopes = parse_toml_scopes(toml_output)
|
|
||||||
if not scopes:
|
if not scopes:
|
||||||
console.print("[red]Error: Generated output is not valid TOML[/]")
|
|
||||||
console.print(f"[dim]{toml_output}[/]")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
save_scopes(str(repo_path), repo_name, scopes)
|
save_scopes(str(repo_path), repo_name, scopes)
|
||||||
@@ -596,13 +922,8 @@ def cmd_refresh():
|
|||||||
console.print("[dim]Cancelled[/]")
|
console.print("[dim]Cancelled[/]")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
toml_output = generate_scopes_with_claude(repo_path, scopes_to_toml(existing_scopes))
|
scopes = generate_scopes(repo_path, existing_scopes)
|
||||||
if not toml_output:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
scopes = parse_toml_scopes(toml_output)
|
|
||||||
if not scopes:
|
if not scopes:
|
||||||
console.print("[red]Error: Generated output is not valid TOML[/]")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
console.print("\n[green]✓ Generated updated scopes[/]\n")
|
console.print("\n[green]✓ Generated updated scopes[/]\n")
|
||||||
@@ -665,6 +986,7 @@ def cmd_sync():
|
|||||||
|
|
||||||
def cmd_commit():
|
def cmd_commit():
|
||||||
repo_path = require_git()
|
repo_path = require_git()
|
||||||
|
repo_name = repo_path.name
|
||||||
|
|
||||||
auto_migrate(repo_path)
|
auto_migrate(repo_path)
|
||||||
|
|
||||||
@@ -672,11 +994,15 @@ def cmd_commit():
|
|||||||
console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n")
|
console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n")
|
||||||
console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n")
|
console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n")
|
||||||
if confirm("Generate scopes now using Claude?"):
|
if confirm("Generate scopes now using Claude?"):
|
||||||
return cmd_init()
|
if cmd_init() != 0:
|
||||||
|
return 1
|
||||||
else:
|
else:
|
||||||
console.print("\n[dim]Run 'gh commit init' to configure scopes[/]")
|
console.print("\n[dim]Run 'gh commit init' to configure scopes[/]")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Keep scopes aligned with the repo whenever .gitignore changes.
|
||||||
|
maybe_auto_refresh_scopes(repo_path, repo_name)
|
||||||
|
|
||||||
console.print("[magenta]Finding scopes with changes...[/]")
|
console.print("[magenta]Finding scopes with changes...[/]")
|
||||||
changed_files = get_changed_files()
|
changed_files = get_changed_files()
|
||||||
scopes = get_repo_scopes(str(repo_path))
|
scopes = get_repo_scopes(str(repo_path))
|
||||||
@@ -691,90 +1017,36 @@ def cmd_commit():
|
|||||||
else:
|
else:
|
||||||
console.print(f"[cyan]Scopes with changes: {' '.join(scopes_with_changes)}[/]\n")
|
console.print(f"[cyan]Scopes with changes: {' '.join(scopes_with_changes)}[/]\n")
|
||||||
|
|
||||||
# Process each scope
|
|
||||||
for scope in scopes_with_changes:
|
for scope in scopes_with_changes:
|
||||||
console.print(f"[magenta bold]Processing scope: {scope}[/]")
|
console.print(f"[magenta bold]Processing scope: {scope}[/]")
|
||||||
paths = scopes[scope]
|
console.print(f"[cyan] Paths: {', '.join(scopes[scope])}[/]")
|
||||||
console.print(f"[cyan] Paths: {', '.join(paths)}[/]")
|
scope_files = get_files_in_scope(get_changed_files(), scopes[scope])
|
||||||
|
|
||||||
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:
|
if not scope_files:
|
||||||
console.print("[dim] No files found in scope paths[/]")
|
console.print("[dim] No files found in scope paths[/]\n")
|
||||||
continue
|
continue
|
||||||
|
commit_group(repo_path, scope_files, scope)
|
||||||
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()
|
reset_staging()
|
||||||
|
|
||||||
# Remaining files
|
# Remaining files outside any scope.
|
||||||
remaining = git("status", "--porcelain")
|
if git("status", "--porcelain"):
|
||||||
if remaining:
|
console.print("[yellow]Processing remaining files outside any scope...[/]\n")
|
||||||
console.print("\n[yellow]Processing remaining files outside any scope...[/]")
|
|
||||||
|
|
||||||
tracked_unstaged = git("diff", "--name-only")
|
tracked = [f for f in git("diff", "--name-only").split("\n") if f]
|
||||||
if tracked_unstaged:
|
if tracked:
|
||||||
files = [f for f in tracked_unstaged.split("\n") if f]
|
console.print("[magenta]Tracked unstaged files:[/]")
|
||||||
console.print("\n[magenta]Tracked unstaged files:[/]")
|
for f in tracked:
|
||||||
for f in files:
|
|
||||||
console.print(f"[dim] {f}[/]")
|
console.print(f"[dim] {f}[/]")
|
||||||
if confirm("Commit tracked unstaged files?"):
|
if confirm("Commit tracked unstaged files?"):
|
||||||
stage_files(files)
|
commit_group(repo_path, tracked)
|
||||||
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")
|
untracked = [f for f in git("ls-files", "--others", "--exclude-standard").split("\n") if f]
|
||||||
if untracked:
|
if untracked:
|
||||||
files = [f for f in untracked.split("\n") if f]
|
console.print("[magenta]Untracked files:[/]")
|
||||||
console.print("\n[magenta]Untracked files:[/]")
|
for f in untracked:
|
||||||
for f in files:
|
|
||||||
console.print(f"[dim] {f}[/]")
|
console.print(f"[dim] {f}[/]")
|
||||||
if confirm("Commit untracked files?"):
|
if confirm("Commit untracked files?"):
|
||||||
stage_files(files)
|
commit_group(repo_path, untracked)
|
||||||
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
|
# Push
|
||||||
unpushed = get_unpushed_commits()
|
unpushed = get_unpushed_commits()
|
||||||
@@ -783,9 +1055,7 @@ def cmd_commit():
|
|||||||
console.print(f"[cyan]{len(unpushed)} commit(s) ready to push:[/]\n")
|
console.print(f"[cyan]{len(unpushed)} commit(s) ready to push:[/]\n")
|
||||||
for line in unpushed:
|
for line in unpushed:
|
||||||
parts = line.split(" ", 1)
|
parts = line.split(" ", 1)
|
||||||
hash_val = parts[0]
|
console.print(f" [bold]{parts[0]}[/] {parts[1] if len(parts) > 1 else ''}")
|
||||||
msg = parts[1] if len(parts) > 1 else ""
|
|
||||||
console.print(f" [bold]{hash_val}[/] {msg}")
|
|
||||||
console.print()
|
console.print()
|
||||||
if AUTO_PUSH or confirm("Push commits to origin?"):
|
if AUTO_PUSH or confirm("Push commits to origin?"):
|
||||||
push_to_origin()
|
push_to_origin()
|
||||||
|
|||||||
Reference in New Issue
Block a user