git_reword_ai.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. import subprocess
  2. import google.generativeai as genai
  3. import os
  4. import argparse
  5. import sys
  6. import datetime
  7. import re
  8. import logging
  9. import tempfile
  10. import json # Used to pass data to editor script
  11. # --- Configuration ---
  12. # Configure logging
  13. logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
  14. # Attempt to get API key from environment variable
  15. API_KEY = os.getenv("GEMINI_API_KEY")
  16. if not API_KEY:
  17. logging.error("GEMINI_API_KEY environment variable not set.")
  18. logging.error(
  19. "Please obtain an API key from Google AI Studio (https://aistudio.google.com/app/apikey)"
  20. )
  21. logging.error("and set it as an environment variable.")
  22. sys.exit(1)
  23. # Configure the Gemini AI Client
  24. try:
  25. genai.configure(api_key=API_KEY)
  26. MODEL_NAME = os.getenv("GEMINI_MODEL")
  27. if not MODEL_NAME:
  28. logging.error("GEMINI_MODEL environment variable not set.")
  29. logging.error(
  30. "Please set the desired Gemini model name (e.g., 'gemini-1.5-flash-latest')."
  31. )
  32. logging.error(" export GEMINI_MODEL='gemini-1.5-flash-latest' (Linux/macOS)")
  33. logging.error(" set GEMINI_MODEL=gemini-1.5-flash-latest (Windows CMD)")
  34. logging.error(
  35. " $env:GEMINI_MODEL='gemini-1.5-flash-latest' (Windows PowerShell)"
  36. )
  37. sys.exit(1)
  38. model = genai.GenerativeModel(MODEL_NAME)
  39. logging.info(f"Using Gemini model: {MODEL_NAME}")
  40. except Exception as e:
  41. logging.error(f"Error configuring Gemini AI: {e}")
  42. sys.exit(1)
  43. # --- Git Helper Functions (Copied from previous script) ---
  44. def run_git_command(command_list, check=True, capture_output=True, env=None):
  45. """
  46. Runs a Git command as a list of arguments and returns its stdout.
  47. Handles errors and returns None on failure if check=True.
  48. Allows passing environment variables.
  49. """
  50. full_command = []
  51. try:
  52. full_command = ["git"] + command_list
  53. logging.info(f"Running command: {' '.join(full_command)}")
  54. cmd_env = os.environ.copy()
  55. if env:
  56. cmd_env.update(env)
  57. result = subprocess.run(
  58. full_command,
  59. check=check,
  60. capture_output=capture_output,
  61. text=True,
  62. encoding="utf-8",
  63. errors="replace",
  64. env=cmd_env,
  65. )
  66. if result.stdout:
  67. logging.info(f"Command output:\n{result.stdout[:200]}...")
  68. return result.stdout.strip() if capture_output else ""
  69. return ""
  70. except subprocess.CalledProcessError as e:
  71. logging.error(f"Error executing Git command: {' '.join(full_command)}")
  72. stderr_safe = (
  73. e.stderr.strip().encode("utf-8", "replace").decode("utf-8")
  74. if e.stderr
  75. else ""
  76. )
  77. stdout_safe = (
  78. e.stdout.strip().encode("utf-8", "replace").decode("utf-8")
  79. if e.stdout
  80. else ""
  81. )
  82. logging.error(f"Exit Code: {e.returncode}")
  83. if stderr_safe:
  84. logging.error(f"Stderr: {stderr_safe}")
  85. if stdout_safe:
  86. logging.error(f"Stdout: {stdout_safe}")
  87. return None
  88. except FileNotFoundError:
  89. logging.error(
  90. "Error: 'git' command not found. Is Git installed and in your PATH?"
  91. )
  92. sys.exit(1)
  93. except Exception as e:
  94. logging.error(f"Running command: {' '.join(full_command)}")
  95. logging.error(f"An unexpected error occurred running git: {e}")
  96. return None
  97. def check_git_repository():
  98. output = run_git_command(["rev-parse", "--is-inside-work-tree"])
  99. return output == "true"
  100. def get_current_branch():
  101. return run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
  102. def create_backup_branch(branch_name):
  103. timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
  104. backup_branch_name = f"{branch_name}-backup-{timestamp}"
  105. logging.info(
  106. f"Attempting to create backup branch: {backup_branch_name} from {branch_name}"
  107. )
  108. output = run_git_command(["branch", backup_branch_name, branch_name])
  109. if output is not None:
  110. logging.info(f"Successfully created backup branch: {backup_branch_name}")
  111. return backup_branch_name
  112. else:
  113. logging.error(f"Failed to create backup branch.")
  114. return None
  115. def get_commit_range(upstream_ref, current_branch):
  116. logging.info(
  117. f"Finding merge base between '{upstream_ref}' and '{current_branch}'..."
  118. )
  119. merge_base = run_git_command(["merge-base", upstream_ref, current_branch])
  120. if not merge_base:
  121. logging.error(
  122. f"Could not find merge base between '{upstream_ref}' and '{current_branch}'."
  123. )
  124. return None, None
  125. logging.info(f"Found merge base: {merge_base}")
  126. commit_range = f"{merge_base}..{current_branch}"
  127. return commit_range, merge_base
  128. def get_commits_in_range(commit_range):
  129. # Use --format=%H %s to get full hash for later matching if needed
  130. log_output = run_git_command(
  131. ["log", "--pretty=format:%h %H %s", "--reverse", commit_range]
  132. )
  133. if log_output is not None:
  134. commits = log_output.splitlines()
  135. # Store as list of dicts for easier access
  136. commit_data = []
  137. for line in commits:
  138. parts = line.split(" ", 2)
  139. if len(parts) == 3:
  140. commit_data.append(
  141. {"short_hash": parts[0], "full_hash": parts[1], "subject": parts[2]}
  142. )
  143. logging.info(f"Found {len(commit_data)} commits in range {commit_range}.")
  144. return commit_data
  145. return []
  146. def get_diff_in_range(commit_range):
  147. """Gets the combined diffstat and patch for the specified range."""
  148. diff_output = run_git_command(["diff", "--patch-with-stat", commit_range])
  149. if diff_output is not None:
  150. logging.info(
  151. f"Generated diff for range {commit_range} (length: {len(diff_output)} chars)."
  152. )
  153. else:
  154. logging.warning(f"Could not generate diff for range {commit_range}.")
  155. return diff_output if diff_output is not None else ""
  156. # --- AI Interaction ---
  157. def generate_reword_suggestion_prompt(commit_range, merge_base, commits_data, diff):
  158. """
  159. Creates a prompt asking the AI to identify commits needing rewording
  160. and to generate the full new commit message for each.
  161. """
  162. # Format commit list for the prompt using only short hash and subject
  163. commit_list_str = (
  164. "\n".join([f"- {c['short_hash']} {c['subject']}" for c in commits_data])
  165. if commits_data
  166. else "No commits in range."
  167. )
  168. prompt = f"""
  169. You are an expert Git assistant specializing in commit message conventions. Your task is to analyze the provided Git commit history within the range `{commit_range}` and identify commits whose messages should be improved using `reword` during an interactive rebase (`git rebase -i {merge_base}`).
  170. **Goal:** For each commit needing improvement, generate a **complete, new commit message** (subject and body) that adheres strictly to standard Git conventions.
  171. **Git Commit Message Conventions to Adhere To:**
  172. 1. **Subject Line:** Concise, imperative summary (max 50 chars). Capitalized. No trailing period. Use types like `feat:`, `fix:`, `refactor:`, `perf:`, `test:`, `build:`, `ci:`, `docs:`, `style:`, `chore:`. Example: `feat: Add user authentication endpoint`
  173. 2. **Blank Line:** Single blank line between subject and body.
  174. 3. **Body:** Explain 'what' and 'why' (motivation, approach, contrast with previous behavior). Wrap lines at 72 chars. Omit body ONLY for truly trivial changes where the subject is self-explanatory. Example:
  175. ```
  176. refactor: Improve database query performance
  177. The previous implementation used multiple sequential queries
  178. to fetch related data, leading to N+1 problems under load.
  179. This change refactors the data access layer to use a single
  180. JOIN query, significantly reducing database roundtrips and
  181. improving response time for the user profile page.
  182. ```
  183. **Provided Context:**
  184. 1. **Commit Range:** `{commit_range}`
  185. 2. **Merge Base Hash:** `{merge_base}`
  186. 3. **Commits in Range (Oldest First - Short Hash & Subject):**
  187. ```
  188. {commit_list_str}
  189. ```
  190. 4. **Combined Diff for the Range (`git diff --patch-with-stat {commit_range}`):**
  191. ```diff
  192. {diff if diff else "No differences found or unable to get diff."}
  193. ```
  194. **Instructions:**
  195. 1. Analyze the commits listed above, focusing on their subjects and likely content based on the diff.
  196. 2. Identify commits whose messages are unclear, too long, lack a type prefix, are poorly formatted, or don't adequately explain the change.
  197. 3. For **each** commit you identify for rewording, output a block EXACTLY in the following format:
  198. ```text
  199. REWORD: <short_hash_to_reword>
  200. NEW_MESSAGE:
  201. <Generated Subject Line Adhering to Conventions>
  202. <Generated Body Line 1 Adhering to Conventions>
  203. <Generated Body Line 2 Adhering to Conventions>
  204. ...
  205. <Generated Body Last Line Adhering to Conventions>
  206. END_MESSAGE
  207. ```
  208. * Replace `<short_hash_to_reword>` with the short hash from the commit list.
  209. * Replace `<Generated Subject Line...>` with the new subject line you generate.
  210. * Replace `<Generated Body Line...>` with the lines of the new body you generate (if a body is needed). Ensure a blank line between subject and body, and wrap body lines at 72 characters. If no body is needed, omit the body lines but keep the blank line after the Subject.
  211. * The `END_MESSAGE` line marks the end of the message for one commit.
  212. 4. Provide *only* blocks in the specified `REWORD:...END_MESSAGE` format. Do not include explanations, introductory text, or any other formatting. If no rewording is suggested, output nothing.
  213. Now, analyze the provided context and generate the reword suggestions with complete new messages.
  214. """
  215. return prompt
  216. def parse_reword_suggestions(ai_response_text, commits_data):
  217. """Parses AI response for REWORD:/NEW_MESSAGE:/END_MESSAGE blocks."""
  218. reword_plan = {} # Use dict: {short_hash: new_message_string}
  219. commit_hashes = {c["short_hash"] for c in commits_data} # Set of valid short hashes
  220. # Regex to find blocks
  221. pattern = re.compile(
  222. r"REWORD:\s*(\w+)\s*NEW_MESSAGE:\s*(.*?)\s*END_MESSAGE",
  223. re.DOTALL | re.IGNORECASE,
  224. )
  225. matches = pattern.findall(ai_response_text)
  226. for match in matches:
  227. reword_hash = match[0].strip()
  228. new_message = match[1].strip() # Includes Subject: and body
  229. if reword_hash in commit_hashes:
  230. reword_plan[reword_hash] = new_message
  231. logging.debug(
  232. f"Parsed reword suggestion for {reword_hash}:\n{new_message[:100]}..."
  233. )
  234. else:
  235. logging.warning(
  236. f"Ignoring invalid reword suggestion (hash {reword_hash} not in range)."
  237. )
  238. return reword_plan
  239. # --- Automatic Rebase Logic ---
  240. def create_rebase_sequence_editor_script(script_path, reword_plan):
  241. """Creates the python script for GIT_SEQUENCE_EDITOR (changes pick to reword)."""
  242. hashes_to_reword = set(reword_plan.keys())
  243. script_content = f"""#!/usr/bin/env python3
  244. import sys
  245. import logging
  246. import re
  247. import os
  248. logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
  249. todo_file_path = sys.argv[1]
  250. logging.info(f"GIT_SEQUENCE_EDITOR script started for: {{todo_file_path}}")
  251. hashes_to_reword = {hashes_to_reword!r}
  252. logging.info(f"Applying rewording for hashes: {{hashes_to_reword}}")
  253. new_lines = []
  254. try:
  255. with open(todo_file_path, 'r', encoding='utf-8') as f:
  256. lines = f.readlines()
  257. for line in lines:
  258. stripped_line = line.strip()
  259. if not stripped_line or stripped_line.startswith('#'):
  260. new_lines.append(line)
  261. continue
  262. match = re.match(r"^(\w+)\s+([0-9a-fA-F]+)(.*)", stripped_line)
  263. if match:
  264. action = match.group(1).lower()
  265. commit_hash = match.group(2)
  266. rest_of_line = match.group(3)
  267. if commit_hash in hashes_to_reword and action == 'pick':
  268. logging.info(f"Changing 'pick {{commit_hash}}' to 'reword {{commit_hash}}'")
  269. new_line = f'r {{commit_hash}}{{rest_of_line}}\\n' # Use 'r' for reword
  270. new_lines.append(new_line)
  271. else:
  272. new_lines.append(line)
  273. else:
  274. logging.warning(f"Could not parse todo line: {{stripped_line}}")
  275. new_lines.append(line)
  276. logging.info(f"Writing {{len(new_lines)}} lines back to {{todo_file_path}}")
  277. with open(todo_file_path, 'w', encoding='utf-8') as f:
  278. f.writelines(new_lines)
  279. logging.info("GIT_SEQUENCE_EDITOR script finished successfully.")
  280. sys.exit(0)
  281. except Exception as e:
  282. logging.error(f"Error in GIT_SEQUENCE_EDITOR script: {{e}}", exc_info=True)
  283. sys.exit(1)
  284. """
  285. try:
  286. with open(script_path, "w", encoding="utf-8") as f:
  287. f.write(script_content)
  288. os.chmod(script_path, 0o755)
  289. logging.info(f"Created GIT_SEQUENCE_EDITOR script: {script_path}")
  290. return True
  291. except Exception as e:
  292. logging.error(f"Failed to create GIT_SEQUENCE_EDITOR script: {e}")
  293. return False
  294. def create_rebase_commit_editor_script(script_path):
  295. """Creates the python script for GIT_EDITOR (provides new commit message)."""
  296. # Note: reword_plan_json is a JSON string containing the {hash: new_message} mapping
  297. script_content = f"""#!/usr/bin/env python3
  298. import sys
  299. import logging
  300. import re
  301. import os
  302. import subprocess
  303. import json
  304. logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
  305. commit_msg_file_path = sys.argv[1]
  306. logging.info(f"GIT_EDITOR script started for commit message file: {{commit_msg_file_path}}")
  307. # The reword plan (hash -> new_message) is passed via environment variable as JSON
  308. reword_plan_json = os.environ.get('GIT_REWORD_PLAN')
  309. if not reword_plan_json:
  310. logging.error("GIT_REWORD_PLAN environment variable not set.")
  311. sys.exit(1)
  312. try:
  313. reword_plan = json.loads(reword_plan_json)
  314. logging.info(f"Loaded reword plan for {{len(reword_plan)}} commits.")
  315. except json.JSONDecodeError as e:
  316. logging.error(f"Failed to decode GIT_REWORD_PLAN JSON: {{e}}")
  317. sys.exit(1)
  318. # --- How to identify the current commit being reworded? ---
  319. # This is the tricky part. Git doesn't directly tell the editor which commit
  320. # it's editing during a reword.
  321. # Approach 1: Read the *original* message from the file Git provides.
  322. # Extract the original hash (if possible, maybe from a trailer?). Unreliable.
  323. # Approach 2: Rely on the *order*. Requires knowing the rebase todo list order. Fragile.
  324. # Approach 3: Use `git rev-parse HEAD`? Might work if HEAD points to the commit being edited. Needs testing.
  325. # Approach 4: Pass the *current* target hash via another env var set by the main script
  326. # before calling rebase? Seems overly complex.
  327. # --- Let's try Approach 3 (Check HEAD) ---
  328. try:
  329. # Use subprocess to run git command to get the full hash of HEAD
  330. result = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True, check=True, encoding='utf-8')
  331. current_full_hash = result.stdout.strip()
  332. logging.info(f"Current HEAD full hash: {{current_full_hash}}")
  333. # Find the corresponding short hash in our plan (keys are short hashes)
  334. current_short_hash = None
  335. for short_h in reword_plan.keys():
  336. # Use git rev-parse to check if short_h resolves to current_full_hash
  337. # This handles potential ambiguity if multiple commits have the same short hash prefix
  338. try:
  339. # Verify that the short_h from the plan resolves to a commit object
  340. # and get its full hash. Simply pass the short hash to verify.
  341. logging.info(f"Verifying short hash {{short_h}} against HEAD {{current_full_hash}}...")
  342. verify_result = subprocess.run(['git', 'rev-parse', '--verify', short_h], capture_output=True, text=True, check=True, encoding='utf-8')
  343. verified_full_hash = verify_result.stdout.strip()
  344. if verified_full_hash == current_full_hash:
  345. current_short_hash = short_h
  346. logging.info(f"Matched HEAD {{current_full_hash}} to short hash {{current_short_hash}} in plan.")
  347. break
  348. except subprocess.CalledProcessError:
  349. logging.debug(f"Short hash {{short_h}} does not resolve to HEAD.")
  350. continue # Try next short hash in plan
  351. if current_short_hash is None:
  352. sys.exit(0) # Exit successfully to avoid blocking rebase, but log warning
  353. elif current_short_hash and current_short_hash in reword_plan:
  354. new_message = reword_plan[current_short_hash]
  355. logging.info(f"Found new message for commit {{current_short_hash}}.")
  356. # Remove the "Subject: " prefix as Git adds that structure
  357. new_message_content = re.sub(r"^[Ss]ubject:\s*", "", new_message, count=1)
  358. logging.info(f"Writing new message to {{commit_msg_file_path}}: {{new_message_content[:100]}}...")
  359. with open(commit_msg_file_path, 'w', encoding='utf-8') as f:
  360. f.write(new_message_content)
  361. logging.info("GIT_EDITOR script finished successfully for reword.")
  362. sys.exit(0)
  363. else:
  364. logging.warning(f"Could not find a matching commit hash in the reword plan for current HEAD {{current_full_hash}} (Short hash: {{current_short_hash}}).")
  365. # Keep the original message provided by Git? Or fail? Let's keep original for safety.
  366. logging.warning("Keeping the original commit message.")
  367. sys.exit(0) # Exit successfully to avoid blocking rebase, but log warning
  368. except subprocess.CalledProcessError as e:
  369. logging.error(f"Failed to run git rev-parse HEAD: {{e}}")
  370. sys.exit(1) # Fail editor script
  371. except Exception as e:
  372. logging.error(f"Error in GIT_EDITOR script: {{e}}", exc_info=True)
  373. sys.exit(1) # Exit with error code
  374. """
  375. try:
  376. with open(script_path, "w", encoding="utf-8") as f:
  377. f.write(script_content)
  378. os.chmod(script_path, 0o755)
  379. logging.info(f"Created GIT_EDITOR script: {script_path}")
  380. return True
  381. except Exception as e:
  382. logging.error(f"Failed to create GIT_EDITOR script: {e}")
  383. return False
  384. def attempt_auto_reword(merge_base, reword_plan):
  385. """Attempts to perform the rebase automatically applying rewording."""
  386. if not reword_plan:
  387. logging.info("No reword suggestions provided by AI. Skipping auto-rebase.")
  388. return True
  389. temp_dir = tempfile.mkdtemp(prefix="git_reword_")
  390. seq_editor_script_path = os.path.join(temp_dir, "rebase_sequence_editor.py")
  391. commit_editor_script_path = os.path.join(temp_dir, "rebase_commit_editor.py")
  392. logging.debug(f"Temporary directory: {temp_dir}")
  393. try:
  394. # Create the sequence editor script (changes pick -> reword)
  395. if not create_rebase_sequence_editor_script(
  396. seq_editor_script_path, reword_plan
  397. ):
  398. return False
  399. # Create the commit editor script (provides new message)
  400. # Pass the reword plan as a JSON string via environment variable
  401. reword_plan_json = json.dumps(reword_plan)
  402. if not create_rebase_commit_editor_script(commit_editor_script_path):
  403. return False
  404. # Prepare environment for the git command
  405. rebase_env = os.environ.copy()
  406. rebase_env["GIT_SEQUENCE_EDITOR"] = seq_editor_script_path
  407. rebase_env["GIT_EDITOR"] = commit_editor_script_path
  408. # Pass the plan to the commit editor script via env var
  409. rebase_env["GIT_REWORD_PLAN"] = reword_plan_json
  410. logging.debug(f"GIT_REWORD_PLAN: {reword_plan_json}")
  411. print("\nAttempting automatic rebase with suggested rewording...")
  412. logging.info(f"Running: git rebase -i {merge_base}")
  413. rebase_result = run_git_command(
  414. ["rebase", "-i", merge_base],
  415. check=False,
  416. capture_output=False,
  417. env=rebase_env,
  418. )
  419. if rebase_result is not None:
  420. print("✅ Automatic reword rebase completed successfully.")
  421. logging.info("Automatic reword rebase seems successful.")
  422. return True
  423. else:
  424. print("\n❌ Automatic reword rebase failed.")
  425. print(
  426. " This could be due to merge conflicts, script errors, or other rebase issues."
  427. )
  428. logging.warning("Automatic reword rebase failed. Aborting...")
  429. print(" Attempting to abort the failed rebase (`git rebase --abort`)...")
  430. abort_result = run_git_command(
  431. ["rebase", "--abort"], check=False, capture_output=False
  432. )
  433. if abort_result is not None:
  434. print(
  435. " Rebase aborted successfully. Your branch is back to its original state."
  436. )
  437. logging.info("Failed rebase aborted successfully.")
  438. else:
  439. print(" ⚠️ Failed to automatically abort the rebase.")
  440. print(" Please run `git rebase --abort` manually to clean up.")
  441. logging.error("Failed to automatically abort the rebase.")
  442. return False
  443. except Exception as e:
  444. logging.error(
  445. f"An unexpected error occurred during auto-reword attempt: {e}",
  446. exc_info=True,
  447. )
  448. print("\n❌ An unexpected error occurred during the automatic reword attempt.")
  449. print(
  450. " You may need to manually check your Git status and potentially run `git rebase --abort`."
  451. )
  452. return False
  453. finally:
  454. # Clean up the temporary directory
  455. if temp_dir and os.path.exists(temp_dir):
  456. try:
  457. import shutil
  458. shutil.rmtree(temp_dir)
  459. logging.debug(f"Cleaned up temporary directory: {temp_dir}")
  460. except OSError as e:
  461. logging.warning(f"Could not remove temporary directory {temp_dir}: {e}")
  462. # --- Main Execution ---
  463. def main():
  464. """Main function to orchestrate Git analysis and AI interaction."""
  465. parser = argparse.ArgumentParser(
  466. description="Uses Gemini AI to suggest and automatically attempt Git 'reword' operations.",
  467. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  468. )
  469. parser.add_argument(
  470. "upstream_ref",
  471. nargs="?",
  472. default="upstream/main",
  473. help="The upstream reference point or commit hash to compare against.",
  474. )
  475. parser.add_argument(
  476. "--instruct",
  477. action="store_true",
  478. help="Only show AI suggestions and instructions; disable automatic reword attempt.",
  479. )
  480. parser.add_argument(
  481. "-v", "--verbose", action="store_true", help="Enable verbose debug logging."
  482. )
  483. args = parser.parse_args()
  484. if args.verbose:
  485. logging.getLogger().setLevel(logging.DEBUG)
  486. logging.debug("Verbose logging enabled.")
  487. if not check_git_repository():
  488. logging.error("This script must be run from within a Git repository.")
  489. sys.exit(1)
  490. current_branch = get_current_branch()
  491. if not current_branch:
  492. logging.error("Could not determine the current Git branch.")
  493. sys.exit(1)
  494. logging.info(f"Current branch: {current_branch}")
  495. upstream_ref = args.upstream_ref
  496. logging.info(f"Comparing against reference: {upstream_ref}")
  497. # --- Safety: Create Backup Branch ---
  498. backup_branch = create_backup_branch(current_branch)
  499. if not backup_branch:
  500. try:
  501. confirm = input(
  502. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  503. ).lower()
  504. except EOFError:
  505. confirm = "no"
  506. if confirm != "yes":
  507. logging.info("Aborting.")
  508. sys.exit(1)
  509. else:
  510. logging.warning("Proceeding without a backup branch. Be careful!")
  511. else:
  512. print("-" * 40)
  513. print(f"✅ Backup branch created: {backup_branch}")
  514. print(
  515. f" Restore with: git checkout {current_branch} && git reset --hard {backup_branch}"
  516. )
  517. print("-" * 40)
  518. # --- Gather Git Context ---
  519. print("\nGathering Git context...")
  520. commit_range, merge_base = get_commit_range(upstream_ref, current_branch)
  521. if not commit_range:
  522. sys.exit(1)
  523. logging.info(f"Analyzing commit range: {commit_range} (Merge Base: {merge_base})")
  524. commits_data = get_commits_in_range(commit_range)
  525. if not commits_data:
  526. logging.info(
  527. f"No commits found between '{merge_base}' and '{current_branch}'. Nothing to do."
  528. )
  529. sys.exit(0)
  530. diff = get_diff_in_range(commit_range) # Diff might help AI judge messages
  531. # --- Interact with AI ---
  532. print("\nGenerating prompt for AI reword suggestions...")
  533. initial_prompt = generate_reword_suggestion_prompt(
  534. commit_range, merge_base, commits_data, diff
  535. )
  536. logging.debug("\n--- Initial AI Prompt Snippet ---")
  537. logging.debug(initial_prompt[:1000] + "...")
  538. logging.debug("--- End Prompt Snippet ---\n")
  539. print(f"Sending request to Gemini AI ({MODEL_NAME})...")
  540. ai_response_text = ""
  541. reword_suggestions_text = "" # Store raw AI suggestions
  542. try:
  543. # For reword, file content is less likely needed, but keep structure just in case
  544. convo = model.start_chat(history=[])
  545. response = convo.send_message(initial_prompt)
  546. ai_response_text = response.text
  547. # Store the final AI response containing suggestions
  548. reword_suggestions_text = ai_response_text.strip()
  549. # Parse the suggestions
  550. reword_plan = parse_reword_suggestions(reword_suggestions_text, commits_data)
  551. if not reword_plan:
  552. print("\n💡 AI did not suggest any specific reword operations.")
  553. else:
  554. print("\n💡 --- AI Reword Suggestions --- 💡")
  555. for i, (hash_key, msg) in enumerate(reword_plan.items()):
  556. print(f" {i + 1}. Reword commit `{hash_key}` with new message:")
  557. # Indent the message for readability
  558. indented_msg = " " + msg.replace("\n", "\n ")
  559. print(indented_msg)
  560. print("-" * 20) # Separator
  561. print("💡 --- End AI Suggestions --- 💡")
  562. # --- Attempt Automatic Rebase or Show Instructions ---
  563. if not args.instruct: # Default behavior: attempt auto-reword
  564. if reword_plan:
  565. success = attempt_auto_reword(merge_base, reword_plan)
  566. if not success:
  567. # Failure message already printed by attempt_auto_reword
  568. print("\n" + "=" * 60)
  569. print("🛠️ MANUAL REBASE REQUIRED 🛠️")
  570. print("=" * 60)
  571. print("The automatic reword rebase failed.")
  572. print("Please perform the rebase manually:")
  573. print(f" 1. Run: `git rebase -i {merge_base}`")
  574. print(
  575. " 2. In the editor, change 'pick' to 'r' (or 'reword') for the commits"
  576. )
  577. print(" suggested by the AI above.")
  578. print(
  579. " 3. Save the editor. Git will stop at each commit marked for reword."
  580. )
  581. print(
  582. " 4. Manually replace the old commit message with the AI-suggested one:"
  583. )
  584. print(" ```text")
  585. # Print raw suggestions which might be easier to copy/paste
  586. print(
  587. reword_suggestions_text
  588. if reword_suggestions_text
  589. else " (No specific reword suggestions found in AI response)"
  590. )
  591. print(" ```")
  592. print(
  593. " 5. Save the message editor and continue the rebase (`git rebase --continue`)."
  594. )
  595. if backup_branch:
  596. print(f" 6. Remember backup branch: {backup_branch}")
  597. print("=" * 60)
  598. sys.exit(1) # Exit with error status after failure
  599. else:
  600. # Auto reword succeeded
  601. print("\nBranch history has been modified by automatic rewording.")
  602. if backup_branch:
  603. print(
  604. f"Backup branch '{backup_branch}' still exists if needed."
  605. )
  606. else:
  607. print("\nNo automatic rebase attempted as AI suggested no rewording.")
  608. elif reword_plan: # --instruct flag was used AND suggestions exist
  609. print("\n" + "=" * 60)
  610. print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
  611. print("=" * 60)
  612. print("AI suggested the rewording listed above.")
  613. print("To apply them manually:")
  614. print(f" 1. Run: `git rebase -i {merge_base}`")
  615. print(
  616. " 2. Edit the 'pick' lines in the editor, changing 'pick' to 'r' (or 'reword')"
  617. )
  618. print(" for the commits listed above.")
  619. print(
  620. " 3. Save the editor. Git will stop at each commit marked for reword."
  621. )
  622. print(
  623. " 4. Manually replace the old commit message with the corresponding AI-suggested message."
  624. )
  625. print(
  626. " 5. Save the message editor and continue the rebase (`git rebase --continue`)."
  627. )
  628. if backup_branch:
  629. print(f" 6. Remember backup branch: {backup_branch}")
  630. print("=" * 60)
  631. except Exception as e:
  632. logging.error(f"\nAn unexpected error occurred: {e}", exc_info=True)
  633. try: # Log AI feedback if possible
  634. if response and hasattr(response, "prompt_feedback"):
  635. logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  636. # ... (rest of feedback logging) ...
  637. except Exception as feedback_e:
  638. logging.error(
  639. f"Could not retrieve detailed feedback from AI response: {feedback_e}"
  640. )
  641. print("\n❌ An unexpected error occurred during the process.")
  642. print(" Please check the logs and your Git status.")
  643. print(" You may need to run `git rebase --abort` manually.")
  644. if __name__ == "__main__":
  645. main()