repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

commit
966a19a
parent
4f3e0b2
author
Eric Bower
date
2024-07-23 11:59:25 -0400 EDT
feat: range diff
20 files changed,  +1047, -72
M cli.go
M db.go
M go.mod
M go.sum
M pr.go
M web.go
M Makefile
+4, -0
 1@@ -12,6 +12,10 @@ lint:
 2 	golangci-lint run -E goimports -E godot --timeout 10m
 3 .PHONY: lint
 4 
 5+test:
 6+	go test ./...
 7+.PHONY: test
 8+
 9 build:
10 	go build -o ./build/ssh ./cmd/ssh
11 	go build -o ./build/web ./cmd/web
M cli.go
+2, -2
 1@@ -485,13 +485,13 @@ Here's how it works:
 2 								prev = patchsets[len(patchsets)-2]
 3 							}
 4 
 5-							patches, err := pr.DiffPatchsets(prev, latest)
 6+							rangeDiff, err := pr.DiffPatchsets(prev, latest)
 7 							if err != nil {
 8 								be.Logger.Error("could not diff patchset", "err", err)
 9 								return err
10 							}
11 
12-							printPatches(sesh, patches)
13+							wish.Println(sesh, rangeDiff)
14 							return nil
15 						},
16 					},
M db.go
+6, -0
 1@@ -6,6 +6,7 @@ import (
 2 	"log/slog"
 3 	"time"
 4 
 5+	"github.com/bluekeyes/go-gitdiff/gitdiff"
 6 	"github.com/jmoiron/sqlx"
 7 	_ "modernc.org/sqlite"
 8 )
 9@@ -67,6 +68,11 @@ type Patch struct {
10 	BaseCommitSha sql.NullString `db:"base_commit_sha"`
11 	RawText       string         `db:"raw_text"`
12 	CreatedAt     time.Time      `db:"created_at"`
13+	Files         []*gitdiff.File
14+}
15+
16+func (p *Patch) CalcDiff() string {
17+	return p.RawText
18 }
19 
20 // EventLog is a event log for RSS or other notification systems.
A fixtures/a_b.patch
+31, -0
 1@@ -0,0 +1,31 @@
 2+From 33c682ac27479f501924cf159d0a75ad91deb589 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:07:57 -0400
 5+Subject: [PATCH] chore: add torch and create random tensor
 6+
 7+---
 8+ requirements.txt | 1 +
 9+ train.py         | 3 +++
10+ 2 files changed, 4 insertions(+)
11+ create mode 100644 requirements.txt
12+
13+diff --git a/requirements.txt b/requirements.txt
14+new file mode 100644
15+index 0000000..4968a39
16+--- /dev/null
17++++ b/requirements.txt
18+@@ -0,0 +1 @@
19++torch==2.3.1
20+diff --git a/train.py b/train.py
21+index 5c027f4..d21dac3 100644
22+--- a/train.py
23++++ b/train.py
24+@@ -1,2 +1,5 @@
25++import torch
26++
27+ if __name__ == "__main__":
28+     print("train!")
29++    torch.rand(3,6)
30+-- 
31+2.45.2
32+
A fixtures/a_b_reorder.patch
+55, -0
 1@@ -0,0 +1,55 @@
 2+From 33c682ac27479f501924cf159d0a75ad91deb589 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:07:57 -0400
 5+Subject: [PATCH 1/2] chore: add torch and create random tensor
 6+
 7+---
 8+ requirements.txt | 1 +
 9+ train.py         | 3 +++
10+ 2 files changed, 4 insertions(+)
11+ create mode 100644 requirements.txt
12+
13+diff --git a/requirements.txt b/requirements.txt
14+new file mode 100644
15+index 0000000..4968a39
16+--- /dev/null
17++++ b/requirements.txt
18+@@ -0,0 +1 @@
19++torch==2.3.1
20+diff --git a/train.py b/train.py
21+index 5c027f4..d21dac3 100644
22+--- a/train.py
23++++ b/train.py
24+@@ -1,2 +1,5 @@
25++import torch
26++
27+ if __name__ == "__main__":
28+     print("train!")
29++    torch.rand(3,6)
30+-- 
31+2.45.2
32+
33+
34+From 22dde1259c34a166d5a9335ebe5236e79541cc63 Mon Sep 17 00:00:00 2001
35+From: Eric Bower <me@erock.io>
36+Date: Tue, 23 Jul 2024 10:14:37 -0400
37+Subject: [PATCH 2/2] docs: readme
38+
39+---
40+ README.md | 4 +++-
41+ 1 file changed, 3 insertions(+), 1 deletion(-)
42+
43+diff --git a/README.md b/README.md
44+index 8f3a780..3043953 100644
45+--- a/README.md
46++++ b/README.md
47+@@ -1,3 +1,5 @@
48+ # Let's build an RNN
49+ 
50+-This repo demonstrates building an RNN using `pytorch`
51++This repo demonstrates building an RNN using `pytorch`.
52++
53++Here is some more readme information.
54+-- 
55+2.45.2
56+
A fixtures/a_c.patch
+33, -0
 1@@ -0,0 +1,33 @@
 2+From 166848469e0b954c2e14233233f3824a46dcddb8 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:06:00 -0400
 5+Subject: [PATCH] chore: add torch and create random tensor
 6+
 7+---
 8+ requirements.txt | 1 +
 9+ train.py         | 3 +++
10+ 2 files changed, 4 insertions(+)
11+ create mode 100644 requirements.txt
12+
13+diff --git a/requirements.txt b/requirements.txt
14+new file mode 100644
15+index 0000000..4968a39
16+--- /dev/null
17++++ b/requirements.txt
18+@@ -0,0 +1 @@
19++torch==2.3.1
20+diff --git a/train.py b/train.py
21+index 5c027f4..d21dac3 100644
22+--- a/train.py
23++++ b/train.py
24+@@ -1,2 +1,5 @@
25++import torch
26++
27+ if __name__ == "__main__":
28+     print("train!")
29++    torch.rand(3,6)
30+
31+base-commit: 59456574a0bfee9f71c91c13046173c820152346
32+-- 
33+2.45.2
34+
A fixtures/a_c_added_commit.patch
+80, -0
 1@@ -0,0 +1,80 @@
 2+From 33c682ac27479f501924cf159d0a75ad91deb589 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:07:57 -0400
 5+Subject: [PATCH 1/3] chore: add torch and create random tensor
 6+
 7+---
 8+ requirements.txt | 1 +
 9+ train.py         | 3 +++
