From 9473e2606cd90cb8d3b78ee87511c671157439f4 Mon Sep 17 00:00:00 2001 From: bigbes Date: Sat, 18 Jul 2026 20:59:28 +0300 Subject: [PATCH] gitx: repository access and ref-to-ref diffs on go-git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gitx/diff.go | 259 ++++++++++++++++++++++++++++ gitx/fidelity_test.go | 125 ++++++++++++++ gitx/fixture_test.go | 150 ++++++++++++++++ gitx/gitx.go | 159 +++++++++++++++++ gitx/gitx_test.go | 387 ++++++++++++++++++++++++++++++++++++++++++ gitx/log.go | 132 ++++++++++++++ gitx/mapping_test.go | 159 +++++++++++++++++ gitx/refs.go | 113 ++++++++++++ go.mod | 25 ++- go.sum | 53 ++++++ 10 files changed, 1558 insertions(+), 4 deletions(-) create mode 100644 gitx/diff.go create mode 100644 gitx/fidelity_test.go create mode 100644 gitx/fixture_test.go create mode 100644 gitx/gitx.go create mode 100644 gitx/gitx_test.go create mode 100644 gitx/log.go create mode 100644 gitx/mapping_test.go create mode 100644 gitx/refs.go diff --git a/gitx/diff.go b/gitx/diff.go new file mode 100644 index 0000000000000000000000000000000000000000..f8fac2c2219af740a0c49d2ca071870474bf3b89 --- /dev/null +++ b/gitx/diff.go @@ -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 +} diff --git a/gitx/fidelity_test.go b/gitx/fidelity_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7633650c6b6cfec8e7cbe7a39845b60616ef88dd --- /dev/null +++ b/gitx/fidelity_test.go @@ -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) + } +} diff --git a/gitx/fixture_test.go b/gitx/fixture_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ecdb87a796ca41737e595fb1560e6886eb240a6c --- /dev/null +++ b/gitx/fixture_test.go @@ -0,0 +1,150 @@ +package gitx + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The fixture builds a bare repository at /~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 +} diff --git a/gitx/gitx.go b/gitx/gitx.go new file mode 100644 index 0000000000000000000000000000000000000000..52d5ed629a4a1518dfb73df3d9ebb15a09aa2f34 --- /dev/null +++ b/gitx/gitx.go @@ -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 +} diff --git a/gitx/gitx_test.go b/gitx/gitx_test.go new file mode 100644 index 0000000000000000000000000000000000000000..81d86a9384822fe4b29c7176eb8a16eb71d317ad --- /dev/null +++ b/gitx/gitx_test.go @@ -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)") + } + }) +} diff --git a/gitx/log.go b/gitx/log.go new file mode 100644 index 0000000000000000000000000000000000000000..b618ced51f2b49f55163fe3b9a197df214fd1dd8 --- /dev/null +++ b/gitx/log.go @@ -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 +} diff --git a/gitx/mapping_test.go b/gitx/mapping_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5f61138356148e734b6f322c3c84a216a30b530b --- /dev/null +++ b/gitx/mapping_test.go @@ -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)) + } + }) +} diff --git a/gitx/refs.go b/gitx/refs.go new file mode 100644 index 0000000000000000000000000000000000000000..db942cd361a1699eb7b9edbe5547e49ace8076fc --- /dev/null +++ b/gitx/refs.go @@ -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 + }) +} diff --git a/go.mod b/go.mod index 35572861da11d8496070a43b8f92bce1e4f35e9f..34cdde8513a3be44fdfc991a9cdc861b663b659c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 79a9f6ccd369c75fe364f4723f17b9e2b7e7781d..7d3cdd30475d2caac59f32e70d05426ba6bdcf84 100644 --- a/go.sum +++ b/go.sum @@ -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=