Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
manifest/secrets/

node_modules

# Claude Code local agent state: per-user settings and scratch worktrees.
.claude/
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ One backend answers three transports:
All three funnel authorization through one pluggable
`internal/auth.Authorizer`.

Git LFS rides on the Smart HTTP listener, behind `-allow-lfs`. It is HTTP even
for an `ssh://` remote: SSH only answers `git-lfs-authenticate`, which names the
HTTP endpoint. See [docs/architecture/lfs.md](docs/architecture/lfs.md).

A fourth listener serves Prometheus metrics at `/metrics` and a `/healthz`
probe endpoint (`-metrics-bind`, default `:9090`). An empty value disables it.

Expand Down Expand Up @@ -60,7 +64,9 @@ Notes on the tests and on configuration:
| `cmd/objgitd/receivepack.go` | The go-git fork that streams hook output, plus `writePack`. |
| `cmd/objgitd/hooks.go` | Ref diffing and the sandboxed hook run. |
| `cmd/objgitd/snapshots.go` | The erofs snapshot run after a push. |
| `cmd/objgitd/lfs.go` | The Git LFS HTTP handlers and `git-lfs-authenticate` over SSH. |
| `internal/auth` | The one authorization interface. |
| `internal/lfs` | Git LFS: protocol types, the bucket store, and the presigner. |
| `internal/repofs` | Maps a repository path to a `storage.Storer`. |
| `internal/storage/tigris` | Repository storage. A `storage.Storer` on the bucket. |
| `internal/bundler` | The async upload queue behind that storer. |
Expand All @@ -85,6 +91,7 @@ describes the daemon and links to one page for each subsystem.
| [snapshots.md](docs/architecture/snapshots.md) | Snapshot images, the snapshot cache, or `runSnapshots`. |
| [tigris-storer.md](docs/architecture/tigris-storer.md) | Object layout, refs, packs, the pack cache, or the upload path. |
| [s3fs.md](docs/architecture/s3fs.md) | The `billy.Filesystem` over the bucket. |
| [lfs.md](docs/architecture/lfs.md) | Git LFS: the batch API, presigned transfers, locks, or the blob layout. |

Two more directories carry detail:

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Price: Free as in mattress. If you use this, it's your problem.

