~bigbes/sr-ht-compare

9473e2606cd90cb8d3b78ee87511c671157439f4 — bigbes 30 days ago 94142bd
gitx: repository access and ref-to-ref diffs on go-git

Implement the git access layer for compare.sr.ht on go-git v5 (no runtime
git binary). Public surface: Open (owner/name validated via core, bare-repo
HEAD check, ErrNotFound on any miss); Refs/DefaultBranch; ResolveCommit/Log/
Parents; Diff/RawDiff/DiffStat/MergeBase/CommitPatch.

- Diffs route through DiffTreeWithOptions(DetectRenames) so old/new tree order
  is explicit: three-dot uses the merge base as old side, two-dot uses base;
  CommitPatch diffs a commit against its parent (root vs empty tree, merge vs
  first parent, ParentSHAs exposed for the banner).
- Patch text is generated in memory then capped (5 MiB page, 50 MiB raw,
  injectable override for tests) and cut at a "diff --git" file boundary so the
  browser parser never sees a torn hunk; ctx timeout (10s) guards runaways.
- FileChange status/counts/binary derived purely from go-git FilePatches
  (mapFilePatches); Log excludes base's full reachable set for correct
  base..head semantics.

Tests build fixtures by driving the real git CLI in t.TempDir(). Fidelity gate
(TestPatchFidelity) confirms go-git emits standard git headers — diff --git,
index, rename from/to, "Binary files ... differ", @@ hunks — parseable by the
frontend parsePatchFiles(). 82% coverage.

core validators reject ^/~ so navigation revs are resolved to SHAs in tests.
A gitx/diff.go => gitx/diff.go +259 -0
@@ 0,0 1,259 @@
package gitx

import (
	"context"
	"fmt"

	"github.com/go-git/go-git/v5/plumbing/format/diff"
	"github.com/go-git/go-git/v5/plumbing/object"

	"go.bigb.es/sourcehut-compare/core"
)

// Patch is a rendered unified diff. Truncated is set when the generated text
// exceeded the byte cap and was cut at a file boundary; callers should then
// fall back to the file list plus a link to the raw .patch.
type Patch struct {
	Text      string
	Truncated bool
}

// FileChange is one entry in a diff's file list. Status is the single-letter
// git class: A(dded), M(odified), D(eleted), R(enamed). (go-git's tree diff
// does not surface copy or type-change classes, so C and T never appear.)
// OldPath is set only for renames. Binary is true when the change is binary, in
// which case Additions and Deletions are zero.
type FileChange struct {
	Path      string
	OldPath   string
	Status    string
	Additions int
	Deletions int
	Binary    bool
}

// validateSpec checks both endpoints of a compare spec.
func validateSpec(spec core.CompareSpec) error {
	if !core.ValidRef(spec.Base) {
		return fmt.Errorf("%w: invalid base %q", core.ErrBadRef, spec.Base)
	}
	if !core.ValidRef(spec.Head) {
		return fmt.Errorf("%w: invalid head %q", core.ErrBadRef, spec.Head)
	}
	return nil
}

// specChanges resolves a compare spec to the ordered tree changes. For a
// three-dot spec the old side is the merge base of base and head; for two-dot
// it is base directly. The new side is always head.
func (r *Repo) specChanges(ctx context.Context, spec core.CompareSpec) (object.Changes, error) {
	if err := validateSpec(spec); err != nil {
		return nil, err
	}
	baseCommit, err := r.resolveCommit(spec.Base)
	if err != nil {
		return nil, err
	}
	headCommit, err := r.resolveCommit(spec.Head)
	if err != nil {
		return nil, err
	}

	oldCommit := baseCommit
	if spec.ThreeDot {
		bases, err := baseCommit.MergeBase(headCommit)
		if err != nil {
			return nil, err
		}
		if len(bases) == 0 {
			return nil, fmt.Errorf("%w: no merge base for %s...%s", core.ErrBadRef, spec.Base, spec.Head)
		}
		oldCommit = bases[0]
	}

	oldTree, err := oldCommit.Tree()
	if err != nil {
		return nil, err
	}
	newTree, err := headCommit.Tree()
	if err != nil {
		return nil, err
	}
	return object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
}

// Diff renders the unified diff for a compare spec, capped at the in-page byte
// limit (Truncated set and the text cut at a file boundary on overflow).
func (r *Repo) Diff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
	return r.diffPatch(ctx, spec, r.diffLimit(pageDiffLimit))
}

// RawDiff is Diff with the larger .patch-download byte cap.
func (r *Repo) RawDiff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
	return r.diffPatch(ctx, spec, r.diffLimit(rawDiffLimit))
}

func (r *Repo) diffPatch(ctx context.Context, spec core.CompareSpec, limit int64) (*Patch, error) {
	ctx, cancel := r.withTimeout(ctx)
	defer cancel()

	changes, err := r.specChanges(ctx, spec)
	if err != nil {
		return nil, err
	}
	return renderPatch(ctx, changes, limit)
}

// DiffStat returns the per-file change list for a compare spec.
func (r *Repo) DiffStat(ctx context.Context, spec core.CompareSpec) ([]FileChange, error) {
	ctx, cancel := r.withTimeout(ctx)
	defer cancel()

	changes, err := r.specChanges(ctx, spec)
	if err != nil {
		return nil, err
	}
	patch, err := changes.PatchContext(ctx)
	if err != nil {
		return nil, err
	}
	return mapFilePatches(patch.FilePatches()), nil
}

// MergeBase returns the SHA of the best common ancestor of two revisions.
func (r *Repo) MergeBase(ctx context.Context, a, b string) (string, error) {
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	ca, err := r.resolveCommit(a)
	if err != nil {
		return "", err
	}
	cb, err := r.resolveCommit(b)
	if err != nil {
		return "", err
	}
	bases, err := ca.MergeBase(cb)
	if err != nil {
		return "", err
	}
	if len(bases) == 0 {
		return "", fmt.Errorf("%w: no merge base for %s and %s", core.ErrNotFound, a, b)
	}
	return bases[0].Hash.String(), nil
}

// CommitPatch renders a single commit as a diff plus its file list and
// metadata. A non-merge commit is diffed against its parent (a root commit
// against the empty tree). A merge commit is diffed against its first parent —
// the conventional, reviewable single-parent view; callers detect the merge via
// len(CommitInfo.ParentSHAs) > 1 to show a banner.
func (r *Repo) CommitPatch(ctx context.Context, rev string) (*Patch, []FileChange, *CommitInfo, error) {
	ctx, cancel := r.withTimeout(ctx)
	defer cancel()

	c, err := r.resolveCommit(rev)
	if err != nil {
		return nil, nil, nil, err
	}

	var oldTree *object.Tree
	if c.NumParents() > 0 {
		parent, err := c.Parent(0)
		if err != nil {
			return nil, nil, nil, err
		}
		oldTree, err = parent.Tree()
		if err != nil {
			return nil, nil, nil, err
		}
	}
	newTree, err := c.Tree()
	if err != nil {
		return nil, nil, nil, err
	}

	changes, err := object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
	if err != nil {
		return nil, nil, nil, err
	}
	patch, err := changes.PatchContext(ctx)
	if err != nil {
		return nil, nil, nil, err
	}
	text, truncated := cutPatch(patch.String(), r.diffLimit(pageDiffLimit))
	files := mapFilePatches(patch.FilePatches())
	return &Patch{Text: text, Truncated: truncated}, files, commitInfo(c), nil
}

