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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Hacktoberfest](https://img.shields.io/badge/Hacktoberfest-2025-orange.svg)](https://hacktoberfest.com/)
[![Go Version](https://img.shields.io/badge/Go-1.23.4-blue.svg)](https://golang.org/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

![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)
`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.

## Screenshot
Expand Down
133 changes: 133 additions & 0 deletions src/internal/display/display.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package display

import (
"fmt"

"github.com/pterm/pterm"
)

// FileStatistics holds statistics about changed files
type FileStatistics struct {
StagedFiles []string
UnstagedFiles []string
UntrackedFiles []string
TotalFiles int
LinesAdded int
LinesDeleted int
}

// ShowFileStatistics displays file statistics with colored output
func ShowFileStatistics(stats *FileStatistics) {
pterm.DefaultSection.Println("📊 Changes Summary")

// Create bullet list items
bulletItems := []pterm.BulletListItem{}

if len(stats.StagedFiles) > 0 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 0,
Text: pterm.Green(fmt.Sprintf("✅ Staged files: %d", len(stats.StagedFiles))),
TextStyle: pterm.NewStyle(pterm.FgGreen),
BulletStyle: pterm.NewStyle(pterm.FgGreen),
})
for i, file := range stats.StagedFiles {
if i < 5 { // Show first 5 files
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 1,
Text: file,
})
}
}
if len(stats.StagedFiles) > 5 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 1,
Text: pterm.Gray(fmt.Sprintf("... and %d more", len(stats.StagedFiles)-5)),
})
}
}

if len(stats.UnstagedFiles) > 0 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 0,
Text: pterm.Yellow(fmt.Sprintf("⚠️ Unstaged files: %d", len(stats.UnstagedFiles))),
TextStyle: pterm.NewStyle(pterm.FgYellow),
BulletStyle: pterm.NewStyle(pterm.FgYellow),
})
for i, file := range stats.UnstagedFiles {
if i < 3 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 1,
Text: file,
})
}
}
if len(stats.UnstagedFiles) > 3 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 1,
Text: pterm.Gray(fmt.Sprintf("... and %d more", len(stats.UnstagedFiles)-3)),
})
}
}

if len(stats.UntrackedFiles) > 0 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 0,
Text: pterm.Cyan(fmt.Sprintf("📝 Untracked files: %d", len(stats.UntrackedFiles))),
TextStyle: pterm.NewStyle(pterm.FgCyan),
BulletStyle: pterm.NewStyle(pterm.FgCyan),
})
for i, file := range stats.UntrackedFiles {
if i < 3 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 1,
Text: file,
})
}
}
if len(stats.UntrackedFiles) > 3 {
bulletItems = append(bulletItems, pterm.BulletListItem{
Level: 1,
Text: pterm.Gray(fmt.Sprintf("... and %d more", len(stats.UntrackedFiles)-3)),
})
}
}

pterm.DefaultBulletList.WithItems(bulletItems).Render()
}

// ShowCommitMessage displays the commit message in a styled panel
func ShowCommitMessage(message string) {
pterm.DefaultSection.Println("📝 Generated Commit Message")

// Create a panel with the commit message
panel := pterm.DefaultBox.
WithTitle("Commit Message").
WithTitleTopCenter().
WithBoxStyle(pterm.NewStyle(pterm.FgLightGreen)).
WithHorizontalString("─").
WithVerticalString("│").
WithTopLeftCornerString("┌").
WithTopRightCornerString("┐").
WithBottomLeftCornerString("└").
WithBottomRightCornerString("┘")

panel.Println(pterm.LightGreen(message))
}

// ShowChangesPreview displays a preview of changes with line statistics
func ShowChangesPreview(stats *FileStatistics) {
pterm.DefaultSection.Println("🔍 Changes Preview")

// Create info boxes
if stats.LinesAdded > 0 || stats.LinesDeleted > 0 {
infoData := [][]string{
{"Lines Added", pterm.Green(fmt.Sprintf("+%d", stats.LinesAdded))},
{"Lines Deleted", pterm.Red(fmt.Sprintf("-%d", stats.LinesDeleted))},
{"Total Files", pterm.Cyan(fmt.Sprintf("%d", stats.TotalFiles))},
}

pterm.DefaultTable.WithHasHeader(false).WithData(infoData).Render()
} else {
pterm.Info.Println("No line statistics available for unstaged changes")
}
}
119 changes: 119 additions & 0 deletions src/internal/git/operations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package git

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/dfanso/commit-msg/src/internal/utils"
"github.com/dfanso/commit-msg/src/types"
)

// IsRepository checks if a directory is a git repository
func IsRepository(path string) bool {
cmd := exec.Command("git", "-C", path, "rev-parse", "--is-inside-work-tree")
output, err := cmd.CombinedOutput()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "true"
}

// GetChanges retrieves all Git changes including staged, unstaged, and untracked files
func GetChanges(config *types.RepoConfig) (string, error) {
var changes strings.Builder

// 1. Check for unstaged changes
cmd := exec.Command("git", "-C", config.Path, "diff", "--name-status")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git diff failed: %v", err)
}

if len(output) > 0 {
changes.WriteString("Unstaged changes:\n")
changes.WriteString(string(output))
changes.WriteString("\n\n")

// Get the content of these changes
diffCmd := exec.Command("git", "-C", config.Path, "diff")
diffOutput, err := diffCmd.Output()
if err != nil {
return "", fmt.Errorf("git diff content failed: %v", err)
}

changes.WriteString("Unstaged diff content:\n")
changes.WriteString(string(diffOutput))
changes.WriteString("\n\n")
}

// 2. Check for staged changes
stagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-status", "--cached")
stagedOutput, err := stagedCmd.Output()
if err != nil {
return "", fmt.Errorf("git diff --cached failed: %v", err)
}

if len(stagedOutput) > 0 {
changes.WriteString("Staged changes:\n")
changes.WriteString(string(stagedOutput))
changes.WriteString("\n\n")

// Get the content of these changes
stagedDiffCmd := exec.Command("git", "-C", config.Path, "diff", "--cached")
stagedDiffOutput, err := stagedDiffCmd.Output()
if err != nil {
return "", fmt.Errorf("git diff --cached content failed: %v", err)
}

changes.WriteString("Staged diff content:\n")
changes.WriteString(string(stagedDiffOutput))
changes.WriteString("\n\n")
}

// 3. Check for untracked files
untrackedCmd := exec.Command("git", "-C", config.Path, "ls-files", "--others", "--exclude-standard")
untrackedOutput, err := untrackedCmd.Output()
if err != nil {
return "", fmt.Errorf("git ls-files failed: %v", err)
}

if len(untrackedOutput) > 0 {
changes.WriteString("Untracked files:\n")
changes.WriteString(string(untrackedOutput))
changes.WriteString("\n\n")

// Try to get content of untracked files (limited to text files and smaller size)
untrackedFiles := strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n")
for _, file := range untrackedFiles {
if file == "" {
continue
}

fullPath := filepath.Join(config.Path, file)
if utils.IsTextFile(fullPath) && utils.IsSmallFile(fullPath) {
fileContent, err := os.ReadFile(fullPath)
if err != nil {
// Log but don't fail - untracked file may have been deleted or is inaccessible
continue
}
changes.WriteString(fmt.Sprintf("Content of new file %s:\n", file))
changes.WriteString(string(fileContent))
changes.WriteString("\n\n")
}
}
}

// 4. Get recent commits for context
recentCommitsCmd := exec.Command("git", "-C", config.Path, "log", "--oneline", "-n", "3")
recentCommitsOutput, err := recentCommitsCmd.Output()
if err == nil && len(recentCommitsOutput) > 0 {
changes.WriteString("Recent commits for context:\n")
changes.WriteString(string(recentCommitsOutput))
changes.WriteString("\n")
}

return changes.String(), nil
}
74 changes: 74 additions & 0 deletions src/internal/stats/statistics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package stats

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

"github.com/dfanso/commit-msg/src/internal/display"
"github.com/dfanso/commit-msg/src/internal/utils"
"github.com/dfanso/commit-msg/src/types"
)

// GetFileStatistics collects comprehensive file statistics from Git
func GetFileStatistics(config *types.RepoConfig) (*display.FileStatistics, error) {
stats := &display.FileStatistics{
StagedFiles: []string{},
UnstagedFiles: []string{},
UntrackedFiles: []string{},
}

// Get staged files
stagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-only", "--cached")
stagedOutput, err := stagedCmd.Output()
if err == nil && len(stagedOutput) > 0 {
stats.StagedFiles = strings.Split(strings.TrimSpace(string(stagedOutput)), "\n")
}

// Get unstaged files
unstagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-only")
unstagedOutput, err := unstagedCmd.Output()
if err == nil && len(unstagedOutput) > 0 {
stats.UnstagedFiles = strings.Split(strings.TrimSpace(string(unstagedOutput)), "\n")
}

// Get untracked files
untrackedCmd := exec.Command("git", "-C", config.Path, "ls-files", "--others", "--exclude-standard")
untrackedOutput, err := untrackedCmd.Output()
if err == nil && len(untrackedOutput) > 0 {
stats.UntrackedFiles = strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n")
}
Comment on lines +22 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Propagate git command errors to the caller.

The function silently ignores all errors from git commands. If git is not installed, the repository is corrupted, or there's a permission issue, the function returns empty statistics without any indication of failure. This makes debugging difficult and could mask critical issues.

Consider propagating the first encountered error:

 	// Get staged files
 	stagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-only", "--cached")
 	stagedOutput, err := stagedCmd.Output()
-	if err == nil && len(stagedOutput) > 0 {
+	if err != nil {
+		return nil, fmt.Errorf("failed to get staged files: %w", err)
+	}
+	if len(stagedOutput) > 0 {
 		stats.StagedFiles = strings.Split(strings.TrimSpace(string(stagedOutput)), "\n")
 	}
 
 	// Get unstaged files
 	unstagedCmd := exec.Command("git", "-C", config.Path, "diff", "--name-only")
 	unstagedOutput, err := unstagedCmd.Output()
-	if err == nil && len(unstagedOutput) > 0 {
+	if err != nil {
+		return nil, fmt.Errorf("failed to get unstaged files: %w", err)
+	}
+	if len(unstagedOutput) > 0 {
 		stats.UnstagedFiles = strings.Split(strings.TrimSpace(string(unstagedOutput)), "\n")
 	}
 
 	// Get untracked files
 	untrackedCmd := exec.Command("git", "-C", config.Path, "ls-files", "--others", "--exclude-standard")
 	untrackedOutput, err := untrackedCmd.Output()
-	if err == nil && len(untrackedOutput) > 0 {
+	if err != nil {
+		return nil, fmt.Errorf("failed to get untracked files: %w", err)
+	}
+	if len(untragedOutput) > 0 {
 		stats.UntrackedFiles = strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n")
 	}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/internal/stats/statistics.go around lines 22 to 40, the code currently
ignores errors from the git exec.Command.Output() calls; update the function to
propagate the first encountered error back to the caller instead of silently
continuing: after each Output() call check if err != nil and immediately return
the error (or wrap it with context), otherwise process the output as currently
done (trim/split and assign to stats fields only when non-empty); ensure the
function signature is updated to return an error if it doesn't already and
include contextual messages (e.g., which git subcommand failed) when returning
the error.


// Filter empty strings
stats.StagedFiles = utils.FilterEmpty(stats.StagedFiles)
stats.UnstagedFiles = utils.FilterEmpty(stats.UnstagedFiles)
stats.UntrackedFiles = utils.FilterEmpty(stats.UntrackedFiles)

stats.TotalFiles = len(stats.StagedFiles) + len(stats.UnstagedFiles) + len(stats.UntrackedFiles)

// Get line statistics from staged changes
if len(stats.StagedFiles) > 0 {
statCmd := exec.Command("git", "-C", config.Path, "diff", "--cached", "--numstat")
statOutput, err := statCmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(statOutput)), "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) >= 2 {
if added := parts[0]; added != "-" {
var addedNum int
fmt.Sscanf(added, "%d", &addedNum)
stats.LinesAdded += addedNum
}
if deleted := parts[1]; deleted != "-" {
var deletedNum int
fmt.Sscanf(deleted, "%d", &deletedNum)
stats.LinesDeleted += deletedNum
}
}
}
}
}

return stats, nil
}
58 changes: 58 additions & 0 deletions src/internal/utils/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package utils

import (
"os"
"path/filepath"
"strings"
)

// NormalizePath handles both forward and backslashes
func NormalizePath(path string) string {
// Replace backslashes with forward slashes
normalized := strings.ReplaceAll(path, "\\", "/")
// Remove any trailing slash
normalized = strings.TrimSuffix(normalized, "/")
return normalized
}

// IsTextFile checks if a file is likely to be a text file
func IsTextFile(filename string) bool {
// List of common text file extensions
textExtensions := []string{
".txt", ".md", ".go", ".js", ".py", ".java", ".c", ".cpp", ".h",
".html", ".css", ".json", ".xml", ".yaml", ".yml", ".sh", ".bash",
".ts", ".tsx", ".jsx", ".php", ".rb", ".rs", ".dart",
}

ext := strings.ToLower(filepath.Ext(filename))
for _, textExt := range textExtensions {
if ext == textExt {
return true
}
}

return false
}

// IsSmallFile checks if a file is small enough to include in context
func IsSmallFile(filename string) bool {
const maxSize = 10 * 1024 // 10KB max

info, err := os.Stat(filename)
if err != nil {
return false
}

return info.Size() <= maxSize
}

// FilterEmpty removes empty strings from a slice
func FilterEmpty(slice []string) []string {
filtered := []string{}
for _, s := range slice {
if s != "" {
filtered = append(filtered, s)
}
}
return filtered
}
Loading
Loading