Skip to content

Commit 5021cd3

Browse files
authored
fix(plugins): handle repos with fewer than 10 commits in bump auto (#236)
* fix(commit-parser): handle repos with fewer than 10 commits in bump auto * fix(changeloggenerator): handle repos with fewer than 10 commits in bump auto
1 parent 1b62a43 commit 5021cd3

6 files changed

Lines changed: 302 additions & 3 deletions

File tree

internal/git/ref.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
)
8+
9+
// SafeFallbackSince returns "HEAD~n" if the repo has more than n commits,
10+
// otherwise it returns the hash of the root (first) commit so that
11+
// `git log <ref>..HEAD` works even in repos with very few commits.
12+
func SafeFallbackSince(execCommand func(string, ...string) *exec.Cmd, n int) string {
13+
cmd := execCommand("git", "rev-list", "--count", "HEAD")
14+
out, err := cmd.Output()
15+
if err != nil {
16+
return fmt.Sprintf("HEAD~%d", n)
17+
}
18+
19+
var count int
20+
if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count); err != nil || count == 0 {
21+
return fmt.Sprintf("HEAD~%d", n)
22+
}
23+
24+
if count > n {
25+
return fmt.Sprintf("HEAD~%d", n)
26+
}
27+
28+
// Fewer than n commits — use the root commit so the range covers everything.
29+
root := execCommand("git", "rev-list", "--max-parents=0", "HEAD")
30+
rootOut, err := root.Output()
31+
if err != nil {
32+
return fmt.Sprintf("HEAD~%d", n)
33+
}
34+
35+
// rev-list --max-parents=0 can return multiple roots; take the first one.
36+
rootHash := strings.TrimSpace(strings.SplitN(string(rootOut), "\n", 2)[0])
37+
if rootHash == "" {
38+
return fmt.Sprintf("HEAD~%d", n)
39+
}
40+
return rootHash
41+
}

internal/git/ref_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package git
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"strings"
7+
"testing"
8+
)
9+
10+
var fakeRefCommands = map[string]string{}
11+
12+
func fakeExecCommand(command string, args ...string) *exec.Cmd {
13+
cmdStr := command + " " + strings.Join(args, " ")
14+
cmd := exec.Command(os.Args[0], "-test.run=TestRefHelperProcess", "--", cmdStr) //nolint:gosec // standard test re-exec pattern
15+
16+
cmd.Env = append(os.Environ(),
17+
"GO_TEST_HELPER_PROCESS=1",
18+
"MOCK_KEY="+cmdStr,
19+
"MOCK_VAL="+fakeRefCommands[cmdStr],
20+
)
21+
22+
return cmd
23+
}
24+
25+
func TestRefHelperProcess(t *testing.T) {
26+
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
27+
return
28+
}
29+
30+
key := os.Getenv("MOCK_KEY")
31+
val := os.Getenv("MOCK_VAL")
32+
33+
_ = key
34+
35+
if val == "ERROR" {
36+
_, _ = os.Stderr.WriteString("mock failure")
37+
os.Exit(1)
38+
}
39+
40+
_, _ = os.Stdout.WriteString(val)
41+
os.Exit(0)
42+
}
43+
44+
func TestSafeFallbackSince(t *testing.T) {
45+
tests := []struct {
46+
name string
47+
n int
48+
mocks map[string]string
49+
expected string
50+
}{
51+
{
52+
name: "enough commits returns HEAD~n",
53+
n: 10,
54+
mocks: map[string]string{
55+
"git rev-list --count HEAD": "25",
56+
},
57+
expected: "HEAD~10",
58+
},
59+
{
60+
name: "fewer commits returns root hash",
61+
n: 10,
62+
mocks: map[string]string{
63+
"git rev-list --count HEAD": "2",
64+
"git rev-list --max-parents=0 HEAD": "abc123def456",
65+
},
66+
expected: "abc123def456",
67+
},
68+
{
69+
name: "exactly n+1 commits returns HEAD~n",
70+
n: 10,
71+
mocks: map[string]string{
72+
"git rev-list --count HEAD": "11",
73+
},
74+
expected: "HEAD~10",
75+
},
76+
{
77+
name: "exactly n commits returns root hash",
78+
n: 10,
79+
mocks: map[string]string{
80+
"git rev-list --count HEAD": "10",
81+
"git rev-list --max-parents=0 HEAD": "rootabc",
82+
},
83+
expected: "rootabc",
84+
},
85+
{
86+
name: "rev-list count fails returns HEAD~n",
87+
n: 10,
88+
mocks: map[string]string{
89+
"git rev-list --count HEAD": "ERROR",
90+
},
91+
expected: "HEAD~10",
92+
},
93+
{
94+
name: "root commit lookup fails returns HEAD~n",
95+
n: 10,
96+
mocks: map[string]string{
97+
"git rev-list --count HEAD": "2",
98+
"git rev-list --max-parents=0 HEAD": "ERROR",
99+
},
100+
expected: "HEAD~10",
101+
},
102+
}
103+
104+
for _, tt := range tests {
105+
t.Run(tt.name, func(t *testing.T) {
106+
fakeRefCommands = tt.mocks
107+
108+
result := SafeFallbackSince(fakeExecCommand, tt.n)
109+
if result != tt.expected {
110+
t.Errorf("expected %q, got %q", tt.expected, result)
111+
}
112+
})
113+
}
114+
}