// renderPatch turns tree changes into a size-capped Patch.
func renderPatch(ctx context.Context, changes object.Changes, limit int64) (*Patch, error) {
	patch, err := changes.PatchContext(ctx)
	if err != nil {
		return nil, err
	}
	text, truncated := cutPatch(patch.String(), limit)
	return &Patch{Text: text, Truncated: truncated}, nil
}

// mapFilePatches converts go-git file patches into the package's FileChange
// list. It is a pure function of the FilePatch slice so it can be unit-tested
// with synthetic patches. Status is derived from the from/to file pair; line
// counts are tallied from the chunks (skipped for binary files).
func mapFilePatches(fps []diff.FilePatch) []FileChange {
	var out []FileChange
	for _, fp := range fps {
		from, to := fp.Files()
		if from == nil && to == nil {
			continue
		}
		fc := FileChange{Binary: fp.IsBinary()}
		switch {
		case from == nil:
			fc.Status = "A"
			fc.Path = to.Path()
		case to == nil:
			fc.Status = "D"
			fc.Path = from.Path()
		case from.Path() != to.Path():
			fc.Status = "R"
			fc.OldPath = from.Path()
			fc.Path = to.Path()
		default:
			fc.Status = "M"
			fc.Path = from.Path()
		}
		if !fc.Binary {
			for _, ch := range fp.Chunks() {
				s := ch.Content()
				if s == "" {
					continue
				}
				n := countLines(s)
				switch ch.Type() {
				case diff.Add:
					fc.Additions += n
				case diff.Delete:
					fc.Deletions += n
				}
			}
		}
		out = append(out, fc)
	}
	return out
}

// countLines counts the lines in a chunk's content, counting a final
// unterminated line as one line (matching git's stat behaviour).
func countLines(s string) int {
	n := 0
	for i := 0; i < len(s); i++ {
		if s[i] == '\n' {
			n++
		}
	}
	if len(s) > 0 && s[len(s)-1] != '\n' {
		n++
	}
	return n
}

A gitx/fidelity_test.go => gitx/fidelity_test.go +125 -0
@@ 0,0 1,125 @@
package gitx

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"go.bigb.es/sourcehut-compare/core"
)

// newFidelityRepo builds a two-commit bare repo whose base..head diff exercises
// (a) a rename WITH an edit, (b) a binary file change, and (c) a plain text
// edit. It returns reposRoot plus the base and head SHAs.
func newFidelityRepo(t *testing.T) (reposRoot, base, head string) {
	t.Helper()
	reposRoot = t.TempDir()
	work := t.TempDir()

	d1 := "2024-02-01T00:00:00Z"
	d2 := "2024-02-02T00:00:00Z"

	gitTest(t, work, d1, "init", "-b", "main")

	// Base commit.
	writeFile(t, work, "a.txt", []byte("line1\nline2\nline3\nline4\n"))
	writeFile(t, work, "old.txt", []byte("one\ntwo\nthree\nfour\nfive\nsix\n"))
	writeFile(t, work, "img.bin", []byte{0x00, 0x01, 0x02, 0x03, 0x00, 0xff})
	gitTest(t, work, d1, "add", "-A")
	gitTest(t, work, d1, "commit", "-m", "base")

	// Head commit: plain edit, rename+edit, binary change.
	writeFile(t, work, "a.txt", []byte("line1\nline2-edited\nline3\nline4\n"))
	gitTest(t, work, d2, "mv", "old.txt", "new.txt")
	writeFile(t, work, "new.txt", []byte("one\ntwo\nthree-edited\nfour\nfive\nsix\n"))
	writeFile(t, work, "img.bin", []byte{0xff, 0xfe, 0x00, 0x11, 0x22, 0x33})
	gitTest(t, work, d2, "add", "-A")
	gitTest(t, work, d2, "commit", "-m", "head: rename+edit, binary change, plain edit")

	if err := os.MkdirAll(filepath.Join(reposRoot, "~"+fxOwner), 0o755); err != nil {
		t.Fatal(err)
	}
	bare := filepath.Join(reposRoot, "~"+fxOwner, "fidelity")
	gitTest(t, work, d2, "clone", "--bare", work, bare)

	base = fxRevIn(t, reposRoot, "fidelity", "main~1")
	head = fxRevIn(t, reposRoot, "fidelity", "main")
	return reposRoot, base, head
}

// fxRevIn is fxRev for an arbitrary repo name under fxOwner.
func fxRevIn(t *testing.T, reposRoot, name, rev string) string {
	t.Helper()
	bare := filepath.Join(reposRoot, "~"+fxOwner, name)
	cmd := exec.Command("git", "-C", bare, "rev-parse", rev)
	cmd.Env = append(os.Environ(),
		"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "LC_ALL=C")
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("fxRevIn %q: %v\n%s", rev, err, out)
	}
	return strings.TrimSpace(string(out))
}

// TestPatchFidelity is the compatibility gate for the browser-side
// parsePatchFiles(): go-git's generated patch must carry standard git headers
// for renames, binary files, and text hunks.
func TestPatchFidelity(t *testing.T) {
	reposRoot, base, head := newFidelityRepo(t)
	repo, err := Open(reposRoot, fxOwner, "fidelity")
	if err != nil {
		t.Fatalf("Open: %v", err)
	}

	spec := core.CompareSpec{Base: base, Head: head}
	patch, err := repo.Diff(ctx(), spec)
	if err != nil {
		t.Fatalf("Diff: %v", err)
	}
	text := patch.Text
	t.Logf("generated patch:\n%s", text)

	mustContain := []string{
		// plain edit
		"diff --git a/a.txt b/a.txt",
		"@@",
		"-line2",
		"+line2-edited",
		// rename + edit
		"diff --git a/old.txt b/new.txt",
		"rename from old.txt",
		"rename to new.txt",
		// binary change
		"img.bin",
		"Binary files",
	}
	for _, want := range mustContain {
		if !strings.Contains(text, want) {
			t.Errorf("patch missing %q\n---\n%s", want, text)
		}
	}

	// The rename must be classified as a single R change, not add+delete.
	files, err := repo.DiffStat(ctx(), spec)
	if err != nil {
		t.Fatalf("DiffStat: %v", err)
	}
	byPath := map[string]FileChange{}
	for _, f := range files {
		byPath[f.Path] = f
	}
	if r, ok := byPath["new.txt"]; !ok || r.Status != "R" || r.OldPath != "old.txt" {
		t.Errorf("rename entry = %+v (ok=%v), want R from old.txt", byPath["new.txt"], ok)
	}
	if r := byPath["new.txt"]; r.Additions == 0 && r.Deletions == 0 {
		t.Errorf("rename+edit should report line changes, got %+v", r)
	}
	if b, ok := byPath["img.bin"]; !ok || !b.Binary {
		t.Errorf("img.bin = %+v (ok=%v), want binary", byPath["img.bin"], ok)
	}
	if a, ok := byPath["a.txt"]; !ok || a.Status != "M" {
		t.Errorf("a.txt = %+v (ok=%v), want M", byPath["a.txt"], ok)
	}
}

