Skip to content

Commit 34da811

Browse files
[git] IJPL-212686 Implement commit message cleanup for commit-tree
GitOrigin-RevId: 3caf0c1d9618d718658ee99fd792c21366f33bd7
1 parent c5faaba commit 34da811

File tree

5 files changed

+202
-6
lines changed

5 files changed

+202
-6
lines changed

plugins/git4idea/src/git4idea/config/GitConfigUtil.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import git4idea.commands.GitCommand;
1414
import git4idea.commands.GitCommandResult;
1515
import git4idea.commands.GitLineHandler;
16+
import git4idea.inMemory.GitCommitMessageFormatter;
1617
import git4idea.repo.GitProjectConfigurationCache;
1718
import org.jetbrains.annotations.NonNls;
1819
import org.jetbrains.annotations.NotNull;
@@ -41,12 +42,15 @@ public final class GitConfigUtil {
4142
public static final @NlsSafe String CORE_SSH_COMMAND = "core.sshCommand";
4243
public static final @NlsSafe String CORE_COMMENT_CHAR = "core.commentChar";
4344
public static final @NlsSafe String LOG_OUTPUT_ENCODING = "i18n.logoutputencoding";
45+
public static final @NlsSafe String COMMIT_CLEANUP = "commit.cleanup";
4446
public static final @NlsSafe String COMMIT_ENCODING = "i18n.commitencoding";
4547
public static final @NlsSafe String COMMIT_TEMPLATE = "commit.template";
4648
public static final @NlsSafe String GPG_PROGRAM = "gpg.program";
4749
public static final @NlsSafe String GPG_COMMIT_SIGN = "commit.gpgSign";
4850
public static final @NlsSafe String GPG_COMMIT_SIGN_KEY = "user.signingkey";
4951

52+
public static final String DEFAULT_COMMENT_CHAR = "#";
53+
5054
private GitConfigUtil() {
5155
}
5256

@@ -163,6 +167,25 @@ public static boolean isRebaseUpdateRefsEnabledCached(@NotNull Project project,
163167
.readRepositoryConfig(root, UPDATE_REFS)));
164168
}
165169

170+
@RequiresBackgroundThread
171+
public static @NotNull GitCommitMessageFormatter.CleanupMode getCommitMessageCleanupModeCached(@NotNull Project project,
172+
@NotNull VirtualFile root) {
173+
String mode = GitProjectConfigurationCache.getInstance(project).readRepositoryConfig(root, COMMIT_CLEANUP);
174+
return GitCommitMessageFormatter.CleanupMode.parse(mode);
175+
}
176+
177+
@RequiresBackgroundThread
178+
public static @NotNull String getCommitMessageCommentCharCached(@NotNull Project project,
179+
@NotNull VirtualFile root) {
180+
String commentString = GitProjectConfigurationCache.getInstance(project).readRepositoryConfig(root, CORE_COMMENT_CHAR);
181+
if (commentString == null) {
182+
return DEFAULT_COMMENT_CHAR;
183+
}
184+
else {
185+
return commentString;
186+
}
187+
}
188+
166189
/**
167190
* Get log output encoding for the specified root, or UTF-8 if the encoding is note explicitly specified
168191
*/
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package git4idea.inMemory
3+
4+
import com.intellij.openapi.project.Project
5+
import com.intellij.openapi.vfs.VirtualFile
6+
import git4idea.config.GitConfigUtil
7+
8+
/**
9+
* Formatting of the message, according to the --cleanup=<mode>
10+
* git commit-tree doesn't respect it, so we have to do it ourselves.
11+
*/
12+
object GitCommitMessageFormatter {
13+
fun format(project: Project, root: VirtualFile, message: String): String {
14+
val mode = GitConfigUtil.getCommitMessageCleanupModeCached(project, root)
15+
if (mode == CleanupMode.NONE) return message
16+
17+
if (mode == CleanupMode.ALL) {
18+
val commentChar = GitConfigUtil.getCommitMessageCommentCharCached(project, root)
19+
return cleanupMessage(message, commentChar)
20+
}
21+
return cleanupMessage(message, null)
22+
}
23+
24+
/**
25+
* Skips every line that starts with [commentChar]
26+
* Removes blank lines at the beginning and end of the message
27+
* as well as trailing blanks from every line
28+
* Multiple consecutive blank lines between non-empty lines are replaced with one blank line
29+
* Adds newline at the end of the last line if needed
30+
*/
31+
private fun cleanupMessage(message: String, commentChar: String?): String {
32+
val lines = message.lines()
33+
34+
val startIndex = lines.indexOfFirst { it.isNotBlank() && !it.isCommentLine(commentChar) }
35+
if (startIndex == -1) return ""
36+
37+
val endIndex = lines.indexOfLast { it.isNotBlank() && !it.isCommentLine(commentChar) }
38+
39+
val result = StringBuilder()
40+
var previousLineEmpty = false
41+
for (i in startIndex..endIndex) {
42+
val line = lines[i].trimEnd()
43+
if (line.isCommentLine(commentChar)) {
44+
continue
45+
}
46+
if (line.isEmpty()) {
47+
if (!previousLineEmpty) {
48+
result.append('\n')
49+
}
50+
previousLineEmpty = true
51+
continue
52+
}
53+
if (result.isNotEmpty()) {
54+
result.append('\n')
55+
}
56+
result.append(line)
57+
previousLineEmpty = false
58+
}
59+
result.append('\n')
60+
return result.toString()
61+
}
62+
63+
private fun String.isCommentLine(commentChar: String?): Boolean {
64+
return commentChar != null && this.startsWith(commentChar)
65+
}
66+
67+
enum class CleanupMode {
68+
SPACE,
69+
NONE,
70+
@Suppress("unused")
71+
SCISSORS, // Not used, as we don't open a commit message in the editor.'
72+
ALL;
73+
74+
companion object {
75+
@JvmStatic
76+
fun parse(value: String?): CleanupMode =
77+
parseOrNull(value) ?: throw IllegalArgumentException("Invalid commit message cleanup mode '$value'")
78+
79+
/**
80+
* Returns the cleanup mode that corresponds to the argument,
81+
* assuming the message is not open in the editor
82+
*/
83+
private fun parseOrNull(value: String?): CleanupMode? {
84+
if (value == null) return SPACE
85+
86+
return when (value) {
87+
"default" -> SPACE
88+
"verbatim" -> NONE
89+
"strip" -> ALL
90+
"scissors" -> SPACE // scissors behave as SPACE when a message is not open in the editor
91+
else -> null
92+
}
93+
}
94+
}
95+
}
96+
}