10+ 2 files changed, 4 insertions(+)
11+ create mode 100644 requirements.txt
12+
13+diff --git a/requirements.txt b/requirements.txt
14+new file mode 100644
15+index 0000000..4968a39
16+--- /dev/null
17++++ b/requirements.txt
18+@@ -0,0 +1 @@
19++torch==2.3.1
20+diff --git a/train.py b/train.py
21+index 5c027f4..d21dac3 100644
22+--- a/train.py
23++++ b/train.py
24+@@ -1,2 +1,5 @@
25++import torch
26++
27+ if __name__ == "__main__":
28+     print("train!")
29++    torch.rand(3,6)
30+-- 
31+2.45.2
32+
33+
34+From 22dde1259c34a166d5a9335ebe5236e79541cc63 Mon Sep 17 00:00:00 2001
35+From: Eric Bower <me@erock.io>
36+Date: Tue, 23 Jul 2024 10:14:37 -0400
37+Subject: [PATCH 2/3] docs: readme
38+
39+---
40+ README.md | 4 +++-
41+ 1 file changed, 3 insertions(+), 1 deletion(-)
42+
43+diff --git a/README.md b/README.md
44+index 8f3a780..3043953 100644
45+--- a/README.md
46++++ b/README.md
47+@@ -1,3 +1,5 @@
48+ # Let's build an RNN
49+ 
50+-This repo demonstrates building an RNN using `pytorch`
51++This repo demonstrates building an RNN using `pytorch`.
52++
53++Here is some more readme information.
54+-- 
55+2.45.2
56+
57+
58+From b248060488df529b850060b3c86417bb87d490cc Mon Sep 17 00:00:00 2001
59+From: Eric Bower <me@erock.io>
60+Date: Tue, 23 Jul 2024 10:20:44 -0400
61+Subject: [PATCH 3/3] chore: make tensor 6x6
62+
63+---
64+ train.py | 4 +++-
65+ 1 file changed, 3 insertions(+), 1 deletion(-)
66+
67+diff --git a/train.py b/train.py
68+index d21dac3..8cd47e0 100644
69+--- a/train.py
70++++ b/train.py
71+@@ -2,4 +2,6 @@ import torch
72+ 
73+ if __name__ == "__main__":
74+     print("train!")
75+-    torch.rand(3,6)
76++    # let's create a 6x6 tensor!
77++    tensor = torch.rand(6,6)
78++    print(tensor)
79+-- 
80+2.45.2
81+
A fixtures/a_c_changed_commit.patch
+60, -0
 1@@ -0,0 +1,60 @@
 2+From 33c682ac27479f501924cf159d0a75ad91deb589 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:07:57 -0400
 5+Subject: [PATCH 1/2] chore: add torch and create random tensor
 6+
 7+---
 8+ requirements.txt | 1 +
 9+ train.py         | 3 +++
10+ 2 files changed, 4 insertions(+)
11+ create mode 100644 requirements.txt
12+
13+diff --git a/requirements.txt b/requirements.txt
14+new file mode 100644
15+index 0000000..4968a39
16+--- /dev/null
17++++ b/requirements.txt
18+@@ -0,0 +1 @@
19++torch==2.3.1
20+diff --git a/train.py b/train.py
21+index 5c027f4..d21dac3 100644
22+--- a/train.py
23++++ b/train.py
24+@@ -1,2 +1,5 @@
25++import torch
26++
27+ if __name__ == "__main__":
28+     print("train!")
29++    torch.rand(3,6)
30+-- 
31+2.45.2
32+
33+
34+From dce20e70280d92aeb88c3d603ad67043ead772fb Mon Sep 17 00:00:00 2001
35+From: Eric Bower <me@erock.io>
36+Date: Tue, 23 Jul 2024 10:14:37 -0400
37+Subject: [PATCH 2/2] docs: readme
38+
39+---
40+ README.md | 9 ++++++++-
41+ 1 file changed, 8 insertions(+), 1 deletion(-)
42+
43+diff --git a/README.md b/README.md
44+index 8f3a780..ba0293b 100644
45+--- a/README.md
46++++ b/README.md
47+@@ -1,3 +1,10 @@
48+ # Let's build an RNN
49+ 
50+-This repo demonstrates building an RNN using `pytorch`
51++This repo demonstrates building an RNN using `pytorch`.
52++
53++Here is some more readme information.
54++
55++Here is how to run this project locally:
56++
57++- install python and pip
58++- `pip install -r requirements.txt`
59+-- 
60+2.45.2
61+
A fixtures/a_c_reorder.patch
+55, -0
 1@@ -0,0 +1,55 @@
 2+From 7dbb94ca1bc8cadf1ce17dacb89172217d88de07 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:15:23 -0400
 5+Subject: [PATCH 1/2] docs: readme
 6+
 7+---
 8+ README.md | 4 +++-
 9+ 1 file changed, 3 insertions(+), 1 deletion(-)
10+
11+diff --git a/README.md b/README.md
12+index 8f3a780..3043953 100644
13+--- a/README.md
14++++ b/README.md
15+@@ -1,3 +1,5 @@
16+ # Let's build an RNN
17+ 
18+-This repo demonstrates building an RNN using `pytorch`
19++This repo demonstrates building an RNN using `pytorch`.
20++
21++Here is some more readme information.
22+-- 
23+2.45.2
24+
25+
26+From ad175875e2bf320859554bae73743675cc5ce444 Mon Sep 17 00:00:00 2001
27+From: Eric Bower <me@erock.io>
28+Date: Tue, 23 Jul 2024 10:06:00 -0400
29+Subject: [PATCH 2/2] chore: add torch and create random tensor
30+
31+---
32+ requirements.txt | 1 +
33+ train.py         | 3 +++
34+ 2 files changed, 4 insertions(+)
35+ create mode 100644 requirements.txt
36+
37+diff --git a/requirements.txt b/requirements.txt
38+new file mode 100644
39+index 0000000..4968a39
40+--- /dev/null
41++++ b/requirements.txt
42+@@ -0,0 +1 @@
43++torch==2.3.1
44+diff --git a/train.py b/train.py
45+index 5c027f4..d21dac3 100644
46+--- a/train.py
47++++ b/train.py
48+@@ -1,2 +1,5 @@
49++import torch
50++
51+ if __name__ == "__main__":
52+     print("train!")
53++    torch.rand(3,6)
54+-- 
55+2.45.2
56+
A fixtures/a_c_rm_commit.patch
+23, -0
 1@@ -0,0 +1,23 @@
 2+From 7dbb94ca1bc8cadf1ce17dacb89172217d88de07 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Tue, 23 Jul 2024 10:15:23 -0400
 5+Subject: [PATCH] docs: readme
 6+
 7+---
 8+ README.md | 4 +++-
 9+ 1 file changed, 3 insertions(+), 1 deletion(-)
