diff.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. package git
  2. /*
  3. diff.go
  4. Text diffing for the GitApp diff viewer.
  5. go-git can produce patches between two commits but has no API for "working
  6. tree versus HEAD", which is exactly the diff the Changes tab shows. The line
  7. differ below is therefore implemented here: it trims the common prefix and
  8. suffix, runs an LCS over what is left and groups the result into hunks with
  9. three lines of context, matching unified-diff conventions.
  10. */
  11. import (
  12. "errors"
  13. "io"
  14. "os"
  15. "path/filepath"
  16. "strconv"
  17. "strings"
  18. gogit "github.com/go-git/go-git/v5"
  19. "github.com/go-git/go-git/v5/plumbing"
  20. "github.com/go-git/go-git/v5/plumbing/object"
  21. )
  22. const (
  23. //maxDiffBytes is the largest file the differ will read on either side.
  24. maxDiffBytes = 2 * 1024 * 1024
  25. //maxLCSRegion bounds the quadratic part of the algorithm. Regions larger
  26. //than this are emitted as a plain delete-then-insert block, which keeps a
  27. //huge rewrite from pinning memory.
  28. maxLCSRegion = 2000
  29. //diffContextLines is the number of unchanged lines kept around each change.
  30. diffContextLines = 3
  31. )
  32. // Diff returns the diff of one repo-relative path between HEAD and the current
  33. // working tree — the change the user is about to commit.
  34. func (m *Manager) Diff(realpath string, file string) (*FileDiff, error) {
  35. cleaned, err := cleanRepoPath(file)
  36. if err != nil {
  37. return nil, err
  38. }
  39. repo, tree, err := m.worktree(realpath)
  40. if err != nil {
  41. return nil, err
  42. }
  43. //Old side: the blob recorded in HEAD, absent for a newly added file
  44. oldContent, oldExists, err := headBlobContent(repo, cleaned)
  45. if err != nil {
  46. return nil, err
  47. }
  48. //New side: what is on disk right now, absent for a deleted file
  49. newContent, newExists, err := worktreeContent(tree.Filesystem.Root(), cleaned)
  50. if err != nil {
  51. return nil, err
  52. }
  53. if !oldExists && !newExists {
  54. return nil, errors.New("file not found in HEAD or working tree: " + cleaned)
  55. }
  56. return buildFileDiff(cleaned, oldContent, newContent, oldExists, newExists), nil
  57. }
  58. // DiffCommit returns the diff of one path introduced by a commit, comparing it
  59. // against the commit's first parent. Used by the History tab.
  60. func (m *Manager) DiffCommit(realpath string, hash string, file string) (*FileDiff, error) {
  61. cleaned, err := cleanRepoPath(file)
  62. if err != nil {
  63. return nil, err
  64. }
  65. repo, err := m.open(realpath)
  66. if err != nil {
  67. return nil, err
  68. }
  69. commit, err := repo.CommitObject(plumbing.NewHash(hash))
  70. if err != nil {
  71. return nil, err
  72. }
  73. newContent, newExists := treeFileContent(commit, cleaned)
  74. oldContent, oldExists := []byte{}, false
  75. if parent, perr := commit.Parent(0); perr == nil {
  76. oldContent, oldExists = treeFileContent(parent, cleaned)
  77. }
  78. if !oldExists && !newExists {
  79. return nil, errors.New("file not found in commit: " + cleaned)
  80. }
  81. return buildFileDiff(cleaned, oldContent, newContent, oldExists, newExists), nil
  82. }
  83. // CommitFiles lists the paths a commit touched, for the History tab file list.
  84. func (m *Manager) CommitFiles(realpath string, hash string) ([]FileChange, error) {
  85. repo, err := m.open(realpath)
  86. if err != nil {
  87. return nil, err
  88. }
  89. commit, err := repo.CommitObject(plumbing.NewHash(hash))
  90. if err != nil {
  91. return nil, err
  92. }
  93. commitTree, err := commit.Tree()
  94. if err != nil {
  95. return nil, err
  96. }
  97. var parentTree *object.Tree
  98. if parent, perr := commit.Parent(0); perr == nil {
  99. parentTree, _ = parent.Tree()
  100. }
  101. changes, err := object.DiffTree(parentTree, commitTree)
  102. if err != nil {
  103. return nil, err
  104. }
  105. results := []FileChange{}
  106. for _, change := range changes {
  107. action, aerr := change.Action()
  108. if aerr != nil {
  109. continue
  110. }
  111. path := change.To.Name
  112. status := "modified"
  113. switch action.String() {
  114. case "Insert":
  115. status = "added"
  116. case "Delete":
  117. status = "deleted"
  118. path = change.From.Name
  119. }
  120. results = append(results, FileChange{
  121. Path: filepath.ToSlash(path),
  122. Status: status,
  123. Staging: status,
  124. Worktree: "unmodified",
  125. Staged: true,
  126. Size: -1,
  127. Preview: PreviewKind(path),
  128. })
  129. }
  130. return results, nil
  131. }
  132. // headBlobContent reads a path out of the HEAD commit tree.
  133. func headBlobContent(repo *gogit.Repository, file string) ([]byte, bool, error) {
  134. head, err := repo.Head()
  135. if err != nil {
  136. if errors.Is(err, plumbing.ErrReferenceNotFound) {
  137. //Unborn branch: everything counts as newly added
  138. return []byte{}, false, nil
  139. }
  140. return nil, false, err
  141. }
  142. commit, err := repo.CommitObject(head.Hash())
  143. if err != nil {
  144. return nil, false, err
  145. }
  146. content, exists := treeFileContent(commit, file)
  147. return content, exists, nil
  148. }
  149. // treeFileContent reads a path from a commit's tree, reporting whether it was
  150. // present at all.
  151. func treeFileContent(commit *object.Commit, file string) ([]byte, bool) {
  152. if commit == nil {
  153. return []byte{}, false
  154. }
  155. treeFile, err := commit.File(file)
  156. if err != nil {
  157. return []byte{}, false
  158. }
  159. if treeFile.Size > maxDiffBytes {
  160. return nil, true
  161. }
  162. reader, err := treeFile.Reader()
  163. if err != nil {
  164. return []byte{}, false
  165. }
  166. defer reader.Close()
  167. content, err := io.ReadAll(reader)
  168. if err != nil {
  169. return []byte{}, false
  170. }
  171. return content, true
  172. }
  173. // worktreeContent reads a path from the working tree.
  174. func worktreeContent(repoRoot string, file string) ([]byte, bool, error) {
  175. fullPath := filepath.Join(repoRoot, filepath.FromSlash(file))
  176. info, err := os.Stat(fullPath)
  177. if err != nil {
  178. if os.IsNotExist(err) {
  179. return []byte{}, false, nil
  180. }
  181. return nil, false, err
  182. }
  183. if info.IsDir() {
  184. return []byte{}, false, nil
  185. }
  186. if info.Size() > maxDiffBytes {
  187. //Present but deliberately not read; buildFileDiff flags it as too large
  188. return nil, true, nil
  189. }
  190. content, err := os.ReadFile(fullPath)
  191. if err != nil {
  192. return nil, false, err
  193. }
  194. return content, true, nil
  195. }
  196. // buildFileDiff turns two file contents into the rendered diff structure.
  197. func buildFileDiff(path string, oldContent []byte, newContent []byte, oldExists bool, newExists bool) *FileDiff {
  198. diff := &FileDiff{
  199. Path: path,
  200. IsNew: !oldExists,
  201. IsDeleted: !newExists,
  202. Hunks: []DiffHunk{},
  203. }
  204. //A nil content with the side marked present means the file exceeded the
  205. //read limit
  206. if (oldContent == nil && oldExists) || (newContent == nil && newExists) {
  207. diff.TooLarge = true
  208. return diff
  209. }
  210. if isBinaryContent(oldContent) || isBinaryContent(newContent) {
  211. diff.Binary = true
  212. return diff
  213. }
  214. oldLines := splitLines(string(oldContent))
  215. newLines := splitLines(string(newContent))
  216. operations := diffLines(oldLines, newLines)
  217. diff.Hunks = buildHunks(operations)
  218. for _, hunk := range diff.Hunks {
  219. for _, line := range hunk.Lines {
  220. switch line.Type {
  221. case "add":
  222. diff.Additions++
  223. case "del":
  224. diff.Deletions++
  225. }
  226. }
  227. }
  228. return diff
  229. }
  230. // diffOp is one line-level edit produced by the differ.
  231. type diffOp struct {
  232. kind string //"context", "add" or "del"
  233. text string
  234. }
  235. // diffLines produces the edit script turning oldLines into newLines.
  236. func diffLines(oldLines []string, newLines []string) []diffOp {
  237. operations := []diffOp{}
  238. //Trim the common prefix — most edits touch a small part of a file, and this
  239. //keeps the quadratic section tiny.
  240. prefix := 0
  241. for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] {
  242. operations = append(operations, diffOp{kind: "context", text: oldLines[prefix]})
  243. prefix++
  244. }
  245. //Trim the common suffix
  246. suffix := 0
  247. for suffix < len(oldLines)-prefix &&
  248. suffix < len(newLines)-prefix &&
  249. oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] {
  250. suffix++
  251. }
  252. oldMiddle := oldLines[prefix : len(oldLines)-suffix]
  253. newMiddle := newLines[prefix : len(newLines)-suffix]
  254. operations = append(operations, diffMiddle(oldMiddle, newMiddle)...)
  255. for i := len(oldLines) - suffix; i < len(oldLines); i++ {
  256. operations = append(operations, diffOp{kind: "context", text: oldLines[i]})
  257. }
  258. return operations
  259. }
  260. // diffMiddle runs the LCS over the changed region, falling back to a plain
  261. // replace when the region is too large to diff cheaply.
  262. func diffMiddle(oldLines []string, newLines []string) []diffOp {
  263. operations := []diffOp{}
  264. if len(oldLines) == 0 && len(newLines) == 0 {
  265. return operations
  266. }
  267. if len(oldLines) > maxLCSRegion || len(newLines) > maxLCSRegion {
  268. for _, line := range oldLines {
  269. operations = append(operations, diffOp{kind: "del", text: line})
  270. }
  271. for _, line := range newLines {
  272. operations = append(operations, diffOp{kind: "add", text: line})
  273. }
  274. return operations
  275. }
  276. oldCount, newCount := len(oldLines), len(newLines)
  277. //lcs[i][j] is the length of the longest common subsequence of
  278. //oldLines[i:] and newLines[j:]
  279. lcs := make([][]int32, oldCount+1)
  280. for i := range lcs {
  281. lcs[i] = make([]int32, newCount+1)
  282. }
  283. for i := oldCount - 1; i >= 0; i-- {
  284. for j := newCount - 1; j >= 0; j-- {
  285. if oldLines[i] == newLines[j] {
  286. lcs[i][j] = lcs[i+1][j+1] + 1
  287. } else if lcs[i+1][j] >= lcs[i][j+1] {
  288. lcs[i][j] = lcs[i+1][j]
  289. } else {
  290. lcs[i][j] = lcs[i][j+1]
  291. }
  292. }
  293. }
  294. i, j := 0, 0
  295. for i < oldCount && j < newCount {
  296. switch {
  297. case oldLines[i] == newLines[j]:
  298. operations = append(operations, diffOp{kind: "context", text: oldLines[i]})
  299. i++
  300. j++
  301. case lcs[i+1][j] >= lcs[i][j+1]:
  302. operations = append(operations, diffOp{kind: "del", text: oldLines[i]})
  303. i++
  304. default:
  305. operations = append(operations, diffOp{kind: "add", text: newLines[j]})
  306. j++
  307. }
  308. }
  309. for ; i < oldCount; i++ {
  310. operations = append(operations, diffOp{kind: "del", text: oldLines[i]})
  311. }
  312. for ; j < newCount; j++ {
  313. operations = append(operations, diffOp{kind: "add", text: newLines[j]})
  314. }
  315. return operations
  316. }
  317. // buildHunks groups the edit script into unified-diff hunks, keeping
  318. // diffContextLines unchanged lines on either side of every change.
  319. func buildHunks(operations []diffOp) []DiffHunk {
  320. hunks := []DiffHunk{}
  321. if len(operations) == 0 {
  322. return hunks
  323. }
  324. //Mark which operations must appear: every change plus its context window
  325. keep := make([]bool, len(operations))
  326. hasChange := false
  327. for index, operation := range operations {
  328. if operation.kind == "context" {
  329. continue
  330. }
  331. hasChange = true
  332. start := index - diffContextLines
  333. if start < 0 {
  334. start = 0
  335. }
  336. end := index + diffContextLines
  337. if end > len(operations)-1 {
  338. end = len(operations) - 1
  339. }
  340. for k := start; k <= end; k++ {
  341. keep[k] = true
  342. }
  343. }
  344. if !hasChange {
  345. return hunks
  346. }
  347. //Walk the script assigning line numbers, cutting a new hunk whenever the
  348. //kept region breaks
  349. oldLineNumber, newLineNumber := 1, 1
  350. var current *DiffHunk
  351. flush := func() {
  352. if current != nil && len(current.Lines) > 0 {
  353. current.Header = "@@ -" + strconv.Itoa(current.OldStart) + "," + strconv.Itoa(current.OldLines) +
  354. " +" + strconv.Itoa(current.NewStart) + "," + strconv.Itoa(current.NewLines) + " @@"
  355. hunks = append(hunks, *current)
  356. }
  357. current = nil
  358. }
  359. for index, operation := range operations {
  360. if !keep[index] {
  361. flush()
  362. switch operation.kind {
  363. case "context":
  364. oldLineNumber++
  365. newLineNumber++
  366. case "del":
  367. oldLineNumber++
  368. case "add":
  369. newLineNumber++
  370. }
  371. continue
  372. }
  373. if current == nil {
  374. current = &DiffHunk{
  375. OldStart: oldLineNumber,
  376. NewStart: newLineNumber,
  377. Lines: []DiffLine{},
  378. }
  379. }
  380. line := DiffLine{Type: operation.kind, Content: operation.text}
  381. switch operation.kind {
  382. case "context":
  383. line.OldLine = oldLineNumber
  384. line.NewLine = newLineNumber
  385. oldLineNumber++
  386. newLineNumber++
  387. current.OldLines++
  388. current.NewLines++
  389. case "del":
  390. line.OldLine = oldLineNumber
  391. oldLineNumber++
  392. current.OldLines++
  393. case "add":
  394. line.NewLine = newLineNumber
  395. newLineNumber++
  396. current.NewLines++
  397. }
  398. current.Lines = append(current.Lines, line)
  399. }
  400. flush()
  401. return hunks
  402. }
  403. // splitLines splits content into lines, dropping the trailing empty element a
  404. // final newline produces and normalising CRLF so a Windows checkout of a Unix
  405. // file does not report every line as changed.
  406. func splitLines(content string) []string {
  407. if content == "" {
  408. return []string{}
  409. }
  410. content = strings.ReplaceAll(content, "\r\n", "\n")
  411. lines := strings.Split(content, "\n")
  412. if len(lines) > 0 && lines[len(lines)-1] == "" {
  413. lines = lines[:len(lines)-1]
  414. }
  415. return lines
  416. }
  417. // isBinaryContent applies git's own NUL-byte heuristic to in-memory content.
  418. func isBinaryContent(content []byte) bool {
  419. limit := len(content)
  420. if limit > 8000 {
  421. limit = 8000
  422. }
  423. for i := 0; i < limit; i++ {
  424. if content[i] == 0 {
  425. return true
  426. }
  427. }
  428. return false
  429. }