A gitx/fixture_test.go => gitx/fixture_test.go +150 -0
@@ 0,0 1,150 @@
package gitx

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
)

// The fixture builds a bare repository at <tmp>/~alice/demo with a history rich
// enough to exercise every command:
//
//	root ── c2 ── rename ── binary ─┐
//	          \                      merge   (branch: main, HEAD)
//	           feature ─────────────┘        (branch: feature/with-slash)
//
//	root    : add a.txt                       (root commit)
//	c2      : add b.txt, edit a.txt
//	rename  : git mv a.txt -> a-renamed.txt   (tag: v0.9.0)
//	binary  : add bin.dat (4 NUL-ish bytes)   (main^1)
//	feature : add feature.txt (from c2)       (branch feature/with-slash, main^2)
//	merge   : merge feature into main         (main, HEAD; tag: v1.0.0)
//
// The commit dates increase monotonically so tag creator-date ordering is
// deterministic (v1.0.0 newer than v0.9.0).

const (
	fxOwner = "alice"
	fxName  = "demo"
)

// gitTest runs git in dir with a hardened, deterministic environment, failing
// the test on error.
func gitTest(t *testing.T, dir string, date string, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(),
		"GIT_CONFIG_GLOBAL=/dev/null",
		"GIT_CONFIG_SYSTEM=/dev/null",
		"GIT_TERMINAL_PROMPT=0",
		"LC_ALL=C",
		"GIT_AUTHOR_NAME=Alice Example",
		"GIT_AUTHOR_EMAIL=alice@example.com",
		"GIT_COMMITTER_NAME=Alice Example",
		"GIT_COMMITTER_EMAIL=alice@example.com",
		"GIT_AUTHOR_DATE="+date,
		"GIT_COMMITTER_DATE="+date,
	)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return string(out)
}

func writeFile(t *testing.T, dir, name string, data []byte) {
	t.Helper()
	p := filepath.Join(dir, name)
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(p, data, 0o644); err != nil {
		t.Fatal(err)
	}
}

// newFixtureRepo builds the bare fixture repository and returns reposRoot (the
// directory that Open takes). The bare repo lives at reposRoot/~alice/demo.
func newFixtureRepo(t *testing.T) string {
	t.Helper()
	if _, err := exec.LookPath("git"); err != nil {
		t.Skipf("git not available: %v", err)
	}

	reposRoot := t.TempDir()
	work := t.TempDir()

	d1 := "2024-01-01T00:00:00Z"
	d2 := "2024-01-02T00:00:00Z"
	d3 := "2024-01-03T00:00:00Z"
	d4 := "2024-01-04T00:00:00Z"
	d5 := "2024-01-05T00:00:00Z"
	d6 := "2024-01-06T00:00:00Z"

	gitTest(t, work, d1, "init", "-b", "main")

	// root: add a.txt
	writeFile(t, work, "a.txt", []byte("hello\nworld\n"))
	gitTest(t, work, d1, "add", "a.txt")
	gitTest(t, work, d1, "commit", "-m", "root: add a.txt")

	// c2: add b.txt, edit a.txt
	writeFile(t, work, "a.txt", []byte("hello\nworld\nmore\n"))
	writeFile(t, work, "b.txt", []byte("bee\n"))
	gitTest(t, work, d2, "add", "a.txt", "b.txt")
	gitTest(t, work, d2, "commit", "-m", "c2: add b, edit a")

	// feature branch from c2 (before rename/binary): add feature.txt
	gitTest(t, work, d5, "branch", "feature/with-slash")

	// rename: pure git mv a.txt -> a-renamed.txt
	gitTest(t, work, d3, "mv", "a.txt", "a-renamed.txt")
	gitTest(t, work, d3, "commit", "-m", "rename: a.txt -> a-renamed.txt")
	gitTest(t, work, d3, "tag", "-a", "-m", "release 0.9.0", "v0.9.0")

	// binary: add bin.dat (4 bytes, includes NUL)
	writeFile(t, work, "bin.dat", []byte{0x00, 0x01, 0x02, 0x03})
	gitTest(t, work, d4, "add", "bin.dat")
	gitTest(t, work, d4, "commit", "-m", "binary: add bin.dat")

	// build feature tip
	gitTest(t, work, d5, "checkout", "feature/with-slash")
	writeFile(t, work, "feature.txt", []byte("feature\n"))
	gitTest(t, work, d5, "add", "feature.txt")
	gitTest(t, work, d5, "commit", "-m", "feature: add feature.txt")

	// merge feature into main (no-ff so a merge commit is created)
	gitTest(t, work, d6, "checkout", "main")
	gitTest(t, work, d6, "merge", "--no-ff", "-m", "merge feature/with-slash", "feature/with-slash")
	gitTest(t, work, d6, "tag", "-a", "-m", "release 1.0.0", "v1.0.0")

	// bare clone into reposRoot/~alice/demo
	if err := os.MkdirAll(filepath.Join(reposRoot, "~"+fxOwner), 0o755); err != nil {
		t.Fatal(err)
	}
	bare := filepath.Join(reposRoot, "~"+fxOwner, fxName)
	gitTest(t, work, d6, "clone", "--bare", work, bare)

	return reposRoot
}

// fxRev resolves a revision inside the bare fixture repo to its full SHA. It is
// a test helper for landmark navigation (e.g. "main^1~1" for the rename
// commit); it is independent of the code under test.
func fxRev(t *testing.T, reposRoot, rev string) string {
	t.Helper()
	return fxRevIn(t, reposRoot, fxName, rev)
}

// openFixture opens the fixture repo through the code under test.
func openFixture(t *testing.T, reposRoot string) *Repo {
	t.Helper()
	repo, err := Open(reposRoot, fxOwner, fxName)
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	return repo
}

A gitx/gitx.go => gitx/gitx.go +159 -0
@@ 0,0 1,159 @@
// Package gitx is the git access layer of compare.sr.ht. It opens bare
// repositories on disk with go-git and answers ref, log, and diff queries
// entirely in-process (no git binary is executed at runtime). Every operation
// is bounded by a context timeout, generated patch text is capped in size (so
// a pathological diff cannot be streamed unbounded to a browser), and every
// user-controlled revision is validated with core.ValidRef before it reaches
// go-git's revision parser.
package gitx

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing/object"

	"go.bigb.es/sourcehut-compare/core"
)

