package thread import ( "context" "fmt" "strconv" "strings" "github.com/barerepo/server/internal/gitx" ) // Anchor is chapter 43's four fields, of which the blob hash recovers what the commenter saw. type Anchor struct { Path string Line int Blob string // the file's blob hash at comment time Side string // old | new } // ParseAnchor reads the "config.go:43" form. func ParseAnchor(s string) (Anchor, bool) { path, num, ok := strings.Cut(s, ":") if !ok { return Anchor{}, false } line, err := strconv.Atoi(num) if err != nil || line <= 0 || !gitx.ValidPath(path) { return Anchor{}, false } return Anchor{Path: path, Line: line}, true } func (a Anchor) String() string { return fmt.Sprintf("%s:%d", a.Path, a.Line) } // Placement is where a comment lands once its file moved on, in chapter 43.3's three outcomes. type Placement string const ( // Exact means the blob is the one the commenter saw. Exact Placement = "exact" // Moved means the line survived at a different number. Moved Placement = "moved" // Outdated keeps the comment and shows the code it meant, never hidden or deleted. 43.4. Outdated Placement = "outdated" // Lost means the retained revision expired, and 43.5 asks this to degrade, not pretend. Lost Placement = "lost" ) // Resolved is an anchor after the file has changed. type Resolved struct { Placement Placement Line int // where to show it now Excerpt string // the line as the commenter saw it } // Resolve maps an anchor forward from its blob hash, because a bare line number retrieves nothing. func Resolve(ctx context.Context, dir string, a Anchor, currentRef string) Resolved { if a.Blob == "" { return Resolved{Placement: Lost, Line: a.Line} } oldBody, err := gitx.Run(ctx, dir, "cat-file", "blob", a.Blob) if err != nil { return Resolved{Placement: Lost, Line: a.Line} } oldLines := strings.Split(strings.TrimSuffix(oldBody, "\n"), "\n") excerpt := "" if a.Line-1 < len(oldLines) { excerpt = oldLines[a.Line-1] } newBlob, err := currentBlob(ctx, dir, currentRef, a.Path) if err != nil { return Resolved{Placement: Outdated, Line: a.Line, Excerpt: excerpt} } if newBlob == a.Blob { return Resolved{Placement: Exact, Line: a.Line, Excerpt: excerpt} } line, ok := mapLine(ctx, dir, a.Blob, newBlob, a.Line) switch { case !ok: return Resolved{Placement: Outdated, Line: a.Line, Excerpt: excerpt} case line == a.Line: return Resolved{Placement: Exact, Line: line, Excerpt: excerpt} default: return Resolved{Placement: Moved, Line: line, Excerpt: excerpt} } } func currentBlob(ctx context.Context, dir, ref, path string) (string, error) { if !gitx.ValidRev(ref) || !gitx.ValidPath(path) { return "", fmt.Errorf("bad ref or path") } out, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref+":"+path) return strings.TrimSpace(out), err } // mapLine walks a line between blobs through their diff, and reports false once it was touched. func mapLine(ctx context.Context, dir, oldBlob, newBlob string, line int) (int, bool) { patch, err := gitx.Run(ctx, dir, "diff", "--unified=0", "--no-color", oldBlob, newBlob) if err != nil { return 0, false } // A hunk before the line shifts it, and a hunk containing it makes the line outdated. shift := 0 for _, h := range strings.Split(patch, "\n") { if !strings.HasPrefix(h, "@@") { continue } oldStart, oldCount, _, newCount, ok := parseHunkHeader(h) if !ok { continue } switch { case oldCount == 0: // git writes @@ -2,0 +3,2 @@ for lines put after old line 2, so line 2 stays put. if oldStart < line { shift += newCount } case oldStart+oldCount <= line: shift += (newCount - oldCount) case oldStart <= line: // The line is inside this hunk's removed range. return 0, false } } return line + shift, true } // parseHunkHeader reads "@@ -a,b +c,d @@". func parseHunkHeader(h string) (oldStart, oldCount, newStart, newCount int, ok bool) { body := strings.TrimPrefix(h, "@@ ") if i := strings.Index(body, " @@"); i >= 0 { body = body[:i] } oldPart, newPart, found := strings.Cut(body, " ") if !found { return 0, 0, 0, 0, false } oldStart, oldCount, ok1 := parseRange(strings.TrimPrefix(oldPart, "-")) newStart, newCount, ok2 := parseRange(strings.TrimPrefix(newPart, "+")) return oldStart, oldCount, newStart, newCount, ok1 && ok2 } func parseRange(s string) (start, count int, ok bool) { startText, countText, found := strings.Cut(s, ",") start, err := strconv.Atoi(startText) if err != nil { return 0, 0, false } count = 1 if found { count, err = strconv.Atoi(countText) if err != nil { return 0, 0, false } } return start, count, true }