Skip to content

Commit 5db93cf

Browse files
authored
Merge pull request #10 from DFanso/dev
Refactor: Extract internal modules for better organization
2 parents 02b96a1 + 7615191 commit 5db93cf

6 files changed

Lines changed: 395 additions & 353 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![Hacktoberfest](https://img.shields.io/badge/Hacktoberfest-2025-orange.svg)](https://hacktoberfest.com/)
44
[![Go Version](https://img.shields.io/badge/Go-1.23.4-blue.svg)](https://golang.org/)
55
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
6-
6+
![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/DFanso/commit-msg?utm_source=oss&utm_medium=github&utm_campaign=DFanso%2Fcommit-msg&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews)
77
`commit-msg` is a command-line tool that generates commit messages using LLM (Large Language Models). It is designed to help developers create clear and concise commit messages for their Git repositories automatically by analyzing your staged changes.
88

99
## Screenshot

src/internal/display/display.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package display
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pterm/pterm"
7+
)
8+
9+
// FileStatistics holds statistics about changed files
10+
type FileStatistics struct {
11+
StagedFiles []string
12+
UnstagedFiles []string
13+
UntrackedFiles []string
14+
TotalFiles int
15+
LinesAdded int
16+
LinesDeleted int
17+
}
18+
19+
// ShowFileStatistics displays file statistics with colored output
20+
func ShowFileStatistics(stats *FileStatistics) {
21+
pterm.DefaultSection.Println("📊 Changes Summary")
22+
23+
// Create bullet list items
24+
bulletItems := []pterm.BulletListItem{}
25+
26+
if len(stats.StagedFiles) > 0 {
27+
bulletItems = append(bulletItems, pterm.BulletListItem{
28+
Level: 0,
29+
Text: pterm.Green(fmt.Sprintf("✅ Staged files: %d", len(stats.StagedFiles))),
30+
TextStyle: pterm.NewStyle(pterm.FgGreen),
31+
BulletStyle: pterm.NewStyle(pterm.FgGreen),
32+
})
33+
for i, file := range stats.StagedFiles {
34+
if i < 5 { // Show first 5 files
35+
bulletItems = append(bulletItems, pterm.BulletListItem{
36+
Level: 1,
37+
Text: file,
38+
})
39+
}
40+
}
41+
if len(stats.StagedFiles) > 5 {
42+
bulletItems = append(bulletItems, pterm.BulletListItem{
43+
Level: 1,
44+
Text: pterm.Gray(fmt.Sprintf("... and %d more", len(stats.StagedFiles)-5)),
45+
})
46+
}
47+
}
48+
49+
if len(stats.UnstagedFiles) > 0 {
50+
bulletItems = append(bulletItems, pterm.BulletListItem{
51+
Level: 0,
52+
Text: pterm.Yellow(fmt.Sprintf("⚠️ Unstaged files: %d", len(stats.UnstagedFiles))),
53+
TextStyle: pterm.NewStyle(pterm.FgYellow),
54+
BulletStyle: pterm.NewStyle(pterm.FgYellow),
55+
})
56+
for i, file := range stats.UnstagedFiles {
57+
if i < 3 {
58+
bulletItems = append(bulletItems, pterm.BulletListItem{
59+
Level: 1,
60+
Text: file,
61+
})
62+
}
63+
}
64+
if len(stats.UnstagedFiles) > 3 {
65+
bulletItems = append(bulletItems, pterm.BulletListItem{
66+
Level: 1,
67+
Text: pterm.Gray(fmt.Sprintf("... and %d more", len(stats.UnstagedFiles)-3)),
68+
})
69+
}
70+
}
71+
72+
if len(stats.UntrackedFiles) > 0 {
73+
bulletItems = append(bulletItems, pterm.BulletListItem{
74+
Level: 0,
75+
Text: pterm.Cyan(fmt.Sprintf("📝 Untracked files: %d", len(stats.UntrackedFiles))),
76+
TextStyle: pterm.NewStyle(pterm.FgCyan),
77+
BulletStyle: pterm.NewStyle(pterm.FgCyan),
78+
})
79+
for i, file := range stats.UntrackedFiles {
80+
if i < 3 {
81+
bulletItems = append(bulletItems, pterm.BulletListItem{
82+
Level: 1,
83+
Text: file,
84+
})
85+
}
86+
}
87+
if len(stats.UntrackedFiles) > 3 {
88+
bulletItems = append(bulletItems, pterm.BulletListItem{
89+
Level: 1,
90+
Text: pterm.Gray(fmt.Sprintf("... and %d more", len(stats.UntrackedFiles)-3)),
91+
})
92+
}
93+
}
94+
95+
pterm.DefaultBulletList.WithItems(bulletItems).Render()
96+
}
97+
98+
// ShowCommitMessage displays the commit message in a styled panel
99+
func ShowCommitMessage(message string) {
100+
pterm.DefaultSection.Println("📝 Generated Commit Message")
101+
102+
// Create a panel with the commit message
103+
panel := pterm.DefaultBox.
104+
WithTitle("Commit Message").
105+
WithTitleTopCenter().
106+
WithBoxStyle(pterm.NewStyle(pterm.FgLightGreen)).
107+
WithHorizontalString("─").
108+
WithVerticalString("│").
109+
WithTopLeftCornerString("┌").
110+
WithTopRightCornerString("┐").
111+
WithBottomLeftCornerString("└").
112+
WithBottomRightCornerString("┘")
113+
114+
panel.Println(pterm.LightGreen(message))
115+
}
116+
117+
// ShowChangesPreview displays a preview of changes with line statistics
118+
func ShowChangesPreview(stats *FileStatistics) {
119+
pterm.DefaultSection.Println("🔍 Changes Preview")
120+
121+
// Create info boxes
122+
if stats.LinesAdded > 0 || stats.LinesDeleted > 0 {
123+
infoData := [][]string{
124+
{"Lines Added", pterm.Green(fmt.Sprintf("+%d", stats.LinesAdded))},
125+
{"Lines Deleted", pterm.Red(fmt.Sprintf("-%d", stats.LinesDeleted))},
126+
{"Total Files", pterm.Cyan(fmt.Sprintf("%d", stats.TotalFiles))},
127+
}
128+
129+
pterm.DefaultTable.WithHasHeader(false).WithData(infoData).Render()
130+
} else {
131+
pterm.Info.Println("No line statistics available for unstaged changes")
132+
}
133+
}

src/internal/git/operations.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/dfanso/commit-msg/src/internal/utils"
11+
"github.com/dfanso/commit-msg/src/types"
12+
)
13+
14+
// IsRepository checks if a directory is a git repository
15+
func IsRepository(path string) bool {
16+
cmd := exec.Command("git", "-C", path, "rev-parse", "--is-inside-work-tree")
17+
output, err := cmd.CombinedOutput()
18+
if err != nil {
19+
return false
20+
}
21+
return strings.TrimSpace(string(output)) == "true"
22+
}
23+
24+
// GetChanges retrieves all Git changes including staged, unstaged, and untracked files
25+
func GetChanges(config *types.RepoConfig) (string, error) {
26+
var changes strings.Builder
27+
28+
// 1. Check for unstaged changes
29+
cmd := exec.Command("git", "-C", config.Path, "diff", "--name-status")
30+
output, err := cmd.Output()
31+
if err != nil {
32+
return "", fmt.Errorf("git diff failed: %v", err)
33+
}
34+
35+
if len(output) > 0 {
36+
changes.WriteString("Unstaged changes:\n")
37+
changes.WriteString(string(output))
38+
changes.WriteString("\n\n")
39+
40+
// Get the content of these changes
41+
diffCmd := exec.Command("git", "-C", config.Path, "diff")
42+
diffOutput, err := diffCmd.Output()
43+
if err != nil {
44+
return "", fmt.Errorf("git diff content failed: %v", err)
45+
}
46+
47+
changes.WriteString("Unstaged diff content:\n")
48+
changes.WriteString(string(diffOutput))
49+
changes.WriteString("\n\n")
50+
}
51+
52+
// 2. Check for staged changes
53+
stagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-status", "--cached")
54+
stagedOutput, err := stagedCmd.Output()
55+
if err != nil {
56+
return "", fmt.Errorf("git diff --cached failed: %v", err)
57+
}
58+
59+
if len(stagedOutput) > 0 {
60+
changes.WriteString("Staged changes:\n")
61+
changes.WriteString(string(stagedOutput))
62+
changes.WriteString("\n\n")
63+
64+
// Get the content of these changes
65+
stagedDiffCmd := exec.Command("git", "-C", config.Path, "diff", "--cached")
66+
stagedDiffOutput, err := stagedDiffCmd.Output()
67+
if err != nil {
68+
return "", fmt.Errorf("git diff --cached content failed: %v", err)
69+
}
70+
71+
changes.WriteString("Staged diff content:\n")
72+
changes.WriteString(string(stagedDiffOutput))
73+
changes.WriteString("\n\n")
74+
}
75+
76+
// 3. Check for untracked files
77+
untrackedCmd := exec.Command("git", "-C", config.Path, "ls-files", "--others", "--exclude-standard")
78+
untrackedOutput, err := untrackedCmd.Output()
79+
if err != nil {
80+
return "", fmt.Errorf("git ls-files failed: %v", err)
81+
}
82+
83+
if len(untrackedOutput) > 0 {
84+
changes.WriteString("Untracked files:\n")
85+
changes.WriteString(string(untrackedOutput))
86+
changes.WriteString("\n\n")
87+
88+
// Try to get content of untracked files (limited to text files and smaller size)
89+
untrackedFiles := strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n")
90+
for _, file := range untrackedFiles {
91+
if file == "" {
92+
continue
93+
}
94+
95+
fullPath := filepath.Join(config.Path, file)
96+
if utils.IsTextFile(fullPath) && utils.IsSmallFile(fullPath) {
97+
fileContent, err := os.ReadFile(fullPath)
98+
if err != nil {
99+
// Log but don't fail - untracked file may have been deleted or is inaccessible
100+
continue
101+
}
102+
changes.WriteString(fmt.Sprintf("Content of new file %s:\n", file))
103+
changes.WriteString(string(fileContent))
104+
changes.WriteString("\n\n")
105+
}
106+
}
107+
}
108+
109+
// 4. Get recent commits for context
110+
recentCommitsCmd := exec.Command("git", "-C", config.Path, "log", "--oneline", "-n", "3")
111+
recentCommitsOutput, err := recentCommitsCmd.Output()
112+
if err == nil && len(recentCommitsOutput) > 0 {
113+
changes.WriteString("Recent commits for context:\n")
114+
changes.WriteString(string(recentCommitsOutput))
115+
changes.WriteString("\n")
116+
}
117+
118+
return changes.String(), nil
119+
}

src/internal/stats/statistics.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package stats
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
8+
"github.com/dfanso/commit-msg/src/internal/display"
9+
"github.com/dfanso/commit-msg/src/internal/utils"
10+
"github.com/dfanso/commit-msg/src/types"
11+
)
12+
13+
// GetFileStatistics collects comprehensive file statistics from Git
14+
func GetFileStatistics(config *types.RepoConfig) (*display.FileStatistics, error) {
15+
stats := &display.FileStatistics{
16+
StagedFiles: []string{},
17+
UnstagedFiles: []string{},
18+
UntrackedFiles: []string{},
19+
}
20+
21+
// Get staged files
22+
stagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-only", "--cached")
23+
stagedOutput, err := stagedCmd.Output()
24+
if err == nil && len(stagedOutput) > 0 {
25+
stats.StagedFiles = strings.Split(strings.TrimSpace(string(stagedOutput)), "\n")
26+
}
27+
28+
// Get unstaged files
29+
unstagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-only")
30+
unstagedOutput, err := unstagedCmd.Output()
31+
if err == nil && len(unstagedOutput) > 0 {
32+
stats.UnstagedFiles = strings.Split(strings.TrimSpace(string(unstagedOutput)), "\n")
33+
}
34+
35+
// Get untracked files
36+
untrackedCmd := exec.Command("git", "-C", config.Path, "ls-files", "--others", "--exclude-standard")
37+
untrackedOutput, err := untrackedCmd.Output()
38+
if err == nil && len(untrackedOutput) > 0 {
39+
stats.UntrackedFiles = strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n")
40+
}
41+
42+
// Filter empty strings
43+
stats.StagedFiles = utils.FilterEmpty(stats.StagedFiles)
44+
stats.UnstagedFiles = utils.FilterEmpty(stats.UnstagedFiles)
45+
stats.UntrackedFiles = utils.FilterEmpty(stats.UntrackedFiles)
46+
47+
stats.TotalFiles = len(stats.StagedFiles) + len(stats.UnstagedFiles) + len(stats.UntrackedFiles)
48+
49+
// Get line statistics from staged changes
50+
if len(stats.StagedFiles) > 0 {
51+
statCmd := exec.Command("git", "-C", config.Path, "diff", "--cached", "--numstat")
52+
statOutput, err := statCmd.Output()
53+
if err == nil {
54+
lines := strings.Split(strings.TrimSpace(string(statOutput)), "\n")
55+
for _, line := range lines {
56+
parts := strings.Fields(line)
57+
if len(parts) >= 2 {
58+
if added := parts[0]; added != "-" {
59+
var addedNum int
60+
fmt.Sscanf(added, "%d", &addedNum)
61+
stats.LinesAdded += addedNum
62+
}
63+
if deleted := parts[1]; deleted != "-" {
64+
var deletedNum int
65+
fmt.Sscanf(deleted, "%d", &deletedNum)
66+
stats.LinesDeleted += deletedNum
67+
}
68+
}
69+
}
70+
}
71+
}
72+
73+
return stats, nil
74+
}

