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=