Exclude more commit trailers from auto-wrapping

We already excluded the most commonly used commit trailers from being
auto-wrapped when typing or rewording a commit message, but this was
limited to two hard-coded ones ("Signed-off-by:" and "Co-authored-by:").
Extend this mechanism to use a heuristic to prevent more trailers from
wrapping; the heuristic kicks in for any "Key: Value" line if Key
contains a dash, or the value looks like a URL (so that it also catches
things like "Bug: https://my-bug-tracker/345").

To avoid mistaking a "Key: Value"-looking line in the message body for a
trailer, only apply the heuristic in the last paragraph of the message,
i.e. the block of lines at the end that is separated from the body by a
blank line. Each line there is judged on its own, so a line that isn't
recognized as a trailer still wraps without affecting the real trailers
next to it.
This commit is contained in:
Stefan Haller 2026-07-29 17:33:04 +02:00
parent 9811bc7ea8
commit b6deefacd2
2 changed files with 91 additions and 41 deletions

View file

@ -87,6 +87,12 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
result = append(result, cells[startOfLine:to]...)
}
// Commit message trailers ("Signed-off-by:" and the like) must not be
// auto-wrapped. They are only recognized in the last paragraph of the
// message, so that a trailer-looking line in the message body isn't treated
// as one; see startOfTrailerBlock.
trailerBlockStart := startOfTrailerBlock(content)
for currentPos, c := range cells {
if c.char == "\n" {
appendCellsSinceLineStart(currentPos + 1)
@ -98,7 +104,9 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
trailerMatcher.reset()
} else {
currentLineWidth += c.width
if c.char == " " && !footNoteMatcher.isFootNote() && !trailerMatcher.isTrailer() {
inTrailerBlock := c.contentIndex >= trailerBlockStart
if c.char == " " && !footNoteMatcher.isFootNote() &&
!(inTrailerBlock && trailerMatcher.isTrailer(content[c.contentIndex+len(c.char):])) {
indexOfLastWhitespace = currentPos + 1
} else if autoWrapWidth > 0 && currentLineWidth > autoWrapWidth && indexOfLastWhitespace >= 0 {
wrapAt := indexOfLastWhitespace
@ -118,7 +126,9 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
}
footNoteMatcher.addCharacter(c.char)
trailerMatcher.addCharacter(c.char)
if inTrailerBlock {
trailerMatcher.addCharacter(c.char)
}
}
}
@ -127,6 +137,21 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
return result, softLineBreakIndices
}
// startOfTrailerBlock returns the byte index into content at which the trailer
// block begins, i.e. the start of the last paragraph (the run of lines at the
// end of the message that is separated from the body by a blank line). Trailers
// are only looked for from this index onwards, so that a trailer-looking line in
// the middle of the message body isn't mistaken for a trailer. Trailing blank
// lines are ignored, and a message that consists of a single paragraph is
// treated as its own trailer block.
func startOfTrailerBlock(content string) int {
end := len(strings.TrimRight(content, "\n"))
if blankLine := strings.LastIndex(content[:end], "\n\n"); blankLine >= 0 {
return blankLine + len("\n\n")
}
return 0
}
var footNoteRe = regexp.MustCompile(`^\[\d+\]:\s*$`)
type footNoteMatcher struct {
@ -171,15 +196,11 @@ func (self *footNoteMatcher) reset() {
self.didFailToMatch = false
}
var supportedTrailers = []string{
"Signed-off-by:",
"Co-authored-by:",
}
type trailerMatcher struct {
lineStr strings.Builder
didFailToMatch bool
didMatch bool
didFailToMatch bool
didMatch bool
keyContainsDash bool
keyEndsWithColon bool
}
func (self *trailerMatcher) addCharacter(chr string) {
@ -194,19 +215,11 @@ func (self *trailerMatcher) addCharacter(chr string) {
return
}
if self.lineStr.Len() == 0 {
// If this is the first character, see if it could possibly match any supported trailer; if
// not, we can fail early and stop tracking further characters for this line.
if !anyOf(supportedTrailers, func(trailer string) bool { return trailer[0] == chr[0] }) {
self.didFailToMatch = true
return
}
}
self.lineStr.WriteString(chr)
self.keyContainsDash = self.keyContainsDash || chr == "-"
self.keyEndsWithColon = chr == ":"
}
func (self *trailerMatcher) isTrailer() bool {
func (self *trailerMatcher) isTrailer(remainingContent string) bool {
if self.didFailToMatch {
return false
}
@ -215,8 +228,9 @@ func (self *trailerMatcher) isTrailer() bool {
return true
}
line := self.lineStr.String()
if anyOf(supportedTrailers, func(trailer string) bool { return line == trailer }) {
remainingContent = strings.TrimLeft(remainingContent, WHITESPACES)
if self.keyEndsWithColon && (self.keyContainsDash ||
strings.HasPrefix(remainingContent, "http://") || strings.HasPrefix(remainingContent, "https://")) {
self.didMatch = true
return true
}
@ -226,19 +240,10 @@ func (self *trailerMatcher) isTrailer() bool {
}
func (self *trailerMatcher) reset() {
self.lineStr.Reset()
self.didFailToMatch = false
self.didMatch = false
}
func anyOf(strings []string, predicate func(s string) bool) bool {
for _, s := range strings {
if predicate(s) {
return true
}
}
return false
self.keyContainsDash = false
self.keyEndsWithColon = false
}
func (self *TextArea) updateCells() {

View file

@ -945,18 +945,63 @@ func Test_AutoWrapContent(t *testing.T) {
expectedSoftLineBreaks: []int{16, 21},
},
{
name: "don't break at space after trailer",
content: "abc\nSigned-off-by: John Doe <john@doe.com>\nCo-authored-by: Jane Smith <jane@smith.com>\n",
name: "don't break at space after trailer at beginning of message",
content: "Signed-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\nSigned-off-by: John Doe <john@doe.com>\nCo-authored-by: Jane Smith <jane@smith.com>\n",
expectedWrappedContent: "Signed-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
expectedSoftLineBreaks: []int{},
},
{
name: "do break at space after trailer if there is no space after the colon",
content: "abc\nSigned-off-by:John Doe <john@doe.com>\n",
name: "don't break at space after trailer in a trailer block at the end of a message",
content: "abc\n\nSigned-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\nSigned-off-by:John \nDoe \n<john@doe.com>\n",
expectedSoftLineBreaks: []int{23, 27},
expectedWrappedContent: "abc\n\nSigned-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
expectedSoftLineBreaks: []int{},
},
{
name: "don't break at space after trailer with URL value",
content: "abc\n\nBug: https://example.com/a/very/long/path\nIssue: http://example.com/a/very/long/path\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\n\nBug: https://example.com/a/very/long/path\nIssue: http://example.com/a/very/long/path\n",
expectedSoftLineBreaks: []int{},
},
{
name: "do break at space if trailer is not in a trailer block at the end",
content: "abc\n\nSigned-off-by: John Doe <john@doe.com>\n\nMore text here\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\n\nSigned-off-by: \nJohn Doe \n<john@doe.com>\n\nMore text \nhere\n",
expectedSoftLineBreaks: []int{20, 29, 55},
},
{
// Each line in the trailer block is judged on its own, so a line
// that isn't recognized as a trailer wraps without affecting the
// real trailers around it.
name: "keep a trailer next to a non-trailer line in the same block",
content: "abc\n\nFixes: a long description that wraps\nSigned-off-by: John Doe <john@doe.com>\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\n\nFixes: a \nlong \ndescription \nthat wraps\nSigned-off-by: John Doe <john@doe.com>\n",
expectedSoftLineBreaks: []int{14, 19, 31},
},
{
name: "don't break at space after trailer when the block ends in a blank line",
content: "abc\n\nSigned-off-by: John Doe <john@doe.com>\n\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\n\nSigned-off-by: John Doe <john@doe.com>\n\n",
expectedSoftLineBreaks: []int{},
},
{
name: "do break normal text after non-hyphenated key",
content: "However: in this commit blah blah blah\n",
autoWrapWidth: 10,
expectedWrappedContent: "However: \nin this \ncommit \nblah blah \nblah\n",
expectedSoftLineBreaks: []int{9, 17, 24, 34},
},
{
name: "do break at space after trailer if there is no space after the colon",
content: "abc\n\nSigned-off-by:John Doe <john@doe.com>\n",
autoWrapWidth: 10,
expectedWrappedContent: "abc\n\nSigned-off-by:John \nDoe \n<john@doe.com>\n",
expectedSoftLineBreaks: []int{24, 28},
},
{
name: "hard line breaks",