package pulls
import (
"context"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/lex/util"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
"golang.org/x/sync/errgroup"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
"tangled.org/core/types"
)
// NOTE: parsing object in middleware is bad pattern
// you will have to check if object exist in context "just in case"
// so it's better to make helper function that can read the url pattern instead.
// A -- B -- C
// (master) (pr/123/0)
//
// A -- B -- C
// \ (pr/123/0)
// `-- D <- B' <- C'
// (master) (pr/123/1)
// 1. rebase B<-C to D
// 2. compare tree of C and D
var pull = &models.Pull2{
ID: 123,
RepoDid: syntax.DID("did:plc:ofcpzigpnwrgtpl3ojrpg3y7"),
AuthorDid: syntax.DID("did:example:alice"),
Rkey: syntax.RecordKey("pr-rkey"),
Title: "Spindle-owned data",
Body: "something something",
TargetBranch: "master",
Versions: []models.PullVersion{
{
SourceRepo: syntax.DID("did:plc:ofcpzigpnwrgtpl3ojrpg3y7"),
Head: "262439d43001b6e258077d933c6f5a39b9f63abf",
Base: "71588c938e39599b17f7cf879e10644816b8a7f6",
},
},
Created: time.Now(),
}
var comments = []*models.Comment{
{
Did: syntax.DID("did:example:alice"),
Collection: syntax.NSID(tangled.FeedCommentNSID),
Rkey: syntax.RecordKey("comment"),
Cid: syntax.CID(""),
Subject: atproto.RepoStrongRef{
Uri: "at://did:example:alice/sh.tangled.repo.pull/pr-rkey",
Cid: "",
},
Body: tangled.MarkupMarkdown{
Text: "review comment",
},
Created: time.Now(),
},
}
// PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change}
//
// Examples:
// - /pulls/123/0..2/all
// - /pulls/123/0..2/nrpytyzw
func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) {
l := s.logger.With("handler", "PullInterDiff")
ctx := r.Context()
_ = l
var (
version1 = 0
version2 = 0
changeId = chi.URLParam(r, "*")
)
if changeId == "all" {
changeId = ""
}
// defer render
var params pages.PullInterdiffParams
params.Pull = pull
params.Version1 = version1
params.Version2 = version2
params.ChangeId = changeId
defer s.pages.PullInterdiff(w, params)
// 1. resolve target branch -> (branch, commit)
xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String())
if err != nil {
panic("unimplemented")
}
base := branch.Hash
head1 := "" // pull.Versions[version1].Head
head2 := "" // pull.Versions[version2].Head
// 1. log commits from base..head1 and base..head2
var commits1, commits2 []types.Commit
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
commits1, err = getTempListCommits(gctx, xrpcc, pull.LatestVersion().SourceRepo, base, head1)
return err
})
g.Go(func() error {
commits2, err = getTempListCommits(gctx, xrpcc, pull.LatestVersion().SourceRepo, base, head2)
return err
})
if err := g.Wait(); err != nil {
params.ErrorMsg = "something something"
panic("unimplemented")
}
if changeId != "" {
// interdiff by change-id
var old, new *types.Commit
for _, commit := range commits1 {
if commit.ChangeId == changeId {
old = &commit
break
}
}
for _, commit := range commits2 {
if commit.ChangeId == changeId {
new = &commit
break
}
}
_, _ = old, new
panic("unimplemented")
} else {
// interdiff of two commit ranges
panic("unimplemented")
}
}
// PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit}
//
// Examples:
// - /pulls/123/latest
// - /pulls/123/2/head
// - /pulls/123/2/base..head
// - /pulls/123/2/a53ab251e..d8add468c
// - /pulls/123/2/d8add468c
func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) {
l := s.logger.With("handler", "PullDiff")
ctx := r.Context()
_ = l
var err error
var version int
var versionRaw = chi.URLParam(r, "version")
if versionRaw == "latest" {
version = pull.LatestVersionId()
} else {
version, err = strconv.Atoi(versionRaw)
if err != nil {
// invalid version number. redirect
http.Redirect(w, r,
fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.ID),
http.StatusSeeOther,
)
return
}
}
var range_ = chi.URLParam(r, "changeId")
base, head, err := parseRevRange(range_)
if err != nil {
http.Redirect(w, r,
fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.ID, versionRaw),
http.StatusSeeOther,
)
return
}
// defer render
var params pages.PullDiffParams
params.Pull = pull
params.Version = version
params.Comments = comments
defer s.pages.PullDiff(w, params)
// 1. resolve target branch -> (branch, commit)
xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String())
if err != nil {
l.Warn("Failed to resolve target branch", "branch", pull.TargetBranch, "err", err)
params.ErrorMsg = fmt.Sprintf("Failed to resolve target branch %q", pull.TargetBranch)
return
}
if base == "base" {
base = branch.Hash
}
if head == "head" {
head = pull.LatestVersion().Head
}
sourceRepoDid := pull.LatestVersion().SourceRepo
// 2. list diverged commits using knotmirror (BASE..HEAD) -> ([]commit)
// - knotmirror needs on-demand fetch implementation for this
commits, err := getTempListCommits(ctx, xrpcc, sourceRepoDid, base, head)
if err != nil {
panic("unimplemented")
}
// 3. list every commits in UI. They will be lazy-loaded
params.Commits = commits
}
// fileDiff pairs a streamed FileDiff with the line content of its base/head blobs.
type fileDiff struct {
diff *gitmirrorv1.FileDiff
baseLines []template.HTML // lines of the base (lhs) blob; nil for binary/submodule/absent
headLines []template.HTML // lines of the head (rhs) blob; nil for binary/submodule/absent
}
// htmx fragment. render diff between commits
func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) {
var (
// repoRaw = r.URL.Query().Get("repo")
base = r.URL.Query().Get("base") // base commit ID
head = r.URL.Query().Get("head") // head commit ID
unified = r.URL.Query().Get("view") == "unified"
)
// repo, err := syntax.ParseDID(repoRaw)
// if err != nil {
// http.Error(w, fmt.Sprintf("invalid repo DID: %q", repoRaw), http.StatusBadRequest)
// return
// }
repo, err := s.repoResolver.Resolve(r)
if err != nil {
panic("unimplemented")
}
l := s.logger
ctx := r.Context()
_ = l
var params pages.PullDiffFragmentParams
defer func() {
s.pages.PullDiffFragment(w, params)
}()
// a. drain the diff stream into one fileDiff per changed file.
stream, err := s.gitmirror.Diff(ctx, &gitmirrorv1.DiffRequest{
Repo: repo.RepoDid,
Base: base,
Head: head,
})
if err != nil {
panic(err)
}
var files []*fileDiff
for {
fd, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
panic(err)
}
files = append(files, &fileDiff{diff: fd})
}
// b. fetch each file's base/head blob in parallel and split into lines.
g, gctx := errgroup.WithContext(ctx)
for _, f := range files {
g.Go(func() error {
lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc()
// Binary/submodule files have no line content to fetch.
if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) {
return nil
}
baseBlob, err := s.getBlob(gctx, repo.RepoDid, lhs.GetOid())
if err != nil {
return err
}
headBlob, err := s.getBlob(gctx, repo.RepoDid, rhs.GetOid())
if err != nil {
return err
}
// TODO: highlight each blobs
// pass lhs_positions & rhs_positions so highlighter can apply diff highlights
for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") {
f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("
%s
", template.HTMLEscapeString(line))))
}
for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") {
f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("%s
", template.HTMLEscapeString(line))))
}
// f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n")
// f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n")
return nil
})
}
if err := g.Wait(); err != nil {
panic(err)
}
// c. build the render model, following difftastic's display layout
// (github.com/Wilfred/difftastic src/display): side-by-side by default, inline when
// ?view=unified. All markup/styling lives in the template; here we only assemble data.
params.Split = !unified
for _, f := range files {
df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()}
if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) {
df.Note = "binary or submodule"
params.Files = append(params.Files, df)
continue
}
for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) {
var dh pages.DiffHunk
if unified {
dh.Lines = buildInlineLines(h, f.baseLines, f.headLines)
} else {
dh.Rows = buildSplitRows(h, f.baseLines, f.headLines)
}
df.Hunks = append(df.Hunks, dh)
}
params.Files = append(params.Files, df)
}
}
// numContextLines is difftastic's default (`num_context_lines`); pad_before/pad_after add
// one extra (n+1), which is what we display and use as the hunk-merge window.
const numContextLines = 3
// linePair is one aligned display line: 0-based line numbers into the base (lhs) and head
// (rhs) blobs; -1 means that side has no line here (a blank/padding cell).
type linePair struct {
lhs int
rhs int
}
// diffRow is one aligned display line plus whether it is novel (removed/added/modified) or
// unchanged context. -1 on a side means that side has no line here (a blank cell).
type diffRow struct {
lhs int
rhs int
changed bool
}
// displayHunk is one rendered hunk: context + changed rows, in display order.
type displayHunk struct {
rows []diffRow
}
// maxDistance mirrors difftastic's MAX_DISTANCE (hunks.rs): the most unchanged lines that
// may sit between two changes before they are split into separate hunks (before context).
const maxDistance = 4
// buildHunks ports difftastic's src/display pre-processing (hunks.rs + context.rs): it
// regroups the changed line pairs into hunks by per-side line distance, merges hunks whose
// context windows overlap, and expands each with context lines, yielding displayHunks.
//
// gitmirror's Hunk.Lines are difftastic's novel line pairs; we lack its token-level
// MatchedPos, but a line-granular diff's unchanged lines form a 1:1 bijection, which stands
// in for difftastic's opposite_positions map (buildOpposites).
func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk {
oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(baseLines, headLines, hunks)
var flat []linePair
for _, h := range hunks {
for _, lp := range h.Lines {
flat = append(flat, toPair(lp))
}
}
merged := mergeAdjacent(linesToHunks(flat), oppLhs, oppRhs, maxLhs, maxRhs, numContextLines)
// Display rows come from the whole-file aligned list sliced per hunk (difftastic's
// side_by_side: all_matched_lines_filled + matched_lines_indexes_for_hunk). Slicing a
// contiguous list keeps the interior context lines of merged hunks, which reconstructing
// via extractLines/fillBetween drops across a deletion->addition boundary.
pairs, changed := alignFile(baseLines, headLines, hunks)
var out []displayHunk
prevEnd := 0
for _, h := range merged {
lo, hi := indexesForHunk(pairs, h, numContextLines)
if lo < prevEnd {
lo = prevEnd // don't re-emit rows shared with the previous hunk's slice
}
var dh displayHunk
for i := lo; i < hi; i++ {
dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]})
}
out = append(out, dh)
prevEnd = hi
}
return out
}
// alignFile builds the whole-file aligned list (difftastic's all_matched_lines_filled,
// specialized to a line-granular diff): every displayed line as a pair, plus a parallel
// `changed` flag for pairs that came from a gitmirror hunk. Unchanged lines are a 1:1
// bijection, so the two cursors advance together across gaps.
func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) {
li, ri := 0, 0
emitContext := func(n int) {
for k := range n {
pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k})
changed = append(changed, false)
}
li += n
ri += n
}
for _, h := range hunks {
lhsStart, _, ok := hunkStart(h, li, ri)
if !ok {
continue
}
emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri)
for _, lp := range h.Lines {
p := toPair(lp)
if p.lhs >= 0 {
li = p.lhs + 1
}
if p.rhs >= 0 {
ri = p.rhs + 1
}
pairs = append(pairs, p)
changed = append(changed, true)
}
}
// Trailing unchanged tail (both sides advance equally).
for li < len(baseLines) && ri < len(headLines) {
pairs = append(pairs, linePair{lhs: li, rhs: ri})
changed = append(changed, false)
li++
ri++
}
return pairs, changed
}
// indexesForHunk returns the [start,end) slice of the whole-file aligned pairs to display
// for a merged hunk: the span from its smallest to largest novel line, expanded by n context
// lines on each side and clamped (difftastic's matched_lines_indexes_for_hunk).
func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) {
minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1
for _, lp := range hunkLines {
if lp.lhs >= 0 {
if minLhs < 0 {
minLhs = lp.lhs
}
maxLhs = lp.lhs
}
if lp.rhs >= 0 {
if minRhs < 0 {
minRhs = lp.rhs
}
maxRhs = lp.rhs
}
}
smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs}
start = 0
for i, p := range pairs {
if eitherSideEqual(p, smallest) {
start = i
break
}
}
end = len(pairs)
for i := len(pairs) - 1; i >= 0; i-- {
if eitherSideEqual(pairs[i], largest) {
end = i + 1
break
}
}
start = max(0, start-n)
end = min(len(pairs), end+n)
return start, end
}
// eitherSideEqual reports whether a and b share a present line number on the same side
// (difftastic's either_side_equal).
func eitherSideEqual(a, b linePair) bool {
if a.lhs >= 0 && a.lhs == b.lhs {
return true
}
if a.rhs >= 0 && a.rhs == b.rhs {
return true
}
return false
}
// toPair converts a proto LinePair to a linePair, mapping absent sides to -1.
func toPair(lp *gitmirrorv1.LinePair) linePair {
p := linePair{lhs: -1, rhs: -1}
if lp.Lhs != nil {
p.lhs = int(*lp.Lhs)
}
if lp.Rhs != nil {
p.rhs = int(*lp.Rhs)
}
return p
}
// buildOpposites is our stand-in for difftastic's opposite_positions: the 1:1 mapping
// between unchanged base and head lines. It walks the gitmirror hunks with two cursors —
// unchanged runs advance both sides together — recording each unchanged line's counterpart.
// maxLhs/maxRhs are the 0-based last line numbers of each blob.
func buildOpposites(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (oppLhs, oppRhs map[int]int, maxLhs, maxRhs int) {
oppLhs, oppRhs = map[int]int{}, map[int]int{}
li, ri := 0, 0
pair := func(gap int) {
for k := range gap {
oppLhs[li+k] = ri + k
oppRhs[ri+k] = li + k
}
li += gap
ri += gap
}
for _, h := range hunks {
lhsStart, _, ok := hunkStart(h, li, ri)
if !ok {
continue
}
pair(lhsStart - li) // unchanged gap before this change
for _, lp := range h.Lines {
if lp.Lhs != nil {
li = int(*lp.Lhs) + 1
}
if lp.Rhs != nil {
ri = int(*lp.Rhs) + 1
}
}
}
// Trailing unchanged tail (both sides advance equally).
for li < len(baseLines) && ri < len(headLines) {
oppLhs[li] = ri
oppRhs[ri] = li
li++
ri++
}
return oppLhs, oppRhs, len(baseLines) - 1, len(headLines) - 1
}
// hunkStart returns the first changed line number on each side, deriving the empty side
// from the cursors (unchanged lines advance both sides equally). ok is false for an
// empty hunk.
func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) {
lhsStart, rhsStart = -1, -1
for _, lp := range h.Lines {
if lp.Lhs != nil && lhsStart < 0 {
lhsStart = int(*lp.Lhs)
}
if lp.Rhs != nil && rhsStart < 0 {
rhsStart = int(*lp.Rhs)
}
}
switch {
case lhsStart < 0 && rhsStart < 0:
return 0, 0, false
case lhsStart < 0: // pure insertion
lhsStart = li + (rhsStart - ri)
case rhsStart < 0: // pure deletion
rhsStart = ri + (lhsStart - li)
}
return lhsStart, rhsStart, true
}
// zipPadShorter pairs a[i] with b[i], padding the shorter side with -1 (difftastic's
// zip_pad_shorter).
func zipPadShorter(a, b []int) []linePair {
out := make([]linePair, 0, max(len(a), len(b)))
for i := 0; i < len(a) || i < len(b); i++ {
p := linePair{lhs: -1, rhs: -1}
if i < len(a) {
p.lhs = a[i]
}
if i < len(b) {
p.rhs = b[i]
}
out = append(out, p)
}
return out
}
// fillBetween returns the unchanged line pairs strictly between two changed pairs, so a
// hunk's lines become contiguous per side (difftastic's fill_between).
func fillBetween(prevLhs, nextLhs, prevRhs, nextRhs int) []linePair {
var lhs, rhs []int
if prevLhs >= 0 && nextLhs >= 0 {
for x := prevLhs + 1; x < nextLhs; x++ {
lhs = append(lhs, x)
}
}
if prevRhs >= 0 && nextRhs >= 0 {
for x := prevRhs + 1; x < nextRhs; x++ {
rhs = append(rhs, x)
}
}
return zipPadShorter(lhs, rhs)
}
// extractLines fills the interior gaps of a hunk's line pairs (difftastic's extract_lines).
func extractLines(lines []linePair) []linePair {
maxLhs, maxRhs := -1, -1
var out []linePair
for _, lp := range lines {
out = append(out, fillBetween(maxLhs, lp.lhs, maxRhs, lp.rhs)...)
if lp.lhs >= 0 {
maxLhs = lp.lhs
}
if lp.rhs >= 0 {
maxRhs = lp.rhs
}
out = append(out, lp)
}
return out
}
// padBefore returns up to numContextLines+1 line numbers immediately before ln, clamped at
// 0, ascending (difftastic's pad_before). The extra line lets immediately-adjacent hunks
// merge.
func padBefore(ln, n int) []int {
var out []int
cur := ln
for i := 0; i < n+1; i++ {
if cur == 0 {
break
}
cur--
out = append(out, cur)
}
slices.Reverse(out)
return out
}
// padAfter returns up to numContextLines+1 line numbers immediately after ln, clamped at
// maxLine (difftastic's pad_after).
func padAfter(ln, maxLine, n int) []int {
var out []int
cur := ln
for i := 0; i < n+1; i++ {
if cur >= maxLine {
break
}
cur++
out = append(out, cur)
}
return out
}
// beforeWithOpposites assigns each before-context line its counterpart on the opposite
// side, seeding from the opposite map then walking backwards (difftastic's
// before_with_opposites). Input lines are ascending; result pairs are (line, opposite).
func beforeWithOpposites(before []int, opp map[int]int) []linePair {
lines := slices.Clone(before)
slices.Reverse(lines)
prevOpp := -1
var res []linePair
for _, line := range lines {
cur := -1
if prevOpp >= 0 {
if prevOpp > 0 {
cur = prevOpp - 1
}
} else if v, ok := opp[line]; ok {
cur = v
}
res = append(res, linePair{lhs: line, rhs: cur})
if cur >= 0 {
prevOpp = cur
}
}
slices.Reverse(res)
return res
}
// afterWithOpposites is the forward counterpart of beforeWithOpposites (difftastic's
// after_with_opposites): opposites walk forward and stop at maxOpp; the first (map-seeded)
// opposite is dropped if it is not past prevMaxOpp (-1 = none).
func afterWithOpposites(after []int, opp map[int]int, prevMaxOpp, maxOpp int) []linePair {
prevOpp := -1
var res []linePair
for _, line := range after {
cur := -1
if prevOpp >= 0 {
if prevOpp < maxOpp {
cur = prevOpp + 1
}
} else if v, ok := opp[line]; ok {
if prevMaxOpp < 0 || v > prevMaxOpp {
cur = v
}
}
res = append(res, linePair{lhs: line, rhs: cur})
if cur >= 0 {
prevOpp = cur
}
}
return res
}
// flipPairs swaps the lhs/rhs of each pair (difftastic's flip_tuples).
func flipPairs(pairs []linePair) []linePair {
out := make([]linePair, len(pairs))
for i, p := range pairs {
out[i] = linePair{lhs: p.rhs, rhs: p.lhs}
}
return out
}
// calculateBeforeContext returns the context pairs preceding a hunk, anchored to whichever
// side the first line has (difftastic's calculate_before_context).
func calculateBeforeContext(lines []linePair, oppLhs, oppRhs map[int]int, n int) []linePair {
if len(lines) == 0 {
return nil
}
first := lines[0]
switch {
case first.lhs >= 0:
return beforeWithOpposites(padBefore(first.lhs, n), oppLhs)
case first.rhs >= 0:
return flipPairs(beforeWithOpposites(padBefore(first.rhs, n), oppRhs))
}
return nil
}
// calculateAfterContext returns the context pairs following a hunk (difftastic's
// calculate_after_context). maxLhs/maxRhs are the blobs' last line numbers.
func calculateAfterContext(lines []linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) []linePair {
if len(lines) == 0 {
return nil
}
last := lines[len(lines)-1]
switch {
case last.lhs >= 0:
maxOpp := -1
for _, lp := range lines {
if lp.rhs >= 0 {
maxOpp = lp.rhs
}
}
return afterWithOpposites(padAfter(last.lhs, maxLhs, n), oppLhs, maxOpp, maxRhs)
case last.rhs >= 0:
maxOpp := -1
for _, lp := range lines {
if lp.lhs >= 0 {
maxOpp = lp.lhs
}
}
return flipPairs(afterWithOpposites(padAfter(last.rhs, maxRhs, n), oppRhs, maxOpp, maxLhs))
}
return nil
}
// addContext prepends before-context and appends after-context to a hunk's lines
// (difftastic's add_context).
func addContext(lines []linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) []linePair {
before := calculateBeforeContext(lines, oppLhs, oppRhs, n)
after := calculateAfterContext(append(slices.Clone(before), lines...), oppLhs, oppRhs, maxLhs, maxRhs, n)
out := make([]linePair, 0, len(before)+len(lines)+len(after))
out = append(out, before...)
out = append(out, lines...)
out = append(out, after...)
return out
}
// enforceIncreasing drops any line number that would go backwards, keeping each side
// monotonically increasing (difftastic's enforce_increasing).
func enforceIncreasing(lines []linePair) []linePair {
var out []linePair
maxLhs, maxRhs := -1, -1
for _, lp := range lines {
l, r := lp.lhs, lp.rhs
if maxLhs < 0 {
maxLhs = l
} else if l >= 0 && l > maxLhs {
maxLhs = l
} else {
l = -1
}
if maxRhs < 0 {
maxRhs = r
} else if r >= 0 && r > maxRhs {
maxRhs = r
} else {
r = -1
}
if l >= 0 || r >= 0 {
out = append(out, linePair{lhs: l, rhs: r})
}
}
return out
}
// linesAreClose reports whether a line is within maxDistance of the last seen line on
// either side (difftastic's lines_are_close).
func linesAreClose(maxLhs, maxRhs int, lp linePair) bool {
if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance {
return true
}
if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance {
return true
}
return false
}
// linesToHunks splits changed line pairs into hunks by per-side proximity (difftastic's
// lines_to_hunks over matched novel lines).
func linesToHunks(flat []linePair) [][]linePair {
var hunks [][]linePair
var cur []linePair
maxLhs, maxRhs := -1, -1
for _, lp := range enforceIncreasing(flat) {
if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) {
cur = append(cur, lp)
} else {
hunks = append(hunks, cur)
cur = []linePair{lp}
}
if lp.lhs >= 0 {
maxLhs = lp.lhs
}
if lp.rhs >= 0 {
maxRhs = lp.rhs
}
}
if len(cur) > 0 {
hunks = append(hunks, cur)
}
return hunks
}
// mergeAdjacent merges hunks whose context-padded line sets overlap on either side
// (difftastic's merge_adjacent). Merging concatenates and de-duplicates lines.
func mergeAdjacent(hunks [][]linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) [][]linePair {
var merged [][]linePair
var prev []linePair
var prevLhs, prevRhs map[int]struct{}
for _, h := range hunks {
lhsSet, rhsSet := lineSets(addContext(extractLines(h), oppLhs, oppRhs, maxLhs, maxRhs, n))
if prev == nil {
prev, prevLhs, prevRhs = h, lhsSet, rhsSet
continue
}
if disjoint(lhsSet, prevLhs) && disjoint(rhsSet, prevRhs) {
merged = append(merged, prev)
prev, prevLhs, prevRhs = h, lhsSet, rhsSet
} else {
prev = mergeHunkLines(prev, h)
unionInto(prevLhs, lhsSet)
unionInto(prevRhs, rhsSet)
}
}
if prev != nil {
merged = append(merged, prev)
}
return merged
}
// mergeHunkLines concatenates two hunks' lines and drops duplicate line numbers per side,
// nulling the already-seen side (difftastic's Hunk::merge).
func mergeHunkLines(a, b []linePair) []linePair {
lhsSeen, rhsSeen := map[int]struct{}{}, map[int]struct{}{}
var out []linePair
for _, lp := range append(slices.Clone(a), b...) {
lhsDupe, rhsDupe := false, false
if lp.lhs >= 0 {
_, lhsDupe = lhsSeen[lp.lhs]
lhsSeen[lp.lhs] = struct{}{}
}
if lp.rhs >= 0 {
_, rhsDupe = rhsSeen[lp.rhs]
rhsSeen[lp.rhs] = struct{}{}
}
if lhsDupe && rhsDupe {
continue
}
np := lp
if lhsDupe {
np.lhs = -1
}
if rhsDupe {
np.rhs = -1
}
out = append(out, np)
}
return out
}
// lineSets returns the sets of present lhs and rhs line numbers in a run of pairs.
func lineSets(lines []linePair) (lhs, rhs map[int]struct{}) {
lhs, rhs = map[int]struct{}{}, map[int]struct{}{}
for _, lp := range lines {
if lp.lhs >= 0 {
lhs[lp.lhs] = struct{}{}
}
if lp.rhs >= 0 {
rhs[lp.rhs] = struct{}{}
}
}
return
}
func disjoint(a, b map[int]struct{}) bool {
for k := range a {
if _, ok := b[k]; ok {
return false
}
}
return true
}
func unionInto(dst, src map[int]struct{}) {
for k := range src {
dst[k] = struct{}{}
}
}
// dispNum maps a 0-based line number to its 1-based display value, passing through the -1
// absent sentinel unchanged.
func dispNum(n int) int {
if n < 0 {
return -1
}
return n + 1
}
// buildSplitRows turns a hunk into side-by-side rows (difftastic's SideBySideShowBoth): each
// row always has a left (base) and right (head) cell. Unchanged rows show context on both
// sides; a changed row shows a deletion on the left and/or an addition on the right, padding
// the missing side with an empty cell.
func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow {
rows := make([]pages.DiffRow, 0, len(h.rows))
for _, r := range h.rows {
var row pages.DiffRow
if !r.changed {
row.Left = pages.DiffCell{Kind: "ctx", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)}
row.Right = pages.DiffCell{Kind: "ctx", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)}
} else {
if r.lhs >= 0 {
row.Left = pages.DiffCell{Kind: "del", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)}
} else {
row.Left = pages.DiffCell{Kind: "empty", Num: -1}
}
if r.rhs >= 0 {
row.Right = pages.DiffCell{Kind: "add", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)}
} else {
row.Right = pages.DiffCell{Kind: "empty", Num: -1}
}
}
rows = append(rows, row)
}
return rows
}
// buildInlineLines turns a hunk into unified-diff lines: context lines shown once (no
// marker), and each contiguous run of changed rows emitted as all removed (LHS, "-") then
// all added (RHS, "+"). Interior context between merged changes stays a single line. Removed
// lines carry only the old number, added lines only the new number.
func buildInlineLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine {
var out []pages.DiffLine
for i := 0; i < len(h.rows); {
r := h.rows[i]
if !r.changed {
if r.lhs >= 0 {
out = append(out, pages.DiffLine{Op: " ", Old: dispNum(r.lhs), New: dispNum(r.rhs), Content: lineAt(baseLines, r.lhs)})
}
i++
continue
}
j := i
for j < len(h.rows) && h.rows[j].changed {
j++
}
for _, cr := range h.rows[i:j] {
if cr.lhs >= 0 {
out = append(out, pages.DiffLine{Op: "-", Old: dispNum(cr.lhs), New: -1, Content: lineAt(baseLines, cr.lhs)})
}
}
for _, cr := range h.rows[i:j] {
if cr.rhs >= 0 {
out = append(out, pages.DiffLine{Op: "+", Old: -1, New: dispNum(cr.rhs), Content: lineAt(headLines, cr.rhs)})
}
}
i = j
}
return out
}
// lineAt returns lines[n], or "" when n is the -1 absent sentinel or out of range.
func lineAt(lines []template.HTML, n int) template.HTML {
if n < 0 || n >= len(lines) {
return ""
}
return lines[n]
}
// getBlob streams a blob's bytes from gitmirror by OID and concatenates them.
// A null (all-zero) oid — the absent side of an addition/deletion — yields nil.
func (s *Pulls) getBlob(ctx context.Context, repo, oid string) ([]byte, error) {
if isNullOid(oid) {
return nil, nil
}
stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo, Oid: oid})
if err != nil {
return nil, err
}
var buf []byte
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
buf = append(buf, chunk.GetData()...)
}
return buf, nil
}
func isBinaryOrSubmodule(fc *gitmirrorv1.FileContent) bool {
return fc != nil && (fc.GetIsBinary() || fc.GetIsSubmodule())
}
// isNullOid reports whether oid is empty or the all-zero object id.
func isNullOid(oid string) bool {
if oid == "" {
return true
}
for _, c := range oid {
if c != '0' {
return false
}
}
return true
}
// htmx fragment. render interdiff between changes
func (s *Pulls) PullInterdiffFragment(w http.ResponseWriter, r *http.Request) {
// var (
// base1 = r.URL.Query().Get("base1") // base1 commit ID
// base2 = r.URL.Query().Get("base2") // base2 commit ID
// head1 = r.URL.Query().Get("head1") // head1 commit ID
// head2 = r.URL.Query().Get("head2") // head1 commit ID
// unified = r.URL.Query().Get("view") == "unified"
// )
// 1. compute interdiff. (knotmirror)
// 2. return rich diff data. (knotmirror)
// 3. load old/new blobs & run syntax highlight
// 4. render diff
}
// parseRevRange parses .. string.
// base and head will default to "base" and "head" when omitted.
func parseRevRange(range_ string) (base string, head string, err error) {
return "base", "head", nil
// panic("unimplemented")
}
// parseVersionRange parses .. string.
// Each versions will default to "base" and "latest" when omitted.
func parseVersionRange(range_ string) (base string, head string, err error) {
return "base", "latest", nil
}
func getTempListCommits(ctx context.Context, xrpcc util.LexClient, repo syntax.DID, base, head string) ([]types.Commit, error) {
// panic("unimplemented")
return nil, nil
// raw, err := tangled.GitTempListCommits(ctx, xrpcc, "", 1000, head, repo.String())
// if err != nil {
// return nil, err
// }
//
// var xrpcResp types.RepoLogResponse
// if err := json.Unmarshal(raw, &xrpcResp); err != nil {
// return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
// }
//
// return xrpcResp.Commits, nil
}
// htmx fragment. render interdiff between commits
func (s *Pulls) PullInterDiffFragment(w http.ResponseWriter, r *http.Request) {
panic("unimplemented")
}
// gitmirror
// - git.ListCommitsSinceMergeBase(repo, base, head)
// - git.Diff(repo, base, head, mode)
// - git.Interdiff(repo,
// for interdiff, we want: from{start,end}, to{start,end}
// 1. squash from.start ~ from.end into one commit
// 2. rebase that commit to to.start.parent()
// 3. diff from_squashed.tree and to.end.tree
// we want git log BASE..HEAD (only commits in HEAD) diverged=false
// and git diff BASE...HEAD (changes from HEAD since merge-base) absolute=false
// commands.go:56 picks comparison type:
// - diff BASE..HEAD (COMPARISON_TYPE_ONLY_IN_HEAD) = direct
// - diff BASE...HEAD (COMPARISON_TYPE_INTERSECTION) = merge-base. Server resolves merge-base via g.MergeBase() first (diff.go:43) then diffs.
// we want second one. we should compute merge-base first.
// we can have