feat(git): add git-aware file change detection infrastructure

- Add GitStatus, FileChange, LineRange types for git integration
- Implement DetectRepo() for repository detection
- Add GetStagedChanges() and GetUnstagedChanges() functions
- Create ParseDiffUnified() for git diff parsing
- Add line range utilities for precise change targeting
- Include comprehensive test suite for git operations
This commit is contained in:
carraes
2025-07-05 22:28:00 -03:00
parent cc41807975
commit 36c10aefdf
3 changed files with 365 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
package git
import (
"bufio"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
func DetectRepo() (*GitStatus, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return &GitStatus{IsRepo: false}, nil
}
rootDir := strings.TrimSpace(string(output))
return &GitStatus{
IsRepo: true,
RootDir: rootDir,
}, nil
}
func GetStagedChanges() ([]FileChange, error) {
cmd := exec.Command("git", "diff", "--cached", "--name-only")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get staged files: %v", err)
}
files := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(files) == 1 && files[0] == "" {
return []FileChange{}, nil
}
var changes []FileChange
for _, file := range files {
if file == "" {
continue
}
lineRanges, err := getLineRangesFromDiff(file, true)
if err != nil {
return nil, fmt.Errorf("failed to get line ranges for %s: %v", file, err)
}
changes = append(changes, FileChange{
Path: file,
Status: StatusStaged,
LineRanges: lineRanges,
})
}
return changes, nil
}
func GetUnstagedChanges() ([]FileChange, error) {
var changes []FileChange
cmd := exec.Command("git", "diff", "--name-only")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get unstaged modified files: %v", err)
}
modifiedFiles := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, file := range modifiedFiles {
if file == "" {
continue
}
lineRanges, err := getLineRangesFromDiff(file, false)
if err != nil {
return nil, fmt.Errorf("failed to get line ranges for %s: %v", file, err)
}
changes = append(changes, FileChange{
Path: file,
Status: StatusUnstaged,
LineRanges: lineRanges,
})
}
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
output, err = cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get untracked files: %v", err)
}
untrackedFiles := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, file := range untrackedFiles {
if file == "" {
continue
}
changes = append(changes, FileChange{
Path: file,
Status: StatusUntracked,
LineRanges: []LineRange{},
})
}
return changes, nil
}
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
var cmd *exec.Cmd
if staged {
cmd = exec.Command("git", "diff", "--cached", "--unified=0", file)
} else {
cmd = exec.Command("git", "diff", "--unified=0", file)
}
output, err := cmd.Output()
if err != nil {
return nil, err
}
return ParseDiffUnified(string(output))
}
func ParseDiffUnified(diff string) ([]LineRange, error) {
var ranges []LineRange
headerRegex := regexp.MustCompile(`@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@`)
scanner := bufio.NewScanner(strings.NewReader(diff))
for scanner.Scan() {
line := scanner.Text()
matches := headerRegex.FindStringSubmatch(line)
if len(matches) > 0 {
newStart, err := strconv.Atoi(matches[3])
if err != nil {
continue
}
var newCount int
if matches[4] == "" {
newCount = 1
} else {
newCount, err = strconv.Atoi(matches[4])
if err != nil {
continue
}
}
if newCount > 0 {
ranges = append(ranges, LineRange{
Start: newStart,
End: newStart + newCount - 1,
})
}
}
}
return ranges, scanner.Err()
}
func IsInLineRanges(lineNum int, ranges []LineRange) bool {
for _, r := range ranges {
if lineNum >= r.Start && lineNum <= r.End {
return true
}
}
return false
}
func GetChangesOnly() ([]FileChange, error) {
var allChanges []FileChange
staged, err := GetStagedChanges()
if err != nil {
return nil, fmt.Errorf("failed to get staged changes: %v", err)
}
allChanges = append(allChanges, staged...)
unstaged, err := GetUnstagedChanges()
if err != nil {
return nil, fmt.Errorf("failed to get unstaged changes: %v", err)
}
allChanges = append(allChanges, unstaged...)
return allChanges, nil
}
+129
View File
@@ -0,0 +1,129 @@
package git
import (
"testing"
)
func TestParseGitDiffUnified(t *testing.T) {
tests := []struct {
name string
input string
expected []LineRange
}{
{
name: "single line change",
input: `@@ -10,1 +10,1 @@
-old line
+new line`,
expected: []LineRange{{Start: 10, End: 10}},
},
{
name: "multiple line addition",
input: `@@ -5,0 +5,3 @@
+line 1
+line 2
+line 3`,
expected: []LineRange{{Start: 5, End: 7}},
},
{
name: "multiple hunks",
input: `@@ -10,1 +10,1 @@
-old line
+new line
@@ -20,0 +20,2 @@
+added line 1
+added line 2`,
expected: []LineRange{
{Start: 10, End: 10},
{Start: 20, End: 21},
},
},
{
name: "deletion only",
input: `@@ -10,2 +10,0 @@
-deleted line 1
-deleted line 2`,
expected: []LineRange{},
},
{
name: "no count specified (single line)",
input: `@@ -10 +10 @@
-old line
+new line`,
expected: []LineRange{{Start: 10, End: 10}},
},
{
name: "empty diff",
input: "",
expected: []LineRange{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseDiffUnified(tt.input)
if err != nil {
t.Fatalf("ParseDiffUnified() error = %v", err)
}
if len(result) != len(tt.expected) {
t.Fatalf("ParseDiffUnified() = %v, want %v", result, tt.expected)
}
for i, r := range result {
if r.Start != tt.expected[i].Start || r.End != tt.expected[i].End {
t.Errorf("ParseDiffUnified()[%d] = %v, want %v", i, r, tt.expected[i])
}
}
})
}
}
func TestIsInLineRanges(t *testing.T) {
ranges := []LineRange{
{Start: 5, End: 7},
{Start: 10, End: 12},
{Start: 20, End: 20},
}
tests := []struct {
lineNum int
expected bool
}{
{1, false},
{4, false},
{5, true},
{6, true},
{7, true},
{8, false},
{10, true},
{11, true},
{12, true},
{15, false},
{20, true},
{21, false},
{100, false},
}
for _, tt := range tests {
t.Run(string(rune(tt.lineNum)), func(t *testing.T) {
result := IsInLineRanges(tt.lineNum, ranges)
if result != tt.expected {
t.Errorf("IsInLineRanges(%d) = %v, want %v", tt.lineNum, result, tt.expected)
}
})
}
}
func TestIsInLineRangesEmptyRanges(t *testing.T) {
var emptyRanges []LineRange
testLines := []int{1, 5, 10, 100}
for _, lineNum := range testLines {
result := IsInLineRanges(lineNum, emptyRanges)
if result != false {
t.Errorf("IsInLineRanges(%d, emptyRanges) = %v, want false", lineNum, result)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package git
type GitStatus struct {
IsRepo bool
RootDir string
}
type FileStatus int
const (
StatusStaged FileStatus = iota
StatusUnstaged
StatusUntracked
)
type FileChange struct {
Path string
Status FileStatus
LineRanges []LineRange
}
type LineRange struct {
Start int
End int
}