const (
	// defaultTimeout bounds a single gitx operation.
	defaultTimeout = 10 * time.Second
	// pageDiffLimit caps an in-page rendered diff; overflow sets Truncated.
	pageDiffLimit = 5 << 20 // 5 MiB
	// rawDiffLimit caps a downloadable .patch.
	rawDiffLimit = 50 << 20 // 50 MiB
	// shortSHALen is how many hex chars an abbreviated SHA carries.
	shortSHALen = 8
)

// diffOpts are the go-git tree-diff options used everywhere: rename detection
// on, matching git's default behaviour.
var diffOpts = object.DefaultDiffTreeOptions

// Repo is a handle to a bare git repository on disk. It is safe for concurrent
// use: go-git's object reads are read-only and the struct holds no mutable
// per-request state.
type Repo struct {
	dir  string
	repo *git.Repository

	// limitOverride, when > 0, replaces the diff-family byte cap. It exists so
	// tests can exercise truncation with a tiny cap instead of huge fixtures.
	limitOverride int64

	// timeout overrides defaultTimeout when > 0 (tests may shorten it).
	timeout time.Duration
}

// Open validates owner/name, resolves the bare-repo path under reposRoot,
// verifies it looks like a bare repository (its HEAD file exists), and opens it
// with go-git. Invalid names and missing/broken repositories all yield
// core.ErrNotFound, so repository existence is never leaked and no crafted name
// can escape reposRoot: the validators reject '/', '..' and a leading '-'
// before any path is built.
func Open(reposRoot, owner, name string) (*Repo, error) {
	if !core.ValidOwner(owner) {
		return nil, fmt.Errorf("%w: invalid owner", core.ErrNotFound)
	}
	if !core.ValidRepoName(name) {
		return nil, fmt.Errorf("%w: invalid repo name", core.ErrNotFound)
	}

	dir := filepath.Join(reposRoot, "~"+owner, name)

	// Cheap bare-repo sanity check before handing the path to go-git.
	if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
		return nil, fmt.Errorf("%w: ~%s/%s", core.ErrNotFound, owner, name)
	}

	repo, err := git.PlainOpen(dir)
	if err != nil {
		return nil, fmt.Errorf("%w: ~%s/%s: %v", core.ErrNotFound, owner, name, err)
	}
	return &Repo{dir: dir, repo: repo}, nil
}

// Dir returns the on-disk path of the bare repository.
func (r *Repo) Dir() string { return r.dir }

// withTimeout derives a per-operation timeout context.
func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
	d := r.timeout
	if d <= 0 {
		d = defaultTimeout
	}
	return context.WithTimeout(ctx, d)
}

// diffLimit returns the effective byte cap for a diff-family command, honoring
// a test override.
func (r *Repo) diffLimit(def int64) int64 {
	if r.limitOverride > 0 {
		return r.limitOverride
	}
	return def
}

// badRef wraps a go-git revision-resolution failure as core.ErrBadRef.
func badRef(rev string, err error) error {
	return fmt.Errorf("%w: %q: %v", core.ErrBadRef, rev, err)
}

// commitInfo projects a go-git commit into the package's CommitInfo.
func commitInfo(c *object.Commit) *CommitInfo {
	subject, body := splitMessage(c.Message)
	sha := c.Hash.String()
	short := sha
	if len(short) > shortSHALen {
		short = short[:shortSHALen]
	}
	ci := &CommitInfo{
		SHA:         sha,
		ShortSHA:    short,
		AuthorName:  c.Author.Name,
		AuthorEmail: c.Author.Email,
		Date:        c.Author.When,
		Subject:     subject,
		Body:        body,
	}
	for _, p := range c.ParentHashes {
		ci.ParentSHAs = append(ci.ParentSHAs, p.String())
	}
	return ci
}

// splitMessage splits a commit message into its subject (first line) and body
// (the remainder, with the separating blank line removed).
func splitMessage(msg string) (subject, body string) {
	msg = strings.TrimRight(msg, "\n")
	if i := strings.IndexByte(msg, '\n'); i >= 0 {
		return msg[:i], strings.TrimLeft(msg[i+1:], "\n")
	}
	return msg, ""
}

// cutPatch enforces the size cap on generated patch text. go-git materializes
// the whole patch in memory, so the cap is applied after generation. To keep
// the browser-side parser from ever seeing a torn hunk, the text is cut at a
// file boundary: the last "\ndiff --git " that starts at or before the limit.
// If a single leading file already exceeds the limit there is no earlier
// boundary and the text is cut hard at the limit.
func cutPatch(text string, limit int64) (string, bool) {
	if limit <= 0 || int64(len(text)) <= limit {
		return text, false
	}
	head := text[:limit]
	// A unified-diff file header is the only place "diff --git" starts a line;
	// content lines always carry a +/-/space prefix after the newline. Cutting
	// before the last such header keeps only whole files.
	if idx := strings.LastIndex(head, "\ndiff --git"); idx >= 0 {
		return text[:idx+1], true
	}
	return head, true
}

A gitx/gitx_test.go => gitx/gitx_test.go +387 -0
@@ 0,0 1,387 @@
package gitx

import (
	"context"
	"errors"
	"strings"
	"testing"

	"go.bigb.es/sourcehut-compare/core"
)

func ctx() context.Context { return context.Background() }

func TestOpen(t *testing.T) {
	reposRoot := newFixtureRepo(t)

	t.Run("valid", func(t *testing.T) {
		repo, err := Open(reposRoot, fxOwner, fxName)
		if err != nil {
			t.Fatalf("Open: %v", err)
		}
		if !strings.HasSuffix(repo.Dir(), "~alice/demo") {
			t.Errorf("dir = %q", repo.Dir())
		}
	})

	// Every hostile input must fail with ErrNotFound and never escape reposRoot.
	traversal := []struct {
		name        string
		owner, repo string
	}{
		{"dotdot owner", "..", "demo"},
		{"tilde owner", "~evil", "demo"},
		{"slash in repo", "alice", "../../etc"},
		{"dotdot repo", "alice", ".."},
		{"absolute-ish repo", "alice", "/etc/passwd"},
		{"leading dash owner", "-x", "demo"},
		{"missing repo", "alice", "nonexistent"},
		{"missing owner", "bob", "demo"},
	}
	for _, tt := range traversal {
		t.Run(tt.name, func(t *testing.T) {
			_, err := Open(reposRoot, tt.owner, tt.repo)
			if err == nil {
				t.Fatalf("Open(%q,%q) succeeded, want error", tt.owner, tt.repo)
			}
			if !errors.Is(err, core.ErrNotFound) {
				t.Errorf("err = %v, want ErrNotFound", err)
			}
		})
	}
}

