This commit is contained in:
ikedam 2025-03-30 12:18:52 +00:00 committed by GitHub
commit ff337fe4b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 625 additions and 4 deletions

View file

@ -459,6 +459,13 @@ We have implemented the following custom template functions. These override func
| `replace` | `func(s, old, new string, n int) string` | Replace `old` with `new` within string `s`, `n` times using `strings.Replace` |
| `upperFirst` | `func(s string) string` | Upper case the first character of a string |
Following custom template functions are also available.
| Name | Signature | Description |
| :------------------- | :---------------------------------------------------- | :---------------------------------------------------------------------------- |
| `uniqueOlderCommits` | `func(commits []*Commit, fields ...string) []*Commit` | Removes duplicated commits. Duplication is evaluated with fields specified. If your logs are created with ".Scope" and ".Subject", specify "Scope" "Subject" to remove duplicated logs. Newer logs, that means ones coming upper in changelogs will be removed for duplication. |
| `uniqueNewerCommits` | `func(commits []*Commit, fields ...string) []*Commit` | Removes duplicated commits just like `uniqueOlderCommits`, but older logs will be removed for duplication. |
If you are not satisfied with the prepared template please try customizing one.
---

105
chglog.go
View file

@ -344,10 +344,12 @@ func (gen *Generator) render(w io.Writer, unreleased *Unreleased, versions []*Ve
// While Sprig provides these functions, they change the standard input
// order which leads to a regression. For an example see:
// https://github.com/Masterminds/sprig/blob/master/functions.go#L149
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"replace": strings.Replace,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"replace": strings.Replace,
"uniqueOlderCommits": uniqueOlderCommits,
"uniqueNewerCommits": uniqueNewerCommits,
}
fname := filepath.Base(gen.config.Template)
@ -360,3 +362,98 @@ func (gen *Generator) render(w io.Writer, unreleased *Unreleased, versions []*Ve
Versions: versions,
})
}
// uniqueOlderCommits remmoves duplicated commits.
// Newer commits are removed when duplicated.
// Tests duplication with fields specified with `fields`.
func uniqueOlderCommits(commits []*Commit, fields ...string) ([]*Commit, error) {
if len(fields) == 0 {
return nil, fmt.Errorf("specify at least one field")
}
if len(commits) == 0 {
return nil, nil
}
filtered := make([]*Commit, 0, len(commits))
for idx, commit := range commits {
// compare with older commits and skip if duplicated.
duplicated, err := hasDuplicatedCommit(commits[idx+1:], commit, fields)
if err != nil {
return nil, err
}
if duplicated {
continue
}
filtered = append(filtered, commit)
}
return filtered, nil
}
// uniqueNewerCommits remmoves duplicated commits.
// Older commits are removed when duplicated.
// Tests duplication with fields specified with `fields`.
func uniqueNewerCommits(commits []*Commit, fields ...string) ([]*Commit, error) {
if len(fields) == 0 {
return nil, fmt.Errorf("specify at least one field")
}
if len(commits) == 0 {
return nil, nil
}
filtered := make([]*Commit, 0, len(commits))
for idx, commit := range commits {
// compare with newer commits and skip if duplicated.
duplicated, err := hasDuplicatedCommit(commits[:idx], commit, fields)
if err != nil {
return nil, err
}
if duplicated {
continue
}
filtered = append(filtered, commit)
}
return filtered, nil
}
func hasDuplicatedCommit(targetCommits []*Commit, commit *Commit, fields []string) (bool, error) {
for _, targetCommit := range targetCommits {
duplicated, err := isDuplicatedCommit(commit, targetCommit, fields)
if err != nil {
return false, err
}
if duplicated {
return true, nil
}
}
return false, nil
}
func isDuplicatedCommit(commit1 *Commit, commit2 *Commit, fields []string) (bool, error) {
for _, field := range fields {
a, ok := dotGetNilable(commit1, field)
if !ok {
return false, fmt.Errorf("cannot extract field \"%v\" for commit \"%v\"", field, commit1.Hash.Short)
}
b, ok := dotGetNilable(commit2, field)
if !ok {
return false, fmt.Errorf("cannot extract field \"%v\" for commit \"%v\"", field, commit1.Hash.Short)
}
// Here, `nil` for interface.
if a == nil || b == nil {
if a != nil || b != nil {
// not duplicated ones if something differs.
return false, nil
}
// considered to be same.
continue
}
eq, err := compare(a, "==", b)
if err != nil {
return false, err
}
if !eq {
// not duplicated ones if something differs.
return false, nil
}
}
// duplicated ones if nothing differs.
return true, nil
}

View file

