Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions internal/git/ref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package git

import (
"fmt"
"os/exec"
"strings"
)

// SafeFallbackSince returns "HEAD~n" if the repo has more than n commits,
// otherwise it returns the hash of the root (first) commit so that
// `git log <ref>..HEAD` works even in repos with very few commits.
func SafeFallbackSince(execCommand func(string, ...string) *exec.Cmd, n int) string {
cmd := execCommand("git", "rev-list", "--count", "HEAD")
out, err := cmd.Output()
if err != nil {
return fmt.Sprintf("HEAD~%d", n)
}

var count int
if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count); err != nil || count == 0 {
return fmt.Sprintf("HEAD~%d", n)
}

if count > n {
return fmt.Sprintf("HEAD~%d", n)
}

// Fewer than n commits — use the root commit so the range covers everything.
root := execCommand("git", "rev-list", "--max-parents=0", "HEAD")
rootOut, err := root.Output()
if err != nil {
return fmt.Sprintf("HEAD~%d", n)
}

// rev-list --max-parents=0 can return multiple roots; take the first one.
rootHash := strings.TrimSpace(strings.SplitN(string(rootOut), "\n", 2)[0])
if rootHash == "" {
return fmt.Sprintf("HEAD~%d", n)
}
return rootHash
}
114 changes: 114 additions & 0 deletions internal/git/ref_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package git

import (
"os"
"os/exec"
"strings"
"testing"
)

var fakeRefCommands = map[string]string{}

func fakeExecCommand(command string, args ...string) *exec.Cmd {
cmdStr := command + " " + strings.Join(args, " ")
cmd := exec.Command(os.Args[0], "-test.run=TestRefHelperProcess", "--", cmdStr) //nolint:gosec // standard test re-exec pattern

cmd.Env = append(os.Environ(),
"GO_TEST_HELPER_PROCESS=1",
"MOCK_KEY="+cmdStr,
"MOCK_VAL="+fakeRefCommands[cmdStr],
)

return cmd
}

func TestRefHelperProcess(t *testing.T) {
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
return
}

key := os.Getenv("MOCK_KEY")
val := os.Getenv("MOCK_VAL")

_ = key

if val == "ERROR" {
_, _ = os.Stderr.WriteString("mock failure")
os.Exit(1)
}

_, _ = os.Stdout.WriteString(val)
os.Exit(0)
}

func TestSafeFallbackSince(t *testing.T) {
tests := []struct {
name string
n int
mocks map[string]string
expected string
}{
{
name: "enough commits returns HEAD~n",
n: 10,
mocks: map[string]string{
"git rev-list --count HEAD": "25",
},
expected: "HEAD~10",
},
{
name: "fewer commits returns root hash",
n: 10,
mocks: map[string]string{
"git rev-list --count HEAD": "2",
"git rev-list --max-parents=0 HEAD": "abc123def456",
},
expected: "abc123def456",
},
{
name: "exactly n+1 commits returns HEAD~n",
n: 10,
mocks: map[string]string{
"git rev-list --count HEAD": "11",
},
expected: "HEAD~10",
},
{
name: "exactly n commits returns root hash",
n: 10,
mocks: map[string]string{
"git rev-list --count HEAD": "10",
"git rev-list --max-parents=0 HEAD": "rootabc",
},
expected: "rootabc",
},
{
name: "rev-list count fails returns HEAD~n",
n: 10,
mocks: map[string]string{
"git rev-list --count HEAD": "ERROR",
},
expected: "HEAD~10",
},
{
name: "root commit lookup fails returns HEAD~n",
n: 10,
mocks: map[string]string{
"git rev-list --count HEAD": "2",
"git rev-list --max-parents=0 HEAD": "ERROR",
},
expected: "HEAD~10",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeRefCommands = tt.mocks

result := SafeFallbackSince(fakeExecCommand, tt.n)
if result != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, result)
}
})
}
}
6 changes: 5 additions & 1 deletion internal/plugins/changeloggenerator/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os/exec"
"regexp"
"strings"

"github.com/indaco/sley/internal/git"
)

// Pre-compiled regexes for URL parsing (compiled once at package init).
Expand Down Expand Up @@ -92,7 +94,9 @@ func (g *GitOps) getCommitsWithMeta(since, until string) ([]CommitInfo, error) {
if since == "" {
lastTag, err := g.getLatestTag()
if err != nil {
since = "HEAD~10"
// No tags found — fall back to a safe recent range.
// Use HEAD~10 if enough commits exist, otherwise use the repo root.
since = git.SafeFallbackSince(g.ExecCommandFn, 10)
} else {
since = lastTag
}
Expand Down
122 changes: 122 additions & 0 deletions internal/plugins/changeloggenerator/git_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,131 @@
package changeloggenerator

import (
"os"
"os/exec"
"strings"
"testing"
)

var fakeGitCommands = map[string]string{}

func fakeExecCommand(command string, args ...string) *exec.Cmd {
cmdStr := command + " " + strings.Join(args, " ")
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", cmdStr) //nolint:gosec // standard test re-exec pattern

cmd.Env = append(os.Environ(),
"GO_TEST_HELPER_PROCESS=1",
"MOCK_KEY="+cmdStr,
"MOCK_VAL="+fakeGitCommands[cmdStr],
)

return cmd
}

func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
return
}

val := os.Getenv("MOCK_VAL")

if val == "ERROR" {
_, _ = os.Stderr.WriteString("mock failure")
os.Exit(1)
}

_, _ = os.Stdout.WriteString(val)
os.Exit(0)
}

