Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview/repo: serve .diff and .patch for commits

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

authored by

Anirudh Oppiliappan and committed by tangled.org 23ee9ca5 c184a759

+450
+61
appview/repo/log.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 + "io" 6 7 "net/http" 7 8 "net/url" 8 9 "strconv" ··· 19 20 "github.com/go-chi/chi/v5" 20 21 "github.com/go-git/go-git/v5/plumbing" 21 22 ) 23 + 24 + func (rp *Repo) CommitRawDiff(w http.ResponseWriter, r *http.Request) { 25 + rp.serveRawCommit(w, r, "diff") 26 + } 27 + 28 + func (rp *Repo) CommitRawPatch(w http.ResponseWriter, r *http.Request) { 29 + rp.serveRawCommit(w, r, "patch") 30 + } 31 + 32 + func (rp *Repo) serveRawCommit(w http.ResponseWriter, r *http.Request, format string) { 33 + l := rp.logger.With("handler", "CommitRaw", "format", format) 34 + 35 + f, err := rp.repoResolver.Resolve(r) 36 + if err != nil { 37 + l.Error("failed to resolve repo", "err", err) 38 + return 39 + } 40 + 41 + ref := chi.URLParam(r, "ref") 42 + ref, _ = url.PathUnescape(ref) 43 + 44 + if !plumbing.IsHash(ref) { 45 + rp.pages.Error404(w) 46 + return 47 + } 48 + 49 + scheme := "http" 50 + if !rp.config.Core.Dev { 51 + scheme = "https" 52 + } 53 + 54 + xrpcc := &indigoxrpc.Client{ 55 + Host: fmt.Sprintf("%s://%s", scheme, f.Knot), 56 + } 57 + 58 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, f.RepoIdentifier()) 59 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 61 + rp.pages.Error503(w) 62 + return 63 + } 64 + 65 + var result types.RepoCommitResponse 66 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 67 + l.Error("failed to decode XRPC response", "err", err) 68 + rp.pages.Error503(w) 69 + return 70 + } 71 + 72 + filename := ref[:7] + "." + format 73 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 74 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 75 + 76 + switch format { 77 + case "patch": 78 + io.WriteString(w, renderFormatPatch(result.Diff)) 79 + default: 80 + io.WriteString(w, renderUnifiedDiff(result.Diff)) 81 + } 82 + } 22 83 23 84 func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { 24 85 l := rp.logger.With("handler", "RepoLog")
+103
appview/repo/rawdiff.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "tangled.org/core/types" 9 + ) 10 + 11 + // renderUnifiedDiff reconstructs a unified diff from a NiceDiff. 12 + func renderUnifiedDiff(nd *types.NiceDiff) string { 13 + if nd == nil { 14 + return "" 15 + } 16 + var sb strings.Builder 17 + for _, d := range nd.Diff { 18 + oldName := d.Name.Old 19 + newName := d.Name.New 20 + if oldName == "" { 21 + oldName = newName 22 + } 23 + if newName == "" { 24 + newName = oldName 25 + } 26 + 27 + fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", oldName, newName) 28 + switch { 29 + case d.IsNew: 30 + fmt.Fprintf(&sb, "new file mode 100644\n") 31 + fmt.Fprintf(&sb, "--- /dev/null\n") 32 + fmt.Fprintf(&sb, "+++ b/%s\n", newName) 33 + case d.IsDelete: 34 + fmt.Fprintf(&sb, "deleted file mode 100644\n") 35 + fmt.Fprintf(&sb, "--- a/%s\n", oldName) 36 + fmt.Fprintf(&sb, "+++ /dev/null\n") 37 + case d.IsRename: 38 + fmt.Fprintf(&sb, "rename from %s\n", oldName) 39 + fmt.Fprintf(&sb, "rename to %s\n", newName) 40 + fmt.Fprintf(&sb, "--- a/%s\n", oldName) 41 + fmt.Fprintf(&sb, "+++ b/%s\n", newName) 42 + default: 43 + fmt.Fprintf(&sb, "--- a/%s\n", oldName) 44 + fmt.Fprintf(&sb, "+++ b/%s\n", newName) 45 + } 46 + 47 + for i := range d.TextFragments { 48 + sb.WriteString(d.TextFragments[i].String()) 49 + } 50 + } 51 + return sb.String() 52 + } 53 + 54 + // renderFormatPatch reconstructs an email-style format-patch from a NiceDiff. 55 + func renderFormatPatch(nd *types.NiceDiff) string { 56 + if nd == nil { 57 + return "" 58 + } 59 + c := nd.Commit 60 + 61 + // subject: first line of commit message 62 + subject := c.Message 63 + if i := strings.IndexByte(subject, '\n'); i >= 0 { 64 + subject = subject[:i] 65 + } 66 + 67 + // body: rest of message after first line 68 + body := "" 69 + if i := strings.Index(c.Message, "\n\n"); i >= 0 { 70 + body = strings.TrimRight(c.Message[i+2:], "\n") 71 + } 72 + 73 + date := c.Author.When.UTC().Format(time.RFC1123Z) 74 + 75 + var sb strings.Builder 76 + fmt.Fprintf(&sb, "From %s Mon Sep 17 00:00:00 2001\n", c.Hash.String()) 77 + fmt.Fprintf(&sb, "From: %s <%s>\n", c.Author.Name, c.Author.Email) 78 + fmt.Fprintf(&sb, "Date: %s\n", date) 79 + fmt.Fprintf(&sb, "Subject: [PATCH] %s\n", subject) 80 + sb.WriteString("\n") 81 + if body != "" { 82 + sb.WriteString(body) 83 + sb.WriteString("\n") 84 + } 85 + sb.WriteString("---\n") 86 + 87 + // stat summary 88 + for _, d := range nd.Diff { 89 + name := d.Name.New 90 + if name == "" { 91 + name = d.Name.Old 92 + } 93 + stats := d.Stats() 94 + fmt.Fprintf(&sb, " %s | %d %s\n", name, stats.Insertions+stats.Deletions, 95 + strings.Repeat("+", int(stats.Insertions))+strings.Repeat("-", int(stats.Deletions))) 96 + } 97 + fmt.Fprintf(&sb, " %d file(s) changed, %d insertion(s)(+), %d deletion(s)(-)\n\n", 98 + nd.Stat.FilesChanged, nd.Stat.Insertions, nd.Stat.Deletions) 99 + 100 + sb.WriteString(renderUnifiedDiff(nd)) 101 + sb.WriteString("\n--\ntangled.sh\n") 102 + return sb.String() 103 + }
+284
appview/repo/rawdiff_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + 8 + "github.com/bluekeyes/go-gitdiff/gitdiff" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/go-git/go-git/v5/plumbing/object" 11 + "tangled.org/core/types" 12 + ) 13 + 14 + // parseDiff is a test helper that parses a unified diff string into gitdiff.TextFragment slices. 15 + func parseDiff(t *testing.T, src string) []*gitdiff.File { 16 + t.Helper() 17 + files, _, err := gitdiff.Parse(strings.NewReader(src)) 18 + if err != nil { 19 + t.Fatalf("gitdiff.Parse: %v", err) 20 + } 21 + return files 22 + } 23 + 24 + // niceDiffFromParsed builds a NiceDiff from parsed gitdiff.File entries, mirroring 25 + // what the knotserver does in git.Diff(). 26 + func niceDiffFromParsed(files []*gitdiff.File, commit types.Commit, stat types.DiffStat) *types.NiceDiff { 27 + nd := &types.NiceDiff{Commit: commit, Stat: stat} 28 + for _, f := range files { 29 + d := types.Diff{ 30 + IsBinary: f.IsBinary, 31 + IsNew: f.IsNew, 32 + IsDelete: f.IsDelete, 33 + IsCopy: f.IsCopy, 34 + IsRename: f.IsRename, 35 + } 36 + d.Name.Old = f.OldName 37 + d.Name.New = f.NewName 38 + for _, tf := range f.TextFragments { 39 + d.TextFragments = append(d.TextFragments, *tf) 40 + } 41 + nd.Diff = append(nd.Diff, d) 42 + } 43 + return nd 44 + } 45 + 46 + func TestRenderUnifiedDiff_nil(t *testing.T) { 47 + if got := renderUnifiedDiff(nil); got != "" { 48 + t.Errorf("expected empty string for nil NiceDiff, got %q", got) 49 + } 50 + } 51 + 52 + func TestRenderUnifiedDiff_modified(t *testing.T) { 53 + const src = `diff --git a/foo.go b/foo.go 54 + --- a/foo.go 55 + +++ b/foo.go 56 + @@ -1,3 +1,3 @@ 57 + package main 58 + -// old comment 59 + +// new comment 60 + func main() {} 61 + ` 62 + files := parseDiff(t, src) 63 + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) 64 + got := renderUnifiedDiff(nd) 65 + 66 + checks := []string{ 67 + "diff --git a/foo.go b/foo.go\n", 68 + "--- a/foo.go\n", 69 + "+++ b/foo.go\n", 70 + "@@ -1,3 +1,3 @@", 71 + "-// old comment\n", 72 + "+// new comment\n", 73 + } 74 + for _, want := range checks { 75 + if !strings.Contains(got, want) { 76 + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) 77 + } 78 + } 79 + } 80 + 81 + func TestRenderUnifiedDiff_newFile(t *testing.T) { 82 + const src = `diff --git a/new.go b/new.go 83 + new file mode 100644 84 + --- /dev/null 85 + +++ b/new.go 86 + @@ -0,0 +1,2 @@ 87 + +package main 88 + +func main() {} 89 + ` 90 + files := parseDiff(t, src) 91 + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) 92 + got := renderUnifiedDiff(nd) 93 + 94 + checks := []string{ 95 + "diff --git a/new.go b/new.go\n", 96 + "new file mode 100644\n", 97 + "--- /dev/null\n", 98 + "+++ b/new.go\n", 99 + "+package main\n", 100 + } 101 + for _, want := range checks { 102 + if !strings.Contains(got, want) { 103 + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) 104 + } 105 + } 106 + } 107 + 108 + func TestRenderUnifiedDiff_deletedFile(t *testing.T) { 109 + const src = `diff --git a/old.go b/old.go 110 + deleted file mode 100644 111 + --- a/old.go 112 + +++ /dev/null 113 + @@ -1,2 +0,0 @@ 114 + -package main 115 + -func main() {} 116 + ` 117 + files := parseDiff(t, src) 118 + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) 119 + got := renderUnifiedDiff(nd) 120 + 121 + checks := []string{ 122 + "diff --git a/old.go b/old.go\n", 123 + "deleted file mode 100644\n", 124 + "--- a/old.go\n", 125 + "+++ /dev/null\n", 126 + "-package main\n", 127 + } 128 + for _, want := range checks { 129 + if !strings.Contains(got, want) { 130 + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) 131 + } 132 + } 133 + } 134 + 135 + func TestRenderUnifiedDiff_renamedFile(t *testing.T) { 136 + const src = `diff --git a/old.go b/renamed.go 137 + rename from old.go 138 + rename to renamed.go 139 + --- a/old.go 140 + +++ b/renamed.go 141 + @@ -1,2 +1,2 @@ 142 + package main 143 + -func old() {} 144 + +func renamed() {} 145 + ` 146 + files := parseDiff(t, src) 147 + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) 148 + got := renderUnifiedDiff(nd) 149 + 150 + checks := []string{ 151 + "diff --git a/old.go b/renamed.go\n", 152 + "rename from old.go\n", 153 + "rename to renamed.go\n", 154 + "--- a/old.go\n", 155 + "+++ b/renamed.go\n", 156 + } 157 + for _, want := range checks { 158 + if !strings.Contains(got, want) { 159 + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) 160 + } 161 + } 162 + } 163 + 164 + func TestRenderUnifiedDiff_multipleFiles(t *testing.T) { 165 + const src = `diff --git a/a.go b/a.go 166 + --- a/a.go 167 + +++ b/a.go 168 + @@ -1,1 +1,1 @@ 169 + -old a 170 + +new a 171 + diff --git a/b.go b/b.go 172 + --- a/b.go 173 + +++ b/b.go 174 + @@ -1,1 +1,1 @@ 175 + -old b 176 + +new b 177 + ` 178 + files := parseDiff(t, src) 179 + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) 180 + got := renderUnifiedDiff(nd) 181 + 182 + for _, want := range []string{"diff --git a/a.go b/a.go", "diff --git a/b.go b/b.go"} { 183 + if !strings.Contains(got, want) { 184 + t.Errorf("missing %q in output:\n%s", want, got) 185 + } 186 + } 187 + } 188 + 189 + func TestRenderFormatPatch_nil(t *testing.T) { 190 + if got := renderFormatPatch(nil); got != "" { 191 + t.Errorf("expected empty string for nil NiceDiff, got %q", got) 192 + } 193 + } 194 + 195 + func TestRenderFormatPatch_headers(t *testing.T) { 196 + when := time.Date(2024, 3, 15, 10, 30, 0, 0, time.UTC) 197 + hash := plumbing.NewHash("abc1234567890000000000000000000000000000") 198 + 199 + nd := &types.NiceDiff{ 200 + Commit: types.Commit{ 201 + Hash: hash, 202 + Message: "Fix the bug\n\nThis patch resolves the long-standing issue.\n", 203 + Author: object.Signature{ 204 + Name: "Alice Dev", 205 + Email: "alice@example.com", 206 + When: when, 207 + }, 208 + }, 209 + Stat: types.DiffStat{FilesChanged: 1, Insertions: 2, Deletions: 1}, 210 + } 211 + 212 + got := renderFormatPatch(nd) 213 + 214 + checks := []string{ 215 + "From abc1234567890000000000000000000000000000 Mon Sep 17 00:00:00 2001\n", 216 + "From: Alice Dev <alice@example.com>\n", 217 + "Date: Fri, 15 Mar 2024 10:30:00 +0000\n", 218 + "Subject: [PATCH] Fix the bug\n", 219 + "This patch resolves the long-standing issue.\n", 220 + "---\n", 221 + " 1 file(s) changed, 2 insertion(s)(+), 1 deletion(s)(-)\n", 222 + "\n--\ntangled.sh\n", 223 + } 224 + for _, want := range checks { 225 + if !strings.Contains(got, want) { 226 + t.Errorf("renderFormatPatch output missing %q\ngot:\n%s", want, got) 227 + } 228 + } 229 + } 230 + 231 + func TestRenderFormatPatch_subjectOnly(t *testing.T) { 232 + // Single-line message (no body) should not emit a blank body section. 233 + nd := &types.NiceDiff{ 234 + Commit: types.Commit{ 235 + Message: "Single line commit", 236 + Author: object.Signature{When: time.Now()}, 237 + }, 238 + } 239 + got := renderFormatPatch(nd) 240 + 241 + if !strings.Contains(got, "Subject: [PATCH] Single line commit\n") { 242 + t.Errorf("missing subject in output:\n%s", got) 243 + } 244 + // Body should not appear between Subject and "---" 245 + parts := strings.SplitN(got, "---\n", 2) 246 + if len(parts) < 2 { 247 + t.Fatalf("expected '---' separator in output:\n%s", got) 248 + } 249 + beforeSep := parts[0] 250 + // Only the blank line between headers and body should be there, no extra content. 251 + afterSubject := strings.SplitN(beforeSep, "Subject: [PATCH] Single line commit\n", 2) 252 + if len(afterSubject) == 2 && strings.TrimSpace(afterSubject[1]) != "" { 253 + t.Errorf("unexpected body content before '---': %q", afterSubject[1]) 254 + } 255 + } 256 + 257 + func TestRenderFormatPatch_containsDiff(t *testing.T) { 258 + const src = `diff --git a/foo.go b/foo.go 259 + --- a/foo.go 260 + +++ b/foo.go 261 + @@ -1,2 +1,2 @@ 262 + package main 263 + -// old 264 + +// new 265 + ` 266 + files := parseDiff(t, src) 267 + nd := niceDiffFromParsed(files, types.Commit{ 268 + Author: object.Signature{When: time.Now()}, 269 + }, types.DiffStat{FilesChanged: 1, Insertions: 1, Deletions: 1}) 270 + 271 + got := renderFormatPatch(nd) 272 + 273 + checks := []string{ 274 + "diff --git a/foo.go b/foo.go\n", 275 + "-// old\n", 276 + "+// new\n", 277 + " foo.go |", 278 + } 279 + for _, want := range checks { 280 + if !strings.Contains(got, want) { 281 + t.Errorf("renderFormatPatch output missing %q\ngot:\n%s", want, got) 282 + } 283 + } 284 + }
+2
appview/repo/router.go
··· 17 17 r.Get("/", rp.Index) 18 18 r.Get("/*", rp.Tree) 19 19 }) 20 + r.Get("/commit/{ref}.diff", rp.CommitRawDiff) 21 + r.Get("/commit/{ref}.patch", rp.CommitRawPatch) 20 22 r.Get("/commit/{ref}", rp.Commit) 21 23 r.Get("/branches", rp.Branches) 22 24 r.Delete("/branches", rp.DeleteBranch)