Merge pull request #3789 from ei-grad/attribute-co-authored-by

feat: Add Co-authored-by attribution for AI commits
This commit is contained in:
paul-gauthier 2025-05-07 16:54:46 -07:00 committed by GitHub
commit c6954f9972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 353 additions and 56 deletions

View file

@ -427,14 +427,20 @@ def get_parser(default_config_files, git_root):
group.add_argument(
"--attribute-author",
action=argparse.BooleanOptionalAction,
default=True,
help="Attribute aider code changes in the git author name (default: True)",
default=None,
help=(
"Attribute aider code changes in the git author name (default: True). If explicitly set"
" to True, overrides --attribute-co-authored-by precedence."
),
)
group.add_argument(
"--attribute-committer",
action=argparse.BooleanOptionalAction,
default=True,
help="Attribute aider commits in the git committer name (default: True)",
default=None,
help=(
"Attribute aider commits in the git committer name (default: True). If explicitly set"
" to True, overrides --attribute-co-authored-by precedence for aider edits."
),
)
group.add_argument(
"--attribute-commit-message-author",
@ -448,6 +454,16 @@ def get_parser(default_config_files, git_root):
default=False,
help="Prefix all commit messages with 'aider: ' (default: False)",
)
group.add_argument(
"--attribute-co-authored-by",
action=argparse.BooleanOptionalAction,
default=False,
help=(
"Attribute aider edits using the Co-authored-by trailer in the commit message"
" (default: False). If True, this takes precedence over default --attribute-author and"
" --attribute-committer behavior unless they are explicitly set to True."
),
)
group.add_argument(
"--git-commit-verify",
action=argparse.BooleanOptionalAction,

View file

@ -2331,7 +2331,7 @@ class Coder:
context = self.get_context_from_history(self.cur_messages)
try:
res = self.repo.commit(fnames=edited, context=context, aider_edits=True)
res = self.repo.commit(fnames=edited, context=context, aider_edits=True, coder=self)
if res:
self.show_auto_commit_outcome(res)
commit_hash, commit_message = res
@ -2367,7 +2367,7 @@ class Coder:
if not self.repo:
return
self.repo.commit(fnames=self.need_commit_before_edits)
self.repo.commit(fnames=self.need_commit_before_edits, coder=self)
# files changed, move cur messages back behind the files messages
# self.move_back_cur_messages(self.gpt_prompts.files_content_local_edits)

View file

@ -904,6 +904,7 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F
commit_prompt=args.commit_prompt,
subtree_only=args.subtree_only,
git_commit_verify=args.git_commit_verify,
attribute_co_authored_by=args.attribute_co_authored_by, # Pass the arg
)
except FileNotFoundError:
pass

View file

@ -1,3 +1,4 @@
import contextlib
import os
import time
from pathlib import Path, PurePosixPath
@ -34,6 +35,19 @@ ANY_GIT_ERROR += [
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
@ -58,6 +72,7 @@ class GitRepo:
commit_prompt=None,
subtree_only=False,
git_commit_verify=True,
attribute_co_authored_by=False, # Added parameter
):
self.io = io
self.models = models
@ -69,6 +84,7 @@ class GitRepo:
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
@ -111,7 +127,71 @@ class GitRepo:
if aider_ignore_file:
self.aider_ignore_file = Path(aider_ignore_file)
def commit(self, fnames=None, context=None, message=None, aider_edits=False):
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
@ -124,17 +204,68 @@ class GitRepo:
else:
commit_message = self.get_commit_message(diffs, context)
if aider_edits and self.attribute_commit_message_author:
commit_message = "aider: " + commit_message
elif self.attribute_commit_message_committer:
commit_message = "aider: " + commit_message
# 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)"
full_commit_message = commit_message
# if context:
# full_commit_message += "\n\n# Aider chat conversation:\n\n" + context
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:
@ -152,36 +283,30 @@ class GitRepo:
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)"
if self.attribute_committer:
os.environ["GIT_COMMITTER_NAME"] = committer_name
if aider_edits and self.attribute_author:
original_author_name_env = os.environ.get("GIT_AUTHOR_NAME")
os.environ["GIT_AUTHOR_NAME"] = committer_name
try:
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
# 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}")
finally:
# Restore the env
if self.attribute_committer:
if original_committer_name_env is not None:
os.environ["GIT_COMMITTER_NAME"] = original_committer_name_env
else:
del os.environ["GIT_COMMITTER_NAME"]
if aider_edits and self.attribute_author:
if original_author_name_env is not None:
os.environ["GIT_AUTHOR_NAME"] = original_author_name_env
else:
del os.environ["GIT_AUTHOR_NAME"]
# No return here, implicitly returns None
def get_rel_repo_dir(self):
try: