git_rebase_ai.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  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. # --- Configuration ---
  10. # Configure logging
  11. logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
  12. # Attempt to get API key from environment variable
  13. API_KEY = os.getenv("GEMINI_API_KEY")
  14. if not API_KEY:
  15. logging.error("GEMINI_API_KEY environment variable not set.")
  16. logging.error(
  17. "Please obtain an API key from Google AI Studio (https://aistudio.google.com/app/apikey)"
  18. )
  19. logging.error("and set it as an environment variable:")
  20. logging.error(" export GEMINI_API_KEY='YOUR_API_KEY' (Linux/macOS)")
  21. logging.error(" set GEMINI_API_KEY=YOUR_API_KEY (Windows CMD)")
  22. logging.error(" $env:GEMINI_API_KEY='YOUR_API_KEY' (Windows PowerShell)")
  23. sys.exit(1)
  24. # Configure the Gemini AI Client
  25. try:
  26. genai.configure(api_key=API_KEY)
  27. # Use a model suitable for complex reasoning like code analysis.
  28. # Adjust model name if needed (e.g., 'gemini-1.5-flash-latest').
  29. MODEL_NAME = os.getenv("GEMINI_MODEL")
  30. if not MODEL_NAME:
  31. logging.error("GEMINI_MODEL environment variable not set.")
  32. logging.error(
  33. "Please set the desired Gemini model name (e.g., 'gemini-1.5-flash-latest')."
  34. )
  35. logging.error(" export GEMINI_MODEL='gemini-1.5-flash-latest' (Linux/macOS)")
  36. logging.error(" set GEMINI_MODEL=gemini-1.5-flash-latest (Windows CMD)")
  37. logging.error(
  38. " $env:GEMINI_MODEL='gemini-1.5-flash-latest' (Windows PowerShell)"
  39. )
  40. sys.exit(1)
  41. model = genai.GenerativeModel(MODEL_NAME)
  42. logging.info(f"Using Gemini model: {MODEL_NAME}")
  43. except Exception as e:
  44. logging.error(f"Error configuring Gemini AI: {e}")
  45. sys.exit(1)
  46. # --- Git Helper Functions ---
  47. def run_git_command(command_list):
  48. """
  49. Runs a Git command as a list of arguments and returns its stdout.
  50. Handles errors and returns None on failure.
  51. """
  52. full_command = []
  53. try:
  54. # Prepend 'git' to the command list
  55. full_command = ["git"] + command_list
  56. logging.debug(f"Running command: {' '.join(full_command)}")
  57. result = subprocess.run(
  58. full_command,
  59. check=True,
  60. capture_output=True,
  61. text=True,
  62. encoding="utf-8", # Be explicit about encoding
  63. errors="replace", # Handle potential decoding errors
  64. )
  65. logging.debug(
  66. f"Command successful. Output:\n{result.stdout[:200]}..."
  67. ) # Log snippet
  68. return result.stdout.strip()
  69. except subprocess.CalledProcessError as e:
  70. logging.error(f"Error executing Git command: {' '.join(full_command)}")
  71. # Log stderr, replacing potential problematic characters
  72. stderr_safe = e.stderr.strip().encode("utf-8", "replace").decode("utf-8")
  73. logging.error(f"Stderr: {stderr_safe}")
  74. return None # Indicate failure
  75. except FileNotFoundError:
  76. logging.error(
  77. "Error: 'git' command not found. Is Git installed and in your PATH?"
  78. )
  79. sys.exit(1) # Critical error, exit
  80. except Exception as e:
  81. logging.error(f"An unexpected error occurred running git: {e}")
  82. return None
  83. def check_git_repository():
  84. """Checks if the current directory is the root of a Git repository."""
  85. # Use git rev-parse --is-inside-work-tree for a more reliable check
  86. output = run_git_command(["rev-parse", "--is-inside-work-tree"])
  87. return output == "true"
  88. def get_current_branch():
  89. """Gets the current active Git branch name."""
  90. return run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
  91. def create_backup_branch(branch_name):
  92. """Creates a timestamped backup branch from the given branch name."""
  93. timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
  94. backup_branch_name = f"{branch_name}-backup-{timestamp}"
  95. logging.info(
  96. f"Attempting to create backup branch: {backup_branch_name} from {branch_name}"
  97. )
  98. # Use list format for run_git_command
  99. output = run_git_command(["branch", backup_branch_name, branch_name])
  100. # run_git_command returns stdout on success (which is empty for git branch)
  101. # or None on failure. Check for None.
  102. if output is not None:
  103. logging.info(f"Successfully created backup branch: {backup_branch_name}")
  104. return backup_branch_name
  105. else:
  106. logging.error("Failed to create backup branch.")
  107. return None
  108. def get_commit_range(upstream_ref, current_branch):
  109. """
  110. Determines the commit range (merge_base..current_branch).
  111. Returns the range string and the merge base hash.
  112. """
  113. logging.info(
  114. f"Finding merge base between '{upstream_ref}' and '{current_branch}'..."
  115. )
  116. merge_base = run_git_command(["merge-base", upstream_ref, current_branch])
  117. if not merge_base:
  118. logging.error(
  119. f"Could not find merge base between '{upstream_ref}' and '{current_branch}'."
  120. )
  121. logging.error(
  122. f"Ensure '{upstream_ref}' is a valid reference (branch, commit, tag)"
  123. )
  124. logging.error("and that it has been fetched (e.g., 'git fetch origin').")
  125. return None, None # Indicate failure
  126. logging.info(f"Found merge base: {merge_base}")
  127. commit_range = f"{merge_base}..{current_branch}"
  128. return commit_range, merge_base
  129. def get_commits_in_range(commit_range):
  130. """Gets a list of commit hashes and subjects in the specified range (oldest first)."""
  131. # --pretty=format adds specific format, %h=short hash, %s=subject
  132. # --reverse shows oldest first, which is how rebase lists them
  133. log_output = run_git_command(
  134. ["log", "--pretty=format:%h %s", "--reverse", commit_range]
  135. )
  136. if log_output is not None:
  137. commits = log_output.splitlines()
  138. logging.info(f"Found {len(commits)} commits in range {commit_range}.")
  139. return commits
  140. return [] # Return empty list on failure or no commits
  141. def get_changed_files_in_range(commit_range):
  142. """
  143. Gets a list of files changed in the specified range and generates
  144. a simple directory structure string representation.
  145. """
  146. # --name-only shows only filenames
  147. diff_output = run_git_command(["diff", "--name-only", commit_range])
  148. if diff_output is not None:
  149. files = diff_output.splitlines()
  150. logging.info(f"Found {len(files)} changed files in range {commit_range}.")
  151. # Basic tree structure representation
  152. tree = {}
  153. for file_path in files:
  154. # Normalize path separators for consistency
  155. parts = file_path.replace("\\", "/").split("/")
  156. node = tree
  157. for i, part in enumerate(parts):
  158. if not part:
  159. continue # Skip empty parts (e.g., leading '/')
  160. if i == len(parts) - 1: # It's a file
  161. node[part] = "file"
  162. else: # It's a directory
  163. if part not in node:
  164. node[part] = {}
  165. # Ensure we don't try to treat a file as a directory
  166. if isinstance(node[part], dict):
  167. node = node[part]
  168. else:
  169. # Handle conflict (e.g., file 'a' and dir 'a/b') - less likely with git paths
  170. logging.warning(
  171. f"Path conflict building file tree for: {file_path}"
  172. )
  173. break # Stop processing this path
  174. # Simple string representation for the prompt
  175. def format_tree(d, indent=0):
  176. lines = []
  177. # Sort items for consistent output
  178. for key, value in sorted(d.items()):
  179. prefix = " " * indent
  180. if isinstance(value, dict):
  181. lines.append(f"{prefix}📁 {key}/")
  182. lines.extend(format_tree(value, indent + 1))
  183. else:
  184. lines.append(f"{prefix}📄 {key}")
  185. return lines
  186. tree_str = "\n".join(format_tree(tree))
  187. return tree_str, files # Return structure string and raw list
  188. return "", [] # Return empty on failure or no changes
  189. def get_diff_in_range(commit_range):
  190. """Gets the combined diffstat and patch for the specified range."""
  191. # Use --patch-with-stat for context (diff + stats)
  192. diff_output = run_git_command(["diff", "--patch-with-stat", commit_range])
  193. if diff_output is not None:
  194. logging.info(
  195. f"Generated diff for range {commit_range} (length: {len(diff_output)} chars)."
  196. )
  197. else:
  198. logging.warning(f"Could not generate diff for range {commit_range}.")
  199. return (
  200. diff_output if diff_output is not None else ""
  201. ) # Return empty string on failure
  202. def get_file_content_at_commit(commit_hash, file_path):
  203. """Gets the content of a specific file at a specific commit hash."""
  204. logging.info(f"Fetching content of '{file_path}' at commit {commit_hash[:7]}...")
  205. # Use 'git show' which handles paths correctly
  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_squash_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. squash and fixup candidates within the commit range.
  220. """
  221. commit_list_str = (
  222. "\n".join([f"- {c}" for c in commits]) if commits else "No commits in range."
  223. )
  224. # The merge base hash isn't strictly needed for *suggestions* but good context
  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 could be logically combined using `squash` or `fixup` during an interactive rebase (`git rebase -i {merge_base}`).
  227. **Goal:** Suggest combinations that group related changes together, merge small fixes into their parent commits, or consolidate work-in-progress commits to make the history more understandable and atomic.
  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 pairs or sequences of commits from the list above that are strong candidates for being combined using `squash` (combine changes and messages) or `fixup` (combine changes, discard message).
  249. 3. For each suggestion, clearly state:
  250. * Which commit(s) should be squashed/fixed up *into* which preceding commit.
  251. * Whether `squash` or `fixup` is more appropriate.
  252. * A brief explanation of *why* this combination makes sense (e.g., "Commit B is a minor fix for commit A", "Commits C, D, E are parts of the same feature implementation").
  253. 4. **Focus ONLY on squash/fixup suggestions.** Do *not* suggest `reword`, `edit`, `drop`, or provide a full rebase plan/command sequence.
  254. 5. Format your response as a list of suggestions.
  255. **Example Output Format:**
  256. ```text
  257. Based on the analysis, here are potential candidates for squashing or fixing up:
  258. * **Suggestion 1:**
  259. * Action: `fixup` commit `<hash2> fix typo` into `<hash1> feat: Add initial framework`.
  260. * Reason: Commit `<hash2>` appears to be a small correction directly related to the initial framework added in `<hash1>`. Its message can likely be discarded.
  261. * **Suggestion 2:**
  262. * Action: `squash` commit `<hash4> Add tests` into `<hash3> feat: Implement user login`.
  263. * Reason: Commit `<hash4>` adds tests specifically for the feature implemented in `<hash3>`. Combining them keeps the feature and its tests together. Their messages should be combined during the rebase.
  264. * **Suggestion 3:**
  265. * Action: `squash` commits `<hash6> WIP part 2` and `<hash7> WIP part 3` into `<hash5> feat: Start implementing feature X`.
  266. * Reason: Commits `<hash6>` and `<hash7>` seem like incremental work-in-progress steps for the feature started in `<hash5>`. Squashing them creates a single, complete commit for the feature.
  267. ```
  268. 6. **File Content Request:** If you absolutely need the content of specific files *at specific commits* to confidently determine if they should be squashed/fixed up, ask for them clearly ONCE. List the files using this exact format at the end of your response:
  269. `REQUEST_FILES: [commit_hash1:path/to/file1.py, commit_hash2:another/path/file2.js]`
  270. Use the short commit hashes provided in the commit list. Do *not* ask for files unless essential for *this specific task* of identifying squash/fixup candidates.
  271. Now, analyze the provided context and generate *only* the squash/fixup suggestions and their reasoning.
  272. """
  273. return prompt
  274. # --- request_files_from_user function remains the same ---
  275. def request_files_from_user(requested_files_str, commits_in_range):
  276. """
  277. Parses AI request string "REQUEST_FILES: [hash:path, ...]", verifies hashes,
  278. asks user permission, fetches file contents, and returns formatted context.
  279. """
  280. file_requests = []
  281. try:
  282. # Extract the part within brackets using regex
  283. content_match = re.search(
  284. r"REQUEST_FILES:\s*\[(.*)\]", requested_files_str, re.IGNORECASE | re.DOTALL
  285. )
  286. if not content_match:
  287. logging.warning("Could not parse file request format from AI response.")
  288. return None, None # Indicate parsing failure
  289. items_str = content_match.group(1).strip()
  290. if not items_str:
  291. logging.info("AI requested files but the list was empty.")
  292. return None, None # Empty request
  293. # Split items, handling potential spaces around commas
  294. items = [item.strip() for item in items_str.split(",") if item.strip()]
  295. # Map short hashes from the original list to verify AI request
  296. commit_hash_map = {
  297. c.split()[0]: c.split()[0] for c in commits_in_range
  298. } # short_hash: short_hash
  299. for item in items:
  300. if ":" not in item:
  301. logging.warning(
  302. f"Invalid format in requested file item (missing ':'): {item}"
  303. )
  304. continue
  305. commit_hash, file_path = item.split(":", 1)
  306. commit_hash = commit_hash.strip()
  307. file_path = file_path.strip()
  308. # Verify the short hash exists in our original list
  309. if commit_hash not in commit_hash_map:
  310. logging.warning(
  311. f"AI requested file for unknown/out-of-range commit hash '{commit_hash}'. Skipping."
  312. )
  313. continue
  314. file_requests.append({"hash": commit_hash, "path": file_path})
  315. except Exception as e:
  316. logging.error(f"Error parsing requested files string: {e}")
  317. return None, None # Indicate parsing error
  318. if not file_requests:
  319. logging.info("No valid file requests found after parsing AI response.")
  320. return None, None # No valid requests
  321. print("\n----------------------------------------")
  322. print("❓ AI Request for File Content ❓")
  323. print("----------------------------------------")
  324. print("The AI needs the content of the following files at specific commits")
  325. print("to provide more accurate squash/fixup suggestions:")
  326. files_to_fetch = []
  327. for i, req in enumerate(file_requests):
  328. print(f" {i + 1}. File: '{req['path']}' at commit {req['hash']}")
  329. files_to_fetch.append(req) # Add to list if valid
  330. if not files_to_fetch:
  331. print("\nNo valid files to fetch based on the request.")
  332. return None, None # No files remain after validation
  333. print("----------------------------------------")
  334. while True:
  335. try:
  336. answer = (
  337. input("Allow fetching these file contents? (yes/no): ").lower().strip()
  338. )
  339. except EOFError: # Handle case where input stream is closed (e.g., piping)
  340. logging.warning("Input stream closed. Assuming 'no'.")
  341. answer = "no"
  342. if answer == "yes":
  343. logging.info("User approved fetching file content.")
  344. fetched_content_list = []
  345. for req in files_to_fetch:
  346. content = get_file_content_at_commit(req["hash"], req["path"])
  347. if content is not None:
  348. # Format for the AI prompt
  349. fetched_content_list.append(
  350. f"--- Content of '{req['path']}' at commit {req['hash']} ---\n"
  351. f"```\n{content}\n```\n"
  352. f"--- End Content for {req['path']} at {req['hash']} ---"
  353. )
  354. else:
  355. # Inform AI that content couldn't be fetched
  356. fetched_content_list.append(
  357. f"--- Could not fetch content of '{req['path']}' at commit {req['hash']} ---"
  358. )
  359. # Return the combined content and the original request string for context
  360. return "\n\n".join(fetched_content_list), requested_files_str
  361. elif answer == "no":
  362. logging.info("User denied fetching file content.")
  363. # Return None for content, but still return the request string
  364. return None, requested_files_str
  365. else:
  366. print("Please answer 'yes' or 'no'.")
  367. # --- Main Execution ---
  368. def main():
  369. """Main function to orchestrate Git analysis and AI interaction."""
  370. parser = argparse.ArgumentParser(
  371. description="Uses Gemini AI to suggest potential Git squash/fixup candidates.",
  372. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  373. )
  374. parser.add_argument(
  375. "upstream_ref",
  376. nargs="?",
  377. # Default to common upstream names, user MUST ensure one exists
  378. default="upstream/main",
  379. help="The upstream reference point or commit hash to compare against "
  380. "(e.g., 'origin/main', 'upstream/develop', specific_commit_hash). "
  381. "Ensure this reference exists and is fetched.",
  382. )
  383. parser.add_argument(
  384. "-v", "--verbose", action="store_true", help="Enable verbose debug logging."
  385. )
  386. args = parser.parse_args()
  387. if args.verbose:
  388. logging.getLogger().setLevel(logging.DEBUG)
  389. logging.debug("Verbose logging enabled.")
  390. if not check_git_repository():
  391. logging.error("This script must be run from within a Git repository.")
  392. sys.exit(1)
  393. current_branch = get_current_branch()
  394. if not current_branch:
  395. logging.error("Could not determine the current Git branch.")
  396. sys.exit(1)
  397. logging.info(f"Current branch: {current_branch}")
  398. upstream_ref = args.upstream_ref
  399. logging.info(f"Comparing against reference: {upstream_ref}")
  400. # --- Safety: Create Backup Branch ---
  401. backup_branch = create_backup_branch(current_branch)
  402. if not backup_branch:
  403. # Ask user if they want to continue without a backup
  404. try:
  405. confirm = input(
  406. "⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
  407. ).lower()
  408. except EOFError:
  409. logging.warning("Input stream closed. Aborting.")
  410. confirm = "no"
  411. if confirm != "yes":
  412. logging.info("Aborting.")
  413. sys.exit(1)
  414. else:
  415. logging.warning("Proceeding without a backup branch. Be careful!")
  416. else:
  417. print("-" * 40)
  418. print(f"✅ Backup branch created: {backup_branch}")
  419. print(
  420. " If anything goes wrong during manual rebase later, you can restore using:"
  421. )
  422. print(f" git checkout {current_branch}")
  423. print(f" git reset --hard {backup_branch}")
  424. print("-" * 40)
  425. # --- Gather Git Context ---
  426. print("\nGathering Git context...")
  427. commit_range, merge_base = get_commit_range(upstream_ref, current_branch)
  428. if not commit_range: # Error handled in get_commit_range
  429. sys.exit(1)
  430. logging.info(f"Analyzing commit range: {commit_range} (Merge Base: {merge_base})")
  431. commits = get_commits_in_range(commit_range)
  432. if not commits:
  433. logging.info(
  434. f"No commits found between '{merge_base}' and '{current_branch}'. Nothing to suggest."
  435. )
  436. sys.exit(0)
  437. file_structure, changed_files_list = get_changed_files_in_range(commit_range)
  438. diff = get_diff_in_range(commit_range)
  439. if not diff and not changed_files_list:
  440. logging.warning(
  441. f"No file changes or diff found between '{merge_base}' and '{current_branch}',"
  442. )
  443. logging.warning("even though commits exist. AI suggestions might be limited.")
  444. print("Commits found:")
  445. for c in commits:
  446. print(f"- {c}")
  447. try:
  448. confirm_proceed = input(
  449. "Proceed with AI analysis despite no diff? (yes/no): "
  450. ).lower()
  451. except EOFError:
  452. confirm_proceed = "no"
  453. if confirm_proceed != "yes":
  454. logging.info("Aborting analysis.")
  455. sys.exit(0)
  456. # --- Interact with AI ---
  457. print("\nGenerating prompt for AI squash/fixup suggestions...")
  458. # *** Use the new prompt function ***
  459. initial_prompt = generate_squash_suggestion_prompt(
  460. commit_range, merge_base, commits, file_structure, diff
  461. )
  462. logging.debug("\n--- Initial AI Prompt Snippet ---")
  463. logging.debug(initial_prompt[:1000] + "...") # Log beginning of prompt
  464. logging.debug("--- End Prompt Snippet ---\n")
  465. print(f"Sending request to Gemini AI ({MODEL_NAME})... This may take a moment.")
  466. try:
  467. # Start a chat session for potential follow-ups (file requests)
  468. convo = model.start_chat(history=[])
  469. response = convo.send_message(initial_prompt)
  470. ai_response_text = response.text
  471. # Loop to handle potential file requests (still relevant for squash decisions)
  472. while "REQUEST_FILES:" in ai_response_text.upper():
  473. logging.info("AI requested additional file content.")
  474. additional_context, original_request = request_files_from_user(
  475. ai_response_text, commits
  476. )
  477. if additional_context:
  478. logging.info("Sending fetched file content back to AI...")
  479. # Construct follow-up prompt for squash suggestions
  480. follow_up_prompt = f"""
  481. Okay, here is the content of the files you requested:
  482. {additional_context}
  483. Please use this new information to refine your **squash/fixup suggestions** based on the original request and context. Provide the final list of suggestions now. Remember to *only* suggest squash/fixup actions and explain why. Do not provide a full rebase plan. Do not ask for more files.
  484. """
  485. logging.debug("\n--- Follow-up AI Prompt Snippet ---")
  486. logging.debug(follow_up_prompt[:500] + "...")
  487. logging.debug("--- End Follow-up Snippet ---\n")
  488. response = convo.send_message(follow_up_prompt)
  489. ai_response_text = response.text
  490. else:
  491. logging.info(
  492. "Proceeding without providing files as requested by AI or user."
  493. )
  494. # Tell the AI to proceed without the files it asked for
  495. no_files_prompt = f"""
  496. I cannot provide the content for the files you requested ({original_request}).
  497. Please proceed with generating the **squash/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 suggestions now. Remember to *only* suggest squash/fixup actions.
  498. """
  499. logging.debug("\n--- No-Files AI Prompt ---")
  500. logging.debug(no_files_prompt)
  501. logging.debug("--- End No-Files Prompt ---\n")
  502. response = convo.send_message(no_files_prompt)
  503. ai_response_text = response.text
  504. # Break the loop as we've instructed AI to proceed without files
  505. break
  506. print("\n💡 --- AI Squash/Fixup Suggestions --- 💡")
  507. # Basic cleanup: remove potential markdown code block fences if AI adds them unnecessarily
  508. suggestion = ai_response_text.strip()
  509. suggestion = re.sub(r"^```(?:bash|text|)\n", "", suggestion, flags=re.MULTILINE)
  510. suggestion = re.sub(r"\n```$", "", suggestion, flags=re.MULTILINE)
  511. print(suggestion)
  512. print("💡 --- End AI Suggestions --- 💡")
  513. print("\n" + "=" * 60)
  514. print("📝 NEXT STEPS 📝")
  515. print("=" * 60)
  516. print("1. REVIEW the suggestions above carefully.")
  517. print("2. These are *only suggestions* for potential squashes/fixups.")
  518. print(" No changes have been made to your Git history.")
  519. print("3. If you want to apply these (or other) changes, you can:")
  520. print(f" a. Manually run `git rebase -i {merge_base}`.")
  521. print(" b. Edit the 'pick' lines in the editor based on these suggestions")
  522. print(" (changing 'pick' to 'squash' or 'fixup' as appropriate).")
  523. print(" c. Save the editor and follow Git's instructions.")
  524. # Optional: Could add a suggestion to run the original script version
  525. # print(" d. Alternatively, run a version of this script that asks the AI")
  526. # print(" for a full rebase plan.")
  527. if backup_branch:
  528. print(f"4. Remember your backup branch is: {backup_branch}")
  529. print(
  530. f" If needed, restore with: git checkout {current_branch} && git reset --hard {backup_branch}"
  531. )
  532. else:
  533. print(
  534. "4. WARNING: No backup branch was created. Proceed with extra caution if rebasing."
  535. )
  536. print("=" * 60)
  537. except Exception as e:
  538. logging.error(f"\nAn error occurred during AI interaction: {e}")
  539. # Attempt to print feedback if available in the response object
  540. try:
  541. if response and hasattr(response, "prompt_feedback"):
  542. logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
  543. if response and hasattr(response, "candidates"):
  544. # Log candidate details, potentially including finish reason
  545. for candidate in response.candidates:
  546. logging.error(
  547. f"AI Candidate Finish Reason: {candidate.finish_reason}"
  548. )
  549. # Safety details if available
  550. if hasattr(candidate, "safety_ratings"):
  551. logging.error(f"AI Safety Ratings: {candidate.safety_ratings}")
  552. except Exception as feedback_e:
  553. logging.error(
  554. f"Could not retrieve detailed feedback from AI response: {feedback_e}"
  555. )
  556. if __name__ == "__main__":
  557. main()