diff --git a/assets/prompts/system_prompt.txt b/assets/prompts/system_prompt.txt index d555461..3b3202e 100644 --- a/assets/prompts/system_prompt.txt +++ b/assets/prompts/system_prompt.txt @@ -18,6 +18,10 @@ Metadata Context: - Dependency Changes: {{.DependencyAlert}} - Added/Deleted Line Ratio: {{printf "%.2f" .DiffSummary.Ratio}} +Recent Commit History (for style reference): +{{range .RecentCommits}}- {{.}} +{{end}} + Summarized Git Diff: {{.DiffContent}} diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index ac25679..4503131 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -29,6 +29,7 @@ func TestRenderPrompt(t *testing.T) { "internal/auth/login.go", "[func] Login", "Added/Deleted Line Ratio: 0.83", + "Recent Commit History", } for _, part := range expectedParts { diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index b0ccb79..8408310 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -8,6 +8,7 @@ import ( "github.com/andev0x/gitmit/assets" "github.com/andev0x/gitmit/internal/analyzer" + "github.com/andev0x/gitmit/internal/history" ) // PromptContext represents the data structure passed to the prompt template @@ -20,6 +21,7 @@ type PromptContext struct { DependencyAlert string DiffSummary DiffSummary DiffContent string + RecentCommits []string } // DiffSummary contains ratio of changes @@ -61,6 +63,9 @@ func RenderPrompt(msg *analyzer.CommitMessage, projectType, branchName string) ( ratio = float64(msg.TotalAdded) / float64(total) } + // Fetch recent commits for style reference + recentCommits, _ := history.GetRecentCommits(5) + ctx := PromptContext{ ProjectType: projectType, CurrentBranch: branchName, @@ -71,7 +76,8 @@ func RenderPrompt(msg *analyzer.CommitMessage, projectType, branchName string) ( DiffSummary: DiffSummary{ Ratio: ratio, }, - DiffContent: msg.FullDiff, + DiffContent: msg.FullDiff, + RecentCommits: recentCommits, } var buf bytes.Buffer diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 03b5569..1d02435 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -1290,11 +1290,16 @@ func (a *Analyzer) summarizeDiff(diff string) string { var summary strings.Builder scanner := bufio.NewScanner(strings.NewReader(diff)) lineCount := 0 - maxLines := 20 // Limit lines per file to avoid context bloat + maxLines := 25 // Limit lines per file to avoid context bloat for scanner.Scan() { line := scanner.Text() // Only include added/removed lines and hunk headers + // Skip binary files or extremely long lines + if len(line) > 500 { + continue + } + if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "@@") { if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") { continue @@ -1304,7 +1309,7 @@ func (a *Analyzer) summarizeDiff(diff string) string { lineCount++ } if lineCount >= maxLines { - summary.WriteString("... (truncated)\n") + summary.WriteString("... (rest of file truncated)\n") break } } diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index 06e1ebb..29c088e 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -2,6 +2,7 @@ package formatter import ( "fmt" + "regexp" "strings" ) @@ -30,7 +31,8 @@ func (f *Formatter) FormatMessage(msg string, isMajor bool) string { subject := strings.TrimSpace(parts[0]) body := "" if len(parts) > 1 { - body = strings.TrimSpace(parts[1]) + body = strings.TrimLeft(parts[1], "\n\r") + body = strings.TrimRight(body, "\n\r\t ") } // Remove redundant phrases from subject @@ -49,6 +51,7 @@ func (f *Formatter) FormatMessage(msg string, isMajor bool) string { subjectParts := strings.SplitN(wrapped, "\n", 2) subject = subjectParts[0] if len(subjectParts) > 1 { + // Subject overflow becomes the start of the body if body != "" { body = subjectParts[1] + "\n\n" + body } else { @@ -68,7 +71,7 @@ func (f *Formatter) FormatMessage(msg string, isMajor bool) string { return subject } -// wrapString wraps a string at the specified limit, preserving paragraphs +// wrapString wraps a string at the specified limit, preserving paragraphs and structures func (f *Formatter) wrapString(s string, limit int) string { if limit <= 0 { return s @@ -82,27 +85,167 @@ func (f *Formatter) wrapString(s string, limit int) string { result.WriteString("\n\n") } - words := strings.Fields(p) - if len(words) == 0 { - continue - } + lines := strings.Split(p, "\n") + var currentParagraph strings.Builder - currentLineLength := 0 - for j, word := range words { - if j > 0 { - if currentLineLength+1+len(word) > limit { + for _, line := range lines { + if f.isStructural(line) { + // Flush any pending paragraph text + if currentParagraph.Len() > 0 { + result.WriteString(f.reflow(currentParagraph.String(), limit)) result.WriteString("\n") - currentLineLength = 0 - } else { - result.WriteString(" ") - currentLineLength++ + currentParagraph.Reset() + } + // Wrap the structural line itself (preserving its prefix if possible) + result.WriteString(f.wrapLine(line, limit)) + result.WriteString("\n") + } else { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if currentParagraph.Len() > 0 { + currentParagraph.WriteString(" ") } + currentParagraph.WriteString(trimmed) } + } + + if currentParagraph.Len() > 0 { + result.WriteString(f.reflow(currentParagraph.String(), limit)) + } - result.WriteString(word) - currentLineLength += len(word) + // Cleanup trailing newline from structural lines at the end of a paragraph + resStr := result.String() + if strings.HasSuffix(resStr, "\n") { + result.Reset() + result.WriteString(strings.TrimSuffix(resStr, "\n")) } } return result.String() } + +// isStructural identifies lines that should not be reflowed into paragraphs +func (f *Formatter) isStructural(line string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + + // List markers at the start of the trimmed line + markers := []string{"- ", "* ", "+ "} + for _, m := range markers { + if strings.HasPrefix(trimmed, m) { + return true + } + } + + // Numeric list markers (e.g., "1. ") + numericRegex := regexp.MustCompile(`^\d+\.\s`) + if numericRegex.MatchString(trimmed) { + return true + } + + // Significant indentation (at least 2 spaces or a tab) + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + return true + } + + return false +} + +// reflow joins words and wraps them at the limit +func (f *Formatter) reflow(s string, limit int) string { + words := strings.Fields(s) + if len(words) == 0 { + return "" + } + + var res strings.Builder + currLen := 0 + for i, w := range words { + if i > 0 { + if currLen+1+len(w) > limit { + res.WriteString("\n") + currLen = 0 + } else { + res.WriteString(" ") + currLen++ + } + } + res.WriteString(w) + currLen += len(w) + } + return res.String() +} + +// wrapLine wraps a single structural line, attempting to preserve indentation +func (f *Formatter) wrapLine(line string, limit int) string { + if len(line) <= limit { + return line + } + + // Find indentation and prefix + indent := "" + for _, char := range line { + if char == ' ' || char == '\t' { + indent += string(char) + } else { + break + } + } + + // Also check for list markers + content := line[len(indent):] + prefix := "" + markers := []string{"- ", "* ", "+ "} + for _, m := range markers { + if strings.HasPrefix(content, m) { + prefix = m + content = content[len(m):] + break + } + } + + // Handle numeric markers + numericRegex := regexp.MustCompile(`^\d+\.\s`) + if loc := numericRegex.FindStringIndex(content); loc != nil && loc[0] == 0 { + prefix = content[loc[0]:loc[1]] + content = content[loc[1]:] + } + + words := strings.Fields(content) + if len(words) == 0 { + return line + } + + var res strings.Builder + res.WriteString(indent) + res.WriteString(prefix) + currLen := len(indent) + len(prefix) + + for i, w := range words { + if i > 0 { + if currLen+1+len(w) > limit { + res.WriteString("\n") + res.WriteString(indent) + // Extra indentation for wrapped list items + if prefix != "" { + res.WriteString(" ") + } + currLen = len(indent) + if prefix != "" { + currLen += 2 + } + } else { + res.WriteString(" ") + currLen++ + } + } + res.WriteString(w) + currLen += len(w) + } + + return res.String() +} diff --git a/internal/formatter/formatter_test.go b/internal/formatter/formatter_test.go index 4203024..ecae217 100644 --- a/internal/formatter/formatter_test.go +++ b/internal/formatter/formatter_test.go @@ -40,6 +40,34 @@ func TestFormatMessage(t *testing.T) { maxBody: 10, expected: "feat: add\n\nfeature\n\nThis is a\nbody\nmessage\nthat is\nvery long.", }, + { + name: "preserve list structure", + msg: "feat: add feature\n\n- Item 1\n- Item 2 which is quite long", + maxSubject: 50, + maxBody: 15, + expected: "feat: add feature\n\n- Item 1\n- Item 2 which\n is quite long", + }, + { + name: "preserve indentation", + msg: "feat: add feature\n\n Indented text that should not be joined", + maxSubject: 50, + maxBody: 20, + expected: "feat: add feature\n\n Indented text\n that should not\n be joined", + }, + { + name: "multi-paragraph reflow", + msg: "feat: add feature\n\nParagraph 1 line 1\nline 2\n\nParagraph 2", + maxSubject: 50, + maxBody: 50, + expected: "feat: add feature\n\nParagraph 1 line 1 line 2\n\nParagraph 2", + }, + { + name: "subject overflow to body with blank line", + msg: "feat: this is a very long subject line that will overflow", + maxSubject: 20, + maxBody: 72, + expected: "feat: this is a very\n\nlong subject line that will overflow", + }, { name: "redundant phrases", msg: "feat feat: add add new feature",