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