- ssh, http, and git protocol support.
- [post-receive hooks](./docs/usage/hooks.md) powered by userspace sandboxed shells and [Kefka](https://xeiaso.net/blog/2026/dancing-mad-sandboxing/).
- [Git LFS](./docs/usage/lfs.md) with presigned transfers, so large files move straight between the client and Tigris instead of through the daemon. Object bytes are deduplicated across every repository in the bucket.
- basic prometheus metrics.
- no authentication whatsoever, if this ends up being actually useful then authentication will be implemented.

Expand Down
164 changes: 164 additions & 0 deletions cmd/formatbench/bucket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package main

import (
"context"
"fmt"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
tstorage "github.com/tigrisdata/storage-go"
)

// bucketUsage is what one repository costs in the bucket after a push: how many
// keys it left and how many bytes those keys hold. Both eras address a
// repository by the same key prefix, so one listing measures either of them.
// The "before" build reaches that prefix through go-git's loader over
// internal/s3fs; the "after" build reaches it through
// repofs.BucketResolver.Resolve.
type bucketUsage struct {
Keys int `json:"keys"`
Bytes int64 `json:"bytes"`
}

// bucketClient reads the bucket the daemon under test writes to. It is the
// harness's own client, separate from the daemon's, so its calls never land in
// the daemon's own S3 counters.
type bucketClient struct {
api *tstorage.Client
bucket string
}

func newBucketClient(ctx context.Context, bucket string) (*bucketClient, error) {
if bucket == "" {
return nil, fmt.Errorf("formatbench: -bucket is required (BUCKET in .env)")
}

api, err := tstorage.New(ctx)
if err != nil {
return nil, fmt.Errorf("formatbench: can't create Tigris storage client: %w", err)
}

return &bucketClient{api: api, bucket: bucket}, nil
}

// keyPrefixes returns every bucket prefix a repository can live under, because
// the two builds disagree about the ".git" suffix.
//
// The "after" build parses the transport path through repofs.Parse, which
// strips a trailing ".git" (internal/repofs/repofs.go:47), so its keys sit
// under "<org>/<name>/". The "before" build hands the raw URL path to go-git's
// server loader over internal/s3fs, which keeps the suffix, so its keys sit
// under "<org>/<name>.git/". Only one of the two exists for any measurement,
// so walking both and summing is safe and keeps the harness from having to
// know which era it is talking to.
func keyPrefixes(prefix string) []string {
prefix = strings.TrimSuffix(prefix, "/")
prefix = strings.TrimSuffix(prefix, ".git")

return []string{prefix + "/", prefix + ".git/"}
}

// usage walks every prefix a repository can live under and sums what it finds.
func (c *bucketClient) usage(ctx context.Context, prefix string) (bucketUsage, error) {
var out bucketUsage

for _, p := range keyPrefixes(prefix) {
err := c.walk(ctx, p, func(key string, size int64) error {
out.Keys++
out.Bytes += size
return nil
})
if err != nil {
return out, err
}
}

return out, nil
}

// deleteBatch is how many keys one DeleteObjects call carries. S3 caps a single
// request at 1000 keys, so that cap is what the chunking below is for.
const deleteBatch = 1000

// remove deletes every key a repository left behind. It is only reached from
// -cleanup; a benchmark run never deletes anything on its own.
//
// The count it returns is how many keys the bucket confirmed gone, including on
// the error path, where it is the keys deleted before the failure and not the
// whole chunk that failure happened in.
func (c *bucketClient) remove(ctx context.Context, prefix string) (int, error) {
var keys []string
for _, p := range keyPrefixes(prefix) {
if err := c.walk(ctx, p, func(key string, _ int64) error {
keys = append(keys, key)
return nil
}); err != nil {
return 0, err
}
}

deleted := 0
for i := 0; i < len(keys); i += deleteBatch {
end := min(i+deleteBatch, len(keys))

objects := make([]types.ObjectIdentifier, 0, end-i)
for _, key := range keys[i:end] {
objects = append(objects, types.ObjectIdentifier{Key: aws.String(key)})
}

out, err := c.api.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: &c.bucket,
Delete: &types.Delete{Objects: objects},
})
if err != nil {
return deleted, fmt.Errorf("formatbench: can't delete %d keys under %q: %w", len(objects), prefix, err)
}

// DeleteObjects answers 200 even when some of the keys failed, so a
// per-key failure arrives in the body and never as err.
deleted += len(out.Deleted)
if len(out.Errors) > 0 {
bad := out.Errors[0]
return deleted, fmt.Errorf("formatbench: can't delete %q (%d of %d keys failed): %s: %s",
aws.ToString(bad.Key), len(out.Errors), len(objects), aws.ToString(bad.Code), aws.ToString(bad.Message))
}
}

return deleted, nil
}

// walk pages one exact prefix and calls fn for every key.
func (c *bucketClient) walk(ctx context.Context, prefix string, fn func(key string, size int64) error) error {
token := ""
for {
in := &s3.ListObjectsV2Input{Bucket: &c.bucket, Prefix: &prefix}
if token != "" {
in.ContinuationToken = &token
}

page, err := c.api.ListObjectsV2(ctx, in)
if err != nil {
return fmt.Errorf("formatbench: can't list %q: %w", prefix, err)
}

for _, entry := range page.Contents {
if entry.Key == nil {
continue
}
var size int64
if entry.Size != nil {
size = *entry.Size
}
if err := fn(*entry.Key, size); err != nil {
return err
}
}

if page.IsTruncated == nil || !*page.IsTruncated || page.NextContinuationToken == nil {
return nil
}
token = *page.NextContinuationToken
}
}
34 changes: 34 additions & 0 deletions cmd/formatbench/bucket_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

import (
"slices"
"testing"
)

// The two builds disagree about the ".git" suffix, so a measurement has to look
// under both spellings or the older one silently reports an empty bucket.
func TestKeyPrefixes(t *testing.T) {
t.Parallel()

want := []string{"formatbench/before-objgit-abc/", "formatbench/before-objgit-abc.git/"}

for _, tt := range []struct {
name string
in string
}{
{"bare prefix", "formatbench/before-objgit-abc"},
{"already carries the suffix", "formatbench/before-objgit-abc.git"},
{"trailing slash", "formatbench/before-objgit-abc/"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := keyPrefixes(tt.in)
if !slices.Equal(got, want) {
t.Logf("want: %v", want)
t.Logf("got: %v", got)
t.Error("prefix set is wrong")
}
})
}
}
148 changes: 148 additions & 0 deletions cmd/formatbench/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)

// gitEnv isolates every git subprocess from the operator's own configuration,
// so a stray push.default or http.postBuffer in ~/.gitconfig cannot move a
// measurement. Same treatment tryGit gives the protocol tests.
var gitEnv = []string{
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
"GIT_TERMINAL_PROMPT=0",
"GIT_ASKPASS=/bin/true",
}