func TestRefs(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	branches, tags, err := repo.Refs(ctx())
	if err != nil {
		t.Fatalf("Refs: %v", err)
	}

	if len(branches) != 2 {
		t.Fatalf("branches = %v, want 2", branches)
	}
	if branches[0].Name != "main" {
		t.Errorf("branches[0] = %q, want HEAD branch main first", branches[0].Name)
	}
	if branches[1].Name != "feature/with-slash" {
		t.Errorf("branches[1] = %q, want feature/with-slash", branches[1].Name)
	}
	if branches[0].SHA != fxRev(t, reposRoot, "main") {
		t.Errorf("main SHA = %q, want %q", branches[0].SHA, fxRev(t, reposRoot, "main"))
	}

	if len(tags) != 2 {
		t.Fatalf("tags = %v, want 2", tags)
	}
	// Newest creator-date first: v1.0.0 was tagged after v0.9.0.
	if tags[0].Name != "v1.0.0" || tags[1].Name != "v0.9.0" {
		t.Errorf("tags order = [%q %q], want [v1.0.0 v0.9.0]", tags[0].Name, tags[1].Name)
	}

	def, err := repo.DefaultBranch(ctx())
	if err != nil {
		t.Fatalf("DefaultBranch: %v", err)
	}
	if def != "main" {
		t.Errorf("DefaultBranch = %q, want main", def)
	}
}

func TestResolveCommit(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	mainSHA := fxRev(t, reposRoot, "main")
	rootSHA := fxRev(t, reposRoot, "main^1~3")

	t.Run("branch", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), "main")
		if err != nil {
			t.Fatalf("ResolveCommit(main): %v", err)
		}
		if ci.SHA != mainSHA {
			t.Errorf("SHA = %q, want %q", ci.SHA, mainSHA)
		}
		if len(ci.ParentSHAs) != 2 {
			t.Errorf("main is a merge, parents = %v", ci.ParentSHAs)
		}
		if ci.AuthorName != "Alice Example" {
			t.Errorf("author = %q", ci.AuthorName)
		}
	})

	t.Run("annotated tag peels to commit", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), "v1.0.0")
		if err != nil {
			t.Fatalf("ResolveCommit(v1.0.0): %v", err)
		}
		if ci.SHA != mainSHA {
			t.Errorf("SHA = %q, want %q", ci.SHA, mainSHA)
		}
	})

	t.Run("full SHA", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), rootSHA)
		if err != nil {
			t.Fatalf("ResolveCommit(root SHA): %v", err)
		}
		if ci.SHA != rootSHA {
			t.Errorf("SHA = %q, want %q", ci.SHA, rootSHA)
		}
		if len(ci.ParentSHAs) != 0 {
			t.Errorf("root parents = %v, want none", ci.ParentSHAs)
		}
	})

	t.Run("branch with slash", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), "feature/with-slash")
		if err != nil {
			t.Fatalf("ResolveCommit(feature/with-slash): %v", err)
		}
		if ci.SHA != fxRev(t, reposRoot, "feature/with-slash") {
			t.Errorf("SHA = %q", ci.SHA)
		}
	})

	t.Run("nonexistent ref is ErrBadRef", func(t *testing.T) {
		_, err := repo.ResolveCommit(ctx(), "does-not-exist")
		if !errors.Is(err, core.ErrBadRef) {
			t.Errorf("err = %v, want ErrBadRef", err)
		}
	})

	t.Run("malformed ref is ErrBadRef", func(t *testing.T) {
		_, err := repo.ResolveCommit(ctx(), "@")
		if !errors.Is(err, core.ErrBadRef) {
			t.Errorf("err = %v, want ErrBadRef", err)
		}
	})
}

func TestLog(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	base := fxRev(t, reposRoot, "main^1~2") // c2
	head := fxRev(t, reposRoot, "main^1")   // binary commit

	commits, err := repo.Log(ctx(), base, head, 10)
	if err != nil {
		t.Fatalf("Log: %v", err)
	}
	if len(commits) != 2 {
		t.Fatalf("commits = %d, want 2 (rename, binary)", len(commits))
	}
	// Newest first.
	if !strings.HasPrefix(commits[0].Subject, "binary") {
		t.Errorf("commits[0] = %q, want binary", commits[0].Subject)
	}
	if !strings.HasPrefix(commits[1].Subject, "rename") {
		t.Errorf("commits[1] = %q, want rename", commits[1].Subject)
	}

	t.Run("limit", func(t *testing.T) {
		commits, err := repo.Log(ctx(), base, head, 1)
		if err != nil {
			t.Fatalf("Log: %v", err)
		}
		if len(commits) != 1 {
			t.Errorf("commits = %d, want 1", len(commits))
		}
	})
}

func TestDiffThreeDotVsTwoDot(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	// base = binary commit (has rename + binary on its side), head = feature.
	// Their merge base is c2. Three-dot must show only head-side changes
	// (feature.txt); two-dot compares the endpoints directly and surfaces the
	// base-only files.
	base := fxRev(t, reposRoot, "main^1")
	head := "feature/with-slash" // slash branch name passes through verbatim

	three, err := repo.Diff(ctx(), core.CompareSpec{Base: base, Head: head, ThreeDot: true})
	if err != nil {
		t.Fatalf("Diff three-dot: %v", err)
	}
	if !strings.Contains(three.Text, "feature.txt") {
		t.Errorf("three-dot diff missing feature.txt")
	}
	if strings.Contains(three.Text, "bin.dat") {
		t.Errorf("three-dot diff should NOT contain base-only bin.dat")
	}

	two, err := repo.Diff(ctx(), core.CompareSpec{Base: base, Head: head, ThreeDot: false})
	if err != nil {
		t.Fatalf("Diff two-dot: %v", err)
	}
	if !strings.Contains(two.Text, "bin.dat") {
		t.Errorf("two-dot diff should surface base-side bin.dat")
	}
}

func TestDiffStat(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	// c2 -> binary: a.txt renamed to a-renamed.txt, bin.dat added (binary).
	spec := core.CompareSpec{
		Base: fxRev(t, reposRoot, "main^1~2"),
		Head: fxRev(t, reposRoot, "main^1"),
	}
	files, err := repo.DiffStat(ctx(), spec)
	if err != nil {
		t.Fatalf("DiffStat: %v", err)
	}

	byPath := map[string]FileChange{}
	for _, f := range files {
		byPath[f.Path] = f
	}

	ren, ok := byPath["a-renamed.txt"]
	if !ok {
		t.Fatalf("missing rename entry; files = %+v", files)
	}
	if ren.Status != "R" || ren.OldPath != "a.txt" {
		t.Errorf("rename = %+v, want R from a.txt", ren)
	}

	bin, ok := byPath["bin.dat"]
	if !ok {
		t.Fatalf("missing bin.dat; files = %+v", files)
	}
	if bin.Status != "A" || !bin.Binary {
		t.Errorf("bin.dat = %+v, want A binary", bin)
	}
	if bin.Additions != 0 || bin.Deletions != 0 {
		t.Errorf("binary counts = %d/%d, want 0/0", bin.Additions, bin.Deletions)
	}
}

