diff --git a/appview/repo/log.go b/appview/repo/log.go --- a/appview/repo/log.go +++ b/appview/repo/log.go @@ -3,6 +3,7 @@ import ( "encoding/json" "fmt" + "io" "net/http" "net/url" "strconv" @@ -19,6 +20,66 @@ "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing" ) + +func (rp *Repo) CommitRawDiff(w http.ResponseWriter, r *http.Request) { + rp.serveRawCommit(w, r, "diff") +} + +func (rp *Repo) CommitRawPatch(w http.ResponseWriter, r *http.Request) { + rp.serveRawCommit(w, r, "patch") +} + +func (rp *Repo) serveRawCommit(w http.ResponseWriter, r *http.Request, format string) { + l := rp.logger.With("handler", "CommitRaw", "format", format) + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to resolve repo", "err", err) + return + } + + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + + if !plumbing.IsHash(ref) { + rp.pages.Error404(w) + return + } + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + + xrpcc := &indigoxrpc.Client{ + Host: fmt.Sprintf("%s://%s", scheme, f.Knot), + } + + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, f.RepoIdentifier()) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var result types.RepoCommitResponse + if err := json.Unmarshal(xrpcBytes, &result); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + + filename := ref[:7] + "." + format + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) + + switch format { + case "patch": + io.WriteString(w, renderFormatPatch(result.Diff)) + default: + io.WriteString(w, renderUnifiedDiff(result.Diff)) + } +} func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoLog") diff --git a/appview/repo/rawdiff.go b/appview/repo/rawdiff.go new file mode 100644 --- /dev/null +++ b/appview/repo/rawdiff.go @@ -0,0 +1,103 @@ +package repo + +import ( + "fmt" + "strings" + "time" + + "tangled.org/core/types" +) + +// renderUnifiedDiff reconstructs a unified diff from a NiceDiff. +func renderUnifiedDiff(nd *types.NiceDiff) string { + if nd == nil { + return "" + } + var sb strings.Builder + for _, d := range nd.Diff { + oldName := d.Name.Old + newName := d.Name.New + if oldName == "" { + oldName = newName + } + if newName == "" { + newName = oldName + } + + fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", oldName, newName) + switch { + case d.IsNew: + fmt.Fprintf(&sb, "new file mode 100644\n") + fmt.Fprintf(&sb, "--- /dev/null\n") + fmt.Fprintf(&sb, "+++ b/%s\n", newName) + case d.IsDelete: + fmt.Fprintf(&sb, "deleted file mode 100644\n") + fmt.Fprintf(&sb, "--- a/%s\n", oldName) + fmt.Fprintf(&sb, "+++ /dev/null\n") + case d.IsRename: + fmt.Fprintf(&sb, "rename from %s\n", oldName) + fmt.Fprintf(&sb, "rename to %s\n", newName) + fmt.Fprintf(&sb, "--- a/%s\n", oldName) + fmt.Fprintf(&sb, "+++ b/%s\n", newName) + default: + fmt.Fprintf(&sb, "--- a/%s\n", oldName) + fmt.Fprintf(&sb, "+++ b/%s\n", newName) + } + + for i := range d.TextFragments { + sb.WriteString(d.TextFragments[i].String()) + } + } + return sb.String() +} + +// renderFormatPatch reconstructs an email-style format-patch from a NiceDiff. +func renderFormatPatch(nd *types.NiceDiff) string { + if nd == nil { + return "" + } + c := nd.Commit + + // subject: first line of commit message + subject := c.Message + if i := strings.IndexByte(subject, '\n'); i >= 0 { + subject = subject[:i] + } + + // body: rest of message after first line + body := "" + if i := strings.Index(c.Message, "\n\n"); i >= 0 { + body = strings.TrimRight(c.Message[i+2:], "\n") + } + + date := c.Author.When.UTC().Format(time.RFC1123Z) + + var sb strings.Builder + fmt.Fprintf(&sb, "From %s Mon Sep 17 00:00:00 2001\n", c.Hash.String()) + fmt.Fprintf(&sb, "From: %s <%s>\n", c.Author.Name, c.Author.Email) + fmt.Fprintf(&sb, "Date: %s\n", date) + fmt.Fprintf(&sb, "Subject: [PATCH] %s\n", subject) + sb.WriteString("\n") + if body != "" { + sb.WriteString(body) + sb.WriteString("\n") + } + sb.WriteString("---\n") + + // stat summary + for _, d := range nd.Diff { + name := d.Name.New + if name == "" { + name = d.Name.Old + } + stats := d.Stats() + fmt.Fprintf(&sb, " %s | %d %s\n", name, stats.Insertions+stats.Deletions, + strings.Repeat("+", int(stats.Insertions))+strings.Repeat("-", int(stats.Deletions))) + } + fmt.Fprintf(&sb, " %d file(s) changed, %d insertion(s)(+), %d deletion(s)(-)\n\n", + nd.Stat.FilesChanged, nd.Stat.Insertions, nd.Stat.Deletions) + + sb.WriteString(renderUnifiedDiff(nd)) + sb.WriteString("\n--\ntangled.sh\n") + return sb.String() +} diff --git a/appview/repo/rawdiff_test.go b/appview/repo/rawdiff_test.go new file mode 100644 --- /dev/null +++ b/appview/repo/rawdiff_test.go @@ -0,0 +1,284 @@ +package repo + +import ( + "strings" + "testing" + "time" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "tangled.org/core/types" +) + +// parseDiff is a test helper that parses a unified diff string into gitdiff.TextFragment slices. +func parseDiff(t *testing.T, src string) []*gitdiff.File { + t.Helper() + files, _, err := gitdiff.Parse(strings.NewReader(src)) + if err != nil { + t.Fatalf("gitdiff.Parse: %v", err) + } + return files +} + +// niceDiffFromParsed builds a NiceDiff from parsed gitdiff.File entries, mirroring +// what the knotserver does in git.Diff(). +func niceDiffFromParsed(files []*gitdiff.File, commit types.Commit, stat types.DiffStat) *types.NiceDiff { + nd := &types.NiceDiff{Commit: commit, Stat: stat} + for _, f := range files { + d := types.Diff{ + IsBinary: f.IsBinary, + IsNew: f.IsNew, + IsDelete: f.IsDelete, + IsCopy: f.IsCopy, + IsRename: f.IsRename, + } + d.Name.Old = f.OldName + d.Name.New = f.NewName + for _, tf := range f.TextFragments { + d.TextFragments = append(d.TextFragments, *tf) + } + nd.Diff = append(nd.Diff, d) + } + return nd +} + +func TestRenderUnifiedDiff_nil(t *testing.T) { + if got := renderUnifiedDiff(nil); got != "" { + t.Errorf("expected empty string for nil NiceDiff, got %q", got) + } +} + +func TestRenderUnifiedDiff_modified(t *testing.T) { + const src = `diff --git a/foo.go b/foo.go +--- a/foo.go ++++ b/foo.go +@@ -1,3 +1,3 @@ + package main +-// old comment ++// new comment + func main() {} +` + files := parseDiff(t, src) + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) + got := renderUnifiedDiff(nd) + + checks := []string{ + "diff --git a/foo.go b/foo.go\n", + "--- a/foo.go\n", + "+++ b/foo.go\n", + "@@ -1,3 +1,3 @@", + "-// old comment\n", + "+// new comment\n", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) + } + } +} + +func TestRenderUnifiedDiff_newFile(t *testing.T) { + const src = `diff --git a/new.go b/new.go +new file mode 100644 +--- /dev/null ++++ b/new.go +@@ -0,0 +1,2 @@ ++package main ++func main() {} +` + files := parseDiff(t, src) + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) + got := renderUnifiedDiff(nd) + + checks := []string{ + "diff --git a/new.go b/new.go\n", + "new file mode 100644\n", + "--- /dev/null\n", + "+++ b/new.go\n", + "+package main\n", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) + } + } +} + +func TestRenderUnifiedDiff_deletedFile(t *testing.T) { + const src = `diff --git a/old.go b/old.go +deleted file mode 100644 +--- a/old.go ++++ /dev/null +@@ -1,2 +0,0 @@ +-package main +-func main() {} +` + files := parseDiff(t, src) + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) + got := renderUnifiedDiff(nd) + + checks := []string{ + "diff --git a/old.go b/old.go\n", + "deleted file mode 100644\n", + "--- a/old.go\n", + "+++ /dev/null\n", + "-package main\n", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) + } + } +} + +func TestRenderUnifiedDiff_renamedFile(t *testing.T) { + const src = `diff --git a/old.go b/renamed.go +rename from old.go +rename to renamed.go +--- a/old.go ++++ b/renamed.go +@@ -1,2 +1,2 @@ + package main +-func old() {} ++func renamed() {} +` + files := parseDiff(t, src) + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) + got := renderUnifiedDiff(nd) + + checks := []string{ + "diff --git a/old.go b/renamed.go\n", + "rename from old.go\n", + "rename to renamed.go\n", + "--- a/old.go\n", + "+++ b/renamed.go\n", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Errorf("renderUnifiedDiff output missing %q\ngot:\n%s", want, got) + } + } +} + +func TestRenderUnifiedDiff_multipleFiles(t *testing.T) { + const src = `diff --git a/a.go b/a.go +--- a/a.go ++++ b/a.go +@@ -1,1 +1,1 @@ +-old a ++new a +diff --git a/b.go b/b.go +--- a/b.go ++++ b/b.go +@@ -1,1 +1,1 @@ +-old b ++new b +` + files := parseDiff(t, src) + nd := niceDiffFromParsed(files, types.Commit{}, types.DiffStat{}) + got := renderUnifiedDiff(nd) + + for _, want := range []string{"diff --git a/a.go b/a.go", "diff --git a/b.go b/b.go"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in output:\n%s", want, got) + } + } +} + +func TestRenderFormatPatch_nil(t *testing.T) { + if got := renderFormatPatch(nil); got != "" { + t.Errorf("expected empty string for nil NiceDiff, got %q", got) + } +} + +func TestRenderFormatPatch_headers(t *testing.T) { + when := time.Date(2024, 3, 15, 10, 30, 0, 0, time.UTC) + hash := plumbing.NewHash("abc1234567890000000000000000000000000000") + + nd := &types.NiceDiff{ + Commit: types.Commit{ + Hash: hash, + Message: "Fix the bug\n\nThis patch resolves the long-standing issue.\n", + Author: object.Signature{ + Name: "Alice Dev", + Email: "alice@example.com", + When: when, + }, + }, + Stat: types.DiffStat{FilesChanged: 1, Insertions: 2, Deletions: 1}, + } + + got := renderFormatPatch(nd) + + checks := []string{ + "From abc1234567890000000000000000000000000000 Mon Sep 17 00:00:00 2001\n", + "From: Alice Dev \n", + "Date: Fri, 15 Mar 2024 10:30:00 +0000\n", + "Subject: [PATCH] Fix the bug\n", + "This patch resolves the long-standing issue.\n", + "---\n", + " 1 file(s) changed, 2 insertion(s)(+), 1 deletion(s)(-)\n", + "\n--\ntangled.sh\n", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Errorf("renderFormatPatch output missing %q\ngot:\n%s", want, got) + } + } +} + +func TestRenderFormatPatch_subjectOnly(t *testing.T) { + // Single-line message (no body) should not emit a blank body section. + nd := &types.NiceDiff{ + Commit: types.Commit{ + Message: "Single line commit", + Author: object.Signature{When: time.Now()}, + }, + } + got := renderFormatPatch(nd) + + if !strings.Contains(got, "Subject: [PATCH] Single line commit\n") { + t.Errorf("missing subject in output:\n%s", got) + } + // Body should not appear between Subject and "---" + parts := strings.SplitN(got, "---\n", 2) + if len(parts) < 2 { + t.Fatalf("expected '---' separator in output:\n%s", got) + } + beforeSep := parts[0] + // Only the blank line between headers and body should be there, no extra content. + afterSubject := strings.SplitN(beforeSep, "Subject: [PATCH] Single line commit\n", 2) + if len(afterSubject) == 2 && strings.TrimSpace(afterSubject[1]) != "" { + t.Errorf("unexpected body content before '---': %q", afterSubject[1]) + } +} + +func TestRenderFormatPatch_containsDiff(t *testing.T) { + const src = `diff --git a/foo.go b/foo.go +--- a/foo.go ++++ b/foo.go +@@ -1,2 +1,2 @@ + package main +-// old ++// new +` + files := parseDiff(t, src) + nd := niceDiffFromParsed(files, types.Commit{ + Author: object.Signature{When: time.Now()}, + }, types.DiffStat{FilesChanged: 1, Insertions: 1, Deletions: 1}) + + got := renderFormatPatch(nd) + + checks := []string{ + "diff --git a/foo.go b/foo.go\n", + "-// old\n", + "+// new\n", + " foo.go |", + } + for _, want := range checks { + if !strings.Contains(got, want) { + t.Errorf("renderFormatPatch output missing %q\ngot:\n%s", want, got) + } + } +} diff --git a/appview/repo/router.go b/appview/repo/router.go --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -17,6 +17,8 @@ r.Get("/", rp.Index) r.Get("/*", rp.Tree) }) + r.Get("/commit/{ref}.diff", rp.CommitRawDiff) + r.Get("/commit/{ref}.patch", rp.CommitRawPatch) r.Get("/commit/{ref}", rp.Commit) r.Get("/branches", rp.Branches) r.Delete("/branches", rp.DeleteBranch)