internal/plugins/changeloggenerator/git.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"os/exec"
77
"regexp"
88
"strings"
9+
10+
"github.com/indaco/sley/internal/git"
911
)
1012

1113
// Pre-compiled regexes for URL parsing (compiled once at package init).
@@ -92,7 +94,9 @@ func (g *GitOps) getCommitsWithMeta(since, until string) ([]CommitInfo, error) {
9294
if since == "" {
9395
lastTag, err := g.getLatestTag()
9496
if err != nil {
95-
since = "HEAD~10"
97+
// No tags found — fall back to a safe recent range.
98+
// Use HEAD~10 if enough commits exist, otherwise use the repo root.
99+
since = git.SafeFallbackSince(g.ExecCommandFn, 10)
96100
} else {
97101
since = lastTag
98102
}

internal/plugins/changeloggenerator/git_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,131 @@
11
package changeloggenerator
22

33
import (
4+
"os"
5+
"os/exec"
6+
"strings"
47
"testing"
58
)
69

10+
var fakeGitCommands = map[string]string{}
11+
12+
func fakeExecCommand(command string, args ...string) *exec.Cmd {
13+
cmdStr := command + " " + strings.Join(args, " ")
14+
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", cmdStr) //nolint:gosec // standard test re-exec pattern
15+
16+
cmd.Env = append(os.Environ(),
17+
"GO_TEST_HELPER_PROCESS=1",
18+
"MOCK_KEY="+cmdStr,
19+
"MOCK_VAL="+fakeGitCommands[cmdStr],
20+
)
21+
22+
return cmd
23+
}
24+
25+
func TestHelperProcess(t *testing.T) {
26+
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
27+
return
28+
}
29+
30+
val := os.Getenv("MOCK_VAL")
31+
32+
if val == "ERROR" {
33+
_, _ = os.Stderr.WriteString("mock failure")
34+
os.Exit(1)
35+
}
36+
37+
_, _ = os.Stdout.WriteString(val)
38+
os.Exit(0)
39+
}
40+
41+
func TestGetCommitsWithMeta(t *testing.T) {
42+
tests := []struct {
43+
name string
44+
since string
45+
until string
46+
mockGitCommands map[string]string
47+
expectedCount int
48+
expectErr bool
49+
}{
50+
{
51+
name: "with explicit since and until",
52+
since: "v1.0.0",
53+
until: "HEAD",
54+
mockGitCommands: map[string]string{
55+
"git log --pretty=format:%H|%h|%s|%an|%ae v1.0.0..HEAD": "abc123|abc123|feat: login|Alice|alice@example.com\ndef456|def456|fix: bug|Bob|bob@example.com",
56+
},
57+
expectedCount: 2,
58+
},
59+
{
60+
name: "fallback to HEAD~10 when no tag and enough commits",
61+
since: "",
62+
until: "HEAD",
63+
mockGitCommands: map[string]string{
64+
"git describe --tags --abbrev=0": "", // no tags
65+
"git rev-list --count HEAD": "25",
66+
"git log --pretty=format:%H|%h|%s|%an|%ae HEAD~10..HEAD": "abc123|abc123|feat: update|Alice|alice@example.com",
67+
},
68+
expectedCount: 1,
69+
},
70+
{
71+
name: "fallback to root commit when fewer than 10 commits",
72+
since: "",
73+
until: "HEAD",
74+
mockGitCommands: map[string]string{
75+
"git describe --tags --abbrev=0": "", // no tags
76+
"git rev-list --count HEAD": "2",
77+
"git rev-list --max-parents=0 HEAD": "root123",
78+
"git log --pretty=format:%H|%h|%s|%an|%ae root123..HEAD": "abc123|abc123|feat: init|Alice|alice@example.com",
79+
},
80+
expectedCount: 1,
81+
},
82+
{
83+
name: "fallback to last tag when tag exists",
84+
since: "",
85+
until: "HEAD",
86+
mockGitCommands: map[string]string{
87+
"git describe --tags --abbrev=0": "v2.0.0",
88+
"git log --pretty=format:%H|%h|%s|%an|%ae v2.0.0..HEAD": "abc123|abc123|feat: new|Alice|alice@example.com",
89+
},
90+
expectedCount: 1,
91+
},
92+
{
93+
name: "git log returns error",
94+
since: "v1.0.0",
95+
until: "HEAD",
96+
mockGitCommands: map[string]string{
97+
"git log --pretty=format:%H|%h|%s|%an|%ae v1.0.0..HEAD": "ERROR",
98+
},
99+
expectErr: true,
100+
},
101+
{
102+
name: "empty commit log",
103+
since: "v1.0.0",
104+
until: "HEAD",
105+
mockGitCommands: map[string]string{
106+
"git log --pretty=format:%H|%h|%s|%an|%ae v1.0.0..HEAD": "",
107+
},
108+
expectedCount: 0,
109+
},
110+
}
111+
112+
for _, tt := range tests {
113+
t.Run(tt.name, func(t *testing.T) {
114+
fakeGitCommands = tt.mockGitCommands
115+
116+
g := &GitOps{ExecCommandFn: fakeExecCommand}
117+
commits, err := g.getCommitsWithMeta(tt.since, tt.until)
118+
119+
if (err != nil) != tt.expectErr {
120+
t.Fatalf("unexpected error: %v", err)
121+
}
122+
if len(commits) != tt.expectedCount {
123+
t.Fatalf("expected %d commits, got %d", tt.expectedCount, len(commits))
124+
}
125+
})
126+
}
127+
}
128+
7129
func TestParseRemoteURL(t *testing.T) {
8130

9131
tests := []struct {

internal/plugins/commitparser/gitlog/gitlog.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"os/exec"
77
"regexp"
88
"strings"
9+
10+
"github.com/indaco/sley/internal/git"
911
)
1012

1113
// validGitRef matches safe git reference names: alphanumeric, dots, hyphens, slashes, tildes, carets.
@@ -56,7 +58,9 @@ func (g *GitLog) GetCommits(since string, until string) ([]string, error) {
5658
if since == "" {
5759
lastTag, err := g.getLastTag()
5860
if err != nil {
59-
since = "HEAD~10"
61+
// No tags found — fall back to a safe recent range.
62+
// Use HEAD~10 if enough commits exist, otherwise use the repo root.
63+
since = git.SafeFallbackSince(g.ExecCommandFn, 10)
6064
} else {
6165
since = lastTag
6266
}

internal/plugins/commitparser/gitlog/gitlog_test.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,28 @@ func TestGetCommits(t *testing.T) {
8080
expectedCommits: []string{},
8181
},
8282
{
83-
name: "Fallback to HEAD~10 when no tag found",
83+
name: "Fallback to HEAD~10 when no tag found and enough commits",
8484
since: "",
8585
until: "HEAD",
8686
mockGitCommands: map[string]string{
8787
"git describe --tags --abbrev=0": "", // simulate error
88+
"git rev-list --count HEAD": "25",
8889
"git log --pretty=format:%s HEAD~10..HEAD": "fix: update",
8990
},
9091
expectedCommits: []string{"fix: update"},
9192
},
93+
{
94+
name: "Fallback to root commit when fewer than 10 commits",
95+
since: "",
96+
until: "HEAD",
97+
mockGitCommands: map[string]string{
98+
"git describe --tags --abbrev=0": "", // simulate error
99+
"git rev-list --count HEAD": "2",
100+
"git rev-list --max-parents=0 HEAD": "abc123",
101+
"git log --pretty=format:%s abc123..HEAD": "feat: init",
102+
},
103+
expectedCommits: []string{"feat: init"},
104+
},
92105
{
93106
name: "Since is empty, getLastTag returns valid tag",
94107
since: "",
@@ -115,6 +128,7 @@ func TestGetCommits(t *testing.T) {
115128
until: "HEAD",
116129
mockGitCommands: map[string]string{
117130
"git describe --tags --abbrev=0": "ERROR",
131+
"git rev-list --count HEAD": "25",
118132
"git log --pretty=format:%s HEAD~10..HEAD": "fix: fallback",
119133
},
120134
expectedCommits: []string{"fix: fallback"},

0 commit comments

Comments
 (0)