git_rebase_ai.py 93 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023
  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 for reword 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. logging.error(" export GEMINI_API_KEY='YOUR_API_KEY' (Linux/macOS)")
  23. logging.error(" set GEMINI_API_KEY=YOUR_API_KEY (Windows CMD)")
  24. logging.error(" $env:GEMINI_API_KEY='YOUR_API_KEY' (Windows PowerShell)")
  25. sys.exit(1)
  26. # Configure the Gemini AI Client
  27. try:
  28. genai.configure(api_key=API_KEY)
  29. # Use a model suitable for complex reasoning like code analysis.
  30. # Adjust model name if needed (e.g., 'gemini-1.5-flash-latest').
  31. MODEL_NAME = os.getenv("GEMINI_MODEL")
  32. if not MODEL_NAME:
  33. logging.error("GEMINI_MODEL environment variable not set.")
  34. logging.error(
  35. "Please set the desired Gemini model name (e.g., 'gemini-1.5-flash-latest')."
  36. )
  37. logging.error(" export GEMINI_MODEL='gemini-1.5-flash-latest' (Linux/macOS)")
  38. logging.error(" set GEMINI_MODEL=gemini-1.5-flash-latest (Windows CMD)")
  39. logging.error(
  40. " $env:GEMINI_MODEL='gemini-1.5-flash-latest' (Windows PowerShell)"
  41. )
  42. sys.exit(1)
  43. model = genai.GenerativeModel(MODEL_NAME)
  44. logging.info(f"Using Gemini model: {MODEL_NAME}")
  45. except Exception as e:
  46. logging.error(f"Error configuring Gemini AI: {e}")
  47. sys.exit(1)
  48. # --- Git Helper Functions ---
  49. def run_git_command(command_list, check=True, capture_output=True, env=None):
  50. """
  51. Runs a Git command as a list of arguments and returns its stdout.
  52. Handles errors and returns None on failure if check=True.
  53. Allows passing environment variables.
  54. """
  55. full_command = []
  56. try:
  57. full_command = ["git"] + command_list
  58. logging.debug(f"Running command: {' '.join(full_command)}")
  59. cmd_env = os.environ.copy()
  60. if env:
  61. cmd_env.update(env)
  62. result = subprocess.run(
  63. full_command,
  64. check=check,
  65. capture_output=capture_output,
  66. text=True,
  67. encoding="utf-8",
  68. errors="replace",
  69. env=cmd_env,
  70. )
  71. logging.debug(f"Command successful. Output:\n{result.stdout[:200]}...")
  72. return result.stdout.strip() if capture_output else ""
  73. except subprocess.CalledProcessError as e:
  74. logging.error(f"Error executing Git command: {' '.join(full_command)}")
  75. stderr_safe = (
  76. e.stderr.strip().encode("utf-8", "replace").decode("utf-8")
  77. if e.stderr
  78. else ""
  79. )
  80. stdout_safe = (
  81. e.stdout.strip().encode("utf-8", "replace").decode("utf-8")
  82. if e.stdout
  83. else ""
  84. )
  85. logging.error(f"Exit Code: {e.returncode}")
  86. if stderr_safe:
  87. logging.error(f"Stderr: {stderr_safe}")
  88. if stdout_safe:
  89. logging.error(f"Stdout: {stdout_safe}")
  90. return None
  91. except FileNotFoundError:
  92. logging.error(
  93. "Error: 'git' command not found. Is Git installed and in your PATH?"
  94. )
  95. sys.exit(1)
  96. except Exception as e:
  97. logging.error(f"An unexpected error occurred running git: {e}")
  98. return None
  99. def check_git_repository():
  100. """Checks if the current directory is the root of a Git repository."""
  101. output = run_git_command(["rev-parse", "--is-inside-work-tree"])
  102. return output == "true"
  103. def get_current_branch():
  104. """Gets the current active Git branch name."""
  105. return run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
  106. def create_backup_branch(branch_name):
  107. """Creates a timestamped backup branch from the given branch name."""
  108. timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
  109. backup_branch_name = f"{branch_name}-backup-{timestamp}"
  110. logging.info(
  111. f"Attempting to create backup branch: {backup_branch_name} from {branch_name}"
  112. )
  113. output = run_git_command(["branch", backup_branch_name, branch_name])
  114. if output is not None:
  115. logging.info(f"Successfully created backup branch: {backup_branch_name}")
  116. return backup_branch_name
  117. else:
  118. logging.error("Failed to create backup branch.")
  119. return None
  120. def get_commit_range(upstream_ref, current_branch):
  121. """
  122. Determines the commit range (merge_base..current_branch).
  123. Returns the range string and the merge base hash.
  124. """
  125. logging.info(
  126. f"Finding merge base between '{upstream_ref}' and '{current_branch}'..."
  127. )
  128. merge_base = run_git_command(["merge-base", upstream_ref, current_branch])
  129. if not merge_base:
  130. logging.error(
  131. f"Could not find merge base between '{upstream_ref}' and '{current_branch}'."
  132. )
  133. logging.error(
  134. f"Ensure '{upstream_ref}' is a valid reference (branch, commit, tag)"
  135. )
  136. logging.error("and that it has been fetched (e.g., 'git fetch origin').")
  137. return None, None # Indicate failure
  138. logging.info(f"Found merge base: {merge_base}")
  139. commit_range = f"{merge_base}..{current_branch}"
  140. return commit_range, merge_base
  141. def get_commits_in_range(commit_range):
  142. """Gets a list of commit hashes and subjects in the specified range (oldest first)."""
  143. log_output = run_git_command(
  144. ["log", "--pretty=format:%h %s", "--reverse", commit_range]
  145. )
  146. if log_output is not None:
  147. commits = log_output.splitlines()
  148. logging.info(f"Found {len(commits)} commits in range {commit_range}.")
  149. return commits
  150. return [] # Return empty list on failure or no commits
  151. def get_changed_files_in_range(commit_range):
  152. """
  153. Gets a list of files changed in the specified range and generates
  154. a simple directory structure string representation.
  155. """
  156. diff_output = run_git_command(["diff", "--name-only", commit_range])
  157. if diff_output is not None:
  158. files = diff_output.splitlines()
  159. logging.info(f"Found {len(files)} changed files in range {commit_range}.")
  160. # Basic tree structure representation
  161. tree = {}
  162. for file_path in files:
  163. parts = file_path.replace("\\", "/").split("/")
  164. node = tree
  165. for i, part in enumerate(parts):
  166. if not part:
  167. continue
  168. if i == len(parts) - 1:
  169. node[part] = "file"
  170. else:
  171. if part not in node:
  172. node[part] = {}
  173. if isinstance(node[part], dict):
  174. node = node[part]
  175. else:
  176. logging.warning(
  177. f"Path conflict building file tree for: {file_path}"
  178. )
  179. break
  180. def format_tree(d, indent=0):
  181. lines = []
  182. for key, value in sorted(d.items()):
  183. prefix = " " * indent
  184. if isinstance(value, dict):
  185. lines.append(f"{prefix}📁 {key}/")
  186. lines.extend(format_tree(value, indent + 1))
  187. else:
  188. lines.append(f"{prefix}📄 {key}")
  189. return lines
  190. tree_str = "\n".join(format_tree(tree))
  191. return tree_str, files
  192. return "", []
  193. def get_diff_in_range(commit_range):
  194. """Gets the combined diffstat and patch for the specified range."""
  195. diff_output = run_git_command(["diff", "--patch-with-stat", commit_range])
  196. if diff_output is not None:
  197. logging.info(
  198. f"Generated diff for range {commit_range} (length: {len(diff_output)} chars)."
  199. )
  200. else:
  201. logging.warning(f"Could not generate diff for range {commit_range}.")
  202. return diff_output if diff_output is not None else ""
  203. def get_file_content_at_commit(commit_hash, file_path):
  204. """Gets the content of a specific file at a specific commit hash."""
  205. logging.info(f"Fetching content of '{file_path}' at commit {commit_hash[:7]}...")
  206. content = run_git_command(["show", f"{commit_hash}:{file_path}"])
  207. if content is None:
  208. logging.warning(
  209. f"Could not retrieve content for {file_path} at {commit_hash[:7]}."
  210. )
  211. return None
  212. return content
  213. # --- AI Interaction ---
  214. def generate_fixup_suggestion_prompt(
  215. commit_range, merge_base, commits, file_structure, diff
  216. ):
  217. """
  218. Creates a prompt asking the AI specifically to identify potential
  219. fixup candidates within the commit range.
  220. Returns suggestions in a parsable format.
  221. """
  222. commit_list_str = (
  223. "\n".join([f"- {c}" for c in commits]) if commits else "No commits in range."
  224. )
  225. prompt = f"""
  226. 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}`).
  227. **Goal:** Identify commits that are minor corrections or direct continuations of the immediately preceding commit, where the commit message can be discarded.
  228. **Git Commit Message Conventions (for context):**
  229. * Subject: Imperative, < 50 chars, capitalized, no period. Use types like `feat:`, `fix:`, `refactor:`, etc.
  230. * Body: Explain 'what' and 'why', wrap at 72 chars.
  231. **Provided Context:**
  232. 1. **Commit Range:** `{commit_range}`
  233. 2. **Merge Base Hash:** `{merge_base}`
  234. 3. **Commits in Range (Oldest First - Short Hash & Subject):**
  235. ```
  236. {commit_list_str}
  237. ```
  238. 4. **Changed Files Structure in Range:**
  239. ```
  240. {file_structure if file_structure else "No files changed or unable to list."}
  241. ```
  242. 5. **Combined Diff for the Range (`git diff --patch-with-stat {commit_range}`):**
  243. ```diff
  244. {diff if diff else "No differences found or unable to get diff."}
  245. ```
  246. **Instructions:**
  247. 1. Analyze the commits, their messages, the changed files, and the diff.
  248. 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.
  249. 3. For each suggestion, output *only* a line in the following format:
  250. `FIXUP: <hash_to_fixup> INTO <preceding_hash>`
  251. Use the short commit hashes provided in the commit list.
  252. 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.
  253. **Example Output:**
  254. ```text
  255. FIXUP: hash2 INTO hash1
  256. FIXUP: hash5 INTO hash4
  257. ```
  258. 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:
  259. `REQUEST_FILES: [commit_hash1:path/to/file1.py, commit_hash2:another/path/file2.js]`
  260. 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.
  261. Now, analyze the provided context and generate *only* the `FIXUP:` lines or `REQUEST_FILES:` line.
  262. """
  263. return prompt
  264. def parse_fixup_suggestions(ai_response_text, commits_in_range):
  265. """Parses AI response for FIXUP: lines and validates hashes."""
  266. fixup_pairs = []
  267. commit_hashes = {
  268. c.split()[0] for c in commits_in_range
  269. } # Set of valid short hashes
  270. for line in ai_response_text.splitlines():
  271. line = line.strip()
  272. if line.startswith("FIXUP:"):
  273. match = re.match(r"FIXUP:\s*(\w+)\s+INTO\s+(\w+)", line, re.IGNORECASE)
  274. if match:
  275. fixup_hash = match.group(1)
  276. target_hash = match.group(2)
  277. # Validate that both hashes were in the original commit list
  278. if fixup_hash in commit_hashes and target_hash in commit_hashes:
  279. fixup_pairs.append({"fixup": fixup_hash, "target": target_hash})
  280. logging.debug(
  281. f"Parsed fixup suggestion: {fixup_hash} into {target_hash}"
  282. )
  283. else:
  284. logging.warning(
  285. f"Ignoring invalid fixup suggestion (hash not in range): {line}"
  286. )
  287. else:
  288. logging.warning(f"Could not parse FIXUP line: {line}")
  289. return fixup_pairs
  290. def generate_reword_suggestion_prompt(commit_range, merge_base, commits_data, diff):
  291. """
  292. Creates a prompt asking the AI to identify commits needing rewording
  293. and to generate the full new commit message for each.
  294. """
  295. # Format commit list for the prompt using only short hash and subject
  296. commit_list_str = (
  297. "\n".join([f"- {c['short_hash']} {c['subject']}" for c in commits_data])
  298. if commits_data
  299. else "No commits in range."
  300. )
  301. prompt = f"""
  302. 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}`).
  303. **Goal:** For each commit needing improvement, generate a **complete, new commit message** (subject and body) that adheres strictly to standard Git conventions.
  304. **Git Commit Message Conventions to Adhere To:**
  305. 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`
  306. 2. **Blank Line:** Single blank line between subject and body.
  307. 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:
  308. ```
  309. refactor: Improve database query performance
  310. The previous implementation used multiple sequential queries
  311. to fetch related data, leading to N+1 problems under load.
  312. This change refactors the data access layer to use a single
  313. JOIN query, significantly reducing database roundtrips and
  314. improving response time for the user profile page.
  315. ```
  316. **Provided Context:**
  317. 1. **Commit Range:** `{commit_range}`
  318. 2. **Merge Base Hash:** `{merge_base}`
  319. 3. **Commits in Range (Oldest First - Short Hash & Subject):**
  320. ```
  321. {commit_list_str}
  322. ```
  323. 4. **Combined Diff for the Range (`git diff --patch-with-stat {commit_range}`):**
  324. ```diff
  325. {diff if diff else "No differences found or unable to get diff."}
  326. ```
  327. **Instructions:**
  328. 1. Analyze the commits listed above, focusing on their subjects and likely content based on the diff.
  329. 2. Identify commits whose messages are unclear, too long, lack a type prefix, are poorly formatted, or don't adequately explain the change.
  330. 3. For **each** commit you identify for rewording, output a block EXACTLY in the following format:
  331. ```text
  332. REWORD: <short_hash_to_reword>
  333. NEW_MESSAGE:
  334. <Generated Subject Line Adhering to Conventions>
  335. <Generated Body Line 1 Adhering to Conventions>
  336. <Generated Body Line 2 Adhering to Conventions>
  337. ...
  338. <Generated Body Last Line Adhering to Conventions>
  339. END_MESSAGE
  340. ```
  341. * Replace `<short_hash_to_reword>` with the short hash from the commit list.
  342. * Replace `<Generated Subject Line...>` with the new subject line you generate.
  343. * 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.
  344. * The `END_MESSAGE` line marks the end of the message for one commit.
  345. 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.
  346. Now, analyze the provided context and generate the reword suggestions with complete new messages.
  347. """
  348. return prompt
  349. def parse_reword_suggestions(ai_response_text, commits_data):
  350. """Parses AI response for REWORD:/NEW_MESSAGE:/END_MESSAGE blocks."""
  351. reword_plan = {} # Use dict: {short_hash: new_message_string}
  352. commit_hashes = {c["short_hash"] for c in commits_data} # Set of valid short hashes
  353. # Regex to find blocks
  354. pattern = re.compile(
  355. r"REWORD:\s*(\w+)\s*NEW_MESSAGE:\s*(.*?)\s*END_MESSAGE",
  356. re.DOTALL | re.IGNORECASE,
  357. )
  358. matches = pattern.findall(ai_response_text)
  359. for match in matches:
  360. reword_hash = match[0].strip()
  361. new_message = match[1].strip() # Includes Subject: and body
  362. if reword_hash in commit_hashes:
  363. reword_plan[reword_hash] = new_message
  364. logging.debug(
  365. f"Parsed reword suggestion for {reword_hash}:\n{new_message[:100]}..."
  366. )
  367. else:
  368. logging.warning(
  369. f"Ignoring invalid reword suggestion (hash {reword_hash} not in range)."
  370. )
  371. return reword_plan
  372. # --- request_files_from_user function remains the same ---
  373. def request_files_from_user(requested_files_str, commits_in_range):
  374. """
  375. Parses AI request string "REQUEST_FILES: [hash:path, ...]", verifies hashes,
  376. asks user permission, fetches file contents, and returns formatted context.
  377. """
  378. file_requests = []
  379. try:
  380. content_match = re.search(
  381. r"REQUEST_FILES:\s*\[(.*)\]", requested_files_str, re.IGNORECASE | re.DOTALL
  382. )
  383. if not content_match:
  384. logging.warning("Could not parse file request format from AI response.")
  385. return None, None
  386. items_str = content_match.group(1).strip()
  387. if not items_str:
  388. logging.info("AI requested files but the list was empty.")
  389. return None, None
  390. items = [item.strip() for item in items_str.split(",") if item.strip()]
  391. commit_hash_map = {c.split()[0]: c.split()[0] for c in commits_in_range}
  392. for item in items:
  393. if ":" not in item:
  394. logging.warning(
  395. f"Invalid format in requested file item (missing ':'): {item}"
  396. )
  397. continue
  398. commit_hash, file_path = item.split(":", 1)
  399. commit_hash = commit_hash.strip()
  400. file_path = file_path.strip()
  401. if commit_hash not in commit_hash_map:
  402. logging.warning(
  403. f"AI requested file for unknown/out-of-range commit hash '{commit_hash}'. Skipping."
  404. )
  405. continue
  406. file_requests.append({"hash": commit_hash, "path": file_path})
  407. except Exception as e:
  408. logging.error(f"Error parsing requested files string: {e}")
  409. return None, None
  410. if not file_requests:
  411. logging.info("No valid file requests found after parsing AI response.")
  412. return None, None
  413. print("\n----------------------------------------")
  414. print("❓ AI Request for File Content ❓")
  415. print("----------------------------------------")
  416. print("The AI needs the content of the following files at specific commits")
  417. print("to provide more accurate fixup suggestions:")
  418. files_to_fetch = []
  419. for i, req in enumerate(file_requests):
  420. print(f" {i + 1}. File: '{req['path']}' at commit {req['hash']}")
  421. files_to_fetch.append(req)
  422. if not files_to_fetch:
  423. print("\nNo valid files to fetch based on the request.")
  424. return None, None
  425. print("----------------------------------------")
  426. while True:
  427. try:
  428. answer = (
  429. input("Allow fetching these file contents? (yes/no): ").lower().strip()
  430. )
  431. except EOFError:
  432. logging.warning("Input stream closed. Assuming 'no'.")
  433. answer = "no"
  434. if answer == "yes":
  435. logging.info("User approved fetching file content.")
  436. fetched_content_list = []
  437. for req in files_to_fetch:
  438. content = get_file_content_at_commit(req["hash"], req["path"])
  439. if content is not None:
  440. fetched_content_list.append(
  441. f"--- Content of '{req['path']}' at commit {req['hash']} ---\n"
  442. f"```\n{content}\n```\n"
  443. f"--- End Content for {req['path']} at {req['hash']} ---"
  444. )
  445. else:
  446. fetched_content_list.append(
  447. f"--- Could not fetch content of '{req['path']}' at commit {req['hash']} ---"
  448. )
  449. return "\n\n".join(fetched_content_list), requested_files_str
  450. elif answer == "no":
  451. logging.info("User denied fetching file content.")
  452. return None, requested_files_str
  453. else:
  454. print("Please answer 'yes' or 'no'.")
  455. # --- Automatic Rebase Logic ---
  456. # --- Fixup Specific ---
  457. def create_fixup_sequence_editor_script(script_path, fixup_plan):
  458. """Creates the python script to be used by GIT_SEQUENCE_EDITOR."""
  459. # Create a set of hashes that need to be fixed up
  460. fixups_to_apply = {pair["fixup"] for pair in fixup_plan}
  461. script_content = f"""#!/usr/bin/env python3
  462. import sys
  463. import logging
  464. import re
  465. import os
  466. # Define log file path relative to the script itself
  467. log_file = __file__ + ".log"
  468. # Setup logging within the editor script to write to the log file
  469. logging.basicConfig(filename=log_file, filemode='w', level=logging.WARN, format="%(asctime)s - %(levelname)s: %(message)s")
  470. todo_file_path = sys.argv[1]
  471. logging.info(f"GIT_SEQUENCE_EDITOR script started for: {{todo_file_path}}")
  472. # Hashes that should be changed to 'fixup'
  473. fixups_to_apply = {fixups_to_apply!r}
  474. logging.info(f"Applying fixups for hashes: {{fixups_to_apply}}")
  475. new_lines = []
  476. try:
  477. with open(todo_file_path, 'r', encoding='utf-8') as f:
  478. lines = f.readlines()
  479. for line in lines:
  480. stripped_line = line.strip()
  481. # Skip comments and blank lines
  482. if not stripped_line or stripped_line.startswith('#'):
  483. new_lines.append(line)
  484. continue
  485. # Use regex for more robust parsing of todo lines (action hash ...)
  486. match = re.match(r"^(\w+)\s+([0-9a-fA-F]+)(.*)", stripped_line)
  487. if match:
  488. action = match.group(1).lower()
  489. commit_hash = match.group(2)
  490. rest_of_line = match.group(3)
  491. # Check if this commit should be fixed up
  492. if commit_hash in fixups_to_apply and action == 'pick':
  493. logging.info(f"Changing 'pick {{commit_hash}}' to 'fixup {{commit_hash}}'")
  494. # Replace 'pick' with 'fixup', preserving the rest of the line
  495. new_line = f'f {{commit_hash}}{{rest_of_line}}\\n'
  496. new_lines.append(new_line)
  497. else:
  498. # Keep the original line
  499. new_lines.append(line)
  500. else:
  501. # Keep lines that don't look like standard todo lines
  502. logging.warning(f"Could not parse todo line: {{stripped_line}}")
  503. new_lines.append(line)
  504. logging.info(f"Writing {{len(new_lines)}} lines back to {{todo_file_path}}")
  505. with open(todo_file_path, 'w', encoding='utf-8') as f:
  506. f.writelines(new_lines)
  507. logging.info("GIT_SEQUENCE_EDITOR script finished successfully.")
  508. sys.exit(0) # Explicitly exit successfully
  509. except Exception as e:
  510. logging.error(f"Error in GIT_SEQUENCE_EDITOR script: {{e}}", exc_info=True)
  511. sys.exit(1) # Exit with error code
  512. """
  513. try:
  514. with open(script_path, "w", encoding="utf-8") as f:
  515. f.write(script_content)
  516. # Make the script executable (important on Linux/macOS)
  517. os.chmod(script_path, 0o755)
  518. logging.info(f"Created GIT_SEQUENCE_EDITOR script: {script_path}")
  519. return True
  520. except Exception as e:
  521. logging.error(f"Failed to create GIT_SEQUENCE_EDITOR script: {e}")
  522. return False
  523. def attempt_auto_fixup(merge_base, fixup_plan, temp_dir_base):
  524. """Attempts to perform the rebase automatically applying fixups."""
  525. if not fixup_plan:
  526. logging.info("No fixup suggestions provided by AI. Skipping auto-rebase.")
  527. return True # Nothing to do, considered success
  528. # Use a temporary directory (passed in) to hold the script and its log
  529. # Ensure sub-directory for fixup exists
  530. fixup_temp_dir = os.path.join(temp_dir_base, "fixup")
  531. os.makedirs(fixup_temp_dir, exist_ok=True)
  532. editor_script_path = os.path.join(fixup_temp_dir, "fixup_sequence_editor.py")
  533. editor_log_path = editor_script_path + ".log" # Define log path early
  534. logging.debug(f"Fixup temporary directory: {fixup_temp_dir}")
  535. logging.debug(f"Fixup editor script path: {editor_script_path}")
  536. try:
  537. if not create_fixup_sequence_editor_script(editor_script_path, fixup_plan):
  538. return False # Failed to create script
  539. # Prepare environment for the git command
  540. rebase_env = os.environ.copy()
  541. rebase_env["GIT_SEQUENCE_EDITOR"] = editor_script_path
  542. # Prevent Git from opening a standard editor for messages etc.
  543. # 'true' simply exits successfully, accepting default messages
  544. rebase_env["GIT_EDITOR"] = "true"
  545. print("\nAttempting automatic rebase with suggested fixups...")
  546. logging.info(f"Running: git rebase -i {merge_base}")
  547. # Run rebase non-interactively, check=False to handle failures manually
  548. rebase_result = run_git_command(
  549. ["rebase", "-i", merge_base],
  550. check=False, # Don't raise exception on failure, check exit code
  551. capture_output=True, # Capture output to see potential errors
  552. env=rebase_env,
  553. )
  554. # Check the result (run_git_command returns None on CalledProcessError)
  555. if rebase_result is not None:
  556. # Command finished, exit code was likely 0 (success)
  557. print("✅ Automatic fixup rebase completed successfully.")
  558. logging.info("Automatic fixup rebase seems successful.")
  559. return True
  560. else:
  561. # Command failed (non-zero exit code, run_git_command returned None)
  562. print("\n❌ Automatic fixup rebase failed.")
  563. print(
  564. " This likely means merge conflicts occurred or another rebase error happened."
  565. )
  566. logging.warning("Automatic fixup rebase failed. Aborting...")
  567. # Attempt to abort the failed rebase
  568. print(" Attempting to abort the failed rebase (`git rebase --abort`)...")
  569. # Run abort without capturing output, just check success/failure
  570. abort_result = run_git_command(
  571. ["rebase", "--abort"], check=False, capture_output=False
  572. )
  573. # run_git_command returns None on failure (CalledProcessError)
  574. if abort_result is not None:
  575. print(
  576. " Rebase aborted successfully. Your branch is back to its original state."
  577. )
  578. logging.info("Failed rebase aborted successfully.")
  579. else:
  580. print(" ⚠️ Failed to automatically abort the rebase.")
  581. print(" Please run `git rebase --abort` manually to clean up.")
  582. logging.error("Failed to automatically abort the rebase.")
  583. return False
  584. except Exception as e:
  585. logging.error(
  586. f"An unexpected error occurred during auto-fixup attempt: {e}",
  587. exc_info=True,
  588. )
  589. # Might need manual cleanup here too
  590. print("\n❌ An unexpected error occurred during the automatic fixup attempt.")
  591. print(
  592. " You may need to manually check your Git status and potentially run `git rebase --abort`."
  593. )
  594. return False
  595. finally:
  596. # Determine if rebase failed *before* potential cleanup errors
  597. # Note: rebase_result is defined in the outer scope of the try block
  598. rebase_failed = "rebase_result" in locals() and rebase_result is None
  599. # Check if we need to display the editor script log
  600. verbose_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
  601. if (rebase_failed or verbose_logging) and os.path.exists(editor_log_path):
  602. try:
  603. with open(editor_log_path, "r", encoding="utf-8") as log_f:
  604. log_content = log_f.read()
  605. if log_content:
  606. print("\n--- Rebase Editor Script Log ---")
  607. print(log_content.strip())
  608. print("--- End Log ---")
  609. else:
  610. # Only log if verbose, otherwise it's just noise
  611. if verbose_logging:
  612. logging.debug(
  613. f"Rebase editor script log file was empty: {editor_log_path}"
  614. )
  615. except Exception as log_e:
  616. logging.warning(
  617. f"Could not read rebase editor script log file {editor_log_path}: {log_e}"
  618. )
  619. # Clean up the temporary directory and its contents
  620. if temp_dir and os.path.exists(temp_dir):
  621. try:
  622. if os.path.exists(editor_log_path):
  623. os.remove(editor_log_path)
  624. if os.path.exists(editor_script_path):
  625. os.remove(editor_script_path)
  626. os.rmdir(temp_dir)
  627. logging.debug(f"Cleaned up temporary directory: {temp_dir}")
  628. except OSError as e:
  629. logging.warning(
  630. f"Could not completely remove temporary directory {fixup_temp_dir}: {e}"
  631. )
  632. # Do not remove the base temp_dir here, it's needed for reword
  633. # --- Reword Specific ---
  634. def create_reword_sequence_editor_script(script_path, reword_plan):
  635. """Creates the python script for GIT_SEQUENCE_EDITOR (changes pick to reword)."""
  636. hashes_to_reword = set(reword_plan.keys())
  637. script_content = f"""#!/usr/bin/env python3
  638. import sys
  639. import logging
  640. import re
  641. import os
  642. # Define log file path relative to the script itself
  643. log_file = __file__ + ".log"
  644. # Setup logging within the editor script to write to the log file
  645. logging.basicConfig(filename=log_file, filemode='w', level=logging.WARN, format="%(asctime)s - %(levelname)s: %(message)s")
  646. todo_file_path = sys.argv[1]
  647. logging.info(f"GIT_SEQUENCE_EDITOR (reword) script started for: {{todo_file_path}}")
  648. hashes_to_reword = {hashes_to_reword!r}
  649. logging.info(f"Applying rewording for hashes: {{hashes_to_reword}}")
  650. new_lines = []
  651. try:
  652. with open(todo_file_path, 'r', encoding='utf-8') as f:
  653. lines = f.readlines()
  654. for line in lines:
  655. stripped_line = line.strip()
  656. if not stripped_line or stripped_line.startswith('#'):
  657. new_lines.append(line)
  658. continue
  659. match = re.match(r"^(\w+)\s+([0-9a-fA-F]+)(.*)", stripped_line)
  660. if match:
  661. action = match.group(1).lower()
  662. commit_hash = match.group(2)
  663. rest_of_line = match.group(3)
  664. if commit_hash in hashes_to_reword and action == 'pick':
  665. logging.info(f"Changing 'pick {{commit_hash}}' to 'reword {{commit_hash}}'")
  666. new_line = f'r {{commit_hash}}{{rest_of_line}}\n' # Use 'r' for reword
  667. new_lines.append(new_line)
  668. else:
  669. new_lines.append(line)
  670. else:
  671. logging.warning(f"Could not parse todo line: {{stripped_line}}")
  672. new_lines.append(line)
  673. logging.info(f"Writing {{len(new_lines)}} lines back to {{todo_file_path}}")
  674. with open(todo_file_path, 'w', encoding='utf-8') as f:
  675. f.writelines(new_lines)
  676. logging.info("GIT_SEQUENCE_EDITOR (reword) script finished successfully.")
  677. sys.exit(0)
  678. except Exception as e:
  679. logging.error(f"Error in GIT_SEQUENCE_EDITOR (reword) script: {{e}}", exc_info=True)
  680. sys.exit(1)
  681. """
  682. try:
  683. with open(script_path, "w", encoding="utf-8") as f:
  684. f.write(script_content)
  685. os.chmod(script_path, 0o755)
  686. logging.info(f"Created GIT_SEQUENCE_EDITOR (reword) script: {script_path}")
  687. return True
  688. except Exception as e:
  689. logging.error(f"Failed to create GIT_SEQUENCE_EDITOR (reword) script: {e}")
  690. return False
  691. def create_reword_commit_editor_script(script_path):
  692. """Creates the python script for GIT_EDITOR (provides new commit message)."""
  693. # Note: reword_plan_json is a JSON string containing the {hash: new_message} mapping
  694. script_content = f"""#!/usr/bin/env python3
  695. import sys
  696. import logging
  697. import re
  698. import os
  699. import subprocess
  700. import json
  701. # Define log file path relative to the script itself
  702. log_file = __file__ + ".log"
  703. # Setup logging within the editor script to write to the log file
  704. logging.basicConfig(filename=log_file, filemode='w', level=logging.WARN, format="%(asctime)s - %(levelname)s: %(message)s")
  705. commit_msg_file_path = sys.argv[1]
  706. logging.info(f"GIT_EDITOR (reword) script started for commit message file: {{commit_msg_file_path}}")
  707. # The reword plan (hash -> new_message) is passed via environment variable as JSON
  708. reword_plan_json = os.environ.get('GIT_REWORD_PLAN')
  709. if not reword_plan_json:
  710. logging.error("GIT_REWORD_PLAN environment variable not set.")
  711. sys.exit(1)
  712. try:
  713. reword_plan = json.loads(reword_plan_json)
  714. logging.info(f"Loaded reword plan for {{len(reword_plan)}} commits.")
  715. except json.JSONDecodeError as e:
  716. logging.error(f"Failed to decode GIT_REWORD_PLAN JSON: {{e}}")
  717. sys.exit(1)
  718. # --- How to identify the current commit being reworded? ---
  719. # Use `git rev-parse HEAD`? Might work if HEAD points to the commit being edited.
  720. try:
  721. # Use subprocess to run git command to get the full hash of HEAD
  722. result = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True, check=True, encoding='utf-8')
  723. current_full_hash = result.stdout.strip()
  724. logging.info(f"Current HEAD full hash: {{current_full_hash}}")
  725. # Find the corresponding short hash in our plan (keys are short hashes)
  726. current_short_hash = None
  727. for short_h in reword_plan.keys():
  728. # Use git rev-parse to check if short_h resolves to current_full_hash
  729. logging.debug(f"Verifying short hash {{short_h}} against HEAD {{current_full_hash}}...")
  730. try:
  731. verify_result = subprocess.run(['git', 'rev-parse', '--verify', f'{{short_h}}^{{commit}}'], capture_output=True, text=True, check=True, encoding='utf-8')
  732. verified_full_hash = verify_result.stdout.strip()
  733. if verified_full_hash == current_full_hash:
  734. current_short_hash = short_h
  735. logging.info(f"Matched HEAD {{current_full_hash}} to short hash {{current_short_hash}} in plan.")
  736. break
  737. except subprocess.CalledProcessError:
  738. logging.debug(f"Short hash {{short_h}} does not resolve to HEAD.")
  739. continue # Try next short hash in plan
  740. if current_short_hash is None:
  741. logging.warning(f"Could not find a matching commit hash in the reword plan for current HEAD {{current_full_hash}}. Keeping original message.")
  742. sys.exit(0) # Exit successfully to avoid blocking rebase, keep original message
  743. elif current_short_hash and current_short_hash in reword_plan:
  744. new_message = reword_plan[current_short_hash]
  745. logging.info(f"Found new message for commit {{current_short_hash}}.")
  746. # Remove the "Subject: " prefix if present, Git adds its own structure
  747. # Also remove potential leading/trailing whitespace from AI message
  748. new_message_content = re.sub(r"^[Ss]ubject:\s*", "", new_message.strip(), count=1)
  749. logging.info(f"Writing new message to {{commit_msg_file_path}}: {{new_message_content[:100]}}...")
  750. with open(commit_msg_file_path, 'w', encoding='utf-8') as f:
  751. f.write(new_message_content)
  752. logging.info("GIT_EDITOR (reword) script finished successfully for reword.")
  753. sys.exit(0)
  754. else:
  755. # Should not happen if current_short_hash was found, but handle defensively
  756. 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}}). Keeping original message.")
  757. sys.exit(0) # Exit successfully to avoid blocking rebase, keep original message
  758. except subprocess.CalledProcessError as e:
  759. logging.error(f"Failed to run git rev-parse HEAD: {{e}}")
  760. sys.exit(1) # Fail editor script
  761. except Exception as e:
  762. logging.error(f"Error in GIT_EDITOR (reword) script: {{e}}", exc_info=True)
  763. sys.exit(1) # Exit with error code
  764. """
  765. try:
  766. with open(script_path, "w", encoding="utf-8") as f:
  767. f.write(script_content)
  768. os.chmod(script_path, 0o755)
  769. logging.info(f"Created GIT_EDITOR (reword) script: {script_path}")
  770. return True
  771. except Exception as e:
  772. logging.error(f"Failed to create GIT_EDITOR (reword) script: {e}")
  773. return False
  774. def attempt_auto_reword(merge_base, reword_plan, temp_dir_base):
  775. """Attempts to perform the rebase automatically applying rewording."""
  776. if not reword_plan:
  777. logging.info("No reword suggestions provided by AI. Skipping auto-reword.")
  778. return True
  779. # Use a temporary directory (passed in) to hold the scripts
  780. reword_temp_dir = os.path.join(temp_dir_base, "reword")
  781. os.makedirs(reword_temp_dir, exist_ok=True)
  782. seq_editor_script_path = os.path.join(reword_temp_dir, "reword_sequence_editor.py")
  783. commit_editor_script_path = os.path.join(reword_temp_dir, "reword_commit_editor.py")
  784. seq_log_path = seq_editor_script_path + ".log"
  785. commit_log_path = commit_editor_script_path + ".log"
  786. logging.debug(f"Reword temporary directory: {reword_temp_dir}")
  787. try:
  788. # Create the sequence editor script (changes pick -> reword)
  789. if not create_reword_sequence_editor_script(
  790. seq_editor_script_path, reword_plan
  791. ):
  792. return False
  793. # Create the commit editor script (provides new message)
  794. if not create_reword_commit_editor_script(commit_editor_script_path):
  795. return False
  796. # Prepare environment for the git command
  797. rebase_env = os.environ.copy()
  798. rebase_env["GIT_SEQUENCE_EDITOR"] = seq_editor_script_path
  799. rebase_env["GIT_EDITOR"] = commit_editor_script_path
  800. # Pass the plan to the commit editor script via env var as JSON
  801. rebase_env["GIT_REWORD_PLAN"] = json.dumps(reword_plan)
  802. # Prevent Git from opening a standard editor for messages etc. if our script fails
  803. # 'true' simply exits successfully, accepting default messages
  804. # rebase_env["GIT_EDITOR"] = "true" # Overridden by specific script path
  805. print("\nAttempting automatic rebase with suggested rewording...")
  806. logging.info(f"Running: git rebase -i {merge_base}")
  807. rebase_result = run_git_command(
  808. ["rebase", "-i", merge_base],
  809. check=False,
  810. capture_output=False, # Show rebase output directly
  811. env=rebase_env,
  812. )
  813. if rebase_result is not None:
  814. print("✅ Automatic reword rebase completed successfully.")
  815. logging.info("Automatic reword rebase seems successful.")
  816. return True
  817. else:
  818. print("\n❌ Automatic reword rebase failed.")
  819. print(
  820. " This could be due to merge conflicts, script errors, or other rebase issues."
  821. )
  822. logging.warning("Automatic reword rebase failed. Aborting...")
  823. print(" Attempting to abort the failed rebase (`git rebase --abort`)...")
  824. abort_result = run_git_command(
  825. ["rebase", "--abort"], check=False, capture_output=False
  826. )
  827. if abort_result is not None:
  828. print(
  829. " Rebase aborted successfully. Your branch is back to its original state."
  830. )
  831. logging.info("Failed rebase aborted successfully.")
  832. else:
  833. print(" ⚠️ Failed to automatically abort the rebase.")
  834. print(" Please run `git rebase --abort` manually to clean up.")
  835. logging.error("Failed to automatically abort the rebase.")
  836. return False
  837. except Exception as e:
  838. logging.error(
  839. f"An unexpected error occurred during auto-reword attempt: {e}",
  840. exc_info=True,
  841. )
  842. print("\n❌ An unexpected error occurred during the automatic reword attempt.")
  843. print(
  844. " You may need to manually check your Git status and potentially run `git rebase --abort`."
  845. )
  846. return False
  847. finally:
  848. # Display logs if needed
  849. rebase_failed = "rebase_result" in locals() and rebase_result is None
  850. verbose_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
  851. for log_path, script_name in [(seq_log_path, "Sequence Editor"), (commit_log_path, "Commit Editor")]:
  852. if (rebase_failed or verbose_logging) and os.path.exists(log_path):
  853. try:
  854. with open(log_path, "r", encoding="utf-8") as log_f:
  855. log_content = log_f.read()
  856. if log_content:
  857. print(f"\n--- Reword {script_name} Script Log ---")
  858. print(log_content.strip())
  859. print("--- End Log ---")
  860. elif verbose_logging:
  861. logging.debug(f"Reword {script_name} script log file was empty: {log_path}")
  862. except Exception as log_e:
  863. logging.warning(f"Could not read reword {script_name} script log file {log_path}: {log_e}")
  864. # Do not remove the base temp_dir here, cleanup happens in main
  865. # --- Main Execution ---
  866. def main():
  867. """Main function to orchestrate Git analysis, AI interaction, and rebase operations."""
  868. parser = argparse.ArgumentParser(
  869. description="Uses Gemini AI to suggest and automatically attempt Git 'fixup' and 'reword' operations.",
  870. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  871. )
  872. parser.add_argument(
  873. "upstream_ref",
  874. nargs="?",
  875. default="upstream/main",
  876. help="The upstream reference point or commit hash to compare against "
  877. "(e.g., 'origin/main', 'upstream/develop', specific_commit_hash). "
  878. "Ensure this reference exists and is fetched.",
  879. )
  880. parser.add_argument(
  881. "--instruct",
  882. action="store_true",
  883. help="Only show AI suggestions and instructions; disable automatic rebase attempts.",
  884. )
  885. # Add argument to skip reword if desired
  886. parser.add_argument(
  887. "--skip-reword",
  888. action="store_true",
  889. help="Skip the reword suggestion and rebase phase.",
  890. )
  891. parser.add_argument(
  892. "-v", "--verbose", action="store_true", help="Enable verbose debug logging."
  893. )
  894. args = parser.parse_args()
  895. if args.verbose:
  896. logging.getLogger().setLevel(logging.DEBUG)
  897. logging.debug("Verbose logging enabled.")
  898. if not check_git_repository():
  899. logging.error("This script must be run from within a Git repository.")
  900. sys.exit(1)
  901. # Create a single temporary directory for all scripts/logs
  902. temp_dir_base = tempfile.mkdtemp(prefix="git_rebase_ai_")
  903. logging.debug(f"Base temporary directory: {temp_dir_base}")
  904. try: # Wrap main logic in try/finally for temp dir cleanup
  905. current_branch = get_current_branch()
  906. if not current_branch:
  907. logging.error("Could not determine the current Git branch.")
  908. sys.exit(1)
  909. logging.info(f"Current branch: {current_branch}")
  910. upstream_ref = args.upstream_ref
  911. logging.info(f"Comparing against reference: {upstream_ref}")
  912. # --- Safety: Create Backup Branch ---
  913. backup_branch = create_backup_branch(current_branch)
  914. if not backup_branch:
  915. try:
  916. confirm = input(
  917. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  918. ).lower()
  919. except EOFError:
  920. logging.warning("Input stream closed. Aborting.")
  921. confirm = "no"
  922. if confirm != "yes":
  923. logging.info("Aborting.")
  924. sys.exit(1)
  925. else:
  926. logging.warning("Proceeding without a backup branch. Be careful!")
  927. else:
  928. print("-" * 40)
  929. print(f"✅ Backup branch created: {backup_branch}")
  930. print(" If anything goes wrong, you can restore using:")
  931. print(f" git checkout {current_branch}")
  932. print(f" git reset --hard {backup_branch}")
  933. print("-" * 40)
  934. # --- Gather Initial Git Context ---
  935. print("\nGathering initial Git context...")
  936. initial_commit_range, initial_merge_base = get_commit_range(upstream_ref, current_branch)
  937. if not initial_commit_range:
  938. sys.exit(1)
  939. logging.info(f"Initial analysis range: {initial_commit_range} (Merge Base: {initial_merge_base})")
  940. initial_commits_list = get_commits_in_range(initial_commit_range) # Simple list for fixup prompt
  941. initial_commits_data = get_commits_in_range_data(initial_commit_range) # Dict list for reword prompt
  942. if not initial_commits_list:
  943. logging.info(
  944. f"No commits found between '{initial_merge_base}' and '{current_branch}'. Nothing to do."
  945. )
  946. sys.exit(0)
  947. initial_file_structure, initial_changed_files = get_changed_files_in_range(initial_commit_range)
  948. initial_diff = get_diff_in_range(initial_commit_range)
  949. if not initial_diff and not initial_changed_files:
  950. logging.warning(
  951. f"No file changes or diff found between '{initial_merge_base}' and '{current_branch}',"
  952. )
  953. logging.warning("even though commits exist. AI suggestions might be limited.")
  954. # --- AI Interaction - Phase 1: Fixup ---
  955. print("\n--- Phase 1: Fixup Analysis ---")
  956. print("Generating prompt for AI fixup suggestions...")
  957. fixup_prompt = generate_fixup_suggestion_prompt(
  958. initial_commit_range, initial_merge_base, initial_commits_list, initial_file_structure, initial_diff
  959. )
  960. logging.debug("\n--- Fixup AI Prompt Snippet ---")
  961. logging.debug(fixup_prompt[:1000] + "...")
  962. logging.debug("--- End Fixup Prompt Snippet ---\n")
  963. print(f"Sending fixup request to Gemini AI ({MODEL_NAME})...")
  964. fixup_ai_response = ""
  965. fixup_suggestions_text = ""
  966. fixup_plan = []
  967. try:
  968. convo = model.start_chat(history=[])
  969. response = convo.send_message(fixup_prompt)
  970. fixup_ai_response = response.text
  971. # Handle potential file requests for fixup
  972. while "REQUEST_FILES:" in fixup_ai_response.upper():
  973. logging.info("AI requested additional file content for fixup analysis.")
  974. additional_context, original_request = request_files_from_user(
  975. fixup_ai_response, initial_commits_list # Use simple list here
  976. )
  977. if additional_context:
  978. logging.info("Sending fetched file content back to AI for fixup...")
  979. follow_up_prompt = f"""
  980. Okay, here is the content of the files you requested for fixup analysis:
  981. {additional_context}
  982. 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.
  983. """
  984. response = convo.send_message(follow_up_prompt)
  985. fixup_ai_response = response.text
  986. else:
  987. logging.info("Proceeding without providing files for fixup analysis.")
  988. no_files_prompt = f"""
  989. I cannot provide the content for the files you requested ({original_request}).
  990. 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.
  991. """
  992. response = convo.send_message(no_files_prompt)
  993. fixup_ai_response = response.text
  994. break # Exit file request loop
  995. fixup_suggestions_text = fixup_ai_response.strip()
  996. fixup_plan = parse_fixup_suggestions(fixup_suggestions_text, initial_commits_list) # Use simple list
  997. if not fixup_plan:
  998. print("\n💡 AI did not suggest any specific fixup operations.")
  999. else:
  1000. print("\n💡 --- AI Fixup Suggestions --- 💡")
  1001. for i, pair in enumerate(fixup_plan):
  1002. print(f" {i + 1}. Fixup commit `{pair['fixup']}` into `{pair['target']}`")
  1003. print("💡 --- End AI Fixup Suggestions --- 💡")
  1004. except Exception as e:
  1005. logging.error(f"\nAn error occurred during AI fixup interaction: {e}", exc_info=True)
  1006. # Log feedback if possible
  1007. try:
  1008. if response and hasattr(response, "prompt_feedback"): logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1009. if response and hasattr(response, "candidates"):
  1010. for c in response.candidates: logging.error(f"AI Candidate Finish Reason: {c.finish_reason}, Safety: {getattr(c, 'safety_ratings', 'N/A')}")
  1011. except Exception as feedback_e: logging.error(f"Could not log AI feedback: {feedback_e}")
  1012. print("\n❌ Error during AI fixup analysis. Skipping remaining steps.")
  1013. sys.exit(1)
  1014. # --- Automatic Rebase - Phase 1: Fixup ---
  1015. fixup_succeeded = False
  1016. current_merge_base = initial_merge_base # Start with initial merge base
  1017. if fixup_plan and not args.instruct:
  1018. print("\n--- Attempting Automatic Fixup Rebase ---")
  1019. fixup_succeeded = attempt_auto_fixup(initial_merge_base, fixup_plan, temp_dir_base)
  1020. if not fixup_succeeded:
  1021. print("\n" + "=" * 60)
  1022. print("🛠️ MANUAL FIXUP REBASE REQUIRED 🛠️")
  1023. print("=" * 60)
  1024. print("The automatic fixup rebase failed (likely due to conflicts).")
  1025. print("Please perform the fixup rebase manually:")
  1026. print(f" 1. Run: `git rebase -i {initial_merge_base}`")
  1027. print(" 2. In the editor, change 'pick' to 'f' (or 'fixup') for the commits suggested by the AI:")
  1028. print(" ```text")
  1029. print(fixup_suggestions_text if fixup_suggestions_text else " (No specific fixup lines found in AI response)")
  1030. print(" ```")
  1031. print(" 3. Save the editor and resolve any conflicts Git reports.")
  1032. if backup_branch: print(f" 4. Remember backup branch: {backup_branch}")
  1033. print("=" * 60)
  1034. sys.exit(1) # Exit after failed auto fixup
  1035. else:
  1036. print("\n✅ Automatic fixup rebase completed successfully.")
  1037. # Need to re-gather context for reword phase
  1038. print("\nGathering Git context after successful fixup...")
  1039. post_fixup_range, post_fixup_merge_base = get_commit_range(upstream_ref, current_branch)
  1040. if not post_fixup_range:
  1041. logging.error("Could not determine commit range after fixup rebase. Cannot proceed with reword.")
  1042. sys.exit(1)
  1043. # Update merge base in case it changed (unlikely but possible)
  1044. current_merge_base = post_fixup_merge_base
  1045. logging.info(f"Post-fixup analysis range: {post_fixup_range} (Merge Base: {current_merge_base})")
  1046. elif args.instruct and fixup_plan:
  1047. # Don't exit, just note that fixup was skipped automatically
  1048. print("\n--instruct flag used, skipping automatic fixup rebase.")
  1049. fixup_succeeded = False # Treat as not succeeded for reword context gathering
  1050. else:
  1051. # No fixups suggested or attempted automatically
  1052. fixup_succeeded = True # Treat as succeeded for proceeding to reword
  1053. print("\nNo automatic fixup rebase needed or attempted.")
  1054. # --- AI Interaction - Phase 2: Reword (if fixup succeeded or was skipped) ---
  1055. reword_plan = []
  1056. reword_suggestions_text = ""
  1057. post_fixup_commits_data = [] # Initialize here
  1058. if args.skip_reword:
  1059. print("\n--skip-reword flag used. Skipping reword phase.")
  1060. elif fixup_succeeded: # Only proceed if fixup was successful OR wasn't needed
  1061. print("\n--- Phase 2: Reword Analysis ---")
  1062. # Use post-fixup context if fixup was done, otherwise initial context
  1063. if fixup_plan and not args.instruct: # Check if fixup was actually performed
  1064. logging.info("Using post-fixup context for reword analysis.")
  1065. # Re-gather commits and diff based on the *new* range
  1066. post_fixup_commits_data = get_commits_in_range_data(post_fixup_range)
  1067. post_fixup_diff = get_diff_in_range(post_fixup_range)
  1068. if not post_fixup_commits_data:
  1069. print("\nNo commits found after fixup rebase. Skipping reword.")
  1070. # Don't exit, just finish gracefully
  1071. else:
  1072. reword_prompt = generate_reword_suggestion_prompt(
  1073. post_fixup_range, current_merge_base, post_fixup_commits_data, post_fixup_diff
  1074. )
  1075. else: # No fixup done (or --instruct), use initial context
  1076. logging.info("Using initial context for reword analysis.")
  1077. post_fixup_commits_data = initial_commits_data # Use initial data
  1078. reword_prompt = generate_reword_suggestion_prompt(
  1079. initial_commit_range, current_merge_base, initial_commits_data, initial_diff
  1080. )
  1081. if post_fixup_commits_data: # Only ask AI if there are commits
  1082. logging.debug("\n--- Reword AI Prompt Snippet ---")
  1083. logging.debug(reword_prompt[:1000] + "...")
  1084. logging.debug("--- End Reword Prompt Snippet ---\n")
  1085. print(f"Sending reword request to Gemini AI ({MODEL_NAME})...")
  1086. try:
  1087. # Use the same conversation or start a new one? Let's use the same.
  1088. # If convo doesn't exist (e.g., error during fixup AI), start new
  1089. if 'convo' not in locals():
  1090. convo = model.start_chat(history=[])
  1091. response = convo.send_message(reword_prompt)
  1092. reword_ai_response = response.text
  1093. reword_suggestions_text = reword_ai_response.strip()
  1094. # Parse reword suggestions using the appropriate commit data
  1095. reword_plan = parse_reword_suggestions(reword_suggestions_text, post_fixup_commits_data)
  1096. if not reword_plan:
  1097. print("\n💡 AI did not suggest any specific reword operations.")
  1098. else:
  1099. print("\n💡 --- AI Reword Suggestions --- 💡")
  1100. for i, (hash_key, msg) in enumerate(reword_plan.items()):
  1101. print(f" {i + 1}. Reword commit `{hash_key}` with new message:")
  1102. indented_msg = " " + msg.replace("\n", "\n ")
  1103. print(indented_msg)
  1104. print("-" * 20)
  1105. print("💡 --- End AI Reword Suggestions --- 💡")
  1106. except Exception as e:
  1107. logging.error(f"\nAn error occurred during AI reword interaction: {e}", exc_info=True)
  1108. try: # Log feedback
  1109. if response and hasattr(response, "prompt_feedback"): logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1110. if response and hasattr(response, "candidates"):
  1111. for c in response.candidates: logging.error(f"AI Candidate Finish Reason: {c.finish_reason}, Safety: {getattr(c, 'safety_ratings', 'N/A')}")
  1112. except Exception as feedback_e: logging.error(f"Could not log AI feedback: {feedback_e}")
  1113. print("\n❌ Error during AI reword analysis. Skipping automatic reword.")
  1114. # Don't exit, allow manual instructions if needed
  1115. # --- Automatic Rebase - Phase 2: Reword ---
  1116. reword_succeeded = False
  1117. if reword_plan and not args.instruct and not args.skip_reword:
  1118. print("\n--- Attempting Automatic Reword Rebase ---")
  1119. # Use the potentially updated current_merge_base
  1120. reword_succeeded = attempt_auto_reword(current_merge_base, reword_plan, temp_dir_base)
  1121. if not reword_succeeded:
  1122. print("\n" + "=" * 60)
  1123. print("🛠️ MANUAL REWORD REBASE REQUIRED 🛠️")
  1124. print("=" * 60)
  1125. print("The automatic reword rebase failed.")
  1126. print("Please perform the reword rebase manually:")
  1127. print(f" 1. Run: `git rebase -i {current_merge_base}`")
  1128. print(" 2. In the editor, change 'pick' to 'r' (or 'reword') for the commits suggested by the AI:")
  1129. # Show the *latest* reword suggestions
  1130. print(" ```text")
  1131. print(reword_suggestions_text if reword_suggestions_text else " (No specific reword suggestions found in AI response)")
  1132. print(" ```")
  1133. print(" 3. Save the editor. Git will stop at each commit marked for reword.")
  1134. print(" 4. Manually replace the old commit message with the AI-suggested one.")
  1135. print(" 5. Save the message editor and continue the rebase (`git rebase --continue`).")
  1136. if backup_branch: print(f" 6. Remember backup branch: {backup_branch}")
  1137. print("=" * 60)
  1138. sys.exit(1) # Exit after failed auto reword
  1139. else:
  1140. print("\n✅ Automatic reword rebase completed successfully.")
  1141. elif args.instruct and reword_plan:
  1142. # Don't exit, just note that reword was skipped automatically
  1143. print("\n--instruct flag used, skipping automatic reword rebase.")
  1144. reword_succeeded = False # Treat as not succeeded for final message
  1145. elif not args.skip_reword:
  1146. # No rewording suggested or attempted automatically
  1147. reword_succeeded = True # Treat as succeeded for final message
  1148. print("\nNo automatic reword rebase needed or attempted.")
  1149. # --- Final Instructions (if --instruct was used) ---
  1150. if args.instruct:
  1151. print("\n" + "=" * 60)
  1152. print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
  1153. print("=" * 60)
  1154. if fixup_plan:
  1155. print("--- Fixup Phase ---")
  1156. print("AI suggested the following fixups:")
  1157. for i, pair in enumerate(fixup_plan):
  1158. print(f" - Fixup commit `{pair['fixup']}` into `{pair['target']}`")
  1159. print(f"To apply manually:")
  1160. print(f" 1. Run: `git rebase -i {initial_merge_base}`")
  1161. print(" 2. Change 'pick' to 'f' (or 'fixup') for the suggested commits.")
  1162. print(" 3. Save the editor and resolve conflicts if any.")
  1163. print("-" * 20)
  1164. else:
  1165. print("--- Fixup Phase ---")
  1166. print("No fixup operations were suggested by the AI.")
  1167. print("-" * 20)
  1168. if reword_plan and not args.skip_reword:
  1169. print("--- Reword Phase (After potential manual fixup) ---")
  1170. print("AI suggested the following rewording:")
  1171. # Print parsed plan for clarity
  1172. for i, (hash_key, msg) in enumerate(reword_plan.items()):
  1173. print(f" - Reword commit `{hash_key}` with new message:")
  1174. indented_msg = " " + msg.replace("\n", "\n ")
  1175. print(indented_msg)
  1176. print(f"To apply manually (after completing the fixup rebase if any):")
  1177. # Important: Use the merge base that *would* be correct after fixup
  1178. # If fixup was suggested, the user needs to complete that first.
  1179. # The merge base *should* remain 'initial_merge_base' unless history was drastically altered.
  1180. # Let's use initial_merge_base for simplicity in instructions.
  1181. print(f" 1. Run: `git rebase -i {initial_merge_base}` (or the new base if fixup changed it)")
  1182. print(" 2. Change 'pick' to 'r' (or 'reword') for the suggested commits.")
  1183. print(" 3. Save the editor. Git will stop at each commit.")
  1184. print(" 4. Replace the old message with the AI suggestion.")
  1185. print(" 5. Continue the rebase (`git rebase --continue`).")
  1186. print("-" * 20)
  1187. elif not args.skip_reword:
  1188. print("--- Reword Phase ---")
  1189. print("No reword operations were suggested by the AI.")
  1190. print("-" * 20)
  1191. if backup_branch:
  1192. print(f"Remember backup branch: {backup_branch}")
  1193. print("=" * 60)
  1194. # --- Final Status Message ---
  1195. print("\n--- Summary ---")
  1196. if fixup_plan and not args.instruct:
  1197. print(f"Fixup rebase attempt: {'Success' if fixup_succeeded else 'Failed/Skipped'}")
  1198. if reword_plan and not args.instruct and not args.skip_reword:
  1199. print(f"Reword rebase attempt: {'Success' if reword_succeeded else 'Failed/Skipped'}")
  1200. if (not fixup_plan or fixup_succeeded) and (args.skip_reword or not reword_plan or reword_succeeded):
  1201. print("\nBranch history has been potentially modified.")
  1202. else:
  1203. print("\nBranch history may be unchanged or in an intermediate state due to manual steps required.")
  1204. if backup_branch:
  1205. print(f"Backup branch '{backup_branch}' still exists if needed.")
  1206. finally:
  1207. # Clean up the base temporary directory and its contents
  1208. if temp_dir_base and os.path.exists(temp_dir_base):
  1209. try:
  1210. import shutil
  1211. shutil.rmtree(temp_dir_base)
  1212. logging.debug(f"Cleaned up base temporary directory: {temp_dir_base}")
  1213. except OSError as e:
  1214. logging.warning(
  1215. f"Could not completely remove base temporary directory {temp_dir_base}: {e}"
  1216. )
  1217. # Helper function to get commit data as dict list (needed for reword)
  1218. def get_commits_in_range_data(commit_range):
  1219. """Gets a list of commit data (short_hash, full_hash, subject) in the specified range (oldest first)."""
  1220. # Use --format=%h %H %s to get hashes and subject
  1221. log_output = run_git_command(
  1222. ["log", "--pretty=format:%h %H %s", "--reverse", commit_range]
  1223. )
  1224. commit_data = []
  1225. if log_output is not None:
  1226. lines = log_output.splitlines()
  1227. for line in lines:
  1228. parts = line.split(" ", 2)
  1229. if len(parts) == 3:
  1230. commit_data.append(
  1231. {"short_hash": parts[0], "full_hash": parts[1], "subject": parts[2]}
  1232. )
  1233. else:
  1234. logging.warning(f"Could not parse commit log line: {line}")
  1235. logging.info(f"Found {len(commit_data)} commits in range {commit_range} (for reword).")
  1236. return commit_data
  1237. # Removed duplicated main execution block
  1238. logging.error("Could not determine the current Git branch.")
  1239. sys.exit(1)
  1240. logging.info(f"Current branch: {current_branch}")
  1241. upstream_ref = args.upstream_ref
  1242. logging.info(f"Comparing against reference: {upstream_ref}")
  1243. # --- Safety: Create Backup Branch ---
  1244. # Always create backup, especially if attempting auto-rebase
  1245. backup_branch = create_backup_branch(current_branch)
  1246. if not backup_branch:
  1247. try:
  1248. confirm = input(
  1249. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  1250. ).lower()
  1251. except EOFError:
  1252. logging.warning("Input stream closed. Aborting.")
  1253. confirm = "no"
  1254. if confirm != "yes":
  1255. logging.info("Aborting.")
  1256. sys.exit(1)
  1257. else:
  1258. logging.warning("Proceeding without a backup branch. Be careful!")
  1259. else:
  1260. print("-" * 40)
  1261. print(f"✅ Backup branch created: {backup_branch}")
  1262. print(" If anything goes wrong, you can restore using:")
  1263. print(f" git checkout {current_branch}")
  1264. print(f" git reset --hard {backup_branch}")
  1265. print("-" * 40)
  1266. # --- Gather Git Context ---
  1267. print("\nGathering Git context...")
  1268. commit_range, merge_base = get_commit_range(upstream_ref, current_branch)
  1269. if not commit_range:
  1270. sys.exit(1)
  1271. try: # Wrap main logic in try/finally for temp dir cleanup
  1272. current_branch = get_current_branch()
  1273. if not current_branch:
  1274. logging.error("Could not determine the current Git branch.")
  1275. sys.exit(1)
  1276. logging.info(f"Current branch: {current_branch}")
  1277. upstream_ref = args.upstream_ref
  1278. logging.info(f"Comparing against reference: {upstream_ref}")
  1279. # --- Safety: Create Backup Branch ---
  1280. backup_branch = create_backup_branch(current_branch)
  1281. if not backup_branch:
  1282. try:
  1283. confirm = input(
  1284. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  1285. ).lower()
  1286. except EOFError:
  1287. logging.warning("Input stream closed. Aborting.")
  1288. confirm = "no"
  1289. if confirm != "yes":
  1290. logging.info("Aborting.")
  1291. sys.exit(1)
  1292. else:
  1293. logging.warning("Proceeding without a backup branch. Be careful!")
  1294. else:
  1295. print("-" * 40)
  1296. print(f"✅ Backup branch created: {backup_branch}")
  1297. print(" If anything goes wrong, you can restore using:")
  1298. print(f" git checkout {current_branch}")
  1299. print(f" git reset --hard {backup_branch}")
  1300. print("-" * 40)
  1301. # --- Gather Initial Git Context ---
  1302. print("\nGathering initial Git context...")
  1303. initial_commit_range, initial_merge_base = get_commit_range(upstream_ref, current_branch)
  1304. if not initial_commit_range:
  1305. sys.exit(1)
  1306. logging.info(f"Initial analysis range: {initial_commit_range} (Merge Base: {initial_merge_base})")
  1307. initial_commits_list = get_commits_in_range(initial_commit_range) # Simple list for fixup prompt
  1308. initial_commits_data = get_commits_in_range_data(initial_commit_range) # Dict list for reword prompt
  1309. if not initial_commits_list:
  1310. logging.info(
  1311. f"No commits found between '{initial_merge_base}' and '{current_branch}'. Nothing to do."
  1312. )
  1313. sys.exit(0)
  1314. initial_file_structure, initial_changed_files = get_changed_files_in_range(initial_commit_range)
  1315. initial_diff = get_diff_in_range(initial_commit_range)
  1316. if not initial_diff and not initial_changed_files:
  1317. logging.warning(
  1318. f"No file changes or diff found between '{initial_merge_base}' and '{current_branch}',"
  1319. )
  1320. logging.warning("even though commits exist. AI suggestions might be limited.")
  1321. # --- AI Interaction - Phase 1: Fixup ---
  1322. print("\n--- Phase 1: Fixup Analysis ---")
  1323. print("Generating prompt for AI fixup suggestions...")
  1324. fixup_prompt = generate_fixup_suggestion_prompt(
  1325. initial_commit_range, initial_merge_base, initial_commits_list, initial_file_structure, initial_diff
  1326. )
  1327. logging.debug("\n--- Fixup AI Prompt Snippet ---")
  1328. logging.debug(fixup_prompt[:1000] + "...")
  1329. logging.debug("--- End Fixup Prompt Snippet ---\n")
  1330. print(f"Sending fixup request to Gemini AI ({MODEL_NAME})...")
  1331. fixup_ai_response = ""
  1332. fixup_suggestions_text = ""
  1333. fixup_plan = []
  1334. try:
  1335. convo = model.start_chat(history=[])
  1336. response = convo.send_message(fixup_prompt)
  1337. fixup_ai_response = response.text
  1338. # Handle potential file requests for fixup
  1339. while "REQUEST_FILES:" in fixup_ai_response.upper():
  1340. logging.info("AI requested additional file content for fixup analysis.")
  1341. additional_context, original_request = request_files_from_user(
  1342. fixup_ai_response, initial_commits_list # Use simple list here
  1343. )
  1344. if additional_context:
  1345. logging.info("Sending fetched file content back to AI for fixup...")
  1346. follow_up_prompt = f"""
  1347. Okay, here is the content of the files you requested for fixup analysis:
  1348. {additional_context}
  1349. 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.
  1350. """
  1351. response = convo.send_message(follow_up_prompt)
  1352. fixup_ai_response = response.text
  1353. else:
  1354. logging.info("Proceeding without providing files for fixup analysis.")
  1355. no_files_prompt = f"""
  1356. I cannot provide the content for the files you requested ({original_request}).
  1357. 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.
  1358. """
  1359. response = convo.send_message(no_files_prompt)
  1360. fixup_ai_response = response.text
  1361. break # Exit file request loop
  1362. fixup_suggestions_text = fixup_ai_response.strip()
  1363. fixup_plan = parse_fixup_suggestions(fixup_suggestions_text, initial_commits_list) # Use simple list
  1364. if not fixup_plan:
  1365. print("\n💡 AI did not suggest any specific fixup operations.")
  1366. else:
  1367. print("\n💡 --- AI Fixup Suggestions --- 💡")
  1368. for i, pair in enumerate(fixup_plan):
  1369. print(f" {i + 1}. Fixup commit `{pair['fixup']}` into `{pair['target']}`")
  1370. print("💡 --- End AI Fixup Suggestions --- 💡")
  1371. except Exception as e:
  1372. logging.error(f"\nAn error occurred during AI fixup interaction: {e}", exc_info=True)
  1373. # Log feedback if possible
  1374. try:
  1375. if response and hasattr(response, "prompt_feedback"): logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1376. if response and hasattr(response, "candidates"):
  1377. for c in response.candidates: logging.error(f"AI Candidate Finish Reason: {c.finish_reason}, Safety: {getattr(c, 'safety_ratings', 'N/A')}")
  1378. except Exception as feedback_e: logging.error(f"Could not log AI feedback: {feedback_e}")
  1379. print("\n❌ Error during AI fixup analysis. Skipping remaining steps.")
  1380. sys.exit(1)
  1381. # --- Automatic Rebase - Phase 1: Fixup ---
  1382. fixup_succeeded = False
  1383. current_merge_base = initial_merge_base # Start with initial merge base
  1384. if fixup_plan and not args.instruct:
  1385. print("\n--- Attempting Automatic Fixup Rebase ---")
  1386. fixup_succeeded = attempt_auto_fixup(initial_merge_base, fixup_plan, temp_dir_base)
  1387. if not fixup_succeeded:
  1388. print("\n" + "=" * 60)
  1389. print("🛠️ MANUAL FIXUP REBASE REQUIRED 🛠️")
  1390. print("=" * 60)
  1391. print("The automatic fixup rebase failed (likely due to conflicts).")
  1392. print("Please perform the fixup rebase manually:")
  1393. print(f" 1. Run: `git rebase -i {initial_merge_base}`")
  1394. print(" 2. In the editor, change 'pick' to 'f' (or 'fixup') for the commits suggested by the AI:")
  1395. print(" ```text")
  1396. print(fixup_suggestions_text if fixup_suggestions_text else " (No specific fixup lines found in AI response)")
  1397. print(" ```")
  1398. print(" 3. Save the editor and resolve any conflicts Git reports.")
  1399. if backup_branch: print(f" 4. Remember backup branch: {backup_branch}")
  1400. print("=" * 60)
  1401. sys.exit(1) # Exit after failed auto fixup
  1402. else:
  1403. print("\n✅ Automatic fixup rebase completed successfully.")
  1404. # Need to re-gather context for reword phase
  1405. print("\nGathering Git context after successful fixup...")
  1406. post_fixup_range, post_fixup_merge_base = get_commit_range(upstream_ref, current_branch)
  1407. if not post_fixup_range:
  1408. logging.error("Could not determine commit range after fixup rebase. Cannot proceed with reword.")
  1409. sys.exit(1)
  1410. # Update merge base in case it changed (unlikely but possible)
  1411. current_merge_base = post_fixup_merge_base
  1412. logging.info(f"Post-fixup analysis range: {post_fixup_range} (Merge Base: {current_merge_base})")
  1413. elif args.instruct and fixup_plan:
  1414. # Don't exit, just note that fixup was skipped automatically
  1415. print("\n--instruct flag used, skipping automatic fixup rebase.")
  1416. fixup_succeeded = False # Treat as not succeeded for reword context gathering
  1417. else:
  1418. # No fixups suggested or attempted automatically
  1419. fixup_succeeded = True # Treat as succeeded for proceeding to reword
  1420. print("\nNo automatic fixup rebase needed or attempted.")
  1421. # --- AI Interaction - Phase 2: Reword (if fixup succeeded or was skipped) ---
  1422. reword_plan = []
  1423. reword_suggestions_text = ""
  1424. post_fixup_commits_data = [] # Initialize here
  1425. if args.skip_reword:
  1426. print("\n--skip-reword flag used. Skipping reword phase.")
  1427. elif fixup_succeeded: # Only proceed if fixup was successful OR wasn't needed
  1428. print("\n--- Phase 2: Reword Analysis ---")
  1429. # Use post-fixup context if fixup was done, otherwise initial context
  1430. if fixup_plan and not args.instruct: # Check if fixup was actually performed
  1431. logging.info("Using post-fixup context for reword analysis.")
  1432. # Re-gather commits and diff based on the *new* range
  1433. post_fixup_commits_data = get_commits_in_range_data(post_fixup_range)
  1434. post_fixup_diff = get_diff_in_range(post_fixup_range)
  1435. if not post_fixup_commits_data:
  1436. print("\nNo commits found after fixup rebase. Skipping reword.")
  1437. # Don't exit, just finish gracefully
  1438. else:
  1439. reword_prompt = generate_reword_suggestion_prompt(
  1440. post_fixup_range, current_merge_base, post_fixup_commits_data, post_fixup_diff
  1441. )
  1442. else: # No fixup done (or --instruct), use initial context
  1443. logging.info("Using initial context for reword analysis.")
  1444. post_fixup_commits_data = initial_commits_data # Use initial data
  1445. reword_prompt = generate_reword_suggestion_prompt(
  1446. initial_commit_range, current_merge_base, initial_commits_data, initial_diff
  1447. )
  1448. if post_fixup_commits_data: # Only ask AI if there are commits
  1449. logging.debug("\n--- Reword AI Prompt Snippet ---")
  1450. logging.debug(reword_prompt[:1000] + "...")
  1451. logging.debug("--- End Reword Prompt Snippet ---\n")
  1452. print(f"Sending reword request to Gemini AI ({MODEL_NAME})...")
  1453. try:
  1454. # Use the same conversation or start a new one? Let's use the same.
  1455. # If convo doesn't exist (e.g., error during fixup AI), start new
  1456. if 'convo' not in locals():
  1457. convo = model.start_chat(history=[])
  1458. response = convo.send_message(reword_prompt)
  1459. reword_ai_response = response.text
  1460. reword_suggestions_text = reword_ai_response.strip()
  1461. # Parse reword suggestions using the appropriate commit data
  1462. reword_plan = parse_reword_suggestions(reword_suggestions_text, post_fixup_commits_data)
  1463. if not reword_plan:
  1464. print("\n💡 AI did not suggest any specific reword operations.")
  1465. else:
  1466. print("\n💡 --- AI Reword Suggestions --- 💡")
  1467. for i, (hash_key, msg) in enumerate(reword_plan.items()):
  1468. print(f" {i + 1}. Reword commit `{hash_key}` with new message:")
  1469. indented_msg = " " + msg.replace("\n", "\n ")
  1470. print(indented_msg)
  1471. print("-" * 20)
  1472. print("💡 --- End AI Reword Suggestions --- 💡")
  1473. except Exception as e:
  1474. logging.error(f"\nAn error occurred during AI reword interaction: {e}", exc_info=True)
  1475. try: # Log feedback
  1476. if response and hasattr(response, "prompt_feedback"): logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1477. if response and hasattr(response, "candidates"):
  1478. for c in response.candidates: logging.error(f"AI Candidate Finish Reason: {c.finish_reason}, Safety: {getattr(c, 'safety_ratings', 'N/A')}")
  1479. except Exception as feedback_e: logging.error(f"Could not log AI feedback: {feedback_e}")
  1480. print("\n❌ Error during AI reword analysis. Skipping automatic reword.")
  1481. # Don't exit, allow manual instructions if needed
  1482. # --- Automatic Rebase - Phase 2: Reword ---
  1483. reword_succeeded = False
  1484. if reword_plan and not args.instruct and not args.skip_reword:
  1485. print("\n--- Attempting Automatic Reword Rebase ---")
  1486. # Use the potentially updated current_merge_base
  1487. reword_succeeded = attempt_auto_reword(current_merge_base, reword_plan, temp_dir_base)
  1488. if not reword_succeeded:
  1489. print("\n" + "=" * 60)
  1490. print("🛠️ MANUAL REWORD REBASE REQUIRED 🛠️")
  1491. print("=" * 60)
  1492. print("The automatic reword rebase failed.")
  1493. print("Please perform the reword rebase manually:")
  1494. print(f" 1. Run: `git rebase -i {current_merge_base}`")
  1495. print(" 2. In the editor, change 'pick' to 'r' (or 'reword') for the commits suggested by the AI:")
  1496. # Show the *latest* reword suggestions
  1497. print(" ```text")
  1498. print(reword_suggestions_text if reword_suggestions_text else " (No specific reword suggestions found in AI response)")
  1499. print(" ```")
  1500. print(" 3. Save the editor. Git will stop at each commit marked for reword.")
  1501. print(" 4. Manually replace the old commit message with the AI-suggested one.")
  1502. print(" 5. Save the message editor and continue the rebase (`git rebase --continue`).")
  1503. if backup_branch: print(f" 6. Remember backup branch: {backup_branch}")
  1504. print("=" * 60)
  1505. sys.exit(1) # Exit after failed auto reword
  1506. else:
  1507. print("\n✅ Automatic reword rebase completed successfully.")
  1508. elif args.instruct and reword_plan:
  1509. # Don't exit, just note that reword was skipped automatically
  1510. print("\n--instruct flag used, skipping automatic reword rebase.")
  1511. reword_succeeded = False # Treat as not succeeded for final message
  1512. elif not args.skip_reword:
  1513. # No rewording suggested or attempted automatically
  1514. reword_succeeded = True # Treat as succeeded for final message
  1515. print("\nNo automatic reword rebase needed or attempted.")
  1516. # --- Final Instructions (if --instruct was used) ---
  1517. if args.instruct:
  1518. print("\n" + "=" * 60)
  1519. print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
  1520. print("=" * 60)
  1521. if fixup_plan:
  1522. print("--- Fixup Phase ---")
  1523. print("AI suggested the following fixups:")
  1524. for i, pair in enumerate(fixup_plan):
  1525. print(f" - Fixup commit `{pair['fixup']}` into `{pair['target']}`")
  1526. print(f"To apply manually:")
  1527. print(f" 1. Run: `git rebase -i {initial_merge_base}`")
  1528. print(" 2. Change 'pick' to 'f' (or 'fixup') for the suggested commits.")
  1529. print(" 3. Save the editor and resolve conflicts if any.")
  1530. print("-" * 20)
  1531. else:
  1532. print("--- Fixup Phase ---")
  1533. print("No fixup operations were suggested by the AI.")
  1534. print("-" * 20)
  1535. if reword_plan and not args.skip_reword:
  1536. print("--- Reword Phase (After potential manual fixup) ---")
  1537. print("AI suggested the following rewording:")
  1538. # Print parsed plan for clarity
  1539. for i, (hash_key, msg) in enumerate(reword_plan.items()):
  1540. print(f" - Reword commit `{hash_key}` with new message:")
  1541. indented_msg = " " + msg.replace("\n", "\n ")
  1542. print(indented_msg)
  1543. print(f"To apply manually (after completing the fixup rebase if any):")
  1544. # Important: Use the merge base that *would* be correct after fixup
  1545. # If fixup was suggested, the user needs to complete that first.
  1546. # The merge base *should* remain 'initial_merge_base' unless history was drastically altered.
  1547. # Let's use initial_merge_base for simplicity in instructions.
  1548. print(f" 1. Run: `git rebase -i {initial_merge_base}` (or the new base if fixup changed it)")
  1549. print(" 2. Change 'pick' to 'r' (or 'reword') for the suggested commits.")
  1550. print(" 3. Save the editor. Git will stop at each commit.")
  1551. print(" 4. Replace the old message with the AI suggestion.")
  1552. print(" 5. Continue the rebase (`git rebase --continue`).")
  1553. print("-" * 20)
  1554. elif not args.skip_reword:
  1555. print("--- Reword Phase ---")
  1556. print("No reword operations were suggested by the AI.")
  1557. print("-" * 20)
  1558. if backup_branch:
  1559. print(f"Remember backup branch: {backup_branch}")
  1560. print("=" * 60)
  1561. # --- Final Status Message ---
  1562. print("\n--- Summary ---")
  1563. if fixup_plan and not args.instruct:
  1564. print(f"Fixup rebase attempt: {'Success' if fixup_succeeded else 'Failed/Skipped'}")
  1565. if reword_plan and not args.instruct and not args.skip_reword:
  1566. print(f"Reword rebase attempt: {'Success' if reword_succeeded else 'Failed/Skipped'}")
  1567. if (not fixup_plan or fixup_succeeded) and (args.skip_reword or not reword_plan or reword_succeeded):
  1568. print("\nBranch history has been potentially modified.")
  1569. else:
  1570. print("\nBranch history may be unchanged or in an intermediate state due to manual steps required.")
  1571. if backup_branch:
  1572. print(f"Backup branch '{backup_branch}' still exists if needed.")
  1573. finally:
  1574. # Clean up the base temporary directory and its contents
  1575. if temp_dir_base and os.path.exists(temp_dir_base):
  1576. try:
  1577. import shutil
  1578. shutil.rmtree(temp_dir_base)
  1579. logging.debug(f"Cleaned up base temporary directory: {temp_dir_base}")
  1580. except OSError as e:
  1581. logging.warning(
  1582. f"Could not completely remove base temporary directory {temp_dir_base}: {e}"
  1583. )
  1584. # Helper function to get commit data as dict list (needed for reword)
  1585. def get_commits_in_range_data(commit_range):
  1586. """Gets a list of commit data (short_hash, full_hash, subject) in the specified range (oldest first)."""
  1587. # Use --format=%h %H %s to get hashes and subject
  1588. log_output = run_git_command(
  1589. ["log", "--pretty=format:%h %H %s", "--reverse", commit_range]
  1590. )
  1591. commit_data = []
  1592. if log_output is not None:
  1593. lines = log_output.splitlines()
  1594. for line in lines:
  1595. parts = line.split(" ", 2)
  1596. if len(parts) == 3:
  1597. commit_data.append(
  1598. {"short_hash": parts[0], "full_hash": parts[1], "subject": parts[2]}
  1599. )
  1600. else:
  1601. logging.warning(f"Could not parse commit log line: {line}")
  1602. logging.info(f"Found {len(commit_data)} commits in range {commit_range} (for reword).")
  1603. return commit_data
  1604. if __name__ == "__main__":
  1605. main()
  1606. )
  1607. sys.exit(0)
  1608. file_structure, changed_files_list = get_changed_files_in_range(commit_range)
  1609. diff = get_diff_in_range(commit_range)
  1610. if not diff and not changed_files_list:
  1611. logging.warning(
  1612. f"No file changes or diff found between '{merge_base}' and '{current_branch}',"
  1613. )
  1614. logging.warning("even though commits exist. AI suggestions might be limited.")
  1615. # Don't exit automatically, let AI try
  1616. # --- Interact with AI ---
  1617. print("\nGenerating prompt for AI fixup suggestions...")
  1618. initial_prompt = generate_fixup_suggestion_prompt(
  1619. commit_range, merge_base, commits, file_structure, diff
  1620. )
  1621. logging.debug("\n--- Initial AI Prompt Snippet ---")
  1622. logging.debug(initial_prompt[:1000] + "...")
  1623. logging.debug("--- End Prompt Snippet ---\n")
  1624. print(f"Sending request to Gemini AI ({MODEL_NAME})...")
  1625. ai_response_text = ""
  1626. fixup_suggestions_text = "" # Store the raw suggestions for later display if needed
  1627. try:
  1628. convo = model.start_chat(history=[])
  1629. response = convo.send_message(initial_prompt)
  1630. ai_response_text = response.text
  1631. # Loop for file requests
  1632. while "REQUEST_FILES:" in ai_response_text.upper():
  1633. logging.info("AI requested additional file content.")
  1634. additional_context, original_request = request_files_from_user(
  1635. ai_response_text, commits
  1636. )
  1637. if additional_context:
  1638. logging.info("Sending fetched file content back to AI...")
  1639. follow_up_prompt = f"""
  1640. Okay, here is the content of the files you requested:
  1641. {additional_context}
  1642. 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.
  1643. """
  1644. logging.debug("\n--- Follow-up AI Prompt Snippet ---")
  1645. logging.debug(follow_up_prompt[:500] + "...")
  1646. logging.debug("--- End Follow-up Snippet ---\n")
  1647. response = convo.send_message(follow_up_prompt)
  1648. ai_response_text = response.text
  1649. else:
  1650. logging.info(
  1651. "Proceeding without providing files as requested by AI or user."
  1652. )
  1653. no_files_prompt = f"""
  1654. I cannot provide the content for the files you requested ({original_request}).
  1655. 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.
  1656. """
  1657. logging.debug("\n--- No-Files AI Prompt ---")
  1658. logging.debug(no_files_prompt)
  1659. logging.debug("--- End No-Files Prompt ---\n")
  1660. response = convo.send_message(no_files_prompt)
  1661. ai_response_text = response.text
  1662. break
  1663. # Store the final AI response containing suggestions
  1664. fixup_suggestions_text = ai_response_text.strip()
  1665. # Parse the suggestions
  1666. fixup_plan = parse_fixup_suggestions(fixup_suggestions_text, commits)
  1667. if not fixup_plan:
  1668. print("\n💡 AI did not suggest any specific fixup operations.")
  1669. else:
  1670. print("\n💡 --- AI Fixup Suggestions --- 💡")
  1671. # Print the parsed plan for clarity
  1672. for i, pair in enumerate(fixup_plan):
  1673. print(
  1674. f" {i + 1}. Fixup commit `{pair['fixup']}` into `{pair['target']}`"
  1675. )
  1676. print("💡 --- End AI Suggestions --- 💡")
  1677. # --- Attempt Automatic Rebase or Show Instructions ---
  1678. # --- Logic Change ---
  1679. if not args.instruct: # Default behavior: attempt auto-fixup
  1680. if fixup_plan:
  1681. success = attempt_auto_fixup(merge_base, fixup_plan)
  1682. if not success:
  1683. # Failure message already printed by attempt_auto_fixup
  1684. print("\n" + "=" * 60)
  1685. print("🛠️ MANUAL REBASE REQUIRED 🛠️")
  1686. print("=" * 60)
  1687. print(
  1688. "The automatic fixup rebase failed (likely due to conflicts)."
  1689. )
  1690. print("Please perform the rebase manually:")
  1691. print(f" 1. Run: `git rebase -i {merge_base}`")
  1692. print(
  1693. " 2. In the editor, change 'pick' to 'f' (or 'fixup') for the commits"
  1694. )
  1695. print(
  1696. " suggested by the AI above (and any other changes you want)."
  1697. )
  1698. print(" Original AI suggestions:")
  1699. print(" ```text")
  1700. # Print raw suggestions which might be easier to copy/paste
  1701. print(
  1702. fixup_suggestions_text
  1703. if fixup_suggestions_text
  1704. else " (No specific fixup lines found in AI response)"
  1705. )
  1706. print(" ```")
  1707. print(" 3. Save the editor and resolve any conflicts Git reports.")
  1708. print(
  1709. " Use `git status`, edit files, `git add <files>`, `git rebase --continue`."
  1710. )
  1711. if backup_branch:
  1712. print(f" 4. Remember backup branch: {backup_branch}")
  1713. print("=" * 60)
  1714. sys.exit(1) # Exit with error status after failure
  1715. else:
  1716. # Auto fixup succeeded
  1717. print("\nBranch history has been modified by automatic fixups.")
  1718. if backup_branch:
  1719. print(
  1720. f"Backup branch '{backup_branch}' still exists if needed."
  1721. )
  1722. else:
  1723. print("\nNo automatic rebase attempted as AI suggested no fixups.")
  1724. elif fixup_plan: # --instruct flag was used AND suggestions exist
  1725. print("\n" + "=" * 60)
  1726. print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
  1727. print("=" * 60)
  1728. print("AI suggested the fixups listed above.")
  1729. print("To apply them (or other changes):")
  1730. print(f" 1. Run: `git rebase -i {merge_base}`")
  1731. print(" 2. Edit the 'pick' lines in the editor based on the suggestions")
  1732. print(" (changing 'pick' to 'f' or 'fixup').")
  1733. print(" 3. Save the editor and follow Git's instructions.")
  1734. if backup_branch:
  1735. print(f" 4. Remember backup branch: {backup_branch}")
  1736. print("=" * 60)
  1737. # If --instruct and no fixup_plan, nothing specific needs to be printed here
  1738. except Exception as e:
  1739. logging.error(f"\nAn unexpected error occurred: {e}", exc_info=True)
  1740. # Attempt to print feedback if available
  1741. try:
  1742. if response and hasattr(response, "prompt_feedback"):
  1743. logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  1744. if response and hasattr(response, "candidates"):
  1745. for candidate in response.candidates:
  1746. logging.error(
  1747. f"AI Candidate Finish Reason: {candidate.finish_reason}"
  1748. )
  1749. if hasattr(candidate, "safety_ratings"):
  1750. logging.error(f"AI Safety Ratings: {candidate.safety_ratings}")
  1751. except Exception as feedback_e:
  1752. logging.error(
  1753. f"Could not retrieve detailed feedback from AI response: {feedback_e}"
  1754. )
  1755. print("\n❌ An unexpected error occurred during the process.")
  1756. print(" Please check the logs and your Git status.")
  1757. print(" You may need to run `git rebase --abort` manually.")
  1758. if __name__ == "__main__":
  1759. main()