func TestTruncation(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	spec := core.CompareSpec{Base: fxRev(t, reposRoot, "main^1~3"), Head: "main", ThreeDot: true}

	full, err := repo.Diff(ctx(), spec)
	if err != nil {
		t.Fatalf("Diff (uncapped): %v", err)
	}
	if full.Truncated {
		t.Fatalf("baseline diff unexpectedly truncated")
	}
	if len(full.Text) <= 32 {
		t.Fatalf("fixture diff too small (%d bytes) to test truncation", len(full.Text))
	}

	repo.limitOverride = 32
	capped, err := repo.Diff(ctx(), spec)
	if err != nil {
		t.Fatalf("Diff (capped): %v", err)
	}
	if !capped.Truncated {
		t.Errorf("capped diff not marked truncated")
	}
	if len(capped.Text) > 32 {
		t.Errorf("capped diff = %d bytes, want <= 32", len(capped.Text))
	}
}

func TestMergeBase(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	got, err := repo.MergeBase(ctx(), fxRev(t, reposRoot, "main^1"), "feature/with-slash")
	if err != nil {
		t.Fatalf("MergeBase: %v", err)
	}
	want := fxRev(t, reposRoot, "main^1~2") // c2
	if got != want {
		t.Errorf("MergeBase = %q, want %q (c2)", got, want)
	}
}

func TestParents(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	t.Run("merge", func(t *testing.T) {
		parents, err := repo.Parents(ctx(), "main")
		if err != nil {
			t.Fatalf("Parents: %v", err)
		}
		if len(parents) != 2 {
			t.Errorf("parents = %v, want 2", parents)
		}
	})

	t.Run("root", func(t *testing.T) {
		parents, err := repo.Parents(ctx(), fxRev(t, reposRoot, "main^1~3"))
		if err != nil {
			t.Fatalf("Parents: %v", err)
		}
		if len(parents) != 0 {
			t.Errorf("root parents = %v, want none", parents)
		}
	})
}

func TestCommitPatch(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	t.Run("root commit", func(t *testing.T) {
		root := fxRev(t, reposRoot, "main^1~3")
		patch, files, ci, err := repo.CommitPatch(ctx(), root)
		if err != nil {
			t.Fatalf("CommitPatch(root): %v", err)
		}
		if ci.SHA != root || len(ci.ParentSHAs) != 0 {
			t.Errorf("ci = %+v, want root with no parents", ci)
		}
		if !strings.Contains(patch.Text, "a.txt") {
			t.Errorf("root patch missing a.txt")
		}
		if len(files) != 1 || files[0].Path != "a.txt" || files[0].Status != "A" {
			t.Errorf("root files = %+v, want single A a.txt", files)
		}
	})

	t.Run("binary commit", func(t *testing.T) {
		bin := fxRev(t, reposRoot, "main^1")
		patch, files, _, err := repo.CommitPatch(ctx(), bin)
		if err != nil {
			t.Fatalf("CommitPatch(binary): %v", err)
		}
		if len(files) != 1 || files[0].Path != "bin.dat" || !files[0].Binary {
			t.Errorf("binary commit files = %+v, want single binary bin.dat", files)
		}
		if !strings.Contains(patch.Text, "bin.dat") {
			t.Errorf("binary patch missing bin.dat header")
		}
	})

	t.Run("merge commit diffs against first parent", func(t *testing.T) {
		patch, files, ci, err := repo.CommitPatch(ctx(), "main")
		if err != nil {
			t.Fatalf("CommitPatch(merge): %v", err)
		}
		if len(ci.ParentSHAs) != 2 {
			t.Fatalf("expected merge commit, parents = %v", ci.ParentSHAs)
		}
		// vs first parent (binary commit), the merge brings in feature.txt.
		if len(files) != 1 || files[0].Path != "feature.txt" {
			t.Errorf("merge files = %+v, want feature.txt", files)
		}
		if !strings.Contains(patch.Text, "feature.txt") {
			t.Errorf("merge patch missing feature.txt (diff-tree -p on a merge is empty; must diff vs parent)")
		}
	})
}

A gitx/log.go => gitx/log.go +132 -0
@@ 0,0 1,132 @@
package gitx

import (
	"context"
	"fmt"
	"time"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/plumbing/storer"

	"go.bigb.es/sourcehut-compare/core"
)

// CommitInfo is the metadata of a single commit.
type CommitInfo struct {
	SHA         string
	ShortSHA    string
	AuthorName  string
	AuthorEmail string
	Date        time.Time
	Subject     string
	Body        string
	// ParentSHAs lists the commit's parents in order. Empty for a root commit;
	// length > 1 marks a merge commit.
	ParentSHAs []string
}

// resolveCommit validates rev, resolves it to a commit, and returns the go-git
// object. A syntactically valid but unresolvable revision yields core.ErrBadRef.
func (r *Repo) resolveCommit(rev string) (*object.Commit, error) {
	if !core.ValidRef(rev) {
		return nil, fmt.Errorf("%w: %q", core.ErrBadRef, rev)
	}
	hash, err := r.repo.ResolveRevision(plumbing.Revision(rev))
	if err != nil {
		return nil, badRef(rev, err)
	}
	c, err := r.repo.CommitObject(*hash)
	if err != nil {
		return nil, badRef(rev, err)
	}
	return c, nil
}

// ResolveCommit resolves an arbitrary revision (branch, tag, short or full SHA)
// to a commit and returns its metadata.
func (r *Repo) ResolveCommit(ctx context.Context, rev string) (*CommitInfo, error) {
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	c, err := r.resolveCommit(rev)
	if err != nil {
		return nil, err
	}
	return commitInfo(c), nil
}

// Log returns up to limit commits in the range base..head (reachable from head
// but not from base), newest first. A non-positive limit defaults to 50.
func (r *Repo) Log(ctx context.Context, base, head string, limit int) ([]CommitInfo, error) {
	if limit <= 0 {
		limit = 50
	}
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	baseCommit, err := r.resolveCommit(base)
	if err != nil {
		return nil, err
	}
	headCommit, err := r.resolveCommit(head)
	if err != nil {
		return nil, err
	}

	// Exclude everything reachable from base so a shared ancestor reached via a
	// non-base path is still dropped — matching git's base..head semantics.
	excluded, err := reachableSet(baseCommit)
	if err != nil {
		return nil, err
	}

	var out []CommitInfo
	iter := object.NewCommitPreorderIter(headCommit, excluded, nil)
	err = iter.ForEach(func(c *object.Commit) error {
		if len(out) >= limit {
			return storer.ErrStop
		}
		out = append(out, *commitInfo(c))
		return nil
	})
	if err != nil {
		return nil, err
	}
	return out, nil
}

// Parents returns the parent SHAs of a commit in order. A root commit yields an
// empty slice; length > 1 marks a merge commit.
func (r *Repo) Parents(ctx context.Context, rev string) ([]string, error) {
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	c, err := r.resolveCommit(rev)
	if err != nil {
		return nil, err
	}
	if len(c.ParentHashes) == 0 {
		return nil, nil
	}
	parents := make([]string, 0, len(c.ParentHashes))
	for _, p := range c.ParentHashes {
		parents = append(parents, p.String())
	}
	return parents, nil
}

// reachableSet returns the set of every commit hash reachable from c
// (inclusive).
func reachableSet(c *object.Commit) (map[plumbing.Hash]bool, error) {
	set := make(map[plumbing.Hash]bool)
	iter := object.NewCommitPreorderIter(c, nil, nil)
	err := iter.ForEach(func(x *object.Commit) error {
		set[x.Hash] = true
		return nil
	})
	if err != nil {
		return nil, err
	}
	return set, nil
}

A gitx/mapping_test.go => gitx/mapping_test.go +159 -0
@@ 0,0 1,159 @@
package gitx

import (
	"strings"
	"testing"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/filemode"
	"github.com/go-git/go-git/v5/plumbing/format/diff"
)

// --- fakes implementing the go-git diff interfaces for pure-function tests ---

type fakeFile struct{ path string }

func (f fakeFile) Hash() plumbing.Hash     { return plumbing.ZeroHash }
func (f fakeFile) Mode() filemode.FileMode { return filemode.Regular }
func (f fakeFile) Path() string            { return f.path }

type fakeChunk struct {
	content string
	op      diff.Operation
}

func (c fakeChunk) Content() string      { return c.content }
func (c fakeChunk) Type() diff.Operation { return c.op }

type fakeFilePatch struct {
	from, to diff.File
	binary   bool
	chunks   []diff.Chunk
}

func (f fakeFilePatch) IsBinary() bool                { return f.binary }
func (f fakeFilePatch) Files() (diff.File, diff.File) { return f.from, f.to }
func (f fakeFilePatch) Chunks() []diff.Chunk          { return f.chunks }

func file(p string) diff.File { return fakeFile{path: p} }

func TestMapFilePatches(t *testing.T) {
	add := func(s string) diff.Chunk { return fakeChunk{content: s, op: diff.Add} }
	del := func(s string) diff.Chunk { return fakeChunk{content: s, op: diff.Delete} }
	eq := func(s string) diff.Chunk { return fakeChunk{content: s, op: diff.Equal} }

	tests := []struct {
		name string
		fp   fakeFilePatch
		want FileChange
	}{
		{
			"added",
			fakeFilePatch{to: file("new.txt"), chunks: []diff.Chunk{add("a\nb\n")}},
			FileChange{Status: "A", Path: "new.txt", Additions: 2},
		},
		{
			"deleted",
			fakeFilePatch{from: file("gone.txt"), chunks: []diff.Chunk{del("x\n")}},
			FileChange{Status: "D", Path: "gone.txt", Deletions: 1},
		},
		{
			"modified",
			fakeFilePatch{
				from:   file("a.txt"),
				to:     file("a.txt"),
				chunks: []diff.Chunk{eq("ctx\n"), add("n\n"), del("o1\no2\n")},
			},
			FileChange{Status: "M", Path: "a.txt", Additions: 1, Deletions: 2},
		},
		{
			"renamed with edit",
			fakeFilePatch{
				from:   file("old.txt"),
				to:     file("new.txt"),
				chunks: []diff.Chunk{add("x\n"), del("y\n")},
			},
			FileChange{Status: "R", OldPath: "old.txt", Path: "new.txt", Additions: 1, Deletions: 1},
		},
		{
			"binary skips counts",
			fakeFilePatch{
				from:   file("img.bin"),
				to:     file("img.bin"),
				binary: true,
				chunks: []diff.Chunk{add("should be ignored\n")},
			},
			FileChange{Status: "M", Path: "img.bin", Binary: true},
		},
		{
			"unterminated final line counts as one",
			fakeFilePatch{to: file("f"), chunks: []diff.Chunk{add("noeol")}},
			FileChange{Status: "A", Path: "f", Additions: 1},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := mapFilePatches([]diff.FilePatch{tt.fp})
			if len(got) != 1 {
				t.Fatalf("len = %d, want 1", len(got))
			}
			if got[0] != tt.want {
				t.Errorf("got %+v, want %+v", got[0], tt.want)
			}
		})
	}

	t.Run("empty from and to is skipped", func(t *testing.T) {
		got := mapFilePatches([]diff.FilePatch{fakeFilePatch{}})
		if len(got) != 0 {
			t.Errorf("got %+v, want none", got)
		}
	})
}

func TestCutPatch(t *testing.T) {
	fileA := "diff --git a/a.txt b/a.txt\n@@ -1 +1 @@\n-x\n+y\n"
	fileB := "diff --git a/b.txt b/b.txt\n@@ -1 +1 @@\n-p\n+q\n"
	full := fileA + fileB

	t.Run("under limit unchanged", func(t *testing.T) {
		got, trunc := cutPatch(full, 1<<20)
		if trunc || got != full {
			t.Errorf("trunc=%v got=%q", trunc, got)
		}
	})

	t.Run("zero limit unchanged", func(t *testing.T) {
		got, trunc := cutPatch(full, 0)
		if trunc || got != full {
			t.Errorf("trunc=%v", trunc)
		}
	})

	t.Run("cut at file boundary drops torn file", func(t *testing.T) {
		// A limit that lands inside fileB must cut back to the end of fileA.
		limit := int64(len(fileA) + 10)
		got, trunc := cutPatch(full, limit)
		if !trunc {
			t.Fatalf("expected truncated")
		}
		if got != fileA {
			t.Errorf("got %q, want just fileA (no torn diff --git)", got)
		}
		if strings.Count(got, "diff --git") != 1 {
			t.Errorf("torn file leaked: %q", got)
		}
	})

	t.Run("single huge leading file cut hard at limit", func(t *testing.T) {
		huge := "diff --git a/big b/big\n" + strings.Repeat("+line\n", 1000)
		got, trunc := cutPatch(huge, 40)
		if !trunc {
			t.Fatalf("expected truncated")
		}
		if int64(len(got)) != 40 {
			t.Errorf("len = %d, want 40", len(got))
		}
	})
}

A gitx/refs.go => gitx/refs.go +113 -0
@@ 0,0 1,113 @@
package gitx

import (
	"context"
	"sort"
	"time"

	"github.com/go-git/go-git/v5/plumbing"
)

// Ref is a named git reference paired with the object id it points at (the tag
// object for annotated tags, matching git for-each-ref's %(objectname)).
type Ref struct {
	Name string
	SHA  string
}

