| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504 |
- package git
- /*
- diff.go
- Text diffing for the GitApp diff viewer.
- go-git can produce patches between two commits but has no API for "working
- tree versus HEAD", which is exactly the diff the Changes tab shows. The line
- differ below is therefore implemented here: it trims the common prefix and
- suffix, runs an LCS over what is left and groups the result into hunks with
- three lines of context, matching unified-diff conventions.
- */
- import (
- "errors"
- "io"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- gogit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- )
- const (
- //maxDiffBytes is the largest file the differ will read on either side.
- maxDiffBytes = 2 * 1024 * 1024
- //maxLCSRegion bounds the quadratic part of the algorithm. Regions larger
- //than this are emitted as a plain delete-then-insert block, which keeps a
- //huge rewrite from pinning memory.
- maxLCSRegion = 2000
- //diffContextLines is the number of unchanged lines kept around each change.
- diffContextLines = 3
- )
- // Diff returns the diff of one repo-relative path between HEAD and the current
- // working tree — the change the user is about to commit.
- func (m *Manager) Diff(realpath string, file string) (*FileDiff, error) {
- cleaned, err := cleanRepoPath(file)
- if err != nil {
- return nil, err
- }
- repo, tree, err := m.worktree(realpath)
- if err != nil {
- return nil, err
- }
- //Old side: the blob recorded in HEAD, absent for a newly added file
- oldContent, oldExists, err := headBlobContent(repo, cleaned)
- if err != nil {
- return nil, err
- }
- //New side: what is on disk right now, absent for a deleted file
- newContent, newExists, err := worktreeContent(tree.Filesystem.Root(), cleaned)
- if err != nil {
- return nil, err
- }
- if !oldExists && !newExists {
- return nil, errors.New("file not found in HEAD or working tree: " + cleaned)
- }
- return buildFileDiff(cleaned, oldContent, newContent, oldExists, newExists), nil
- }
- // DiffCommit returns the diff of one path introduced by a commit, comparing it
- // against the commit's first parent. Used by the History tab.
- func (m *Manager) DiffCommit(realpath string, hash string, file string) (*FileDiff, error) {
- cleaned, err := cleanRepoPath(file)
- if err != nil {
- return nil, err
- }
- repo, err := m.open(realpath)
- if err != nil {
- return nil, err
- }
- commit, err := repo.CommitObject(plumbing.NewHash(hash))
- if err != nil {
- return nil, err
- }
- newContent, newExists := treeFileContent(commit, cleaned)
- oldContent, oldExists := []byte{}, false
- if parent, perr := commit.Parent(0); perr == nil {
- oldContent, oldExists = treeFileContent(parent, cleaned)
- }
- if !oldExists && !newExists {
- return nil, errors.New("file not found in commit: " + cleaned)
- }
- return buildFileDiff(cleaned, oldContent, newContent, oldExists, newExists), nil
- }
- // CommitFiles lists the paths a commit touched, for the History tab file list.
- func (m *Manager) CommitFiles(realpath string, hash string) ([]FileChange, error) {
- repo, err := m.open(realpath)
- if err != nil {
- return nil, err
- }
- commit, err := repo.CommitObject(plumbing.NewHash(hash))
- if err != nil {
- return nil, err
- }
- commitTree, err := commit.Tree()
- if err != nil {
- return nil, err
- }
- var parentTree *object.Tree
- if parent, perr := commit.Parent(0); perr == nil {
- parentTree, _ = parent.Tree()
- }
- changes, err := object.DiffTree(parentTree, commitTree)
- if err != nil {
- return nil, err
- }
- results := []FileChange{}
- for _, change := range changes {
- action, aerr := change.Action()
- if aerr != nil {
- continue
- }
- path := change.To.Name
- status := "modified"
- switch action.String() {
- case "Insert":
- status = "added"
- case "Delete":
- status = "deleted"
- path = change.From.Name
- }
- results = append(results, FileChange{
- Path: filepath.ToSlash(path),
- Status: status,
- Staging: status,
- Worktree: "unmodified",
- Staged: true,
- Size: -1,
- Preview: PreviewKind(path),
- })
- }
- return results, nil
- }
- // headBlobContent reads a path out of the HEAD commit tree.
- func headBlobContent(repo *gogit.Repository, file string) ([]byte, bool, error) {
- head, err := repo.Head()
- if err != nil {
- if errors.Is(err, plumbing.ErrReferenceNotFound) {
- //Unborn branch: everything counts as newly added
- return []byte{}, false, nil
- }
- return nil, false, err
- }
- commit, err := repo.CommitObject(head.Hash())
- if err != nil {
- return nil, false, err
- }
- content, exists := treeFileContent(commit, file)
- return content, exists, nil
- }
- // treeFileContent reads a path from a commit's tree, reporting whether it was
- // present at all.
- func treeFileContent(commit *object.Commit, file string) ([]byte, bool) {
- if commit == nil {
- return []byte{}, false
- }
- treeFile, err := commit.File(file)
- if err != nil {
- return []byte{}, false
- }
- if treeFile.Size > maxDiffBytes {
- return nil, true
- }
- reader, err := treeFile.Reader()
- if err != nil {
- return []byte{}, false
- }
- defer reader.Close()
- content, err := io.ReadAll(reader)
- if err != nil {
- return []byte{}, false
- }
- return content, true
- }
- // worktreeContent reads a path from the working tree.
- func worktreeContent(repoRoot string, file string) ([]byte, bool, error) {
- fullPath := filepath.Join(repoRoot, filepath.FromSlash(file))
- info, err := os.Stat(fullPath)
- if err != nil {
- if os.IsNotExist(err) {
- return []byte{}, false, nil
- }
- return nil, false, err
- }
- if info.IsDir() {
- return []byte{}, false, nil
- }
- if info.Size() > maxDiffBytes {
- //Present but deliberately not read; buildFileDiff flags it as too large
- return nil, true, nil
- }
- content, err := os.ReadFile(fullPath)
- if err != nil {
- return nil, false, err
- }
- return content, true, nil
- }
- // buildFileDiff turns two file contents into the rendered diff structure.
- func buildFileDiff(path string, oldContent []byte, newContent []byte, oldExists bool, newExists bool) *FileDiff {
- diff := &FileDiff{
- Path: path,
- IsNew: !oldExists,
- IsDeleted: !newExists,
- Hunks: []DiffHunk{},
- }
- //A nil content with the side marked present means the file exceeded the
- //read limit
- if (oldContent == nil && oldExists) || (newContent == nil && newExists) {
- diff.TooLarge = true
- return diff
- }
- if isBinaryContent(oldContent) || isBinaryContent(newContent) {
- diff.Binary = true
- return diff
- }
- oldLines := splitLines(string(oldContent))
- newLines := splitLines(string(newContent))
- operations := diffLines(oldLines, newLines)
- diff.Hunks = buildHunks(operations)
- for _, hunk := range diff.Hunks {
- for _, line := range hunk.Lines {
- switch line.Type {
- case "add":
- diff.Additions++
- case "del":
- diff.Deletions++
- }
- }
- }
- return diff
- }
- // diffOp is one line-level edit produced by the differ.
- type diffOp struct {
- kind string //"context", "add" or "del"
- text string
- }
- // diffLines produces the edit script turning oldLines into newLines.
- func diffLines(oldLines []string, newLines []string) []diffOp {
- operations := []diffOp{}
- //Trim the common prefix — most edits touch a small part of a file, and this
- //keeps the quadratic section tiny.
- prefix := 0
- for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] {
- operations = append(operations, diffOp{kind: "context", text: oldLines[prefix]})
- prefix++
- }
- //Trim the common suffix
- suffix := 0
- for suffix < len(oldLines)-prefix &&
- suffix < len(newLines)-prefix &&
- oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] {
- suffix++
- }
- oldMiddle := oldLines[prefix : len(oldLines)-suffix]
- newMiddle := newLines[prefix : len(newLines)-suffix]
- operations = append(operations, diffMiddle(oldMiddle, newMiddle)...)
- for i := len(oldLines) - suffix; i < len(oldLines); i++ {
- operations = append(operations, diffOp{kind: "context", text: oldLines[i]})
- }
- return operations
- }
- // diffMiddle runs the LCS over the changed region, falling back to a plain
- // replace when the region is too large to diff cheaply.
- func diffMiddle(oldLines []string, newLines []string) []diffOp {
- operations := []diffOp{}
- if len(oldLines) == 0 && len(newLines) == 0 {
- return operations
- }
- if len(oldLines) > maxLCSRegion || len(newLines) > maxLCSRegion {
- for _, line := range oldLines {
- operations = append(operations, diffOp{kind: "del", text: line})
- }
- for _, line := range newLines {
- operations = append(operations, diffOp{kind: "add", text: line})
- }
- return operations
- }
- oldCount, newCount := len(oldLines), len(newLines)
- //lcs[i][j] is the length of the longest common subsequence of
- //oldLines[i:] and newLines[j:]
- lcs := make([][]int32, oldCount+1)
- for i := range lcs {
- lcs[i] = make([]int32, newCount+1)
- }
- for i := oldCount - 1; i >= 0; i-- {
- for j := newCount - 1; j >= 0; j-- {
- if oldLines[i] == newLines[j] {
- lcs[i][j] = lcs[i+1][j+1] + 1
- } else if lcs[i+1][j] >= lcs[i][j+1] {
- lcs[i][j] = lcs[i+1][j]
- } else {
- lcs[i][j] = lcs[i][j+1]
- }
- }
- }
- i, j := 0, 0
- for i < oldCount && j < newCount {
- switch {
- case oldLines[i] == newLines[j]:
- operations = append(operations, diffOp{kind: "context", text: oldLines[i]})
- i++
- j++
- case lcs[i+1][j] >= lcs[i][j+1]:
- operations = append(operations, diffOp{kind: "del", text: oldLines[i]})
- i++
- default:
- operations = append(operations, diffOp{kind: "add", text: newLines[j]})
- j++
- }
- }
- for ; i < oldCount; i++ {
- operations = append(operations, diffOp{kind: "del", text: oldLines[i]})
- }
- for ; j < newCount; j++ {
- operations = append(operations, diffOp{kind: "add", text: newLines[j]})
- }
- return operations
- }
- // buildHunks groups the edit script into unified-diff hunks, keeping
- // diffContextLines unchanged lines on either side of every change.
- func buildHunks(operations []diffOp) []DiffHunk {
- hunks := []DiffHunk{}
- if len(operations) == 0 {
- return hunks
- }
- //Mark which operations must appear: every change plus its context window
- keep := make([]bool, len(operations))
- hasChange := false
- for index, operation := range operations {
- if operation.kind == "context" {
- continue
- }
- hasChange = true
- start := index - diffContextLines
- if start < 0 {
- start = 0
- }
- end := index + diffContextLines
- if end > len(operations)-1 {
- end = len(operations) - 1
- }
- for k := start; k <= end; k++ {
- keep[k] = true
- }
- }
- if !hasChange {
- return hunks
- }
- //Walk the script assigning line numbers, cutting a new hunk whenever the
- //kept region breaks
- oldLineNumber, newLineNumber := 1, 1
- var current *DiffHunk
- flush := func() {
- if current != nil && len(current.Lines) > 0 {
- current.Header = "@@ -" + strconv.Itoa(current.OldStart) + "," + strconv.Itoa(current.OldLines) +
- " +" + strconv.Itoa(current.NewStart) + "," + strconv.Itoa(current.NewLines) + " @@"
- hunks = append(hunks, *current)
- }
- current = nil
- }
- for index, operation := range operations {
- if !keep[index] {
- flush()
- switch operation.kind {
- case "context":
- oldLineNumber++
- newLineNumber++
- case "del":
- oldLineNumber++
- case "add":
- newLineNumber++
- }
- continue
- }
- if current == nil {
- current = &DiffHunk{
- OldStart: oldLineNumber,
- NewStart: newLineNumber,
- Lines: []DiffLine{},
- }
- }
- line := DiffLine{Type: operation.kind, Content: operation.text}
- switch operation.kind {
- case "context":
- line.OldLine = oldLineNumber
- line.NewLine = newLineNumber
- oldLineNumber++
- newLineNumber++
- current.OldLines++
- current.NewLines++
- case "del":
- line.OldLine = oldLineNumber
- oldLineNumber++
- current.OldLines++
- case "add":
- line.NewLine = newLineNumber
- newLineNumber++
- current.NewLines++
- }
- current.Lines = append(current.Lines, line)
- }
- flush()
- return hunks
- }
- // splitLines splits content into lines, dropping the trailing empty element a
- // final newline produces and normalising CRLF so a Windows checkout of a Unix
- // file does not report every line as changed.
- func splitLines(content string) []string {
- if content == "" {
- return []string{}
- }
- content = strings.ReplaceAll(content, "\r\n", "\n")
- lines := strings.Split(content, "\n")
- if len(lines) > 0 && lines[len(lines)-1] == "" {
- lines = lines[:len(lines)-1]
- }
- return lines
- }
- // isBinaryContent applies git's own NUL-byte heuristic to in-memory content.
- func isBinaryContent(content []byte) bool {
- limit := len(content)
- if limit > 8000 {
- limit = 8000
- }
- for i := 0; i < limit; i++ {
- if content[i] == 0 {
- return true
- }
- }
- return false
- }
|