From a1fb571ba2c221b99a0db1dd34b8ca5c3c046140 Mon Sep 17 00:00:00 2001 From: andev0x Date: Tue, 19 May 2026 02:03:08 +0700 Subject: [PATCH 1/2] feat(analyzer): upgrade language-aware regex and dependency detection --- internal/analyzer/analyzer.go | 269 +++++++++-------------------- internal/analyzer/analyzer_test.go | 128 ++++++++++++++ 2 files changed, 210 insertions(+), 187 deletions(-) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 43ba248..2fba0cf 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -153,6 +153,16 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin } } + // NEW: Monitoring Dependency Changes (Dependency Watcher) + newDeps := a.detectNewDependencies() + if len(newDeps) > 0 { + commitMessage.Action = "chore" + commitMessage.Scope = "deps" + commitMessage.Item = strings.Join(newDeps, ", ") + commitMessage.Purpose = "update dependencies" + return commitMessage // Priority return for dependency updates + } + // NEW: Learning from recent commit history (Commit History Consistency) if historyScope := a.analyzeHistoryScopes(); historyScope != "" { // Only override if scope is empty or "core" @@ -762,113 +772,32 @@ func (a *Analyzer) detectFunctions(diff string) []string { var functions []string scanner := bufio.NewScanner(strings.NewReader(diff)) + // Regex registry for functions + patterns := map[string]*regexp.Regexp{ + "go": regexp.MustCompile(`func\s+(?:\([^)]*\)\s+)?([A-Z][A-Za-z0-9]*)`), + "ts": regexp.MustCompile(`(?:function\s+([a-zA-Z0-9]*)|const\s+([a-zA-Z0-9]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z0-9]*)\s*=>)`), + "js": regexp.MustCompile(`(?:function\s+([a-zA-Z0-9]*)|const\s+([a-zA-Z0-9]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z0-9]*)\s*=>)`), + "python": regexp.MustCompile(`def\s+([a-zA-Z0-9_]+)\s*\(`), + "java": regexp.MustCompile(`(?:public|private|protected|static)\s+(?:[\w<>[\]]+\s+)+([a-zA-Z0-9_]+)\s*\(`), + } + for scanner.Scan() { line := scanner.Text() - - // Only look at added lines - if !strings.HasPrefix(line, "+") { + if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") { continue } - cleanLine := strings.TrimSpace(strings.TrimPrefix(line, "+")) - - // Go functions - if strings.HasPrefix(cleanLine, "func ") { - // Extract function name: func FunctionName( or func (receiver) MethodName( - if strings.Contains(cleanLine, "(") { - // Check for method receiver - if cleanLine[5] == '(' { - // Method: func (r Receiver) MethodName - parts := strings.SplitN(cleanLine[5:], ")", 2) - if len(parts) == 2 { - methodPart := strings.TrimSpace(parts[1]) - if idx := strings.Index(methodPart, "("); idx > 0 { - methodName := strings.TrimSpace(methodPart[:idx]) - if methodName != "" { - functions = append(functions, methodName) - } - } - } - } else { - // Regular function: func FunctionName( - parts := strings.Fields(cleanLine) - if len(parts) >= 2 { - funcName := strings.Split(parts[1], "(")[0] - if funcName != "" { - functions = append(functions, funcName) - } - } - } - } - } - - // JavaScript/TypeScript functions - if strings.Contains(cleanLine, "function ") { - // function functionName( or function( - idx := strings.Index(cleanLine, "function ") - if idx >= 0 { - remaining := cleanLine[idx+9:] - if parenIdx := strings.Index(remaining, "("); parenIdx > 0 { - funcName := strings.TrimSpace(remaining[:parenIdx]) - if funcName != "" && funcName != "function" { - functions = append(functions, funcName) - } - } - } - } - - // Arrow functions: const funcName = () => - if strings.Contains(cleanLine, "=>") && (strings.Contains(cleanLine, "const ") || strings.Contains(cleanLine, "let ") || strings.Contains(cleanLine, "var ")) { - // Extract: const funcName = ... - for _, prefix := range []string{"const ", "let ", "var "} { - if strings.Contains(cleanLine, prefix) { - idx := strings.Index(cleanLine, prefix) - remaining := cleanLine[idx+len(prefix):] - if eqIdx := strings.Index(remaining, "="); eqIdx > 0 { - funcName := strings.TrimSpace(remaining[:eqIdx]) - if funcName != "" { - functions = append(functions, funcName) - } - } - break - } - } - } + cleanLine := strings.TrimPrefix(line, "+") - // Python functions - if strings.HasPrefix(cleanLine, "def ") || strings.HasPrefix(cleanLine, "async def ") { - // Extract: def function_name( or async def function_name( - var remaining string - if strings.HasPrefix(cleanLine, "async def ") { - remaining = cleanLine[10:] - } else { - remaining = cleanLine[4:] - } - if parenIdx := strings.Index(remaining, "("); parenIdx > 0 { - funcName := strings.TrimSpace(remaining[:parenIdx]) - if funcName != "" { - functions = append(functions, funcName) - } - } - } - - // Java/C/C++ methods - // Pattern: public/private/protected Type methodName( - if strings.Contains(cleanLine, "(") { - for _, modifier := range []string{"public ", "private ", "protected ", "static "} { - if strings.Contains(cleanLine, modifier) { - parts := strings.Fields(cleanLine) - // Find the part before ( - for _, part := range parts { - if strings.Contains(part, "(") { - funcName := strings.Split(part, "(")[0] - if funcName != "" && funcName != "if" && funcName != "for" && funcName != "while" && funcName != "switch" { - functions = append(functions, funcName) - break - } - } + for _, re := range patterns { + matches := re.FindStringSubmatch(cleanLine) + if len(matches) > 0 { + // The first captured group (that is not empty) is the function name + for i := 1; i < len(matches); i++ { + if matches[i] != "" { + functions = append(functions, matches[i]) + break } - break } } } @@ -881,87 +810,27 @@ func (a *Analyzer) detectStructs(diff string) []string { var structs []string scanner := bufio.NewScanner(strings.NewReader(diff)) + // Regex registry for structs/classes + patterns := map[string]*regexp.Regexp{ + "go": regexp.MustCompile(`type\s+([A-Z][A-Za-z0-9]*)\s+(?:struct|interface)`), + "ts": regexp.MustCompile(`class\s+([a-zA-Z0-9]*)`), + "js": regexp.MustCompile(`class\s+([a-zA-Z0-9]*)`), + "python": regexp.MustCompile(`class\s+([a-zA-Z0-9_]+)\s*(?:\(|:)`), + "java": regexp.MustCompile(`(?:public|private|protected|abstract)?\s*class\s+([a-zA-Z0-9_]+)`), + } + for scanner.Scan() { line := scanner.Text() - - // Only look at added lines - if !strings.HasPrefix(line, "+") { + if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") { continue } - cleanLine := strings.TrimSpace(strings.TrimPrefix(line, "+")) + cleanLine := strings.TrimPrefix(line, "+") - // Go structs and interfaces - if strings.HasPrefix(cleanLine, "type ") && (strings.Contains(cleanLine, "struct") || strings.Contains(cleanLine, "interface")) { - parts := strings.Fields(cleanLine) - if len(parts) >= 2 { - structName := parts[1] - if structName != "" { - structs = append(structs, structName) - } - } - } - - // JavaScript/TypeScript classes - if strings.HasPrefix(cleanLine, "class ") || strings.HasPrefix(cleanLine, "export class ") { - var remaining string - if strings.HasPrefix(cleanLine, "export class ") { - remaining = cleanLine[13:] - } else { - remaining = cleanLine[6:] - } - - // Extract class name (before space, { or extends) - className := remaining - for _, delimiter := range []string{" ", "{", "extends"} { - if idx := strings.Index(className, delimiter); idx > 0 { - className = className[:idx] - break - } - } - className = strings.TrimSpace(className) - if className != "" { - structs = append(structs, className) - } - } - - // Python classes - if strings.HasPrefix(cleanLine, "class ") { - remaining := cleanLine[6:] - // Extract class name (before ( or :) - className := remaining - for _, delimiter := range []string{"(", ":"} { - if idx := strings.Index(className, delimiter); idx > 0 { - className = className[:idx] - break - } - } - className = strings.TrimSpace(className) - if className != "" { - structs = append(structs, className) - } - } - - // Java classes - if strings.Contains(cleanLine, "class ") { - for _, modifier := range []string{"public class ", "private class ", "protected class ", "abstract class "} { - if strings.Contains(cleanLine, modifier) { - idx := strings.Index(cleanLine, modifier) - remaining := cleanLine[idx+len(modifier):] - // Extract class name (before space, { or extends/implements) - className := remaining - for _, delimiter := range []string{" ", "{", "extends", "implements"} { - if idx := strings.Index(className, delimiter); idx > 0 { - className = className[:idx] - break - } - } - className = strings.TrimSpace(className) - if className != "" { - structs = append(structs, className) - } - break - } + for _, re := range patterns { + matches := re.FindStringSubmatch(cleanLine) + if len(matches) > 1 && matches[1] != "" { + structs = append(structs, matches[1]) } } } @@ -1141,37 +1010,63 @@ func (a *Analyzer) analyzeDiffStat(totalAdded, totalRemoved int) string { return "" } - deletedRatio := float64(totalRemoved) / float64(total) - addedRatio := float64(totalAdded) / float64(total) + // Structural Ratio Calculation + ratio := float64(totalAdded) / float64(total) threshold := a.config.DiffStatThreshold if threshold == 0 { threshold = 0.5 } - // If deleted lines dominate, suggest cleanup or refactor - if deletedRatio > threshold+0.2 { // More than 70% deletions + // If deletions heavily dominate (Ratio < 0.2) + if ratio < 0.2 { return "refactor" } - // If a large number of lines are added with minimal deletions, suggest feat - if addedRatio > threshold+0.2 && totalAdded > 50 { - // Check if it's a new file addition - for _, change := range a.changes { - if change.Action == "A" && change.Added > 30 { - return "feat" - } + // If additions heavily dominate (Ratio > 0.8) + if ratio > 0.8 { + // If many lines added, likely a feature + if totalAdded > 30 { + return "feat" } } // Balanced changes often indicate modifications or fixes - if deletedRatio > 0.3 && addedRatio > 0.3 { + if ratio >= 0.3 && ratio <= 0.7 { return "refactor" } return "" } +// detectNewDependencies identifies newly added libraries in package management files +func (a *Analyzer) detectNewDependencies() []string { + var newDeps []string + depFiles := map[string]*regexp.Regexp{ + "go.mod": regexp.MustCompile(`^\+\s+([^\s]+)\s+v`), + "package.json": regexp.MustCompile(`^\+\s+"([^"]+)":`), + "requirements.txt": regexp.MustCompile(`^\+([a-zA-Z0-9\-_]+)==`), + "Cargo.toml": regexp.MustCompile(`^\+([a-zA-Z0-9\-_]+)\s+=`), + } + + for _, change := range a.changes { + fileName := filepath.Base(change.File) + if re, ok := depFiles[fileName]; ok { + scanner := bufio.NewScanner(strings.NewReader(change.Diff)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + matches := re.FindStringSubmatch(line) + if len(matches) > 1 { + newDeps = append(newDeps, matches[1]) + } + } + } + } + } + return uniqueStrings(newDeps) +} + // parseBranchName extracts type and scope from branch name func (a *Analyzer) parseBranchName(branch string) (string, string) { // Patterns like feature/auth-login or bugfix/fix-memleak diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 34afeee..b86fa80 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -1,6 +1,8 @@ package analyzer import ( + "gitmit/internal/config" + "gitmit/internal/parser" "testing" ) @@ -99,3 +101,129 @@ func TestCalculateHistoryScope(t *testing.T) { }) } } + +func TestDetectNewDependencies(t *testing.T) { + tests := []struct { + name string + fileName string + diff string + want []string + }{ + { + "Go mod addition", + "go.mod", + "+ github.com/stretchr/testify v1.8.0\n+ github.com/spf13/cobra v1.5.0", + []string{"github.com/stretchr/testify", "github.com/spf13/cobra"}, + }, + { + "Package JSON addition", + "package.json", + "+ \"lodash\": \"^4.17.21\",\n+ \"react\": \"^18.2.0\"", + []string{"lodash", "react"}, + }, + { + "Requirements TXT addition", + "requirements.txt", + "+requests==2.28.1\n+flask==2.2.2", + []string{"requests", "flask"}, + }, + { + "Cargo TOML addition", + "Cargo.toml", + "+serde = \"1.0\"\n+tokio = \"1.0\"", + []string{"serde", "tokio"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &Analyzer{ + changes: []*parser.Change{ + {File: tt.fileName, Diff: tt.diff}, + }, + } + got := a.detectNewDependencies() + if len(got) != len(tt.want) { + t.Errorf("detectNewDependencies() got = %v, want %v", got, tt.want) + } + for i, v := range got { + if v != tt.want[i] { + t.Errorf("detectNewDependencies()[%d] = %q, want %q", i, v, tt.want[i]) + } + } + }) + } +} + +func TestAnalyzeDiffStatRatio(t *testing.T) { + a := &Analyzer{config: &config.Config{}} + + tests := []struct { + added int + removed int + want string + }{ + {10, 90, "refactor"}, // Ratio 0.1 < 0.2 + {90, 10, "feat"}, // Ratio 0.9 > 0.8 and added > 30 + {50, 50, "refactor"}, // Ratio 0.5 balanced + {10, 10, "refactor"}, // Ratio 0.5 balanced + {20, 2, "feat"}, // Ratio 0.9 > 0.8 but added < 30 -> empty from this func, defaults elsewhere + } + + for _, tt := range tests { + got := a.analyzeDiffStat(tt.added, tt.removed) + if tt.want == "feat" && tt.added < 30 { + if got != "" { + t.Errorf("analyzeDiffStat(%d, %d) = %q, want \"\"", tt.added, tt.removed, got) + } + } else if got != tt.want { + t.Errorf("analyzeDiffStat(%d, %d) = %q, want %q", tt.added, tt.removed, got, tt.want) + } + } +} + +func TestStructureDetectionRegex(t *testing.T) { + a := &Analyzer{} + + t.Run("Go functions and structs", func(t *testing.T) { + diff := "+func MyFunc() {\n+type MyStruct struct {\n+func (r *Receiver) MyMethod() {" + funcs := a.detectFunctions(diff) + structs := a.detectStructs(diff) + + if !contains(funcs, "MyFunc") { + t.Errorf("Expected MyFunc in %v", funcs) + } + if !contains(structs, "MyStruct") { + t.Errorf("Expected MyStruct in %v", structs) + } + }) + + t.Run("TS functions and classes", func(t *testing.T) { + diff := "+function myFunc() {\n+const myArrow = () => {\n+class MyClass {" + funcs := a.detectFunctions(diff) + structs := a.detectStructs(diff) + + if !contains(funcs, "myFunc") { + t.Errorf("Expected myFunc in %v", funcs) + } + if !contains(funcs, "myArrow") { + t.Errorf("Expected myArrow in %v", funcs) + } + if !contains(structs, "MyClass") { + t.Errorf("Expected MyClass in %v", structs) + } + }) + + t.Run("Python functions and classes", func(t *testing.T) { + diff := "+def my_func():\n+class MyClass:" + funcs := a.detectFunctions(diff) + structs := a.detectStructs(diff) + + if !contains(funcs, "my_func") { + t.Errorf("Expected my_func in %v", funcs) + } + if !contains(structs, "MyClass") { + t.Errorf("Expected MyClass in %v", structs) + } + }) +} From 1295e24efed0a0955b150ad57508c004ce5d6883 Mon Sep 17 00:00:00 2001 From: andev0x Date: Tue, 19 May 2026 02:12:14 +0700 Subject: [PATCH 2/2] perf(parser): optimize memory with streaming diff input --- internal/analyzer/analyzer.go | 129 ++++++++++++++--------------- internal/analyzer/analyzer_test.go | 40 +++++++++ internal/parser/git.go | 100 +++++++++++----------- 3 files changed, 151 insertions(+), 118 deletions(-) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 2fba0cf..a01aec8 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -111,24 +111,67 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin return msg } - // Default analysis based on the first change if no specific fallback applies - firstChange := a.changes[0] + // Initialize a score tracker for the action (type) + scoreMap := make(map[string]int) - // Apply diff stat analysis to infer intent based on added vs deleted lines - action := a.analyzeDiffStat(totalAdded, totalRemoved) - if action != "" { - commitMessage.Action = action - } else { - // Use keyword scoring algorithm to determine the best action - action = a.determineActionByKeywordScoring() - if action != "" { - commitMessage.Action = action - } else { - // Fallback to default action determination - commitMessage.Action = a.determineAction(firstChange) + // Step 1: Scan the Branch status + if branchName != "" { + branchAction, branchScope := a.parseBranchName(branchName) + if branchAction != "" { + scoreMap[branchAction] += 3 + } + if branchScope != "" { + commitMessage.Scope = branchScope + } + } + + // Step 2: Add weights from diff stat ratio + statAction := a.analyzeDiffStat(totalAdded, totalRemoved) + if statAction != "" { + scoreMap[statAction] += 2 + } + + // Step 3: Aggregate keyword scores + keywordScores := a.calculateKeywordScores() + for action, score := range keywordScores { + scoreMap[action] += score + } + + // Step 4: Add weights from multi-file patterns + multiPatterns := a.detectMultiFilePatterns() + for _, p := range multiPatterns { + switch p { + case "feature-addition": + scoreMap["feat"] += 4 + case "bug-fix-cascade": + scoreMap["fix"] += 4 + case "refactor-sweep": + scoreMap["refactor"] += 3 + case "test-suite-update": + scoreMap["test"] += 4 + } + } + + // Step 5: Select the recommended type with the highest accumulated score + bestAction := "" + maxScore := -1 + for action, score := range scoreMap { + if score > maxScore { + maxScore = score + bestAction = action } } + if bestAction != "" { + commitMessage.Action = bestAction + } else { + // Fallback to default action determination if no signals + commitMessage.Action = a.determineAction(a.changes[0]) + } + + // Default analysis based on the first change if no specific fallback applies + firstChange := a.changes[0] + // Determine other components commitMessage.Topic = a.determineTopic(firstChange.File) commitMessage.Item = a.determineItem(firstChange.File) @@ -142,17 +185,6 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin } } - // NEW: Extract intent from branch name - if branchName != "" { - branchAction, branchScope := a.parseBranchName(branchName) - if branchAction != "" { - commitMessage.Action = branchAction - } - if branchScope != "" { - commitMessage.Scope = branchScope - } - } - // NEW: Monitoring Dependency Changes (Dependency Watcher) newDeps := a.detectNewDependencies() if len(newDeps) > 0 { @@ -179,33 +211,14 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin } } - // Detect multi-file patterns - multiPatterns := a.detectMultiFilePatterns() - if len(multiPatterns) > 0 { - // Adjust action and purpose based on multi-file patterns - if contains(multiPatterns, "feature-addition") { - commitMessage.Action = "feat" - commitMessage.Purpose = "add new feature across multiple modules" - } else if contains(multiPatterns, "bug-fix-cascade") { - commitMessage.Action = "fix" - commitMessage.Purpose = "resolve issue across multiple components" - } else if contains(multiPatterns, "refactor-sweep") { - commitMessage.Action = "refactor" - commitMessage.Purpose = "restructure and improve code organization" - } else if contains(multiPatterns, "test-suite-update") { - commitMessage.Action = "test" - commitMessage.Purpose = "update test suite" - } - } - return commitMessage } -// determineActionByKeywordScoring analyzes git diff content and scores keywords to determine the best action -// This implements the keyword scoring algorithm requirement -func (a *Analyzer) determineActionByKeywordScoring() string { +// calculateKeywordScores analyzes git diff content and returns a map of scores for each action +func (a *Analyzer) calculateKeywordScores() map[string]int { + actionScores := make(map[string]int) if len(a.config.Keywords) == 0 { - return "" // No keywords configured, fall back to default logic + return actionScores } // Concatenate all diffs @@ -216,9 +229,6 @@ func (a *Analyzer) determineActionByKeywordScoring() string { } diffContent := strings.ToLower(allDiffs.String()) - // Score each action based on keyword matches - actionScores := make(map[string]int) - for action, keywords := range a.config.Keywords { score := 0 for keyword, weight := range keywords { @@ -230,22 +240,7 @@ func (a *Analyzer) determineActionByKeywordScoring() string { actionScores[action] = score } - // Find the action with the highest score - maxScore := 0 - bestAction := "" - for action, score := range actionScores { - if score > maxScore { - maxScore = score - bestAction = action - } - } - - // Only return the action if the score is significant (> 0) - if maxScore > 0 { - return bestAction - } - - return "" + return actionScores } // detectIntelligentScope determines the best scope based on file paths and patterns diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index b86fa80..2c64a78 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -227,3 +227,43 @@ func TestStructureDetectionRegex(t *testing.T) { } }) } + +func TestCrossScoringMatrix(t *testing.T) { + cfg := &config.Config{ + Keywords: map[string]map[string]int{ + "fix": {"error": 4}, + }, + } + + t.Run("Branch overrides keyword if score is higher", func(t *testing.T) { + a := &Analyzer{ + config: cfg, + changes: []*parser.Change{ + {File: "main.go", Diff: "+ fmt.Println(\"error\")"}, + }, + } + // branch "feat/new-ui" adds 3 to feat + // "error" keyword adds 4 to fix + // fix (4) > feat (3) -> fix + msg := a.AnalyzeChanges(1, 0, "feat/new-ui") + if msg.Action != "fix" { + t.Errorf("Expected action fix, got %s", msg.Action) + } + }) + + t.Run("Combined signals", func(t *testing.T) { + a := &Analyzer{ + config: cfg, + changes: []*parser.Change{ + {File: "main.go", Diff: "+ func NewFeature() {", Added: 40, Removed: 0}, + }, + } + // branch "feature/cool" adds 3 to feat + // ratio 1.0 adds 2 to feat (added > 30) + // total feat = 5 + msg := a.AnalyzeChanges(40, 0, "feature/cool") + if msg.Action != "feat" { + t.Errorf("Expected action feat, got %s", msg.Action) + } + }) +} diff --git a/internal/parser/git.go b/internal/parser/git.go index 02dc96f..2f4a877 100644 --- a/internal/parser/git.go +++ b/internal/parser/git.go @@ -2,7 +2,6 @@ package parser import ( "bufio" - "bytes" "fmt" "os/exec" "path/filepath" @@ -39,15 +38,17 @@ func NewGitParser() *GitParser { func (p *GitParser) ParseStagedChanges() ([]*Change, error) { // Use git status --porcelain for more accurate file state detection cmd := exec.Command("git", "status", "--porcelain") - var out bytes.Buffer - cmd.Stdout = &out - err := cmd.Run() + stdout, err := cmd.StdoutPipe() if err != nil { - return nil, fmt.Errorf("error running git status --porcelain: %w", err) + return nil, fmt.Errorf("error creating stdout pipe for git status: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("error starting git status: %w", err) } var changes []*Change - scanner := bufio.NewScanner(&out) + scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() if len(line) < 3 { @@ -55,38 +56,22 @@ func (p *GitParser) ParseStagedChanges() ([]*Change, error) { } // Porcelain format: XY filename - // X = staged status, Y = unstaged status stagedStatus := line[0:1] - // unstagedStatus := line[1:2] filename := strings.TrimSpace(line[3:]) - // Skip if not staged (staged status is space) + // Skip if not staged if stagedStatus == " " || stagedStatus == "?" { continue } - // Map porcelain status to action action := stagedStatus - switch stagedStatus { - case "M": - action = "M" // Modified - case "A": - action = "A" // Added - case "D": - action = "D" // Deleted - case "R": - action = "R" // Renamed - case "C": - action = "C" // Copied - } - change := &Change{ File: filename, Action: action, FileExtension: getFileExtension(filename), } - // Handle renames and copies (format: "R oldname -> newname") + // Handle renames and copies if action == "R" || action == "C" { parts := strings.Split(filename, " -> ") if len(parts) == 2 { @@ -94,36 +79,36 @@ func (p *GitParser) ParseStagedChanges() ([]*Change, error) { change.IsCopy = action == "C" change.Source = strings.TrimSpace(parts[0]) change.Target = strings.TrimSpace(parts[1]) - change.File = change.Target // Use the new name as the file + change.File = change.Target change.FileExtension = getFileExtension(change.Target) } } - // Get the diff for the file + // Get the diff for the file using streaming diffCmd := exec.Command("git", "diff", "--cached", "-U0", "--", change.File) - var diffOut bytes.Buffer - diffCmd.Stdout = &diffOut - err := diffCmd.Run() - if err != nil && action != "D" { - // For deleted files, diff may fail, which is expected - return nil, fmt.Errorf("error running git diff for %s: %w", change.File, err) - } - change.Diff = diffOut.String() - - // Count added and removed lines - diffScanner := bufio.NewScanner(strings.NewReader(change.Diff)) - for diffScanner.Scan() { - diffLine := diffScanner.Text() - if strings.HasPrefix(diffLine, "+") && !strings.HasPrefix(diffLine, "+++") { - change.Added++ - } else if strings.HasPrefix(diffLine, "-") && !strings.HasPrefix(diffLine, "---") { - change.Removed++ + diffStdout, err := diffCmd.StdoutPipe() + if err == nil { + if err := diffCmd.Start(); err == nil { + diffScanner := bufio.NewScanner(diffStdout) + var diffBuilder strings.Builder + for diffScanner.Scan() { + diffLine := diffScanner.Text() + if strings.HasPrefix(diffLine, "+") && !strings.HasPrefix(diffLine, "+++") { + change.Added++ + } else if strings.HasPrefix(diffLine, "-") && !strings.HasPrefix(diffLine, "---") { + change.Removed++ + } + diffBuilder.WriteString(diffLine) + diffBuilder.WriteString("\n") + } + change.Diff = diffBuilder.String() + diffCmd.Wait() } } + p.TotalAdded += change.Added p.TotalRemoved += change.Removed - // Detect large changes if (change.Added + change.Removed) >= 500 { change.IsMajor = true } @@ -131,8 +116,8 @@ func (p *GitParser) ParseStagedChanges() ([]*Change, error) { changes = append(changes, change) } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error scanning git status output: %w", err) + if err := cmd.Wait(); err != nil { + return nil, fmt.Errorf("error waiting for git status: %w", err) } return changes, nil @@ -141,13 +126,26 @@ func (p *GitParser) ParseStagedChanges() ([]*Change, error) { // GetCurrentBranch returns the name of the current git branch func (p *GitParser) GetCurrentBranch() (string, error) { cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - var out bytes.Buffer - cmd.Stdout = &out - err := cmd.Run() + stdout, err := cmd.StdoutPipe() if err != nil { - return "", fmt.Errorf("error getting current branch: %w", err) + return "", fmt.Errorf("error creating stdout pipe for rev-parse: %w", err) } - return strings.TrimSpace(out.String()), nil + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("error starting rev-parse: %w", err) + } + + var branch string + scanner := bufio.NewScanner(stdout) + if scanner.Scan() { + branch = strings.TrimSpace(scanner.Text()) + } + + if err := cmd.Wait(); err != nil { + return "", fmt.Errorf("error waiting for rev-parse: %w", err) + } + + return branch, nil } // getFileExtension returns the file extension of a given file path