plugins/git4idea/src/git4idea/inMemory/GitObjectRepository.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,12 @@ internal class GitObjectRepository(val repository: GitRepository) {
118118
author: GitObject.Commit.Author? = null,
119119
): Oid {
120120
LOG.debug("Starting commitTree operation: treeOid=$treeOid, parents=${parentsOids}")
121+
val project = repository.project
122+
val root = repository.root
121123

124+
val formattedMessage = GitCommitMessageFormatter.format(project, root, message.toString(Charsets.UTF_8))
122125
val messageFile = try {
123-
GitCheckinEnvironment.createCommitMessageFile(repository.project, repository.root, message.toString(Charsets.UTF_8))
126+
GitCheckinEnvironment.createCommitMessageFile(project, root, formattedMessage)
124127
}
125128
catch (e: ProcessCanceledException) {
126129
throw e
@@ -130,7 +133,7 @@ internal class GitObjectRepository(val repository: GitRepository) {
130133
throw e
131134
}
132135

133-
val handler = GitLineHandler(repository.project, repository.root, GitCommand.COMMIT_TREE).apply {
136+
val handler = GitLineHandler(project, root, GitCommand.COMMIT_TREE).apply {
134137
setSilent(true)
135138
parentsOids.forEach { addParameters("-p", it.hex()) }
136139
if (author != null) {
@@ -145,7 +148,7 @@ internal class GitObjectRepository(val repository: GitRepository) {
145148
addAbsoluteFile(messageFile)
146149
addParameters(treeOid.hex())
147150

148-
if (message.isEmpty()) { // in this case git will ignore -F and read message from stdin
151+
if (formattedMessage.isEmpty()) { // in this case git will ignore -F and read message from stdin
149152
setInputProcessor(GitHandlerInputProcessorUtil.redirectStream(byteArrayOf().inputStream()))
150153
}
151154
}

plugins/git4idea/tests/git4idea/inMemory/GitObjectRepositoryTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import git4idea.config.GitConfigUtil
77
import git4idea.inMemory.objects.GitObject
88
import git4idea.inMemory.objects.Oid
99
import git4idea.test.GitSingleRepoTest
10+
import git4idea.test.assertMessage
1011
import git4idea.test.gitAsBytes
1112
import git4idea.test.tac
1213
import kotlin.jvm.java
@@ -67,7 +68,7 @@ class GitObjectRepositoryTest : GitSingleRepoTest() {
6768

6869
assertEquals("Commit should have correct tree OID", tree.oid, commit.treeOid)
6970
assertEquals("Commit should have correct author", SAMPLE_AUTHOR, commit.author)
70-
assertEquals("Commit should have correct message", "Test commit message", commit.message.toString(Charsets.UTF_8))
71+
assertMessage(commit.message.toString(Charsets.UTF_8), "Test commit message", "Commit should have correct message")
7172
assertEquals("Commit should have no parents", 0, commit.parentsOids.size)
7273
}
7374

@@ -205,7 +206,7 @@ class GitObjectRepositoryTest : GitSingleRepoTest() {
205206
val commitOid = repository.commitTree(tree.oid, emptyList(), emptyMessage, SAMPLE_AUTHOR)
206207
val commit = repository.findCommit(commitOid)
207208

208-
assertEquals("Commit should have empty message", "", commit.message.toString(Charsets.UTF_8))
209+
assertMessage(commit.message.toString(Charsets.UTF_8), "", "Commit should have empty message")
209210
}
210211

211212
private fun createTreeEntries(blob: GitObject.Blob): Map<GitObject.Tree.FileName, GitObject.Tree.Entry> {

plugins/git4idea/tests/git4idea/inMemory/rebase/log/reword/GitInMemoryRewordOperationTest.kt

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import git4idea.rebase.log.GitCommitEditingOperationResult
66
import git4idea.test.assertLastMessage
77
import git4idea.test.message
88
import org.junit.jupiter.api.Assertions.assertNotEquals
9+
import git4idea.config.GitConfigUtil
10+
import git4idea.test.lastMessage
911

1012
internal class GitInMemoryRewordOperationTest : GitInMemoryOperationTest() {
1113
fun `test reword last commit`() {
@@ -32,7 +34,7 @@ internal class GitInMemoryRewordOperationTest : GitInMemoryOperationTest() {
3234
val commit = file("a").append("content").addCommit("Old message").details()
3335
file("b").create().addCommit("Latest commit")
3436

35-
val newMessage = "New message"
37+
val newMessage = "New message\n"
3638

3739
refresh()
3840
updateChangeListManager()
@@ -87,4 +89,75 @@ internal class GitInMemoryRewordOperationTest : GitInMemoryOperationTest() {
8789

8890
assertLastMessage(newMessage)
8991
}
92+
93+
fun `test compare messages should add newline at the end`() {
94+
val (inMemMessage, nativeMessage) = rewordInMemoryAndNativeAndGetMessages("Implement feature")
95+
96+
assertEquals(nativeMessage, inMemMessage)
97+
}
98+
99+
fun `test compare messages with default cleanup and default comment char`() {
100+
val (inMemMessage, nativeMessage) = rewordInMemoryAndNativeAndGetMessages(COMPLEX_MESSAGE)
101+
102+
assertEquals(nativeMessage, inMemMessage)
103+
}
104+
105+
fun `test compare messages with verbatim cleanup`() {
106+
GitConfigUtil.setValue(project, repo.root, GitConfigUtil.COMMIT_CLEANUP, "verbatim")
107+
108+
val (inMemMessage, nativeMessage) = rewordInMemoryAndNativeAndGetMessages(COMPLEX_MESSAGE)
109+
110+
assertEquals(nativeMessage, inMemMessage)
111+
}
112+
113+
fun `test compare messages with strip cleanup and custom comment char`() {
114+
GitConfigUtil.setValue(project, repo.root, GitConfigUtil.COMMIT_CLEANUP, "strip")
115+
GitConfigUtil.setValue(project, repo.root, GitConfigUtil.CORE_COMMENT_CHAR, ";")
116+
117+
val (inMemMessage, nativeMessage) = rewordInMemoryAndNativeAndGetMessages(COMPLEX_MESSAGE)
118+
119+
assertEquals(nativeMessage, inMemMessage)
120+
}
121+
122+
// IJPL-212686
123+
private fun rewordInMemoryAndNativeAndGetMessages(message: String): Pair<String, String> {
124+
file("a").create().addCommit("Add a")
125+
val commit = file("a").append("new content").addCommit("Modify a").details()
126+
refresh()
127+
updateChangeListManager()
128+
129+
val inBranch = "in-memory"
130+
val nativeBranch = "native"
131+
132+
git("checkout -B $inBranch")
133+
GitInMemoryRewordOperation(objectRepo, commit, message).run() as GitCommitEditingOperationResult.Complete
134+
val inMemMessage = lastMessage()
135+
136+
git("checkout master")
137+
138+
git("checkout -B $nativeBranch")
139+
git("commit --amend -m '$message'")
140+
val nativeMessage = lastMessage()
141+
142+
return inMemMessage to nativeMessage
143+
}
144+
145+
private val COMPLEX_MESSAGE = """
146+
147+
# This is a comment with default char
148+
149+
Subject with trailing spaces
150+
151+
# Another default comment
152+
; Comment with different char
153+
154+
Body line with trailing spaces
155+
156+
157+
158+
Another body line
159+
160+
; comment at the end
161+
162+
""".trimIndent()
90163
}

0 commit comments

Comments
 (0)