func TestGetCommitsWithMeta(t *testing.T) {
tests := []struct {
name string
since string
until string
mockGitCommands map[string]string
expectedCount int
expectErr bool
}{
{
name: "with explicit since and until",
since: "v1.0.0",
until: "HEAD",
mockGitCommands: map[string]string{
"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",
},
expectedCount: 2,
},
{
name: "fallback to HEAD~10 when no tag and enough commits",
since: "",
until: "HEAD",
mockGitCommands: map[string]string{
"git describe --tags --abbrev=0": "", // no tags
"git rev-list --count HEAD": "25",
"git log --pretty=format:%H|%h|%s|%an|%ae HEAD~10..HEAD": "abc123|abc123|feat: update|Alice|alice@example.com",
},
expectedCount: 1,
},
{
name: "fallback to root commit when fewer than 10 commits",
since: "",
until: "HEAD",
mockGitCommands: map[string]string{
"git describe --tags --abbrev=0": "", // no tags
"git rev-list --count HEAD": "2",
"git rev-list --max-parents=0 HEAD": "root123",
"git log --pretty=format:%H|%h|%s|%an|%ae root123..HEAD": "abc123|abc123|feat: init|Alice|alice@example.com",
},
expectedCount: 1,
},
{
name: "fallback to last tag when tag exists",
since: "",
until: "HEAD",
mockGitCommands: map[string]string{
"git describe --tags --abbrev=0": "v2.0.0",
"git log --pretty=format:%H|%h|%s|%an|%ae v2.0.0..HEAD": "abc123|abc123|feat: new|Alice|alice@example.com",
},
expectedCount: 1,
},
{
name: "git log returns error",
since: "v1.0.0",
until: "HEAD",
mockGitCommands: map[string]string{
"git log --pretty=format:%H|%h|%s|%an|%ae v1.0.0..HEAD": "ERROR",
},
expectErr: true,
},
{
name: "empty commit log",
since: "v1.0.0",
until: "HEAD",
mockGitCommands: map[string]string{
"git log --pretty=format:%H|%h|%s|%an|%ae v1.0.0..HEAD": "",
},
expectedCount: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeGitCommands = tt.mockGitCommands

g := &GitOps{ExecCommandFn: fakeExecCommand}
commits, err := g.getCommitsWithMeta(tt.since, tt.until)

if (err != nil) != tt.expectErr {
t.Fatalf("unexpected error: %v", err)
}
if len(commits) != tt.expectedCount {
t.Fatalf("expected %d commits, got %d", tt.expectedCount, len(commits))
}
})
}
}

func TestParseRemoteURL(t *testing.T) {

tests := []struct {
Expand Down
6 changes: 5 additions & 1 deletion internal/plugins/commitparser/gitlog/gitlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os/exec"
"regexp"
"strings"

"github.com/indaco/sley/internal/git"
)

// validGitRef matches safe git reference names: alphanumeric, dots, hyphens, slashes, tildes, carets.
Expand Down Expand Up @@ -56,7 +58,9 @@ func (g *GitLog) GetCommits(since string, until string) ([]string, error) {
if since == "" {
lastTag, err := g.getLastTag()
if err != nil {
since = "HEAD~10"
// No tags found — fall back to a safe recent range.
// Use HEAD~10 if enough commits exist, otherwise use the repo root.
since = git.SafeFallbackSince(g.ExecCommandFn, 10)
} else {
since = lastTag
}
Expand Down
16 changes: 15 additions & 1 deletion internal/plugins/commitparser/gitlog/gitlog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,28 @@ func TestGetCommits(t *testing.T) {
expectedCommits: []string{},
},
{
name: "Fallback to HEAD~10 when no tag found",
name: "Fallback to HEAD~10 when no tag found and enough commits",
since: "",
until: "HEAD",
mockGitCommands: map[string]string{
"git describe --tags --abbrev=0": "", // simulate error
"git rev-list --count HEAD": "25",
"git log --pretty=format:%s HEAD~10..HEAD": "fix: update",
},
expectedCommits: []string{"fix: update"},
},
{
name: "Fallback to root commit when fewer than 10 commits",
since: "",
until: "HEAD",
mockGitCommands: map[string]string{
"git describe --tags --abbrev=0": "", // simulate error
"git rev-list --count HEAD": "2",
"git rev-list --max-parents=0 HEAD": "abc123",
"git log --pretty=format:%s abc123..HEAD": "feat: init",
},
expectedCommits: []string{"feat: init"},
},
{
name: "Since is empty, getLastTag returns valid tag",
since: "",
Expand All @@ -115,6 +128,7 @@ func TestGetCommits(t *testing.T) {
until: "HEAD",
mockGitCommands: map[string]string{
"git describe --tags --abbrev=0": "ERROR",
"git rev-list --count HEAD": "25",
"git log --pretty=format:%s HEAD~10..HEAD": "fix: fallback",
},
expectedCommits: []string{"fix: fallback"},
Expand Down