src/internal/utils/utils.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package utils
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// NormalizePath handles both forward and backslashes
10+
func NormalizePath(path string) string {
11+
// Replace backslashes with forward slashes
12+
normalized := strings.ReplaceAll(path, "\\", "/")
13+
// Remove any trailing slash
14+
normalized = strings.TrimSuffix(normalized, "/")
15+
return normalized
16+
}
17+
18+
// IsTextFile checks if a file is likely to be a text file
19+
func IsTextFile(filename string) bool {
20+
// List of common text file extensions
21+
textExtensions := []string{
22+
".txt", ".md", ".go", ".js", ".py", ".java", ".c", ".cpp", ".h",
23+
".html", ".css", ".json", ".xml", ".yaml", ".yml", ".sh", ".bash",
24+
".ts", ".tsx", ".jsx", ".php", ".rb", ".rs", ".dart",
25+
}
26+
27+
ext := strings.ToLower(filepath.Ext(filename))
28+
for _, textExt := range textExtensions {
29+
if ext == textExt {
30+
return true
31+
}
32+
}
33+
34+
return false
35+
}
36+
37+
// IsSmallFile checks if a file is small enough to include in context
38+
func IsSmallFile(filename string) bool {
39+
const maxSize = 10 * 1024 // 10KB max
40+
41+
info, err := os.Stat(filename)
42+
if err != nil {
43+
return false
44+
}
45+
46+
return info.Size() <= maxSize
47+
}
48+
49+
// FilterEmpty removes empty strings from a slice
50+
func FilterEmpty(slice []string) []string {
51+
filtered := []string{}
52+
for _, s := range slice {
53+
if s != "" {
54+
filtered = append(filtered, s)
55+
}
56+
}
57+
return filtered
58+
}

0 commit comments

Comments
 (0)