mirror of
https://github.com/Aider-AI/aider.git
synced 2025-05-24 06:15:00 +00:00
589 lines
21 KiB
Python
589 lines
21 KiB
Python
import contextlib
|
|
import os
|
|
import time
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
try:
|
|
import git
|
|
|
|
ANY_GIT_ERROR = [
|
|
git.exc.ODBError,
|
|
git.exc.GitError,
|
|
git.exc.InvalidGitRepositoryError,
|
|
git.exc.GitCommandNotFound,
|
|
]
|
|
except ImportError:
|
|
git = None
|
|
ANY_GIT_ERROR = []
|
|
|
|
import pathspec
|
|
|
|
from aider import prompts, utils
|
|
|
|
from .dump import dump # noqa: F401
|
|
|
|
ANY_GIT_ERROR += [
|
|
OSError,
|
|
IndexError,
|
|
BufferError,
|
|
TypeError,
|
|
ValueError,
|
|
AttributeError,
|
|
AssertionError,
|
|
TimeoutError,
|
|
]
|
|
ANY_GIT_ERROR = tuple(ANY_GIT_ERROR)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def set_git_env(var_name, value, original_value):
|
|
"""Temporarily set a Git environment variable."""
|
|
os.environ[var_name] = value
|
|
try:
|
|
yield
|
|
finally:
|
|
if original_value is not None:
|
|
os.environ[var_name] = original_value
|
|
elif var_name in os.environ:
|
|
del os.environ[var_name]
|
|
|
|
|
|
class GitRepo:
|
|
repo = None
|
|
aider_ignore_file = None
|
|
aider_ignore_spec = None
|
|
aider_ignore_ts = 0
|
|
aider_ignore_last_check = 0
|
|
subtree_only = False
|
|
ignore_file_cache = {}
|
|
git_repo_error = None
|
|
|
|
def __init__(
|
|
self,
|
|
io,
|
|
fnames,
|
|
git_dname,
|
|
aider_ignore_file=None,
|
|
models=None,
|
|
attribute_author=True,
|
|
attribute_committer=True,
|
|
attribute_commit_message_author=False,
|
|
attribute_commit_message_committer=False,
|
|
commit_prompt=None,
|
|
subtree_only=False,
|
|
git_commit_verify=True,
|
|
attribute_co_authored_by=False, # Added parameter
|
|
):
|
|
self.io = io
|
|
self.models = models
|
|
|
|
self.normalized_path = {}
|
|
self.tree_files = {}
|
|
|
|
self.attribute_author = attribute_author
|
|
self.attribute_committer = attribute_committer
|
|
self.attribute_commit_message_author = attribute_commit_message_author
|
|
self.attribute_commit_message_committer = attribute_commit_message_committer
|
|
self.attribute_co_authored_by = attribute_co_authored_by # Assign from parameter
|
|
self.commit_prompt = commit_prompt
|
|
self.subtree_only = subtree_only
|
|
self.git_commit_verify = git_commit_verify
|
|
self.ignore_file_cache = {}
|
|
|
|
if git_dname:
|
|
check_fnames = [git_dname]
|
|
elif fnames:
|
|
check_fnames = fnames
|
|
else:
|
|
check_fnames = ["."]
|
|
|
|
repo_paths = []
|
|
for fname in check_fnames:
|
|
fname = Path(fname)
|
|
fname = fname.resolve()
|
|
|
|
if not fname.exists() and fname.parent.exists():
|
|
fname = fname.parent
|
|
|
|
try:
|
|
repo_path = git.Repo(fname, search_parent_directories=True).working_dir
|
|
repo_path = utils.safe_abs_path(repo_path)
|
|
repo_paths.append(repo_path)
|
|
except ANY_GIT_ERROR:
|
|
pass
|
|
|
|
num_repos = len(set(repo_paths))
|
|
|
|
if num_repos == 0:
|
|
raise FileNotFoundError
|
|
if num_repos > 1:
|
|
self.io.tool_error("Files are in different git repos.")
|
|
raise FileNotFoundError
|
|
|
|
# https://github.com/gitpython-developers/GitPython/issues/427
|
|
self.repo = git.Repo(repo_paths.pop(), odbt=git.GitDB)
|
|
self.root = utils.safe_abs_path(self.repo.working_tree_dir)
|
|
|
|
if aider_ignore_file:
|
|
self.aider_ignore_file = Path(aider_ignore_file)
|
|
|
|
def commit(self, fnames=None, context=None, message=None, aider_edits=False, coder=None):
|
|
"""
|
|
Commit the specified files or all dirty files if none are specified.
|
|
|
|
Args:
|
|
fnames (list, optional): List of filenames to commit. Defaults to None (commit all
|
|
dirty files).
|
|
context (str, optional): Context for generating the commit message. Defaults to None.
|
|
message (str, optional): Explicit commit message. Defaults to None (generate message).
|
|
aider_edits (bool, optional): Whether the changes were made by Aider. Defaults to False.
|
|
This affects attribution logic.
|
|
coder (Coder, optional): The Coder instance, used to access config and model info.
|
|
Defaults to None.
|
|
|
|
Returns:
|
|
tuple(str, str) or None: The commit hash and commit message if successful, else None.
|
|
|
|
Attribution Logic:
|
|
------------------
|
|
This method handles Git commit attribution based on configuration flags and whether
|
|
Aider generated the changes (`aider_edits`).
|
|
|
|
Key Concepts:
|
|
- Author: The person who originally wrote the code changes.
|
|
- Committer: The person who last applied the commit to the repository.
|
|
- aider_edits=True: Changes were generated by Aider (LLM).
|
|
- aider_edits=False: Commit is user-driven (e.g., /commit manually staged changes).
|
|
- Explicit Setting: A flag (--attribute-...) is set to True or False via command line
|
|
or config file.
|
|
- Implicit Default: A flag is not explicitly set, defaulting to None in args, which is
|
|
interpreted as True unless overridden by other logic.
|
|
|
|
Flags:
|
|
- --attribute-author: Modify Author name to "User Name (aider)".
|
|
- --attribute-committer: Modify Committer name to "User Name (aider)".
|
|
- --attribute-co-authored-by: Add "Co-authored-by: aider (<model>) <noreply@aider.chat>"
|
|
trailer to the commit message.
|
|
|
|
Behavior Summary:
|
|
|
|
1. When aider_edits = True (AI Changes):
|
|
- If --attribute-co-authored-by=True:
|
|
- Co-authored-by trailer IS ADDED.
|
|
- Author/Committer names are NOT modified by default (co-authored-by takes precedence).
|
|
- EXCEPTION: If --attribute-author/--attribute-committer is EXPLICITLY True,
|
|
the respective name IS modified (explicit overrides precedence).
|
|
- If --attribute-co-authored-by=False:
|
|
- Co-authored-by trailer is NOT added.
|
|
- Author/Committer names ARE modified by default (implicit True).
|
|
- EXCEPTION: If --attribute-author/--attribute-committer is EXPLICITLY False,
|
|
the respective name is NOT modified.
|
|
|
|
2. When aider_edits = False (User Changes):
|
|
- --attribute-co-authored-by is IGNORED (trailer never added).
|
|
- Author name is NEVER modified (--attribute-author ignored).
|
|
- Committer name IS modified by default (implicit True, as Aider runs `git commit`).
|
|
- EXCEPTION: If --attribute-committer is EXPLICITLY False, the name is NOT modified.
|
|
|
|
Resulting Scenarios:
|
|
- Standard AI edit (defaults): Co-authored-by=False -> Author=You(aider), Committer=You(aider)
|
|
- AI edit with Co-authored-by (default): Co-authored-by=True -> Author=You, Committer=You, Trailer added
|
|
- AI edit with Co-authored-by + Explicit Author: Co-authored-by=True, --attribute-author -> Author=You(aider), Committer=You, Trailer added
|
|
- User commit (defaults): aider_edits=False -> Author=You, Committer=You(aider)
|
|
- User commit with explicit no-committer: aider_edits=False, --no-attribute-committer -> Author=You, Committer=You
|
|
"""
|
|
if not fnames and not self.repo.is_dirty():
|
|
return
|
|
|
|
diffs = self.get_diffs(fnames)
|
|
if not diffs:
|
|
return
|
|
|
|
if message:
|
|
commit_message = message
|
|
else:
|
|
commit_message = self.get_commit_message(diffs, context)
|
|
|
|
# Retrieve attribute settings, prioritizing coder.args if available
|
|
if coder and hasattr(coder, "args"):
|
|
attribute_author = coder.args.attribute_author
|
|
attribute_committer = coder.args.attribute_committer
|
|
attribute_commit_message_author = coder.args.attribute_commit_message_author
|
|
attribute_commit_message_committer = coder.args.attribute_commit_message_committer
|
|
attribute_co_authored_by = coder.args.attribute_co_authored_by
|
|
else:
|
|
# Fallback to self attributes (initialized from config/defaults)
|
|
attribute_author = self.attribute_author
|
|
attribute_committer = self.attribute_committer
|
|
attribute_commit_message_author = self.attribute_commit_message_author
|
|
attribute_commit_message_committer = self.attribute_commit_message_committer
|
|
attribute_co_authored_by = self.attribute_co_authored_by
|
|
|
|
# Determine explicit settings (None means use default behavior)
|
|
author_explicit = attribute_author is not None
|
|
committer_explicit = attribute_committer is not None
|
|
|
|
# Determine effective settings (apply default True if not explicit)
|
|
effective_author = True if attribute_author is None else attribute_author
|
|
effective_committer = True if attribute_committer is None else attribute_committer
|
|
|
|
|
|
# Determine commit message prefixing
|
|
prefix_commit_message = aider_edits and (
|
|
attribute_commit_message_author or attribute_commit_message_committer
|
|
)
|
|
|
|
# Determine Co-authored-by trailer
|
|
commit_message_trailer = ""
|
|
if aider_edits and attribute_co_authored_by:
|
|
model_name = "unknown-model"
|
|
if coder and hasattr(coder, "main_model") and coder.main_model.name:
|
|
model_name = coder.main_model.name
|
|
commit_message_trailer = (
|
|
f"\n\nCo-authored-by: aider ({model_name}) <noreply@aider.chat>"
|
|
)
|
|
|
|
# Determine if author/committer names should be modified
|
|
# Author modification applies only to aider edits.
|
|
# It's used if effective_author is True AND (co-authored-by is False OR author was explicitly set).
|
|
use_attribute_author = (
|
|
aider_edits
|
|
and effective_author
|
|
and (not attribute_co_authored_by or author_explicit)
|
|
)
|
|
|
|
# Committer modification applies regardless of aider_edits (based on tests).
|
|
# It's used if effective_committer is True AND (it's not an aider edit with co-authored-by OR committer was explicitly set).
|
|
use_attribute_committer = effective_committer and (
|
|
not (aider_edits and attribute_co_authored_by) or committer_explicit
|
|
)
|
|
|
|
|
|
if not commit_message:
|
|
commit_message = "(no commit message provided)"
|
|
|
|
if prefix_commit_message:
|
|
commit_message = "aider: " + commit_message
|
|
|
|
full_commit_message = commit_message + commit_message_trailer
|
|
|
|
cmd = ["-m", full_commit_message]
|
|
if not self.git_commit_verify:
|
|
cmd.append("--no-verify")
|
|
if fnames:
|
|
fnames = [str(self.abs_root_path(fn)) for fn in fnames]
|
|
for fname in fnames:
|
|
try:
|
|
self.repo.git.add(fname)
|
|
except ANY_GIT_ERROR as err:
|
|
self.io.tool_error(f"Unable to add {fname}: {err}")
|
|
cmd += ["--"] + fnames
|
|
else:
|
|
cmd += ["-a"]
|
|
|
|
original_user_name = self.repo.git.config("--get", "user.name")
|
|
original_committer_name_env = os.environ.get("GIT_COMMITTER_NAME")
|
|
original_author_name_env = os.environ.get("GIT_AUTHOR_NAME")
|
|
committer_name = f"{original_user_name} (aider)"
|
|
|
|
try:
|
|
# Use context managers to handle environment variables
|
|
with contextlib.ExitStack() as stack:
|
|
if use_attribute_committer:
|
|
stack.enter_context(
|
|
set_git_env("GIT_COMMITTER_NAME", committer_name, original_committer_name_env)
|
|
)
|
|
if use_attribute_author:
|
|
stack.enter_context(
|
|
set_git_env("GIT_AUTHOR_NAME", committer_name, original_author_name_env)
|
|
)
|
|
|
|
# Perform the commit
|
|
self.repo.git.commit(cmd)
|
|
commit_hash = self.get_head_commit_sha(short=True)
|
|
self.io.tool_output(f"Commit {commit_hash} {commit_message}", bold=True)
|
|
return commit_hash, commit_message
|
|
|
|
except ANY_GIT_ERROR as err:
|
|
self.io.tool_error(f"Unable to commit: {err}")
|
|
# No return here, implicitly returns None
|
|
|
|
def get_rel_repo_dir(self):
|
|
try:
|
|
return os.path.relpath(self.repo.git_dir, os.getcwd())
|
|
except (ValueError, OSError):
|
|
return self.repo.git_dir
|
|
|
|
def get_commit_message(self, diffs, context):
|
|
diffs = "# Diffs:\n" + diffs
|
|
|
|
content = ""
|
|
if context:
|
|
content += context + "\n"
|
|
content += diffs
|
|
|
|
system_content = self.commit_prompt or prompts.commit_system
|
|
messages = [
|
|
dict(role="system", content=system_content),
|
|
dict(role="user", content=content),
|
|
]
|
|
|
|
commit_message = None
|
|
for model in self.models:
|
|
num_tokens = model.token_count(messages)
|
|
max_tokens = model.info.get("max_input_tokens") or 0
|
|
if max_tokens and num_tokens > max_tokens:
|
|
continue
|
|
commit_message = model.simple_send_with_retries(messages)
|
|
if commit_message:
|
|
break
|
|
|
|
if not commit_message:
|
|
self.io.tool_error("Failed to generate commit message!")
|
|
return
|
|
|
|
commit_message = commit_message.strip()
|
|
if commit_message and commit_message[0] == '"' and commit_message[-1] == '"':
|
|
commit_message = commit_message[1:-1].strip()
|
|
|
|
return commit_message
|
|
|
|
def get_diffs(self, fnames=None):
|
|
# We always want diffs of index and working dir
|
|
|
|
current_branch_has_commits = False
|
|
try:
|
|
active_branch = self.repo.active_branch
|
|
try:
|
|
commits = self.repo.iter_commits(active_branch)
|
|
current_branch_has_commits = any(commits)
|
|
except ANY_GIT_ERROR:
|
|
pass
|
|
except (TypeError,) + ANY_GIT_ERROR:
|
|
pass
|
|
|
|
if not fnames:
|
|
fnames = []
|
|
|
|
diffs = ""
|
|
for fname in fnames:
|
|
if not self.path_in_repo(fname):
|
|
diffs += f"Added {fname}\n"
|
|
|
|
try:
|
|
if current_branch_has_commits:
|
|
args = ["HEAD", "--"] + list(fnames)
|
|
diffs += self.repo.git.diff(*args)
|
|
return diffs
|
|
|
|
wd_args = ["--"] + list(fnames)
|
|
index_args = ["--cached"] + wd_args
|
|
|
|
diffs += self.repo.git.diff(*index_args)
|
|
diffs += self.repo.git.diff(*wd_args)
|
|
|
|
return diffs
|
|
except ANY_GIT_ERROR as err:
|
|
self.io.tool_error(f"Unable to diff: {err}")
|
|
|
|
def diff_commits(self, pretty, from_commit, to_commit):
|
|
args = []
|
|
if pretty:
|
|
args += ["--color"]
|
|
else:
|
|
args += ["--color=never"]
|
|
|
|
args += [from_commit, to_commit]
|
|
diffs = self.repo.git.diff(*args)
|
|
|
|
return diffs
|
|
|
|
def get_tracked_files(self):
|
|
if not self.repo:
|
|
return []
|
|
|
|
try:
|
|
commit = self.repo.head.commit
|
|
except ValueError:
|
|
commit = None
|
|
except ANY_GIT_ERROR as err:
|
|
self.git_repo_error = err
|
|
self.io.tool_error(f"Unable to list files in git repo: {err}")
|
|
self.io.tool_output("Is your git repo corrupted?")
|
|
return []
|
|
|
|
files = set()
|
|
if commit:
|
|
if commit in self.tree_files:
|
|
files = self.tree_files[commit]
|
|
else:
|
|
try:
|
|
iterator = commit.tree.traverse()
|
|
blob = None # Initialize blob
|
|
while True:
|
|
try:
|
|
blob = next(iterator)
|
|
if blob.type == "blob": # blob is a file
|
|
files.add(blob.path)
|
|
except IndexError:
|
|
# Handle potential index error during tree traversal
|
|
# without relying on potentially unassigned 'blob'
|
|
self.io.tool_warning(
|
|
"GitRepo: Index error encountered while reading git tree object."
|
|
" Skipping."
|
|
)
|
|
continue
|
|
except StopIteration:
|
|
break
|
|
except ANY_GIT_ERROR as err:
|
|
self.git_repo_error = err
|
|
self.io.tool_error(f"Unable to list files in git repo: {err}")
|
|
self.io.tool_output("Is your git repo corrupted?")
|
|
return []
|
|
files = set(self.normalize_path(path) for path in files)
|
|
self.tree_files[commit] = set(files)
|
|
|
|
# Add staged files
|
|
index = self.repo.index
|
|
try:
|
|
staged_files = [path for path, _ in index.entries.keys()]
|
|
files.update(self.normalize_path(path) for path in staged_files)
|
|
except ANY_GIT_ERROR as err:
|
|
self.io.tool_error(f"Unable to read staged files: {err}")
|
|
|
|
res = [fname for fname in files if not self.ignored_file(fname)]
|
|
|
|
return res
|
|
|
|
def normalize_path(self, path):
|
|
orig_path = path
|
|
res = self.normalized_path.get(orig_path)
|
|
if res:
|
|
return res
|
|
|
|
path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root))))
|
|
self.normalized_path[orig_path] = path
|
|
return path
|
|
|
|
def refresh_aider_ignore(self):
|
|
if not self.aider_ignore_file:
|
|
return
|
|
|
|
current_time = time.time()
|
|
if current_time - self.aider_ignore_last_check < 1:
|
|
return
|
|
|
|
self.aider_ignore_last_check = current_time
|
|
|
|
if not self.aider_ignore_file.is_file():
|
|
return
|
|
|
|
mtime = self.aider_ignore_file.stat().st_mtime
|
|
if mtime != self.aider_ignore_ts:
|
|
self.aider_ignore_ts = mtime
|
|
self.ignore_file_cache = {}
|
|
lines = self.aider_ignore_file.read_text().splitlines()
|
|
self.aider_ignore_spec = pathspec.PathSpec.from_lines(
|
|
pathspec.patterns.GitWildMatchPattern,
|
|
lines,
|
|
)
|
|
|
|
def git_ignored_file(self, path):
|
|
if not self.repo:
|
|
return
|
|
try:
|
|
if self.repo.ignored(path):
|
|
return True
|
|
except ANY_GIT_ERROR:
|
|
return False
|
|
|
|
def ignored_file(self, fname):
|
|
self.refresh_aider_ignore()
|
|
|
|
if fname in self.ignore_file_cache:
|
|
return self.ignore_file_cache[fname]
|
|
|
|
result = self.ignored_file_raw(fname)
|
|
self.ignore_file_cache[fname] = result
|
|
return result
|
|
|
|
def ignored_file_raw(self, fname):
|
|
if self.subtree_only:
|
|
try:
|
|
fname_path = Path(self.normalize_path(fname))
|
|
cwd_path = Path.cwd().resolve().relative_to(Path(self.root).resolve())
|
|
except ValueError:
|
|
# Issue #1524
|
|
# ValueError: 'C:\\dev\\squid-certbot' is not in the subpath of
|
|
# 'C:\\dev\\squid-certbot'
|
|
# Clearly, fname is not under cwd... so ignore it
|
|
return True
|
|
|
|
if cwd_path not in fname_path.parents and fname_path != cwd_path:
|
|
return True
|
|
|
|
if not self.aider_ignore_file or not self.aider_ignore_file.is_file():
|
|
return False
|
|
|
|
try:
|
|
fname = self.normalize_path(fname)
|
|
except ValueError:
|
|
return True
|
|
|
|
return self.aider_ignore_spec.match_file(fname)
|
|
|
|
def path_in_repo(self, path):
|
|
if not self.repo:
|
|
return
|
|
if not path:
|
|
return
|
|
|
|
tracked_files = set(self.get_tracked_files())
|
|
return self.normalize_path(path) in tracked_files
|
|
|
|
def abs_root_path(self, path):
|
|
res = Path(self.root) / path
|
|
return utils.safe_abs_path(res)
|
|
|
|
def get_dirty_files(self):
|
|
"""
|
|
Returns a list of all files which are dirty (not committed), either staged or in the working
|
|
directory.
|
|
"""
|
|
dirty_files = set()
|
|
|
|
# Get staged files
|
|
staged_files = self.repo.git.diff("--name-only", "--cached").splitlines()
|
|
dirty_files.update(staged_files)
|
|
|
|
# Get unstaged files
|
|
unstaged_files = self.repo.git.diff("--name-only").splitlines()
|
|
dirty_files.update(unstaged_files)
|
|
|
|
return list(dirty_files)
|
|
|
|
def is_dirty(self, path=None):
|
|
if path and not self.path_in_repo(path):
|
|
return True
|
|
|
|
return self.repo.is_dirty(path=path)
|
|
|
|
def get_head_commit(self):
|
|
try:
|
|
return self.repo.head.commit
|
|
except (ValueError,) + ANY_GIT_ERROR:
|
|
return None
|
|
|
|
def get_head_commit_sha(self, short=False):
|
|
commit = self.get_head_commit()
|
|
if not commit:
|
|
return
|
|
if short:
|
|
return commit.hexsha[:7]
|
|
return commit.hexsha
|
|
|
|
def get_head_commit_message(self, default=None):
|
|
commit = self.get_head_commit()
|
|
if not commit:
|
|
return default
|
|
return commit.message
|