Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions git/index/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,7 @@ def commit(
author_date: Union[datetime.datetime, str, None] = None,
commit_date: Union[datetime.datetime, str, None] = None,
skip_hooks: bool = False,
trailers: Union[None, "Dict[str, str]", "List[Tuple[str, str]]"] = None,
) -> Commit:
"""Commit the current default index file, creating a
:class:`~git.objects.commit.Commit` object.
Expand Down Expand Up @@ -1169,6 +1170,7 @@ def commit(
committer=committer,
author_date=author_date,
commit_date=commit_date,
trailers=trailers,
)
if not skip_hooks:
run_commit_hook("post-commit", self)
Expand Down
32 changes: 32 additions & 0 deletions git/objects/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ def create_from_tree(
committer: Union[None, Actor] = None,
author_date: Union[None, str, datetime.datetime] = None,
commit_date: Union[None, str, datetime.datetime] = None,
trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None,
) -> "Commit":
"""Commit the given tree, creating a :class:`Commit` object.

Expand Down Expand Up @@ -609,6 +610,14 @@ def create_from_tree(
:param commit_date:
The timestamp for the committer field.

:param trailers:
Optional trailer key-value pairs to append to the commit message.
Can be a dictionary mapping trailer keys to values, or a list of
``(key, value)`` tuples (useful when the same key appears multiple
times, e.g. multiple ``Signed-off-by`` trailers). Trailers are
appended using ``git interpret-trailers``.
See :manpage:`git-interpret-trailers(1)`.

:return:
:class:`Commit` object representing the new commit.

Expand Down Expand Up @@ -678,6 +687,29 @@ def create_from_tree(
tree = repo.tree(tree)
# END tree conversion

# APPLY TRAILERS
if trailers:
trailer_args: List[str] = []
if isinstance(trailers, dict):
for key, val in trailers.items():
trailer_args.append("--trailer")
trailer_args.append(f"{key}: {val}")
else:
for key, val in trailers:
trailer_args.append("--trailer")
trailer_args.append(f"{key}: {val}")

cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers"] + trailer_args
proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload]
cmd,
Comment on lines +702 to +704
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command uses repo.git.GIT_PYTHON_GIT_EXECUTABLE here, but Commit.trailers_list/trailers_dict currently invoke interpret-trailers via a hard-coded ['git', ...]. If a caller overrides GIT_PYTHON_GIT_EXECUTABLE, trailer creation and trailer parsing can end up using different Git binaries; consider aligning both paths on the same executable selection approach for consistent round-trips.

Suggested change
cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers"] + trailer_args
proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload]
cmd,
proc: Git.AutoInterrupt = repo.git._call_process( # type: ignore[attr-defined]
"interpret-trailers",
*trailer_args,

Copilot uses AI. Check for mistakes.
as_process=True,
istream=PIPE,
)
stdout_bytes, _ = proc.communicate(str(message).encode())
finalize_process(proc)
Comment on lines +707 to +709
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses as_process=True, which makes Git.execute(..., with_exceptions=True) ineffective (see Git.execute docs), but the code never checks proc.returncode / stderr after communicate(). If interpret-trailers fails (e.g., unsupported --trailer on an older Git), message may be replaced with empty/partial stdout and the commit is still created; please detect non-zero exit status and raise (or preserve the original message on failure).

Suggested change
)
stdout_bytes, _ = proc.communicate(str(message).encode())
finalize_process(proc)
stderr=PIPE,
)
stdout_bytes, stderr_bytes = proc.communicate(str(message).encode())
finalize_process(proc)
if proc.returncode:
stderr = stderr_bytes.decode("utf8", errors="replace") if stderr_bytes else ""
raise RuntimeError(
"git interpret-trailers failed with exit code "
f"{proc.returncode}: {stderr or 'no error output'}"
)

Copilot uses AI. Check for mistakes.
message = stdout_bytes.decode("utf8")
Comment on lines +708 to +710
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interpret-trailers I/O is hard-coded to UTF-8 (.encode() default + .decode('utf8')), but this method already resolves conf_encoding for the commit’s declared encoding. If i18n.commitencoding is not UTF-8, this can raise decode errors or corrupt non-ASCII characters; use the resolved encoding for both encoding stdin and decoding stdout (and consider an explicit error strategy).

Suggested change
stdout_bytes, _ = proc.communicate(str(message).encode())
finalize_process(proc)
message = stdout_bytes.decode("utf8")
stdout_bytes, _ = proc.communicate(str(message).encode(conf_encoding, errors="strict"))
finalize_process(proc)
message = stdout_bytes.decode(conf_encoding, errors="strict")

Copilot uses AI. Check for mistakes.
# END apply trailers

# CREATE NEW COMMIT
new_commit = cls(
repo,
Expand Down
74 changes: 74 additions & 0 deletions test/test_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,77 @@ def test_commit_co_authors(self):
Actor("test_user_2", "another_user-email@github.com"),
Actor("test_user_3", "test_user_3@github.com"),
]

@with_rw_directory
def test_create_from_tree_with_trailers_dict(self, rw_dir):
"""Test that create_from_tree supports adding trailers via a dict."""
rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_dict"))
path = osp.join(str(rw_repo.working_tree_dir), "hello.txt")
touch(path)
rw_repo.index.add([path])
tree = rw_repo.index.write_tree()

trailers = {"Issue": "123", "Signed-off-by": "Test User <test@test.com>"}
commit = Commit.create_from_tree(
rw_repo,
tree,
"Test commit with trailers",
head=True,
trailers=trailers,
)

assert "Issue: 123" in commit.message
assert "Signed-off-by: Test User <test@test.com>" in commit.message
assert commit.trailers_dict == {
"Issue": ["123"],
"Signed-off-by": ["Test User <test@test.com>"],
}

@with_rw_directory
def test_create_from_tree_with_trailers_list(self, rw_dir):
"""Test that create_from_tree supports adding trailers via a list of tuples."""
rw_repo = Repo.init(osp.join(rw_dir, "test_trailers_list"))
path = osp.join(str(rw_repo.working_tree_dir), "hello.txt")
touch(path)
rw_repo.index.add([path])
tree = rw_repo.index.write_tree()

trailers = [
("Signed-off-by", "Alice <alice@example.com>"),
("Signed-off-by", "Bob <bob@example.com>"),
("Issue", "456"),
]
commit = Commit.create_from_tree(
rw_repo,
tree,
"Test commit with multiple trailers",
head=True,
trailers=trailers,
)

assert "Signed-off-by: Alice <alice@example.com>" in commit.message
assert "Signed-off-by: Bob <bob@example.com>" in commit.message
assert "Issue: 456" in commit.message
assert commit.trailers_dict == {
"Signed-off-by": ["Alice <alice@example.com>", "Bob <bob@example.com>"],
"Issue": ["456"],
}

@with_rw_directory
def test_index_commit_with_trailers(self, rw_dir):
"""Test that IndexFile.commit() supports adding trailers."""
rw_repo = Repo.init(osp.join(rw_dir, "test_index_trailers"))
path = osp.join(str(rw_repo.working_tree_dir), "hello.txt")
touch(path)
rw_repo.index.add([path])

trailers = {"Reviewed-by": "Reviewer <reviewer@example.com>"}
commit = rw_repo.index.commit(
"Test index commit with trailers",
trailers=trailers,
)

assert "Reviewed-by: Reviewer <reviewer@example.com>" in commit.message
assert commit.trailers_dict == {
"Reviewed-by": ["Reviewer <reviewer@example.com>"],
}
Loading