// opResult is one timed git operation against the daemon under test.
type opResult struct {
Wall time.Duration `json:"wall_ns"`
WallStr string `json:"wall"`

S3 s3Counters `json:"s3"`

// WireBytes is what git reported receiving. Clone only; a push does not
// print a comparable figure.
WireBytes int64 `json:"wire_bytes,omitempty"`

// DNF marks an operation the harness stopped at its time limit. Its Wall is
// the limit, not a completion time, and the S3 counters are however far it
// got. A DNF is a result and gets reported as one; it is never extrapolated
// into a finish time.
DNF bool `json:"dnf,omitempty"`

Err string `json:"err,omitempty"`
}

func (r opResult) ok() bool { return r.Err == "" && !r.DNF }

// runGit runs one git command under a time limit and returns its combined
// output. The boolean reports whether the limit was what stopped it.
func runGit(ctx context.Context, limit time.Duration, dir string, args ...string) (string, bool, error) {
runCtx, cancel := context.WithTimeout(ctx, limit)
defer cancel()

cmd := exec.CommandContext(runCtx, "git", args...)
cmd.Dir = dir
cmd.Env = append(cmd.Environ(), gitEnv...)

var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf

err := cmd.Run()
out := strings.TrimSpace(buf.String())

// A timeout kills the child, which surfaces as a signal error rather than a
// deadline error, so ask the context which one happened.
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return out, true, fmt.Errorf("git %s stopped at the %s limit", args[0], limit)
}
if err != nil {
return out, false, fmt.Errorf("git %s: %w: %s", args[0], err, out)
}

return out, false, nil
}

// gitPush sends the whole repository to a prefix that does not exist yet.
//
// --mirror rather than a heads-and-tags refspec, because the point is to
// measure storing an entire repository: a mirror clone of a GitHub project
// also carries refs/notes/* and refs/pull/*, and those hold commits that a
// heads-and-tags push leaves behind. Leaving them behind would understate the
// work on both sides of the comparison and would make every clone disagree
// with its mirror in verifyClone.
func gitPush(ctx context.Context, limit time.Duration, mirror, url string) (time.Duration, bool, error) {
start := time.Now()
_, dnf, err := runGit(ctx, limit, mirror, "push", "--quiet", "--mirror", url)

return time.Since(start), dnf, err
}

// gitClone pulls the repository back out. --progress forces the receive
// counters onto stderr even though the harness is not a terminal, which is
// where the wire byte count comes from.
func gitClone(ctx context.Context, limit time.Duration, url, dst string) (time.Duration, int64, bool, error) {
start := time.Now()
out, dnf, err := runGit(ctx, limit, "", "clone", "--mirror", "--progress", url, dst)
wall := time.Since(start)

return wall, parseReceivedBytes(out), dnf, err
}

// receivedRe matches the byte total git prints once a pack is fully received,
// for example "Receiving objects: 100% (59459/59459), 80.86 MiB | 4.20 MiB/s".
// A small pack is reported in bytes instead ("244 bytes"), so both spellings
// are accepted. Only the 100% line matches, so the partial progress lines that
// precede it on the same stream are ignored.
var receivedRe = regexp.MustCompile(`Receiving objects: *100%[^,]*, *([0-9.]+) *(bytes|[KMG]iB)`)

// parseReceivedBytes pulls the pack size off git's progress output. It returns
// zero when the line is absent, which happens for an empty clone or a clone
// that failed before any data moved.
func parseReceivedBytes(out string) int64 {
m := receivedRe.FindStringSubmatch(out)
if m == nil {
return 0
}

n, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0
}

switch m[2] {
case "KiB":
n *= 1 << 10
case "MiB":
n *= 1 << 20
case "GiB":
n *= 1 << 30
}

return int64(n)
}

// mirrorClone copies a source repository once. Every push in the run reads from
// this immutable copy, so the harness never writes to whatever it was pointed
// at and every repetition sends byte-identical history.
//
// It goes through runGit for the -mirror-timeout limit: a hung fetch from a
// forge would otherwise block the whole run on the bare signal context, with
// nothing in the output to say which step stopped.
func mirrorClone(ctx context.Context, src, dst string) error {
if _, _, err := runGit(ctx, *mirrorTimeout, "", "clone", "--mirror", src, dst); err != nil {
return fmt.Errorf("formatbench: can't mirror %s: %w", src, err)
}

return nil
}
Loading
Loading