Skip to content

feat: Add Co-authored-by attribution for AI commits #3789

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
67bb4f9
feat: add co-authored-by commit attribution
ei-grad Apr 12, 2025
b6b8f30
test: add tests for co-authored-by commit attribution
ei-grad Apr 12, 2025
eb28e22
test: fix mock setup for model name in co-authored-by test
ei-grad Apr 12, 2025
192f8be
test: fix mock model name setup in co-authored-by test
ei-grad Apr 12, 2025
a5327af
test: fix mock setup for co-authored-by commit test
ei-grad Apr 12, 2025
b22c9b8
feat: implement Co-authored-by attribution option
ei-grad Apr 12, 2025
c73b987
fix: fix syntax error in commit logic
ei-grad Apr 12, 2025
e951164
chore: Add test comment to dump function
ei-grad Apr 12, 2025
482e0c2
chore: Add test comment
ei-grad Apr 12, 2025
4783ad3
feat: add attribute-co-authored-by option for commit attribution
ei-grad Apr 12, 2025
43cb4d6
test: Temporarily disable co-author attribution to verify test failure
ei-grad Apr 12, 2025
dede701
test: intentionally break co-authored-by logic
ei-grad Apr 12, 2025
80114e7
chore: revert intentional break introduced for testing
ei-grad Apr 12, 2025
d5671c2
chore: Add test comment
ei-grad Apr 12, 2025
48f89f2
fix: prevent name modification when using co-authored-by
ei-grad Apr 12, 2025
072bd30
test: add comment for testing
ei-grad Apr 12, 2025
f648a01
fix: Pass attribute_co_authored_by arg to GitRepo constructor
ei-grad Apr 12, 2025
ff8e985
chore: add test comment to dump function
ei-grad Apr 12, 2025
d1437b7
chore: add debug prints for attribute_co_authored_by
ei-grad Apr 12, 2025
15d623f
chore: add another test comment to prompts
ei-grad Apr 12, 2025
316d8f8
chore: add third test comment
ei-grad Apr 12, 2025
66fdece
Revert "chore: add third test comment"
ei-grad Apr 12, 2025
0a59c38
Revert "chore: add another test comment to prompts"
ei-grad Apr 12, 2025
e182052
Revert "chore: add debug prints for attribute_co_authored_by"
ei-grad Apr 12, 2025
02bc9a8
Revert "chore: add test comment to dump function"
ei-grad Apr 12, 2025
cf7b35f
Revert "test: add comment for testing"
ei-grad Apr 12, 2025
7b8c7ed
Revert "chore: Add test comment"
ei-grad Apr 12, 2025
aa07e16
Revert "chore: Add test comment"
ei-grad Apr 12, 2025
427f9c5
Revert "chore: Add test comment to dump function"
ei-grad Apr 12, 2025
c56e836
refactor: simplify commit logic and use context manager for git env
ei-grad Apr 12, 2025
dd4b61d
test: add test for co-authored-by precedence over author/committer
ei-grad Apr 12, 2025
ea74f31
feat: Explicit author/committer flags override co-authored-by
ei-grad Apr 12, 2025
278a596
docs: clarify commit author/committer/co-authored-by logic
ei-grad Apr 12, 2025
5664b5b
test: Assert commit return value in more tests
ei-grad Apr 12, 2025
37a2527
test: Fix commit result assertion in test_noop_commit
ei-grad Apr 12, 2025
d991cb6
test: cover user commit with no committer attribution
ei-grad Apr 12, 2025
3e1bc77
test: add tests for commit author/committer attribution logic
ei-grad Apr 12, 2025
9e91e8f
test: remove redundant commit attribution tests
ei-grad Apr 12, 2025
6a970c3
test: remove redundant co-authored-by precedence test
ei-grad Apr 12, 2025
5851d66
test: improve test clarity with skipIf and assertion messages
ei-grad Apr 12, 2025
38dfd6f
docs: clarify --attribute-co-authored-by precedence
ei-grad Apr 12, 2025
165e237
chore: remove unnecessary comment in repo.py
ei-grad Apr 12, 2025
3f94fd5
refactor: Simplify access to attribute_co_authored_by
ei-grad Apr 12, 2025
1d42690
fix: update co-authored-by domain to aider.chat
ei-grad Apr 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions aider/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions aider/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2248,7 +2248,7 @@ def auto_commit(self, edited, context=None):
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
Expand Down Expand Up @@ -2284,7 +2284,7 @@ def dirty_commit(self):
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)
Expand Down
1 change: 1 addition & 0 deletions aider/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,7 @@ def get_io(pretty):
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
Expand Down
191 changes: 158 additions & 33 deletions aider/repo.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import os
import time
from pathlib import Path, PurePosixPath
Expand Down Expand Up @@ -34,6 +35,19 @@
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
Expand All @@ -58,6 +72,7 @@ def __init__(
commit_prompt=None,
subtree_only=False,
git_commit_verify=True,
attribute_co_authored_by=False, # Added parameter
):
self.io = io
self.models = models
Expand All @@ -69,6 +84,7 @@ def __init__(
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
Expand Down Expand Up @@ -111,7 +127,71 @@ def __init__(
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>) <[email protected]>"
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

Expand All @@ -124,17 +204,68 @@ def commit(self, fnames=None, context=None, message=None, aider_edits=False):
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 # <-- Restored
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 = getattr(self, "attribute_co_authored_by", False) # Should be False if not set

# 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}) <[email protected]>"
)

# 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:
Expand All @@ -152,36 +283,30 @@ def commit(self, fnames=None, context=None, message=None, aider_edits=False):

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:
Expand Down
Loading