# MiniGit integrated into RP Assistant
import os
import json
import hashlib
import zlib
import difflib
import datetime
import subprocess
import sqlite3
import urllib.request
import urllib.error
from rp.config import DB_PATH
# Constants
IGNORE_SET = {
".minigit",
".git",
".hg",
".svn",
"__pycache__",
".DS_Store",
}
# AI constants for commit message generation
API_URL = "https://static.molodetz.nl/rp.cgi/api/v1/chat/completions"
MODEL = "google/gemma-3-12b-it:free"
TEMPERATURE = 1.0
MAX_TOKENS = None
IGNORE_SET = {
".minigit",
".git",
".hg",
".svn",
"__pycache__",
".DS_Store",
}
def init_storage():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS minigit_objects (
hash TEXT PRIMARY KEY,
data BLOB
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS minigit_commits (
hash TEXT PRIMARY KEY,
data TEXT
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS minigit_trees (
hash TEXT PRIMARY KEY,
data TEXT
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS minigit_meta (
key TEXT PRIMARY KEY,
value TEXT
)
"""
)
# Check if initial commit exists
cursor.execute("SELECT value FROM minigit_meta WHERE key = 'head'")
if not cursor.fetchone():
initial_commit_hash = "0" * 40
initial_commit_data = {
"parent_hash": None,
"message": "Initial commit",
"author": "System",
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"tree_hash": None,
"project_name": os.path.basename(os.getcwd()),
"changed_files": [],
}
save_commit(initial_commit_hash, initial_commit_data)
cursor.execute(
"INSERT INTO minigit_meta (key, value) VALUES ('head', ?)", (initial_commit_hash,)
)
conn.commit()
conn.close()
return True
def get_db_connection():
return sqlite3.connect(DB_PATH)
def save_object(table, obj_hash, data):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(f"INSERT OR REPLACE INTO {table} (hash, data) VALUES (?, ?)", (obj_hash, data))
conn.commit()
conn.close()
def get_object(table, obj_hash):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT data FROM {table} WHERE hash = ?", (obj_hash,))
row = cursor.fetchone()
conn.close()
if not row:
raise ValueError(f"Object not found: {obj_hash}")
return row[0]
def object_exists(table, obj_hash):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT 1 FROM {table} WHERE hash = ?", (obj_hash,))
exists = cursor.fetchone() is not None
conn.close()
return exists
def save_blob(content: bytes) -> str:
blob_hash = hash_content(content)
if not object_exists("minigit_objects", blob_hash):
compressed_content = zlib.compress(content)
save_object("minigit_objects", blob_hash, compressed_content)
return blob_hash
def get_blob_content(blob_hash: str) -> bytes:
compressed_content = get_object("minigit_objects", blob_hash)
return zlib.decompress(compressed_content)
def save_commit(commit_hash, commit_data):
save_object("minigit_commits", commit_hash, json.dumps(commit_data, indent=2))
def get_commit(commit_hash: str) -> dict:
return json.loads(get_object("minigit_commits", commit_hash))
def save_tree(tree_hash, tree_data):
save_object("minigit_trees", tree_hash, json.dumps(tree_data, indent=2))
def get_tree(tree_hash: str) -> dict:
if not tree_hash:
return {}
return json.loads(get_object("minigit_trees", tree_hash))
def get_tree_for_commit(commit_hash: str) -> dict:
commit = get_commit(commit_hash)
return get_tree(commit.get("tree_hash"))
def get_latest_commit_hash() -> str:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT value FROM minigit_meta WHERE key = 'head'")
row = cursor.fetchone()
conn.close()
return row[0] if row else "0" * 40
def update_head(commit_hash: str):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO minigit_meta (key, value) VALUES ('head', ?)", (commit_hash,)
)
conn.commit()
conn.close()
def get_all_commit_hashes():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT hash FROM minigit_commits")
for row in cursor.fetchall():
yield row[0]
conn.close()
def get_full_commit_hash(partial_hash: str) -> str | None:
if not partial_hash:
return None
if len(partial_hash) == 40:
return partial_hash if object_exists(COMMITS_DIR, partial_hash) else None
matches = [h for h in get_all_commit_hashes() if h.startswith(partial_hash)]
if len(matches) == 0:
return None
if len(matches) > 1:
return None
return matches[0]
def safe_run(cmd, default=""):
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return result.stdout.strip() if result.returncode == 0 else default
except Exception:
return default
def call_ai(prompt):
try:
api_key = os.environ.get("MOLODETZ_API_KEY", "retoorded")
if not api_key:
return None
data = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": TEMPERATURE,
}
if MAX_TOKENS is not None:
data["max_tokens"] = MAX_TOKENS
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/commit-hook",
"X-Title": "Git Commit Hook",
}
req = urllib.request.Request(
API_URL, data=json.dumps(data).encode("utf-8"), headers=headers
)
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode("utf-8"))
return result["choices"][0]["message"]["content"].strip()
except Exception:
return None
def get_diff_string():
parent_hash = get_latest_commit_hash()
if parent_hash == "0" * 40:
return ""
tree_a = get_tree_for_commit(parent_hash)
tree_b = {}
for file_path, content in scan_working_dir():
tree_b[file_path] = hash_content(content)
diff_lines = []
files_a = set(tree_a.keys())
files_b = set(tree_b.keys())
all_files = sorted(list(files_a.union(files_b)))
for file_path in all_files:
if file_path not in files_a:
try:
content_b = get_blob_content(tree_b[file_path]).decode("utf-8")
diff_lines.append(f"+++ Added file: {file_path}")
for line in content_b.splitlines():
diff_lines.append(f"+{line}")
except:
diff_lines.append(f"+++ Added file: {file_path} [binary]")
elif file_path not in files_b:
try:
content_a = get_blob_content(tree_a[file_path]).decode("utf-8")
diff_lines.append(f"--- Removed file: {file_path}")
for line in content_a.splitlines():
diff_lines.append(f"-{line}")
except:
diff_lines.append(f"--- Removed file: {file_path} [binary]")
else:
if tree_a[file_path] != tree_b[file_path]:
try:
content_a = get_blob_content(tree_a[file_path]).decode("utf-8").splitlines()
content_b = get_blob_content(tree_b[file_path]).decode("utf-8").splitlines()
diff = difflib.unified_diff(
content_a,
content_b,
fromfile=f"a/{file_path}",
tofile=f"b/{file_path}",
lineterm="",
)
diff_lines.append(f"~~~ Modified file: {file_path}")
diff_lines.extend(diff)
except:
diff_lines.append(f"~~~ Modified file: {file_path} [binary]")
return "\n".join(diff_lines)
def get_changed_files_list():
parent_hash = get_latest_commit_hash()
if parent_hash == "0" * 40:
return [file_path for file_path, _ in scan_working_dir()]
tree_a = get_tree_for_commit(parent_hash)
tree_b = {}
for file_path, content in scan_working_dir():
tree_b[file_path] = hash_content(content)
changed = []
files_a = set(tree_a.keys())
files_b = set(tree_b.keys())
all_files = files_a.union(files_b)
for file_path in all_files:
if (
file_path not in files_a
or file_path not in files_b
or tree_a.get(file_path) != tree_b.get(file_path)
):
changed.append(file_path)
return changed
def generate_commit_message(diff, files):
try:
files_list = "\n".join([f"- {f}" for f in files[:20]])
prompt = f"""You write commit messages for code changes.
Changed files:
{files_list}
Code changes:
{diff[:12000]}
Write a commit message with this format:
<prefix>: <description>
<prefix>: <description>
... (use multiple lines for multiple distinct changes)
Format rules:
- All lowercase for the prefix
- Colon and space after prefix
- Start description with lowercase letter
- No period at the end
- Use imperative mood (Add not Added)
- Each line should be a separate change
Choose prefixes from:
- fix: for bug fixes
Example: fix: resolve null pointer error in user login
Example: fix: correct date format in export function
- feat: for new features
Example: feat: add dark mode toggle to settings
Example: feat: implement search filter for products
- docs: for documentation changes
Example: docs: update api endpoint descriptions
Example: docs: add setup guide for development
- perf: for performance improvements
Example: perf: reduce database query time by 40%
Example: perf: optimize image loading with lazy load
- refactor: for code restructuring
Example: refactor: simplify user validation logic
Example: refactor: extract common functions to utils
- maintenance: for routine updates and maintenance
Example: maintenance: update dependencies to latest versions
Example: maintenance: clean up unused imports and files
Reply with ONLY the commit message, nothing else. No other text. No explanations or reasoning."""
message = call_ai(prompt)
if not message:
return generate_fallback_message(files)
message = message.strip().strip('"').strip("\"'")
lines = message.split("\n")
processed_lines = []
prefixes = ["fix:", "feat:", "docs:", "perf:", "refactor:", "maintenance:"]
for line in lines:
line = line.strip()
if line and not any(line.startswith(p) for p in prefixes):
line = f"feat: {line}"
if line:
processed_lines.append(line)
if not processed_lines:
return generate_fallback_message(files)
return "\n".join(processed_lines)
except Exception:
return generate_fallback_message(files)
def generate_fallback_message(files):
try:
if not files:
return "feat: update project"
exts = set()
for f in files:
ext = os.path.splitext(f)[1]
if ext:
exts.add(ext[1:])
if exts:
return f"feat: update {', '.join(sorted(exts)[:3])} files"
return "feat: update project files"
except Exception:
return "feat: update project"
def hash_content(content: bytes) -> str:
return hashlib.sha1(content).hexdigest()
def scan_working_dir():
for root, dirs, files in os.walk(".", topdown=True):
dirs[:] = [d for d in dirs if d not in IGNORE_SET and not d.startswith(".")]
for file in files:
if file in IGNORE_SET:
continue
file_path = os.path.normpath(os.path.join(root, file))
file_path = file_path.replace(os.path.sep, "/")
if file_path.startswith("./"):
file_path = file_path[2:]
try:
with open(file_path, "rb") as f:
content = f.read()
yield file_path, content
except IOError:
pass
def cmd_commit(message=None, author="rp"):
init_storage() # Ensure tables exist
parent_hash = get_latest_commit_hash()
current_tree = {}
files_scanned = 0
for file_path, content in scan_working_dir():
files_scanned += 1
blob_hash = save_blob(content)
current_tree[file_path] = blob_hash
if files_scanned == 0 and parent_hash != "0" * 40:
return
parent_tree = get_tree_for_commit(parent_hash)
if parent_tree == current_tree:
return
changed_files = get_changed_files_list()
if message is None:
diff = get_diff_string()
message = generate_commit_message(diff, changed_files)
tree_data_str = json.dumps(current_tree, sort_keys=True)
tree_hash = hash_content(tree_data_str.encode())
save_tree(tree_hash, current_tree)
commit_timestamp = datetime.datetime.now(datetime.timezone.utc)
project_name = os.path.basename(os.getcwd())
commit_data_to_hash = (
f"tree:{tree_hash}\n"
f"parent:{parent_hash}\n"
f"author:{author}\n"
f"timestamp:{commit_timestamp.isoformat()}\n"
f"message:{message}\n"
f"project:{project_name}\n"
)
commit_hash = hash_content(commit_data_to_hash.encode())
commit_data = {
"parent_hash": parent_hash,
"message": message,
"author": author,
"timestamp": commit_timestamp.isoformat(),
"tree_hash": tree_hash,
"project_name": project_name,
"changed_files": changed_files,
}
save_commit(commit_hash, commit_data)
update_head(commit_hash)
return commit_hash
def minigit_commit(message=None):
"""Commit current state to MiniGit."""
try:
init_storage() # Ensure repo exists
return cmd_commit(message)
except Exception:
return None
def checkout_commit(commit_hash):
"""Checkout to a specific commit."""
commit = get_commit(commit_hash)
tree = get_tree(commit["tree_hash"])
current_files = set(scan_working_dir_files())
tree_files = set(tree.keys())
# Remove files not in tree
for file_path in current_files - tree_files:
if os.path.exists(file_path):
os.remove(file_path)
# Add or update files in tree
for file_path, blob_hash in tree.items():
content = get_blob_content(blob_hash)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
f.write(content)
update_head(commit_hash)
def scan_working_dir_files():
for root, dirs, files in os.walk(".", topdown=True):
dirs[:] = [d for d in dirs if d not in IGNORE_SET and not d.startswith(".")]
for file in files:
if file in IGNORE_SET:
continue
file_path = os.path.normpath(os.path.join(root, file))
file_path = file_path.replace(os.path.sep, "/")
if file_path.startswith("./"):
file_path = file_path[2:]
yield file_path
def minigit_revert(description):
"""Revert to the latest commit whose message contains the description."""
try:
init_storage()
matching_commits = []
for commit_hash in get_all_commit_hashes():
commit = get_commit(commit_hash)
if description.lower() in commit["message"].lower():
matching_commits.append((commit_hash, commit))
if not matching_commits:
return None
# Sort by timestamp, latest first
matching_commits.sort(key=lambda x: x[1]["timestamp"], reverse=True)
commit_hash, _ = matching_commits[0]
checkout_commit(commit_hash)
return commit_hash
except Exception:
return None
# For integration, this will be called before file modifications
def pre_commit():
"""Pre-commit hook for file modifications."""
return minigit_commit("maintenance: prepare for file modification")