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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user