Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136package 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/nrpytyzwfunc (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/d8add468cfunc (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 commitsfunc (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("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) } for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", 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 changesfunc (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 <head>..<base> 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 <version>..<version> 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 commitsfunc (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