10+
11+diff --git a/README.md b/README.md
12+index 8f3a780..3043953 100644
13+--- a/README.md
14++++ b/README.md
15+@@ -1,3 +1,5 @@
16+ # Let's build an RNN
17+ 
18+-This repo demonstrates building an RNN using `pytorch`
19++This repo demonstrates building an RNN using `pytorch`.
20++
21++Here is some more readme information.
22+-- 
23+2.45.2
24+
A fixtures/expected_commit_changed.txt
+14, -0
 1@@ -0,0 +1,14 @@
 2+1:  33c682a = 1:  33c682a chore: add torch and create random tensor
 3+2:  22dde12 ! 2:  dce20e7 docs: readme
 4+@@ README.md
 5+ # Let's build an RNN
 6+ 
 7+-This repo demonstrates building an RNN using `pytorch`
 8++This repo demonstrates building an RNN using `pytorch`.
 9++
10++Here is some more readme information.
11++
12++Here is how to run this project locally:
13++
14++- install python and pip
15++- `pip install -r requirements.txt`
M go.mod
+3, -1
 1@@ -4,7 +4,7 @@ go 1.22
 2 
 3 require (
 4 	github.com/alecthomas/chroma/v2 v2.13.0
 5-	github.com/bluekeyes/go-gitdiff v0.7.4-0.20240715034416-0a4e55f9a190
 6+	github.com/bluekeyes/go-gitdiff v0.8.0
 7 	github.com/charmbracelet/soft-serve v0.7.4
 8 	github.com/charmbracelet/ssh v0.0.0-20240301204039-e79ff702f5b3
 9 	github.com/charmbracelet/wish v1.3.2
10@@ -14,6 +14,8 @@ require (
11 	github.com/knadh/koanf/providers/env v0.1.0
12 	github.com/knadh/koanf/providers/file v1.0.0
13 	github.com/knadh/koanf/v2 v2.1.1
14+	github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de
15+	github.com/sergi/go-diff v1.1.0
16 	github.com/urfave/cli/v2 v2.27.2
17 	golang.org/x/crypto v0.21.0
18 	modernc.org/sqlite v1.27.0
M go.sum
+17, -2
 1@@ -8,10 +8,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 2 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 3 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 5-github.com/bluekeyes/go-gitdiff v0.7.2 h1:42jrcVZdjjxXtVsFNYTo/I6T1ZvIiQL+iDDLiH904hw=
 6-github.com/bluekeyes/go-gitdiff v0.7.2/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
 7 github.com/bluekeyes/go-gitdiff v0.7.4-0.20240715034416-0a4e55f9a190 h1:k6Ep4yQtmsoP/St4bf7ofXyWc6ITB/FyGy9ewaAn5os=
 8 github.com/bluekeyes/go-gitdiff v0.7.4-0.20240715034416-0a4e55f9a190/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
 9+github.com/bluekeyes/go-gitdiff v0.8.0 h1:Nn1wfw3/XeKoc3lWk+2bEXGUHIx36kj80FM1gVcBk+o=
10+github.com/bluekeyes/go-gitdiff v0.8.0/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
11 github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
12 github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
13 github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc=
14@@ -36,6 +36,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lV
15 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
16 github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
17 github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
18+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
20 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
21 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
22@@ -74,8 +75,11 @@ github.com/knadh/koanf/providers/file v1.0.0 h1:DtPvSQBeF+N0QLPMz0yf2bx0nFSxUcnc
23 github.com/knadh/koanf/providers/file v1.0.0/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI=
24 github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM=
25 github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
26+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
27 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
28 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
29+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
30+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
31 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
32 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
33 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
34@@ -105,8 +109,11 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
35 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
36 github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
37 github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
38+github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de h1:kuqx+ZOU3HjRVyMuT43K4xzTCqq+Ag1TCf8wtbYmqrw=
39+github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de/go.mod h1:dv3Q0yoeN8DwXGhZiv8Vi6/rr9mPtf4ylV60eLTGjUo=
40 github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
41 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
42+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
43 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
44 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
45 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
46@@ -119,6 +126,10 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN
47 github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
48 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
49 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
50+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
51+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
52+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
53+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
54 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
55 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
56 github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
57@@ -143,6 +154,10 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
58 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
59 golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
60 golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
61+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
62+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
63+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
64+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
65 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
66 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
67 lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
M pr.go
+29, -18
 1@@ -47,7 +47,7 @@ type GitPatchRequest interface {
 2 	GetEventLogsByRepoID(repoID string) ([]*EventLog, error)
 3 	GetEventLogsByPrID(prID int64) ([]*EventLog, error)
 4 	GetEventLogsByUserID(userID int64) ([]*EventLog, error)
 5-	DiffPatchsets(aset *Patchset, bset *Patchset) ([]*Patch, error)
 6+	DiffPatchsets(aset *Patchset, bset *Patchset) ([]*RangeDiffOutput, error)
 7 }
 8 
 9 type PrCmd struct {
10@@ -447,7 +447,7 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
11 		_ = tx.Rollback()
12 	}()
13 
14-	patches, err := parsePatchset(patchset)
15+	patches, err := ParsePatchset(patchset)
16 	if err != nil {
17 		return nil, err
18 	}
19@@ -541,7 +541,7 @@ func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchse
20 		_ = tx.Rollback()
21 	}()
22 
23-	patches, err := parsePatchset(patchset)
24+	patches, err := ParsePatchset(patchset)
25 	if err != nil {
26 		return fin, err
27 	}
28@@ -677,34 +677,45 @@ func (cmd PrCmd) GetEventLogsByUserID(userID int64) ([]*EventLog, error) {
29 	return eventLogs, err
30 }
31 
32-func (cmd PrCmd) DiffPatchsets(prev *Patchset, next *Patchset) ([]*Patch, error) {
33+func (cmd PrCmd) DiffPatchsets(prev *Patchset, next *Patchset) ([]*RangeDiffOutput, error) {
34+	output := []*RangeDiffOutput{}
35 	patches, err := cmd.GetPatchesByPatchsetID(next.ID)
36 	if err != nil {
37-		return nil, err
38+		return output, err
39+	}
40+
41+	for idx, patch := range patches {
42+		patchStr := patch.RawText
43+		if idx > 0 {
44+			patchStr = startOfPatch + patch.RawText
45+		}
46+		diffFiles, _, err := ParsePatch(patchStr)
47+		if err != nil {
48+			continue
49+		}
50+		patch.Files = diffFiles
51 	}
52 
53 	if prev == nil {
54-		return patches, nil
55+		return output, nil
56 	}
57 
58 	prevPatches, err := cmd.GetPatchesByPatchsetID(prev.ID)
59 	if err != nil {
60-		return nil, fmt.Errorf("cannot get previous patchset patches: %w", err)
61+		return output, fmt.Errorf("cannot get previous patchset patches: %w", err)
62 	}
63 
64-	diffPatches := []*Patch{}
65-	for _, patch := range patches {
66-		foundPatch := false
67-		for _, prev := range prevPatches {
68-			if prev.ContentSha == patch.ContentSha {
69-				foundPatch = true
70-			}
71+	for idx, patch := range prevPatches {
72+		patchStr := patch.RawText
73+		if idx > 0 {
74+			patchStr = startOfPatch + patch.RawText
75 		}
76-
77-		if !foundPatch {
78-			diffPatches = append(diffPatches, patch)
79+		diffFiles, _, err := ParsePatch(patchStr)
80+		if err != nil {
81+			continue
82 		}
83+		patch.Files = diffFiles
84 	}
85 
86-	return diffPatches, nil
87+	return RangeDiff(prevPatches, patches), nil
88 }
A range_diff.go
+308, -0
  1@@ -0,0 +1,308 @@
  2+package git
  3+
  4+import (
  5+	"fmt"
  6+	"math"
  7+
  8+	ha "github.com/oddg/hungarian-algorithm"
  9+	"github.com/sergi/go-diff/diffmatchpatch"
 10+)
 11+
 12+var COST_MAX = 65536
 13+var RANGE_DIFF_CREATION_FACTOR_DEFAULT = 60
 14+
 15+type PatchRange struct {
 16+	*Patch
 17+	Matching int
 18+	Diff     string
 19+	DiffSize int
 20+	Shown    bool
 21+}
 22+
 23+func NewPatchRange(patch *Patch) *PatchRange {
 24+	diff := patch.CalcDiff()
 25+	return &PatchRange{
 26+		Patch:    patch,
 27+		Matching: -1,
 28+		Diff:     diff,
 29+		DiffSize: len(diff),
 30+		Shown:    false,
 31+	}
 32+}
 33+
 34+type RangeDiffOutput struct {
 35+	Header string
 36+	Diff   []diffmatchpatch.Diff
 37+	Type   string
 38+}
 39+
 40+func output(a []*PatchRange, b []*PatchRange) []*RangeDiffOutput {
 41+	outputs := []*RangeDiffOutput{}
 42+	for i, patchA := range a {
 43+		if patchA.Matching == -1 {
 44+			outputs = append(
 45+				outputs,
 46+				&RangeDiffOutput{
 47+					Header: outputPairHeader(patchA, nil, i+1, -1),
 48+					Type:   "rm",
 49+				},
 50+			)
 51+		}
 52+	}
 53+
 54+	for j, patchB := range b {
 55+		if patchB.Matching == -1 {
 56+			outputs = append(
 57+				outputs,
 58+				&RangeDiffOutput{
 59+					Header: outputPairHeader(nil, patchB, -1, j+1),
 60+					Type:   "add",
 61+				},
 62+			)
 63+			continue
 64+		}
 65+		patchA := a[patchB.Matching]
 66+		if patchB.ContentSha == patchA.ContentSha {
 67+			outputs = append(
 68+				outputs,
 69+				&RangeDiffOutput{
 70+					Header: outputPairHeader(patchA, patchB, patchB.Matching+1, patchA.Matching+1),
 71+					Type:   "equal",
 72+				},
 73+			)
 74+		} else {
 75+			header := fmt.Sprintf(
 76+				"%d:  %s ! %d:  %s %s",
 77+				patchA.Matching+1, truncateSha(patchA.CommitSha),
 78+				patchB.Matching+1, truncateSha(patchB.CommitSha), patchB.Title,
 79+			)
 80+			diff := outputDiff(patchA, patchB)
 81+			outputs = append(
 82+				outputs,
 83+				&RangeDiffOutput{
 84+					Header: header,
 85+					Diff:   diff,
 86+					Type:   "diff",
 87+				},
 88+			)
 89+		}
 90+	}
 91+	return outputs
 92+}
 93+
 94+func DoDiff(src, dst string) []diffmatchpatch.Diff {
 95+	dmp := diffmatchpatch.New()
 96+	wSrc, wDst, warray := dmp.DiffLinesToChars(src, dst)
 97+	diffs := dmp.DiffMain(wSrc, wDst, false)
 98+	diffs = dmp.DiffCharsToLines(diffs, warray)
 99+	return diffs
100+}
101+
102+func outputDiff(patchA, patchB *PatchRange) []diffmatchpatch.Diff {
103+	diffs := []diffmatchpatch.Diff{}
104+	for _, fileA := range patchA.Files {
105+		for _, fileB := range patchB.Files {
106+			if fileA.NewName == fileB.NewName {
107+				strA := "\n@@ " + fileA.NewName + "\n"
108+				for _, frag := range fileA.TextFragments {
109+					for _, line := range frag.Lines {
110+						strA += line.String()
111+					}
112+				}
113+				strB := "\n@@ " + fileB.NewName + "\n"
114+				for _, frag := range fileB.TextFragments {
115+					for _, line := range frag.Lines {
116+						strB += line.String()
117+					}
118+				}
119+				diffs = append(diffs, DoDiff(strA, strB)...)
120+			}
121+		}
122+	}
123+
124+	return diffs
125+}
126+
127+func outputPairHeader(a *PatchRange, b *PatchRange, aIndex, bIndex int) string {
128+	if a == nil {
129+		return fmt.Sprintf("-:  ------- > %d:  %s %s\n", bIndex, truncateSha(b.CommitSha), b.Title)
130+	}
131+	if b == nil {
132+		return fmt.Sprintf("%d:  %s < -:  ------- %s\n", aIndex, truncateSha(a.CommitSha), a.Title)
133+	}
134+	return fmt.Sprintf("%d:  %s = %d:  %s %s\n", aIndex, truncateSha(a.CommitSha), bIndex, truncateSha(b.CommitSha), a.Title)
135+}
136+
137+func RangeDiff(a []*Patch, b []*Patch) []*RangeDiffOutput {
138+	aPatches := []*PatchRange{}
139+	for _, patch := range a {
140+		aPatches = append(aPatches, NewPatchRange(patch))
141+	}
142+	bPatches := []*PatchRange{}
143+	for _, patch := range b {
144+		bPatches = append(bPatches, NewPatchRange(patch))
145+	}
146+	findExactMatches(aPatches, bPatches)
147+	getCorrespondences(aPatches, bPatches, RANGE_DIFF_CREATION_FACTOR_DEFAULT)
148+	return output(aPatches, bPatches)
149+}
150+
151+func RangeDiffToStr(diffs []*RangeDiffOutput) string {
152+	output := ""
153+	for _, diff := range diffs {
154+		output += diff.Header
155+		for _, d := range diff.Diff {
156+			switch d.Type {
157+			case diffmatchpatch.DiffEqual:
158+				output += d.Text
159+			case diffmatchpatch.DiffInsert:
160+				output += d.Text
161+			case diffmatchpatch.DiffDelete:
162+				output += d.Text
163+			}
164+		}
165+	}
166+	return output
167+}
168+
169+func findExactMatches(a []*PatchRange, b []*PatchRange) {
170+	for i, patchA := range a {
171+		for j, patchB := range b {
172+			if patchA.ContentSha == patchB.ContentSha {
173+				patchA.Matching = j
174+				patchB.Matching = i
175+			}
176+		}
177+	}
178+}
179+
180+func createMatrix(rows, cols int) [][]int {
181+	mat := make([][]int, rows)
182+	for i := range mat {
183+		mat[i] = make([]int, cols)
184+	}
185+	return mat
186+}
187+
188+func diffsize(a *PatchRange, b *PatchRange) int {
189+	dmp := diffmatchpatch.New()
190+	diffs := dmp.DiffMain(a.Diff, b.Diff, false)
191+	return len(diffs)
192+}
193+
194+func getCorrespondences(a []*PatchRange, b []*PatchRange, creationFactor int) {
195+	n := len(a) + len(b)
196+	cost := createMatrix(n, n)
197+
198+	for i, patchA := range a {
199+		var c int
200+		for j, patchB := range b {
201+			if patchA.Matching == j {
202+				c = 0
203+			} else if patchA.Matching == -1 && patchB.Matching == -1 {
204+				c = diffsize(patchA, patchB)
205+			} else {
206+				c = COST_MAX
207+			}
208+			cost[i][j] = c
209+		}
210+	}
211+
212+	for j, patchB := range b {
213+		creationCost := (patchB.DiffSize * creationFactor) / 100
214+		if patchB.Matching >= 0 {
215+			creationCost = math.MaxInt32
216+		}
217+		for i := len(a); i < n; i++ {
218+			cost[i][j] = creationCost
219+		}
220+	}
221+
222+	for i := len(a); i < n; i++ {
223+		for j := len(b); j < n; j++ {
224+			cost[i][j] = 0
225+		}
226+	}
227+
228+	assignment, _ := ha.Solve(cost)
229+	for i := range a {
230+		j := assignment[i]
231+		if j >= 0 && j < len(b) {
232+			a[i].Matching = j
233+			b[j].Matching = i
234+		}
235+	}
236+}
237+
238+// computeAssignment assigns patches using the Hungarian algorithm.
239+/* func computeAssignment(costMatrix [][]int, m, n int) []int {
240+	u := make([]int, m+1) // potential for workers
241+	v := make([]int, n+1) // potential for jobs
242+	p := make([]int, n+1) // job assignment
243+	way := make([]int, n+1)
244+
245+	for i := 1; i <= m; i++ {
246+		links := make([]int, n+1)
247+		minV := make([]int, n+1)
248+		used := make([]bool, n+1)
249+		for j := 0; j <= n; j++ {
250+			minV[j] = math.MaxInt32
251+			used[j] = false
252+		}
253+
254+		j0 := 0
255+		p[0] = i
256+
257+		for {
258+			used[j0] = true
259+			i0 := p[j0]
260+			delta := math.MaxInt32
261+			j1 := 0
262+
263+			for j := 1; j <= n; j++ {
264+				if !used[j] {
265+					cur := costMatrix[i0-1][j-1] - u[i0] - v[j]
266+					if cur < minV[j] {
267+						minV[j] = cur
268+						links[j] = j0
269+					}
270+					if minV[j] < delta {
271+						delta = minV[j]
272+						j1 = j
273+					}
274+				}
275+			}
276+
277+			for j := 0; j <= n; j++ {
278+				if used[j] {
279+					u[p[j]] += delta
280+					v[j] -= delta
281+				} else {
282+					minV[j] -= delta
283+				}
284+			}
285+
286+			j0 = j1
287+			if p[j0] == 0 {
288+				break
289+			}
290+		}
291+
292+		for {
293+			j1 := way[j0]
294+			p[j0] = p[j1]
295+			j0 = j1
296+			if j0 == 0 {
297+				break
298+			}
299+		}
300+	}
301+
302+	assignment := make([]int, m)
303+	for j := 1; j <= n; j++ {
304+		if p[j] > 0 {
305+			assignment[p[j]-1] = j - 1
306+		}
307+	}
308+	return assignment
309+} */
A range_diff_test.go
+278, -0
  1@@ -0,0 +1,278 @@
  2+package git
  3+
  4+import (
  5+	"fmt"
  6+	"strings"
  7+	"testing"
  8+
  9+	"github.com/picosh/git-pr/fixtures"
 10+)
 11+
 12+func bail(err error) {
 13+	if err != nil {
 14+		panic(bail)
 15+	}
 16+}
 17+
 18+func cmp(afile, bfile string) string {
 19+	a, err := fixtures.Fixtures.Open(afile)
 20+	bail(err)
 21+	b, err := fixtures.Fixtures.Open(bfile)
 22+	bail(err)
 23+	aPatches, err := ParsePatchset(a)
 24+	bail(err)
 25+	bPatches, err := ParsePatchset(b)
 26+	bail(err)
 27+	actual := RangeDiff(aPatches, bPatches)
 28+	return RangeDiffToStr(actual)
 29+}
 30+
 31+func fail(expected, actual string) string {
 32+	return fmt.Sprintf("expected:[\n%s] actual:[\n%s]", expected, actual)
 33+}
 34+
 35+// https://git.kernel.org/tree/t/t3206-range-diff.sh?id=d19b6cd2dd72dc811f19df4b32c7ed223256c3ee
 36+
 37+// simple A..B A..C (unmodified)
 38+/*
 39+	1:  $(test_oid t1) = 1:  $(test_oid u1) s/5/A/
 40+	2:  $(test_oid t2) = 2:  $(test_oid u2) s/4/A/
 41+	3:  $(test_oid t3) = 3:  $(test_oid u3) s/11/B/
 42+	4:  $(test_oid t4) = 4:  $(test_oid u4) s/12/B/
 43+*/
 44+func TestRangeDiffUnmodified(t *testing.T) {
 45+	actual := cmp("a_b.patch", "a_c.patch")
 46+	expected := "1:  33c682a = 1:  1668484 chore: add torch and create random tensor\n"
 47+	if expected != actual {
 48+		t.Fatal(fail(expected, actual))
 49+	}
 50+}
 51+
 52+// trivial reordering
 53+/*
 54+	1:  $(test_oid t1) = 1:  $(test_oid r1) s/5/A/
 55+	3:  $(test_oid t3) = 2:  $(test_oid r2) s/11/B/
 56+	4:  $(test_oid t4) = 3:  $(test_oid r3) s/12/B/
 57+	2:  $(test_oid t2) = 4:  $(test_oid r4) s/4/A/
 58+*/
 59+func TestRangeDiffTrivialReordering(t *testing.T) {
 60+	actual := cmp("a_b_reorder.patch", "a_c_reorder.patch")
 61+	expected := `2:  22dde12 = 1:  7dbb94c docs: readme
 62+1:  33c682a = 2:  ad17587 chore: add torch and create random tensor
 63+`
 64+	if expected != actual {
 65+		t.Fatal(fail(expected, actual))
 66+	}
 67+}
 68+
 69+// removed commit
 70+/*
 71+	1:  $(test_oid t1) = 1:  $(test_oid d1) s/5/A/
 72+	2:  $(test_oid t2) < -:  $(test_oid __) s/4/A/
 73+	3:  $(test_oid t3) = 2:  $(test_oid d2) s/11/B/
 74+	4:  $(test_oid t4) = 3:  $(test_oid d3) s/12/B/
 75+*/
 76+func TestRangeDiffRemovedCommit(t *testing.T) {
 77+	actual := cmp("a_b_reorder.patch", "a_c_rm_commit.patch")
 78+	expected := `1:  33c682a < -:  ------- chore: add torch and create random tensor
 79+2:  22dde12 = 1:  7dbb94c docs: readme
 80+`
 81+	if expected != actual {
 82+		t.Fatal(fail(expected, actual))
 83+	}
 84+}
 85+
 86+// added commit
 87+/*
 88+	1:  $(test_oid t1) = 1:  $(test_oid a1) s/5/A/
 89+	2:  $(test_oid t2) = 2:  $(test_oid a2) s/4/A/
 90+	-:  $(test_oid __) > 3:  $(test_oid a3) s/6/A/
 91+	3:  $(test_oid t3) = 4:  $(test_oid a4) s/11/B/
 92+	4:  $(test_oid t4) = 5:  $(test_oid a5) s/12/B/
 93+*/
 94+func TestRangeDiffAddedCommit(t *testing.T) {
 95+	actual := cmp("a_b_reorder.patch", "a_c_added_commit.patch")
 96+	expected := `1:  33c682a = 1:  33c682a chore: add torch and create random tensor
 97+2:  22dde12 = 2:  22dde12 docs: readme
 98+-:  ------- > 3:  b248060 chore: make tensor 6x6
 99+`
100+	if expected != actual {
101+		t.Fatal(fail(expected, actual))
102+	}
103+}
104+
105+// changed commit
106+/*
107+	1:  $(test_oid t1) = 1:  $(test_oid c1) s/5/A/
108+	2:  $(test_oid t2) = 2:  $(test_oid c2) s/4/A/
109+	3:  $(test_oid t3) ! 3:  $(test_oid c3) s/11/B/
110+	    @@ file: A
111+	      9
112+	      10
113+	     -11
114+	    -+B
115+	    ++BB
116+	      12
117+	      13
118+	      14
119+	4:  $(test_oid t4) ! 4:  $(test_oid c4) s/12/B/
120+	    @@ file
121+	     @@ file: A
122+	      9
123+	      10
124+	    - B
125+	    + BB
126+	     -12
127+	     +B
128+	      13
129+*/
130+func TestRangeDiffChangedCommit(t *testing.T) {
131+	actual := cmp("a_b_reorder.patch", "a_c_changed_commit.patch")
132+	// os.WriteFile("fixtures/expected_commit_changed.txt", []byte(actual), 0644)
133+	fp, err := fixtures.Fixtures.ReadFile("expected_commit_changed.txt")
134+	if err != nil {
135+		t.Fatal("file not found")
136+	}
137+	expected := string(fp)
138+	if strings.TrimSpace(expected) != strings.TrimSpace(actual) {
139+		t.Fatal(fail(expected, actual))
140+	}
141+}
142+
143+// renamed file
144+/*
145+	1:  $(test_oid t1) = 1:  $(test_oid n1) s/5/A/
146+	2:  $(test_oid t2) ! 2:  $(test_oid n2) s/4/A/
147+	    @@ Metadata
148+	    ZAuthor: Thomas Rast <trast@inf.ethz.ch>
149+	    Z
150+	    Z ## Commit message ##
151+	    -    s/4/A/
152+	    +    s/4/A/ + rename file
153+	    Z
154+	    - ## file ##
155+	    + ## file => renamed-file ##
156+	    Z@@
157+	    Z 1
158+	    Z 2
159+	3:  $(test_oid t3) ! 3:  $(test_oid n3) s/11/B/
160+	    @@ Metadata
161+	    Z ## Commit message ##
162+	    Z    s/11/B/
163+	    Z
164+	    - ## file ##
165+	    -@@ file: A
166+	    + ## renamed-file ##
167+	    +@@ renamed-file: A
168+	    Z 8
169+	    Z 9
170+	    Z 10
171+	4:  $(test_oid t4) ! 4:  $(test_oid n4) s/12/B/
172+	    @@ Metadata
173+	    Z ## Commit message ##
174+	    Z    s/12/B/
175+	    Z
176+	    - ## file ##
177+	    -@@ file: A
178+	    + ## renamed-file ##
179+	    +@@ renamed-file: A
180+	    Z 9
181+	    Z 10
182+	    Z B
183+*/
184+// func TestRangeDiffRenamedFile(t *testing.T) {}
185+
186+// file with mode only change
187+/*
188+	1:  $(test_oid t2) ! 1:  $(test_oid o1) s/4/A/
189+	    @@ Metadata
190+	    ZAuthor: Thomas Rast <trast@inf.ethz.ch>
191+	    Z
192+	    Z ## Commit message ##
193+	    -    s/4/A/
194+	    +    s/4/A/ + add other-file
195+	    Z
196+	    Z ## file ##
197+	    Z@@
198+	    @@ file
199+	    Z A
200+	    Z 6
201+	    Z 7
202+	    +
203+	    + ## other-file (new) ##
204+	2:  $(test_oid t3) ! 2:  $(test_oid o2) s/11/B/
205+	    @@ Metadata
206+	    ZAuthor: Thomas Rast <trast@inf.ethz.ch>
207+	    Z
208+	    Z ## Commit message ##
209+	    -    s/11/B/
210+	    +    s/11/B/ + mode change other-file
211+	    Z
212+	    Z ## file ##
213+	    Z@@ file: A
214+	    @@ file: A
215+	    Z 12
216+	    Z 13
217+	    Z 14
218+	    +
219+	    + ## other-file (mode change 100644 => 100755) ##
220+	3:  $(test_oid t4) = 3:  $(test_oid o3) s/12/B/
221+*/
222+// func TestRangeDiffFileWithModeOnlyChange(t *testing.T) {}
223+
224+// file added and later removed
225+/*
226+	1:  $(test_oid t1) = 1:  $(test_oid s1) s/5/A/
227+	2:  $(test_oid t2) ! 2:  $(test_oid s2) s/4/A/
228+	    @@ Metadata
229+	    ZAuthor: Thomas Rast <trast@inf.ethz.ch>
230+	    Z
231+	    Z ## Commit message ##
232+	    -    s/4/A/
233+	    +    s/4/A/ + new-file
234+	    Z
235+	    Z ## file ##
236+	    Z@@
237+	    @@ file
238+	    Z A
239+	    Z 6
240+	    Z 7
241+	    +
242+	    + ## new-file (new) ##
243+	3:  $(test_oid t3) ! 3:  $(test_oid s3) s/11/B/
244+	    @@ Metadata
245+	    ZAuthor: Thomas Rast <trast@inf.ethz.ch>
246+	    Z
247+	    Z ## Commit message ##
248+	    -    s/11/B/
249+	    +    s/11/B/ + remove file
250+	    Z
251+	    Z ## file ##
252+	    Z@@ file: A
253+	    @@ file: A
254+	    Z 12
255+	    Z 13
256+	    Z 14
257+	    +
258+	    + ## new-file (deleted) ##
259+	4:  $(test_oid t4) = 4:  $(test_oid s4) s/12/B/
260+*/
261+// func TestRangeDiffFileAddedThenRemoved(t *testing.T) {}
262+
263+// changed message
264+/*
265+	1:  $(test_oid t1) = 1:  $(test_oid m1) s/5/A/
266+	2:  $(test_oid t2) ! 2:  $(test_oid m2) s/4/A/
267+	    @@ Metadata
268+	    Z ## Commit message ##
269+	    Z    s/4/A/
270+	    Z
271+	    +    Also a silly comment here!
272+	    +
273+	    Z ## file ##
274+	    Z@@
275+	    Z 1
276+	3:  $(test_oid t3) = 3:  $(test_oid m3) s/11/B/
277+	4:  $(test_oid t4) = 4:  $(test_oid m4) s/12/B/
278+*/
279+// func TestRangeDiffChangedMessage(t *testing.T) {}
M tmpl/pr-detail.html
+23, -8
 1@@ -53,18 +53,33 @@
 2     <h3 class="text-lg">Patchsets</h3>
 3 
 4     {{range .Patchsets}}
 5+      {{if .RangeDiff}}
 6       <details>
 7-        <summary class="text-sm">Diff ↕</summary>
 8+        <summary class="text-sm">Range Diff ↕</summary>
 9         <div class="group">
10-          {{range .DiffPatches}}
11-            <div class="group" id="{{.Url}}">
12-              {{template "patch" .}}
13-            </div>
14-          {{else}}
15-            No patches found, that doesn't seem right.
16-          {{end}}
17+        {{- range .RangeDiff -}}
18+          <div>
19+            <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "diff"}}pill-review{{end}}'>
20+              {{.Header}}
21+            </code>
22+          </div>
23+          {{- if .Diff -}}
24+          <pre>
25+            {{- range .Diff -}}
26+              {{- if eq .Type -1 -}}
27+                <span style="color: tomato;">{{.Text}}</span>
28+              {{- else if eq .Type 1 -}}
29+                <span style="color: limegreen;">{{.Text}}</span>
30+              {{- else -}}
31+                <span>{{.Text}}</span>
32+              {{- end -}}
33+            {{- end -}}
34+          </pre>
35+          {{- end -}}
36+        {{- end -}}
37         </div>
38       </details>
39+      {{end}}
40 
41       <div>
42         <code class="{{if .Review}}pill-review{{end}}">{{.FormattedID}}</code>
M util.go
+10, -5
 1@@ -109,7 +109,13 @@ func patchToDiff(patch io.Reader) (string, error) {
 2 	return str[idx:], nil
 3 }
 4 
 5-func parsePatchset(patchset io.Reader) ([]*Patch, error) {
 6+func ParsePatch(patchRaw string) ([]*gitdiff.File, string, error) {
 7+	reader := strings.NewReader(patchRaw)
 8+	diffFiles, preamble, err := gitdiff.Parse(reader)
 9+	return diffFiles, preamble, err
10+}
11+
12+func ParsePatchset(patchset io.Reader) ([]*Patch, error) {
13 	patches := []*Patch{}
14 	buf := new(strings.Builder)
15 	_, err := io.Copy(buf, patchset)
16@@ -123,8 +129,7 @@ func parsePatchset(patchset io.Reader) ([]*Patch, error) {
17 		if idx > 0 {
18 			patchStr = startOfPatch + patchRaw
19 		}
20-		reader := strings.NewReader(patchStr)
21-		diffFiles, preamble, err := gitdiff.Parse(reader)
22+		diffFiles, preamble, err := ParsePatch(patchStr)
23 		if err != nil {
24 			return nil, err
25 		}
26@@ -154,6 +159,7 @@ func parsePatchset(patchset io.Reader) ([]*Patch, error) {
27 			ContentSha:    contentSha,
28 			RawText:       patchStr,
29 			BaseCommitSha: sql.NullString{String: baseCommit},
30+			Files:         diffFiles,
31 		})
32 	}
33 
34@@ -172,12 +178,11 @@ func calcContentSha(diffFiles []*gitdiff.File, header *gitdiff.PatchHeader) stri
35 		authorEmail = header.Author.Email
36 	}
37 	content := fmt.Sprintf(
38-		"%s\n%s\n%s\n%s\n%s\n",
39+		"%s\n%s\n%s\n%s\n",
40 		header.Title,
41 		header.Body,
42 		authorName,
43 		authorEmail,
44-		header.AuthorDate,
45 	)
46 	for _, diff := range diffFiles {
47 		// we need to ignore diffs with base commit because that depends
M util_test.go
+8, -8
 1@@ -13,11 +13,11 @@ func TestParsePatchsetWithCover(t *testing.T) {
 2 		_ = file.Close()
 3 	}()
 4 	if err != nil {
 5-		t.Fatalf(err.Error())
 6+		t.Fatal(err.Error())
 7 	}
 8-	actual, err := parsePatchset(file)
 9+	actual, err := ParsePatchset(file)
10 	if err != nil {
11-		t.Fatalf(err.Error())
12+		t.Fatal(err.Error())
13 	}
14 	expected := []*Patch{
15 		{Title: "Add torch deps"},
16@@ -41,7 +41,7 @@ func TestPatchToDiff(t *testing.T) {
17 		_ = file.Close()
18 	}()
19 	if err != nil {
20-		t.Fatalf(err.Error())
21+		t.Fatal(err.Error())
22 	}
23 
24 	fileExp, err := os.Open("fixtures/single.diff")
25@@ -49,21 +49,21 @@ func TestPatchToDiff(t *testing.T) {
26 		_ = file.Close()
27 	}()
28 	if err != nil {
29-		t.Fatalf(err.Error())
30+		t.Fatal(err.Error())
31 	}
32 
33 	actual, err := patchToDiff(file)
34 	if err != nil {
35-		t.Fatalf(err.Error())
36+		t.Fatal(err.Error())
37 	}
38 
39 	by, err := io.ReadAll(fileExp)
40 	if err != nil {
41-		t.Fatalf("cannot read expected file")
42+		t.Fatal("cannot read expected file")
43 	}
44 
45 	if actual != string(by) {
46 		fmt.Println(actual)
47-		t.Fatalf("diff does not match expected")
48+		t.Fatal("diff does not match expected")
49 	}
50 }
M web.go
+8, -28
 1@@ -311,7 +311,7 @@ type PatchsetData struct {
 2 	UserData
 3 	FormattedID string
 4 	Date        string
 5-	DiffPatches []PatchData
 6+	RangeDiff   []*RangeDiffOutput
 7 }
 8 
 9 type PrDetailData struct {
10@@ -373,27 +373,14 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
11 		if idx > 0 {
12 			prevPatchset = patchsets[idx-1]
13 		}
14-		patches, err := web.Pr.DiffPatchsets(prevPatchset, patchset)
15-		if err != nil {
16-			web.Logger.Error("could not diff patchset", "err", err)
17-			continue
18-		}
19 
20-		patchesData := []PatchData{}
21-		for _, patch := range patches {
22-			diffStr, err := parseText(web.Formatter, web.Theme, patch.RawText)
23+		var rangeDiff []*RangeDiffOutput
24+		if idx > 0 {
25+			rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
26 			if err != nil {
27-				web.Logger.Error("cannot parse patch", "err", err)
28-				w.WriteHeader(http.StatusUnprocessableEntity)
29-				return
30+				web.Logger.Error("could not diff patchset", "err", err)
31+				continue
32 			}
33-
34-			patchesData = append(patchesData, PatchData{
35-				Patch:   patch,
36-				Url:     template.URL(fmt.Sprintf("patch-%d", patch.ID)),
37-				DiffStr: template.HTML(diffStr),
38-				Review:  patchset.Review,
39-			})
40 		}
41 
42 		pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
43@@ -411,8 +398,8 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
44 				IsAdmin: web.Backend.IsAdmin(pk),
45 				Pubkey:  user.Pubkey,
46 			},
47-			Date:        patchset.CreatedAt.Format(time.RFC3339),
48-			DiffPatches: patchesData,
49+			Date:      patchset.CreatedAt.Format(time.RFC3339),
50+			RangeDiff: rangeDiff,
51 		})
52 	}
53 
54@@ -437,13 +424,6 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
55 
56 			// highlight review
57 			isReview := false
58-			if latest.Review {
59-				for _, diffPatch := range latest.DiffPatches {
60-					if diffPatch.ID == patch.ID {
61-						isReview = true
62-					}
63-				}
64-			}
65 
66 			patchesData = append(patchesData, PatchData{
67 				Patch:               patch,