Repository: WeblateOrg/weblate

Issue:
Ability to set "parent message" for commit via "Squash Git commits" addon
**Is your feature request related to a problem? Please describe.**
We are currently usinge the "Squash Git commits" addon.

We'd like to retain authorship information in the squashed commit by using `Co-authored-by: {{ author }}`, however with multiple authors this ends up producing a commit message like:

```
Translated using Weblate

Translation: pypa/warehouse
Translate-URL: https://hosted.weblate.org/projects/pypa/warehouse/es/

Co-authored-by: Contributor 1 <email@example.com>

Translated using Weblate

Translation: pypa/warehouse
Translate-URL: https://hosted.weblate.org/projects/pypa/warehouse/de/

Co-authored-by: Contributor 2 <email@example.com>
```

**Describe the solution you'd like**
Ideally, we could produce a single commit with a commit message like:
```
Translated using Weblate


Co-authored-by: Contributor 1 <email@example.com>
Co-authored-by: Contributor 2 <email@example.com>
```
I think it would be possible to do this if were possible to configure the "Squash Git commits" to have a "parent commit message" which the squashed commit messages are appended to. 

**Describe alternatives you've considered**
Setting the commit message to just:

```
Co-authored-by: {{ author }}
```
doesn't work, as the first line is interpreted by git as the commit message title, not part of the body.

<bountysource-plugin>

---
Want to back this issue? **[Post a bounty on it!](https://www.bountysource.com/issues/90871894-ability-to-set-parent-message-for-commit-via-squash-git-commits-addon?utm_campaign=plugin&utm_content=tracker%2F253393&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F253393&utm_medium=issues&utm_source=github).
</bountysource-plugin>


Discussion:
We do no processing of commit messages in the squash addon. But adding `Co-authored-by` certainly makes sense. The messages are simply retrieved by `git log` right now:

https://github.com/WeblateOrg/weblate/blob/3c2c3143b1e246ab49abbd0034ad5836f13dd361/weblate/addons/git.py#L47
@nijel I'd be interested in trying to implement this if you'd like.
Sure, patches are always welcome ;-).

Resolving patch:
diff --git a/weblate/addons/forms.py b/weblate/addons/forms.py
--- a/weblate/addons/forms.py
+++ b/weblate/addons/forms.py
@@ -146,12 +146,33 @@ class GitSquashForm(BaseAddonForm):
         initial="all",
         required=True,
     )
+    append_trailers = forms.BooleanField(
+        label=_("Append trailers to squashed commit message"),
+        required=False,
+        initial=False,
+        help_text=_(
+            "Trailer lines are lines that look similar to RFC 822 e-mail "
+            "headers, at the end of the otherwise free-form part of a commit "
+            "message, such as 'Co-authored-by: ...'."
+        ),
+    )
+    commit_message = forms.CharField(
+        widget=forms.Textarea(),
+        required=False,
+        help_text=_(
+            "This commit message will be used instead of the combined commit "
+            "messages from the squashed commits."
+        ),
+    )
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.helper = FormHelper(self)
         self.helper.layout = Layout(
-            Field("squash"), Div(template="addons/squash_help.html")
+            Field("squash"),
+            Field("append_trailers"),
+            Field("commit_message"),
+            Div(template="addons/squash_help.html"),
         )
 
 
diff --git a/weblate/addons/git.py b/weblate/addons/git.py
--- a/weblate/addons/git.py
+++ b/weblate/addons/git.py
@@ -19,7 +19,7 @@
 
 
 import os.path
-from collections import defaultdict
+from collections import OrderedDict, defaultdict
 
 from django.utils.translation import gettext_lazy as _
 
@@ -44,7 +44,7 @@ class GitSquashAddon(BaseAddon):
 
     def squash_all(self, component, repository, base=None, author=None):
         remote = base if base else repository.get_remote_branch_name()
-        message = repository.execute(["log", "--format=%B", "{}..HEAD".format(remote)])
+        message = self.get_squash_commit_message(repository, "%B", remote)
         repository.execute(["reset", "--mixed", remote])
         # Can happen for added and removed translation
         if repository.needs_commit():
@@ -60,6 +60,50 @@ def get_filenames(self, component):
                 languages[code].extend(translation.filenames)
         return languages
 
