diff --git a/internal/git/ref.go b/internal/git/ref.go new file mode 100644 index 00000000..c3a6763f --- /dev/null +++ b/internal/git/ref.go @@ -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 ..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 +} diff --git a/internal/git/ref_test.go b/internal/git/ref_test.go new file mode 100644 index 00000000..72327694 --- /dev/null +++ b/internal/git/ref_test.go @@ -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) + } + }) + } +} diff --git a/internal/plugins/changeloggenerator/git.go b/internal/plugins/changeloggenerator/git.go index 00c50057..a282e361 100644 --- a/internal/plugins/changeloggenerator/git.go +++ b/internal/plugins/changeloggenerator/git.go @@ -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). @@ -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 } diff --git a/internal/plugins/changeloggenerator/git_test.go b/internal/plugins/changeloggenerator/git_test.go index 04888bec..d19bfbea 100644 --- a/internal/plugins/changeloggenerator/git_test.go +++ b/internal/plugins/changeloggenerator/git_test.go @@ -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 { diff --git a/internal/plugins/commitparser/gitlog/gitlog.go b/internal/plugins/commitparser/gitlog/gitlog.go index ddeac677..64875df3 100644 --- a/internal/plugins/commitparser/gitlog/gitlog.go +++ b/internal/plugins/commitparser/gitlog/gitlog.go @@ -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. @@ -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 } diff --git a/internal/plugins/commitparser/gitlog/gitlog_test.go b/internal/plugins/commitparser/gitlog/gitlog_test.go index 7344f204..36c1a35e 100644 --- a/internal/plugins/commitparser/gitlog/gitlog_test.go +++ b/internal/plugins/commitparser/gitlog/gitlog_test.go @@ -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: "", @@ -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"},