git_rebase_ai.py 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617
  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
  11. import fnmatch
  12. # --- Configuration ---
  13. # Configure logging
  14. logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
  15. # Attempt to get API key from environment variable
  16. API_KEY = os.getenv("GEMINI_API_KEY")
  17. if not API_KEY:
  18. logging.error("GEMINI_API_KEY environment variable not set.")
  19. logging.error(
  20. "Please obtain an API key from Google AI Studio (https://aistudio.google.com/app/apikey)"
  21. )
  22. logging.error("and set it as an environment variable:")
  23. logging.error(" export GEMINI_API_KEY='YOUR_API_KEY' (Linux/macOS)")
  24. logging.error(" set GEMINI_API_KEY=YOUR_API_KEY (Windows CMD)")
  25. logging.error(" $env:GEMINI_API_KEY='YOUR_API_KEY' (Windows PowerShell)")
  26. sys.exit(1)
  27. # Configure the Gemini AI Client
  28. try:
  29. genai.configure(api_key=API_KEY)
  30. # Use a model suitable for complex reasoning like code analysis.
  31. # Adjust model name if needed (e.g., 'gemini-1.5-flash-latest').
  32. MODEL_NAME = os.getenv("GEMINI_MODEL")
  33. if not MODEL_NAME:
  34. logging.error("GEMINI_MODEL environment variable not set.")
  35. logging.error(
  36. "Please set the desired Gemini model name (e.g., 'gemini-1.5-flash-latest')."
  37. )
  38. logging.error(" export GEMINI_MODEL='gemini-1.5-flash-latest' (Linux/macOS)")
  39. logging.error(" set GEMINI_MODEL=gemini-1.5-flash-latest (Windows CMD)")
  40. logging.error(
  41. " $env:GEMINI_MODEL='gemini-1.5-flash-latest' (Windows PowerShell)"
  42. )
  43. sys.exit(1)
  44. model = genai.GenerativeModel(MODEL_NAME)
  45. logging.info(f"Using Gemini model: {MODEL_NAME}")
  46. except Exception as e:
  47. logging.error(f"Error configuring Gemini AI: {e}")
  48. sys.exit(1)
  49. # --- Git Helper Functions ---
  50. def run_git_command(command_list, check=True, capture_output=True, env=None):
  51. """
  52. Runs a Git command as a list of arguments and returns its stdout.
  53. Handles errors and returns None on failure if check=True.
  54. Allows passing environment variables.
  55. """
  56. full_command = []
  57. try:
  58. full_command = ["git"] + command_list
  59. logging.info(f"Running command: {' '.join(full_command)}")
  60. cmd_env = os.environ.copy()
  61. if env:
  62. cmd_env.update(env)
  63. result = subprocess.run(
  64. full_command,
  65. check=check,
  66. capture_output=capture_output,
  67. text=True,
  68. encoding="utf-8",
  69. errors="replace",
  70. env=cmd_env,
  71. )
  72. if result.stdout:
  73. logging.info(f"Command output:\n{result.stdout[:200]}...")
  74. return result.stdout.strip() if capture_output else ""
  75. return ""
  76. except subprocess.CalledProcessError as e:
  77. logging.error(f"Error executing Git command: {' '.join(full_command)}")
  78. stderr_safe = (
  79. e.stderr.strip().encode("utf-8", "replace").decode("utf-8")
  80. if e.stderr
  81. else ""
  82. )
  83. stdout_safe = (
  84. e.stdout.strip().encode("utf-8", "replace").decode("utf-8")
  85. if e.stdout
  86. else ""
  87. )
  88. logging.error(f"Exit Code: {e.returncode}")
  89. if stderr_safe:
  90. logging.error(f"Stderr: {stderr_safe}")
  91. if stdout_safe:
  92. logging.error(f"Stdout: {stdout_safe}")
  93. return None
  94. except FileNotFoundError:
  95. logging.error(
  96. "Error: 'git' command not found. Is Git installed and in your PATH?"
  97. )
  98. sys.exit(1)
  99. except Exception as e:
  100. logging.error(f"Running command: {' '.join(full_command)}")
  101. logging.error(f"An unexpected error occurred running git: {e}")
  102. return None
  103. def check_git_repository():
  104. """Checks if the current directory is the root of a Git repository."""
  105. output = run_git_command(["rev-parse", "--is-inside-work-tree"])
  106. return output == "true"
  107. def get_current_branch():
  108. """Gets the current active Git branch name."""
  109. return run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
  110. def create_backup_branch(branch_name):
  111. """Creates a timestamped backup branch from the given branch name."""
  112. timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
  113. backup_branch_name = f"{branch_name}-backup-{timestamp}"
  114. logging.info(
  115. f"Attempting to create backup branch: {backup_branch_name} from {branch_name}"
  116. )
  117. output = run_git_command(["branch", backup_branch_name, branch_name])
  118. if output is not None:
  119. logging.info(f"Successfully created backup branch: {backup_branch_name}")
  120. return backup_branch_name
  121. else:
  122. logging.error("Failed to create backup branch.")
  123. return None
  124. def get_commit_range(upstream_ref, current_branch):
  125. """
  126. Determines the commit range (merge_base..current_branch).
  127. Returns the range string and the merge base hash.
  128. """
  129. logging.info(
  130. f"Finding merge base between '{upstream_ref}' and '{current_branch}'..."
  131. )
  132. merge_base = run_git_command(["merge-base", upstream_ref, current_branch])
  133. if not merge_base:
  134. logging.error(
  135. f"Could not find merge base between '{upstream_ref}' and '{current_branch}'."
  136. )
  137. logging.error(
  138. f"Ensure '{upstream_ref}' is a valid reference (branch, commit, tag)"
  139. )
  140. logging.error("and that it has been fetched (e.g., 'git fetch origin').")
  141. return None, None # Indicate failure
  142. logging.info(f"Found merge base: {merge_base}")
  143. commit_range = f"{merge_base}..{current_branch}"
  144. return commit_range, merge_base
  145. def get_commits_in_range(commit_range):
  146. """Gets a list of commit hashes and subjects in the specified range (oldest first)."""
  147. log_output = run_git_command(
  148. ["log", "--pretty=format:%h %s", "--reverse", commit_range]
  149. )
  150. if log_output is not None:
  151. commits = log_output.splitlines()
  152. logging.info(f"Found {len(commits)} commits in range {commit_range}.")
  153. return commits
  154. return [] # Return empty list on failure or no commits
  155. def get_project_file_structure(ignore_patterns):
  156. """
  157. Gets a list of all tracked files in the repository, filters them using
  158. ignore_patterns, and generates a simple directory structure string representation.
  159. """
  160. ls_files_output = run_git_command(["ls-files"])
  161. if ls_files_output is not None:
  162. all_files = ls_files_output.splitlines()
  163. # Filter files based on ignore_patterns
  164. filtered_files = [
  165. f for f in all_files if not is_path_ignored(f, ignore_patterns)
  166. ]
  167. logging.info(
  168. f"Found {len(all_files)} total files, {len(filtered_files)} after applying ignore patterns for project structure."
  169. )
  170. # Basic tree structure representation
  171. tree = {}
  172. for file_path in filtered_files:
  173. parts = file_path.replace("\\", "/").split("/")
  174. node = tree
  175. for i, part in enumerate(parts):
  176. if not part:
  177. continue
  178. if i == len(parts) - 1:
  179. node[part] = "file"
  180. else:
  181. if part not in node:
  182. node[part] = {}
  183. if isinstance(node[part], dict):
  184. node = node[part]
  185. else:
  186. logging.warning(
  187. f"Path conflict building file tree for: {file_path}"
  188. )
  189. break
  190. def format_tree(d, indent=0):
  191. lines = []
  192. for key, value in sorted(d.items()):
  193. prefix = " " * indent
  194. if isinstance(value, dict):
  195. lines.append(f"{prefix}📁 {key}/")
  196. lines.extend(format_tree(value, indent + 1))
  197. else:
  198. lines.append(f"{prefix}📄 {key}")
  199. return lines
  200. tree_str = "\n".join(format_tree(tree))
  201. return tree_str, filtered_files
  202. return "", []
  203. def get_diff_in_range(commit_range, ignore_patterns):
  204. """
  205. Gets the combined diffstat and patch for the specified range,
  206. respecting ignore patterns.
  207. """
  208. command = ["diff", "--patch-with-stat", commit_range]
  209. if ignore_patterns:
  210. command.append("--") # Separator for pathspecs
  211. for pattern in ignore_patterns:
  212. command.append(f":(exclude){pattern}")
  213. diff_output = run_git_command(command)
  214. if diff_output is not None:
  215. logging.info(
  216. f"Generated diff for range {commit_range} (length: {len(diff_output)} chars, respecting ignores)."
  217. )
  218. else:
  219. logging.warning(
  220. f"Could not generate diff for range {commit_range} (respecting ignores)."
  221. )
  222. return diff_output if diff_output is not None else ""
  223. def get_commits_data_in_range(commit_range):
  224. # Use --format=%H %s to get full hash for later matching if needed
  225. log_output = run_git_command(
  226. ["log", "--pretty=format:%h %H %s", "--reverse", commit_range]
  227. )
  228. if log_output is not None:
  229. commits = log_output.splitlines()
  230. # Store as list of dicts for easier access
  231. commit_data = []
  232. for line in commits:
  233. parts = line.split(" ", 2)
  234. if len(parts) == 3:
  235. commit_data.append(
  236. {"short_hash": parts[0], "full_hash": parts[1], "subject": parts[2]}
  237. )
  238. logging.info(f"Found {len(commit_data)} commits in range {commit_range}.")
  239. return commit_data
  240. return []
  241. def get_file_content_at_commit(commit_hash, file_path):
  242. """Gets the content of a specific file at a specific commit hash."""
  243. logging.info(f"Fetching content of '{file_path}' at commit {commit_hash[:7]}...")
  244. content = run_git_command(["show", f"{commit_hash}:{file_path}"])
  245. if content is None:
  246. logging.warning(
  247. f"Could not retrieve content for {file_path} at {commit_hash[:7]}."
  248. )
  249. return None
  250. return content
  251. # --- Ignore Pattern Helpers ---
  252. def is_path_ignored(path, ignore_patterns):
  253. """Checks if a path matches any of the gitignore-style patterns."""
  254. for pattern in ignore_patterns:
  255. if fnmatch.fnmatch(path, pattern):
  256. return True
  257. return False
  258. def load_ignore_patterns(ignore_file_path):
  259. """Loads patterns from a .gitignore-style file."""
  260. patterns = []
  261. if os.path.exists(ignore_file_path):
  262. try:
  263. with open(ignore_file_path, "r", encoding="utf-8") as f:
  264. for line in f:
  265. stripped_line = line.strip()
  266. if stripped_line and not stripped_line.startswith("#"):
  267. patterns.append(stripped_line)
  268. logging.info(f"Loaded {len(patterns)} patterns from {ignore_file_path}")
  269. except Exception as e:
  270. logging.warning(f"Could not read ignore file {ignore_file_path}: {e}")
  271. else:
  272. logging.info(f"Ignore file {ignore_file_path} not found. No patterns loaded.")
  273. return patterns
  274. # --- AI Interaction ---
  275. def generate_fixup_suggestion_prompt(
  276. commit_range, merge_base, commits, file_structure, diff
  277. ):
  278. """
  279. Creates a prompt asking the AI specifically to identify potential
  280. fixup candidates within the commit range.
  281. Returns suggestions in a parsable format.
  282. """
  283. commit_list_str = (
  284. "\n".join([f"- {c}" for c in commits]) if commits else "No commits in range."
  285. )
  286. prompt = f"""
  287. You are an expert Git assistant. Your task is to analyze the provided Git commit history and identify commits within the range `{commit_range}` that should be combined using `fixup` during an interactive rebase (`git rebase -i {merge_base}`).
  288. **Goal:** Identify commits that are minor corrections or direct continuations of the immediately preceding commit, where the commit message can be discarded.
  289. **Git Commit Message Conventions (for context):**
  290. * Subject: Imperative, < 50 chars, capitalized, no period. Use types like `feat:`, `fix:`, `refactor:`, etc.
  291. * Body: Explain 'what' and 'why', wrap at 72 chars.
  292. **Provided Context:**
  293. 1. **Commit Range:** `{commit_range}`
  294. 2. **Merge Base Hash:** `{merge_base}`
  295. 3. **Commits in Range (Oldest First - Short Hash & Subject):**
  296. ```
  297. {commit_list_str}
  298. ```
  299. 4. **Changed Files Structure in Range:**
  300. ```
  301. {file_structure if file_structure else "No files changed or unable to list."}
  302. ```
  303. 5. **Combined Diff for the Range (`git diff --patch-with-stat {commit_range}`):**
  304. ```diff
  305. {diff if diff else "No differences found or unable to get diff."}
  306. ```
  307. **Instructions:**
  308. 1. Analyze the commits, their messages, the changed files, and the diff.
  309. 2. Identify commits from the list that are strong candidates for being combined into their **immediately preceding commit** using `fixup` (combine changes, discard message). Focus on small fixes, typo corrections, or direct continuations where the commit message isn't valuable.
  310. 3. For each suggestion, output *only* a line in the following format:
  311. `FIXUP: <hash_to_fixup> INTO <preceding_hash>`
  312. Use the short commit hashes provided in the commit list.
  313. 4. Provide *only* lines in the `FIXUP:` format. Do not include explanations, introductory text, or any other formatting. If no fixups are suggested, output nothing.
  314. **Example Output:**
  315. ```text
  316. FIXUP: hash2 INTO hash1
  317. FIXUP: hash5 INTO hash4
  318. ```
  319. 5. **File Content Request:** If you absolutely need the content of specific files *at specific commits* to confidently determine if they should be fixed up, ask for them clearly ONCE. List the files using this exact format at the end of your response:
  320. `REQUEST_FILES: [commit_hash1:path/to/file1.py, commit_hash2:another/path/file2.js]`
  321. Use the short commit hashes provided in the commit list. Do *not* ask for files unless essential for *this specific task* of identifying fixup candidates.
  322. Now, analyze the provided context and generate *only* the `FIXUP:` lines or `REQUEST_FILES:` line.
  323. """
  324. return prompt
  325. def parse_fixup_suggestions(ai_response_text, commits_in_range):
  326. """Parses AI response for FIXUP: lines and validates hashes."""
  327. fixup_pairs = []
  328. commit_hashes = {
  329. c.split()[0] for c in commits_in_range
  330. } # Set of valid short hashes
  331. for line in ai_response_text.splitlines():
  332. line = line.strip()
  333. if line.startswith("FIXUP:"):
  334. match = re.match(r"FIXUP:\s*(\w+)\s+INTO\s+(\w+)", line, re.IGNORECASE)
  335. if match:
  336. fixup_hash = match.group(1)
  337. target_hash = match.group(2)
  338. # Validate that both hashes were in the original commit list
  339. if fixup_hash in commit_hashes and target_hash in commit_hashes:
  340. fixup_pairs.append({"fixup": fixup_hash, "target": target_hash})
  341. logging.debug(
  342. f"Parsed fixup suggestion: {fixup_hash} into {target_hash}"
  343. )
  344. else:
  345. logging.warning(
  346. f"Ignoring invalid fixup suggestion (hash not in range): {line}"
  347. )
  348. else:
  349. logging.warning(f"Could not parse FIXUP line: {line}")
  350. return fixup_pairs
  351. # --- request_files_from_user function remains the same ---
  352. def request_files_from_user(requested_files_str, commits_in_range):
  353. """
  354. Parses AI request string "REQUEST_FILES: [hash:path, ...]", verifies hashes,
  355. asks user permission, fetches file contents, and returns formatted context.
  356. """
  357. file_requests = []
  358. try:
  359. content_match = re.search(
  360. r"REQUEST_FILES:\s*\[(.*)\]", requested_files_str, re.IGNORECASE | re.DOTALL
  361. )
  362. if not content_match:
  363. logging.warning("Could not parse file request format from AI response.")
  364. return None, None
  365. items_str = content_match.group(1).strip()
  366. if not items_str:
  367. logging.info("AI requested files but the list was empty.")
  368. return None, None
  369. items = [item.strip() for item in items_str.split(",") if item.strip()]
  370. commit_hash_map = {c.split()[0]: c.split()[0] for c in commits_in_range}
  371. for item in items:
  372. if ":" not in item:
  373. logging.warning(
  374. f"Invalid format in requested file item (missing ':'): {item}"
  375. )
  376. continue
  377. commit_hash, file_path = item.split(":", 1)
  378. commit_hash = commit_hash.strip()
  379. file_path = file_path.strip()
  380. if commit_hash not in commit_hash_map:
  381. logging.warning(
  382. f"AI requested file for unknown/out-of-range commit hash '{commit_hash}'. Skipping."
  383. )
  384. continue
  385. file_requests.append({"hash": commit_hash, "path": file_path})
  386. except Exception as e:
  387. logging.error(f"Error parsing requested files string: {e}")
  388. return None, None
  389. if not file_requests:
  390. logging.info("No valid file requests found after parsing AI response.")
  391. return None, None
  392. print("\n----------------------------------------")
  393. print("❓ AI Request for File Content ❓")
  394. print("----------------------------------------")
  395. print("The AI needs the content of the following files at specific commits")
  396. print("to provide more accurate fixup suggestions:")
  397. files_to_fetch = []
  398. for i, req in enumerate(file_requests):
  399. print(f" {i + 1}. File: '{req['path']}' at commit {req['hash']}")
  400. files_to_fetch.append(req)
  401. if not files_to_fetch:
  402. print("\nNo valid files to fetch based on the request.")
  403. return None, None
  404. print("----------------------------------------")
  405. while True:
  406. try:
  407. answer = (
  408. input("Allow fetching these file contents? (yes/no): ").lower().strip()
  409. )
  410. except EOFError:
  411. logging.warning("Input stream closed. Assuming 'no'.")
  412. answer = "no"
  413. if answer == "yes":
  414. logging.info("User approved fetching file content.")
  415. fetched_content_list = []
  416. for req in files_to_fetch:
  417. content = get_file_content_at_commit(req["hash"], req["path"])
  418. if content is not None:
  419. fetched_content_list.append(
  420. f"--- Content of '{req['path']}' at commit {req['hash']} ---\n"
  421. f"```\n{content}\n```\n"
  422. f"--- End Content for {req['path']} at {req['hash']} ---"
  423. )
  424. else:
  425. fetched_content_list.append(
  426. f"--- Could not fetch content of '{req['path']}' at commit {req['hash']} ---"
  427. )
  428. return "\n\n".join(fetched_content_list), requested_files_str
  429. elif answer == "no":
  430. logging.info("User denied fetching file content.")
  431. return None, requested_files_str
  432. else:
  433. print("Please answer 'yes' or 'no'.")
  434. # --- Automatic Rebase Logic ---
  435. def create_rebase_editor_script(script_path, fixup_plan):
  436. """Creates the python script to be used by GIT_SEQUENCE_EDITOR."""
  437. # Create a set of hashes that need to be fixed up
  438. fixups_to_apply = {pair["fixup"] for pair in fixup_plan}
  439. script_content = f"""#!/usr/bin/env python3
  440. import sys
  441. import logging
  442. import re
  443. import os
  444. # Define log file path relative to the script itself
  445. log_file = __file__ + ".log"
  446. # Setup logging within the editor script to write to the log file
  447. logging.basicConfig(filename=log_file, filemode='w', level=logging.WARN, format="%(asctime)s - %(levelname)s: %(message)s")
  448. todo_file_path = sys.argv[1]
  449. logging.info(f"GIT_SEQUENCE_EDITOR script started for: {{todo_file_path}}")
  450. # Hashes that should be changed to 'fixup'
  451. fixups_to_apply = {fixups_to_apply!r}
  452. logging.info(f"Applying fixups for hashes: {{fixups_to_apply}}")
  453. new_lines = []
  454. try:
  455. with open(todo_file_path, 'r', encoding='utf-8') as f:
  456. lines = f.readlines()
  457. for line in lines:
  458. stripped_line = line.strip()
  459. # Skip comments and blank lines
  460. if not stripped_line or stripped_line.startswith('#'):
  461. new_lines.append(line)
  462. continue
  463. # Use regex for more robust parsing of todo lines (action hash ...)
  464. match = re.match(r"^(\w+)\s+([0-9a-fA-F]+)(.*)", stripped_line)
  465. if match:
  466. action = match.group(1).lower()
  467. commit_hash = match.group(2)
  468. rest_of_line = match.group(3)
  469. # Check if this commit should be fixed up
  470. if commit_hash in fixups_to_apply and action == 'pick':
  471. logging.info(f"Changing 'pick {{commit_hash}}' to 'fixup {{commit_hash}}'")
  472. # Replace 'pick' with 'fixup', preserving the rest of the line
  473. new_line = f'f {{commit_hash}}{{rest_of_line}}\\n'
  474. new_lines.append(new_line)
  475. else:
  476. # Keep the original line
  477. new_lines.append(line)
  478. else:
  479. # Keep lines that don't look like standard todo lines
  480. logging.warning(f"Could not parse todo line: {{stripped_line}}")
  481. new_lines.append(line)
  482. logging.info(f"Writing {{len(new_lines)}} lines back to {{todo_file_path}}")
  483. with open(todo_file_path, 'w', encoding='utf-8') as f:
  484. f.writelines(new_lines)
  485. logging.info("GIT_SEQUENCE_EDITOR script finished successfully.")
  486. sys.exit(0) # Explicitly exit successfully
  487. except Exception as e:
  488. logging.error(f"Error in GIT_SEQUENCE_EDITOR script: {{e}}", exc_info=True)
  489. sys.exit(1) # Exit with error code
  490. """
  491. try:
  492. with open(script_path, "w", encoding="utf-8") as f:
  493. f.write(script_content)
  494. # Make the script executable (important on Linux/macOS)
  495. os.chmod(script_path, 0o755)
  496. logging.info(f"Created GIT_SEQUENCE_EDITOR script: {script_path}")
  497. return True
  498. except Exception as e:
  499. logging.error(f"Failed to create GIT_SEQUENCE_EDITOR script: {e}")
  500. return False
  501. def attempt_auto_fixup(merge_base, fixup_plan):
  502. """Attempts to perform the rebase automatically applying fixups."""
  503. if not fixup_plan:
  504. logging.info("No fixup suggestions provided by AI. Skipping auto-rebase.")
  505. return True # Nothing to do, considered success
  506. # Use a temporary directory to hold the script and its log
  507. temp_dir = tempfile.mkdtemp(prefix="git_rebase_")
  508. editor_script_path = os.path.join(temp_dir, "rebase_editor.py")
  509. logging.debug(f"Temporary directory: {temp_dir}")
  510. logging.debug(f"Temporary editor script path: {editor_script_path}")
  511. try:
  512. if not create_rebase_editor_script(editor_script_path, fixup_plan):
  513. return False # Failed to create script
  514. # Prepare environment for the git command
  515. rebase_env = os.environ.copy()
  516. rebase_env["GIT_SEQUENCE_EDITOR"] = editor_script_path
  517. # Prevent Git from opening a standard editor for messages etc.
  518. # 'true' simply exits successfully, accepting default messages
  519. rebase_env["GIT_EDITOR"] = "true"
  520. print("\nAttempting automatic rebase with suggested fixups...")
  521. logging.info(f"Running: git rebase -i {merge_base}")
  522. # Run rebase non-interactively, check=False to handle failures manually
  523. rebase_result = run_git_command(
  524. ["rebase", "-i", merge_base],
  525. check=False, # Don't raise exception on failure, check exit code
  526. capture_output=True, # Capture output to see potential errors
  527. env=rebase_env,
  528. )
  529. # Check the result (run_git_command returns None on CalledProcessError)
  530. if rebase_result is not None:
  531. # Command finished, exit code was likely 0 (success)
  532. print("✅ Automatic fixup rebase completed successfully.")
  533. logging.info("Automatic fixup rebase seems successful.")
  534. return True
  535. else:
  536. # Command failed (non-zero exit code, run_git_command returned None)
  537. print("\n❌ Automatic fixup rebase failed.")
  538. print(
  539. " This likely means merge conflicts occurred or another rebase error happened."
  540. )
  541. logging.warning("Automatic fixup rebase failed. Aborting...")
  542. # Attempt to abort the failed rebase
  543. print(" Attempting to abort the failed rebase (`git rebase --abort`)...")
  544. # Run abort without capturing output, just check success/failure
  545. abort_result = run_git_command(
  546. ["rebase", "--abort"], check=False, capture_output=False
  547. )
  548. # run_git_command returns None on failure (CalledProcessError)
  549. if abort_result is not None:
  550. print(
  551. " Rebase aborted successfully. Your branch is back to its original state."
  552. )
  553. logging.info("Failed rebase aborted successfully.")
  554. else:
  555. print(" ⚠️ Failed to automatically abort the rebase.")
  556. print(" Please run `git rebase --abort` manually to clean up.")
  557. logging.error("Failed to automatically abort the rebase.")
  558. return False
  559. except Exception as e:
  560. logging.error(
  561. f"An unexpected error occurred during auto-fixup attempt: {e}",
  562. exc_info=True,
  563. )
  564. # Might need manual cleanup here too
  565. print("\n❌ An unexpected error occurred during the automatic fixup attempt.")
  566. print(
  567. " You may need to manually check your Git status and potentially run `git rebase --abort`."
  568. )
  569. return False
  570. finally:
  571. # Determine if rebase failed *before* potential cleanup errors
  572. # Note: rebase_result is defined in the outer scope of the try block
  573. rebase_failed = "rebase_result" in locals() and rebase_result is None
  574. # Check if we need to display the editor script log
  575. editor_log_path = editor_script_path + ".log"
  576. verbose_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
  577. if (rebase_failed or verbose_logging) and os.path.exists(editor_log_path):
  578. try:
  579. with open(editor_log_path, "r", encoding="utf-8") as log_f:
  580. log_content = log_f.read()
  581. if log_content:
  582. print("\n--- Rebase Editor Script Log ---")
  583. print(log_content.strip())
  584. print("--- End Log ---")
  585. else:
  586. # Only log if verbose, otherwise it's just noise
  587. if verbose_logging:
  588. logging.debug(
  589. f"Rebase editor script log file was empty: {editor_log_path}"
  590. )
  591. except Exception as log_e:
  592. logging.warning(
  593. f"Could not read rebase editor script log file {editor_log_path}: {log_e}"
  594. )
  595. # Clean up the temporary directory and its contents
  596. if temp_dir and os.path.exists(temp_dir):
  597. try:
  598. if os.path.exists(editor_log_path):
  599. os.remove(editor_log_path)
  600. if os.path.exists(editor_script_path):
  601. os.remove(editor_script_path)
  602. os.rmdir(temp_dir)
  603. logging.debug(f"Cleaned up temporary directory: {temp_dir}")
  604. except OSError as e:
  605. logging.warning(
  606. f"Could not completely remove temporary directory {temp_dir}: {e}"
  607. )
  608. # --- AI Interaction ---
  609. def generate_reword_suggestion_prompt(commit_range, merge_base, commits_data, diff):
  610. """
  611. Creates a prompt asking the AI to identify commits needing rewording
  612. and to generate the full new commit message for each.
  613. """
  614. # Format commit list for the prompt using only short hash and subject
  615. commit_list_str = (
  616. "\n".join([f"- {c['short_hash']} {c['subject']}" for c in commits_data])
  617. if commits_data
  618. else "No commits in range."
  619. )
  620. prompt = f"""
  621. 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}`).
  622. **Goal:** For each commit needing improvement, generate a **complete, new commit message** (subject and body) that adheres strictly to standard Git conventions.
  623. **Git Commit Message Conventions to Adhere To:**
  624. 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`
  625. 2. **Blank Line:** Single blank line between subject and body.
  626. 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:
  627. ```
  628. refactor: Improve database query performance
  629. The previous implementation used multiple sequential queries
  630. to fetch related data, leading to N+1 problems under load.
  631. This change refactors the data access layer to use a single
  632. JOIN query, significantly reducing database roundtrips and
  633. improving response time for the user profile page.
  634. ```
  635. **Provided Context:**
  636. 1. **Commit Range:** `{commit_range}`
  637. 2. **Merge Base Hash:** `{merge_base}`
  638. 3. **Commits in Range (Oldest First - Short Hash & Subject):**
  639. ```
  640. {commit_list_str}
  641. ```
  642. 4. **Combined Diff for the Range (respecting .rebase-ignore, `git diff --patch-with-stat {commit_range}`):**
  643. ```diff
  644. {diff if diff else "No differences found or unable to get diff (respecting ignores)."}
  645. ```
  646. **Instructions:**
  647. 1. Analyze the commits listed above, focusing on their subjects and likely content based on the diff.
  648. 2. Identify commits whose messages are unclear, too long, lack a type prefix, are poorly formatted, or don't adequately explain the change.
  649. 3. For **each** commit you identify for rewording, output a block EXACTLY in the following format:
  650. ```text
  651. REWORD: <short_hash_to_reword>
  652. NEW_MESSAGE:
  653. <Generated Subject Line Adhering to Conventions>
  654. <Generated Body Line 1 Adhering to Conventions>
  655. <Generated Body Line 2 Adhering to Conventions>
  656. ...
  657. <Generated Body Last Line Adhering to Conventions>
  658. END_MESSAGE
  659. ```
  660. * Replace `<short_hash_to_reword>` with the short hash from the commit list.
  661. * Replace `<Generated Subject Line...>` with the new subject line you generate.
  662. * 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.
  663. * The `END_MESSAGE` line marks the end of the message for one commit.
  664. 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.
  665. Now, analyze the provided context and generate the reword suggestions with complete new messages.
  666. """
  667. return prompt
  668. def parse_reword_suggestions(ai_response_text, commits_data):
  669. """Parses AI response for REWORD:/NEW_MESSAGE:/END_MESSAGE blocks."""
  670. reword_plan = {} # Use dict: {short_hash: new_message_string}
  671. commit_hashes = {c["short_hash"] for c in commits_data} # Set of valid short hashes
  672. # Regex to find blocks
  673. pattern = re.compile(
  674. r"REWORD:\s*(\w+)\s*NEW_MESSAGE:\s*(.*?)\s*END_MESSAGE",
  675. re.DOTALL | re.IGNORECASE,
  676. )
  677. matches = pattern.findall(ai_response_text)
  678. for match in matches:
  679. reword_hash = match[0].strip()
  680. new_message = match[1].strip() # Includes Subject: and body
  681. if reword_hash in commit_hashes:
  682. reword_plan[reword_hash] = new_message
  683. logging.debug(
  684. f"Parsed reword suggestion for {reword_hash}:\n{new_message[:100]}..."
  685. )
  686. else:
  687. logging.warning(
  688. f"Ignoring invalid reword suggestion (hash {reword_hash} not in range)."
  689. )
  690. return reword_plan
  691. # --- Automatic Rebase Logic ---
  692. def create_rebase_sequence_editor_script(script_path, reword_plan):
  693. """Creates the python script for GIT_SEQUENCE_EDITOR (changes pick to reword)."""
  694. hashes_to_reword = set(reword_plan.keys())
  695. script_content = f"""#!/usr/bin/env python3
  696. import sys
  697. import logging
  698. import re
  699. import os
  700. logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
  701. todo_file_path = sys.argv[1]
  702. logging.info(f"GIT_SEQUENCE_EDITOR script started for: {{todo_file_path}}")
  703. hashes_to_reword = {hashes_to_reword!r}
  704. logging.info(f"Applying rewording for hashes: {{hashes_to_reword}}")
  705. new_lines = []
  706. try:
  707. with open(todo_file_path, 'r', encoding='utf-8') as f:
  708. lines = f.readlines()
  709. for line in lines:
  710. stripped_line = line.strip()
  711. if not stripped_line or stripped_line.startswith('#'):
  712. new_lines.append(line)
  713. continue
  714. match = re.match(r"^(\w+)\s+([0-9a-fA-F]+)(.*)", stripped_line)
  715. if match:
  716. action = match.group(1).lower()
  717. commit_hash = match.group(2)
  718. rest_of_line = match.group(3)
  719. if commit_hash in hashes_to_reword and action == 'pick':
  720. logging.info(f"Changing 'pick {{commit_hash}}' to 'reword {{commit_hash}}'")
  721. new_line = f'r {{commit_hash}}{{rest_of_line}}\\n' # Use 'r' for reword
  722. new_lines.append(new_line)
  723. else:
  724. new_lines.append(line)
  725. else:
  726. logging.warning(f"Could not parse todo line: {{stripped_line}}")
  727. new_lines.append(line)
  728. logging.info(f"Writing {{len(new_lines)}} lines back to {{todo_file_path}}")
  729. with open(todo_file_path, 'w', encoding='utf-8') as f:
  730. f.writelines(new_lines)
  731. logging.info("GIT_SEQUENCE_EDITOR script finished successfully.")
  732. sys.exit(0)
  733. except Exception as e:
  734. logging.error(f"Error in GIT_SEQUENCE_EDITOR script: {{e}}", exc_info=True)
  735. sys.exit(1)
  736. """
  737. try:
  738. with open(script_path, "w", encoding="utf-8") as f:
  739. f.write(script_content)
  740. os.chmod(script_path, 0o755)
  741. logging.info(f"Created GIT_SEQUENCE_EDITOR script: {script_path}")
  742. return True
  743. except Exception as e:
  744. logging.error(f"Failed to create GIT_SEQUENCE_EDITOR script: {e}")
  745. return False
  746. def create_rebase_commit_editor_script(script_path):
  747. """Creates the python script for GIT_EDITOR (provides new commit message)."""
  748. # Note: reword_plan_json is a JSON string containing the {hash: new_message} mapping
  749. script_content = f"""#!/usr/bin/env python3
  750. import sys
  751. import logging
  752. import re
  753. import os
  754. import subprocess
  755. import json
  756. logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
  757. commit_msg_file_path = sys.argv[1]
  758. logging.info(f"GIT_EDITOR script started for commit message file: {{commit_msg_file_path}}")
  759. # The reword plan (hash -> new_message) is passed via environment variable as JSON
  760. reword_plan_json = os.environ.get('GIT_REWORD_PLAN')
  761. if not reword_plan_json:
  762. logging.error("GIT_REWORD_PLAN environment variable not set.")
  763. sys.exit(1)
  764. try:
  765. reword_plan = json.loads(reword_plan_json)
  766. logging.info(f"Loaded reword plan for {{len(reword_plan)}} commits.")
  767. except json.JSONDecodeError as e:
  768. logging.error(f"Failed to decode GIT_REWORD_PLAN JSON: {{e}}")
  769. sys.exit(1)
  770. # --- How to identify the current commit being reworded? ---
  771. # This is the tricky part. Git doesn't directly tell the editor which commit
  772. # it's editing during a reword.
  773. # Approach 1: Read the *original* message from the file Git provides.
  774. # Extract the original hash (if possible, maybe from a trailer?). Unreliable.
  775. # Approach 2: Rely on the *order*. Requires knowing the rebase todo list order. Fragile.
  776. # Approach 3: Use `git rev-parse HEAD`? Might work if HEAD points to the commit being edited. Needs testing.
  777. # Approach 4: Pass the *current* target hash via another env var set by the main script
  778. # before calling rebase? Seems overly complex.
  779. # --- Let's try Approach 3 (Check HEAD) ---
  780. try:
  781. # Use subprocess to run git command to get the full hash of HEAD
  782. result = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True, check=True, encoding='utf-8')
  783. current_full_hash = result.stdout.strip()
  784. logging.info(f"Current HEAD full hash: {{current_full_hash}}")
  785. # Find the corresponding short hash in our plan (keys are short hashes)
  786. current_short_hash = None
  787. for short_h in reword_plan.keys():
  788. # Use git rev-parse to check if short_h resolves to current_full_hash
  789. # This handles potential ambiguity if multiple commits have the same short hash prefix
  790. try:
  791. # Verify that the short_h from the plan resolves to a commit object
  792. # and get its full hash. Simply pass the short hash to verify.
  793. logging.info(f"Verifying short hash {{short_h}} against HEAD {{current_full_hash}}...")
  794. verify_result = subprocess.run(['git', 'rev-parse', '--verify', short_h], capture_output=True, text=True, check=True, encoding='utf-8')
  795. verified_full_hash = verify_result.stdout.strip()
  796. if verified_full_hash == current_full_hash:
  797. current_short_hash = short_h
  798. logging.info(f"Matched HEAD {{current_full_hash}} to short hash {{current_short_hash}} in plan.")
  799. break
  800. except subprocess.CalledProcessError:
  801. logging.debug(f"Short hash {{short_h}} does not resolve to HEAD.")
  802. continue # Try next short hash in plan
  803. if current_short_hash is None:
  804. sys.exit(0) # Exit successfully to avoid blocking rebase, but log warning
  805. elif current_short_hash and current_short_hash in reword_plan:
  806. new_message = reword_plan[current_short_hash]
  807. logging.info(f"Found new message for commit {{current_short_hash}}.")
  808. # Remove the "Subject: " prefix as Git adds that structure
  809. new_message_content = re.sub(r"^[Ss]ubject:\s*", "", new_message, count=1)
  810. logging.info(f"Writing new message to {{commit_msg_file_path}}: {{new_message_content[:100]}}...")
  811. with open(commit_msg_file_path, 'w', encoding='utf-8') as f:
  812. f.write(new_message_content)
  813. logging.info("GIT_EDITOR script finished successfully for reword.")
  814. sys.exit(0)
  815. else:
  816. 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}}).")
  817. # Keep the original message provided by Git? Or fail? Let's keep original for safety.
  818. logging.warning("Keeping the original commit message.")
  819. sys.exit(0) # Exit successfully to avoid blocking rebase, but log warning
  820. except subprocess.CalledProcessError as e:
  821. logging.error(f"Failed to run git rev-parse HEAD: {{e}}")
  822. sys.exit(1) # Fail editor script
  823. except Exception as e:
  824. logging.error(f"Error in GIT_EDITOR script: {{e}}", exc_info=True)
  825. sys.exit(1) # Exit with error code
  826. """
  827. try:
  828. with open(script_path, "w", encoding="utf-8") as f:
  829. f.write(script_content)
  830. os.chmod(script_path, 0o755)
  831. logging.info(f"Created GIT_EDITOR script: {script_path}")
  832. return True
  833. except Exception as e:
  834. logging.error(f"Failed to create GIT_EDITOR script: {e}")
  835. return False
  836. def attempt_auto_reword(merge_base, reword_plan):
  837. """Attempts to perform the rebase automatically applying rewording."""
  838. if not reword_plan:
  839. logging.info("No reword suggestions provided by AI. Skipping auto-rebase.")
  840. return True
  841. temp_dir = tempfile.mkdtemp(prefix="git_reword_")
  842. seq_editor_script_path = os.path.join(temp_dir, "rebase_sequence_editor.py")
  843. commit_editor_script_path = os.path.join(temp_dir, "rebase_commit_editor.py")
  844. logging.debug(f"Temporary directory: {temp_dir}")
  845. try:
  846. # Create the sequence editor script (changes pick -> reword)
  847. if not create_rebase_sequence_editor_script(
  848. seq_editor_script_path, reword_plan
  849. ):
  850. return False
  851. # Create the commit editor script (provides new message)
  852. # Pass the reword plan as a JSON string via environment variable
  853. reword_plan_json = json.dumps(reword_plan)
  854. if not create_rebase_commit_editor_script(commit_editor_script_path):
  855. return False
  856. # Prepare environment for the git command
  857. rebase_env = os.environ.copy()
  858. rebase_env["GIT_SEQUENCE_EDITOR"] = seq_editor_script_path
  859. rebase_env["GIT_EDITOR"] = commit_editor_script_path
  860. # Pass the plan to the commit editor script via env var
  861. rebase_env["GIT_REWORD_PLAN"] = reword_plan_json
  862. logging.debug(f"GIT_REWORD_PLAN: {reword_plan_json}")
  863. print("\nAttempting automatic rebase with suggested rewording...")
  864. logging.info(f"Running: git rebase -i {merge_base}")
  865. rebase_result = run_git_command(
  866. ["rebase", "-i", merge_base],
  867. check=False,
  868. capture_output=False,
  869. env=rebase_env,
  870. )
  871. if rebase_result is not None:
  872. print("✅ Automatic reword rebase completed successfully.")
  873. logging.info("Automatic reword rebase seems successful.")
  874. return True
  875. else:
  876. print("\n❌ Automatic reword rebase failed.")
  877. print(
  878. " This could be due to merge conflicts, script errors, or other rebase issues."
  879. )
  880. logging.warning("Automatic reword rebase failed. Aborting...")
  881. print(" Attempting to abort the failed rebase (`git rebase --abort`)...")
  882. abort_result = run_git_command(
  883. ["rebase", "--abort"], check=False, capture_output=False
  884. )
  885. if abort_result is not None:
  886. print(
  887. " Rebase aborted successfully. Your branch is back to its original state."
  888. )
  889. logging.info("Failed rebase aborted successfully.")
  890. else:
  891. print(" ⚠️ Failed to automatically abort the rebase.")
  892. print(" Please run `git rebase --abort` manually to clean up.")
  893. logging.error("Failed to automatically abort the rebase.")
  894. return False
  895. except Exception as e:
  896. logging.error(
  897. f"An unexpected error occurred during auto-reword attempt: {e}",
  898. exc_info=True,
  899. )
  900. print("\n❌ An unexpected error occurred during the automatic reword attempt.")
  901. print(
  902. " You may need to manually check your Git status and potentially run `git rebase --abort`."
  903. )
  904. return False
  905. finally:
  906. # Clean up the temporary directory
  907. if temp_dir and os.path.exists(temp_dir):
  908. try:
  909. import shutil
  910. shutil.rmtree(temp_dir)
  911. logging.debug(f"Cleaned up temporary directory: {temp_dir}")
  912. except OSError as e:
  913. logging.warning(f"Could not remove temporary directory {temp_dir}: {e}")
  914. # --- Main Execution ---
  915. def rebase(args):
  916. if not check_git_repository():
  917. logging.error("This script must be run from within a Git repository.")
  918. sys.exit(1)
  919. current_branch = get_current_branch()
  920. if not current_branch:
  921. logging.error("Could not determine the current Git branch.")
  922. sys.exit(1)
  923. logging.info(f"Current branch: {current_branch}")
  924. upstream_ref = args.upstream_ref
  925. logging.info(f"Comparing against reference: {upstream_ref}")
  926. # Load ignore patterns early
  927. ignore_patterns = load_ignore_patterns(".rebase-ignore")
  928. # --- Gather Initial Git Context ---
  929. print("\nGathering initial Git context...")
  930. commit_range, merge_base = get_commit_range(upstream_ref, current_branch)
  931. if not commit_range:
  932. sys.exit(1) # Error message already printed by get_commit_range
  933. logging.info(f"Analyzing commit range: {commit_range} (Merge Base: {merge_base})")
  934. commits = get_commits_in_range(commit_range)
  935. if not commits:
  936. print(
  937. f"\n✅ No commits found between '{merge_base}' ({upstream_ref}) and '{current_branch}'. Nothing to rebase."
  938. )
  939. sys.exit(0)
  940. # --- Safety: Create Backup Branch (only if commits exist) ---
  941. backup_branch = create_backup_branch(current_branch)
  942. if not backup_branch:
  943. try:
  944. confirm = input(
  945. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  946. ).lower()
  947. except EOFError:
  948. logging.warning("Input stream closed. Aborting.")
  949. confirm = "no"
  950. if confirm != "yes":
  951. logging.info("Aborting.")
  952. sys.exit(1)
  953. else:
  954. logging.warning("Proceeding without a backup branch. Be careful!")
  955. else:
  956. print("-" * 40)
  957. print(f"✅ Backup branch created: {backup_branch}")
  958. print(" If anything goes wrong, you can restore using:")
  959. print(f" git checkout {current_branch}")
  960. print(f" git reset --hard {backup_branch}")
  961. print("-" * 40)
  962. # --- Gather Remaining Git Context ---
  963. print("\nGathering detailed Git context for AI...")
  964. # We already have commits, commit_range, merge_base from the initial check
  965. file_structure, project_files_for_structure = get_project_file_structure(
  966. ignore_patterns
  967. )
  968. diff = get_diff_in_range(commit_range, ignore_patterns)
  969. if not diff: # If diff is empty after respecting ignores
  970. logging.warning(
  971. f"No relevant diff found between '{merge_base}' and '{current_branch}' (respecting .rebase-ignore)."
  972. )
  973. logging.warning(
  974. "AI suggestions might be limited if all changes were in ignored files."
  975. )
  976. # Don't exit automatically, let AI try, it has project structure
  977. # --- Interact with AI ---
  978. print("\nGenerating prompt for AI fixup suggestions...")
  979. initial_prompt = generate_fixup_suggestion_prompt(
  980. commit_range, merge_base, commits, file_structure, diff
  981. )
  982. logging.debug("\n--- Initial AI Prompt Snippet ---")
  983. logging.debug(initial_prompt[:1000] + "...")
  984. logging.debug("--- End Prompt Snippet ---\n")
  985. print(f"Sending request to Gemini AI ({MODEL_NAME})...")
  986. ai_response_text = ""
  987. fixup_suggestions_text = "" # Store the raw suggestions for later display if needed
  988. try:
  989. convo = model.start_chat(history=[])
  990. response = convo.send_message(initial_prompt)
  991. ai_response_text = response.text
  992. # Loop for file requests
  993. while "REQUEST_FILES:" in ai_response_text.upper():
  994. logging.info("AI requested additional file content.")
  995. additional_context, original_request = request_files_from_user(
  996. ai_response_text, commits
  997. )
  998. if additional_context:
  999. logging.info("Sending fetched file content back to AI...")
  1000. follow_up_prompt = f"""
  1001. Okay, here is the content of the files you requested:
  1002. {additional_context}
  1003. Please use this new information to refine your **fixup suggestions** based on the original request and context. Provide the final list of `FIXUP: ...` lines now. Remember to *only* suggest fixup actions and output *only* `FIXUP:` lines. Do not ask for more files.
  1004. """
  1005. logging.debug("\n--- Follow-up AI Prompt Snippet ---")
  1006. logging.debug(follow_up_prompt[:500] + "...")
  1007. logging.debug("--- End Follow-up Snippet ---\n")
  1008. response = convo.send_message(follow_up_prompt)
  1009. ai_response_text = response.text
  1010. else:
  1011. logging.info(
  1012. "Proceeding without providing files as requested by AI or user."
  1013. )
  1014. no_files_prompt = f"""
  1015. I cannot provide the content for the files you requested ({original_request}).
  1016. Please proceed with generating the **fixup suggestions** based *only* on the initial context (commit list, file structure, diff) I provided earlier. Make your best suggestions without the file content. Provide the final list of `FIXUP: ...` lines now. Remember to *only* suggest fixup actions.
  1017. """
  1018. logging.debug("\n--- No-Files AI Prompt ---")
  1019. logging.debug(no_files_prompt)
  1020. logging.debug("--- End No-Files Prompt ---\n")
  1021. response = convo.send_message(no_files_prompt)
  1022. ai_response_text = response.text
  1023. break
  1024. # Store the final AI response containing suggestions
  1025. fixup_suggestions_text = ai_response_text.strip()
  1026. # Parse the suggestions
  1027. fixup_plan = parse_fixup_suggestions(fixup_suggestions_text, commits)
  1028. if not fixup_plan:
  1029. print("\n💡 AI did not suggest any specific fixup operations.")
  1030. else:
  1031. print("\n💡 --- AI Fixup Suggestions --- 💡")
  1032. # Print the parsed plan for clarity
  1033. for i, pair in enumerate(fixup_plan):
  1034. print(
  1035. f" {i + 1}. Fixup commit `{pair['fixup']}` into `{pair['target']}`"
  1036. )
  1037. print("💡 --- End AI Suggestions --- 💡")
  1038. # --- Attempt Automatic Rebase or Show Instructions ---
  1039. # --- Logic Change ---
  1040. if not args.instruct: # Default behavior: attempt auto-fixup
  1041. if fixup_plan:
  1042. success = attempt_auto_fixup(merge_base, fixup_plan)
  1043. if not success:
  1044. # Failure message already printed by attempt_auto_fixup
  1045. print("\n" + "=" * 60)
  1046. print("🛠️ MANUAL REBASE REQUIRED 🛠️")
  1047. print("=" * 60)
  1048. print(
  1049. "The automatic fixup rebase failed (likely due to conflicts)."
  1050. )
  1051. print("Please perform the rebase manually:")
  1052. print(f" 1. Run: `git rebase -i {merge_base}`")
  1053. print(
  1054. " 2. In the editor, change 'pick' to 'f' (or 'fixup') for the commits"
  1055. )
  1056. print(
  1057. " suggested by the AI above (and any other changes you want)."
  1058. )
  1059. print(" Original AI suggestions:")
  1060. print(" ```text")
  1061. # Print raw suggestions which might be easier to copy/paste
  1062. print(
  1063. fixup_suggestions_text
  1064. if fixup_suggestions_text
  1065. else " (No specific fixup lines found in AI response)"
  1066. )
  1067. print(" ```")
  1068. print(" 3. Save the editor and resolve any conflicts Git reports.")
  1069. print(
  1070. " Use `git status`, edit files, `git add <files>`, `git rebase --continue`."
  1071. )
  1072. if backup_branch:
  1073. print(f" 4. Remember backup branch: {backup_branch}")
  1074. print("=" * 60)
  1075. sys.exit(1) # Exit with error status after failure
  1076. else:
  1077. # Auto fixup succeeded
  1078. print("\nBranch history has been modified by automatic fixups.")
  1079. if backup_branch:
  1080. print(
  1081. f"Backup branch '{backup_branch}' still exists if needed."
  1082. )
  1083. else:
  1084. print("\nNo automatic rebase attempted as AI suggested no fixups.")
  1085. elif fixup_plan: # --instruct flag was used AND suggestions exist
  1086. print("\n" + "=" * 60)
  1087. print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
  1088. print("=" * 60)
  1089. print("AI suggested the fixups listed above.")
  1090. print("To apply them (or other changes):")
  1091. print(f" 1. Run: `git rebase -i {merge_base}`")
  1092. print(" 2. Edit the 'pick' lines in the editor based on the suggestions")
  1093. print(" (changing 'pick' to 'f' or 'fixup').")
  1094. print(" 3. Save the editor and follow Git's instructions.")
  1095. if backup_branch:
  1096. print(f" 4. Remember backup branch: {backup_branch}")
  1097. print("=" * 60)
  1098. # If --instruct and no fixup_plan, nothing specific needs to be printed here
  1099. except Exception as e:
  1100. logging.error(f"\nAn unexpected error occurred: {e}", exc_info=True)
  1101. # Attempt to print feedback if available
  1102. try:
  1103. if response and hasattr(response, "prompt_feedback"):
  1104. logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1105. if response and hasattr(response, "candidates"):
  1106. for candidate in response.candidates:
  1107. logging.error(
  1108. f"AI Candidate Finish Reason: {candidate.finish_reason}"
  1109. )
  1110. if hasattr(candidate, "safety_ratings"):
  1111. logging.error(f"AI Safety Ratings: {candidate.safety_ratings}")
  1112. except Exception as feedback_e:
  1113. logging.error(
  1114. f"Could not retrieve detailed feedback from AI response: {feedback_e}"
  1115. )
  1116. print("\n❌ An unexpected error occurred during the process.")
  1117. print(" Please check the logs and your Git status.")
  1118. print(" You may need to run `git rebase --abort` manually.")
  1119. def reword(args):
  1120. """Main function to orchestrate Git analysis and AI interaction."""
  1121. if not check_git_repository():
  1122. logging.error("This script must be run from within a Git repository.")
  1123. sys.exit(1)
  1124. current_branch = get_current_branch()
  1125. if not current_branch:
  1126. logging.error("Could not determine the current Git branch.")
  1127. sys.exit(1)
  1128. logging.info(f"Current branch: {current_branch}")
  1129. upstream_ref = args.upstream_ref
  1130. logging.info(f"Comparing against reference: {upstream_ref}")
  1131. # Load ignore patterns early (reword also uses get_diff_in_range)
  1132. ignore_patterns = load_ignore_patterns(".rebase-ignore")
  1133. # --- Safety: Create Backup Branch ---
  1134. backup_branch = create_backup_branch(current_branch)
  1135. if not backup_branch:
  1136. try:
  1137. confirm = input(
  1138. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  1139. ).lower()
  1140. except EOFError:
  1141. confirm = "no"
  1142. if confirm != "yes":
  1143. logging.info("Aborting.")
  1144. sys.exit(1)
  1145. else:
  1146. logging.warning("Proceeding without a backup branch. Be careful!")
  1147. else:
  1148. print("-" * 40)
  1149. print(f"✅ Backup branch created: {backup_branch}")
  1150. print(
  1151. f" Restore with: git checkout {current_branch} && git reset --hard {backup_branch}"
  1152. )
  1153. print("-" * 40)
  1154. # --- Gather Git Context ---
  1155. print("\nGathering Git context...")
  1156. commit_range, merge_base = get_commit_range(upstream_ref, current_branch)
  1157. if not commit_range:
  1158. sys.exit(1)
  1159. logging.info(f"Analyzing commit range: {commit_range} (Merge Base: {merge_base})")
  1160. commits_data = get_commits_data_in_range(commit_range)
  1161. if not commits_data:
  1162. logging.info(
  1163. f"No commits found between '{merge_base}' and '{current_branch}'. Nothing to do."
  1164. )
  1165. sys.exit(0)
  1166. diff = get_diff_in_range(
  1167. commit_range, ignore_patterns
  1168. ) # Diff might help AI judge messages
  1169. # --- Interact with AI ---
  1170. print("\nGenerating prompt for AI reword suggestions...")
  1171. initial_prompt = generate_reword_suggestion_prompt(
  1172. commit_range, merge_base, commits_data, diff
  1173. )
  1174. logging.debug("\n--- Initial AI Prompt Snippet ---")
  1175. logging.debug(initial_prompt[:1000] + "...")
  1176. logging.debug("--- End Prompt Snippet ---\n")
  1177. print(f"Sending request to Gemini AI ({MODEL_NAME})...")
  1178. ai_response_text = ""
  1179. reword_suggestions_text = "" # Store raw AI suggestions
  1180. try:
  1181. # For reword, file content is less likely needed, but keep structure just in case
  1182. convo = model.start_chat(history=[])
  1183. response = convo.send_message(initial_prompt)
  1184. ai_response_text = response.text
  1185. # Store the final AI response containing suggestions
  1186. reword_suggestions_text = ai_response_text.strip()
  1187. # Parse the suggestions
  1188. reword_plan = parse_reword_suggestions(reword_suggestions_text, commits_data)
  1189. if not reword_plan:
  1190. print("\n💡 AI did not suggest any specific reword operations.")
  1191. else:
  1192. print("\n💡 --- AI Reword Suggestions --- 💡")
  1193. for i, (hash_key, msg) in enumerate(reword_plan.items()):
  1194. print(f" {i + 1}. Reword commit `{hash_key}` with new message:")
  1195. # Indent the message for readability
  1196. indented_msg = " " + msg.replace("\n", "\n ")
  1197. print(indented_msg)
  1198. print("-" * 20) # Separator
  1199. print("💡 --- End AI Suggestions --- 💡")
  1200. # --- Attempt Automatic Rebase or Show Instructions ---
  1201. if not args.instruct: # Default behavior: attempt auto-reword
  1202. if reword_plan:
  1203. success = attempt_auto_reword(merge_base, reword_plan)
  1204. if not success:
  1205. # Failure message already printed by attempt_auto_reword
  1206. print("\n" + "=" * 60)
  1207. print("🛠️ MANUAL REBASE REQUIRED 🛠️")
  1208. print("=" * 60)
  1209. print("The automatic reword rebase failed.")
  1210. print("Please perform the rebase manually:")
  1211. print(f" 1. Run: `git rebase -i {merge_base}`")
  1212. print(
  1213. " 2. In the editor, change 'pick' to 'r' (or 'reword') for the commits"
  1214. )
  1215. print(" suggested by the AI above.")
  1216. print(
  1217. " 3. Save the editor. Git will stop at each commit marked for reword."
  1218. )
  1219. print(
  1220. " 4. Manually replace the old commit message with the AI-suggested one:"
  1221. )
  1222. print(" ```text")
  1223. # Print raw suggestions which might be easier to copy/paste
  1224. print(
  1225. reword_suggestions_text
  1226. if reword_suggestions_text
  1227. else " (No specific reword suggestions found in AI response)"
  1228. )
  1229. print(" ```")
  1230. print(
  1231. " 5. Save the message editor and continue the rebase (`git rebase --continue`)."
  1232. )
  1233. if backup_branch:
  1234. print(f" 6. Remember backup branch: {backup_branch}")
  1235. print("=" * 60)
  1236. sys.exit(1) # Exit with error status after failure
  1237. else:
  1238. # Auto reword succeeded
  1239. print("\nBranch history has been modified by automatic rewording.")
  1240. if backup_branch:
  1241. print(
  1242. f"Backup branch '{backup_branch}' still exists if needed."
  1243. )
  1244. else:
  1245. print("\nNo automatic rebase attempted as AI suggested no rewording.")
  1246. elif reword_plan: # --instruct flag was used AND suggestions exist
  1247. print("\n" + "=" * 60)
  1248. print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
  1249. print("=" * 60)
  1250. print("AI suggested the rewording listed above.")
  1251. print("To apply them manually:")
  1252. print(f" 1. Run: `git rebase -i {merge_base}`")
  1253. print(
  1254. " 2. Edit the 'pick' lines in the editor, changing 'pick' to 'r' (or 'reword')"
  1255. )
  1256. print(" for the commits listed above.")
  1257. print(
  1258. " 3. Save the editor. Git will stop at each commit marked for reword."
  1259. )
  1260. print(
  1261. " 4. Manually replace the old commit message with the corresponding AI-suggested message."
  1262. )
  1263. print(
  1264. " 5. Save the message editor and continue the rebase (`git rebase --continue`)."
  1265. )
  1266. if backup_branch:
  1267. print(f" 6. Remember backup branch: {backup_branch}")
  1268. print("=" * 60)
  1269. except Exception as e:
  1270. logging.error(f"\nAn unexpected error occurred: {e}", exc_info=True)
  1271. try: # Log AI feedback if possible
  1272. if response and hasattr(response, "prompt_feedback"):
  1273. logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1274. # ... (rest of feedback logging) ...
  1275. except Exception as feedback_e:
  1276. logging.error(
  1277. f"Could not retrieve detailed feedback from AI response: {feedback_e}"
  1278. )
  1279. print("\n❌ An unexpected error occurred during the process.")
  1280. print(" Please check the logs and your Git status.")
  1281. print(" You may need to run `git rebase --abort` manually.")
  1282. def check_git_status():
  1283. """Checks if the Git working directory is clean."""
  1284. status_output = run_git_command(
  1285. ["status", "--porcelain"], check=True, capture_output=True
  1286. )
  1287. if status_output is None:
  1288. # Error running command, already logged by run_git_command
  1289. return False # Assume dirty or error state
  1290. if status_output:
  1291. logging.warning("Git working directory is not clean.")
  1292. print(
  1293. "⚠️ Your Git working directory has uncommitted changes or untracked files:"
  1294. )
  1295. print(status_output)
  1296. print("Please commit or stash your changes before running this tool.")
  1297. return False
  1298. logging.info("Git working directory is clean.")
  1299. return True
  1300. def main():
  1301. """Main function to orchestrate Git analysis and AI interaction."""
  1302. parser = argparse.ArgumentParser(
  1303. description="Uses Gemini AI to suggest and automatically attempt Git 'fixup' and 'reword' operations.",
  1304. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  1305. )
  1306. parser.add_argument(
  1307. "upstream_ref",
  1308. nargs="?",
  1309. default="upstream/master",
  1310. help="The upstream reference point or commit hash to compare against "
  1311. "(e.g., 'origin/main', 'upstream/develop', specific_commit_hash). "
  1312. "Ensure this reference exists and is fetched.",
  1313. )
  1314. # --- Argument Change ---
  1315. parser.add_argument(
  1316. "--instruct",
  1317. action="store_true",
  1318. help="Only show AI suggestions and instructions; disable automatic fixup attempt.",
  1319. )
  1320. parser.add_argument(
  1321. "-v", "--verbose", action="store_true", help="Enable verbose debug logging."
  1322. )
  1323. parser.add_argument(
  1324. "--delete-backups",
  1325. action="store_true",
  1326. help="Delete all backup branches created by this tool for the current branch and exit.",
  1327. )
  1328. args = parser.parse_args()
  1329. if args.verbose:
  1330. logging.getLogger().setLevel(logging.DEBUG)
  1331. logging.debug("Verbose logging enabled.")
  1332. # --- Initial Checks ---
  1333. if not check_git_repository():
  1334. logging.error("This script must be run from within a Git repository.")
  1335. sys.exit(1)
  1336. if not check_git_status():
  1337. sys.exit(1) # Exit if working directory is not clean
  1338. # Handle --delete-backups flag first
  1339. if args.delete_backups:
  1340. current_branch = get_current_branch()
  1341. if not current_branch:
  1342. logging.error("Could not determine the current Git branch.")
  1343. sys.exit(1)
  1344. backup_pattern = f"{current_branch}-backup-*"
  1345. logging.info(f"Searching for backup branches matching: {backup_pattern}")
  1346. # List backup branches
  1347. list_command = ["branch", "--list", backup_pattern]
  1348. backup_branches_output = run_git_command(
  1349. list_command, check=True, capture_output=True
  1350. )
  1351. if backup_branches_output:
  1352. # Branches are listed one per line, potentially with leading whitespace/asterisk
  1353. branches_to_delete = [
  1354. b.strip().lstrip("* ")
  1355. for b in backup_branches_output.splitlines()
  1356. if b.strip()
  1357. ]
  1358. if branches_to_delete:
  1359. print(f"Found backup branches for '{current_branch}':")
  1360. for branch in branches_to_delete:
  1361. print(f" - {branch}")
  1362. delete_command = ["branch", "-D"] + branches_to_delete
  1363. print(
  1364. f"Attempting to delete {len(branches_to_delete)} backup branch(es)..."
  1365. )
  1366. delete_output = run_git_command(
  1367. delete_command, check=False, capture_output=True
  1368. ) # Use check=False to see output even on error
  1369. if (
  1370. delete_output is not None
  1371. ): # run_git_command returns None on CalledProcessError
  1372. # Print the output from the delete command (shows which branches were deleted)
  1373. print("--- Deletion Output ---")
  1374. print(delete_output if delete_output else "(No output)")
  1375. print("-----------------------")
  1376. print("✅ Backup branches deleted successfully.")
  1377. sys.exit(0)
  1378. else:
  1379. # Error occurred, run_git_command already logged details
  1380. print("❌ Failed to delete backup branches. Check logs above.")
  1381. sys.exit(1)
  1382. else:
  1383. print(f"No backup branches matching '{backup_pattern}' found.")
  1384. sys.exit(0)
  1385. else:
  1386. # Handle case where listing command succeeds but finds nothing
  1387. print(f"No backup branches matching '{backup_pattern}' found.")
  1388. sys.exit(0)
  1389. # Proceed with rebase/reword if --delete-backups was not used
  1390. rebase(args)
  1391. reword(args)
  1392. if __name__ == "__main__":
  1393. main()