// Refs lists the repository's branches and tags. Branches are returned with the
// default (HEAD) branch first and the remainder alphabetical; tags are ordered
// newest-first by creator date (tagger date for annotated tags, committer date
// for lightweight tags).
func (r *Repo) Refs(ctx context.Context) (branches, tags []Ref, err error) {
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	bIter, err := r.repo.Branches()
	if err != nil {
		return nil, nil, err
	}
	err = bIter.ForEach(func(ref *plumbing.Reference) error {
		branches = append(branches, Ref{Name: ref.Name().Short(), SHA: ref.Hash().String()})
		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	def, _ := r.DefaultBranch(ctx)
	sortBranches(branches, def)

	type tagRef struct {
		ref  Ref
		when time.Time
	}
	var trefs []tagRef
	tIter, err := r.repo.Tags()
	if err != nil {
		return nil, nil, err
	}
	err = tIter.ForEach(func(ref *plumbing.Reference) error {
		trefs = append(trefs, tagRef{
			ref:  Ref{Name: ref.Name().Short(), SHA: ref.Hash().String()},
			when: r.tagWhen(ref.Hash()),
		})
		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	sort.SliceStable(trefs, func(i, j int) bool {
		if !trefs[i].when.Equal(trefs[j].when) {
			return trefs[i].when.After(trefs[j].when)
		}
		return trefs[i].ref.Name > trefs[j].ref.Name
	})
	for _, t := range trefs {
		tags = append(tags, t.ref)
	}

	return branches, tags, nil
}

// DefaultBranch returns the short name of the branch HEAD points at (e.g.
// "main"). It fails if HEAD is detached (not a symbolic reference).
func (r *Repo) DefaultBranch(ctx context.Context) (string, error) {
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	ref, err := r.repo.Reference(plumbing.HEAD, false)
	if err != nil {
		return "", err
	}
	if ref.Type() != plumbing.SymbolicReference {
		return "", plumbing.ErrReferenceNotFound
	}
	return ref.Target().Short(), nil
}

// tagWhen returns the creation time of a tag reference: the tagger time for an
// annotated tag, else the committer time of the pointed-at commit. A zero time
// is returned when neither can be resolved.
func (r *Repo) tagWhen(h plumbing.Hash) time.Time {
	if t, err := r.repo.TagObject(h); err == nil {
		return t.Tagger.When
	}
	if c, err := r.repo.CommitObject(h); err == nil {
		return c.Committer.When
	}
	return time.Time{}
}

// sortBranches orders refs alphabetically but floats the default branch to the
// front.
func sortBranches(refs []Ref, defaultBranch string) {
	sort.SliceStable(refs, func(i, j int) bool {
		if refs[i].Name == defaultBranch {
			return refs[j].Name != defaultBranch
		}
		if refs[j].Name == defaultBranch {
			return false
		}
		return refs[i].Name < refs[j].Name
	})
}

M go.mod => go.mod +21 -4
@@ 5,47 5,64 @@ go 1.26.4
require (
	git.sr.ht/~sircmpwn/core-go v0.0.0-20260718172441-c2c2f3848fa9
	github.com/go-chi/chi/v5 v5.3.1
	github.com/go-git/go-git/v5 v5.19.1
	github.com/sirupsen/logrus v1.9.4
)

require (
	dario.cat/mergo v1.0.0 // indirect
	git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c // indirect
	git.sr.ht/~sircmpwn/getopt v1.0.0 // indirect
	git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 // indirect
	github.com/99designs/gqlgen v0.17.36 // indirect
	github.com/Masterminds/squirrel v1.5.4 // indirect
	github.com/Microsoft/go-winio v0.6.2 // indirect
	github.com/ProtonMail/go-crypto v1.3.0 // indirect
	github.com/agnivade/levenshtein v1.1.1 // indirect
	github.com/beorn7/perks v1.0.1 // indirect
	github.com/cespare/xxhash/v2 v2.2.0 // indirect
	github.com/cloudflare/circl v1.6.0 // indirect
	github.com/cloudflare/circl v1.6.3 // indirect
	github.com/cyphar/filepath-securejoin v0.6.1 // indirect
	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
	github.com/emersion/go-message v0.18.2 // indirect
	github.com/emersion/go-pgpmail v0.2.2 // indirect
	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
	github.com/emersion/go-smtp v0.21.3 // indirect
	github.com/emirpasic/gods v1.18.1 // indirect
	github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee // indirect
	github.com/go-chi/cors v1.2.2 // indirect
	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
	github.com/go-git/go-billy/v5 v5.9.0 // indirect
	github.com/go-redis/redis/v8 v8.11.5 // indirect
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
	github.com/golang/protobuf v1.5.4 // indirect
	github.com/gorilla/websocket v1.5.0 // indirect
	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
	github.com/kavu/go_reuseport v1.5.0 // indirect
	github.com/kevinburke/ssh_config v1.2.0 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
	github.com/lib/pq v1.10.9 // indirect
	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
	github.com/mitchellh/mapstructure v1.5.0 // indirect
	github.com/pjbgf/sha1cd v0.6.0 // indirect
	github.com/prometheus/client_golang v1.16.0 // indirect
	github.com/prometheus/client_model v0.4.0 // indirect
	github.com/prometheus/common v0.44.0 // indirect
	github.com/prometheus/procfs v0.12.0 // indirect
	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
	github.com/skeema/knownhosts v1.3.1 // indirect
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect
	github.com/vektah/gqlparser/v2 v2.5.8 // indirect
	golang.org/x/crypto v0.33.0 // indirect
	golang.org/x/sys v0.30.0 // indirect
	golang.org/x/text v0.22.0 // indirect
	github.com/xanzy/ssh-agent v0.3.3 // indirect
	golang.org/x/crypto v0.50.0 // indirect
	golang.org/x/net v0.53.0 // indirect
	golang.org/x/sys v0.43.0 // indirect
	golang.org/x/text v0.36.0 // indirect
	google.golang.org/protobuf v1.33.0 // indirect
	gopkg.in/warnings.v0 v0.1.2 // indirect
)

replace git.sr.ht/~sircmpwn/core-go => git.srht.bigb.es/~bigbes/core-go v0.0.0-20260718172441-c2c2f3848fa9

M go.sum => go.sum +53 -0
@@ 1,3 1,5 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c h1:v2opuaN0C5ZpuCifRNR9ZQ8V9IG+Ja80otK1MFj5RnI=
git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c/go.mod h1:8neHEO3503w/rNtttnR0JFpQgM/GFhaafVwvkPsFIDw=
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=


@@ 11,6 13,9 @@ github.com/99designs/gqlgen v0.17.36 h1:u/o/rv2SZ9s5280dyUOOrkpIIkr/7kITMXYD3rkJ
github.com/99designs/gqlgen v0.17.36/go.mod h1:6RdyY8puhCoWAQVr2qzF2OMVfudQzc8ACxzpzluoQm4=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=


@@ 34,6 39,10 @@ github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUK
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=


@@ 50,12 59,20 @@ github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTe
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee h1:v6Eju/FhxsACGNipFEPBZZAzGr1F/jlRQr1qiBw2nEE=
github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=


@@ 64,6 81,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=


@@ 83,11 102,17 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk=
github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=


@@ 109,8 134,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=


@@ 132,10 160,15 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=


@@ 146,26 179,34 @@ github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7t
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4=
github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=


@@ 176,11 217,15 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=


@@ 190,6 235,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=


@@ 199,6 246,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=


@@ 208,6 256,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=


@@ 225,9 275,12 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=