@ -629,3 +629,475 @@ func TestGeneratorWithSprig(t *testing.T) {
[2.0.0]: https://github.com/git-chglog/git-chglog/compare/1.0.0...2.0.0`, expected)
}
func TestUniqueOlderCommits(t *testing.T) {
tests := []struct {
name string
commits []*Commit
fields []string
expected []*Commit
expectError assert.ErrorAssertionFunc
}{
{
name: "Duplication detected",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{"Subject"},
expected: []*Commit{
// {Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
expectError: assert.NoError,
},
{
name: "2 duplications detected",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log2"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
fields: []string{"Subject"},
expected: []*Commit{
// {Hash: &Hash{Short: "1"}, Subject: "log1"},
// {Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log2"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
expectError: assert.NoError,
},
{
name: "A double duplication detected",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
fields: []string{"Subject"},
expected: []*Commit{
// {Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
// {Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
expectError: assert.NoError,
},
{
name: "No duplicates",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{"Subject"},
expected: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
expectError: assert.NoError,
},
{
name: "No commits (empty)",
commits: []*Commit{},
fields: []string{"Subject"},
expected: nil,
expectError: assert.NoError,
},
{
name: "No commits (nil)",
commits: nil,
fields: []string{"Subject"},
expected: nil,
expectError: assert.NoError,
},
{
name: "Empty fields (error)",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{},
expected: nil,
expectError: assert.Error,
},
{
name: "nil for fields (error)",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: nil,
expected: nil,
expectError: assert.Error,
},
{
name: "bad field (error)",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{"NoSuchField"},
expected: nil,
expectError: assert.Error,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := uniqueOlderCommits(
test.commits,
test.fields...,
)
test.expectError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}
func TestUniqueNewerCommits(t *testing.T) {
tests := []struct {
name string
commits []*Commit
fields []string
expected []*Commit
expectError assert.ErrorAssertionFunc
}{
{
name: "Duplication detected",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{"Subject"},
expected: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
// {Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
expectError: assert.NoError,
},
{
name: "2 duplications detected",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log2"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
fields: []string{"Subject"},
expected: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
// {Hash: &Hash{Short: "3"}, Subject: "log2"}, // Duplicated!
// {Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
expectError: assert.NoError,
},
{
name: "A double duplication detected",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
fields: []string{"Subject"},
expected: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
// {Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
// {Hash: &Hash{Short: "4"}, Subject: "log1"}, // Duplicated!
},
expectError: assert.NoError,
},
{
name: "No duplicates",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{"Subject"},
expected: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
expectError: assert.NoError,
},
{
name: "No commits (empty)",
commits: []*Commit{},
fields: []string{"Subject"},
expected: nil,
expectError: assert.NoError,
},
{
name: "No commits (nil)",
commits: nil,
fields: []string{"Subject"},
expected: nil,
expectError: assert.NoError,
},
{
name: "Empty fields (error)",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{},
expected: nil,
expectError: assert.Error,
},
{
name: "nil for fields (error)",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: nil,
expected: nil,
expectError: assert.Error,
},
{
name: "bad field (error)",
commits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log1"}, // Duplicated!
{Hash: &Hash{Short: "4"}, Subject: "log4"},
},
fields: []string{"NoSuchField"},
expected: nil,
expectError: assert.Error,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := uniqueNewerCommits(
test.commits,
test.fields...,
)
test.expectError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}
func TestHasDuplicatedCommit(t *testing.T) {
tests := []struct {
name string
targetCommits []*Commit
commit *Commit
fields []string
expected bool
expectError assert.ErrorAssertionFunc
}{
{
name: "Duplication found",
targetCommits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log2"},
fields: []string{"Subject"},
expected: true,
expectError: assert.NoError,
},
{
name: "Duplication not found",
targetCommits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log99"},
fields: []string{"Subject"},
expected: false,
expectError: assert.NoError,
},
{
name: "No commits (empty)",
targetCommits: []*Commit{},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log1"},
fields: []string{"Subject"},
expected: false,
expectError: assert.NoError,
},
{
name: "No commits (nil)",
targetCommits: []*Commit{},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log1"},
fields: []string{"Subject"},
expected: false,
expectError: assert.NoError,
},
{
name: "Empty fields: Nothing to compare and everything is duplicated",
targetCommits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log1"},
fields: []string{},
expected: true,
expectError: assert.NoError,
},
{
name: "nil for fields: Nothing to compare and everything is duplicated",
targetCommits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log1"},
fields: nil,
expected: true,
expectError: assert.NoError,
},
{
name: "bad field (error)",
targetCommits: []*Commit{
{Hash: &Hash{Short: "1"}, Subject: "log1"},
{Hash: &Hash{Short: "2"}, Subject: "log2"},
{Hash: &Hash{Short: "3"}, Subject: "log3"},
},
commit: &Commit{Hash: &Hash{Short: "99"}, Subject: "log1"},
fields: []string{"NoSuchField"},
expected: false,
expectError: assert.Error,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := hasDuplicatedCommit(
test.targetCommits,
test.commit,
test.fields,
)
test.expectError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}
func TestIsDuplicatedCommit(t *testing.T) {
tests := []struct {
name string
commit1 *Commit
commit2 *Commit
fields []string
expected bool
expectError assert.ErrorAssertionFunc
}{
{
name: "Duplicated for single field",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log1"},
fields: []string{"Subject"},
expected: true,
expectError: assert.NoError,
},
{
name: "Not duplicted for single field",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log2"},
fields: []string{"Subject"},
expected: false,
expectError: assert.NoError,
},
{
name: "Duplicated for multiple fields",
commit1: &Commit{Hash: &Hash{Short: "1"}, Scope: "chore", Subject: "log1", JiraIssueID: "FOO-1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Scope: "chore", Subject: "log1", JiraIssueID: "FOO-1"},
fields: []string{"Scope", "Subject", "JiraIssueID"},
expected: true,
expectError: assert.NoError,
},
{
name: "Not duplicted for multiple fields",
commit1: &Commit{Hash: &Hash{Short: "1"}, Scope: "chore", Subject: "log1", JiraIssueID: "FOO-1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Scope: "chore", Subject: "log1", JiraIssueID: "FOO-2"},
fields: []string{"Scope", "Subject", "JiraIssueID"},
expected: false,
expectError: assert.NoError,
},
{
name: "Nested",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1", Author: &Author{Name: "user1"}},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log2", Author: &Author{Name: "user1"}},
fields: []string{"Author.Name"},
expected: true,
expectError: assert.NoError,
},
{
name: "Nested: dereference nil",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1", Author: &Author{Name: "user1"}},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log2", Author: nil},
fields: []string{"Author.Name"},
expected: false,
expectError: assert.NoError,
},
{
name: "Empty fields: Nothing to compare and everything is duplicated",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log2"},
fields: []string{},
expected: true,
expectError: assert.NoError,
},
{
name: "nil for fields: Nothing to compare and everything is duplicated",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log2"},
fields: nil,
expected: true,
expectError: assert.NoError,
},
{
name: "bad field (error)",
commit1: &Commit{Hash: &Hash{Short: "1"}, Subject: "log1"},
commit2: &Commit{Hash: &Hash{Short: "2"}, Subject: "log2"},
fields: []string{"NoSuchField"},
expected: false,
expectError: assert.Error,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := isDuplicatedCommit(
test.commit1,
test.commit2,
test.fields,
)
test.expectError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}

View file

@ -8,6 +8,14 @@ import (
)
func dotGet(target interface{}, prop string) (interface{}, bool) {
return dotGetImpl(target, prop, false)
}
func dotGetNilable(target interface{}, prop string) (interface{}, bool) {
return dotGetImpl(target, prop, true)
}
func dotGetImpl(target interface{}, prop string, nilable bool) (interface{}, bool) {
path := strings.Split(prop, ".")
if len(path) == 0 {
@ -18,6 +26,10 @@ func dotGet(target interface{}, prop string) (interface{}, bool) {
var value reflect.Value
if reflect.TypeOf(target).Kind() == reflect.Ptr {
if nilable && reflect.ValueOf(target).IsNil() {
// avoid dereferencing nil.
return nil, true
}
value = reflect.ValueOf(target).Elem()
} else {
value = reflect.ValueOf(target)
@ -79,6 +91,8 @@ func compareString(a string, operator string, b string) bool {
return a < b
case ">":
return a > b
case "==":
return a == b
default:
return false
}
@ -90,6 +104,8 @@ func compareInt(a int, operator string, b int) bool {
return a < b
case ">":
return a > b
case "==":
return a == b
default:
return false
}
@ -101,6 +117,8 @@ func compareTime(a time.Time, operator string, b time.Time) bool {
return !a.After(b)
case ">":
return a.After(b)
case "==":
return a.Equal(b)
default:
return false
}

View file

@ -81,6 +81,27 @@ func TestDotGet(t *testing.T) {
assert.Nil(val)
}
func TestDotGetNilable(t *testing.T) {
assert := assert.New(t)
type Nest struct {
Str string
}
type Sample struct {
Nest *Nest
}
sample := Sample{
Nest: nil,
}
// Dereferencing nil
val, ok := dotGetNilable(&sample, "Nest.Str")
assert.True(ok)
assert.Equal(val, nil)
}
func TestCompare(t *testing.T) {
assert := assert.New(t)
@ -96,10 +117,16 @@ func TestCompare(t *testing.T) {
{0, ">", 1, false},
{1, ">", 0, true},
{1, "<", 0, false},
{0, "==", 1, false},
{1, "==", 1, true},
{"a", "<", "b", true},
{"a", ">", "b", false},
{"a", "==", "b", false},
{"b", "==", "b", true},
{time.Unix(1518018017, 0), "<", time.Unix(1518018043, 0), true},
{time.Unix(1518018017, 0), ">", time.Unix(1518018043, 0), false},
{time.Unix(1518018017, 0), "==", time.Unix(1518018043, 0), false},
{time.Unix(1518018043, 0), "==", time.Unix(1518018043, 0), true},
}
for _, sa := range table {