+    def get_squash_commit_message(self, repository, log_format, remote, filenames=None):
+        commit_message = self.instance.configuration.get("commit_message")
+
+        if not commit_message:
+            command = [
+                "log",
+                "--format={}".format(log_format),
+                "{}..HEAD".format(remote),
+            ]
+            if filenames:
+                command += ["--"] + filenames
+
+            commit_message = repository.execute(command)
+
+        if self.instance.configuration.get("append_trailers"):
+            command = [
+                "log",
+                "--format=%(trailers)",
+                "{}..HEAD".format(remote),
+            ]
+            if filenames:
+                command += ["--"] + filenames
+
+            trailer_lines = OrderedDict.fromkeys(
+                [
+                    trailer
+                    for trailer in repository.execute(command).split("\n")
+                    if trailer.strip()
+                ]
+            )
+
+            commit_message_lines_with_trailers_removed = [
+                line for line in commit_message.split("\n") if line not in trailer_lines
+            ]
+
+            commit_message = "\n\n".join(
+                [
+                    "\n".join(commit_message_lines_with_trailers_removed),
+                    "\n".join(trailer_lines),
+                ]
+            )
+
+        return commit_message
+
     def commit_existing(self, repository, message, files):
         files = [name for name in files if os.path.exists(name)]
         if files:
@@ -73,8 +117,8 @@ def squash_language(self, component, repository):
         for code, filenames in languages.items():
             if not filenames:
                 continue
-            messages[code] = repository.execute(
-                ["log", "--format=%B", "{}..HEAD".format(remote), "--"] + filenames
+            messages[code] = self.get_squash_commit_message(
+                repository, "%B", remote, filenames
             )
 
         repository.execute(["reset", "--mixed", remote])
@@ -91,8 +135,8 @@ def squash_file(self, component, repository):
         messages = {}
         for filenames in languages.values():
             for filename in filenames:
-                messages[filename] = repository.execute(
-                    ["log", "--format=%B", "{}..HEAD".format(remote), "--", filename]
+                messages[filename] = self.get_squash_commit_message(
+                    repository, "%B", remote, [filename]
                 )
 
         repository.execute(["reset", "--mixed", remote])
diff --git a/weblate/vcs/git.py b/weblate/vcs/git.py
--- a/weblate/vcs/git.py
+++ b/weblate/vcs/git.py
@@ -42,7 +42,7 @@ class GitRepository(Repository):
     _cmd_list_changed_files = ["diff", "--name-status"]
 
     name = "Git"
-    req_version = "1.6"
+    req_version = "2.12"
     default_branch = "master"
     ref_to_remote = "..{0}"
     ref_from_remote = "{0}.."
@@ -395,7 +395,7 @@ def push(self):
 class SubversionRepository(GitRepository):
 
     name = "Subversion"
-    req_version = "1.6"
+    req_version = "2.12"
 
     _version = None
 


Test patch:
diff --git a/weblate/addons/tests.py b/weblate/addons/tests.py
--- a/weblate/addons/tests.py
+++ b/weblate/addons/tests.py
@@ -800,6 +800,36 @@ def test_author(self):
         self.component.commit_pending("test", None)
         self.assertEqual(self.component.repository.count_outgoing(), 3)
 
+    def test_commit_message(self):
+        commit_message = "Squashed commit message"
+        GitSquashAddon.create(
+            self.component,
+            configuration={"squash": "all", "commit_message": commit_message},
+        )
+
+        self.edit()
+
+        commit = self.component.repository.show(self.component.repository.last_revision)
+        self.assertIn(commit_message, commit)
+        self.assertEqual(self.component.repository.count_outgoing(), 1)
+
+    def test_append_trailers(self):
+        GitSquashAddon.create(
+            self.component, configuration={"squash": "all", "append_trailers": True}
+        )
+
+        self.edit()
+
+        commit = self.component.repository.show(self.component.repository.last_revision)
+
+        expected_trailers = (
+            "    Translation: Test/Test\n"
+            "    Translate-URL: http://example.com/projects/test/test/de/\n"
+            "    Translate-URL: http://example.com/projects/test/test/cs/\n"
+        )
+        self.assertIn(expected_trailers, commit)
+        self.assertEqual(self.component.repository.count_outgoing(), 1)
+
 
 class TestRemoval(FixtureTestCase):
     def setUp(self):