internal/platform/git/git_changes.go (177 lines of code) (raw):
/*
* Copyright 2021-2024 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package git
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/JetBrains/qodana-cli/internal/platform/strutil"
log "github.com/sirupsen/logrus"
)
type ChangedRegion struct {
FirstLine int `json:"firstLine"`
Count int `json:"count"`
}
type HunkChange struct {
FromPath string
ToPath string
Added []*ChangedRegion
Deleted []*ChangedRegion
}
type ChangedFile struct {
Path string `json:"path"`
Added []*ChangedRegion `json:"added"`
Deleted []*ChangedRegion `json:"deleted"`
}
type ChangedFiles struct {
Files []*ChangedFile `json:"files"`
}
func computeAbsPath(cwd string) (string, error) {
cwd, err := filepath.EvalSymlinks(cwd)
if err != nil {
return "", err
}
cwdAbs, err := filepath.Abs(cwd)
return cwdAbs, err
}
func ComputeChangedFiles(cwd string, diffStart string, diffEnd string, logdir string) (ChangedFiles, error) {
absCwd, err := computeAbsPath(cwd)
if err != nil {
return ChangedFiles{}, err
}
repoRoot, err := Root(cwd, logdir)
if err != nil {
return ChangedFiles{}, err
}
absRepoRoot, err := computeAbsPath(repoRoot)
if err != nil {
return ChangedFiles{}, err
}
filePath, _ := filepath.Abs(filepath.Join(logdir, "git-diff.log"))
file, err := os.Create(filePath)
if err != nil {
return ChangedFiles{}, fmt.Errorf("failed to create file %s: %w", filePath, err)
}
if err = file.Close(); err != nil {
return ChangedFiles{}, fmt.Errorf("failed to close file %s: %w", filePath, err)
}
// Rev-parsing references in advance helps with clearer error messages and in case references could be confused
// with `git diff` options.
diffStartSha, err := RevParse(cwd, diffStart, logdir)
if err != nil {
return ChangedFiles{}, err
}
log.Debugf("Resolved git ref %q as %s", diffStart, diffStartSha)
diffEndSha, err := RevParse(cwd, diffEnd, logdir)
if err != nil {
return ChangedFiles{}, err
}
log.Debugf("Resolved git ref %q as %s", diffEnd, diffEndSha)
_, _, err = gitRun(
cwd,
[]string{"diff", diffStartSha, diffEndSha, "--unified=0", "--no-renames", ">", strutil.QuoteIfSpace(filePath)},
logdir,
)
if err != nil {
return ChangedFiles{}, err
}
return parseDiff(filePath, absRepoRoot, absCwd)
}
// parseDiff parses the git diff output and extracts changes
func parseDiff(diffPath string, repoRoot string, cwd string) (ChangedFiles, error) {
log.Debugf("Parsing diff - repo root: %s, cwd: %s", repoRoot, cwd)
var changes []HunkChange
diffFile, err := os.Open(diffPath)
if err != nil {
return ChangedFiles{}, fmt.Errorf("failed to open diff file %s: %w", diffPath, err)
}
defer func(diffFile *os.File) {
err := diffFile.Close()
if err != nil {
log.Errorf("failed to close diff file %s: %s", diffPath, err)
}
}(diffFile)
scanner := bufio.NewReader(diffFile)
var currentChange *HunkChange
var line string
// Regular expressions to match diff headers and hunks
reFilename := regexp.MustCompile(`^diff --git a/(.*) b/(.*)`)
reHunk := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
for {
line, err = scanner.ReadString('\n')
if err == io.EOF || err != nil {
break
}
line = strings.TrimSpace(line)
if matches := reFilename.FindStringSubmatch(line); matches != nil {
if currentChange != nil {
changes = append(changes, *currentChange)
}
currentChange = &HunkChange{
FromPath: matches[1],
ToPath: matches[2],
Added: []*ChangedRegion{},
Deleted: []*ChangedRegion{},
}
continue
}
if matches := reHunk.FindStringSubmatch(line); matches != nil && currentChange != nil {
origLineStart := diffToInt(matches[1])
origCount := diffToInt(matches[2])
newLineStart := diffToInt(matches[3])
newCount := diffToInt(matches[4])
if origCount != 0 {
currentChange.Deleted = append(
currentChange.Deleted,
&ChangedRegion{FirstLine: origLineStart, Count: origCount},
)
}
if newCount != 0 {
currentChange.Added = append(
currentChange.Added,
&ChangedRegion{FirstLine: newLineStart, Count: newCount},
)
}
}
}
if currentChange != nil {
changes = append(changes, *currentChange)
}
if err != nil && err != io.EOF {
return ChangedFiles{}, err
}
files := make([]*ChangedFile, 0, len(changes))
for _, file := range changes {
fileName := file.ToPath
if file.ToPath != file.FromPath {
if len(file.Deleted) > 0 {
fileName = file.FromPath
} else {
fileName = file.ToPath
}
}
path := filepath.Join(repoRoot, fileName)
if strings.HasPrefix(path, cwd) { // take changes only inside project
files = append(
files, &ChangedFile{
Path: path,
Added: file.Added,
Deleted: file.Deleted,
},
)
}
}
sort.Slice(
files, func(i, j int) bool {
return files[i].Path < files[j].Path
},
)
return ChangedFiles{Files: files}, nil
}
// diffToInt converts a string to an integer preserving git default number logic
func diffToInt(str string) int {
if str == "" {
return 1
}
var result int
_, _ = fmt.Sscanf(str, "%d", &result)
return result
}