|
@@ -6,11 +6,12 @@ import sys
|
|
|
import datetime
|
|
|
import re
|
|
|
import logging
|
|
|
+import tempfile
|
|
|
|
|
|
# --- Configuration ---
|
|
|
|
|
|
# Configure logging
|
|
|
-logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
|
+logging.basicConfig(level=logging.WARN, format="%(levelname)s: %(message)s")
|
|
|
|
|
|
# Attempt to get API key from environment variable
|
|
|
API_KEY = os.getenv("GEMINI_API_KEY")
|
|
@@ -51,39 +52,53 @@ except Exception as e:
|
|
|
# --- Git Helper Functions ---
|
|
|
|
|
|
|
|
|
-def run_git_command(command_list):
|
|
|
+def run_git_command(command_list, check=True, capture_output=True, env=None):
|
|
|
"""
|
|
|
Runs a Git command as a list of arguments and returns its stdout.
|
|
|
- Handles errors and returns None on failure.
|
|
|
+ Handles errors and returns None on failure if check=True.
|
|
|
+ Allows passing environment variables.
|
|
|
"""
|
|
|
full_command = []
|
|
|
try:
|
|
|
- # Prepend 'git' to the command list
|
|
|
full_command = ["git"] + command_list
|
|
|
logging.debug(f"Running command: {' '.join(full_command)}")
|
|
|
+ cmd_env = os.environ.copy()
|
|
|
+ if env:
|
|
|
+ cmd_env.update(env)
|
|
|
result = subprocess.run(
|
|
|
full_command,
|
|
|
- check=True,
|
|
|
- capture_output=True,
|
|
|
+ check=check,
|
|
|
+ capture_output=capture_output,
|
|
|
text=True,
|
|
|
- encoding="utf-8", # Be explicit about encoding
|
|
|
- errors="replace", # Handle potential decoding errors
|
|
|
+ encoding="utf-8",
|
|
|
+ errors="replace",
|
|
|
+ env=cmd_env,
|
|
|
)
|
|
|
- logging.debug(
|
|
|
- f"Command successful. Output:\n{result.stdout[:200]}..."
|
|
|
- ) # Log snippet
|
|
|
- return result.stdout.strip()
|
|
|
+ logging.debug(f"Command successful. Output:\n{result.stdout[:200]}...")
|
|
|
+ return result.stdout.strip() if capture_output else ""
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
logging.error(f"Error executing Git command: {' '.join(full_command)}")
|
|
|
- # Log stderr, replacing potential problematic characters
|
|
|
- stderr_safe = e.stderr.strip().encode("utf-8", "replace").decode("utf-8")
|
|
|
- logging.error(f"Stderr: {stderr_safe}")
|
|
|
- return None # Indicate failure
|
|
|
+ stderr_safe = (
|
|
|
+ e.stderr.strip().encode("utf-8", "replace").decode("utf-8")
|
|
|
+ if e.stderr
|
|
|
+ else ""
|
|
|
+ )
|
|
|
+ stdout_safe = (
|
|
|
+ e.stdout.strip().encode("utf-8", "replace").decode("utf-8")
|
|
|
+ if e.stdout
|
|
|
+ else ""
|
|
|
+ )
|
|
|
+ logging.error(f"Exit Code: {e.returncode}")
|
|
|
+ if stderr_safe:
|
|
|
+ logging.error(f"Stderr: {stderr_safe}")
|
|
|
+ if stdout_safe:
|
|
|
+ logging.error(f"Stdout: {stdout_safe}")
|
|
|
+ return None
|
|
|
except FileNotFoundError:
|
|
|
logging.error(
|
|
|
"Error: 'git' command not found. Is Git installed and in your PATH?"
|
|
|
)
|
|
|
- sys.exit(1) # Critical error, exit
|
|
|
+ sys.exit(1)
|
|
|
except Exception as e:
|
|
|
logging.error(f"An unexpected error occurred running git: {e}")
|
|
|
return None
|
|
@@ -91,7 +106,6 @@ def run_git_command(command_list):
|
|
|
|
|
|
def check_git_repository():
|
|
|
"""Checks if the current directory is the root of a Git repository."""
|
|
|
- # Use git rev-parse --is-inside-work-tree for a more reliable check
|
|
|
output = run_git_command(["rev-parse", "--is-inside-work-tree"])
|
|
|
return output == "true"
|
|
|
|
|
@@ -108,10 +122,7 @@ def create_backup_branch(branch_name):
|
|
|
logging.info(
|
|
|
f"Attempting to create backup branch: {backup_branch_name} from {branch_name}"
|
|
|
)
|
|
|
- # Use list format for run_git_command
|
|
|
output = run_git_command(["branch", backup_branch_name, branch_name])
|
|
|
- # run_git_command returns stdout on success (which is empty for git branch)
|
|
|
- # or None on failure. Check for None.
|
|
|
if output is not None:
|
|
|
logging.info(f"Successfully created backup branch: {backup_branch_name}")
|
|
|
return backup_branch_name
|
|
@@ -146,8 +157,6 @@ def get_commit_range(upstream_ref, current_branch):
|
|
|
|
|
|
def get_commits_in_range(commit_range):
|
|
|
"""Gets a list of commit hashes and subjects in the specified range (oldest first)."""
|
|
|
- # --pretty=format adds specific format, %h=short hash, %s=subject
|
|
|
- # --reverse shows oldest first, which is how rebase lists them
|
|
|
log_output = run_git_command(
|
|
|
["log", "--pretty=format:%h %s", "--reverse", commit_range]
|
|
|
)
|
|
@@ -163,7 +172,6 @@ def get_changed_files_in_range(commit_range):
|
|
|
Gets a list of files changed in the specified range and generates
|
|
|
a simple directory structure string representation.
|
|
|
"""
|
|
|
- # --name-only shows only filenames
|
|
|
diff_output = run_git_command(["diff", "--name-only", commit_range])
|
|
|
if diff_output is not None:
|
|
|
files = diff_output.splitlines()
|
|
@@ -172,31 +180,26 @@ def get_changed_files_in_range(commit_range):
|
|
|
# Basic tree structure representation
|
|
|
tree = {}
|
|
|
for file_path in files:
|
|
|
- # Normalize path separators for consistency
|
|
|
parts = file_path.replace("\\", "/").split("/")
|
|
|
node = tree
|
|
|
for i, part in enumerate(parts):
|
|
|
if not part:
|
|
|
- continue # Skip empty parts (e.g., leading '/')
|
|
|
- if i == len(parts) - 1: # It's a file
|
|
|
+ continue
|
|
|
+ if i == len(parts) - 1:
|
|
|
node[part] = "file"
|
|
|
- else: # It's a directory
|
|
|
+ else:
|
|
|
if part not in node:
|
|
|
node[part] = {}
|
|
|
- # Ensure we don't try to treat a file as a directory
|
|
|
if isinstance(node[part], dict):
|
|
|
node = node[part]
|
|
|
else:
|
|
|
- # Handle conflict (e.g., file 'a' and dir 'a/b') - less likely with git paths
|
|
|
logging.warning(
|
|
|
f"Path conflict building file tree for: {file_path}"
|
|
|
)
|
|
|
- break # Stop processing this path
|
|
|
+ break
|
|
|
|
|
|
- # Simple string representation for the prompt
|
|
|
def format_tree(d, indent=0):
|
|
|
lines = []
|
|
|
- # Sort items for consistent output
|
|
|
for key, value in sorted(d.items()):
|
|
|
prefix = " " * indent
|
|
|
if isinstance(value, dict):
|
|
@@ -207,13 +210,12 @@ def get_changed_files_in_range(commit_range):
|
|
|
return lines
|
|
|
|
|
|
tree_str = "\n".join(format_tree(tree))
|
|
|
- return tree_str, files # Return structure string and raw list
|
|
|
- return "", [] # Return empty on failure or no changes
|
|
|
+ return tree_str, files
|
|
|
+ return "", []
|
|
|
|
|
|
|
|
|
def get_diff_in_range(commit_range):
|
|
|
"""Gets the combined diffstat and patch for the specified range."""
|
|
|
- # Use --patch-with-stat for context (diff + stats)
|
|
|
diff_output = run_git_command(["diff", "--patch-with-stat", commit_range])
|
|
|
if diff_output is not None:
|
|
|
logging.info(
|
|
@@ -221,15 +223,12 @@ def get_diff_in_range(commit_range):
|
|
|
)
|
|
|
else:
|
|
|
logging.warning(f"Could not generate diff for range {commit_range}.")
|
|
|
- return (
|
|
|
- diff_output if diff_output is not None else ""
|
|
|
- ) # Return empty string on failure
|
|
|
+ return diff_output if diff_output is not None else ""
|
|
|
|
|
|
|
|
|
def get_file_content_at_commit(commit_hash, file_path):
|
|
|
"""Gets the content of a specific file at a specific commit hash."""
|
|
|
logging.info(f"Fetching content of '{file_path}' at commit {commit_hash[:7]}...")
|
|
|
- # Use 'git show' which handles paths correctly
|
|
|
content = run_git_command(["show", f"{commit_hash}:{file_path}"])
|
|
|
if content is None:
|
|
|
logging.warning(
|
|
@@ -242,23 +241,23 @@ def get_file_content_at_commit(commit_hash, file_path):
|
|
|
# --- AI Interaction ---
|
|
|
|
|
|
|
|
|
-def generate_squash_suggestion_prompt(
|
|
|
+def generate_fixup_suggestion_prompt(
|
|
|
commit_range, merge_base, commits, file_structure, diff
|
|
|
):
|
|
|
"""
|
|
|
Creates a prompt asking the AI specifically to identify potential
|
|
|
- squash and fixup candidates within the commit range.
|
|
|
+ fixup candidates within the commit range.
|
|
|
+ Returns suggestions in a parsable format.
|
|
|
"""
|
|
|
|
|
|
commit_list_str = (
|
|
|
"\n".join([f"- {c}" for c in commits]) if commits else "No commits in range."
|
|
|
)
|
|
|
|
|
|
- # The merge base hash isn't strictly needed for *suggestions* but good context
|
|
|
prompt = f"""
|
|
|
-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}`).
|
|
|
+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}`).
|
|
|
|
|
|
-**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.
|
|
|
+**Goal:** Identify commits that are minor corrections or direct continuations of the immediately preceding commit, where the commit message can be discarded.
|
|
|
|
|
|
**Git Commit Message Conventions (for context):**
|
|
|
* Subject: Imperative, < 50 chars, capitalized, no period. Use types like `feat:`, `fix:`, `refactor:`, etc.
|
|
@@ -284,41 +283,57 @@ You are an expert Git assistant. Your task is to analyze the provided Git commit
|
|
|
**Instructions:**
|
|
|
|
|
|
1. Analyze the commits, their messages, the changed files, and the diff.
|
|
|
-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).
|
|
|
-3. For each suggestion, clearly state:
|
|
|
- * Which commit(s) should be squashed/fixed up *into* which preceding commit.
|
|
|
- * Whether `squash` or `fixup` is more appropriate.
|
|
|
- * 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").
|
|
|
-4. **Focus ONLY on squash/fixup suggestions.** Do *not* suggest `reword`, `edit`, `drop`, or provide a full rebase plan/command sequence.
|
|
|
-5. Format your response as a list of suggestions.
|
|
|
+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.
|
|
|
+3. For each suggestion, output *only* a line in the following format:
|
|
|
+ `FIXUP: <hash_to_fixup> INTO <preceding_hash>`
|
|
|
+ Use the short commit hashes provided in the commit list.
|
|
|
+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.
|
|
|
|
|
|
-**Example Output Format:**
|
|
|
+**Example Output:**
|
|
|
|
|
|
```text
|
|
|
-Based on the analysis, here are potential candidates for squashing or fixing up:
|
|
|
-
|
|
|
-* **Suggestion 1:**
|
|
|
- * Action: `fixup` commit `<hash2> fix typo` into `<hash1> feat: Add initial framework`.
|
|
|
- * Reason: Commit `<hash2>` appears to be a small correction directly related to the initial framework added in `<hash1>`. Its message can likely be discarded.
|
|
|
-
|
|
|
-* **Suggestion 2:**
|
|
|
- * Action: `squash` commit `<hash4> Add tests` into `<hash3> feat: Implement user login`.
|
|
|
- * 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.
|
|
|
-
|
|
|
-* **Suggestion 3:**
|
|
|
- * Action: `squash` commits `<hash6> WIP part 2` and `<hash7> WIP part 3` into `<hash5> feat: Start implementing feature X`.
|
|
|
- * 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.
|
|
|
+FIXUP: hash2 INTO hash1
|
|
|
+FIXUP: hash5 INTO hash4
|
|
|
```
|
|
|
|
|
|
-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:
|
|
|
+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:
|
|
|
`REQUEST_FILES: [commit_hash1:path/to/file1.py, commit_hash2:another/path/file2.js]`
|
|
|
- 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.
|
|
|
+ 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.
|
|
|
|
|
|
-Now, analyze the provided context and generate *only* the squash/fixup suggestions and their reasoning.
|
|
|
+Now, analyze the provided context and generate *only* the `FIXUP:` lines or `REQUEST_FILES:` line.
|
|
|
"""
|
|
|
return prompt
|
|
|
|
|
|
|
|
|
+def parse_fixup_suggestions(ai_response_text, commits_in_range):
|
|
|
+ """Parses AI response for FIXUP: lines and validates hashes."""
|
|
|
+ fixup_pairs = []
|
|
|
+ commit_hashes = {
|
|
|
+ c.split()[0] for c in commits_in_range
|
|
|
+ } # Set of valid short hashes
|
|
|
+
|
|
|
+ for line in ai_response_text.splitlines():
|
|
|
+ line = line.strip()
|
|
|
+ if line.startswith("FIXUP:"):
|
|
|
+ match = re.match(r"FIXUP:\s*(\w+)\s+INTO\s+(\w+)", line, re.IGNORECASE)
|
|
|
+ if match:
|
|
|
+ fixup_hash = match.group(1)
|
|
|
+ target_hash = match.group(2)
|
|
|
+ # Validate that both hashes were in the original commit list
|
|
|
+ if fixup_hash in commit_hashes and target_hash in commit_hashes:
|
|
|
+ fixup_pairs.append({"fixup": fixup_hash, "target": target_hash})
|
|
|
+ logging.debug(
|
|
|
+ f"Parsed fixup suggestion: {fixup_hash} into {target_hash}"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ logging.warning(
|
|
|
+ f"Ignoring invalid fixup suggestion (hash not in range): {line}"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ logging.warning(f"Could not parse FIXUP line: {line}")
|
|
|
+ return fixup_pairs
|
|
|
+
|
|
|
+
|
|
|
# --- request_files_from_user function remains the same ---
|
|
|
def request_files_from_user(requested_files_str, commits_in_range):
|
|
|
"""
|
|
@@ -327,26 +342,20 @@ def request_files_from_user(requested_files_str, commits_in_range):
|
|
|
"""
|
|
|
file_requests = []
|
|
|
try:
|
|
|
- # Extract the part within brackets using regex
|
|
|
content_match = re.search(
|
|
|
r"REQUEST_FILES:\s*\[(.*)\]", requested_files_str, re.IGNORECASE | re.DOTALL
|
|
|
)
|
|
|
if not content_match:
|
|
|
logging.warning("Could not parse file request format from AI response.")
|
|
|
- return None, None # Indicate parsing failure
|
|
|
+ return None, None
|
|
|
|
|
|
items_str = content_match.group(1).strip()
|
|
|
if not items_str:
|
|
|
logging.info("AI requested files but the list was empty.")
|
|
|
- return None, None # Empty request
|
|
|
+ return None, None
|
|
|
|
|
|
- # Split items, handling potential spaces around commas
|
|
|
items = [item.strip() for item in items_str.split(",") if item.strip()]
|
|
|
-
|
|
|
- # Map short hashes from the original list to verify AI request
|
|
|
- commit_hash_map = {
|
|
|
- c.split()[0]: c.split()[0] for c in commits_in_range
|
|
|
- } # short_hash: short_hash
|
|
|
+ commit_hash_map = {c.split()[0]: c.split()[0] for c in commits_in_range}
|
|
|
|
|
|
for item in items:
|
|
|
if ":" not in item:
|
|
@@ -358,36 +367,34 @@ def request_files_from_user(requested_files_str, commits_in_range):
|
|
|
commit_hash = commit_hash.strip()
|
|
|
file_path = file_path.strip()
|
|
|
|
|
|
- # Verify the short hash exists in our original list
|
|
|
if commit_hash not in commit_hash_map:
|
|
|
logging.warning(
|
|
|
f"AI requested file for unknown/out-of-range commit hash '{commit_hash}'. Skipping."
|
|
|
)
|
|
|
continue
|
|
|
-
|
|
|
file_requests.append({"hash": commit_hash, "path": file_path})
|
|
|
|
|
|
except Exception as e:
|
|
|
logging.error(f"Error parsing requested files string: {e}")
|
|
|
- return None, None # Indicate parsing error
|
|
|
+ return None, None
|
|
|
|
|
|
if not file_requests:
|
|
|
logging.info("No valid file requests found after parsing AI response.")
|
|
|
- return None, None # No valid requests
|
|
|
+ return None, None
|
|
|
|
|
|
print("\n----------------------------------------")
|
|
|
print("❓ AI Request for File Content ❓")
|
|
|
print("----------------------------------------")
|
|
|
print("The AI needs the content of the following files at specific commits")
|
|
|
- print("to provide more accurate squash/fixup suggestions:")
|
|
|
+ print("to provide more accurate fixup suggestions:")
|
|
|
files_to_fetch = []
|
|
|
for i, req in enumerate(file_requests):
|
|
|
print(f" {i + 1}. File: '{req['path']}' at commit {req['hash']}")
|
|
|
- files_to_fetch.append(req) # Add to list if valid
|
|
|
+ files_to_fetch.append(req)
|
|
|
|
|
|
if not files_to_fetch:
|
|
|
print("\nNo valid files to fetch based on the request.")
|
|
|
- return None, None # No files remain after validation
|
|
|
+ return None, None
|
|
|
|
|
|
print("----------------------------------------")
|
|
|
|
|
@@ -396,7 +403,7 @@ def request_files_from_user(requested_files_str, commits_in_range):
|
|
|
answer = (
|
|
|
input("Allow fetching these file contents? (yes/no): ").lower().strip()
|
|
|
)
|
|
|
- except EOFError: # Handle case where input stream is closed (e.g., piping)
|
|
|
+ except EOFError:
|
|
|
logging.warning("Input stream closed. Assuming 'no'.")
|
|
|
answer = "no"
|
|
|
|
|
@@ -406,47 +413,249 @@ def request_files_from_user(requested_files_str, commits_in_range):
|
|
|
for req in files_to_fetch:
|
|
|
content = get_file_content_at_commit(req["hash"], req["path"])
|
|
|
if content is not None:
|
|
|
- # Format for the AI prompt
|
|
|
fetched_content_list.append(
|
|
|
f"--- Content of '{req['path']}' at commit {req['hash']} ---\n"
|
|
|
f"```\n{content}\n```\n"
|
|
|
f"--- End Content for {req['path']} at {req['hash']} ---"
|
|
|
)
|
|
|
else:
|
|
|
- # Inform AI that content couldn't be fetched
|
|
|
fetched_content_list.append(
|
|
|
f"--- Could not fetch content of '{req['path']}' at commit {req['hash']} ---"
|
|
|
)
|
|
|
-
|
|
|
- # Return the combined content and the original request string for context
|
|
|
return "\n\n".join(fetched_content_list), requested_files_str
|
|
|
|
|
|
elif answer == "no":
|
|
|
logging.info("User denied fetching file content.")
|
|
|
- # Return None for content, but still return the request string
|
|
|
return None, requested_files_str
|
|
|
else:
|
|
|
print("Please answer 'yes' or 'no'.")
|
|
|
|
|
|
|
|
|
+# --- Automatic Rebase Logic ---
|
|
|
+
|
|
|
+
|
|
|
+def create_rebase_editor_script(script_path, fixup_plan):
|
|
|
+ """Creates the python script to be used by GIT_SEQUENCE_EDITOR."""
|
|
|
+ # Create a set of hashes that need to be fixed up
|
|
|
+ fixups_to_apply = {pair["fixup"] for pair in fixup_plan}
|
|
|
+
|
|
|
+ script_content = f"""#!/usr/bin/env python3
|
|
|
+import sys
|
|
|
+import logging
|
|
|
+import re
|
|
|
+import os
|
|
|
+
|
|
|
+# Define log file path relative to the script itself
|
|
|
+log_file = __file__ + ".log"
|
|
|
+# Setup logging within the editor script to write to the log file
|
|
|
+logging.basicConfig(filename=log_file, filemode='w', level=logging.WARN, format="%(asctime)s - %(levelname)s: %(message)s")
|
|
|
+
|
|
|
+todo_file_path = sys.argv[1]
|
|
|
+logging.info(f"GIT_SEQUENCE_EDITOR script started for: {{todo_file_path}}")
|
|
|
+
|
|
|
+# Hashes that should be changed to 'fixup'
|
|
|
+fixups_to_apply = {fixups_to_apply!r}
|
|
|
+logging.info(f"Applying fixups for hashes: {{fixups_to_apply}}")
|
|
|
+
|
|
|
+new_lines = []
|
|
|
+try:
|
|
|
+ with open(todo_file_path, 'r', encoding='utf-8') as f:
|
|
|
+ lines = f.readlines()
|
|
|
+
|
|
|
+ for line in lines:
|
|
|
+ stripped_line = line.strip()
|
|
|
+ # Skip comments and blank lines
|
|
|
+ if not stripped_line or stripped_line.startswith('#'):
|
|
|
+ new_lines.append(line)
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Use regex for more robust parsing of todo lines (action hash ...)
|
|
|
+ match = re.match(r"^(\w+)\s+([0-9a-fA-F]+)(.*)", stripped_line)
|
|
|
+ if match:
|
|
|
+ action = match.group(1).lower()
|
|
|
+ commit_hash = match.group(2)
|
|
|
+ rest_of_line = match.group(3)
|
|
|
+
|
|
|
+ # Check if this commit should be fixed up
|
|
|
+ if commit_hash in fixups_to_apply and action == 'pick':
|
|
|
+ logging.info(f"Changing 'pick {{commit_hash}}' to 'fixup {{commit_hash}}'")
|
|
|
+ # Replace 'pick' with 'fixup', preserving the rest of the line
|
|
|
+ new_line = f'f {{commit_hash}}{{rest_of_line}}\\n'
|
|
|
+ new_lines.append(new_line)
|
|
|
+ else:
|
|
|
+ # Keep the original line
|
|
|
+ new_lines.append(line)
|
|
|
+ else:
|
|
|
+ # Keep lines that don't look like standard todo lines
|
|
|
+ logging.warning(f"Could not parse todo line: {{stripped_line}}")
|
|
|
+ new_lines.append(line)
|
|
|
+
|
|
|
+
|
|
|
+ logging.info(f"Writing {{len(new_lines)}} lines back to {{todo_file_path}}")
|
|
|
+ with open(todo_file_path, 'w', encoding='utf-8') as f:
|
|
|
+ f.writelines(new_lines)
|
|
|
+
|
|
|
+ logging.info("GIT_SEQUENCE_EDITOR script finished successfully.")
|
|
|
+ sys.exit(0) # Explicitly exit successfully
|
|
|
+
|
|
|
+except Exception as e:
|
|
|
+ logging.error(f"Error in GIT_SEQUENCE_EDITOR script: {{e}}", exc_info=True)
|
|
|
+ sys.exit(1) # Exit with error code
|
|
|
+"""
|
|
|
+ try:
|
|
|
+ with open(script_path, "w", encoding="utf-8") as f:
|
|
|
+ f.write(script_content)
|
|
|
+ # Make the script executable (important on Linux/macOS)
|
|
|
+ os.chmod(script_path, 0o755)
|
|
|
+ logging.info(f"Created GIT_SEQUENCE_EDITOR script: {script_path}")
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ logging.error(f"Failed to create GIT_SEQUENCE_EDITOR script: {e}")
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def attempt_auto_fixup(merge_base, fixup_plan):
|
|
|
+ """Attempts to perform the rebase automatically applying fixups."""
|
|
|
+ if not fixup_plan:
|
|
|
+ logging.info("No fixup suggestions provided by AI. Skipping auto-rebase.")
|
|
|
+ return True # Nothing to do, considered success
|
|
|
+
|
|
|
+ # Use a temporary directory to hold the script and its log
|
|
|
+ temp_dir = tempfile.mkdtemp(prefix="git_rebase_")
|
|
|
+ editor_script_path = os.path.join(temp_dir, "rebase_editor.py")
|
|
|
+ logging.debug(f"Temporary directory: {temp_dir}")
|
|
|
+ logging.debug(f"Temporary editor script path: {editor_script_path}")
|
|
|
+
|
|
|
+ try:
|
|
|
+ if not create_rebase_editor_script(editor_script_path, fixup_plan):
|
|
|
+ return False # Failed to create script
|
|
|
+
|
|
|
+ # Prepare environment for the git command
|
|
|
+ rebase_env = os.environ.copy()
|
|
|
+ rebase_env["GIT_SEQUENCE_EDITOR"] = editor_script_path
|
|
|
+ # Prevent Git from opening a standard editor for messages etc.
|
|
|
+ # 'true' simply exits successfully, accepting default messages
|
|
|
+ rebase_env["GIT_EDITOR"] = "true"
|
|
|
+
|
|
|
+ print("\nAttempting automatic rebase with suggested fixups...")
|
|
|
+ logging.info(f"Running: git rebase -i {merge_base}")
|
|
|
+ # Run rebase non-interactively, check=False to handle failures manually
|
|
|
+ rebase_result = run_git_command(
|
|
|
+ ["rebase", "-i", merge_base],
|
|
|
+ check=False, # Don't raise exception on failure, check exit code
|
|
|
+ capture_output=True, # Capture output to see potential errors
|
|
|
+ env=rebase_env,
|
|
|
+ )
|
|
|
+
|
|
|
+ # Check the result (run_git_command returns None on CalledProcessError)
|
|
|
+ if rebase_result is not None:
|
|
|
+ # Command finished, exit code was likely 0 (success)
|
|
|
+ print("✅ Automatic fixup rebase completed successfully.")
|
|
|
+ logging.info("Automatic fixup rebase seems successful.")
|
|
|
+ return True
|
|
|
+ else:
|
|
|
+ # Command failed (non-zero exit code, run_git_command returned None)
|
|
|
+ print("\n❌ Automatic fixup rebase failed.")
|
|
|
+ print(
|
|
|
+ " This likely means merge conflicts occurred or another rebase error happened."
|
|
|
+ )
|
|
|
+ logging.warning("Automatic fixup rebase failed. Aborting...")
|
|
|
+
|
|
|
+ # Attempt to abort the failed rebase
|
|
|
+ print(" Attempting to abort the failed rebase (`git rebase --abort`)...")
|
|
|
+ # Run abort without capturing output, just check success/failure
|
|
|
+ abort_result = run_git_command(
|
|
|
+ ["rebase", "--abort"], check=False, capture_output=False
|
|
|
+ )
|
|
|
+ # run_git_command returns None on failure (CalledProcessError)
|
|
|
+ if abort_result is not None:
|
|
|
+ print(
|
|
|
+ " Rebase aborted successfully. Your branch is back to its original state."
|
|
|
+ )
|
|
|
+ logging.info("Failed rebase aborted successfully.")
|
|
|
+ else:
|
|
|
+ print(" ⚠️ Failed to automatically abort the rebase.")
|
|
|
+ print(" Please run `git rebase --abort` manually to clean up.")
|
|
|
+ logging.error("Failed to automatically abort the rebase.")
|
|
|
+ return False
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logging.error(
|
|
|
+ f"An unexpected error occurred during auto-fixup attempt: {e}",
|
|
|
+ exc_info=True,
|
|
|
+ )
|
|
|
+ # Might need manual cleanup here too
|
|
|
+ print("\n❌ An unexpected error occurred during the automatic fixup attempt.")
|
|
|
+ print(
|
|
|
+ " You may need to manually check your Git status and potentially run `git rebase --abort`."
|
|
|
+ )
|
|
|
+ return False
|
|
|
+ finally:
|
|
|
+ # Determine if rebase failed *before* potential cleanup errors
|
|
|
+ # Note: rebase_result is defined in the outer scope of the try block
|
|
|
+ rebase_failed = "rebase_result" in locals() and rebase_result is None
|
|
|
+
|
|
|
+ # Check if we need to display the editor script log
|
|
|
+ editor_log_path = editor_script_path + ".log"
|
|
|
+ verbose_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
|
|
+
|
|
|
+ if (rebase_failed or verbose_logging) and os.path.exists(editor_log_path):
|
|
|
+ try:
|
|
|
+ with open(editor_log_path, "r", encoding="utf-8") as log_f:
|
|
|
+ log_content = log_f.read()
|
|
|
+ if log_content:
|
|
|
+ print("\n--- Rebase Editor Script Log ---")
|
|
|
+ print(log_content.strip())
|
|
|
+ print("--- End Log ---")
|
|
|
+ else:
|
|
|
+ # Only log if verbose, otherwise it's just noise
|
|
|
+ if verbose_logging:
|
|
|
+ logging.debug(
|
|
|
+ f"Rebase editor script log file was empty: {editor_log_path}"
|
|
|
+ )
|
|
|
+ except Exception as log_e:
|
|
|
+ logging.warning(
|
|
|
+ f"Could not read rebase editor script log file {editor_log_path}: {log_e}"
|
|
|
+ )
|
|
|
+
|
|
|
+ # Clean up the temporary directory and its contents
|
|
|
+ if temp_dir and os.path.exists(temp_dir):
|
|
|
+ try:
|
|
|
+ if os.path.exists(editor_log_path):
|
|
|
+ os.remove(editor_log_path)
|
|
|
+ if os.path.exists(editor_script_path):
|
|
|
+ os.remove(editor_script_path)
|
|
|
+ os.rmdir(temp_dir)
|
|
|
+ logging.debug(f"Cleaned up temporary directory: {temp_dir}")
|
|
|
+ except OSError as e:
|
|
|
+ logging.warning(
|
|
|
+ f"Could not completely remove temporary directory {temp_dir}: {e}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
# --- Main Execution ---
|
|
|
|
|
|
|
|
|
def main():
|
|
|
"""Main function to orchestrate Git analysis and AI interaction."""
|
|
|
parser = argparse.ArgumentParser(
|
|
|
- description="Uses Gemini AI to suggest potential Git squash/fixup candidates.",
|
|
|
+ description="Uses Gemini AI to suggest and automatically attempt Git 'fixup' operations.",
|
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
|
)
|
|
|
parser.add_argument(
|
|
|
"upstream_ref",
|
|
|
nargs="?",
|
|
|
- # Default to common upstream names, user MUST ensure one exists
|
|
|
default="upstream/main",
|
|
|
help="The upstream reference point or commit hash to compare against "
|
|
|
"(e.g., 'origin/main', 'upstream/develop', specific_commit_hash). "
|
|
|
"Ensure this reference exists and is fetched.",
|
|
|
)
|
|
|
+ # --- Argument Change ---
|
|
|
+ parser.add_argument(
|
|
|
+ "--instruct",
|
|
|
+ action="store_true",
|
|
|
+ help="Only show AI suggestions and instructions; disable automatic fixup attempt.",
|
|
|
+ )
|
|
|
parser.add_argument(
|
|
|
"-v", "--verbose", action="store_true", help="Enable verbose debug logging."
|
|
|
)
|
|
@@ -470,9 +679,9 @@ def main():
|
|
|
logging.info(f"Comparing against reference: {upstream_ref}")
|
|
|
|
|
|
# --- Safety: Create Backup Branch ---
|
|
|
+ # Always create backup, especially if attempting auto-rebase
|
|
|
backup_branch = create_backup_branch(current_branch)
|
|
|
if not backup_branch:
|
|
|
- # Ask user if they want to continue without a backup
|
|
|
try:
|
|
|
confirm = input(
|
|
|
"⚠️ Failed to create backup branch. Continue without backup? (yes/no): "
|
|
@@ -488,9 +697,7 @@ def main():
|
|
|
else:
|
|
|
print("-" * 40)
|
|
|
print(f"✅ Backup branch created: {backup_branch}")
|
|
|
- print(
|
|
|
- " If anything goes wrong during manual rebase later, you can restore using:"
|
|
|
- )
|
|
|
+ print(" If anything goes wrong, you can restore using:")
|
|
|
print(f" git checkout {current_branch}")
|
|
|
print(f" git reset --hard {backup_branch}")
|
|
|
print("-" * 40)
|
|
@@ -498,7 +705,7 @@ def main():
|
|
|
# --- Gather Git Context ---
|
|
|
print("\nGathering Git context...")
|
|
|
commit_range, merge_base = get_commit_range(upstream_ref, current_branch)
|
|
|
- if not commit_range: # Error handled in get_commit_range
|
|
|
+ if not commit_range:
|
|
|
sys.exit(1)
|
|
|
|
|
|
logging.info(f"Analyzing commit range: {commit_range} (Merge Base: {merge_base})")
|
|
@@ -506,7 +713,7 @@ def main():
|
|
|
commits = get_commits_in_range(commit_range)
|
|
|
if not commits:
|
|
|
logging.info(
|
|
|
- f"No commits found between '{merge_base}' and '{current_branch}'. Nothing to suggest."
|
|
|
+ f"No commits found between '{merge_base}' and '{current_branch}'. Nothing to do."
|
|
|
)
|
|
|
sys.exit(0)
|
|
|
|
|
@@ -518,39 +725,28 @@ def main():
|
|
|
f"No file changes or diff found between '{merge_base}' and '{current_branch}',"
|
|
|
)
|
|
|
logging.warning("even though commits exist. AI suggestions might be limited.")
|
|
|
- print("Commits found:")
|
|
|
- for c in commits:
|
|
|
- print(f"- {c}")
|
|
|
- try:
|
|
|
- confirm_proceed = input(
|
|
|
- "Proceed with AI analysis despite no diff? (yes/no): "
|
|
|
- ).lower()
|
|
|
- except EOFError:
|
|
|
- confirm_proceed = "no"
|
|
|
- if confirm_proceed != "yes":
|
|
|
- logging.info("Aborting analysis.")
|
|
|
- sys.exit(0)
|
|
|
+ # Don't exit automatically, let AI try
|
|
|
|
|
|
# --- Interact with AI ---
|
|
|
- print("\nGenerating prompt for AI squash/fixup suggestions...")
|
|
|
- # *** Use the new prompt function ***
|
|
|
- initial_prompt = generate_squash_suggestion_prompt(
|
|
|
+ print("\nGenerating prompt for AI fixup suggestions...")
|
|
|
+ initial_prompt = generate_fixup_suggestion_prompt(
|
|
|
commit_range, merge_base, commits, file_structure, diff
|
|
|
)
|
|
|
|
|
|
logging.debug("\n--- Initial AI Prompt Snippet ---")
|
|
|
- logging.debug(initial_prompt[:1000] + "...") # Log beginning of prompt
|
|
|
+ logging.debug(initial_prompt[:1000] + "...")
|
|
|
logging.debug("--- End Prompt Snippet ---\n")
|
|
|
|
|
|
- print(f"Sending request to Gemini AI ({MODEL_NAME})... This may take a moment.")
|
|
|
+ print(f"Sending request to Gemini AI ({MODEL_NAME})...")
|
|
|
|
|
|
+ ai_response_text = ""
|
|
|
+ fixup_suggestions_text = "" # Store the raw suggestions for later display if needed
|
|
|
try:
|
|
|
- # Start a chat session for potential follow-ups (file requests)
|
|
|
convo = model.start_chat(history=[])
|
|
|
response = convo.send_message(initial_prompt)
|
|
|
ai_response_text = response.text
|
|
|
|
|
|
- # Loop to handle potential file requests (still relevant for squash decisions)
|
|
|
+ # Loop for file requests
|
|
|
while "REQUEST_FILES:" in ai_response_text.upper():
|
|
|
logging.info("AI requested additional file content.")
|
|
|
additional_context, original_request = request_files_from_user(
|
|
@@ -559,13 +755,12 @@ def main():
|
|
|
|
|
|
if additional_context:
|
|
|
logging.info("Sending fetched file content back to AI...")
|
|
|
- # Construct follow-up prompt for squash suggestions
|
|
|
follow_up_prompt = f"""
|
|
|
Okay, here is the content of the files you requested:
|
|
|
|
|
|
{additional_context}
|
|
|
|
|
|
-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.
|
|
|
+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.
|
|
|
"""
|
|
|
logging.debug("\n--- Follow-up AI Prompt Snippet ---")
|
|
|
logging.debug(follow_up_prompt[:500] + "...")
|
|
@@ -576,73 +771,117 @@ Please use this new information to refine your **squash/fixup suggestions** base
|
|
|
logging.info(
|
|
|
"Proceeding without providing files as requested by AI or user."
|
|
|
)
|
|
|
- # Tell the AI to proceed without the files it asked for
|
|
|
no_files_prompt = f"""
|
|
|
I cannot provide the content for the files you requested ({original_request}).
|
|
|
-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.
|
|
|
+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.
|
|
|
"""
|
|
|
logging.debug("\n--- No-Files AI Prompt ---")
|
|
|
logging.debug(no_files_prompt)
|
|
|
logging.debug("--- End No-Files Prompt ---\n")
|
|
|
response = convo.send_message(no_files_prompt)
|
|
|
ai_response_text = response.text
|
|
|
- # Break the loop as we've instructed AI to proceed without files
|
|
|
break
|
|
|
|
|
|
- print("\n💡 --- AI Squash/Fixup Suggestions --- 💡")
|
|
|
- # Basic cleanup: remove potential markdown code block fences if AI adds them unnecessarily
|
|
|
- suggestion = ai_response_text.strip()
|
|
|
- suggestion = re.sub(r"^```(?:bash|text|)\n", "", suggestion, flags=re.MULTILINE)
|
|
|
- suggestion = re.sub(r"\n```$", "", suggestion, flags=re.MULTILINE)
|
|
|
-
|
|
|
- print(suggestion)
|
|
|
- print("💡 --- End AI Suggestions --- 💡")
|
|
|
-
|
|
|
- print("\n" + "=" * 60)
|
|
|
- print("📝 NEXT STEPS 📝")
|
|
|
- print("=" * 60)
|
|
|
- print("1. REVIEW the suggestions above carefully.")
|
|
|
- print("2. These are *only suggestions* for potential squashes/fixups.")
|
|
|
- print(" No changes have been made to your Git history.")
|
|
|
- print("3. If you want to apply these (or other) changes, you can:")
|
|
|
- print(f" a. Manually run `git rebase -i {merge_base}`.")
|
|
|
- print(" b. Edit the 'pick' lines in the editor based on these suggestions")
|
|
|
- print(" (changing 'pick' to 'squash' or 'fixup' as appropriate).")
|
|
|
- print(" c. Save the editor and follow Git's instructions.")
|
|
|
- # Optional: Could add a suggestion to run the original script version
|
|
|
- # print(" d. Alternatively, run a version of this script that asks the AI")
|
|
|
- # print(" for a full rebase plan.")
|
|
|
- if backup_branch:
|
|
|
- print(f"4. Remember your backup branch is: {backup_branch}")
|
|
|
- print(
|
|
|
- f" If needed, restore with: git checkout {current_branch} && git reset --hard {backup_branch}"
|
|
|
- )
|
|
|
+ # Store the final AI response containing suggestions
|
|
|
+ fixup_suggestions_text = ai_response_text.strip()
|
|
|
+
|
|
|
+ # Parse the suggestions
|
|
|
+ fixup_plan = parse_fixup_suggestions(fixup_suggestions_text, commits)
|
|
|
+
|
|
|
+ if not fixup_plan:
|
|
|
+ print("\n💡 AI did not suggest any specific fixup operations.")
|
|
|
else:
|
|
|
- print(
|
|
|
- "4. WARNING: No backup branch was created. Proceed with extra caution if rebasing."
|
|
|
- )
|
|
|
- print("=" * 60)
|
|
|
+ print("\n💡 --- AI Fixup Suggestions --- 💡")
|
|
|
+ # Print the parsed plan for clarity
|
|
|
+ for i, pair in enumerate(fixup_plan):
|
|
|
+ print(
|
|
|
+ f" {i + 1}. Fixup commit `{pair['fixup']}` into `{pair['target']}`"
|
|
|
+ )
|
|
|
+ print("💡 --- End AI Suggestions --- 💡")
|
|
|
+
|
|
|
+ # --- Attempt Automatic Rebase or Show Instructions ---
|
|
|
+ # --- Logic Change ---
|
|
|
+ if not args.instruct: # Default behavior: attempt auto-fixup
|
|
|
+ if fixup_plan:
|
|
|
+ success = attempt_auto_fixup(merge_base, fixup_plan)
|
|
|
+ if not success:
|
|
|
+ # Failure message already printed by attempt_auto_fixup
|
|
|
+ print("\n" + "=" * 60)
|
|
|
+ print("🛠️ MANUAL REBASE REQUIRED 🛠️")
|
|
|
+ print("=" * 60)
|
|
|
+ print(
|
|
|
+ "The automatic fixup rebase failed (likely due to conflicts)."
|
|
|
+ )
|
|
|
+ print("Please perform the rebase manually:")
|
|
|
+ print(f" 1. Run: `git rebase -i {merge_base}`")
|
|
|
+ print(
|
|
|
+ " 2. In the editor, change 'pick' to 'f' (or 'fixup') for the commits"
|
|
|
+ )
|
|
|
+ print(
|
|
|
+ " suggested by the AI above (and any other changes you want)."
|
|
|
+ )
|
|
|
+ print(" Original AI suggestions:")
|
|
|
+ print(" ```text")
|
|
|
+ # Print raw suggestions which might be easier to copy/paste
|
|
|
+ print(
|
|
|
+ fixup_suggestions_text
|
|
|
+ if fixup_suggestions_text
|
|
|
+ else " (No specific fixup lines found in AI response)"
|
|
|
+ )
|
|
|
+ print(" ```")
|
|
|
+ print(" 3. Save the editor and resolve any conflicts Git reports.")
|
|
|
+ print(
|
|
|
+ " Use `git status`, edit files, `git add <files>`, `git rebase --continue`."
|
|
|
+ )
|
|
|
+ if backup_branch:
|
|
|
+ print(f" 4. Remember backup branch: {backup_branch}")
|
|
|
+ print("=" * 60)
|
|
|
+ sys.exit(1) # Exit with error status after failure
|
|
|
+ else:
|
|
|
+ # Auto fixup succeeded
|
|
|
+ print("\nBranch history has been modified by automatic fixups.")
|
|
|
+ if backup_branch:
|
|
|
+ print(
|
|
|
+ f"Backup branch '{backup_branch}' still exists if needed."
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ print("\nNo automatic rebase attempted as AI suggested no fixups.")
|
|
|
+
|
|
|
+ elif fixup_plan: # --instruct flag was used AND suggestions exist
|
|
|
+ print("\n" + "=" * 60)
|
|
|
+ print("📝 MANUAL REBASE INSTRUCTIONS (--instruct used) 📝")
|
|
|
+ print("=" * 60)
|
|
|
+ print("AI suggested the fixups listed above.")
|
|
|
+ print("To apply them (or other changes):")
|
|
|
+ print(f" 1. Run: `git rebase -i {merge_base}`")
|
|
|
+ print(" 2. Edit the 'pick' lines in the editor based on the suggestions")
|
|
|
+ print(" (changing 'pick' to 'f' or 'fixup').")
|
|
|
+ print(" 3. Save the editor and follow Git's instructions.")
|
|
|
+ if backup_branch:
|
|
|
+ print(f" 4. Remember backup branch: {backup_branch}")
|
|
|
+ print("=" * 60)
|
|
|
+ # If --instruct and no fixup_plan, nothing specific needs to be printed here
|
|
|
|
|
|
except Exception as e:
|
|
|
- logging.error(f"\nAn error occurred during AI interaction: {e}")
|
|
|
- # Attempt to print feedback if available in the response object
|
|
|
+ logging.error(f"\nAn unexpected error occurred: {e}", exc_info=True)
|
|
|
+ # Attempt to print feedback if available
|
|
|
try:
|
|
|
if response and hasattr(response, "prompt_feedback"):
|
|
|
logging.error(f"AI Prompt Feedback: {response.prompt_feedback}")
|
|
|
if response and hasattr(response, "candidates"):
|
|
|
- # Log candidate details, potentially including finish reason
|
|
|
for candidate in response.candidates:
|
|
|
logging.error(
|
|
|
f"AI Candidate Finish Reason: {candidate.finish_reason}"
|
|
|
)
|
|
|
- # Safety details if available
|
|
|
if hasattr(candidate, "safety_ratings"):
|
|
|
logging.error(f"AI Safety Ratings: {candidate.safety_ratings}")
|
|
|
-
|
|
|
except Exception as feedback_e:
|
|
|
logging.error(
|
|
|
f"Could not retrieve detailed feedback from AI response: {feedback_e}"
|
|
|
)
|
|
|
+ print("\n❌ An unexpected error occurred during the process.")
|
|
|
+ print(" Please check the logs and your Git status.")
|
|
|
+ print(" You may need to run `git rebase --abort` manually.")
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|