Merge origin/trunk: replace REST code browser with GraphQL (#1541)

Michael Muré and Claude Sonnet 4.6 created

Go backend:
- Remove api/http/git_browse_handler.go and git_serve_handler.go
- Remove GetPath() from RepoCommon interface and all implementations
- Remove GetRepo() (dead code) from RepoCache
- Remove old RepoBrowse interface/types from repository/repo.go
  (now in repository/browse.go from trunk)
- Remove duplicate RepoBrowse method implementations from gogit.go
- cache: keep trunk's IsDefaultRepo() and BrowseRepo(), drop GetPath()
- commands/webui.go: use trunk's setupRoutes structure while keeping
  OAuth provider support and ServerConfig
- Fix graphql_test.go: update NewHandler calls to pass ServerConfig

webui2 frontend:
- Delete src/lib/gitApi.ts (REST client, now replaced by GraphQL)
- Rewrite CodePage, CommitPage, CommitList, FileDiffView using Apollo
- Update FileViewer to use GraphQL GitBlob type (drop download button)
- Update FileTree and RefSelector to use generated types with uppercase
  enum values (BLOB/TREE/BRANCH/TAG)
- Run codegen to regenerate TypeScript types for git.graphql
- Remove /api proxy from vite.config.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Change summary

.github/workflows/build-and-test.yml        |    2 
.github/workflows/release.yml               |    2 
.github/workflows/trunk.yml                 |    2 
api/graphql/connections/edges.go            |    8 
api/graphql/graph/git.generated.go          | 3998 +++++++++++++++++++++++
api/graphql/graph/prelude.generated.go      |   11 
api/graphql/graph/repository.generated.go   |  987 +++++
api/graphql/graph/root.generated.go         |   12 
api/graphql/graph/root_.generated.go        |  823 ++++
api/graphql/graph/types.generated.go        |   18 
api/graphql/graphql_test.go                 |  230 +
api/graphql/models/enums.go                 |   58 
api/graphql/models/gen_models.go            |   61 
api/graphql/models/models.go                |   11 
api/graphql/resolvers/git.go                |   74 
api/graphql/resolvers/query.go              |    7 
api/graphql/resolvers/repo.go               |  229 +
api/graphql/resolvers/root.go               |    6 
api/graphql/schema/directives.graphql       |    4 
api/graphql/schema/git.graphql              |  214 +
api/graphql/schema/repository.graphql       |   51 
api/graphql/schema/root.graphql             |    3 
api/http/git_browse_handler.go              |  566 ---
api/http/git_serve_handler.go               |  331 -
cache/repo_cache_common.go                  |   17 
commands/execenv/env.go                     |    5 
commands/execenv/env_testing.go             |    1 
commands/root.go                            |    5 
commands/webui.go                           |  195 
doc/generate.go                             |    3 
go.mod                                      |   28 
go.sum                                      |   40 
main.go                                     |    8 
misc/completion/generate.go                 |    3 
repository/browse.go                        |  160 
repository/common.go                        |    7 
repository/gogit.go                         | 1064 +++--
repository/mock_repo.go                     |  521 ++
repository/repo.go                          |  151 
repository/repo_testing.go                  |  380 ++
repository/tree_entry.go                    |   58 
webui2/src/__generated__/graphql.ts         |  301 +
webui2/src/components/code/CommitList.tsx   |  102 
webui2/src/components/code/FileDiffView.tsx |  106 
webui2/src/components/code/FileTree.tsx     |   23 
webui2/src/components/code/FileViewer.tsx   |   51 
webui2/src/components/code/RefSelector.tsx  |    8 
webui2/src/lib/gitApi.ts                    |  127 
webui2/src/pages/CodePage.tsx               |  244 
webui2/src/pages/CommitPage.tsx             |   74 
webui2/tsconfig.app.tsbuildinfo             |    0 
webui2/vite.config.ts                       |    3 
52 files changed, 9,339 insertions(+), 2,054 deletions(-)

Detailed changes

.github/workflows/build-and-test.yml 🔗

@@ -11,7 +11,7 @@ jobs:
   with-go:
     strategy:
       matrix:
-        go-version: [1.24.2]
+        go-version: [1.25.x]
         platform: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.platform }}
     steps:

.github/workflows/release.yml 🔗

@@ -30,7 +30,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
         with:
-          go-version: 1.24.2
+          go-version: 1.25.x
 
       - name: Build
         run: make

.github/workflows/trunk.yml 🔗

@@ -33,7 +33,7 @@ jobs:
 
       - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
         with:
-          go-version: 1.24.2
+          go-version: 1.25.x
 
       - name: Run benchmark
         run: go test -v ./... -bench=. -run=xxx -benchmem | tee output.txt

api/graphql/connections/edges.go 🔗

@@ -2,6 +2,14 @@ package connections
 
 import "github.com/git-bug/git-bug/entity"
 
+// CursorEdge is a minimal edge carrying only a cursor. Use it with
+// connections.Connection when the edge type needs no additional fields.
+type CursorEdge struct {
+	Cursor string
+}
+
+func (e CursorEdge) GetCursor() string { return e.Cursor }
+
 // LazyBugEdge is a special relay edge used to implement a lazy loading connection
 type LazyBugEdge struct {
 	Id     entity.Id

api/graphql/graph/git.generated.go 🔗

@@ -0,0 +1,3998 @@
+// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
+
+package graph
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strconv"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/99designs/gqlgen/graphql"
+	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/repository"
+	"github.com/vektah/gqlparser/v2/ast"
+)
+
+// region    ************************** generated!.gotpl **************************
+
+type GitCommitResolver interface {
+	ShortHash(ctx context.Context, obj *models.GitCommitMeta) (string, error)
+
+	FullMessage(ctx context.Context, obj *models.GitCommitMeta) (string, error)
+
+	Parents(ctx context.Context, obj *models.GitCommitMeta) ([]string, error)
+	Files(ctx context.Context, obj *models.GitCommitMeta, after *string, before *string, first *int, last *int) (*models.GitChangedFileConnection, error)
+	Diff(ctx context.Context, obj *models.GitCommitMeta, path string) (*repository.FileDiff, error)
+}
+
+// endregion ************************** generated!.gotpl **************************
+
+// region    ***************************** args.gotpl *****************************
+
+func (ec *executionContext) field_GitCommit_diff_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_GitCommit_diff_argsPath(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["path"] = arg0
+	return args, nil
+}
+func (ec *executionContext) field_GitCommit_diff_argsPath(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["path"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("path"))
+	if tmp, ok := rawArgs["path"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_GitCommit_files_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_GitCommit_files_argsAfter(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["after"] = arg0
+	arg1, err := ec.field_GitCommit_files_argsBefore(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["before"] = arg1
+	arg2, err := ec.field_GitCommit_files_argsFirst(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["first"] = arg2
+	arg3, err := ec.field_GitCommit_files_argsLast(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["last"] = arg3
+	return args, nil
+}
+func (ec *executionContext) field_GitCommit_files_argsAfter(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["after"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+	if tmp, ok := rawArgs["after"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_GitCommit_files_argsBefore(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["before"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
+	if tmp, ok := rawArgs["before"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_GitCommit_files_argsFirst(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["first"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
+	if tmp, ok := rawArgs["first"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_GitCommit_files_argsLast(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["last"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last"))
+	if tmp, ok := rawArgs["last"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+// endregion ***************************** args.gotpl *****************************
+
+// region    ************************** directives.gotpl **************************
+
+// endregion ************************** directives.gotpl **************************
+
+// region    **************************** field.gotpl *****************************
+
+func (ec *executionContext) _GitBlob_path(ctx context.Context, field graphql.CollectedField, obj *models.GitBlob) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitBlob_path(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Path, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitBlob_path(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitBlob",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitBlob_hash(ctx context.Context, field graphql.CollectedField, obj *models.GitBlob) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitBlob_hash(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Hash, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitBlob_hash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitBlob",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitBlob_text(ctx context.Context, field graphql.CollectedField, obj *models.GitBlob) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitBlob_text(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Text, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	fc.Result = res
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitBlob_text(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitBlob",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitBlob_size(ctx context.Context, field graphql.CollectedField, obj *models.GitBlob) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitBlob_size(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Size, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitBlob_size(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitBlob",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitBlob_isBinary(ctx context.Context, field graphql.CollectedField, obj *models.GitBlob) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitBlob_isBinary(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.IsBinary, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitBlob_isBinary(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitBlob",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitBlob_isTruncated(ctx context.Context, field graphql.CollectedField, obj *models.GitBlob) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitBlob_isTruncated(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.IsTruncated, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitBlob_isTruncated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitBlob",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitChangedFile_path(ctx context.Context, field graphql.CollectedField, obj *repository.ChangedFile) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitChangedFile_path(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Path, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitChangedFile_path(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitChangedFile",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitChangedFile_oldPath(ctx context.Context, field graphql.CollectedField, obj *repository.ChangedFile) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitChangedFile_oldPath(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.OldPath, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	fc.Result = res
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitChangedFile_oldPath(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitChangedFile",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitChangedFile_status(ctx context.Context, field graphql.CollectedField, obj *repository.ChangedFile) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitChangedFile_status(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Status, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(repository.ChangeStatus)
+	fc.Result = res
+	return ec.marshalNGitChangeStatus2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangeStatus(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitChangedFile_status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitChangedFile",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type GitChangeStatus does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitChangedFileConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *models.GitChangedFileConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitChangedFileConnection_nodes(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Nodes, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*repository.ChangedFile)
+	fc.Result = res
+	return ec.marshalNGitChangedFile2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangedFileᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitChangedFileConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitChangedFileConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "path":
+				return ec.fieldContext_GitChangedFile_path(ctx, field)
+			case "oldPath":
+				return ec.fieldContext_GitChangedFile_oldPath(ctx, field)
+			case "status":
+				return ec.fieldContext_GitChangedFile_status(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitChangedFile", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitChangedFileConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *models.GitChangedFileConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitChangedFileConnection_pageInfo(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.PageInfo, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.PageInfo)
+	fc.Result = res
+	return ec.marshalNPageInfo2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐPageInfo(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitChangedFileConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitChangedFileConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "hasNextPage":
+				return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+			case "hasPreviousPage":
+				return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field)
+			case "startCursor":
+				return ec.fieldContext_PageInfo_startCursor(ctx, field)
+			case "endCursor":
+				return ec.fieldContext_PageInfo_endCursor(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitChangedFileConnection_totalCount(ctx context.Context, field graphql.CollectedField, obj *models.GitChangedFileConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitChangedFileConnection_totalCount(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.TotalCount, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitChangedFileConnection_totalCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitChangedFileConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_hash(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_hash(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Hash, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(repository.Hash)
+	fc.Result = res
+	return ec.marshalNString2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐHash(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_hash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_shortHash(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_shortHash(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.GitCommit().ShortHash(rctx, obj)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_shortHash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_message(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_message(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Message, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_fullMessage(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_fullMessage(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.GitCommit().FullMessage(rctx, obj)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_fullMessage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_authorName(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_authorName(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.AuthorName, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_authorName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_authorEmail(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_authorEmail(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.AuthorEmail, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_authorEmail(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_date(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_date(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Date, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(time.Time)
+	fc.Result = res
+	return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_date(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Time does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_parents(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_parents(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.GitCommit().Parents(rctx, obj)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]string)
+	fc.Result = res
+	return ec.marshalNString2ᚕstringᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_parents(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_files(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_files(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.GitCommit().Files(rctx, obj, fc.Args["after"].(*string), fc.Args["before"].(*string), fc.Args["first"].(*int), fc.Args["last"].(*int))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.GitChangedFileConnection)
+	fc.Result = res
+	return ec.marshalNGitChangedFileConnection2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitChangedFileConnection(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_files(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "nodes":
+				return ec.fieldContext_GitChangedFileConnection_nodes(ctx, field)
+			case "pageInfo":
+				return ec.fieldContext_GitChangedFileConnection_pageInfo(ctx, field)
+			case "totalCount":
+				return ec.fieldContext_GitChangedFileConnection_totalCount(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitChangedFileConnection", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_GitCommit_files_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommit_diff(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitMeta) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommit_diff(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.GitCommit().Diff(rctx, obj, fc.Args["path"].(string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*repository.FileDiff)
+	fc.Result = res
+	return ec.marshalOGitFileDiff2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐFileDiff(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommit_diff(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommit",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "path":
+				return ec.fieldContext_GitFileDiff_path(ctx, field)
+			case "oldPath":
+				return ec.fieldContext_GitFileDiff_oldPath(ctx, field)
+			case "isBinary":
+				return ec.fieldContext_GitFileDiff_isBinary(ctx, field)
+			case "isNew":
+				return ec.fieldContext_GitFileDiff_isNew(ctx, field)
+			case "isDelete":
+				return ec.fieldContext_GitFileDiff_isDelete(ctx, field)
+			case "hunks":
+				return ec.fieldContext_GitFileDiff_hunks(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitFileDiff", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_GitCommit_diff_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommitConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommitConnection_nodes(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Nodes, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*models.GitCommitMeta)
+	fc.Result = res
+	return ec.marshalNGitCommit2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMetaᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommitConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommitConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "hash":
+				return ec.fieldContext_GitCommit_hash(ctx, field)
+			case "shortHash":
+				return ec.fieldContext_GitCommit_shortHash(ctx, field)
+			case "message":
+				return ec.fieldContext_GitCommit_message(ctx, field)
+			case "fullMessage":
+				return ec.fieldContext_GitCommit_fullMessage(ctx, field)
+			case "authorName":
+				return ec.fieldContext_GitCommit_authorName(ctx, field)
+			case "authorEmail":
+				return ec.fieldContext_GitCommit_authorEmail(ctx, field)
+			case "date":
+				return ec.fieldContext_GitCommit_date(ctx, field)
+			case "parents":
+				return ec.fieldContext_GitCommit_parents(ctx, field)
+			case "files":
+				return ec.fieldContext_GitCommit_files(ctx, field)
+			case "diff":
+				return ec.fieldContext_GitCommit_diff(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitCommit", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommitConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommitConnection_pageInfo(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.PageInfo, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.PageInfo)
+	fc.Result = res
+	return ec.marshalNPageInfo2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐPageInfo(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommitConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommitConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "hasNextPage":
+				return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+			case "hasPreviousPage":
+				return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field)
+			case "startCursor":
+				return ec.fieldContext_PageInfo_startCursor(ctx, field)
+			case "endCursor":
+				return ec.fieldContext_PageInfo_endCursor(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitCommitConnection_totalCount(ctx context.Context, field graphql.CollectedField, obj *models.GitCommitConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitCommitConnection_totalCount(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.TotalCount, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitCommitConnection_totalCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitCommitConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffHunk_oldStart(ctx context.Context, field graphql.CollectedField, obj *repository.DiffHunk) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffHunk_oldStart(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.OldStart, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffHunk_oldStart(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffHunk",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffHunk_oldLines(ctx context.Context, field graphql.CollectedField, obj *repository.DiffHunk) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffHunk_oldLines(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.OldLines, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffHunk_oldLines(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffHunk",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffHunk_newStart(ctx context.Context, field graphql.CollectedField, obj *repository.DiffHunk) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffHunk_newStart(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.NewStart, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffHunk_newStart(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffHunk",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffHunk_newLines(ctx context.Context, field graphql.CollectedField, obj *repository.DiffHunk) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffHunk_newLines(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.NewLines, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffHunk_newLines(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffHunk",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffHunk_lines(ctx context.Context, field graphql.CollectedField, obj *repository.DiffHunk) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffHunk_lines(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Lines, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]repository.DiffLine)
+	fc.Result = res
+	return ec.marshalNGitDiffLine2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLineᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffHunk_lines(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffHunk",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "type":
+				return ec.fieldContext_GitDiffLine_type(ctx, field)
+			case "content":
+				return ec.fieldContext_GitDiffLine_content(ctx, field)
+			case "oldLine":
+				return ec.fieldContext_GitDiffLine_oldLine(ctx, field)
+			case "newLine":
+				return ec.fieldContext_GitDiffLine_newLine(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitDiffLine", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffLine_type(ctx context.Context, field graphql.CollectedField, obj *repository.DiffLine) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffLine_type(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Type, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(repository.DiffLineType)
+	fc.Result = res
+	return ec.marshalNGitDiffLineType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLineType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffLine_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffLine",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type GitDiffLineType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffLine_content(ctx context.Context, field graphql.CollectedField, obj *repository.DiffLine) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffLine_content(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Content, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffLine_content(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffLine",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffLine_oldLine(ctx context.Context, field graphql.CollectedField, obj *repository.DiffLine) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffLine_oldLine(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.OldLine, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffLine_oldLine(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffLine",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitDiffLine_newLine(ctx context.Context, field graphql.CollectedField, obj *repository.DiffLine) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitDiffLine_newLine(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.NewLine, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitDiffLine_newLine(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitDiffLine",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitFileDiff_path(ctx context.Context, field graphql.CollectedField, obj *repository.FileDiff) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitFileDiff_path(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Path, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitFileDiff_path(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitFileDiff",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitFileDiff_oldPath(ctx context.Context, field graphql.CollectedField, obj *repository.FileDiff) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitFileDiff_oldPath(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.OldPath, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	fc.Result = res
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitFileDiff_oldPath(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitFileDiff",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitFileDiff_isBinary(ctx context.Context, field graphql.CollectedField, obj *repository.FileDiff) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitFileDiff_isBinary(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.IsBinary, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitFileDiff_isBinary(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitFileDiff",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitFileDiff_isNew(ctx context.Context, field graphql.CollectedField, obj *repository.FileDiff) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitFileDiff_isNew(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.IsNew, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitFileDiff_isNew(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitFileDiff",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitFileDiff_isDelete(ctx context.Context, field graphql.CollectedField, obj *repository.FileDiff) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitFileDiff_isDelete(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.IsDelete, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitFileDiff_isDelete(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitFileDiff",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitFileDiff_hunks(ctx context.Context, field graphql.CollectedField, obj *repository.FileDiff) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitFileDiff_hunks(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Hunks, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]repository.DiffHunk)
+	fc.Result = res
+	return ec.marshalNGitDiffHunk2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffHunkᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitFileDiff_hunks(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitFileDiff",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "oldStart":
+				return ec.fieldContext_GitDiffHunk_oldStart(ctx, field)
+			case "oldLines":
+				return ec.fieldContext_GitDiffHunk_oldLines(ctx, field)
+			case "newStart":
+				return ec.fieldContext_GitDiffHunk_newStart(ctx, field)
+			case "newLines":
+				return ec.fieldContext_GitDiffHunk_newLines(ctx, field)
+			case "lines":
+				return ec.fieldContext_GitDiffHunk_lines(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitDiffHunk", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitLastCommit_name(ctx context.Context, field graphql.CollectedField, obj *models.GitLastCommit) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitLastCommit_name(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Name, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitLastCommit_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitLastCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitLastCommit_commit(ctx context.Context, field graphql.CollectedField, obj *models.GitLastCommit) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitLastCommit_commit(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Commit, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.GitCommitMeta)
+	fc.Result = res
+	return ec.marshalNGitCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMeta(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitLastCommit_commit(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitLastCommit",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "hash":
+				return ec.fieldContext_GitCommit_hash(ctx, field)
+			case "shortHash":
+				return ec.fieldContext_GitCommit_shortHash(ctx, field)
+			case "message":
+				return ec.fieldContext_GitCommit_message(ctx, field)
+			case "fullMessage":
+				return ec.fieldContext_GitCommit_fullMessage(ctx, field)
+			case "authorName":
+				return ec.fieldContext_GitCommit_authorName(ctx, field)
+			case "authorEmail":
+				return ec.fieldContext_GitCommit_authorEmail(ctx, field)
+			case "date":
+				return ec.fieldContext_GitCommit_date(ctx, field)
+			case "parents":
+				return ec.fieldContext_GitCommit_parents(ctx, field)
+			case "files":
+				return ec.fieldContext_GitCommit_files(ctx, field)
+			case "diff":
+				return ec.fieldContext_GitCommit_diff(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitCommit", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRef_name(ctx context.Context, field graphql.CollectedField, obj *models.GitRef) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRef_name(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Name, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRef_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRef",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRef_shortName(ctx context.Context, field graphql.CollectedField, obj *models.GitRef) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRef_shortName(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.ShortName, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRef_shortName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRef",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRef_type(ctx context.Context, field graphql.CollectedField, obj *models.GitRef) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRef_type(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Type, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(models.GitRefType)
+	fc.Result = res
+	return ec.marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRef_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRef",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type GitRefType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRef_hash(ctx context.Context, field graphql.CollectedField, obj *models.GitRef) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRef_hash(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Hash, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRef_hash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRef",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRef_isDefault(ctx context.Context, field graphql.CollectedField, obj *models.GitRef) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRef_isDefault(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.IsDefault, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRef_isDefault(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRef",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRefConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *models.GitRefConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRefConnection_nodes(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Nodes, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*models.GitRef)
+	fc.Result = res
+	return ec.marshalNGitRef2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRefConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRefConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "name":
+				return ec.fieldContext_GitRef_name(ctx, field)
+			case "shortName":
+				return ec.fieldContext_GitRef_shortName(ctx, field)
+			case "type":
+				return ec.fieldContext_GitRef_type(ctx, field)
+			case "hash":
+				return ec.fieldContext_GitRef_hash(ctx, field)
+			case "isDefault":
+				return ec.fieldContext_GitRef_isDefault(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type GitRef", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRefConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *models.GitRefConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRefConnection_pageInfo(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.PageInfo, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.PageInfo)
+	fc.Result = res
+	return ec.marshalNPageInfo2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐPageInfo(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRefConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRefConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "hasNextPage":
+				return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+			case "hasPreviousPage":
+				return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field)
+			case "startCursor":
+				return ec.fieldContext_PageInfo_startCursor(ctx, field)
+			case "endCursor":
+				return ec.fieldContext_PageInfo_endCursor(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitRefConnection_totalCount(ctx context.Context, field graphql.CollectedField, obj *models.GitRefConnection) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRefConnection_totalCount(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.TotalCount, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitRefConnection_totalCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRefConnection",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitTreeEntry_name(ctx context.Context, field graphql.CollectedField, obj *repository.TreeEntry) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitTreeEntry_name(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Name, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitTreeEntry_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitTreeEntry",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitTreeEntry_type(ctx context.Context, field graphql.CollectedField, obj *repository.TreeEntry) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitTreeEntry_type(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.ObjectType, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(repository.ObjectType)
+	fc.Result = res
+	return ec.marshalNGitObjectType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐObjectType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitTreeEntry_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitTreeEntry",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type GitObjectType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _GitTreeEntry_hash(ctx context.Context, field graphql.CollectedField, obj *repository.TreeEntry) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitTreeEntry_hash(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Hash, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(repository.Hash)
+	fc.Result = res
+	return ec.marshalNString2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐHash(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_GitTreeEntry_hash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitTreeEntry",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+// endregion **************************** field.gotpl *****************************
+
+// region    **************************** input.gotpl *****************************
+
+// endregion **************************** input.gotpl *****************************
+
+// region    ************************** interface.gotpl ***************************
+
+// endregion ************************** interface.gotpl ***************************
+
+// region    **************************** object.gotpl ****************************
+
+var gitBlobImplementors = []string{"GitBlob"}
+
+func (ec *executionContext) _GitBlob(ctx context.Context, sel ast.SelectionSet, obj *models.GitBlob) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitBlobImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitBlob")
+		case "path":
+			out.Values[i] = ec._GitBlob_path(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "hash":
+			out.Values[i] = ec._GitBlob_hash(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "text":
+			out.Values[i] = ec._GitBlob_text(ctx, field, obj)
+		case "size":
+			out.Values[i] = ec._GitBlob_size(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "isBinary":
+			out.Values[i] = ec._GitBlob_isBinary(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "isTruncated":
+			out.Values[i] = ec._GitBlob_isTruncated(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitChangedFileImplementors = []string{"GitChangedFile"}
+
+func (ec *executionContext) _GitChangedFile(ctx context.Context, sel ast.SelectionSet, obj *repository.ChangedFile) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitChangedFileImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitChangedFile")
+		case "path":
+			out.Values[i] = ec._GitChangedFile_path(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "oldPath":
+			out.Values[i] = ec._GitChangedFile_oldPath(ctx, field, obj)
+		case "status":
+			out.Values[i] = ec._GitChangedFile_status(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitChangedFileConnectionImplementors = []string{"GitChangedFileConnection"}
+
+func (ec *executionContext) _GitChangedFileConnection(ctx context.Context, sel ast.SelectionSet, obj *models.GitChangedFileConnection) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitChangedFileConnectionImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitChangedFileConnection")
+		case "nodes":
+			out.Values[i] = ec._GitChangedFileConnection_nodes(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "pageInfo":
+			out.Values[i] = ec._GitChangedFileConnection_pageInfo(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "totalCount":
+			out.Values[i] = ec._GitChangedFileConnection_totalCount(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitCommitImplementors = []string{"GitCommit"}
+
+func (ec *executionContext) _GitCommit(ctx context.Context, sel ast.SelectionSet, obj *models.GitCommitMeta) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitCommitImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitCommit")
+		case "hash":
+			out.Values[i] = ec._GitCommit_hash(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
+		case "shortHash":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._GitCommit_shortHash(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		case "message":
+			out.Values[i] = ec._GitCommit_message(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
+		case "fullMessage":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._GitCommit_fullMessage(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		case "authorName":
+			out.Values[i] = ec._GitCommit_authorName(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
+		case "authorEmail":
+			out.Values[i] = ec._GitCommit_authorEmail(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
+		case "date":
+			out.Values[i] = ec._GitCommit_date(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
+		case "parents":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._GitCommit_parents(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		case "files":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._GitCommit_files(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		case "diff":
+			field := field
+
+			innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._GitCommit_diff(ctx, field, obj)
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitCommitConnectionImplementors = []string{"GitCommitConnection"}
+
+func (ec *executionContext) _GitCommitConnection(ctx context.Context, sel ast.SelectionSet, obj *models.GitCommitConnection) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitCommitConnectionImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitCommitConnection")
+		case "nodes":
+			out.Values[i] = ec._GitCommitConnection_nodes(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "pageInfo":
+			out.Values[i] = ec._GitCommitConnection_pageInfo(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "totalCount":
+			out.Values[i] = ec._GitCommitConnection_totalCount(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitDiffHunkImplementors = []string{"GitDiffHunk"}
+
+func (ec *executionContext) _GitDiffHunk(ctx context.Context, sel ast.SelectionSet, obj *repository.DiffHunk) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitDiffHunkImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitDiffHunk")
+		case "oldStart":
+			out.Values[i] = ec._GitDiffHunk_oldStart(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "oldLines":
+			out.Values[i] = ec._GitDiffHunk_oldLines(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "newStart":
+			out.Values[i] = ec._GitDiffHunk_newStart(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "newLines":
+			out.Values[i] = ec._GitDiffHunk_newLines(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "lines":
+			out.Values[i] = ec._GitDiffHunk_lines(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitDiffLineImplementors = []string{"GitDiffLine"}
+
+func (ec *executionContext) _GitDiffLine(ctx context.Context, sel ast.SelectionSet, obj *repository.DiffLine) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitDiffLineImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitDiffLine")
+		case "type":
+			out.Values[i] = ec._GitDiffLine_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "content":
+			out.Values[i] = ec._GitDiffLine_content(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "oldLine":
+			out.Values[i] = ec._GitDiffLine_oldLine(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "newLine":
+			out.Values[i] = ec._GitDiffLine_newLine(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitFileDiffImplementors = []string{"GitFileDiff"}
+
+func (ec *executionContext) _GitFileDiff(ctx context.Context, sel ast.SelectionSet, obj *repository.FileDiff) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitFileDiffImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitFileDiff")
+		case "path":
+			out.Values[i] = ec._GitFileDiff_path(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "oldPath":
+			out.Values[i] = ec._GitFileDiff_oldPath(ctx, field, obj)
+		case "isBinary":
+			out.Values[i] = ec._GitFileDiff_isBinary(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "isNew":
+			out.Values[i] = ec._GitFileDiff_isNew(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "isDelete":
+			out.Values[i] = ec._GitFileDiff_isDelete(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "hunks":
+			out.Values[i] = ec._GitFileDiff_hunks(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitLastCommitImplementors = []string{"GitLastCommit"}
+
+func (ec *executionContext) _GitLastCommit(ctx context.Context, sel ast.SelectionSet, obj *models.GitLastCommit) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitLastCommitImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitLastCommit")
+		case "name":
+			out.Values[i] = ec._GitLastCommit_name(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "commit":
+			out.Values[i] = ec._GitLastCommit_commit(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitRefImplementors = []string{"GitRef"}
+
+func (ec *executionContext) _GitRef(ctx context.Context, sel ast.SelectionSet, obj *models.GitRef) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitRefImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitRef")
+		case "name":
+			out.Values[i] = ec._GitRef_name(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "shortName":
+			out.Values[i] = ec._GitRef_shortName(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "type":
+			out.Values[i] = ec._GitRef_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "hash":
+			out.Values[i] = ec._GitRef_hash(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "isDefault":
+			out.Values[i] = ec._GitRef_isDefault(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitRefConnectionImplementors = []string{"GitRefConnection"}
+
+func (ec *executionContext) _GitRefConnection(ctx context.Context, sel ast.SelectionSet, obj *models.GitRefConnection) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitRefConnectionImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitRefConnection")
+		case "nodes":
+			out.Values[i] = ec._GitRefConnection_nodes(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "pageInfo":
+			out.Values[i] = ec._GitRefConnection_pageInfo(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "totalCount":
+			out.Values[i] = ec._GitRefConnection_totalCount(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var gitTreeEntryImplementors = []string{"GitTreeEntry"}
+
+func (ec *executionContext) _GitTreeEntry(ctx context.Context, sel ast.SelectionSet, obj *repository.TreeEntry) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, gitTreeEntryImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("GitTreeEntry")
+		case "name":
+			out.Values[i] = ec._GitTreeEntry_name(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "type":
+			out.Values[i] = ec._GitTreeEntry_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "hash":
+			out.Values[i] = ec._GitTreeEntry_hash(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+// endregion **************************** object.gotpl ****************************
+
+// region    ***************************** type.gotpl *****************************
+
+func (ec *executionContext) unmarshalNGitChangeStatus2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangeStatus(ctx context.Context, v any) (repository.ChangeStatus, error) {
+	var res repository.ChangeStatus
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNGitChangeStatus2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangeStatus(ctx context.Context, sel ast.SelectionSet, v repository.ChangeStatus) graphql.Marshaler {
+	return v
+}
+
+func (ec *executionContext) marshalNGitChangedFile2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangedFileᚄ(ctx context.Context, sel ast.SelectionSet, v []*repository.ChangedFile) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitChangedFile2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangedFile(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) marshalNGitChangedFile2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐChangedFile(ctx context.Context, sel ast.SelectionSet, v *repository.ChangedFile) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitChangedFile(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNGitChangedFileConnection2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitChangedFileConnection(ctx context.Context, sel ast.SelectionSet, v models.GitChangedFileConnection) graphql.Marshaler {
+	return ec._GitChangedFileConnection(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNGitChangedFileConnection2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitChangedFileConnection(ctx context.Context, sel ast.SelectionSet, v *models.GitChangedFileConnection) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitChangedFileConnection(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNGitCommit2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMetaᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.GitCommitMeta) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMeta(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) marshalNGitCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMeta(ctx context.Context, sel ast.SelectionSet, v *models.GitCommitMeta) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitCommit(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNGitCommitConnection2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitConnection(ctx context.Context, sel ast.SelectionSet, v models.GitCommitConnection) graphql.Marshaler {
+	return ec._GitCommitConnection(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNGitCommitConnection2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitConnection(ctx context.Context, sel ast.SelectionSet, v *models.GitCommitConnection) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitCommitConnection(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNGitDiffHunk2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffHunk(ctx context.Context, sel ast.SelectionSet, v repository.DiffHunk) graphql.Marshaler {
+	return ec._GitDiffHunk(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNGitDiffHunk2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffHunkᚄ(ctx context.Context, sel ast.SelectionSet, v []repository.DiffHunk) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitDiffHunk2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffHunk(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) marshalNGitDiffLine2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLine(ctx context.Context, sel ast.SelectionSet, v repository.DiffLine) graphql.Marshaler {
+	return ec._GitDiffLine(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNGitDiffLine2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLineᚄ(ctx context.Context, sel ast.SelectionSet, v []repository.DiffLine) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitDiffLine2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLine(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) unmarshalNGitDiffLineType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLineType(ctx context.Context, v any) (repository.DiffLineType, error) {
+	var res repository.DiffLineType
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNGitDiffLineType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐDiffLineType(ctx context.Context, sel ast.SelectionSet, v repository.DiffLineType) graphql.Marshaler {
+	return v
+}
+
+func (ec *executionContext) marshalNGitLastCommit2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitLastCommitᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.GitLastCommit) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitLastCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitLastCommit(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) marshalNGitLastCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitLastCommit(ctx context.Context, sel ast.SelectionSet, v *models.GitLastCommit) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitLastCommit(ctx, sel, v)
+}
+
+func (ec *executionContext) unmarshalNGitObjectType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐObjectType(ctx context.Context, v any) (repository.ObjectType, error) {
+	var res repository.ObjectType
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNGitObjectType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐObjectType(ctx context.Context, sel ast.SelectionSet, v repository.ObjectType) graphql.Marshaler {
+	return v
+}
+
+func (ec *executionContext) marshalNGitRef2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.GitRef) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitRef2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRef(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) marshalNGitRef2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRef(ctx context.Context, sel ast.SelectionSet, v *models.GitRef) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitRef(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNGitRefConnection2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefConnection(ctx context.Context, sel ast.SelectionSet, v models.GitRefConnection) graphql.Marshaler {
+	return ec._GitRefConnection(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNGitRefConnection2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefConnection(ctx context.Context, sel ast.SelectionSet, v *models.GitRefConnection) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitRefConnection(ctx, sel, v)
+}
+
+func (ec *executionContext) unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx context.Context, v any) (models.GitRefType, error) {
+	tmp, err := graphql.UnmarshalString(v)
+	res := unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType[tmp]
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx context.Context, sel ast.SelectionSet, v models.GitRefType) graphql.Marshaler {
+	_ = sel
+	res := graphql.MarshalString(marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType[v])
+	if res == graphql.Null {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+	}
+	return res
+}
+
+var (
+	unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType = map[string]models.GitRefType{
+		"BRANCH": models.GitRefTypeBranch,
+		"TAG":    models.GitRefTypeTag,
+	}
+	marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType = map[models.GitRefType]string{
+		models.GitRefTypeBranch: "BRANCH",
+		models.GitRefTypeTag:    "TAG",
+	}
+)
+
+func (ec *executionContext) marshalNGitTreeEntry2ᚕᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐTreeEntryᚄ(ctx context.Context, sel ast.SelectionSet, v []*repository.TreeEntry) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNGitTreeEntry2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐTreeEntry(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	for _, e := range ret {
+		if e == graphql.Null {
+			return graphql.Null
+		}
+	}
+
+	return ret
+}
+
+func (ec *executionContext) marshalNGitTreeEntry2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐTreeEntry(ctx context.Context, sel ast.SelectionSet, v *repository.TreeEntry) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._GitTreeEntry(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalOGitBlob2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitBlob(ctx context.Context, sel ast.SelectionSet, v *models.GitBlob) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return ec._GitBlob(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalOGitCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMeta(ctx context.Context, sel ast.SelectionSet, v *models.GitCommitMeta) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return ec._GitCommit(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalOGitFileDiff2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐFileDiff(ctx context.Context, sel ast.SelectionSet, v *repository.FileDiff) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return ec._GitFileDiff(ctx, sel, v)
+}
+
+func (ec *executionContext) unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx context.Context, v any) (*models.GitRefType, error) {
+	if v == nil {
+		return nil, nil
+	}
+	tmp, err := graphql.UnmarshalString(v)
+	res := unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType[tmp]
+	return &res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx context.Context, sel ast.SelectionSet, v *models.GitRefType) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	_ = sel
+	_ = ctx
+	res := graphql.MarshalString(marshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType[*v])
+	return res
+}
+
+var (
+	unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType = map[string]models.GitRefType{
+		"BRANCH": models.GitRefTypeBranch,
+		"TAG":    models.GitRefTypeTag,
+	}
+	marshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType = map[models.GitRefType]string{
+		models.GitRefTypeBranch: "BRANCH",
+		models.GitRefTypeTag:    "TAG",
+	}
+)
+
+// endregion ***************************** type.gotpl *****************************

api/graphql/graph/prelude.generated.go 🔗

@@ -13,6 +13,7 @@ import (
 	"github.com/99designs/gqlgen/graphql"
 	"github.com/99designs/gqlgen/graphql/introspection"
 	"github.com/git-bug/git-bug/entity"
+	"github.com/git-bug/git-bug/repository"
 	"github.com/vektah/gqlparser/v2/ast"
 )
 
@@ -2539,6 +2540,16 @@ func (ec *executionContext) marshalNString2githubᚗcomᚋgitᚑbugᚋgitᚑbug
 	return v
 }
 
+func (ec *executionContext) unmarshalNString2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐHash(ctx context.Context, v any) (repository.Hash, error) {
+	var res repository.Hash
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNString2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐHash(ctx context.Context, sel ast.SelectionSet, v repository.Hash) graphql.Marshaler {
+	return v
+}
+
 func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) {
 	res, err := graphql.UnmarshalString(v)
 	return res, graphql.ErrorOnPath(ctx, err)

api/graphql/graph/repository.generated.go 🔗

@@ -9,9 +9,11 @@ import (
 	"strconv"
 	"sync"
 	"sync/atomic"
+	"time"
 
 	"github.com/99designs/gqlgen/graphql"
 	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/repository"
 	"github.com/vektah/gqlparser/v2/ast"
 )
 
@@ -24,6 +26,12 @@ type RepositoryResolver interface {
 	AllIdentities(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error)
 	Identity(ctx context.Context, obj *models.Repository, prefix string) (models.IdentityWrapper, error)
 	UserIdentity(ctx context.Context, obj *models.Repository) (models.IdentityWrapper, error)
+	Refs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, typeArg *models.GitRefType) (*models.GitRefConnection, error)
+	Tree(ctx context.Context, obj *models.Repository, ref string, path *string) ([]*repository.TreeEntry, error)
+	Blob(ctx context.Context, obj *models.Repository, ref string, path string) (*models.GitBlob, error)
+	Commits(ctx context.Context, obj *models.Repository, after *string, first *int, ref string, path *string, since *time.Time, until *time.Time) (*models.GitCommitConnection, error)
+	Commit(ctx context.Context, obj *models.Repository, hash string) (*models.GitCommitMeta, error)
+	LastCommits(ctx context.Context, obj *models.Repository, ref string, path *string, names []string) ([]*models.GitLastCommit, error)
 	ValidLabels(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error)
 }
 
@@ -248,6 +256,57 @@ func (ec *executionContext) field_Repository_allIdentities_argsLast(
 	return zeroVal, nil
 }
 
+func (ec *executionContext) field_Repository_blob_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Repository_blob_argsRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["ref"] = arg0
+	arg1, err := ec.field_Repository_blob_argsPath(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["path"] = arg1
+	return args, nil
+}
+func (ec *executionContext) field_Repository_blob_argsRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["ref"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("ref"))
+	if tmp, ok := rawArgs["ref"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_blob_argsPath(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["path"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("path"))
+	if tmp, ok := rawArgs["path"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
 func (ec *executionContext) field_Repository_bug_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
 	var err error
 	args := map[string]any{}
@@ -276,6 +335,177 @@ func (ec *executionContext) field_Repository_bug_argsPrefix(
 	return zeroVal, nil
 }
 
+func (ec *executionContext) field_Repository_commit_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Repository_commit_argsHash(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["hash"] = arg0
+	return args, nil
+}
+func (ec *executionContext) field_Repository_commit_argsHash(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["hash"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("hash"))
+	if tmp, ok := rawArgs["hash"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_commits_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Repository_commits_argsAfter(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["after"] = arg0
+	arg1, err := ec.field_Repository_commits_argsFirst(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["first"] = arg1
+	arg2, err := ec.field_Repository_commits_argsRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["ref"] = arg2
+	arg3, err := ec.field_Repository_commits_argsPath(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["path"] = arg3
+	arg4, err := ec.field_Repository_commits_argsSince(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["since"] = arg4
+	arg5, err := ec.field_Repository_commits_argsUntil(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["until"] = arg5
+	return args, nil
+}
+func (ec *executionContext) field_Repository_commits_argsAfter(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["after"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+	if tmp, ok := rawArgs["after"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_commits_argsFirst(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["first"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
+	if tmp, ok := rawArgs["first"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_commits_argsRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["ref"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("ref"))
+	if tmp, ok := rawArgs["ref"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_commits_argsPath(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["path"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("path"))
+	if tmp, ok := rawArgs["path"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_commits_argsSince(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*time.Time, error) {
+	if _, ok := rawArgs["since"]; !ok {
+		var zeroVal *time.Time
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("since"))
+	if tmp, ok := rawArgs["since"]; ok {
+		return ec.unmarshalOTime2ᚖtimeᚐTime(ctx, tmp)
+	}
+
+	var zeroVal *time.Time
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_commits_argsUntil(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*time.Time, error) {
+	if _, ok := rawArgs["until"]; !ok {
+		var zeroVal *time.Time
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("until"))
+	if tmp, ok := rawArgs["until"]; ok {
+		return ec.unmarshalOTime2ᚖtimeᚐTime(ctx, tmp)
+	}
+
+	var zeroVal *time.Time
+	return zeroVal, nil
+}
+
 func (ec *executionContext) field_Repository_identity_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
 	var err error
 	args := map[string]any{}
@@ -304,32 +534,111 @@ func (ec *executionContext) field_Repository_identity_argsPrefix(
 	return zeroVal, nil
 }
 
-func (ec *executionContext) field_Repository_validLabels_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+func (ec *executionContext) field_Repository_lastCommits_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
 	var err error
 	args := map[string]any{}
-	arg0, err := ec.field_Repository_validLabels_argsAfter(ctx, rawArgs)
+	arg0, err := ec.field_Repository_lastCommits_argsRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["ref"] = arg0
+	arg1, err := ec.field_Repository_lastCommits_argsPath(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["path"] = arg1
+	arg2, err := ec.field_Repository_lastCommits_argsNames(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["names"] = arg2
+	return args, nil
+}
+func (ec *executionContext) field_Repository_lastCommits_argsRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["ref"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("ref"))
+	if tmp, ok := rawArgs["ref"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_lastCommits_argsPath(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["path"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("path"))
+	if tmp, ok := rawArgs["path"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_lastCommits_argsNames(
+	ctx context.Context,
+	rawArgs map[string]any,
+) ([]string, error) {
+	if _, ok := rawArgs["names"]; !ok {
+		var zeroVal []string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("names"))
+	if tmp, ok := rawArgs["names"]; ok {
+		return ec.unmarshalNString2ᚕstringᚄ(ctx, tmp)
+	}
+
+	var zeroVal []string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_refs_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Repository_refs_argsAfter(ctx, rawArgs)
 	if err != nil {
 		return nil, err
 	}
 	args["after"] = arg0
-	arg1, err := ec.field_Repository_validLabels_argsBefore(ctx, rawArgs)
+	arg1, err := ec.field_Repository_refs_argsBefore(ctx, rawArgs)
 	if err != nil {
 		return nil, err
 	}
 	args["before"] = arg1
-	arg2, err := ec.field_Repository_validLabels_argsFirst(ctx, rawArgs)
+	arg2, err := ec.field_Repository_refs_argsFirst(ctx, rawArgs)
 	if err != nil {
 		return nil, err
 	}
 	args["first"] = arg2
-	arg3, err := ec.field_Repository_validLabels_argsLast(ctx, rawArgs)
+	arg3, err := ec.field_Repository_refs_argsLast(ctx, rawArgs)
 	if err != nil {
 		return nil, err
 	}
 	args["last"] = arg3
+	arg4, err := ec.field_Repository_refs_argsType(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["type"] = arg4
 	return args, nil
 }
-func (ec *executionContext) field_Repository_validLabels_argsAfter(
+func (ec *executionContext) field_Repository_refs_argsAfter(
 	ctx context.Context,
 	rawArgs map[string]any,
 ) (*string, error) {
@@ -337,80 +646,626 @@ func (ec *executionContext) field_Repository_validLabels_argsAfter(
 		var zeroVal *string
 		return zeroVal, nil
 	}
-
-	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-	if tmp, ok := rawArgs["after"]; ok {
-		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+	if tmp, ok := rawArgs["after"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_refs_argsBefore(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["before"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
+	if tmp, ok := rawArgs["before"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_refs_argsFirst(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["first"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
+	if tmp, ok := rawArgs["first"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_refs_argsLast(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["last"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last"))
+	if tmp, ok := rawArgs["last"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_refs_argsType(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*models.GitRefType, error) {
+	if _, ok := rawArgs["type"]; !ok {
+		var zeroVal *models.GitRefType
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("type"))
+	if tmp, ok := rawArgs["type"]; ok {
+		return ec.unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx, tmp)
+	}
+
+	var zeroVal *models.GitRefType
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_tree_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Repository_tree_argsRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["ref"] = arg0
+	arg1, err := ec.field_Repository_tree_argsPath(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["path"] = arg1
+	return args, nil
+}
+func (ec *executionContext) field_Repository_tree_argsRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (string, error) {
+	if _, ok := rawArgs["ref"]; !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("ref"))
+	if tmp, ok := rawArgs["ref"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_tree_argsPath(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["path"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("path"))
+	if tmp, ok := rawArgs["path"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_validLabels_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Repository_validLabels_argsAfter(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["after"] = arg0
+	arg1, err := ec.field_Repository_validLabels_argsBefore(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["before"] = arg1
+	arg2, err := ec.field_Repository_validLabels_argsFirst(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["first"] = arg2
+	arg3, err := ec.field_Repository_validLabels_argsLast(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["last"] = arg3
+	return args, nil
+}
+func (ec *executionContext) field_Repository_validLabels_argsAfter(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["after"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+	if tmp, ok := rawArgs["after"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_validLabels_argsBefore(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["before"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
+	if tmp, ok := rawArgs["before"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_validLabels_argsFirst(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["first"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
+	if tmp, ok := rawArgs["first"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Repository_validLabels_argsLast(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*int, error) {
+	if _, ok := rawArgs["last"]; !ok {
+		var zeroVal *int
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last"))
+	if tmp, ok := rawArgs["last"]; ok {
+		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	}
+
+	var zeroVal *int
+	return zeroVal, nil
+}
+
+// endregion ***************************** args.gotpl *****************************
+
+// region    ************************** directives.gotpl **************************
+
+// endregion ************************** directives.gotpl **************************
+
+// region    **************************** field.gotpl *****************************
+
+func (ec *executionContext) _Repository_name(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_name(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().Name(rctx, obj)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	fc.Result = res
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Repository_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Repository_allBugs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_allBugs(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().AllBugs(rctx, obj, fc.Args["after"].(*string), fc.Args["before"].(*string), fc.Args["first"].(*int), fc.Args["last"].(*int), fc.Args["query"].(*string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.BugConnection)
+	fc.Result = res
+	return ec.marshalNBugConnection2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugConnection(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Repository_allBugs(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "edges":
+				return ec.fieldContext_BugConnection_edges(ctx, field)
+			case "nodes":
+				return ec.fieldContext_BugConnection_nodes(ctx, field)
+			case "pageInfo":
+				return ec.fieldContext_BugConnection_pageInfo(ctx, field)
+			case "totalCount":
+				return ec.fieldContext_BugConnection_totalCount(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type BugConnection", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Repository_allBugs_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Repository_bug(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_bug(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().Bug(rctx, obj, fc.Args["prefix"].(string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(models.BugWrapper)
+	fc.Result = res
+	return ec.marshalOBug2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Repository_bug(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Bug_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Bug_humanId(ctx, field)
+			case "status":
+				return ec.fieldContext_Bug_status(ctx, field)
+			case "title":
+				return ec.fieldContext_Bug_title(ctx, field)
+			case "labels":
+				return ec.fieldContext_Bug_labels(ctx, field)
+			case "author":
+				return ec.fieldContext_Bug_author(ctx, field)
+			case "createdAt":
+				return ec.fieldContext_Bug_createdAt(ctx, field)
+			case "lastEdit":
+				return ec.fieldContext_Bug_lastEdit(ctx, field)
+			case "actors":
+				return ec.fieldContext_Bug_actors(ctx, field)
+			case "participants":
+				return ec.fieldContext_Bug_participants(ctx, field)
+			case "comments":
+				return ec.fieldContext_Bug_comments(ctx, field)
+			case "timeline":
+				return ec.fieldContext_Bug_timeline(ctx, field)
+			case "operations":
+				return ec.fieldContext_Bug_operations(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Bug", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Repository_bug_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Repository_allIdentities(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_allIdentities(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().AllIdentities(rctx, obj, fc.Args["after"].(*string), fc.Args["before"].(*string), fc.Args["first"].(*int), fc.Args["last"].(*int))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.IdentityConnection)
+	fc.Result = res
+	return ec.marshalNIdentityConnection2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityConnection(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Repository_allIdentities(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "edges":
+				return ec.fieldContext_IdentityConnection_edges(ctx, field)
+			case "nodes":
+				return ec.fieldContext_IdentityConnection_nodes(ctx, field)
+			case "pageInfo":
+				return ec.fieldContext_IdentityConnection_pageInfo(ctx, field)
+			case "totalCount":
+				return ec.fieldContext_IdentityConnection_totalCount(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type IdentityConnection", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Repository_allIdentities_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Repository_identity(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_identity(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().Identity(rctx, obj, fc.Args["prefix"].(string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
 	}
-
-	var zeroVal *string
-	return zeroVal, nil
+	res := resTmp.(models.IdentityWrapper)
+	fc.Result = res
+	return ec.marshalOIdentity2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityWrapper(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) field_Repository_validLabels_argsBefore(
-	ctx context.Context,
-	rawArgs map[string]any,
-) (*string, error) {
-	if _, ok := rawArgs["before"]; !ok {
-		var zeroVal *string
-		return zeroVal, nil
+func (ec *executionContext) fieldContext_Repository_identity(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Identity_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Identity_humanId(ctx, field)
+			case "name":
+				return ec.fieldContext_Identity_name(ctx, field)
+			case "email":
+				return ec.fieldContext_Identity_email(ctx, field)
+			case "login":
+				return ec.fieldContext_Identity_login(ctx, field)
+			case "displayName":
+				return ec.fieldContext_Identity_displayName(ctx, field)
+			case "avatarUrl":
+				return ec.fieldContext_Identity_avatarUrl(ctx, field)
+			case "isProtected":
+				return ec.fieldContext_Identity_isProtected(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Identity", field.Name)
+		},
 	}
-
-	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-	if tmp, ok := rawArgs["before"]; ok {
-		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Repository_identity_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
 	}
-
-	var zeroVal *string
-	return zeroVal, nil
+	return fc, nil
 }
 
-func (ec *executionContext) field_Repository_validLabels_argsFirst(
-	ctx context.Context,
-	rawArgs map[string]any,
-) (*int, error) {
-	if _, ok := rawArgs["first"]; !ok {
-		var zeroVal *int
-		return zeroVal, nil
+func (ec *executionContext) _Repository_userIdentity(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_userIdentity(ctx, field)
+	if err != nil {
+		return graphql.Null
 	}
-
-	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
-	if tmp, ok := rawArgs["first"]; ok {
-		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().UserIdentity(rctx, obj)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
 	}
-
-	var zeroVal *int
-	return zeroVal, nil
-}
-
-func (ec *executionContext) field_Repository_validLabels_argsLast(
-	ctx context.Context,
-	rawArgs map[string]any,
-) (*int, error) {
-	if _, ok := rawArgs["last"]; !ok {
-		var zeroVal *int
-		return zeroVal, nil
+	if resTmp == nil {
+		return graphql.Null
 	}
+	res := resTmp.(models.IdentityWrapper)
+	fc.Result = res
+	return ec.marshalOIdentity2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityWrapper(ctx, field.Selections, res)
+}
 
-	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("last"))
-	if tmp, ok := rawArgs["last"]; ok {
-		return ec.unmarshalOInt2ᚖint(ctx, tmp)
+func (ec *executionContext) fieldContext_Repository_userIdentity(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Identity_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Identity_humanId(ctx, field)
+			case "name":
+				return ec.fieldContext_Identity_name(ctx, field)
+			case "email":
+				return ec.fieldContext_Identity_email(ctx, field)
+			case "login":
+				return ec.fieldContext_Identity_login(ctx, field)
+			case "displayName":
+				return ec.fieldContext_Identity_displayName(ctx, field)
+			case "avatarUrl":
+				return ec.fieldContext_Identity_avatarUrl(ctx, field)
+			case "isProtected":
+				return ec.fieldContext_Identity_isProtected(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Identity", field.Name)
+		},
 	}
-
-	var zeroVal *int
-	return zeroVal, nil
+	return fc, nil
 }
 
-// endregion ***************************** args.gotpl *****************************
-
-// region    ************************** directives.gotpl **************************
-
-// endregion ************************** directives.gotpl **************************
-
-// region    **************************** field.gotpl *****************************
-
-func (ec *executionContext) _Repository_name(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_Repository_name(ctx, field)
+func (ec *executionContext) _Repository_refs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_refs(ctx, field)
 	if err != nil {
 		return graphql.Null
 	}

api/graphql/graph/root.generated.go 🔗

@@ -1122,6 +1122,18 @@ func (ec *executionContext) fieldContext_Query_repository(ctx context.Context, f
 				return ec.fieldContext_Repository_identity(ctx, field)
 			case "userIdentity":
 				return ec.fieldContext_Repository_userIdentity(ctx, field)
+			case "refs":
+				return ec.fieldContext_Repository_refs(ctx, field)
+			case "tree":
+				return ec.fieldContext_Repository_tree(ctx, field)
+			case "blob":
+				return ec.fieldContext_Repository_blob(ctx, field)
+			case "commits":
+				return ec.fieldContext_Repository_commits(ctx, field)
+			case "commit":
+				return ec.fieldContext_Repository_commit(ctx, field)
+			case "lastCommits":
+				return ec.fieldContext_Repository_lastCommits(ctx, field)
 			case "validLabels":
 				return ec.fieldContext_Repository_validLabels(ctx, field)
 			}

api/graphql/graph/root_.generated.go 🔗

@@ -7,6 +7,7 @@ import (
 	"context"
 	"errors"
 	"sync/atomic"
+	"time"
 
 	"github.com/99designs/gqlgen/graphql"
 	"github.com/99designs/gqlgen/graphql/introspection"
@@ -48,6 +49,7 @@ type ResolverRoot interface {
 	BugSetTitleOperation() BugSetTitleOperationResolver
 	BugSetTitleTimelineItem() BugSetTitleTimelineItemResolver
 	Color() ColorResolver
+	GitCommit() GitCommitResolver
 	Identity() IdentityResolver
 	Label() LabelResolver
 	Mutation() MutationResolver
@@ -293,6 +295,95 @@ type ComplexityRoot struct {
 		Type   func(childComplexity int) int
 	}
 
+	GitBlob struct {
+		Hash        func(childComplexity int) int
+		IsBinary    func(childComplexity int) int
+		IsTruncated func(childComplexity int) int
+		Path        func(childComplexity int) int
+		Size        func(childComplexity int) int
+		Text        func(childComplexity int) int
+	}
+
+	GitChangedFile struct {
+		OldPath func(childComplexity int) int
+		Path    func(childComplexity int) int
+		Status  func(childComplexity int) int
+	}
+
+	GitChangedFileConnection struct {
+		Nodes      func(childComplexity int) int
+		PageInfo   func(childComplexity int) int
+		TotalCount func(childComplexity int) int
+	}
+
+	GitCommit struct {
+		AuthorEmail func(childComplexity int) int
+		AuthorName  func(childComplexity int) int
+		Date        func(childComplexity int) int
+		Diff        func(childComplexity int, path string) int
+		Files       func(childComplexity int, after *string, before *string, first *int, last *int) int
+		FullMessage func(childComplexity int) int
+		Hash        func(childComplexity int) int
+		Message     func(childComplexity int) int
+		Parents     func(childComplexity int) int
+		ShortHash   func(childComplexity int) int
+	}
+
+	GitCommitConnection struct {
+		Nodes      func(childComplexity int) int
+		PageInfo   func(childComplexity int) int
+		TotalCount func(childComplexity int) int
+	}
+
+	GitDiffHunk struct {
+		Lines    func(childComplexity int) int
+		NewLines func(childComplexity int) int
+		NewStart func(childComplexity int) int
+		OldLines func(childComplexity int) int
+		OldStart func(childComplexity int) int
+	}
+
+	GitDiffLine struct {
+		Content func(childComplexity int) int
+		NewLine func(childComplexity int) int
+		OldLine func(childComplexity int) int
+		Type    func(childComplexity int) int
+	}
+
+	GitFileDiff struct {
+		Hunks    func(childComplexity int) int
+		IsBinary func(childComplexity int) int
+		IsDelete func(childComplexity int) int
+		IsNew    func(childComplexity int) int
+		OldPath  func(childComplexity int) int
+		Path     func(childComplexity int) int
+	}
+
+	GitLastCommit struct {
+		Commit func(childComplexity int) int
+		Name   func(childComplexity int) int
+	}
+
+	GitRef struct {
+		Hash      func(childComplexity int) int
+		IsDefault func(childComplexity int) int
+		Name      func(childComplexity int) int
+		ShortName func(childComplexity int) int
+		Type      func(childComplexity int) int
+	}
+
+	GitRefConnection struct {
+		Nodes      func(childComplexity int) int
+		PageInfo   func(childComplexity int) int
+		TotalCount func(childComplexity int) int
+	}
+
+	GitTreeEntry struct {
+		Hash       func(childComplexity int) int
+		Name       func(childComplexity int) int
+		ObjectType func(childComplexity int) int
+	}
+
 	Identity struct {
 		AvatarUrl   func(childComplexity int) int
 		DisplayName func(childComplexity int) int
@@ -383,9 +474,15 @@ type ComplexityRoot struct {
 	Repository struct {
 		AllBugs       func(childComplexity int, after *string, before *string, first *int, last *int, query *string) int
 		AllIdentities func(childComplexity int, after *string, before *string, first *int, last *int) int
+		Blob          func(childComplexity int, ref string, path string) int
 		Bug           func(childComplexity int, prefix string) int
+		Commit        func(childComplexity int, hash string) int
+		Commits       func(childComplexity int, after *string, first *int, ref string, path *string, since *time.Time, until *time.Time) int
 		Identity      func(childComplexity int, prefix string) int
+		LastCommits   func(childComplexity int, ref string, path *string, names []string) int
 		Name          func(childComplexity int) int
+		Refs          func(childComplexity int, after *string, before *string, first *int, last *int, typeArg *models.GitRefType) int
+		Tree          func(childComplexity int, ref string, path *string) int
 		UserIdentity  func(childComplexity int) int
 		ValidLabels   func(childComplexity int, after *string, before *string, first *int, last *int) int
 	}
@@ -1417,6 +1514,387 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.EntityEvent.Type(childComplexity), true
 
+	case "GitBlob.hash":
+		if e.complexity.GitBlob.Hash == nil {
+			break
+		}
+
+		return e.complexity.GitBlob.Hash(childComplexity), true
+
+	case "GitBlob.isBinary":
+		if e.complexity.GitBlob.IsBinary == nil {
+			break
+		}
+
+		return e.complexity.GitBlob.IsBinary(childComplexity), true
+
+	case "GitBlob.isTruncated":
+		if e.complexity.GitBlob.IsTruncated == nil {
+			break
+		}
+
+		return e.complexity.GitBlob.IsTruncated(childComplexity), true
+
+	case "GitBlob.path":
+		if e.complexity.GitBlob.Path == nil {
+			break
+		}
+
+		return e.complexity.GitBlob.Path(childComplexity), true
+
+	case "GitBlob.size":
+		if e.complexity.GitBlob.Size == nil {
+			break
+		}
+
+		return e.complexity.GitBlob.Size(childComplexity), true
+
+	case "GitBlob.text":
+		if e.complexity.GitBlob.Text == nil {
+			break
+		}
+
+		return e.complexity.GitBlob.Text(childComplexity), true
+
+	case "GitChangedFile.oldPath":
+		if e.complexity.GitChangedFile.OldPath == nil {
+			break
+		}
+
+		return e.complexity.GitChangedFile.OldPath(childComplexity), true
+
+	case "GitChangedFile.path":
+		if e.complexity.GitChangedFile.Path == nil {
+			break
+		}
+
+		return e.complexity.GitChangedFile.Path(childComplexity), true
+
+	case "GitChangedFile.status":
+		if e.complexity.GitChangedFile.Status == nil {
+			break
+		}
+
+		return e.complexity.GitChangedFile.Status(childComplexity), true
+
+	case "GitChangedFileConnection.nodes":
+		if e.complexity.GitChangedFileConnection.Nodes == nil {
+			break
+		}
+
+		return e.complexity.GitChangedFileConnection.Nodes(childComplexity), true
+
+	case "GitChangedFileConnection.pageInfo":
+		if e.complexity.GitChangedFileConnection.PageInfo == nil {
+			break
+		}
+
+		return e.complexity.GitChangedFileConnection.PageInfo(childComplexity), true
+
+	case "GitChangedFileConnection.totalCount":
+		if e.complexity.GitChangedFileConnection.TotalCount == nil {
+			break
+		}
+
+		return e.complexity.GitChangedFileConnection.TotalCount(childComplexity), true
+
+	case "GitCommit.authorEmail":
+		if e.complexity.GitCommit.AuthorEmail == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.AuthorEmail(childComplexity), true
+
+	case "GitCommit.authorName":
+		if e.complexity.GitCommit.AuthorName == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.AuthorName(childComplexity), true
+
+	case "GitCommit.date":
+		if e.complexity.GitCommit.Date == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.Date(childComplexity), true
+
+	case "GitCommit.diff":
+		if e.complexity.GitCommit.Diff == nil {
+			break
+		}
+
+		args, err := ec.field_GitCommit_diff_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.GitCommit.Diff(childComplexity, args["path"].(string)), true
+
+	case "GitCommit.files":
+		if e.complexity.GitCommit.Files == nil {
+			break
+		}
+
+		args, err := ec.field_GitCommit_files_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.GitCommit.Files(childComplexity, args["after"].(*string), args["before"].(*string), args["first"].(*int), args["last"].(*int)), true
+
+	case "GitCommit.fullMessage":
+		if e.complexity.GitCommit.FullMessage == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.FullMessage(childComplexity), true
+
+	case "GitCommit.hash":
+		if e.complexity.GitCommit.Hash == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.Hash(childComplexity), true
+
+	case "GitCommit.message":
+		if e.complexity.GitCommit.Message == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.Message(childComplexity), true
+
+	case "GitCommit.parents":
+		if e.complexity.GitCommit.Parents == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.Parents(childComplexity), true
+
+	case "GitCommit.shortHash":
+		if e.complexity.GitCommit.ShortHash == nil {
+			break
+		}
+
+		return e.complexity.GitCommit.ShortHash(childComplexity), true
+
+	case "GitCommitConnection.nodes":
+		if e.complexity.GitCommitConnection.Nodes == nil {
+			break
+		}
+
+		return e.complexity.GitCommitConnection.Nodes(childComplexity), true
+
+	case "GitCommitConnection.pageInfo":
+		if e.complexity.GitCommitConnection.PageInfo == nil {
+			break
+		}
+
+		return e.complexity.GitCommitConnection.PageInfo(childComplexity), true
+
+	case "GitCommitConnection.totalCount":
+		if e.complexity.GitCommitConnection.TotalCount == nil {
+			break
+		}
+
+		return e.complexity.GitCommitConnection.TotalCount(childComplexity), true
+
+	case "GitDiffHunk.lines":
+		if e.complexity.GitDiffHunk.Lines == nil {
+			break
+		}
+
+		return e.complexity.GitDiffHunk.Lines(childComplexity), true
+
+	case "GitDiffHunk.newLines":
+		if e.complexity.GitDiffHunk.NewLines == nil {
+			break
+		}
+
+		return e.complexity.GitDiffHunk.NewLines(childComplexity), true
+
+	case "GitDiffHunk.newStart":
+		if e.complexity.GitDiffHunk.NewStart == nil {
+			break
+		}
+
+		return e.complexity.GitDiffHunk.NewStart(childComplexity), true
+
+	case "GitDiffHunk.oldLines":
+		if e.complexity.GitDiffHunk.OldLines == nil {
+			break
+		}
+
+		return e.complexity.GitDiffHunk.OldLines(childComplexity), true
+
+	case "GitDiffHunk.oldStart":
+		if e.complexity.GitDiffHunk.OldStart == nil {
+			break
+		}
+
+		return e.complexity.GitDiffHunk.OldStart(childComplexity), true
+
+	case "GitDiffLine.content":
+		if e.complexity.GitDiffLine.Content == nil {
+			break
+		}
+
+		return e.complexity.GitDiffLine.Content(childComplexity), true
+
+	case "GitDiffLine.newLine":
+		if e.complexity.GitDiffLine.NewLine == nil {
+			break
+		}
+
+		return e.complexity.GitDiffLine.NewLine(childComplexity), true
+
+	case "GitDiffLine.oldLine":
+		if e.complexity.GitDiffLine.OldLine == nil {
+			break
+		}
+
+		return e.complexity.GitDiffLine.OldLine(childComplexity), true
+
+	case "GitDiffLine.type":
+		if e.complexity.GitDiffLine.Type == nil {
+			break
+		}
+
+		return e.complexity.GitDiffLine.Type(childComplexity), true
+
+	case "GitFileDiff.hunks":
+		if e.complexity.GitFileDiff.Hunks == nil {
+			break
+		}
+
+		return e.complexity.GitFileDiff.Hunks(childComplexity), true
+
+	case "GitFileDiff.isBinary":
+		if e.complexity.GitFileDiff.IsBinary == nil {
+			break
+		}
+
+		return e.complexity.GitFileDiff.IsBinary(childComplexity), true
+
+	case "GitFileDiff.isDelete":
+		if e.complexity.GitFileDiff.IsDelete == nil {
+			break
+		}
+
+		return e.complexity.GitFileDiff.IsDelete(childComplexity), true
+
+	case "GitFileDiff.isNew":
+		if e.complexity.GitFileDiff.IsNew == nil {
+			break
+		}
+
+		return e.complexity.GitFileDiff.IsNew(childComplexity), true
+
+	case "GitFileDiff.oldPath":
+		if e.complexity.GitFileDiff.OldPath == nil {
+			break
+		}
+
+		return e.complexity.GitFileDiff.OldPath(childComplexity), true
+
+	case "GitFileDiff.path":
+		if e.complexity.GitFileDiff.Path == nil {
+			break
+		}
+
+		return e.complexity.GitFileDiff.Path(childComplexity), true
+
+	case "GitLastCommit.commit":
+		if e.complexity.GitLastCommit.Commit == nil {
+			break
+		}
+
+		return e.complexity.GitLastCommit.Commit(childComplexity), true
+
+	case "GitLastCommit.name":
+		if e.complexity.GitLastCommit.Name == nil {
+			break
+		}
+
+		return e.complexity.GitLastCommit.Name(childComplexity), true
+
+	case "GitRef.hash":
+		if e.complexity.GitRef.Hash == nil {
+			break
+		}
+
+		return e.complexity.GitRef.Hash(childComplexity), true
+
+	case "GitRef.isDefault":
+		if e.complexity.GitRef.IsDefault == nil {
+			break
+		}
+
+		return e.complexity.GitRef.IsDefault(childComplexity), true
+
+	case "GitRef.name":
+		if e.complexity.GitRef.Name == nil {
+			break
+		}
+
+		return e.complexity.GitRef.Name(childComplexity), true
+
+	case "GitRef.shortName":
+		if e.complexity.GitRef.ShortName == nil {
+			break
+		}
+
+		return e.complexity.GitRef.ShortName(childComplexity), true
+
+	case "GitRef.type":
+		if e.complexity.GitRef.Type == nil {
+			break
+		}
+
+		return e.complexity.GitRef.Type(childComplexity), true
+
+	case "GitRefConnection.nodes":
+		if e.complexity.GitRefConnection.Nodes == nil {
+			break
+		}
+
+		return e.complexity.GitRefConnection.Nodes(childComplexity), true
+
+	case "GitRefConnection.pageInfo":
+		if e.complexity.GitRefConnection.PageInfo == nil {
+			break
+		}
+
+		return e.complexity.GitRefConnection.PageInfo(childComplexity), true
+
+	case "GitRefConnection.totalCount":
+		if e.complexity.GitRefConnection.TotalCount == nil {
+			break
+		}
+
+		return e.complexity.GitRefConnection.TotalCount(childComplexity), true
+
+	case "GitTreeEntry.hash":
+		if e.complexity.GitTreeEntry.Hash == nil {
+			break
+		}
+
+		return e.complexity.GitTreeEntry.Hash(childComplexity), true
+
+	case "GitTreeEntry.name":
+		if e.complexity.GitTreeEntry.Name == nil {
+			break
+		}
+
+		return e.complexity.GitTreeEntry.Name(childComplexity), true
+
+	case "GitTreeEntry.type":
+		if e.complexity.GitTreeEntry.ObjectType == nil {
+			break
+		}
+
+		return e.complexity.GitTreeEntry.ObjectType(childComplexity), true
+
 	case "Identity.avatarUrl":
 		if e.complexity.Identity.AvatarUrl == nil {
 			break
@@ -1832,6 +2310,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.AllIdentities(childComplexity, args["after"].(*string), args["before"].(*string), args["first"].(*int), args["last"].(*int)), true
 
+	case "Repository.blob":
+		if e.complexity.Repository.Blob == nil {
+			break
+		}
+
+		args, err := ec.field_Repository_blob_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Repository.Blob(childComplexity, args["ref"].(string), args["path"].(string)), true
+
 	case "Repository.bug":
 		if e.complexity.Repository.Bug == nil {
 			break
@@ -1844,6 +2334,30 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.Bug(childComplexity, args["prefix"].(string)), true
 
+	case "Repository.commit":
+		if e.complexity.Repository.Commit == nil {
+			break
+		}
+
+		args, err := ec.field_Repository_commit_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Repository.Commit(childComplexity, args["hash"].(string)), true
+
+	case "Repository.commits":
+		if e.complexity.Repository.Commits == nil {
+			break
+		}
+
+		args, err := ec.field_Repository_commits_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Repository.Commits(childComplexity, args["after"].(*string), args["first"].(*int), args["ref"].(string), args["path"].(*string), args["since"].(*time.Time), args["until"].(*time.Time)), true
+
 	case "Repository.identity":
 		if e.complexity.Repository.Identity == nil {
 			break
@@ -1856,6 +2370,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.Identity(childComplexity, args["prefix"].(string)), true
 
+	case "Repository.lastCommits":
+		if e.complexity.Repository.LastCommits == nil {
+			break
+		}
+
+		args, err := ec.field_Repository_lastCommits_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Repository.LastCommits(childComplexity, args["ref"].(string), args["path"].(*string), args["names"].([]string)), true
+
 	case "Repository.name":
 		if e.complexity.Repository.Name == nil {
 			break
@@ -1863,6 +2389,30 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.Name(childComplexity), true
 
+	case "Repository.refs":
+		if e.complexity.Repository.Refs == nil {
+			break
+		}
+
+		args, err := ec.field_Repository_refs_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Repository.Refs(childComplexity, args["after"].(*string), args["before"].(*string), args["first"].(*int), args["last"].(*int), args["type"].(*models.GitRefType)), true
+
+	case "Repository.tree":
+		if e.complexity.Repository.Tree == nil {
+			break
+		}
+
+		args, err := ec.field_Repository_tree_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Repository.Tree(childComplexity, args["ref"].(string), args["path"].(*string)), true
+
 	case "Repository.userIdentity":
 		if e.complexity.Repository.UserIdentity == nil {
 			break
@@ -2628,6 +3178,225 @@ directive @goTag(
     key: String!
     value: String
 ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
+
+directive @goEnum(
+    value: String
+) on ENUM_VALUE
+`, BuiltIn: false},
+	{Name: "../schema/git.graphql", Input: `"""A git branch or tag reference."""
+type GitRef {
+    """Full reference name, e.g. refs/heads/main or refs/tags/v1.0."""
+    name: String!
+    """Short name, e.g. main or v1.0."""
+    shortName: String!
+    """Whether this reference is a branch or a tag."""
+    type: GitRefType!
+    """Commit hash the reference points to."""
+    hash: String!
+    """True for the branch HEAD currently points to."""
+    isDefault: Boolean!
+}
+
+"""An entry in a git tree (directory listing)."""
+type GitTreeEntry
+@goModel(model: "github.com/git-bug/git-bug/repository.TreeEntry") {
+    """File or directory name within the parent tree."""
+    name: String!
+    """Whether this entry is a file, directory, symlink, or submodule."""
+    type: GitObjectType! @goField(name: "ObjectType")
+    """Git object hash."""
+    hash: String!
+}
+
+"""The content of a git blob (file)."""
+type GitBlob {
+    """Path of the file relative to the repository root."""
+    path: String!
+    """Git object hash. Can be used as a stable cache key or to construct a
+    raw download URL."""
+    hash: String!
+    """UTF-8 text content of the file. Null when isBinary is true or when
+    the file is too large to be returned inline (see isTruncated)."""
+    text: String
+    """Size in bytes."""
+    size: Int!
+    """True when the file contains null bytes and is treated as binary.
+    text will be null."""
+    isBinary: Boolean!
+    """True when the file exceeds the maximum inline size and text has been
+    omitted. Use the raw download endpoint to retrieve the full content."""
+    isTruncated: Boolean!
+}
+
+"""Metadata for a single git commit."""
+type GitCommit
+@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitCommitMeta") {
+    """Full SHA-1 commit hash."""
+    hash: String!
+    """Abbreviated commit hash, typically 8 characters."""
+    shortHash: String!
+    """First line of the commit message."""
+    message: String!
+    """Full commit message."""
+    fullMessage: String!
+    """Name of the commit author."""
+    authorName: String!
+    """Email address of the commit author."""
+    authorEmail: String!
+    """Timestamp from the author field (when the change was originally made)."""
+    date: Time!
+    """Hashes of parent commits. Empty for the initial commit."""
+    parents: [String!]!
+    """Files changed relative to the first parent (or the empty tree for the
+    initial commit)."""
+    files(
+        """Returns the elements in the list that come after the specified cursor."""
+        after: String
+        """Returns the elements in the list that come before the specified cursor."""
+        before: String
+        """Returns the first _n_ elements from the list."""
+        first: Int
+        """Returns the last _n_ elements from the list."""
+        last: Int
+    ): GitChangedFileConnection!
+    """Unified diff for a single file in this commit."""
+    diff(path: String!): GitFileDiff
+}
+
+"""The last commit that touched each requested entry in a directory."""
+type GitLastCommit {
+    """Entry name within the directory."""
+    name: String!
+    """Most recent commit that modified this entry."""
+    commit: GitCommit!
+}
+
+# ── connection types ──────────────────────────────────────────────────────────
+
+type GitRefConnection {
+    nodes: [GitRef!]!
+    pageInfo: PageInfo!
+    totalCount: Int!
+}
+
+"""Paginated list of commits."""
+type GitCommitConnection {
+    nodes: [GitCommit!]!
+    pageInfo: PageInfo!
+    totalCount: Int!
+}
+
+type GitChangedFileConnection {
+    nodes: [GitChangedFile!]!
+    pageInfo: PageInfo!
+    totalCount: Int!
+}
+
+# ── commit sub-types ──────────────────────────────────────────────────────────
+
+"""A file that was changed in a commit."""
+type GitChangedFile
+@goModel(model: "github.com/git-bug/git-bug/repository.ChangedFile") {
+    """Path of the file in the new version of the commit."""
+    path: String!
+    """Previous path, non-null only for renames."""
+    oldPath: String
+    """How the file was affected by the commit."""
+    status: GitChangeStatus!
+}
+
+"""The diff for a single file in a commit."""
+type GitFileDiff
+@goModel(model: "github.com/git-bug/git-bug/repository.FileDiff") {
+    """Path of the file in the new version."""
+    path: String!
+    """Previous path, non-null only for renames."""
+    oldPath: String
+    """True when the file is binary and no textual diff is available."""
+    isBinary: Boolean!
+    """True when the file was created in this commit."""
+    isNew: Boolean!
+    """True when the file was deleted in this commit."""
+    isDelete: Boolean!
+    """Contiguous blocks of changes. Empty for binary files."""
+    hunks: [GitDiffHunk!]!
+}
+
+"""A contiguous block of changes in a unified diff."""
+type GitDiffHunk
+@goModel(model: "github.com/git-bug/git-bug/repository.DiffHunk") {
+    """Starting line number in the old file."""
+    oldStart: Int!
+    """Number of lines from the old file included in this hunk."""
+    oldLines: Int!
+    """Starting line number in the new file."""
+    newStart: Int!
+    """Number of lines from the new file included in this hunk."""
+    newLines: Int!
+    """Lines in this hunk, including context, additions, and deletions."""
+    lines: [GitDiffLine!]!
+}
+
+"""A single line in a unified diff hunk."""
+type GitDiffLine
+@goModel(model: "github.com/git-bug/git-bug/repository.DiffLine") {
+    """Whether this line is context, an addition, or a deletion."""
+    type: GitDiffLineType!
+    """Raw line content, without the leading +/- prefix."""
+    content: String!
+    """Line number in the old file. 0 for added lines."""
+    oldLine: Int!
+    """Line number in the new file. 0 for deleted lines."""
+    newLine: Int!
+}
+
+# ── enums ─────────────────────────────────────────────────────────────────────
+
+"""The kind of git reference: a branch or a tag."""
+enum GitRefType
+@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitRefType") {
+    """A local branch (refs/heads/*)."""
+    BRANCH @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeBranch")
+    """An annotated or lightweight tag (refs/tags/*)."""
+    TAG @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeTag")
+}
+
+"""The type of object a git tree entry points to."""
+enum GitObjectType
+@goModel(model: "github.com/git-bug/git-bug/repository.ObjectType") {
+    """A directory."""
+    TREE
+    """A regular or executable file."""
+    BLOB
+    """A symbolic link."""
+    SYMLINK
+    """A git submodule."""
+    SUBMODULE
+}
+
+"""How a file was affected by a commit."""
+enum GitChangeStatus
+@goModel(model: "github.com/git-bug/git-bug/repository.ChangeStatus") {
+    """File was created in this commit."""
+    ADDED
+    """File content changed in this commit."""
+    MODIFIED
+    """File was removed in this commit."""
+    DELETED
+    """File was moved or renamed in this commit."""
+    RENAMED
+}
+
+"""The role of a line within a unified diff hunk."""
+enum GitDiffLineType
+@goModel(model: "github.com/git-bug/git-bug/repository.DiffLineType") {
+    """An unchanged line present in both old and new versions."""
+    CONTEXT
+    """A line added in the new version."""
+    ADDED
+    """A line removed from the old version."""
+    DELETED
+}
 `, BuiltIn: false},
 	{Name: "../schema/identity.graphql", Input: `"""Represents an identity"""
 type Identity implements Entity {
@@ -2725,7 +3494,7 @@ type OperationEdge {
 }
 `, BuiltIn: false},
 	{Name: "../schema/repository.graphql", Input: `type Repository {
-    """The name of the repository. Null for the default (unnamed) repository."""
+    """The name of the repository. Null for the default (unnamed) repository in a single-repo setup."""
     name: String
 
     """All the bugs"""
@@ -2742,6 +3511,7 @@ type OperationEdge {
         query: String
     ): BugConnection!
 
+    """Look up a bug by id prefix. Returns null if no bug matches the prefix."""
     bug(prefix: String!): Bug
 
     """All the identities"""
@@ -2756,11 +3526,59 @@ type OperationEdge {
         last: Int
     ): IdentityConnection!
 
+    """Look up an identity by id prefix. Returns null if no identity matches the prefix."""
     identity(prefix: String!): Identity
 
     """The identity created or selected by the user as its own"""
     userIdentity: Identity
 
+    """All branches and tags, optionally filtered by type."""
+    refs(
+        """Returns the elements in the list that come after the specified cursor."""
+        after: String
+        """Returns the elements in the list that come before the specified cursor."""
+        before: String
+        """Returns the first _n_ elements from the list."""
+        first: Int
+        """Returns the last _n_ elements from the list."""
+        last: Int
+        """Restrict to references of this type."""
+        type: GitRefType
+    ): GitRefConnection!
+
+    """Directory listing at path under ref. An empty path returns the root tree."""
+    tree(ref: String!, path: String): [GitTreeEntry!]!
+
+    """Content of the file at path under ref. Null if the path does not exist
+    or resolves to a tree rather than a blob."""
+    blob(ref: String!, path: String!): GitBlob
+
+    """Paginated commit log reachable from ref, optionally filtered to commits
+    touching path."""
+    commits(
+        """Returns the elements in the list that come after the specified cursor."""
+        after: String
+        """Returns the first _n_ elements from the list (max 100, default 20)."""
+        first: Int
+        """Branch name, tag name, full ref (e.g. refs/heads/main), or commit hash
+        to start the log from."""
+        ref: String!
+        """Restrict to commits that touched this path."""
+        path: String
+        """Restrict to commits authored on or after this timestamp."""
+        since: Time
+        """Restrict to commits authored before or on this timestamp."""
+        until: Time
+    ): GitCommitConnection!
+
+    """A single commit by hash. Returns null if the hash does not exist in the repository."""
+    commit(hash: String!): GitCommit
+
+    """The most recent commit that touched each of the named entries in the
+    directory at path under ref. Use this to populate last-commit info on a
+    tree listing without blocking the initial tree fetch."""
+    lastCommits(ref: String!, path: String, names: [String!]!): [GitLastCommit!]!
+
     """List of valid labels."""
     validLabels(
         """Returns the elements in the list that come after the specified cursor."""
@@ -2801,7 +3619,8 @@ type Query {
     """Server configuration and authentication mode."""
     serverConfig: ServerConfig!
 
-    """Access a repository by reference/name. If no ref is given, the default repository is returned if any."""
+    """Access a repository by reference/name. If no ref is given, the default repository is returned if any.
+    Returns null if the referenced repository does not exist."""
     repository(ref: String): Repository
 
     """List all registered repositories."""

api/graphql/graph/types.generated.go 🔗

@@ -809,4 +809,22 @@ func (ec *executionContext) marshalOHash2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbug
 	return ret
 }
 
+func (ec *executionContext) unmarshalOTime2ᚖtimeᚐTime(ctx context.Context, v any) (*time.Time, error) {
+	if v == nil {
+		return nil, nil
+	}
+	res, err := graphql.UnmarshalTime(v)
+	return &res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOTime2ᚖtimeᚐTime(ctx context.Context, sel ast.SelectionSet, v *time.Time) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	_ = sel
+	_ = ctx
+	res := graphql.MarshalTime(*v)
+	return res
+}
+
 // endregion ***************************** type.gotpl *****************************

api/graphql/graphql_test.go 🔗

@@ -24,7 +24,7 @@ func TestQueries(t *testing.T) {
 		require.NoError(t, event.Err)
 	}
 
-	handler := NewHandler(mrc, nil)
+	handler := NewHandler(mrc, ServerConfig{AuthMode: "local"}, nil)
 
 	c := client.New(handler)
 
@@ -220,6 +220,232 @@ func TestQueries(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+// TestGitBrowseQueries exercises the git-browsing GraphQL fields (commit, blob,
+// tree, commits, lastCommits) against a synthetic fixture repo with the same
+// commit graph used by RepoBrowseTest:
+//
+//	c1 ── c2 ── c3   refs/heads/main
+//	       └────────  refs/heads/feature
+//	c1 ←── refs/tags/v1.0
+func TestGitBrowseQueries(t *testing.T) {
+	repo := repository.CreateGoGitTestRepo(t, false)
+	require.NoError(t, repo.LocalConfig().StoreString("init.defaultBranch", "main"))
+
+	// ── build fixture ─────────────────────────────────────────────────────────
+
+	readmeV1 := []byte("# Hello\n")
+	readmeV3 := []byte("# Hello\n\n## Updated\n")
+	mainV1 := []byte("package main\n")
+	mainV2 := []byte("package main\n\n// updated\n")
+	libV1 := []byte("package lib\n")
+	utilV1 := []byte("package util\n")
+
+	hReadmeV1, err := repo.StoreData(readmeV1)
+	require.NoError(t, err)
+	hReadmeV3, err := repo.StoreData(readmeV3)
+	require.NoError(t, err)
+	hMainV1, err := repo.StoreData(mainV1)
+	require.NoError(t, err)
+	hMainV2, err := repo.StoreData(mainV2)
+	require.NoError(t, err)
+	hLibV1, err := repo.StoreData(libV1)
+	require.NoError(t, err)
+	hUtilV1, err := repo.StoreData(utilV1)
+	require.NoError(t, err)
+
+	srcTreeV1, err := repo.StoreTree([]repository.TreeEntry{
+		{ObjectType: repository.Blob, Hash: hLibV1, Name: "lib.go"},
+	})
+	require.NoError(t, err)
+	rootTreeV1, err := repo.StoreTree([]repository.TreeEntry{
+		{ObjectType: repository.Blob, Hash: hReadmeV1, Name: "README.md"},
+		{ObjectType: repository.Blob, Hash: hMainV1, Name: "main.go"},
+		{ObjectType: repository.Tree, Hash: srcTreeV1, Name: "src"},
+	})
+	require.NoError(t, err)
+
+	srcTreeV2, err := repo.StoreTree([]repository.TreeEntry{
+		{ObjectType: repository.Blob, Hash: hLibV1, Name: "lib.go"},
+		{ObjectType: repository.Blob, Hash: hUtilV1, Name: "util.go"},
+	})
+	require.NoError(t, err)
+	rootTreeV2, err := repo.StoreTree([]repository.TreeEntry{
+		{ObjectType: repository.Blob, Hash: hReadmeV1, Name: "README.md"},
+		{ObjectType: repository.Blob, Hash: hMainV2, Name: "main.go"},
+		{ObjectType: repository.Tree, Hash: srcTreeV2, Name: "src"},
+	})
+	require.NoError(t, err)
+
+	rootTreeV3, err := repo.StoreTree([]repository.TreeEntry{
+		{ObjectType: repository.Blob, Hash: hReadmeV3, Name: "README.md"},
+		{ObjectType: repository.Blob, Hash: hMainV2, Name: "main.go"},
+		{ObjectType: repository.Tree, Hash: srcTreeV2, Name: "src"},
+	})
+	require.NoError(t, err)
+
+	c1, err := repo.StoreCommit(rootTreeV1)
+	require.NoError(t, err)
+	c2, err := repo.StoreCommit(rootTreeV2, c1)
+	require.NoError(t, err)
+	c3, err := repo.StoreCommit(rootTreeV3, c2)
+	require.NoError(t, err)
+
+	require.NoError(t, repo.UpdateRef("refs/heads/main", c3))
+	require.NoError(t, repo.UpdateRef("refs/heads/feature", c2))
+	require.NoError(t, repo.UpdateRef("refs/tags/v1.0", c1))
+
+	// ── set up GraphQL handler ─────────────────────────────────────────────────
+
+	mrc := cache.NewMultiRepoCache()
+	_, events := mrc.RegisterDefaultRepository(repo)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
+	c := client.New(NewHandler(mrc, ServerConfig{AuthMode: "local"}, nil))
+
+	// ── commit ────────────────────────────────────────────────────────────────
+
+	t.Run("commit", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Commit struct {
+					Hash    string
+					Parents []string
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query($hash: String!) {
+			repository { commit(hash: $hash) { hash parents } }
+		}`, &resp, client.Var("hash", string(c3))))
+		got := resp.Repository.Commit
+		require.Equal(t, string(c3), got.Hash)
+		require.Equal(t, []string{string(c2)}, got.Parents)
+	})
+
+	t.Run("commit_not_found", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Commit *struct{ Hash string }
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { commit(hash: "0000000000000000000000000000000000000000") { hash } }
+		}`, &resp))
+		require.Nil(t, resp.Repository.Commit)
+	})
+
+	// ── blob ──────────────────────────────────────────────────────────────────
+
+	t.Run("blob", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Blob struct {
+					Hash     string
+					IsBinary bool
+					Size     int
+					Text     *string
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { blob(ref: "main", path: "README.md") { hash isBinary size text } }
+		}`, &resp))
+		got := resp.Repository.Blob
+		require.Equal(t, string(hReadmeV3), got.Hash)
+		require.False(t, got.IsBinary)
+		require.Equal(t, len(readmeV3), got.Size)
+		require.NotNil(t, got.Text)
+		require.Equal(t, string(readmeV3), *got.Text)
+	})
+
+	t.Run("blob_not_found", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Blob *struct{ Hash string }
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { blob(ref: "main", path: "nonexistent.go") { hash } }
+		}`, &resp))
+		require.Nil(t, resp.Repository.Blob)
+	})
+
+	// ── tree ──────────────────────────────────────────────────────────────────
+
+	t.Run("tree", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Tree []struct {
+					Name string
+					Type string `json:"type"`
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { tree(ref: "main", path: "") { name type } }
+		}`, &resp))
+		byName := make(map[string]string)
+		for _, e := range resp.Repository.Tree {
+			byName[e.Name] = e.Type
+		}
+		require.Equal(t, "BLOB", byName["README.md"])
+		require.Equal(t, "BLOB", byName["main.go"])
+		require.Equal(t, "TREE", byName["src"])
+	})
+
+	// ── commits ───────────────────────────────────────────────────────────────
+
+	t.Run("commits", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Commits struct {
+					TotalCount int
+					PageInfo   struct{ HasNextPage bool }
+					Nodes      []struct{ Hash string }
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository {
+				commits(ref: "main", first: 2) {
+					totalCount pageInfo { hasNextPage } nodes { hash }
+				}
+			}
+		}`, &resp))
+		got := resp.Repository.Commits
+		require.Equal(t, 2, got.TotalCount)
+		require.True(t, got.PageInfo.HasNextPage)
+		require.Equal(t, string(c3), got.Nodes[0].Hash)
+		require.Equal(t, string(c2), got.Nodes[1].Hash)
+	})
+
+	// ── lastCommits ───────────────────────────────────────────────────────────
+
+	t.Run("lastCommits", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				LastCommits []struct {
+					Name   string
+					Commit struct{ Hash string }
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository {
+				lastCommits(ref: "main", names: ["README.md", "main.go"]) {
+					name commit { hash }
+				}
+			}
+		}`, &resp))
+		byName := make(map[string]string)
+		for _, lc := range resp.Repository.LastCommits {
+			byName[lc.Name] = lc.Commit.Hash
+		}
+		require.Equal(t, string(c3), byName["README.md"]) // changed in c3
+		require.Equal(t, string(c2), byName["main.go"])   // changed in c2
+	})
+}
+
 func TestBugEventsSubscription(t *testing.T) {
 	repo := repository.CreateGoGitTestRepo(t, false)
 
@@ -229,7 +455,7 @@ func TestBugEventsSubscription(t *testing.T) {
 		require.NoError(t, event.Err)
 	}
 
-	h := NewHandler(mrc, nil)
+	h := NewHandler(mrc, ServerConfig{AuthMode: "local"}, nil)
 	c := client.New(h)
 
 	sub := c.Websocket(`subscription { bugEvents { type bug { id } } }`)

api/graphql/models/enums.go 🔗

@@ -0,0 +1,58 @@
+package models
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"strconv"
+)
+
+// GitRefType is the kind of git reference: a branch or a tag.
+type GitRefType string
+
+const (
+	// GitRefTypeBranch refers to a local branch (refs/heads/*).
+	GitRefTypeBranch GitRefType = "BRANCH"
+	// GitRefTypeTag refers to an annotated or lightweight tag (refs/tags/*).
+	GitRefTypeTag GitRefType = "TAG"
+)
+
+func (e GitRefType) IsValid() bool {
+	switch e {
+	case GitRefTypeBranch, GitRefTypeTag:
+		return true
+	}
+	return false
+}
+
+func (e GitRefType) String() string { return string(e) }
+
+func (e *GitRefType) UnmarshalGQL(v any) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+	*e = GitRefType(str)
+	if !e.IsValid() {
+		return fmt.Errorf("%s is not a valid GitRefType", str)
+	}
+	return nil
+}
+
+func (e GitRefType) MarshalGQL(w io.Writer) {
+	fmt.Fprint(w, strconv.Quote(e.String()))
+}
+
+func (e *GitRefType) UnmarshalJSON(b []byte) error {
+	s, err := strconv.Unquote(string(b))
+	if err != nil {
+		return err
+	}
+	return e.UnmarshalGQL(s)
+}
+
+func (e GitRefType) MarshalJSON() ([]byte, error) {
+	var buf bytes.Buffer
+	e.MarshalGQL(&buf)
+	return buf.Bytes(), nil
+}

api/graphql/models/gen_models.go 🔗

@@ -269,6 +269,67 @@ type EntityEvent struct {
 	Entity Entity                `json:"entity,omitempty"`
 }
 
+// The content of a git blob (file).
+type GitBlob struct {
+	// Path of the file relative to the repository root.
+	Path string `json:"path"`
+	// Git object hash. Can be used as a stable cache key or to construct a
+	//     raw download URL.
+	Hash string `json:"hash"`
+	// UTF-8 text content of the file. Null when isBinary is true or when
+	//     the file is too large to be returned inline (see isTruncated).
+	Text *string `json:"text,omitempty"`
+	// Size in bytes.
+	Size int `json:"size"`
+	// True when the file contains null bytes and is treated as binary.
+	//     text will be null.
+	IsBinary bool `json:"isBinary"`
+	// True when the file exceeds the maximum inline size and text has been
+	//     omitted. Use the raw download endpoint to retrieve the full content.
+	IsTruncated bool `json:"isTruncated"`
+}
+
+type GitChangedFileConnection struct {
+	Nodes      []*repository.ChangedFile `json:"nodes"`
+	PageInfo   *PageInfo                 `json:"pageInfo"`
+	TotalCount int                       `json:"totalCount"`
+}
+
+// Paginated list of commits.
+type GitCommitConnection struct {
+	Nodes      []*GitCommitMeta `json:"nodes"`
+	PageInfo   *PageInfo        `json:"pageInfo"`
+	TotalCount int              `json:"totalCount"`
+}
+
+// The last commit that touched each requested entry in a directory.
+type GitLastCommit struct {
+	// Entry name within the directory.
+	Name string `json:"name"`
+	// Most recent commit that modified this entry.
+	Commit *GitCommitMeta `json:"commit"`
+}
+
+// A git branch or tag reference.
+type GitRef struct {
+	// Full reference name, e.g. refs/heads/main or refs/tags/v1.0.
+	Name string `json:"name"`
+	// Short name, e.g. main or v1.0.
+	ShortName string `json:"shortName"`
+	// Whether this reference is a branch or a tag.
+	Type GitRefType `json:"type"`
+	// Commit hash the reference points to.
+	Hash string `json:"hash"`
+	// True for the branch HEAD currently points to.
+	IsDefault bool `json:"isDefault"`
+}
+
+type GitRefConnection struct {
+	Nodes      []*GitRef `json:"nodes"`
+	PageInfo   *PageInfo `json:"pageInfo"`
+	TotalCount int       `json:"totalCount"`
+}
+
 type IdentityConnection struct {
 	Edges      []*IdentityEdge   `json:"edges"`
 	Nodes      []IdentityWrapper `json:"nodes"`

api/graphql/models/models.go 🔗

@@ -3,6 +3,7 @@ package models
 
 import (
 	"github.com/git-bug/git-bug/cache"
+	"github.com/git-bug/git-bug/repository"
 )
 
 type ConnectionInput struct {
@@ -13,6 +14,12 @@ type ConnectionInput struct {
 }
 
 type Repository struct {
-	Cache *cache.MultiRepoCache
-	Repo  *cache.RepoCache
+	Repo *cache.RepoCache
+}
+
+// GitCommitMeta is a wrapper around a CommitMeta that includes the Repo,
+// to keep the repo context in sub-resolvers.
+type GitCommitMeta struct {
+	Repo *cache.RepoCache
+	repository.CommitMeta
 }

api/graphql/resolvers/git.go 🔗

@@ -0,0 +1,74 @@
+package resolvers
+
+import (
+	"context"
+
+	"github.com/git-bug/git-bug/api/graphql/connections"
+	"github.com/git-bug/git-bug/api/graphql/graph"
+	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/cache"
+	"github.com/git-bug/git-bug/repository"
+)
+
+const blobTruncateSize = 1 << 20 // 1 MiB
+
+var _ graph.GitCommitResolver = &gitCommitResolver{}
+
+type gitCommitResolver struct {
+	cache *cache.MultiRepoCache
+}
+
+func (r gitCommitResolver) ShortHash(_ context.Context, obj *models.GitCommitMeta) (string, error) {
+	s := string(obj.Hash)
+	if len(s) > 8 {
+		s = s[:8]
+	}
+	return s, nil
+}
+
+func (r gitCommitResolver) FullMessage(_ context.Context, obj *models.GitCommitMeta) (string, error) {
+	repo := obj.Repo.BrowseRepo()
+	detail, err := repo.CommitDetail(obj.Hash)
+	if err != nil {
+		return "", err
+	}
+	return detail.FullMessage, nil
+}
+
+func (r gitCommitResolver) Parents(_ context.Context, obj *models.GitCommitMeta) ([]string, error) {
+	out := make([]string, len(obj.Parents))
+	for i, h := range obj.Parents {
+		out[i] = string(h)
+	}
+	return out, nil
+}
+
+func (r gitCommitResolver) Files(_ context.Context, obj *models.GitCommitMeta, after *string, before *string, first *int, last *int) (*models.GitChangedFileConnection, error) {
+	repo := obj.Repo.BrowseRepo()
+	detail, err := repo.CommitDetail(obj.Hash)
+	if err != nil {
+		return nil, err
+	}
+
+	input := models.ConnectionInput{After: after, Before: before, First: first, Last: last}
+	edger := func(f repository.ChangedFile, offset int) connections.Edge {
+		return connections.CursorEdge{Cursor: connections.OffsetToCursor(offset)}
+	}
+	conMaker := func(_ []*connections.CursorEdge, nodes []repository.ChangedFile, info *models.PageInfo, total int) (*models.GitChangedFileConnection, error) {
+		ptrs := make([]*repository.ChangedFile, len(nodes))
+		for i := range nodes {
+			ptrs[i] = &nodes[i]
+		}
+		return &models.GitChangedFileConnection{Nodes: ptrs, PageInfo: info, TotalCount: total}, nil
+	}
+	return connections.Connection(detail.Files, edger, conMaker, input)
+}
+
+func (r gitCommitResolver) Diff(_ context.Context, obj *models.GitCommitMeta, path string) (*repository.FileDiff, error) {
+	repo := obj.Repo.BrowseRepo()
+	fd, err := repo.CommitFileDiff(obj.Hash, path)
+	if err != nil {
+		return nil, err
+	}
+	return &fd, nil
+}

api/graphql/resolvers/query.go 🔗

@@ -42,12 +42,11 @@ func (r rootQueryResolver) Repository(_ context.Context, ref *string) (*models.R
 	}
 
 	if err != nil {
-		return nil, err
+		return nil, nil
 	}
 
 	return &models.Repository{
-		Cache: r.cache,
-		Repo:  repo,
+		Repo: repo,
 	}, nil
 }
 
@@ -65,7 +64,7 @@ func (r rootQueryResolver) Repositories(_ context.Context, after *string, before
 
 	edger := func(repo *cache.RepoCache, offset int) connections.Edge {
 		return models.RepositoryEdge{
-			Node:   &models.Repository{Cache: r.cache, Repo: repo},
+			Node:   &models.Repository{Repo: repo},
 			Cursor: connections.OffsetToCursor(offset),
 		}
 	}

api/graphql/resolvers/repo.go 🔗

@@ -1,7 +1,13 @@
 package resolvers
 
 import (
+	"bytes"
 	"context"
+	"errors"
+	"io"
+	"math"
+	"sort"
+	"time"
 
 	"github.com/git-bug/git-bug/api/auth"
 	"github.com/git-bug/git-bug/api/graphql/connections"
@@ -10,6 +16,7 @@ import (
 	"github.com/git-bug/git-bug/entities/common"
 	"github.com/git-bug/git-bug/entity"
 	"github.com/git-bug/git-bug/query"
+	"github.com/git-bug/git-bug/repository"
 )
 
 var _ graph.RepositoryResolver = &repoResolver{}
@@ -17,6 +24,9 @@ var _ graph.RepositoryResolver = &repoResolver{}
 type repoResolver struct{}
 
 func (repoResolver) Name(_ context.Context, obj *models.Repository) (*string, error) {
+	if obj.Repo.IsDefaultRepo() {
+		return nil, nil
+	}
 	name := obj.Repo.Name()
 	if name == "" {
 		return nil, nil
@@ -90,6 +100,9 @@ func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *st
 
 func (repoResolver) Bug(_ context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error) {
 	excerpt, err := obj.Repo.Bugs().ResolveExcerptPrefix(prefix)
+	if entity.IsErrNotFound(err) {
+		return nil, nil
+	}
 	if err != nil {
 		return nil, err
 	}
@@ -149,6 +162,9 @@ func (repoResolver) AllIdentities(_ context.Context, obj *models.Repository, aft
 
 func (repoResolver) Identity(_ context.Context, obj *models.Repository, prefix string) (models.IdentityWrapper, error) {
 	excerpt, err := obj.Repo.Identities().ResolveExcerptPrefix(prefix)
+	if entity.IsErrNotFound(err) {
+		return nil, nil
+	}
 	if err != nil {
 		return nil, err
 	}
@@ -192,3 +208,216 @@ func (repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after
 
 	return connections.Connection(obj.Repo.Bugs().ValidLabels(), edger, conMaker, input)
 }
+
+func (repoResolver) Refs(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, typeArg *models.GitRefType) (*models.GitRefConnection, error) {
+	repo := obj.Repo.BrowseRepo()
+
+	var refs []*models.GitRef
+
+	if typeArg == nil || *typeArg == models.GitRefTypeBranch {
+		branches, err := repo.Branches()
+		if err != nil {
+			return nil, err
+		}
+		for _, b := range branches {
+			refs = append(refs, &models.GitRef{
+				Name:      "refs/heads/" + b.Name,
+				ShortName: b.Name,
+				Type:      models.GitRefTypeBranch,
+				Hash:      string(b.Hash),
+				IsDefault: b.IsDefault,
+			})
+		}
+	}
+
+	if typeArg == nil || *typeArg == models.GitRefTypeTag {
+		tags, err := repo.Tags()
+		if err != nil {
+			return nil, err
+		}
+		for _, t := range tags {
+			refs = append(refs, &models.GitRef{
+				Name:      "refs/tags/" + t.Name,
+				ShortName: t.Name,
+				Type:      models.GitRefTypeTag,
+				Hash:      string(t.Hash),
+			})
+		}
+	}
+
+	// Sort by type (branches before tags) then by short name for stable cursors.
+	sort.Slice(refs, func(i, j int) bool {
+		if refs[i].Type != refs[j].Type {
+			return refs[i].Type < refs[j].Type
+		}
+		return refs[i].ShortName < refs[j].ShortName
+	})
+
+	input := models.ConnectionInput{After: after, Before: before, First: first, Last: last}
+	edger := func(r *models.GitRef, offset int) connections.Edge {
+		return connections.CursorEdge{Cursor: connections.OffsetToCursor(offset)}
+	}
+	conMaker := func(edges []*connections.CursorEdge, nodes []*models.GitRef, info *models.PageInfo, total int) (*models.GitRefConnection, error) {
+		return &models.GitRefConnection{Nodes: nodes, PageInfo: info, TotalCount: total}, nil
+	}
+	return connections.Connection(refs, edger, conMaker, input)
+}
+
+func (repoResolver) Tree(_ context.Context, obj *models.Repository, ref string, path *string) ([]*repository.TreeEntry, error) {
+	repo := obj.Repo.BrowseRepo()
+	p := ""
+	if path != nil {
+		p = *path
+	}
+	entries, err := repo.TreeAtPath(ref, p)
+	if err != nil {
+		return nil, err
+	}
+	ptrs := make([]*repository.TreeEntry, len(entries))
+	for i := range entries {
+		ptrs[i] = &entries[i]
+	}
+	return ptrs, nil
+}
+
+func (repoResolver) Blob(_ context.Context, obj *models.Repository, ref string, path string) (*models.GitBlob, error) {
+	repo := obj.Repo.BrowseRepo()
+	rc, size, hash, err := repo.BlobAtPath(ref, path)
+	if errors.Is(err, repository.ErrNotFound) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	defer rc.Close()
+
+	limited := io.LimitReader(rc, blobTruncateSize+1)
+	data, err := io.ReadAll(limited)
+	if err != nil {
+		return nil, err
+	}
+
+	// Binary detection: same heuristic as git — a null byte anywhere in the
+	// content means binary. Git caps its probe at 8000 bytes; we probe all
+	// bytes read (up to blobTruncateSize+1) before slicing, so a NUL in the
+	// extra byte also triggers the flag. Files whose first blobTruncateSize
+	// bytes are all non-NUL will be reported as text even if the remainder is
+	// binary; this is a documented prefix-based heuristic.
+	isBinary := bytes.IndexByte(data, 0) >= 0
+
+	isTruncated := int64(len(data)) > blobTruncateSize
+	if isTruncated {
+		data = data[:blobTruncateSize]
+	}
+
+	blob := &models.GitBlob{
+		Path: path,
+		Hash: string(hash),
+		// GraphQL Int is 32-bit; clamp to avoid overflow on 32-bit platforms or for
+		// exceptionally large files (which will be truncated anyway).
+		Size:        int(min(size, int64(math.MaxInt32))),
+		IsBinary:    isBinary,
+		IsTruncated: isTruncated,
+	}
+	if !isBinary {
+		text := string(data)
+		blob.Text = &text
+	}
+	return blob, nil
+}
+
+func (repoResolver) Commits(_ context.Context, obj *models.Repository, after *string, first *int, ref string, path *string, since *time.Time, until *time.Time) (*models.GitCommitConnection, error) {
+	// This is not using the normal relay pagination (connection.Connection()), because that requires having the
+	// full list in memory. Here, go-git does a partial walk only, which is better.
+
+	repo := obj.Repo.BrowseRepo()
+
+	p := ""
+	if path != nil {
+		p = *path
+	}
+
+	const defaultFirst = 20
+	const maxFirst = 100
+
+	n := defaultFirst
+	if first != nil {
+		n = *first
+		if n > maxFirst {
+			n = maxFirst
+		}
+	}
+	limit := n + 1 // fetch one extra to detect hasNextPage
+
+	var afterHash repository.Hash
+	if after != nil {
+		afterHash = repository.Hash(*after)
+	}
+
+	commits, err := repo.CommitLog(ref, p, limit, afterHash, since, until)
+	if err != nil {
+		return nil, err
+	}
+
+	hasNextPage := false
+	if len(commits) > n {
+		hasNextPage = true
+		commits = commits[:n]
+	}
+
+	nodes := make([]*models.GitCommitMeta, len(commits))
+	for i := range commits {
+		nodes[i] = &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: commits[i]}
+	}
+
+	startCursor := ""
+	endCursor := ""
+	if len(nodes) > 0 {
+		startCursor = string(nodes[0].Hash)
+		endCursor = string(nodes[len(nodes)-1].Hash)
+	}
+
+	return &models.GitCommitConnection{
+		Nodes: nodes,
+		PageInfo: &models.PageInfo{
+			HasNextPage:     hasNextPage,
+			HasPreviousPage: after != nil,
+			StartCursor:     startCursor,
+			EndCursor:       endCursor,
+		},
+		TotalCount: len(nodes), // lower bound; exact total unknown without full walk
+	}, nil
+}
+
+func (repoResolver) Commit(_ context.Context, obj *models.Repository, hash string) (*models.GitCommitMeta, error) {
+	repo := obj.Repo.BrowseRepo()
+	detail, err := repo.CommitDetail(repository.Hash(hash))
+	if errors.Is(err, repository.ErrNotFound) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: detail.CommitMeta}, nil
+}
+
+func (repoResolver) LastCommits(_ context.Context, obj *models.Repository, ref string, path *string, names []string) ([]*models.GitLastCommit, error) {
+	repo := obj.Repo.BrowseRepo()
+	p := ""
+	if path != nil {
+		p = *path
+	}
+	byName, err := repo.LastCommitForEntries(ref, p, names)
+	if err != nil {
+		return nil, err
+	}
+	// Iterate over the input names to preserve caller-specified order.
+	result := make([]*models.GitLastCommit, 0, len(names))
+	for _, name := range names {
+		if meta, ok := byName[name]; ok {
+			m := meta
+			result = append(result, &models.GitLastCommit{Name: name, Commit: &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: m}})
+		}
+	}
+	return result, nil
+}

api/graphql/resolvers/root.go 🔗

@@ -63,3 +63,9 @@ func (RootResolver) Repository() graph.RepositoryResolver {
 func (RootResolver) Bug() graph.BugResolver {
 	return &bugResolver{}
 }
+
+func (r RootResolver) GitCommit() graph.GitCommitResolver {
+	return &gitCommitResolver{
+		cache: r.MultiRepoCache,
+	}
+}

api/graphql/schema/directives.graphql 🔗

@@ -16,3 +16,7 @@ directive @goTag(
     key: String!
     value: String
 ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
+
+directive @goEnum(
+    value: String
+) on ENUM_VALUE

api/graphql/schema/git.graphql 🔗

@@ -0,0 +1,214 @@
+"""A git branch or tag reference."""
+type GitRef {
+    """Full reference name, e.g. refs/heads/main or refs/tags/v1.0."""
+    name: String!
+    """Short name, e.g. main or v1.0."""
+    shortName: String!
+    """Whether this reference is a branch or a tag."""
+    type: GitRefType!
+    """Commit hash the reference points to."""
+    hash: String!
+    """True for the branch HEAD currently points to."""
+    isDefault: Boolean!
+}
+
+"""An entry in a git tree (directory listing)."""
+type GitTreeEntry
+@goModel(model: "github.com/git-bug/git-bug/repository.TreeEntry") {
+    """File or directory name within the parent tree."""
+    name: String!
+    """Whether this entry is a file, directory, symlink, or submodule."""
+    type: GitObjectType! @goField(name: "ObjectType")
+    """Git object hash."""
+    hash: String!
+}
+
+"""The content of a git blob (file)."""
+type GitBlob {
+    """Path of the file relative to the repository root."""
+    path: String!
+    """Git object hash. Can be used as a stable cache key or to construct a
+    raw download URL."""
+    hash: String!
+    """UTF-8 text content of the file. Null when isBinary is true or when
+    the file is too large to be returned inline (see isTruncated)."""
+    text: String
+    """Size in bytes."""
+    size: Int!
+    """True when the file contains null bytes and is treated as binary.
+    text will be null."""
+    isBinary: Boolean!
+    """True when the file exceeds the maximum inline size and text has been
+    omitted. Use the raw download endpoint to retrieve the full content."""
+    isTruncated: Boolean!
+}
+
+"""Metadata for a single git commit."""
+type GitCommit
+@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitCommitMeta") {
+    """Full SHA-1 commit hash."""
+    hash: String!
+    """Abbreviated commit hash, typically 8 characters."""
+    shortHash: String!
+    """First line of the commit message."""
+    message: String!
+    """Full commit message."""
+    fullMessage: String!
+    """Name of the commit author."""
+    authorName: String!
+    """Email address of the commit author."""
+    authorEmail: String!
+    """Timestamp from the author field (when the change was originally made)."""
+    date: Time!
+    """Hashes of parent commits. Empty for the initial commit."""
+    parents: [String!]!
+    """Files changed relative to the first parent (or the empty tree for the
+    initial commit)."""
+    files(
+        """Returns the elements in the list that come after the specified cursor."""
+        after: String
+        """Returns the elements in the list that come before the specified cursor."""
+        before: String
+        """Returns the first _n_ elements from the list."""
+        first: Int
+        """Returns the last _n_ elements from the list."""
+        last: Int
+    ): GitChangedFileConnection!
+    """Unified diff for a single file in this commit."""
+    diff(path: String!): GitFileDiff
+}
+
+"""The last commit that touched each requested entry in a directory."""
+type GitLastCommit {
+    """Entry name within the directory."""
+    name: String!
+    """Most recent commit that modified this entry."""
+    commit: GitCommit!
+}
+
+# ── connection types ──────────────────────────────────────────────────────────
+
+type GitRefConnection {
+    nodes: [GitRef!]!
+    pageInfo: PageInfo!
+    totalCount: Int!
+}
+
+"""Paginated list of commits."""
+type GitCommitConnection {
+    nodes: [GitCommit!]!
+    pageInfo: PageInfo!
+    totalCount: Int!
+}
+
+type GitChangedFileConnection {
+    nodes: [GitChangedFile!]!
+    pageInfo: PageInfo!
+    totalCount: Int!
+}
+
+# ── commit sub-types ──────────────────────────────────────────────────────────
+
+"""A file that was changed in a commit."""
+type GitChangedFile
+@goModel(model: "github.com/git-bug/git-bug/repository.ChangedFile") {
+    """Path of the file in the new version of the commit."""
+    path: String!
+    """Previous path, non-null only for renames."""
+    oldPath: String
+    """How the file was affected by the commit."""
+    status: GitChangeStatus!
+}
+
+"""The diff for a single file in a commit."""
+type GitFileDiff
+@goModel(model: "github.com/git-bug/git-bug/repository.FileDiff") {
+    """Path of the file in the new version."""
+    path: String!
+    """Previous path, non-null only for renames."""
+    oldPath: String
+    """True when the file is binary and no textual diff is available."""
+    isBinary: Boolean!
+    """True when the file was created in this commit."""
+    isNew: Boolean!
+    """True when the file was deleted in this commit."""
+    isDelete: Boolean!
+    """Contiguous blocks of changes. Empty for binary files."""
+    hunks: [GitDiffHunk!]!
+}
+
+"""A contiguous block of changes in a unified diff."""
+type GitDiffHunk
+@goModel(model: "github.com/git-bug/git-bug/repository.DiffHunk") {
+    """Starting line number in the old file."""
+    oldStart: Int!
+    """Number of lines from the old file included in this hunk."""
+    oldLines: Int!
+    """Starting line number in the new file."""
+    newStart: Int!
+    """Number of lines from the new file included in this hunk."""
+    newLines: Int!
+    """Lines in this hunk, including context, additions, and deletions."""
+    lines: [GitDiffLine!]!
+}
+
+"""A single line in a unified diff hunk."""
+type GitDiffLine
+@goModel(model: "github.com/git-bug/git-bug/repository.DiffLine") {
+    """Whether this line is context, an addition, or a deletion."""
+    type: GitDiffLineType!
+    """Raw line content, without the leading +/- prefix."""
+    content: String!
+    """Line number in the old file. 0 for added lines."""
+    oldLine: Int!
+    """Line number in the new file. 0 for deleted lines."""
+    newLine: Int!
+}
+
+# ── enums ─────────────────────────────────────────────────────────────────────
+
+"""The kind of git reference: a branch or a tag."""
+enum GitRefType
+@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitRefType") {
+    """A local branch (refs/heads/*)."""
+    BRANCH @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeBranch")
+    """An annotated or lightweight tag (refs/tags/*)."""
+    TAG @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeTag")
+}
+
+"""The type of object a git tree entry points to."""
+enum GitObjectType
+@goModel(model: "github.com/git-bug/git-bug/repository.ObjectType") {
+    """A directory."""
+    TREE
+    """A regular or executable file."""
+    BLOB
+    """A symbolic link."""
+    SYMLINK
+    """A git submodule."""
+    SUBMODULE
+}
+
+"""How a file was affected by a commit."""
+enum GitChangeStatus
+@goModel(model: "github.com/git-bug/git-bug/repository.ChangeStatus") {
+    """File was created in this commit."""
+    ADDED
+    """File content changed in this commit."""
+    MODIFIED
+    """File was removed in this commit."""
+    DELETED
+    """File was moved or renamed in this commit."""
+    RENAMED
+}
+
+"""The role of a line within a unified diff hunk."""
+enum GitDiffLineType
+@goModel(model: "github.com/git-bug/git-bug/repository.DiffLineType") {
+    """An unchanged line present in both old and new versions."""
+    CONTEXT
+    """A line added in the new version."""
+    ADDED
+    """A line removed from the old version."""
+    DELETED
+}

api/graphql/schema/repository.graphql 🔗

@@ -1,5 +1,5 @@
 type Repository {
-    """The name of the repository. Null for the default (unnamed) repository."""
+    """The name of the repository. Null for the default (unnamed) repository in a single-repo setup."""
     name: String
 
     """All the bugs"""
@@ -16,6 +16,7 @@ type Repository {
         query: String
     ): BugConnection!
 
+    """Look up a bug by id prefix. Returns null if no bug matches the prefix."""
     bug(prefix: String!): Bug
 
     """All the identities"""
@@ -30,11 +31,59 @@ type Repository {
         last: Int
     ): IdentityConnection!
 
+    """Look up an identity by id prefix. Returns null if no identity matches the prefix."""
     identity(prefix: String!): Identity
 
     """The identity created or selected by the user as its own"""
     userIdentity: Identity
 
+    """All branches and tags, optionally filtered by type."""
+    refs(
+        """Returns the elements in the list that come after the specified cursor."""
+        after: String
+        """Returns the elements in the list that come before the specified cursor."""
+        before: String
+        """Returns the first _n_ elements from the list."""
+        first: Int
+        """Returns the last _n_ elements from the list."""
+        last: Int
+        """Restrict to references of this type."""
+        type: GitRefType
+    ): GitRefConnection!
+
+    """Directory listing at path under ref. An empty path returns the root tree."""
+    tree(ref: String!, path: String): [GitTreeEntry!]!
+
+    """Content of the file at path under ref. Null if the path does not exist
+    or resolves to a tree rather than a blob."""
+    blob(ref: String!, path: String!): GitBlob
+
+    """Paginated commit log reachable from ref, optionally filtered to commits
+    touching path."""
+    commits(
+        """Returns the elements in the list that come after the specified cursor."""
+        after: String
+        """Returns the first _n_ elements from the list (max 100, default 20)."""
+        first: Int
+        """Branch name, tag name, full ref (e.g. refs/heads/main), or commit hash
+        to start the log from."""
+        ref: String!
+        """Restrict to commits that touched this path."""
+        path: String
+        """Restrict to commits authored on or after this timestamp."""
+        since: Time
+        """Restrict to commits authored before or on this timestamp."""
+        until: Time
+    ): GitCommitConnection!
+
+    """A single commit by hash. Returns null if the hash does not exist in the repository."""
+    commit(hash: String!): GitCommit
+
+    """The most recent commit that touched each of the named entries in the
+    directory at path under ref. Use this to populate last-commit info on a
+    tree listing without blocking the initial tree fetch."""
+    lastCommits(ref: String!, path: String, names: [String!]!): [GitLastCommit!]!
+
     """List of valid labels."""
     validLabels(
         """Returns the elements in the list that come after the specified cursor."""

api/graphql/schema/root.graphql 🔗

@@ -13,7 +13,8 @@ type Query {
     """Server configuration and authentication mode."""
     serverConfig: ServerConfig!
 
-    """Access a repository by reference/name. If no ref is given, the default repository is returned if any."""
+    """Access a repository by reference/name. If no ref is given, the default repository is returned if any.
+    Returns null if the referenced repository does not exist."""
     repository(ref: String): Repository
 
     """List all registered repositories."""

api/http/git_browse_handler.go 🔗

@@ -1,566 +0,0 @@
-package http
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"strconv"
-	"strings"
-
-	"github.com/gorilla/mux"
-
-	"github.com/git-bug/git-bug/cache"
-	"github.com/git-bug/git-bug/repository"
-)
-
-// ── shared helpers ────────────────────────────────────────────────────────────
-
-func writeJSON(w http.ResponseWriter, v any) {
-	w.Header().Set("Content-Type", "application/json")
-	if err := json.NewEncoder(w).Encode(v); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-	}
-}
-
-// repoFromPath resolves the repository from the {owner} and {repo} mux path
-// variables. "_" is the wildcard value: owner is always ignored (single-owner
-// for now), and repo "_" resolves to the default repository.
-func repoFromPath(mrc *cache.MultiRepoCache, r *http.Request) (*cache.RepoCache, error) {
-	repoVar := mux.Vars(r)["repo"]
-	if repoVar == "_" {
-		return mrc.DefaultRepo()
-	}
-	return mrc.ResolveRepo(repoVar)
-}
-
-// browseRepo resolves the repository and asserts it implements RepoBrowse.
-func browseRepo(mrc *cache.MultiRepoCache, r *http.Request) (repository.ClockedRepo, repository.RepoBrowse, error) {
-	rc, err := repoFromPath(mrc, r)
-	if err != nil {
-		return nil, nil, err
-	}
-	underlying := rc.GetRepo()
-	br, ok := underlying.(repository.RepoBrowse)
-	if !ok {
-		return nil, nil, fmt.Errorf("repository does not support code browsing")
-	}
-	return underlying, br, nil
-}
-
-// resolveRef tries refs/heads/<ref>, refs/tags/<ref>, then a raw hash.
-func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) {
-	for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} {
-		h, err := repo.ResolveRef(prefix + ref)
-		if err == nil {
-			return h, nil
-		}
-	}
-	return "", repository.ErrNotFound
-}
-
-// resolveTreeAtPath walks the git tree of a commit down to the given path.
-func resolveTreeAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) ([]repository.TreeEntry, error) {
-	commit, err := repo.ReadCommit(commitHash)
-	if err != nil {
-		return nil, err
-	}
-
-	entries, err := repo.ReadTree(commit.TreeHash)
-	if err != nil {
-		return nil, err
-	}
-
-	if path == "" {
-		return entries, nil
-	}
-
-	for _, segment := range strings.Split(path, "/") {
-		if segment == "" {
-			continue
-		}
-		entry, ok := repository.SearchTreeEntry(entries, segment)
-		if !ok {
-			return nil, repository.ErrNotFound
-		}
-		if entry.ObjectType != repository.Tree {
-			return nil, repository.ErrNotFound
-		}
-		entries, err = repo.ReadTree(entry.Hash)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return entries, nil
-}
-
-// resolveBlobAtPath walks the tree to the given file path and returns its hash.
-func resolveBlobAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) (repository.Hash, error) {
-	parts := strings.Split(path, "/")
-	dirPath := strings.Join(parts[:len(parts)-1], "/")
-	fileName := parts[len(parts)-1]
-
-	entries, err := resolveTreeAtPath(repo, commitHash, dirPath)
-	if err != nil {
-		return "", err
-	}
-
-	entry, ok := repository.SearchTreeEntry(entries, fileName)
-	if !ok {
-		return "", repository.ErrNotFound
-	}
-	if entry.ObjectType != repository.Blob {
-		return "", repository.ErrNotFound
-	}
-	return entry.Hash, nil
-}
-
-// isBinaryContent returns true if data contains a null byte (simple heuristic).
-func isBinaryContent(data []byte) bool {
-	for _, b := range data {
-		if b == 0 {
-			return true
-		}
-	}
-	return false
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/refs ────────────────────────────────────
-
-type gitRefsHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitRefsHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitRefsHandler{mrc: mrc}
-}
-
-type refResponse struct {
-	Name      string `json:"name"`
-	ShortName string `json:"shortName"`
-	Type      string `json:"type"` // "branch" | "tag"
-	Hash      string `json:"hash"`
-	IsDefault bool   `json:"isDefault"`
-}
-
-func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	repo, br, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	defaultBranch, _ := br.GetDefaultBranch()
-
-	var refs []refResponse
-	for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
-		names, err := repo.ListRefs(prefix)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		for _, name := range names {
-			hash, err := repo.ResolveRef(name)
-			if err != nil {
-				continue
-			}
-			refType := "branch"
-			if prefix == "refs/tags/" {
-				refType = "tag"
-			}
-			short := strings.TrimPrefix(name, prefix)
-			refs = append(refs, refResponse{
-				Name:      name,
-				ShortName: short,
-				Type:      refType,
-				Hash:      hash.String(),
-				IsDefault: short == defaultBranch,
-			})
-		}
-	}
-
-	writeJSON(w, refs)
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/trees/{ref}?path= ──────────────────────
-
-type gitTreeHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitTreeHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitTreeHandler{mrc: mrc}
-}
-
-type treeEntryResponse struct {
-	Name       string              `json:"name"`
-	Type       string              `json:"type"` // "tree" | "blob"
-	Hash       string              `json:"hash"`
-	Mode       string              `json:"mode"`
-	LastCommit *commitMetaResponse `json:"lastCommit,omitempty"`
-}
-
-func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ref := mux.Vars(r)["ref"]
-	path := r.URL.Query().Get("path")
-
-	repo, br, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	commitHash, err := resolveRef(repo, ref)
-	if err != nil {
-		http.Error(w, "ref not found", http.StatusNotFound)
-		return
-	}
-
-	entries, err := resolveTreeAtPath(repo, commitHash, path)
-	if err == repository.ErrNotFound {
-		http.Error(w, "path not found", http.StatusNotFound)
-		return
-	}
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	names := make([]string, len(entries))
-	for i, e := range entries {
-		names[i] = e.Name
-	}
-	lastCommits, _ := br.LastCommitForEntries(ref, path, names)
-
-	resp := make([]treeEntryResponse, 0, len(entries))
-	for _, e := range entries {
-		objType := "blob"
-		mode := "100644"
-		if e.ObjectType == repository.Tree {
-			objType = "tree"
-			mode = "040000"
-		}
-		item := treeEntryResponse{
-			Name: e.Name,
-			Type: objType,
-			Hash: e.Hash.String(),
-			Mode: mode,
-		}
-		if cm, ok := lastCommits[e.Name]; ok {
-			item.LastCommit = toCommitMetaResponse(cm)
-		}
-		resp = append(resp, item)
-	}
-
-	writeJSON(w, resp)
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/blobs/{ref}?path= ──────────────────────
-
-type gitBlobHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitBlobHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitBlobHandler{mrc: mrc}
-}
-
-type blobResponse struct {
-	Path     string `json:"path"`
-	Content  string `json:"content"`
-	Size     int    `json:"size"`
-	IsBinary bool   `json:"isBinary"`
-}
-
-func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ref := mux.Vars(r)["ref"]
-	path := r.URL.Query().Get("path")
-
-	if path == "" {
-		http.Error(w, "missing path", http.StatusBadRequest)
-		return
-	}
-
-	repo, _, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	commitHash, err := resolveRef(repo, ref)
-	if err != nil {
-		http.Error(w, "ref not found", http.StatusNotFound)
-		return
-	}
-
-	blobHash, err := resolveBlobAtPath(repo, commitHash, path)
-	if err == repository.ErrNotFound {
-		http.Error(w, "path not found", http.StatusNotFound)
-		return
-	}
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	data, err := repo.ReadData(blobHash)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	isBinary := isBinaryContent(data)
-	content := ""
-	if !isBinary {
-		content = string(data)
-	}
-
-	writeJSON(w, blobResponse{
-		Path:     path,
-		Content:  content,
-		Size:     len(data),
-		IsBinary: isBinary,
-	})
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/raw/{ref}/{path} ───────────────────────
-// Serves the raw file content for download. ref and path are both in the URL
-// path, producing human-readable download URLs like:
-//
-//	/api/repos/_/_/git/raw/main/src/foo/bar.go
-
-type gitRawHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitRawHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitRawHandler{mrc: mrc}
-}
-
-func (h *gitRawHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ref := mux.Vars(r)["ref"]
-	path := mux.Vars(r)["path"]
-
-	if path == "" {
-		http.Error(w, "missing path", http.StatusBadRequest)
-		return
-	}
-
-	repo, _, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	commitHash, err := resolveRef(repo, ref)
-	if err != nil {
-		http.Error(w, "ref not found", http.StatusNotFound)
-		return
-	}
-
-	blobHash, err := resolveBlobAtPath(repo, commitHash, path)
-	if err == repository.ErrNotFound {
-		http.Error(w, "path not found", http.StatusNotFound)
-		return
-	}
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	data, err := repo.ReadData(blobHash)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	fileName := path[strings.LastIndex(path, "/")+1:]
-	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, fileName))
-	w.Header().Set("Content-Type", "application/octet-stream")
-	w.Write(data)
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/commits?ref=&path=&limit=&after= ───────
-
-type gitCommitsHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitCommitsHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitCommitsHandler{mrc: mrc}
-}
-
-type commitMetaResponse struct {
-	Hash        string   `json:"hash"`
-	ShortHash   string   `json:"shortHash"`
-	Message     string   `json:"message"`
-	AuthorName  string   `json:"authorName"`
-	AuthorEmail string   `json:"authorEmail"`
-	Date        string   `json:"date"` // RFC3339
-	Parents     []string `json:"parents"`
-}
-
-func toCommitMetaResponse(m repository.CommitMeta) *commitMetaResponse {
-	parents := make([]string, len(m.Parents))
-	for i, p := range m.Parents {
-		parents[i] = p.String()
-	}
-	return &commitMetaResponse{
-		Hash:        m.Hash.String(),
-		ShortHash:   m.ShortHash,
-		Message:     m.Message,
-		AuthorName:  m.AuthorName,
-		AuthorEmail: m.AuthorEmail,
-		Date:        m.Date.UTC().Format("2006-01-02T15:04:05Z"),
-		Parents:     parents,
-	}
-}
-
-func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ref := r.URL.Query().Get("ref")
-	path := r.URL.Query().Get("path")
-	after := repository.Hash(r.URL.Query().Get("after"))
-
-	limit := 20
-	if l := r.URL.Query().Get("limit"); l != "" {
-		if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
-			limit = n
-		}
-	}
-
-	if ref == "" {
-		http.Error(w, "missing ref", http.StatusBadRequest)
-		return
-	}
-
-	_, br, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	commits, err := br.CommitLog(ref, path, limit, after)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	resp := make([]*commitMetaResponse, len(commits))
-	for i, c := range commits {
-		resp[i] = toCommitMetaResponse(c)
-	}
-	writeJSON(w, resp)
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/commits/{sha} ──────────────────────────
-
-type gitCommitHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitCommitHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitCommitHandler{mrc: mrc}
-}
-
-// ── GET /api/repos/{owner}/{repo}/git/commits/{sha}/diff?path= ───────────────
-
-type gitCommitDiffHandler struct{ mrc *cache.MultiRepoCache }
-
-func NewGitCommitDiffHandler(mrc *cache.MultiRepoCache) http.Handler {
-	return &gitCommitDiffHandler{mrc: mrc}
-}
-
-func (h *gitCommitDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	sha := mux.Vars(r)["sha"]
-	filePath := r.URL.Query().Get("path")
-	if filePath == "" {
-		http.Error(w, "missing path", http.StatusBadRequest)
-		return
-	}
-
-	_, br, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	fd, err := br.CommitFileDiff(repository.Hash(sha), filePath)
-	if err == repository.ErrNotFound {
-		http.Error(w, "not found", http.StatusNotFound)
-		return
-	}
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	type diffLineResp struct {
-		Type    string `json:"type"`
-		Content string `json:"content"`
-		OldLine int    `json:"oldLine,omitempty"`
-		NewLine int    `json:"newLine,omitempty"`
-	}
-	type diffHunkResp struct {
-		OldStart int            `json:"oldStart"`
-		OldLines int            `json:"oldLines"`
-		NewStart int            `json:"newStart"`
-		NewLines int            `json:"newLines"`
-		Lines    []diffLineResp `json:"lines"`
-	}
-	type fileDiffResp struct {
-		Path     string         `json:"path"`
-		OldPath  string         `json:"oldPath,omitempty"`
-		IsBinary bool           `json:"isBinary"`
-		IsNew    bool           `json:"isNew"`
-		IsDelete bool           `json:"isDelete"`
-		Hunks    []diffHunkResp `json:"hunks"`
-	}
-
-	hunks := make([]diffHunkResp, len(fd.Hunks))
-	for i, h := range fd.Hunks {
-		lines := make([]diffLineResp, len(h.Lines))
-		for j, l := range h.Lines {
-			lines[j] = diffLineResp{Type: l.Type, Content: l.Content, OldLine: l.OldLine, NewLine: l.NewLine}
-		}
-		hunks[i] = diffHunkResp{OldStart: h.OldStart, OldLines: h.OldLines, NewStart: h.NewStart, NewLines: h.NewLines, Lines: lines}
-	}
-
-	writeJSON(w, fileDiffResp{
-		Path:     fd.Path,
-		OldPath:  fd.OldPath,
-		IsBinary: fd.IsBinary,
-		IsNew:    fd.IsNew,
-		IsDelete: fd.IsDelete,
-		Hunks:    hunks,
-	})
-}
-
-type changedFileResponse struct {
-	Path    string `json:"path"`
-	OldPath string `json:"oldPath,omitempty"`
-	Status  string `json:"status"`
-}
-
-type commitDetailResponse struct {
-	*commitMetaResponse
-	FullMessage string                `json:"fullMessage"`
-	Files       []changedFileResponse `json:"files"`
-}
-
-func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	sha := mux.Vars(r)["sha"]
-
-	_, br, err := browseRepo(h.mrc, r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	detail, err := br.CommitDetail(repository.Hash(sha))
-	if err == repository.ErrNotFound {
-		http.Error(w, "commit not found", http.StatusNotFound)
-		return
-	}
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	files := make([]changedFileResponse, len(detail.Files))
-	for i, f := range detail.Files {
-		files[i] = changedFileResponse{Path: f.Path, OldPath: f.OldPath, Status: f.Status}
-	}
-
-	writeJSON(w, commitDetailResponse{
-		commitMetaResponse: toCommitMetaResponse(detail.CommitMeta),
-		FullMessage:        detail.FullMessage,
-		Files:              files,
-	})
-}

api/http/git_serve_handler.go 🔗

@@ -1,331 +0,0 @@
-// Git smart HTTP handler — serves git clone and push using native git
-// subprocesses (git-upload-pack / git-receive-pack --stateless-rpc).
-//
-// Security notes:
-//   - No shell is used; exec.Command receives explicit argument slices.
-//   - The subprocess environment is sanitised: variables that could redirect
-//     git's operations (GIT_DIR, GIT_EXEC_PATH, GIT_SSH, …) are stripped.
-//   - The repository path is resolved from our internal config, never from
-//     URL parameters or request body content.
-//   - Client stderr is captured and discarded; it is never forwarded to the
-//     HTTP response.
-//
-// Routes (registered on the /api/repos/{owner}/{repo} subrouter):
-//
-//	GET  /info/refs?service=git-{upload,receive}-pack  → capability advertisement
-//	POST /git-upload-pack                               → fetch / clone
-//	POST /git-receive-pack                              → push (blocked in read-only mode)
-
-package http
-
-import (
-	"bytes"
-	"compress/gzip"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"os/exec"
-	"strings"
-
-	pktline "github.com/go-git/go-git/v5/plumbing/format/pktline"
-
-	"github.com/git-bug/git-bug/cache"
-)
-
-// GitServeHandler exposes the repository over git's smart HTTP protocol.
-type GitServeHandler struct {
-	mrc      *cache.MultiRepoCache
-	readOnly bool
-}
-
-func NewGitServeHandler(mrc *cache.MultiRepoCache, readOnly bool) *GitServeHandler {
-	return &GitServeHandler{mrc: mrc, readOnly: readOnly}
-}
-
-// ServeInfoRefs handles GET /info/refs — the capability advertisement step.
-// Runs `git {upload,receive}-pack --stateless-rpc --advertise-refs` and
-// prepends the required PKT-LINE service header.
-// For upload-pack the advertised refs are filtered to heads and tags only so
-// that cloners do not inadvertently fetch git-bug internal objects.
-func (h *GitServeHandler) ServeInfoRefs(w http.ResponseWriter, r *http.Request) {
-	service := r.URL.Query().Get("service")
-	if service != "git-upload-pack" && service != "git-receive-pack" {
-		http.Error(w, "unknown service", http.StatusForbidden)
-		return
-	}
-	if service == "git-receive-pack" && h.readOnly {
-		http.Error(w, "repository is read-only", http.StatusForbidden)
-		return
-	}
-
-	repoPath, err := h.repoPathFor(r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusNotFound)
-		return
-	}
-
-	// "git-upload-pack" → "upload-pack", "git-receive-pack" → "receive-pack"
-	subCmd := strings.TrimPrefix(service, "git-")
-
-	cmd := exec.CommandContext(r.Context(),
-		"git", subCmd, "--stateless-rpc", "--advertise-refs", repoPath)
-	cmd.Env = safeGitEnv()
-
-	out, err := cmd.Output()
-	if err != nil {
-		http.Error(w, "git advertisement failed", http.StatusInternalServerError)
-		return
-	}
-
-	w.Header().Set("Cache-Control", "no-cache")
-	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
-
-	// PKT-LINE service header required by the smart HTTP protocol.
-	enc := pktline.NewEncoder(w)
-	if err := enc.EncodeString(fmt.Sprintf("# service=%s\n", service)); err != nil {
-		return
-	}
-	if err := enc.Flush(); err != nil {
-		return
-	}
-
-	// For upload-pack, filter out internal git-bug refs (refs/bugs/,
-	// refs/identities/, …) so cloners only receive source code objects.
-	if service == "git-upload-pack" {
-		_ = writeFilteredInfoRefs(w, out)
-	} else {
-		_, _ = w.Write(out)
-	}
-}
-
-// ServeUploadPack handles POST /git-upload-pack — serves a fetch or clone.
-// The request body is piped directly to `git upload-pack --stateless-rpc`.
-func (h *GitServeHandler) ServeUploadPack(w http.ResponseWriter, r *http.Request) {
-	repoPath, err := h.repoPathFor(r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusNotFound)
-		return
-	}
-
-	body, err := requestBody(r)
-	if err != nil {
-		http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest)
-		return
-	}
-	defer body.Close()
-
-	cmd := exec.CommandContext(r.Context(),
-		"git", "upload-pack", "--stateless-rpc", repoPath)
-	cmd.Env = safeGitEnv()
-	cmd.Stdin = body
-
-	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
-	w.Header().Set("Cache-Control", "no-cache")
-	cmd.Stdout = w
-
-	var stderr bytes.Buffer
-	cmd.Stderr = &stderr
-	// Errors after this point can't change the HTTP status (headers already
-	// committed on first write), so we just return silently.
-	_ = cmd.Run()
-}
-
-// ServeReceivePack handles POST /git-receive-pack — accepts a push.
-// Before running git, the PKT-LINE ref-update commands are parsed so that the
-// git-bug cache can be synchronised for any git-bug namespaces that were
-// updated.
-func (h *GitServeHandler) ServeReceivePack(w http.ResponseWriter, r *http.Request) {
-	if h.readOnly {
-		http.Error(w, "repository is read-only", http.StatusForbidden)
-		return
-	}
-
-	repoPath, err := h.repoPathFor(r)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusNotFound)
-		return
-	}
-
-	body, err := requestBody(r)
-	if err != nil {
-		http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest)
-		return
-	}
-	defer body.Close()
-
-	// Parse the PKT-LINE ref-update commands so we know which git-bug entities
-	// to resync after the push completes.  The full request body is
-	// reconstructed (commands + flush + packfile) for git's stdin.
-	updatedRefs, fullBody, err := parseReceivePackCommands(body)
-	if err != nil {
-		http.Error(w, "parsing receive-pack request: "+err.Error(), http.StatusBadRequest)
-		return
-	}
-
-	cmd := exec.CommandContext(r.Context(),
-		"git", "receive-pack", "--stateless-rpc", repoPath)
-	cmd.Env = safeGitEnv()
-	cmd.Stdin = fullBody
-
-	w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
-	w.Header().Set("Cache-Control", "no-cache")
-	cmd.Stdout = w
-
-	var stderr bytes.Buffer
-	cmd.Stderr = &stderr
-
-	if err := cmd.Run(); err != nil {
-		// Headers may already be committed; best-effort return.
-		return
-	}
-
-	h.syncAfterPush(r, updatedRefs)
-}
-
-// ── helpers ───────────────────────────────────────────────────────────────────
-
-// repoPathFor returns the filesystem path of the repository referenced in the
-// request URL variables.  The path is always resolved from our internal
-// MultiRepoCache configuration — it is never derived from request content.
-func (h *GitServeHandler) repoPathFor(r *http.Request) (string, error) {
-	rc, err := repoFromPath(h.mrc, r)
-	if err != nil {
-		return "", err
-	}
-	return rc.GetPath(), nil
-}
-
-// syncAfterPush updates the git-bug in-memory cache for any refs that were
-// updated by the push.
-func (h *GitServeHandler) syncAfterPush(r *http.Request, refs []string) {
-	if len(refs) == 0 {
-		return
-	}
-	rc, err := repoFromPath(h.mrc, r)
-	if err != nil {
-		return
-	}
-	_ = rc.SyncLocalRefs(refs)
-}
-
-// writeFilteredInfoRefs re-encodes the raw PKT-LINE advertisement output from
-// git, keeping only HEAD and refs/heads/* and refs/tags/*.  The first line is
-// always forwarded unchanged because it carries the server capability list
-// (appended after a NUL byte).
-func writeFilteredInfoRefs(w io.Writer, raw []byte) error {
-	scanner := pktline.NewScanner(bytes.NewReader(raw))
-	enc := pktline.NewEncoder(w)
-	first := true
-	for scanner.Scan() {
-		b := scanner.Bytes()
-		if len(b) == 0 { // flush packet
-			return enc.Flush()
-		}
-		if first {
-			// First line always passes — it carries server capabilities.
-			first = false
-			if err := enc.Encode(b); err != nil {
-				return err
-			}
-			continue
-		}
-		// Lines are "<sha> <refname>\n"; strip the newline to get the ref name.
-		line := strings.TrimSuffix(string(b), "\n")
-		parts := strings.SplitN(line, " ", 2)
-		if len(parts) == 2 {
-			ref := parts[1]
-			if strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/") {
-				if err := enc.Encode(b); err != nil {
-					return err
-				}
-			}
-		}
-	}
-	return scanner.Err()
-}
-
-// parseReceivePackCommands reads the PKT-LINE ref-update command lines from the
-// receive-pack request body (up to and including the flush packet), extracts
-// the ref names, and returns an io.Reader that replays the full original body
-// (commands + flush + packfile) for the git subprocess.
-func parseReceivePackCommands(r io.Reader) (refs []string, full io.Reader, err error) {
-	// TeeReader mirrors everything consumed by the scanner into cmds, so we
-	// can replay it verbatim later.
-	var cmds bytes.Buffer
-	scanner := pktline.NewScanner(io.TeeReader(r, &cmds))
-	for scanner.Scan() {
-		b := scanner.Bytes()
-		if len(b) == 0 { // flush — end of command list
-			break
-		}
-		// Command format: "<old-sha> <new-sha> <refname>\0<caps>" (first line)
-		//              or "<old-sha> <new-sha> <refname>"          (subsequent)
-		line := strings.TrimSuffix(string(b), "\n")
-		if i := strings.IndexByte(line, 0); i >= 0 {
-			line = line[:i] // strip NUL + capability list
-		}
-		parts := strings.SplitN(line, " ", 3)
-		if len(parts) == 3 {
-			refs = append(refs, parts[2])
-		}
-	}
-	if err = scanner.Err(); err != nil {
-		return nil, nil, err
-	}
-	// cmds holds [commands + flush]; r holds the remaining packfile data.
-	return refs, io.MultiReader(&cmds, r), nil
-}
-
-// requestBody returns the request body, transparently decompressing it when
-// the client sent Content-Encoding: gzip (git does this by default).
-func requestBody(r *http.Request) (io.ReadCloser, error) {
-	if r.Header.Get("Content-Encoding") == "gzip" {
-		gr, err := gzip.NewReader(r.Body)
-		if err != nil {
-			return nil, err
-		}
-		return gr, nil
-	}
-	return r.Body, nil
-}
-
-// safeGitEnv returns a sanitised copy of the process environment for use with
-// git subprocesses.  Variables that could redirect git's operations to
-// unintended paths or trigger credential prompts are removed.
-func safeGitEnv() []string {
-	// These variables could redirect git internals to attacker-controlled
-	// paths or commands when the git-bug server process itself inherits a
-	// tainted environment.
-	blocked := map[string]bool{
-		"GIT_DIR":                          true,
-		"GIT_WORK_TREE":                    true,
-		"GIT_INDEX_FILE":                   true,
-		"GIT_OBJECT_DIRECTORY":             true,
-		"GIT_ALTERNATE_OBJECT_DIRECTORIES": true,
-		"GIT_EXEC_PATH":                    true,
-		"GIT_SSH":                          true,
-		"GIT_SSH_COMMAND":                  true,
-		"GIT_PROXY_COMMAND":                true,
-		"GIT_ASKPASS":                      true,
-		"SSH_ASKPASS":                      true,
-		"GIT_TRACE":                        true,
-		"GIT_TRACE_PACKET":                 true,
-		"GIT_TRACE_PERFORMANCE":            true,
-	}
-	parent := os.Environ()
-	safe := make([]string, 0, len(parent)+1)
-	for _, kv := range parent {
-		key := kv
-		if i := strings.IndexByte(kv, '='); i >= 0 {
-			key = kv[:i]
-		}
-		if !blocked[key] {
-			safe = append(safe, kv)
-		}
-	}
-	// Prevent git from blocking on a credential/passphrase prompt, which
-	// would hang the HTTP handler goroutine.
-	safe = append(safe, "GIT_TERMINAL_PROMPT=0")
-	return safe
-}

cache/repo_cache_common.go 🔗

@@ -21,9 +21,14 @@ func (c *RepoCache) Name() string {
 	return c.name
 }
 
-// GetPath returns the root directory path of the underlying git repository.
-func (c *RepoCache) GetPath() string {
-	return c.repo.GetPath()
+// IsDefaultRepo reports whether this is an unnamed (single-repo) repository.
+func (c *RepoCache) IsDefaultRepo() bool {
+	return c.name == defaultRepoName
+}
+
+// BrowseRepo returns the underlying RepoBrowse implementation.
+func (c *RepoCache) BrowseRepo() repository.RepoBrowse {
+	return c.repo
 }
 
 // LocalConfig give access to the repository scoped configuration
@@ -76,12 +81,6 @@ func (c *RepoCache) ReadData(hash repository.Hash) ([]byte, error) {
 	return c.repo.ReadData(hash)
 }
 
-// GetRepo returns the underlying repository for operations not covered by
-// RepoCache (e.g. git object browsing). Callers may type-assert to
-// repository.RepoBrowse for extended read-only access.
-func (c *RepoCache) GetRepo() repository.ClockedRepo {
-	return c.repo
-}
 
 // StoreData will store arbitrary data and return the corresponding hash
 func (c *RepoCache) StoreData(data []byte) (repository.Hash, error) {

commands/execenv/env.go 🔗

@@ -1,6 +1,7 @@
 package execenv
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -19,6 +20,7 @@ const gitBugNamespace = "git-bug"
 
 // Env is the environment of a command
 type Env struct {
+	Ctx     context.Context
 	Repo    repository.ClockedRepo
 	Backend *cache.RepoCache
 	In      In
@@ -26,8 +28,9 @@ type Env struct {
 	Err     Out
 }
 
-func NewEnv() *Env {
+func NewEnv(ctx context.Context) *Env {
 	return &Env{
+		Ctx:  ctx,
 		Repo: nil,
 		In:   in{Reader: os.Stdin},
 		Out:  out{Writer: os.Stdout},

commands/execenv/env_testing.go 🔗

@@ -93,6 +93,7 @@ func newTestEnv(t *testing.T, isTerminal bool) *Env {
 	})
 
 	return &Env{
+		Ctx:     t.Context(),
 		Repo:    repo,
 		Backend: backend,
 		In:      &TestIn{Buffer: &bytes.Buffer{}, forceIsTerminal: isTerminal},

commands/root.go 🔗

@@ -1,6 +1,7 @@
 package commands
 
 import (
+	"context"
 	"os"
 
 	"github.com/spf13/cobra"
@@ -11,7 +12,7 @@ import (
 	"github.com/git-bug/git-bug/commands/user"
 )
 
-func NewRootCommand(version string) *cobra.Command {
+func NewRootCommand(ctx context.Context, version string) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   execenv.RootCommandName,
 		Short: "A bug tracker embedded in Git",
@@ -54,7 +55,7 @@ the same git remote you are already using to collaborate with other people.
 		child.GroupID = groupID
 	}
 
-	env := execenv.NewEnv()
+	env := execenv.NewEnv(ctx)
 
 	addCmdWithGroup(bugcmd.NewBugCommand(env), entityGroup)
 	addCmdWithGroup(usercmd.NewUserCommand(env), entityGroup)

commands/webui.go 🔗

@@ -5,14 +5,10 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"log"
 	"net"
 	"net/http"
 	"net/url"
-	"os"
-	"os/signal"
 	"strconv"
-	"syscall"
 	"time"
 
 	"github.com/99designs/gqlgen/graphql/playground"
@@ -35,7 +31,7 @@ import (
 const webUIOpenConfigKey = "git-bug.webui.open"
 
 type webUIOptions struct {
-	host      string
+	bind      string
 	port      int
 	open      bool
 	noOpen    bool
@@ -70,10 +66,10 @@ Available git config:
 	flags := cmd.Flags()
 	flags.SortFlags = false
 
-	flags.StringVar(&options.host, "host", "127.0.0.1", "Network address or hostname to listen to (default to 127.0.0.1)")
+	flags.StringVar(&options.bind, "bind", "127.0.0.1", "Network address to bind to (default to 127.0.0.1)")
+	flags.IntVarP(&options.port, "port", "p", 0, "Port to listen on (default to random available port)")
 	flags.BoolVar(&options.open, "open", false, "Automatically open the web UI in the default browser")
 	flags.BoolVar(&options.noOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
-	flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default to random available port)")
 	flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
 	flags.BoolVar(&options.logErrors, "log-errors", false, "Whether to log errors")
 	flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
@@ -86,25 +82,8 @@ Available git config:
 	return cmd
 }
 
-func runWebUI(env *execenv.Env, opts webUIOptions) error {
-	if opts.port == 0 {
-		var err error
-		opts.port, err = freeport.GetFreePort()
-		if err != nil {
-			return err
-		}
-	}
-
-	addr := net.JoinHostPort(opts.host, strconv.Itoa(opts.port))
-	baseURL := fmt.Sprintf("http://%s", addr)
-	webUiAddr := baseURL
-	toOpen := webUiAddr
-
-	if len(opts.query) > 0 {
-		// Explicitly set the query parameter instead of going with a default one.
-		toOpen = fmt.Sprintf("%s/?q=%s", webUiAddr, url.QueryEscape(opts.query))
-	}
-
+// setupRoutes builds the router and registers all API and UI routes.
+func setupRoutes(env *execenv.Env, opts webUIOptions, baseURL string) (*mux.Router, func() error, error) {
 	// Collect enabled login providers.
 	var providers []provider.Provider
 	if opts.githubClientId != "" {
@@ -131,18 +110,15 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		// Single-user mode: inject the identity from git config for every request.
 		author, err := identity.GetUserIdentity(env.Repo)
 		if err != nil {
-			return err
+			return nil, nil, err
 		}
 		router.Use(auth.Middleware(author.Id()))
 	}
 
 	mrc := cache.NewMultiRepoCache()
-
 	_, events := mrc.RegisterDefaultRepository(env.Repo)
-
-	err := execenv.CacheBuildProgressBar(env, events)
-	if err != nil {
-		return err
+	if err := execenv.CacheBuildProgressBar(env, events); err != nil {
+		return nil, nil, err
 	}
 
 	var errOut io.Writer
@@ -172,80 +148,55 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		router.Path("/auth/adopt").Methods("POST").HandlerFunc(ah.HandleAdopt)
 	}
 
-	// Top-level API routes
 	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
 	router.Path("/graphql").Handler(graphqlHandler)
 
-	// /api/repos/{owner}/{repo}/ subrouter.
-	// owner is reserved for future use; "_" means "local".
-	// repo "_" resolves to the default repository.
-	//
-	// In oauth mode all API endpoints require a valid session, making the
-	// server safe to deploy publicly. In local and readonly modes the
-	// middleware only injects identity without blocking.
-	apiRepos := router.PathPrefix("/api/repos/{owner}/{repo}").Subrouter()
-	if authMode == "external" {
-		apiRepos.Use(auth.RequireAuth)
-	}
-	apiRepos.Path("/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc))
-	apiRepos.Path("/git/trees/{ref}").Methods("GET").Handler(httpapi.NewGitTreeHandler(mrc))
-	apiRepos.Path("/git/blobs/{ref}").Methods("GET").Handler(httpapi.NewGitBlobHandler(mrc))
-	apiRepos.Path("/git/raw/{ref}/{path:.*}").Methods("GET").Handler(httpapi.NewGitRawHandler(mrc))
-	apiRepos.Path("/git/commits").Methods("GET").Handler(httpapi.NewGitCommitsHandler(mrc))
-	apiRepos.Path("/git/commits/{sha}").Methods("GET").Handler(httpapi.NewGitCommitHandler(mrc))
-	apiRepos.Path("/git/commits/{sha}/diff").Methods("GET").Handler(httpapi.NewGitCommitDiffHandler(mrc))
-	apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
-	apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
-
-	// Git smart HTTP — clone, fetch, push.
-	gitSrv := httpapi.NewGitServeHandler(mrc, opts.readOnly)
-	apiRepos.Path("/info/refs").Methods("GET").HandlerFunc(gitSrv.ServeInfoRefs)
-	apiRepos.Path("/git-upload-pack").Methods("POST").HandlerFunc(gitSrv.ServeUploadPack)
-	apiRepos.Path("/git-receive-pack").Methods("POST").HandlerFunc(gitSrv.ServeReceivePack)
+	// File and upload routes for bug attachments.
+	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
+	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
 
 	router.PathPrefix("/").Handler(webui2.NewHandler())
 
-	srv := &http.Server{
-		Addr:    addr,
-		Handler: router,
-	}
-
-	done := make(chan bool)
-	quit := make(chan os.Signal, 1)
-
-	// register as handler of the interrupt signal to trigger the teardown
-	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
-
-	go func() {
-		<-quit
-		env.Out.Println("WebUI is shutting down...")
-
-		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-		defer cancel()
-
-		srv.SetKeepAlivesEnabled(false)
-		if err := srv.Shutdown(ctx); err != nil {
-			log.Fatalf("Could not gracefully shutdown the WebUI: %v\n", err)
-		}
+	return router, mrc.Close, nil
+}
 
-		// Teardown
-		err = mrc.Close()
+func runWebUI(env *execenv.Env, opts webUIOptions) error {
+	if opts.port == 0 {
+		var err error
+		opts.port, err = freeport.GetFreePort()
 		if err != nil {
-			env.Out.Println(err)
+			return err
 		}
+	}
+
+	addr := net.JoinHostPort(opts.bind, strconv.Itoa(opts.port))
+	baseURL := "http://" + addr
 
-		close(done)
+	router, closeRoutes, err := setupRoutes(env, opts, baseURL)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := closeRoutes(); err != nil {
+			env.Err.Println(err)
+		}
 	}()
 
-	env.Out.Printf("Web UI: %s\n", webUiAddr)
-	env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
-	env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
-	if authMode == "external" {
+	server := &http.Server{Addr: addr, Handler: router}
+
+	env.Out.Printf("Web UI: %s\n", baseURL)
+	env.Out.Printf("Graphql API: %s/graphql\n", baseURL)
+	env.Out.Printf("Graphql Playground: %s/playground\n", baseURL)
+	if opts.githubClientId != "" {
 		env.Out.Printf("Login callback URL: %s/auth/callback\n", baseURL)
 		env.Out.Println("  ↳ Register this URL in your OAuth/OIDC application settings")
 	}
-	env.Out.Println("Press Ctrl+c to quit")
+	env.Out.Printf("\n[ Press Ctrl+c to quit ]\n\n")
 
+	toOpen := baseURL
+	if len(opts.query) > 0 {
+		toOpen = fmt.Sprintf("%s/?q=%s", baseURL, url.QueryEscape(opts.query))
+	}
 	configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
 	if errors.Is(err, repository.ErrNoConfigEntry) {
 		// default to true
@@ -253,23 +204,65 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	} else if err != nil {
 		return err
 	}
+	if (configOpen && !opts.noOpen) || opts.open {
+		go openWhenUp(env, toOpen)
+	}
+
+	go func() {
+		<-env.Ctx.Done()
+		env.Out.Println("shutting down...")
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+		defer cancel()
+		server.SetKeepAlivesEnabled(false)
+		if err := server.Shutdown(shutdownCtx); err != nil {
+			env.Err.Printf("Could not gracefully shutdown the HTTP server: %v\n", err)
+		}
+	}()
 
-	shouldOpen := (configOpen && !opts.noOpen) || opts.open
+	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+		return err
+	}
+	return nil
+}
 
-	if shouldOpen {
-		err = open.Run(toOpen)
-		if err != nil {
-			env.Out.Println(err)
+func openWhenUp(env *execenv.Env, toOpen string) {
+	const maxAttempts = 3
+	if isUp(toOpen, maxAttempts, 3*time.Second) {
+		if err := open.Run(toOpen); err != nil {
+			env.Err.Println(err)
+			return
 		}
+		env.Out.Printf("opened your default browser to url: %s\n", toOpen)
+		return
 	}
+	env.Err.Printf(
+		"uh oh! it appears that the http server hasn't started.\n"+
+			"we failed to reach %s after %d attempts.\n",
+		toOpen, maxAttempts,
+	)
+}
 
-	err = srv.ListenAndServe()
-	if err != nil && err != http.ErrServerClosed {
-		return err
+func isUp(url string, maxRetries int, initialDelay time.Duration) bool {
+	client := &http.Client{
+		Timeout: 5 * time.Second,
 	}
 
-	<-done
+	delay := initialDelay
 
-	env.Out.Println("WebUI stopped")
-	return nil
+	for attempt := 1; attempt <= maxRetries; attempt++ {
+		resp, err := client.Head(url)
+		if err == nil {
+			_ = resp.Body.Close()
+			if resp.StatusCode >= 200 && resp.StatusCode < 400 {
+				return true
+			}
+		}
+
+		if attempt < maxRetries {
+			time.Sleep(delay)
+			delay *= 2
+		}
+	}
+
+	return false
 }

doc/generate.go 🔗

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -34,7 +35,7 @@ func main() {
 		wg.Add(1)
 		go func(name string, f func(*cobra.Command) error) {
 			defer wg.Done()
-			root := commands.NewRootCommand("")
+			root := commands.NewRootCommand(context.Background(), "")
 			err := f(root)
 			if err != nil {
 				fmt.Printf("  - %s: FATAL\n", name)

go.mod 🔗

@@ -1,8 +1,6 @@
 module github.com/git-bug/git-bug
 
-go 1.24.0
-
-toolchain go1.24.2
+go 1.25.0
 
 require (
 	github.com/99designs/gqlgen v0.17.73
@@ -32,17 +30,19 @@ require (
 	github.com/vbauerster/mpb/v8 v8.8.2
 	github.com/vektah/gqlparser/v2 v2.5.26
 	gitlab.com/gitlab-org/api/client-go v0.116.0
-	golang.org/x/crypto v0.45.0
-	golang.org/x/mod v0.29.0
-	golang.org/x/net v0.47.0
-	golang.org/x/oauth2 v0.27.0
-	golang.org/x/sync v0.18.0
-	golang.org/x/sys v0.38.0
-	golang.org/x/term v0.37.0
-	golang.org/x/text v0.31.0
+	golang.org/x/crypto v0.49.0
+	golang.org/x/mod v0.34.0
+	golang.org/x/net v0.52.0
+	golang.org/x/oauth2 v0.36.0
+	golang.org/x/sync v0.20.0
+	golang.org/x/sys v0.42.0
+	golang.org/x/term v0.41.0
+	golang.org/x/text v0.35.0
 	golang.org/x/vuln v1.1.3
 )
 
+tool github.com/99designs/gqlgen
+
 require (
 	dario.cat/mergo v1.0.1 // indirect
 	github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
@@ -113,14 +113,12 @@ require (
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
 	go.etcd.io/bbolt v1.4.0 // indirect
-	golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
+	golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
 	golang.org/x/time v0.3.0 // indirect
-	golang.org/x/tools v0.38.0 // indirect
+	golang.org/x/tools v0.42.0 // indirect
 	golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
 	golang.org/x/tools/godoc v0.1.0-deprecated // indirect
 	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
-
-tool github.com/99designs/gqlgen

go.sum 🔗

@@ -257,28 +257,28 @@ go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
 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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
 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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
-golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -291,33 +291,33 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
-golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
+golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
 golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
 golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 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=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
 golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
 golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=

main.go 🔗

@@ -4,14 +4,20 @@
 package main
 
 import (
+	"context"
 	"os"
+	"os/signal"
+	"syscall"
 
 	"github.com/git-bug/git-bug/commands"
 )
 
 func main() {
+	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer cancel()
+
 	v, _ := getVersion()
-	root := commands.NewRootCommand(v)
+	root := commands.NewRootCommand(ctx, v)
 	if err := root.Execute(); err != nil {
 		os.Exit(1)
 	}

misc/completion/generate.go 🔗

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -26,7 +27,7 @@ func main() {
 		wg.Add(1)
 		go func(name string, f func(*cobra.Command) error) {
 			defer wg.Done()
-			root := commands.NewRootCommand("")
+			root := commands.NewRootCommand(context.Background(), "")
 			err := f(root)
 			if err != nil {
 				fmt.Printf("  - %s: %v\n", name, err)

repository/browse.go 🔗

@@ -0,0 +1,160 @@
+package repository
+
+import (
+	"fmt"
+	"io"
+	"strconv"
+	"time"
+)
+
+// ChangeStatus describes how a file was affected by a commit.
+type ChangeStatus string
+
+const (
+	ChangeStatusAdded    ChangeStatus = "added"
+	ChangeStatusModified ChangeStatus = "modified"
+	ChangeStatusDeleted  ChangeStatus = "deleted"
+	ChangeStatusRenamed  ChangeStatus = "renamed"
+)
+
+func (s ChangeStatus) MarshalGQL(w io.Writer) {
+	switch s {
+	case ChangeStatusAdded:
+		fmt.Fprint(w, strconv.Quote("ADDED"))
+	case ChangeStatusModified:
+		fmt.Fprint(w, strconv.Quote("MODIFIED"))
+	case ChangeStatusDeleted:
+		fmt.Fprint(w, strconv.Quote("DELETED"))
+	case ChangeStatusRenamed:
+		fmt.Fprint(w, strconv.Quote("RENAMED"))
+	default:
+		panic(fmt.Sprintf("unknown ChangeStatus value %q", string(s)))
+	}
+}
+
+func (s *ChangeStatus) UnmarshalGQL(v any) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+	switch str {
+	case "ADDED":
+		*s = ChangeStatusAdded
+	case "MODIFIED":
+		*s = ChangeStatusModified
+	case "DELETED":
+		*s = ChangeStatusDeleted
+	case "RENAMED":
+		*s = ChangeStatusRenamed
+	default:
+		return fmt.Errorf("%q is not a valid ChangeStatus", str)
+	}
+	return nil
+}
+
+// DiffLineType is the role of a line within a unified diff hunk.
+type DiffLineType string
+
+const (
+	DiffLineContext DiffLineType = "context"
+	DiffLineAdded   DiffLineType = "added"
+	DiffLineDeleted DiffLineType = "deleted"
+)
+
+func (t DiffLineType) MarshalGQL(w io.Writer) {
+	switch t {
+	case DiffLineContext:
+		fmt.Fprint(w, strconv.Quote("CONTEXT"))
+	case DiffLineAdded:
+		fmt.Fprint(w, strconv.Quote("ADDED"))
+	case DiffLineDeleted:
+		fmt.Fprint(w, strconv.Quote("DELETED"))
+	default:
+		panic(fmt.Sprintf("unknown DiffLineType value %q", string(t)))
+	}
+}
+
+func (t *DiffLineType) UnmarshalGQL(v any) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+	switch str {
+	case "CONTEXT":
+		*t = DiffLineContext
+	case "ADDED":
+		*t = DiffLineAdded
+	case "DELETED":
+		*t = DiffLineDeleted
+	default:
+		return fmt.Errorf("%q is not a valid DiffLineType", str)
+	}
+	return nil
+}
+
+// CommitMeta holds the metadata for a single commit, suitable for listing.
+type CommitMeta struct {
+	Hash        Hash
+	Message     string
+	AuthorName  string
+	AuthorEmail string
+	Date        time.Time
+	Parents     []Hash
+}
+
+// ChangedFile describes a file that was modified in a commit.
+type ChangedFile struct {
+	Path    string
+	OldPath *string // non-nil for renames
+	Status  ChangeStatus
+}
+
+// CommitDetail extends CommitMeta with the full message and the list of
+// changed files (relative to the first parent).
+type CommitDetail struct {
+	CommitMeta
+	FullMessage string
+	Files       []ChangedFile
+}
+
+// DiffLine represents one line in a unified diff hunk.
+type DiffLine struct {
+	Type    DiffLineType
+	Content string
+	OldLine int
+	NewLine int
+}
+
+// DiffHunk is a contiguous block of changes in a unified diff.
+type DiffHunk struct {
+	OldStart int
+	OldLines int
+	NewStart int
+	NewLines int
+	Lines    []DiffLine
+}
+
+// FileDiff is the diff for a single file in a commit.
+type FileDiff struct {
+	Path     string
+	OldPath  *string // non-nil for renames
+	IsBinary bool
+	IsNew    bool
+	IsDelete bool
+	Hunks    []DiffHunk
+}
+
+// BranchInfo describes a local branch returned by RepoBrowse.Branches.
+type BranchInfo struct {
+	Name      string
+	Hash      Hash // commit hash
+	IsDefault bool // true for the branch HEAD points to
+}
+
+// TagInfo describes a tag returned by RepoBrowse.Tags.
+type TagInfo struct {
+	Name string
+	// Hash is always the target commit hash.  For annotated tags the tag
+	// object is dereferenced; for lightweight tags this is the ref hash.
+	Hash Hash
+}

repository/common.go 🔗

@@ -65,3 +65,10 @@ func deArmorSignature(armoredSig io.Reader) (io.Reader, error) {
 	}
 	return block.Body, nil
 }
+
+func must[T any](v T, err error) T {
+	if err != nil {
+		panic(err)
+	}
+	return v
+}

repository/gogit.go 🔗

@@ -19,8 +19,9 @@ import (
 	"github.com/go-git/go-git/v5/config"
 	"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"
+	fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"
 	"github.com/go-git/go-git/v5/plumbing/object"
+	lru "github.com/hashicorp/golang-lru/v2"
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/sys/execabs"
 
@@ -30,6 +31,17 @@ import (
 const clockPath = "clocks"
 const indexPath = "indexes"
 
+// lastCommitDepthLimit is the maximum number of commits walked by
+// LastCommitForEntries. Entries not found within this horizon are omitted from
+// the result rather than stalling the caller indefinitely.
+const lastCommitDepthLimit = 1000
+
+// lastCommitCacheSize is the number of (resolvedHash, dirPath) pairs kept in
+// the LRU cache for LastCommitForEntries. Each entry holds one CommitMeta per
+// directory entry (≈ a few KB for a typical directory), so 256 slots ≈ a few
+// MB of memory at most.
+const lastCommitCacheSize = 256
+
 var _ ClockedRepo = &GoGitRepo{}
 var _ TestedRepo = &GoGitRepo{}
 
@@ -48,6 +60,13 @@ type GoGitRepo struct {
 	indexesMutex sync.Mutex
 	indexes      map[string]Index
 
+	// lastCommitCache caches LastCommitForEntries results keyed by
+	// "<treeHash>\x00<path>". Git trees are content-addressed and
+	// immutable, so entries never need invalidation and can be shared
+	// across refs that point to the same directory tree. The LRU bounds
+	// memory to lastCommitCacheSize unique (treeHash, directory) pairs.
+	lastCommitCache *lru.Cache[string, map[string]CommitMeta]
+
 	keyring      Keyring
 	localStorage LocalStorage
 }
@@ -73,12 +92,13 @@ func OpenGoGitRepo(path, namespace string, clockLoaders []ClockLoader) (*GoGitRe
 	}
 
 	repo := &GoGitRepo{
-		r:            r,
-		path:         path,
-		clocks:       make(map[string]lamport.Clock),
-		indexes:      make(map[string]Index),
-		keyring:      k,
-		localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
+		r:               r,
+		path:            path,
+		clocks:          make(map[string]lamport.Clock),
+		indexes:         make(map[string]Index),
+		lastCommitCache: must(lru.New[string, map[string]CommitMeta](lastCommitCacheSize)),
+		keyring:         k,
+		localStorage:    billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
 	}
 
 	loaderToRun := make([]ClockLoader, 0, len(clockLoaders))
@@ -127,12 +147,13 @@ func InitGoGitRepo(path, namespace string) (*GoGitRepo, error) {
 	}
 
 	return &GoGitRepo{
-		r:            r,
-		path:         filepath.Join(path, ".git"),
-		clocks:       make(map[string]lamport.Clock),
-		indexes:      make(map[string]Index),
-		keyring:      k,
-		localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, ".git", namespace))},
+		r:               r,
+		path:            filepath.Join(path, ".git"),
+		clocks:          make(map[string]lamport.Clock),
+		indexes:         make(map[string]Index),
+		lastCommitCache: must(lru.New[string, map[string]CommitMeta](lastCommitCacheSize)),
+		keyring:         k,
+		localStorage:    billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, ".git", namespace))},
 	}, nil
 }
 
@@ -152,12 +173,13 @@ func InitBareGoGitRepo(path, namespace string) (*GoGitRepo, error) {
 	}
 
 	return &GoGitRepo{
-		r:            r,
-		path:         path,
-		clocks:       make(map[string]lamport.Clock),
-		indexes:      make(map[string]Index),
-		keyring:      k,
-		localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
+		r:               r,
+		path:            path,
+		clocks:          make(map[string]lamport.Clock),
+		indexes:         make(map[string]Index),
+		lastCommitCache: must(lru.New[string, map[string]CommitMeta](lastCommitCacheSize)),
+		keyring:         k,
+		localStorage:    billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
 	}, nil
 }
 
@@ -830,48 +852,376 @@ func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) {
 
 var _ RepoBrowse = &GoGitRepo{}
 
-func (repo *GoGitRepo) GetDefaultBranch() (string, error) {
+func (repo *GoGitRepo) AllClocks() (map[string]lamport.Clock, error) {
+	repo.clocksMutex.Lock()
+	defer repo.clocksMutex.Unlock()
+
+	result := make(map[string]lamport.Clock)
+
+	files, err := os.ReadDir(filepath.Join(repo.localStorage.Root(), clockPath))
+	if os.IsNotExist(err) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	for _, file := range files {
+		name := file.Name()
+		if c, ok := repo.clocks[name]; ok {
+			result[name] = c
+		} else {
+			c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
+			if err != nil {
+				return nil, err
+			}
+			repo.clocks[name] = c
+			result[name] = c
+		}
+	}
+
+	return result, nil
+}
+
+// GetOrCreateClock return a Lamport clock stored in the Repo.
+// If the clock doesn't exist, it's created.
+func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) {
+	repo.clocksMutex.Lock()
+	defer repo.clocksMutex.Unlock()
+
+	c, err := repo.getClock(name)
+	if err == nil {
+		return c, nil
+	}
+	if err != ErrClockNotExist {
+		return nil, err
+	}
+
+	c, err = lamport.NewPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
+	if err != nil {
+		return nil, err
+	}
+
+	repo.clocks[name] = c
+	return c, nil
+}
+
+func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) {
+	if c, ok := repo.clocks[name]; ok {
+		return c, nil
+	}
+
+	c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
+	if err == nil {
+		repo.clocks[name] = c
+		return c, nil
+	}
+	if err == lamport.ErrClockNotExist {
+		return nil, ErrClockNotExist
+	}
+	return nil, err
+}
+
+// Increment is equivalent to c = GetOrCreateClock(name) + c.Increment()
+func (repo *GoGitRepo) Increment(name string) (lamport.Time, error) {
+	c, err := repo.GetOrCreateClock(name)
+	if err != nil {
+		return lamport.Time(0), err
+	}
+	return c.Increment()
+}
+
+// Witness is equivalent to c = GetOrCreateClock(name) + c.Witness(time)
+func (repo *GoGitRepo) Witness(name string, time lamport.Time) error {
+	c, err := repo.GetOrCreateClock(name)
+	if err != nil {
+		return err
+	}
+	return c.Witness(time)
+}
+
+// commitToMeta converts a go-git Commit to a CommitMeta.
+func commitToMeta(c *object.Commit) CommitMeta {
+	h := Hash(c.Hash.String())
+	parents := make([]Hash, len(c.ParentHashes))
+	for i, p := range c.ParentHashes {
+		parents[i] = Hash(p.String())
+	}
+	// Use first line of message as the short message.
+	msg := strings.TrimSpace(c.Message)
+	if idx := strings.Index(msg, "\n"); idx >= 0 {
+		msg = msg[:idx]
+	}
+	return CommitMeta{
+		Hash:        h,
+		Message:     msg,
+		AuthorName:  c.Author.Name,
+		AuthorEmail: c.Author.Email,
+		Date:        c.Author.When,
+		Parents:     parents,
+	}
+}
+
+// peelToCommit follows tag objects until it reaches a commit hash.
+// This is necessary for annotated tags, whose ref hash points to a tag object
+// rather than directly to a commit.
+func (repo *GoGitRepo) peelToCommit(h plumbing.Hash) (plumbing.Hash, error) {
+	for {
+		if _, err := repo.r.CommitObject(h); err == nil {
+			return h, nil
+		}
+		tagObj, err := repo.r.TagObject(h)
+		if err != nil {
+			return plumbing.ZeroHash, ErrNotFound
+		}
+		h = tagObj.Target
+	}
+}
+
+// resolveRefToHash resolves a branch/tag name or raw hash to a commit hash.
+// Resolution order: refs/heads/<ref>, refs/tags/<ref>, full ref name, raw commit hash.
+// Annotated tags are peeled to their target commit.
+func (repo *GoGitRepo) resolveRefToHash(ref string) (plumbing.Hash, error) {
+	for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
+		r, err := repo.r.Reference(plumbing.ReferenceName(prefix+ref), true)
+		if err == nil {
+			return repo.peelToCommit(r.Hash())
+		}
+	}
+	// try as a full ref name
+	r, err := repo.r.Reference(plumbing.ReferenceName(ref), true)
+	if err == nil {
+		return repo.peelToCommit(r.Hash())
+	}
+	// try as a raw commit hash
+	h := plumbing.NewHash(ref)
+	if h != plumbing.ZeroHash {
+		if _, err := repo.r.CommitObject(h); err == nil {
+			return h, nil
+		}
+	}
+	return plumbing.ZeroHash, ErrNotFound
+}
+
+// defaultBranchName returns the short name of the default branch.
+func (repo *GoGitRepo) defaultBranchName() string {
+	repo.rMutex.Lock()
+	defer repo.rMutex.Unlock()
+
+	// refs/remotes/origin/HEAD is a symbolic ref set by git clone that points
+	// to the remote's default branch (e.g. refs/remotes/origin/main). It is
+	// the most reliable signal for "what does the upstream consider default".
+	ref, err := repo.r.Reference("refs/remotes/origin/HEAD", false)
+	if err == nil && ref.Type() == plumbing.SymbolicReference {
+		const prefix = "refs/remotes/origin/"
+		if target := ref.Target().String(); strings.HasPrefix(target, prefix) {
+			return strings.TrimPrefix(target, prefix)
+		}
+	}
+	// Fall back to well-known names for repos without a configured remote.
+	for _, name := range []string{"main", "master", "trunk", "develop"} {
+		_, err := repo.r.Reference(plumbing.NewBranchReferenceName(name), false)
+		if err == nil {
+			return name
+		}
+	}
+	return ""
+}
+
+// Branches returns all local branches. IsDefault marks the upstream's default
+// branch, determined in order:
+//  1. refs/remotes/origin/HEAD (set by git clone, reflects the server default)
+//  2. First match among: main, master, trunk, develop
+//  3. No branch marked if none of the above resolve
+func (repo *GoGitRepo) Branches() ([]BranchInfo, error) {
+	defaultBranch := repo.defaultBranchName()
+
 	repo.rMutex.Lock()
 	defer repo.rMutex.Unlock()
 
-	head, err := repo.r.Head()
+	refs, err := repo.r.References()
+	if err != nil {
+		return nil, err
+	}
+
+	var branches []BranchInfo
+	err = refs.ForEach(func(r *plumbing.Reference) error {
+		if !r.Name().IsBranch() {
+			return nil
+		}
+		branches = append(branches, BranchInfo{
+			Name:      r.Name().Short(),
+			Hash:      Hash(r.Hash().String()),
+			IsDefault: r.Name().Short() == defaultBranch,
+		})
+		return nil
+	})
 	if err != nil {
-		return "main", nil // sensible fallback for detached HEAD
+		return nil, err
 	}
-	return head.Name().Short(), nil
+	if branches == nil {
+		branches = []BranchInfo{}
+	}
+	return branches, nil
 }
 
-func (repo *GoGitRepo) ReadCommitMeta(hash Hash) (CommitMeta, error) {
+// Tags returns all tags. For annotated tags the hash is dereferenced to the
+// target commit; for lightweight tags it is the commit hash directly.
+func (repo *GoGitRepo) Tags() ([]TagInfo, error) {
 	repo.rMutex.Lock()
 	defer repo.rMutex.Unlock()
 
-	commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
-	if err == plumbing.ErrObjectNotFound {
-		return CommitMeta{}, ErrNotFound
+	refs, err := repo.r.References()
+	if err != nil {
+		return nil, err
+	}
+
+	var tags []TagInfo
+	err = refs.ForEach(func(r *plumbing.Reference) error {
+		if !r.Name().IsTag() {
+			return nil
+		}
+		// Peel to the target commit hash, handling arbitrarily nested tag objects.
+		commit, err := repo.peelToCommit(r.Hash())
+		if err != nil {
+			// Skip refs that don't resolve to a commit (shouldn't happen for tags).
+			return nil
+		}
+		tags = append(tags, TagInfo{
+			Name: r.Name().Short(),
+			Hash: Hash(commit.String()),
+		})
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	if tags == nil {
+		tags = []TagInfo{}
+	}
+	return tags, nil
+}
+
+// TreeAtPath returns the entries of the directory at path under ref.
+func (repo *GoGitRepo) TreeAtPath(ref, path string) ([]TreeEntry, error) {
+	path = strings.Trim(path, "/")
+
+	repo.rMutex.Lock()
+	defer repo.rMutex.Unlock()
+
+	startHash, err := repo.resolveRefToHash(ref)
+	if err != nil {
+		return nil, ErrNotFound
+	}
+	commit, err := repo.r.CommitObject(startHash)
+	if err != nil {
+		return nil, err
+	}
+	tree, err := commit.Tree()
+	if err != nil {
+		return nil, err
+	}
+	if path != "" {
+		subtree, err := tree.Tree(path)
+		if err != nil {
+			return nil, ErrNotFound
+		}
+		tree = subtree
+	}
+
+	entries := make([]TreeEntry, len(tree.Entries))
+	for i, e := range tree.Entries {
+		entries[i] = TreeEntry{
+			Name:       e.Name,
+			Hash:       Hash(e.Hash.String()),
+			ObjectType: objectTypeFromFileMode(e.Mode),
+		}
+	}
+	return entries, nil
+}
+
+// objectTypeFromFileMode maps a go-git filemode to the repository ObjectType.
+func objectTypeFromFileMode(m filemode.FileMode) ObjectType {
+	switch m {
+	case filemode.Dir:
+		return Tree
+	case filemode.Regular:
+		return Blob
+	case filemode.Executable:
+		return Executable
+	case filemode.Symlink:
+		return Symlink
+	case filemode.Submodule:
+		return Submodule
+	default:
+		return Unknown
+	}
+}
+
+// BlobAtPath returns the content, size, and git object hash of the file at
+// path under ref. rMutex is held for the entire function, covering all
+// shared-Scanner access (CommitObject, Tree, File). The returned reader is
+// safe to use without the mutex: small blobs are already materialized into a
+// MemoryObject (bytes.Reader) by the time File() returns; large blobs come
+// back as an FSObject whose Reader() opens its own independent file handle and
+// Scanner and then reads via ReadAt — no shared state is touched after this
+// function returns. Callers must Close the reader.
+func (repo *GoGitRepo) BlobAtPath(ref, path string) (io.ReadCloser, int64, Hash, error) {
+	path = strings.Trim(path, "/")
+	if path == "" {
+		return nil, 0, "", ErrNotFound
+	}
+
+	repo.rMutex.Lock()
+	defer repo.rMutex.Unlock()
+
+	startHash, err := repo.resolveRefToHash(ref)
+	if err != nil {
+		return nil, 0, "", ErrNotFound
 	}
+	commit, err := repo.r.CommitObject(startHash)
+	if err != nil {
+		return nil, 0, "", err
+	}
+	tree, err := commit.Tree()
 	if err != nil {
-		return CommitMeta{}, err
+		return nil, 0, "", err
+	}
+	f, err := tree.File(path)
+	if err != nil {
+		return nil, 0, "", ErrNotFound
+	}
+	r, err := f.Reader()
+	if err != nil {
+		return nil, 0, "", err
 	}
 
-	return commitToMeta(commit), nil
+	return r, f.Blob.Size, Hash(f.Blob.Hash.String()), nil
 }
 
-func (repo *GoGitRepo) CommitLog(ref string, path string, limit int, after Hash) ([]CommitMeta, error) {
+// CommitLog returns at most limit commits reachable from ref, optionally
+// filtered to those that touched path, starting after the given cursor hash,
+// and bounded by the since/until author-date range.
+func (repo *GoGitRepo) CommitLog(ref, path string, limit int, after Hash, since, until *time.Time) ([]CommitMeta, error) {
 	repo.rMutex.Lock()
 	defer repo.rMutex.Unlock()
 
-	h, err := repo.resolveShortRef(ref)
+	startHash, err := repo.resolveRefToHash(ref)
 	if err != nil {
 		return nil, err
 	}
 
-	opts := &gogit.LogOptions{From: h}
+	// Normalize path: strip leading/trailing slashes so prefix matching works.
+	path = strings.Trim(path, "/")
+
+	opts := &gogit.LogOptions{
+		From:  startHash,
+		Order: gogit.LogOrderCommitterTime,
+	}
 	if path != "" {
 		opts.PathFilter = func(p string) bool {
 			return p == path || strings.HasPrefix(p, path+"/")
 		}
-		// PathFilter requires OrderCommitterTime for correct results
-		opts.Order = gogit.LogOrderCommitterTime
 	}
 
 	iter, err := repo.r.Log(opts)
@@ -880,185 +1230,207 @@ func (repo *GoGitRepo) CommitLog(ref string, path string, limit int, after Hash)
 	}
 	defer iter.Close()
 
-	var commits []CommitMeta
+	var result []CommitMeta
 	skipping := after != ""
-
 	for {
-		if len(commits) >= limit {
-			break
-		}
-		commit, err := iter.Next()
+		c, err := iter.Next()
 		if err == io.EOF {
 			break
 		}
 		if err != nil {
 			return nil, err
 		}
+		h := Hash(c.Hash.String())
 		if skipping {
-			if Hash(commit.Hash.String()) == after {
+			if h == after {
 				skipping = false
 			}
 			continue
 		}
-		commits = append(commits, commitToMeta(commit))
-	}
-
-	return commits, nil
-}
-
-// resolveShortRef resolves a short branch/tag name or full ref to a commit hash.
-// Must be called with rMutex held.
-func (repo *GoGitRepo) resolveShortRef(ref string) (plumbing.Hash, error) {
-	// Try as full ref first, then refs/heads/, refs/tags/, then raw hash.
-	for _, prefix := range []string{"", "refs/heads/", "refs/tags/"} {
-		r, err := repo.r.Reference(plumbing.ReferenceName(prefix+ref), true)
-		if err == nil {
-			return r.Hash(), nil
+		if since != nil && c.Author.When.Before(*since) {
+			continue
+		}
+		if until != nil && c.Author.When.After(*until) {
+			continue
+		}
+		result = append(result, commitToMeta(c))
+		if limit > 0 && len(result) >= limit {
+			break
 		}
 	}
-	// Fall back to treating it as a commit hash directly.
-	h := plumbing.NewHash(ref)
-	if !h.IsZero() {
-		return h, nil
-	}
-	return plumbing.ZeroHash, fmt.Errorf("cannot resolve ref %q", ref)
+	return result, nil
 }
 
-func commitToMeta(c *object.Commit) CommitMeta {
-	msg := strings.TrimSpace(c.Message)
-	if i := strings.IndexByte(msg, '\n'); i >= 0 {
-		msg = msg[:i]
+// treeEntriesAtPath returns the tree hash and a name→entry-hash map for the
+// directory at dirPath inside the given commit. An empty dirPath means the
+// root tree. The tree hash is content-addressed and can be used as a stable
+// cache key regardless of which branch or ref was resolved.
+func treeEntriesAtPath(c *object.Commit, dirPath string) (plumbing.Hash, map[string]plumbing.Hash, error) {
+	tree, err := c.Tree()
+	if err != nil {
+		return plumbing.ZeroHash, nil, err
 	}
-	parents := make([]Hash, len(c.ParentHashes))
-	for i, p := range c.ParentHashes {
-		parents[i] = Hash(p.String())
+	if dirPath != "" {
+		subtree, err := tree.Tree(dirPath)
+		if err != nil {
+			return plumbing.ZeroHash, nil, err
+		}
+		tree = subtree
 	}
-	h := Hash(c.Hash.String())
-	return CommitMeta{
-		Hash:        h,
-		ShortHash:   h.String()[:7],
-		Message:     msg,
-		AuthorName:  c.Author.Name,
-		AuthorEmail: c.Author.Email,
-		Date:        c.Author.When,
-		Parents:     parents,
+	result := make(map[string]plumbing.Hash, len(tree.Entries))
+	for _, e := range tree.Entries {
+		result[e.Name] = e.Hash
 	}
+	return tree.Hash, result, nil
 }
 
-// LastCommitForEntries walks the commit history once (newest-first) and returns
-// the most recent commit that modified each named entry in dirPath.
+// LastCommitForEntries performs a single history walk to find, for each name,
+// the most recent commit that changed that entry in the directory at path.
 //
-// Instead of computing recursive tree diffs, it reads only the shallow tree at
-// dirPath for consecutive commits and compares entry hashes directly. This is
-// O(commits × entries) with cheap hash comparisons rather than O(commits × all
-// changed files in repo).
-func (repo *GoGitRepo) LastCommitForEntries(ref string, dirPath string, names []string) (map[string]CommitMeta, error) {
+// Results are cached by (dirTreeHash, path). Because git trees are
+// content-addressed, two refs that point to the same directory tree share one
+// cache entry, and the cache never needs invalidation: a changed directory
+// produces a new tree hash, which becomes a new key.
+func (repo *GoGitRepo) LastCommitForEntries(ref, path string, names []string) (map[string]CommitMeta, error) {
+	// Normalize path up front so the cache key is canonical.
+	path = strings.Trim(path, "/")
+
+	// Resolve ref and load the current directory tree in one brief lock.
+	// We need the tree hash for the cache key and we keep the entries to
+	// seed the parent-reuse optimisation in the walk below.
 	repo.rMutex.Lock()
-	defer repo.rMutex.Unlock()
-
-	h, err := repo.resolveShortRef(ref)
+	startHash, err := repo.resolveRefToHash(ref)
+	if err != nil {
+		repo.rMutex.Unlock()
+		return nil, err
+	}
+	startCommit, err := repo.r.CommitObject(startHash)
 	if err != nil {
+		repo.rMutex.Unlock()
 		return nil, err
 	}
+	treeHash, startEntries, err := treeEntriesAtPath(startCommit, path)
+	repo.rMutex.Unlock()
+	if err != nil {
+		// path doesn't exist at HEAD — nothing to return.
+		return map[string]CommitMeta{}, nil
+	}
+
+	// The cache is keyed by the directory's tree hash (content-addressed)
+	// plus the path so two directories with identical content but different
+	// locations don't collide.
+	cacheKey := treeHash.String() + "\x00" + path
 
-	result := make(map[string]CommitMeta, len(names))
-	if len(names) == 0 {
+	// Cache hit: filter the stored result down to the requested names.
+	if cached, ok := repo.lastCommitCache.Get(cacheKey); ok {
+		result := make(map[string]CommitMeta, len(names))
+		for _, n := range names {
+			if m, found := cached[n]; found {
+				result[n] = m
+			}
+		}
 		return result, nil
 	}
 
-	// Build lookup set for fast membership test.
-	want := make(map[string]bool, len(names))
-	for _, n := range names {
-		want[n] = true
+	// Cache miss: walk history for ALL entries in this directory so the
+	// cached result is complete and valid for any future name subset.
+	remaining := make(map[string]bool, len(startEntries))
+	for name := range startEntries {
+		remaining[name] = true
 	}
+	result := make(map[string]CommitMeta, len(remaining))
+
+	repo.rMutex.Lock()
 
-	iter, err := repo.r.Log(&gogit.LogOptions{From: h, Order: gogit.LogOrderCommitterTime})
+	iter, err := repo.r.Log(&gogit.LogOptions{
+		From:  startHash,
+		Order: gogit.LogOrderCommitterTime,
+	})
 	if err != nil {
+		repo.rMutex.Unlock()
 		return nil, err
 	}
-	defer iter.Close()
-
-	// dirHashes reads the entry hashes at dirPath for the given commit tree.
-	// Returns a map of entry name → blob/tree hash (shallow, no recursion).
-	dirHashes := func(tree *object.Tree) map[string]plumbing.Hash {
-		t := tree
-		if dirPath != "" {
-			sub, err := tree.Tree(dirPath)
-			if err != nil {
-				return nil
-			}
-			t = sub
-		}
-		m := make(map[string]plumbing.Hash, len(t.Entries))
-		for _, e := range t.Entries {
-			if want[e.Name] {
-				m[e.Name] = e.Hash
-			}
-		}
-		return m
-	}
 
-	// Walk newest→oldest, comparing each commit's directory snapshot with the
-	// previous (newer) commit's snapshot. When a hash differs, the newer commit
-	// is the one that last changed that entry.
-	var prevHashes map[string]plumbing.Hash
-	var prevMeta CommitMeta
+	// Seed the parent-reuse cache with the entries we already fetched above
+	// so the first iteration's current-tree read is skipped for free.
+	// In a linear history this halves tree reads for every subsequent step:
+	// the parent fetched at depth D is the current commit at depth D+1.
+	cachedParentHash := startHash
+	cachedParentEntries := startEntries
 
-	for len(result) < len(names) {
-		commit, err := iter.Next()
+	for depth := 0; len(remaining) > 0 && depth < lastCommitDepthLimit; depth++ {
+		c, err := iter.Next()
 		if err == io.EOF {
 			break
 		}
 		if err != nil {
-			return result, nil
+			iter.Close()
+			repo.rMutex.Unlock()
+			return nil, err
 		}
 
-		tree, err := commit.Tree()
-		if err != nil {
-			continue
+		var currentEntries map[string]plumbing.Hash
+		if c.Hash == cachedParentHash && cachedParentEntries != nil {
+			currentEntries = cachedParentEntries
+		} else {
+			_, currentEntries, err = treeEntriesAtPath(c, path)
+			if err != nil {
+				// path may not exist in this commit; treat as empty
+				currentEntries = map[string]plumbing.Hash{}
+			}
 		}
-		currHashes := dirHashes(tree)
-		meta := commitToMeta(commit)
 
-		if prevHashes != nil {
-			for name := range want {
-				if _, done := result[name]; done {
-					continue
-				}
-				prev, inPrev := prevHashes[name]
-				curr, inCurr := currHashes[name]
-				// If the entry existed in prevHashes but differs (or is gone now),
-				// the previous (newer) commit is when it was last changed.
-				if inPrev && (!inCurr || prev != curr) {
-					result[name] = prevMeta
-				}
+		var parentEntries map[string]plumbing.Hash
+		cachedParentHash = plumbing.ZeroHash
+		cachedParentEntries = nil
+		if len(c.ParentHashes) > 0 {
+			if parent, err := c.Parents().Next(); err == nil {
+				_, parentEntries, _ = treeEntriesAtPath(parent, path)
+				cachedParentHash = c.ParentHashes[0]
+				cachedParentEntries = parentEntries
+			}
+		}
+
+		meta := commitToMeta(c)
+		for name := range remaining {
+			curHash, inCurrent := currentEntries[name]
+			parentHash, inParent := parentEntries[name]
+			if inCurrent != inParent || (inCurrent && curHash != parentHash) {
+				result[name] = meta
+				delete(remaining, name)
 			}
 		}
+	}
 
-		prevHashes = currHashes
-		prevMeta = meta
+	iter.Close()
+	repo.rMutex.Unlock()
+
+	// Store a defensive copy so that callers cannot mutate cached entries.
+	// The cached map contains all directory entries, not just the requested
+	// names, so future calls for the same directory are fully served from
+	// cache regardless of which names they request.
+	cached := make(map[string]CommitMeta, len(result))
+	for k, v := range result {
+		cached[k] = v
 	}
+	repo.lastCommitCache.Add(cacheKey, cached)
 
-	// Any names still present in prevHashes were last changed at the oldest
-	// commit we reached (the entry existed there and we never saw it change).
-	for name := range want {
-		if _, done := result[name]; done {
-			continue
-		}
-		if _, exists := prevHashes[name]; exists {
-			result[name] = prevMeta
+	// Return only the entries that were requested.
+	filtered := make(map[string]CommitMeta, len(names))
+	for _, n := range names {
+		if m, ok := result[n]; ok {
+			filtered[n] = m
 		}
 	}
-
-	return result, nil
+	return filtered, nil
 }
 
-// CommitDetail returns full metadata for a commit plus its changed files.
+// CommitDetail returns the full commit metadata and list of changed files.
 func (repo *GoGitRepo) CommitDetail(hash Hash) (CommitDetail, error) {
 	repo.rMutex.Lock()
 	defer repo.rMutex.Unlock()
 
-	commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
+	c, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
 	if err == plumbing.ErrObjectNotFound {
 		return CommitDetail{}, ErrNotFound
 	}
@@ -1066,56 +1438,63 @@ func (repo *GoGitRepo) CommitDetail(hash Hash) (CommitDetail, error) {
 		return CommitDetail{}, err
 	}
 
-	detail := CommitDetail{
-		CommitMeta:  commitToMeta(commit),
-		FullMessage: strings.TrimSpace(commit.Message),
-	}
-
-	tree, err := commit.Tree()
+	toTree, err := c.Tree()
 	if err != nil {
-		return detail, nil
+		return CommitDetail{}, err
 	}
 
-	var parentTree *object.Tree
-	if len(commit.ParentHashes) > 0 {
-		if parent, err := commit.Parent(0); err == nil {
-			parentTree, _ = parent.Tree()
+	var fromTree *object.Tree
+	if len(c.ParentHashes) > 0 {
+		parent, err := repo.r.CommitObject(c.ParentHashes[0])
+		if err != nil {
+			return CommitDetail{}, fmt.Errorf("loading parent commit: %w", err)
+		}
+		fromTree, err = parent.Tree()
+		if err != nil {
+			return CommitDetail{}, fmt.Errorf("loading parent tree: %w", err)
 		}
-	}
-	if parentTree == nil {
-		parentTree = &object.Tree{}
 	}
 
-	changes, err := object.DiffTree(parentTree, tree)
+	changes, err := object.DiffTree(fromTree, toTree)
 	if err != nil {
-		return detail, nil
+		return CommitDetail{}, err
 	}
 
-	for _, change := range changes {
-		from, to := change.From.Name, change.To.Name
-		var f ChangedFile
-		switch {
-		case from == "":
-			f = ChangedFile{Path: to, Status: "added"}
-		case to == "":
-			f = ChangedFile{Path: from, Status: "deleted"}
-		case from != to:
-			f = ChangedFile{Path: to, OldPath: from, Status: "renamed"}
-		default:
-			f = ChangedFile{Path: to, Status: "modified"}
-		}
-		detail.Files = append(detail.Files, f)
+	// Use ch.From.Name / ch.To.Name directly — these come from the tree
+	// metadata and do not require reading any blob content.
+	files := make([]ChangedFile, 0, len(changes))
+	for _, ch := range changes {
+		files = append(files, changedFileFromChange(ch.From.Name, ch.To.Name))
 	}
 
-	return detail, nil
+	return CommitDetail{
+		CommitMeta:  commitToMeta(c),
+		FullMessage: c.Message,
+		Files:       files,
+	}, nil
 }
 
-// CommitFileDiff returns the structured diff for a single file in a commit.
+func changedFileFromChange(fromName, toName string) ChangedFile {
+	switch {
+	case fromName == "":
+		return ChangedFile{Path: toName, Status: ChangeStatusAdded}
+	case toName == "":
+		return ChangedFile{Path: fromName, Status: ChangeStatusDeleted}
+	case fromName != toName:
+		op := fromName
+		return ChangedFile{Path: toName, OldPath: &op, Status: ChangeStatusRenamed}
+	default:
+		return ChangedFile{Path: toName, Status: ChangeStatusModified}
+	}
+}
+
+// CommitFileDiff returns the unified diff for a single file in a commit,
+// relative to the first parent.
 func (repo *GoGitRepo) CommitFileDiff(hash Hash, filePath string) (FileDiff, error) {
 	repo.rMutex.Lock()
 	defer repo.rMutex.Unlock()
 
-	commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
+	c, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
 	if err == plumbing.ErrObjectNotFound {
 		return FileDiff{}, ErrNotFound
 	}
@@ -1123,250 +1502,175 @@ func (repo *GoGitRepo) CommitFileDiff(hash Hash, filePath string) (FileDiff, err
 		return FileDiff{}, err
 	}
 
-	tree, err := commit.Tree()
+	toTree, err := c.Tree()
 	if err != nil {
 		return FileDiff{}, err
 	}
 
-	var parentTree *object.Tree
-	if len(commit.ParentHashes) > 0 {
-		if parent, err := commit.Parent(0); err == nil {
-			parentTree, _ = parent.Tree()
+	var fromTree *object.Tree
+	if len(c.ParentHashes) > 0 {
+		parent, err := repo.r.CommitObject(c.ParentHashes[0])
+		if err != nil {
+			return FileDiff{}, fmt.Errorf("loading parent commit: %w", err)
+		}
+		fromTree, err = parent.Tree()
+		if err != nil {
+			return FileDiff{}, fmt.Errorf("loading parent tree: %w", err)
 		}
-	}
-	if parentTree == nil {
-		parentTree = &object.Tree{}
 	}
 
-	changes, err := object.DiffTree(parentTree, tree)
+	changes, err := object.DiffTree(fromTree, toTree)
 	if err != nil {
 		return FileDiff{}, err
 	}
 
-	for _, change := range changes {
-		from, to := change.From.Name, change.To.Name
-		if to != filePath && from != filePath {
+	for _, ch := range changes {
+		name := ch.To.Name
+		if name == "" {
+			name = ch.From.Name
+		}
+		// match on either new or old path
+		if name != filePath && ch.From.Name != filePath {
 			continue
 		}
 
-		patch, err := change.Patch()
+		from, to, err := ch.Files()
 		if err != nil {
 			return FileDiff{}, err
 		}
 
-		fps := patch.FilePatches()
-		if len(fps) == 0 {
-			return FileDiff{}, ErrNotFound
+		patch, err := ch.Patch()
+		if err != nil {
+			return FileDiff{}, err
 		}
-		fp := fps[0]
 
-		fromFile, toFile := fp.Files()
 		fd := FileDiff{
-			IsBinary: fp.IsBinary(),
-			IsNew:    fromFile == nil,
-			IsDelete: toFile == nil,
+			IsNew:    from == nil,
+			IsDelete: to == nil,
 		}
-		if toFile != nil {
-			fd.Path = toFile.Path()
-		} else if fromFile != nil {
-			fd.Path = fromFile.Path()
+		if to != nil {
+			fd.Path = to.Name
 		}
-		if fromFile != nil && toFile != nil && fromFile.Path() != toFile.Path() {
-			fd.OldPath = fromFile.Path()
+		if from != nil {
+			if fd.Path == "" {
+				fd.Path = from.Name
+			} else if from.Name != fd.Path {
+				op := from.Name
+				fd.OldPath = &op
+			}
 		}
 
-		if !fd.IsBinary {
-			fd.Hunks = buildDiffHunks(fp.Chunks())
+		fps := patch.FilePatches()
+		if len(fps) > 0 {
+			fp := fps[0]
+			fd.IsBinary = fp.IsBinary()
+			if !fd.IsBinary {
+				fd.Hunks = buildDiffHunks(fp)
+			}
 		}
 		return fd, nil
 	}
-
 	return FileDiff{}, ErrNotFound
 }
 
-// buildDiffHunks converts go-git diff chunks into DiffHunks with context lines.
-func buildDiffHunks(chunks []diff.Chunk) []DiffHunk {
-	const ctx = 3
-
-	type line struct {
-		op      diff.Operation
+// buildDiffHunks converts a go-git FilePatch into DiffHunks with line numbers
+// and context grouping.
+func buildDiffHunks(fp fdiff.FilePatch) []DiffHunk {
+	type pendingLine struct {
+		typ     DiffLineType
 		content string
 		oldLine int
 		newLine int
 	}
 
-	// Expand chunks into individual lines.
-	var lines []line
-	oldN, newN := 1, 1
-	for _, chunk := range chunks {
-		parts := strings.Split(chunk.Content(), "\n")
-		// Split always produces a trailing empty element if content ends with \n.
-		if len(parts) > 0 && parts[len(parts)-1] == "" {
-			parts = parts[:len(parts)-1]
-		}
-		for _, p := range parts {
-			l := line{op: chunk.Type(), content: p}
-			switch chunk.Type() {
-			case diff.Equal:
-				l.oldLine, l.newLine = oldN, newN
-				oldN++
-				newN++
-			case diff.Add:
-				l.newLine = newN
-				newN++
-			case diff.Delete:
-				l.oldLine = oldN
-				oldN++
-			}
-			lines = append(lines, l)
-		}
-	}
-
-	// Collect indices of changed lines.
-	var changed []int
-	for i, l := range lines {
-		if l.op != diff.Equal {
-			changed = append(changed, i)
+	var allLines []pendingLine
+	oldLine, newLine := 1, 1
+	for _, chunk := range fp.Chunks() {
+		lines := strings.Split(chunk.Content(), "\n")
+		// strip trailing empty element produced by a trailing newline
+		if len(lines) > 0 && lines[len(lines)-1] == "" {
+			lines = lines[:len(lines)-1]
 		}
-	}
-	if len(changed) == 0 {
-		return nil
-	}
-
-	// Merge overlapping/adjacent change windows into hunk ranges.
-	type hunkRange struct{ start, end int }
-	var ranges []hunkRange
-	i := 0
-	for i < len(changed) {
-		start := max(0, changed[i]-ctx)
-		end := changed[i]
-		j := i
-		for j < len(changed) && changed[j] <= end+ctx {
-			end = changed[j]
-			j++
-		}
-		end = min(len(lines)-1, end+ctx)
-		ranges = append(ranges, hunkRange{start, end})
-		i = j
-	}
-
-	// Build DiffHunks from ranges.
-	hunks := make([]DiffHunk, 0, len(ranges))
-	for _, r := range ranges {
-		hunk := DiffHunk{}
-		for _, l := range lines[r.start : r.end+1] {
-			if hunk.OldStart == 0 && l.oldLine > 0 {
-				hunk.OldStart = l.oldLine
+		switch chunk.Type() {
+		case fdiff.Equal:
+			for _, l := range lines {
+				allLines = append(allLines, pendingLine{DiffLineContext, l, oldLine, newLine})
+				oldLine++
+				newLine++
 			}
-			if hunk.NewStart == 0 && l.newLine > 0 {
-				hunk.NewStart = l.newLine
+		case fdiff.Add:
+			for _, l := range lines {
+				allLines = append(allLines, pendingLine{DiffLineAdded, l, 0, newLine})
+				newLine++
 			}
-			dl := DiffLine{Content: l.content, OldLine: l.oldLine, NewLine: l.newLine}
-			switch l.op {
-			case diff.Equal:
-				dl.Type = "context"
-				hunk.OldLines++
-				hunk.NewLines++
-			case diff.Add:
-				dl.Type = "added"
-				hunk.NewLines++
-			case diff.Delete:
-				dl.Type = "deleted"
-				hunk.OldLines++
+		case fdiff.Delete:
+			for _, l := range lines {
+				allLines = append(allLines, pendingLine{DiffLineDeleted, l, oldLine, 0})
+				oldLine++
 			}
-			hunk.Lines = append(hunk.Lines, dl)
 		}
-		hunks = append(hunks, hunk)
-	}
-	return hunks
-}
-
-func (repo *GoGitRepo) AllClocks() (map[string]lamport.Clock, error) {
-	repo.clocksMutex.Lock()
-	defer repo.clocksMutex.Unlock()
-
-	result := make(map[string]lamport.Clock)
-
-	files, err := os.ReadDir(filepath.Join(repo.localStorage.Root(), clockPath))
-	if os.IsNotExist(err) {
-		return nil, nil
 	}
-	if err != nil {
-		return nil, err
+	if len(allLines) == 0 {
+		return nil
 	}
 
-	for _, file := range files {
-		name := file.Name()
-		if c, ok := repo.clocks[name]; ok {
-			result[name] = c
+	const ctx = 3 // context lines around each changed block
+
+	// find spans of changed lines
+	type span struct{ start, end int }
+	var spans []span
+	for i, l := range allLines {
+		if l.typ == DiffLineContext {
+			continue
+		}
+		if len(spans) == 0 || i > spans[len(spans)-1].end+1 {
+			spans = append(spans, span{i, i})
 		} else {
-			c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
-			if err != nil {
-				return nil, err
-			}
-			repo.clocks[name] = c
-			result[name] = c
+			spans[len(spans)-1].end = i
 		}
 	}
 
-	return result, nil
-}
-
-// GetOrCreateClock return a Lamport clock stored in the Repo.
-// If the clock doesn't exist, it's created.
-func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) {
-	repo.clocksMutex.Lock()
-	defer repo.clocksMutex.Unlock()
-
-	c, err := repo.getClock(name)
-	if err == nil {
-		return c, nil
-	}
-	if err != ErrClockNotExist {
-		return nil, err
-	}
-
-	c, err = lamport.NewPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
-	if err != nil {
-		return nil, err
-	}
-
-	repo.clocks[name] = c
-	return c, nil
-}
-
-func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) {
-	if c, ok := repo.clocks[name]; ok {
-		return c, nil
-	}
-
-	c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
-	if err == nil {
-		repo.clocks[name] = c
-		return c, nil
-	}
-	if err == lamport.ErrClockNotExist {
-		return nil, ErrClockNotExist
-	}
-	return nil, err
-}
-
-// Increment is equivalent to c = GetOrCreateClock(name) + c.Increment()
-func (repo *GoGitRepo) Increment(name string) (lamport.Time, error) {
-	c, err := repo.GetOrCreateClock(name)
-	if err != nil {
-		return lamport.Time(0), err
+	// expand each span by ctx lines and merge overlapping ones
+	var merged []span
+	for _, s := range spans {
+		s.start = max(0, s.start-ctx)
+		s.end = min(len(allLines)-1, s.end+ctx)
+		if len(merged) > 0 && s.start <= merged[len(merged)-1].end+1 {
+			merged[len(merged)-1].end = s.end
+		} else {
+			merged = append(merged, s)
+		}
 	}
-	return c.Increment()
-}
 
-// Witness is equivalent to c = GetOrCreateClock(name) + c.Witness(time)
-func (repo *GoGitRepo) Witness(name string, time lamport.Time) error {
-	c, err := repo.GetOrCreateClock(name)
-	if err != nil {
-		return err
+	hunks := make([]DiffHunk, 0, len(merged))
+	for _, s := range merged {
+		segment := allLines[s.start : s.end+1]
+		dl := make([]DiffLine, len(segment))
+		var oldStart, newStart, oldCount, newCount int
+		for i, l := range segment {
+			dl[i] = DiffLine{Type: l.typ, Content: l.content, OldLine: l.oldLine, NewLine: l.newLine}
+			if l.oldLine > 0 {
+				if oldStart == 0 {
+					oldStart = l.oldLine
+				}
+				oldCount++
+			}
+			if l.newLine > 0 {
+				if newStart == 0 {
+					newStart = l.newLine
+				}
+				newCount++
+			}
+		}
+		hunks = append(hunks, DiffHunk{
+			OldStart: oldStart,
+			OldLines: oldCount,
+			NewStart: newStart,
+			NewLines: newCount,
+			Lines:    dl,
+		})
 	}
-	return c.Witness(time)
+	return hunks
 }
 
 // AddRemote add a new remote to the repository

repository/mock_repo.go 🔗

@@ -4,8 +4,10 @@ import (
 	"bytes"
 	"crypto/sha1"
 	"fmt"
+	"io"
 	"strings"
 	"sync"
+	"time"
 
 	"github.com/99designs/keyring"
 	"github.com/ProtonMail/go-crypto/openpgp"
@@ -24,7 +26,7 @@ type mockRepo struct {
 	*mockRepoCommon
 	*mockRepoStorage
 	*mockRepoIndex
-	*mockRepoData
+	*mockRepoDataBrowse
 	*mockRepoClock
 	*mockRepoTest
 }
@@ -33,14 +35,14 @@ func (m *mockRepo) Close() error { return nil }
 
 func NewMockRepo() *mockRepo {
 	return &mockRepo{
-		mockRepoConfig:  NewMockRepoConfig(),
-		mockRepoKeyring: NewMockRepoKeyring(),
-		mockRepoCommon:  NewMockRepoCommon(),
-		mockRepoStorage: NewMockRepoStorage(),
-		mockRepoIndex:   newMockRepoIndex(),
-		mockRepoData:    NewMockRepoData(),
-		mockRepoClock:   NewMockRepoClock(),
-		mockRepoTest:    NewMockRepoTest(),
+		mockRepoConfig:     NewMockRepoConfig(),
+		mockRepoKeyring:    NewMockRepoKeyring(),
+		mockRepoCommon:     NewMockRepoCommon(),
+		mockRepoStorage:    NewMockRepoStorage(),
+		mockRepoIndex:      newMockRepoIndex(),
+		mockRepoDataBrowse: newMockRepoDataBrowse(),
+		mockRepoClock:      NewMockRepoClock(),
+		mockRepoTest:       NewMockRepoTest(),
 	}
 }
 
@@ -119,10 +121,6 @@ func (r *mockRepoCommon) GetRemotes() (map[string]string, error) {
 	}, nil
 }
 
-// GetPath returns an empty string for in-memory mock repos.
-func (r *mockRepoCommon) GetPath() string {
-	return ""
-}
 
 var _ RepoStorage = &mockRepoStorage{}
 
@@ -224,47 +222,51 @@ func (m *mockIndex) Close() error {
 	return nil
 }
 
-var _ RepoData = &mockRepoData{}
+var _ RepoData = &mockRepoDataBrowse{}
 
 type commit struct {
 	treeHash Hash
 	parents  []Hash
 	sig      string
+	date     time.Time
+	message  string
 }
 
-type mockRepoData struct {
-	blobs   map[Hash][]byte
-	trees   map[Hash]string
-	commits map[Hash]commit
-	refs    map[string]Hash
+type mockRepoDataBrowse struct {
+	blobs         map[Hash][]byte
+	trees         map[Hash]string
+	commits       map[Hash]commit
+	refs          map[string]Hash
+	defaultBranch string
 }
 
-func NewMockRepoData() *mockRepoData {
-	return &mockRepoData{
-		blobs:   make(map[Hash][]byte),
-		trees:   make(map[Hash]string),
-		commits: make(map[Hash]commit),
-		refs:    make(map[string]Hash),
+func newMockRepoDataBrowse() *mockRepoDataBrowse {
+	return &mockRepoDataBrowse{
+		blobs:         make(map[Hash][]byte),
+		trees:         make(map[Hash]string),
+		commits:       make(map[Hash]commit),
+		refs:          make(map[string]Hash),
+		defaultBranch: "main",
 	}
 }
 
-func (r *mockRepoData) FetchRefs(remote string, prefixes ...string) (string, error) {
+func (r *mockRepoDataBrowse) FetchRefs(remote string, prefixes ...string) (string, error) {
 	panic("implement me")
 }
 
 // PushRefs push git refs to a remote
-func (r *mockRepoData) PushRefs(remote string, prefixes ...string) (string, error) {
+func (r *mockRepoDataBrowse) PushRefs(remote string, prefixes ...string) (string, error) {
 	panic("implement me")
 }
 
-func (r *mockRepoData) StoreData(data []byte) (Hash, error) {
+func (r *mockRepoDataBrowse) StoreData(data []byte) (Hash, error) {
 	rawHash := sha1.Sum(data)
 	hash := Hash(fmt.Sprintf("%x", rawHash))
 	r.blobs[hash] = data
 	return hash, nil
 }
 
-func (r *mockRepoData) ReadData(hash Hash) ([]byte, error) {
+func (r *mockRepoDataBrowse) ReadData(hash Hash) ([]byte, error) {
 	data, ok := r.blobs[hash]
 	if !ok {
 		return nil, ErrNotFound
@@ -273,7 +275,7 @@ func (r *mockRepoData) ReadData(hash Hash) ([]byte, error) {
 	return data, nil
 }
 
-func (r *mockRepoData) StoreTree(entries []TreeEntry) (Hash, error) {
+func (r *mockRepoDataBrowse) StoreTree(entries []TreeEntry) (Hash, error) {
 	buffer := prepareTreeEntries(entries)
 	rawHash := sha1.Sum(buffer.Bytes())
 	hash := Hash(fmt.Sprintf("%x", rawHash))
@@ -282,7 +284,7 @@ func (r *mockRepoData) StoreTree(entries []TreeEntry) (Hash, error) {
 	return hash, nil
 }
 
-func (r *mockRepoData) ReadTree(hash Hash) ([]TreeEntry, error) {
+func (r *mockRepoDataBrowse) ReadTree(hash Hash) ([]TreeEntry, error) {
 	var data string
 
 	data, ok := r.trees[hash]
@@ -305,11 +307,11 @@ func (r *mockRepoData) ReadTree(hash Hash) ([]TreeEntry, error) {
 	return readTreeEntries(data)
 }
 
-func (r *mockRepoData) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) {
+func (r *mockRepoDataBrowse) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) {
 	return r.StoreSignedCommit(treeHash, nil, parents...)
 }
 
-func (r *mockRepoData) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) {
+func (r *mockRepoDataBrowse) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) {
 	hasher := sha1.New()
 	hasher.Write([]byte(treeHash))
 	for _, parent := range parents {
@@ -320,6 +322,7 @@ func (r *mockRepoData) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity,
 	c := commit{
 		treeHash: treeHash,
 		parents:  parents,
+		date:     time.Now(),
 	}
 	if signKey != nil {
 		// unlike go-git, we only sign the tree hash for simplicity instead of all the fields (parents ...)
@@ -333,7 +336,7 @@ func (r *mockRepoData) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity,
 	return hash, nil
 }
 
-func (r *mockRepoData) ReadCommit(hash Hash) (Commit, error) {
+func (r *mockRepoDataBrowse) ReadCommit(hash Hash) (Commit, error) {
 	c, ok := r.commits[hash]
 	if !ok {
 		return Commit{}, ErrNotFound
@@ -355,7 +358,7 @@ func (r *mockRepoData) ReadCommit(hash Hash) (Commit, error) {
 	return result, nil
 }
 
-func (r *mockRepoData) ResolveRef(ref string) (Hash, error) {
+func (r *mockRepoDataBrowse) ResolveRef(ref string) (Hash, error) {
 	h, ok := r.refs[ref]
 	if !ok {
 		return "", ErrNotFound
@@ -363,17 +366,17 @@ func (r *mockRepoData) ResolveRef(ref string) (Hash, error) {
 	return h, nil
 }
 
-func (r *mockRepoData) UpdateRef(ref string, hash Hash) error {
+func (r *mockRepoDataBrowse) UpdateRef(ref string, hash Hash) error {
 	r.refs[ref] = hash
 	return nil
 }
 
-func (r *mockRepoData) RemoveRef(ref string) error {
+func (r *mockRepoDataBrowse) RemoveRef(ref string) error {
 	delete(r.refs, ref)
 	return nil
 }
 
-func (r *mockRepoData) ListRefs(refPrefix string) ([]string, error) {
+func (r *mockRepoDataBrowse) ListRefs(refPrefix string) ([]string, error) {
 	var keys []string
 
 	for k := range r.refs {
@@ -385,12 +388,12 @@ func (r *mockRepoData) ListRefs(refPrefix string) ([]string, error) {
 	return keys, nil
 }
 
-func (r *mockRepoData) RefExist(ref string) (bool, error) {
+func (r *mockRepoDataBrowse) RefExist(ref string) (bool, error) {
 	_, exist := r.refs[ref]
 	return exist, nil
 }
 
-func (r *mockRepoData) CopyRef(source string, dest string) error {
+func (r *mockRepoDataBrowse) CopyRef(source string, dest string) error {
 	hash, exist := r.refs[source]
 
 	if !exist {
@@ -401,10 +404,446 @@ func (r *mockRepoData) CopyRef(source string, dest string) error {
 	return nil
 }
 
-func (r *mockRepoData) ListCommits(ref string) ([]Hash, error) {
+func (r *mockRepoDataBrowse) ListCommits(ref string) ([]Hash, error) {
 	return nonNativeListCommits(r, ref)
 }
 
+// resolveRef resolves a ref matching the RepoBrowse contract:
+// refs/heads/<ref>, refs/tags/<ref>, full ref name, raw commit hash.
+func (r *mockRepoDataBrowse) resolveRef(ref string) (Hash, error) {
+	for _, candidate := range []string{"refs/heads/" + ref, "refs/tags/" + ref, ref} {
+		if h, ok := r.refs[candidate]; ok {
+			return h, nil
+		}
+	}
+	if _, ok := r.commits[Hash(ref)]; ok {
+		return Hash(ref), nil
+	}
+	return "", ErrNotFound
+}
+
+// treeEntriesAtHash parses the entries of the tree stored under hash.
+func (r *mockRepoDataBrowse) treeEntriesAtHash(hash Hash) ([]TreeEntry, error) {
+	data, ok := r.trees[hash]
+	if !ok {
+		return nil, ErrNotFound
+	}
+	return readTreeEntries(data)
+}
+
+// treeEntriesAt returns the directory entries at path inside the tree rooted at
+// treeHash. path="" returns root entries. Returns ErrNotFound if path doesn't
+// exist or resolves to a blob rather than a tree.
+func (r *mockRepoDataBrowse) treeEntriesAt(treeHash Hash, path string) ([]TreeEntry, error) {
+	path = strings.Trim(path, "/")
+	if path == "" {
+		return r.treeEntriesAtHash(treeHash)
+	}
+	seg, rest, _ := strings.Cut(path, "/")
+	entries, err := r.treeEntriesAtHash(treeHash)
+	if err != nil {
+		return nil, err
+	}
+	for _, e := range entries {
+		if e.Name != seg || e.ObjectType != Tree {
+			continue
+		}
+		if rest == "" {
+			return r.treeEntriesAtHash(e.Hash)
+		}
+		return r.treeEntriesAt(e.Hash, rest)
+	}
+	return nil, ErrNotFound
+}
+
+// blobHashAt walks the tree to find the blob hash for the file at path.
+func (r *mockRepoDataBrowse) blobHashAt(treeHash Hash, path string) (Hash, error) {
+	path = strings.Trim(path, "/")
+	seg, rest, hasRest := strings.Cut(path, "/")
+	entries, err := r.treeEntriesAtHash(treeHash)
+	if err != nil {
+		return "", err
+	}
+	for _, e := range entries {
+		if e.Name != seg {
+			continue
+		}
+		if !hasRest {
+			return e.Hash, nil
+		}
+		if e.ObjectType != Tree {
+			return "", ErrNotFound
+		}
+		return r.blobHashAt(e.Hash, rest)
+	}
+	return "", ErrNotFound
+}
+
+// diffTrees returns the changed files between two trees, recursing into
+// sub-trees. fromHash=="" means an empty (non-existent) tree.
+func (r *mockRepoDataBrowse) diffTrees(fromHash, toHash Hash, prefix string) []ChangedFile {
+	var fromEntries, toEntries []TreeEntry
+	if fromHash != "" {
+		fromEntries, _ = r.treeEntriesAtHash(fromHash)
+	}
+	if toHash != "" {
+		toEntries, _ = r.treeEntriesAtHash(toHash)
+	}
+
+	fromMap := make(map[string]TreeEntry, len(fromEntries))
+	for _, e := range fromEntries {
+		fromMap[e.Name] = e
+	}
+	toMap := make(map[string]TreeEntry, len(toEntries))
+	for _, e := range toEntries {
+		toMap[e.Name] = e
+	}
+
+	var result []ChangedFile
+	for _, e := range toEntries {
+		path := prefix + e.Name
+		f, existed := fromMap[e.Name]
+		if e.ObjectType == Tree {
+			var sub Hash
+			if existed {
+				sub = f.Hash
+			}
+			result = append(result, r.diffTrees(sub, e.Hash, path+"/")...)
+		} else if !existed {
+			result = append(result, ChangedFile{Path: path, Status: ChangeStatusAdded})
+		} else if f.Hash != e.Hash {
+			result = append(result, ChangedFile{Path: path, Status: ChangeStatusModified})
+		}
+	}
+	for _, f := range fromEntries {
+		if _, exists := toMap[f.Name]; exists {
+			continue
+		}
+		path := prefix + f.Name
+		if f.ObjectType == Tree {
+			result = append(result, r.diffTrees(f.Hash, "", path+"/")...)
+		} else {
+			result = append(result, ChangedFile{Path: path, Status: ChangeStatusDeleted})
+		}
+	}
+	return result
+}
+
+func mockCommitMeta(hash Hash, c commit) CommitMeta {
+	return CommitMeta{
+		Hash:    hash,
+		Parents: c.parents,
+		Date:    c.date,
+		Message: c.message,
+	}
+}
+
+func (r *mockRepoDataBrowse) Branches() ([]BranchInfo, error) {
+	var branches []BranchInfo
+	for ref, hash := range r.refs {
+		name, ok := strings.CutPrefix(ref, "refs/heads/")
+		if !ok {
+			continue
+		}
+		branches = append(branches, BranchInfo{
+			Name:      name,
+			Hash:      hash,
+			IsDefault: name == r.defaultBranch,
+		})
+	}
+	return branches, nil
+}
+
+func (r *mockRepoDataBrowse) Tags() ([]TagInfo, error) {
+	var tags []TagInfo
+	for ref, hash := range r.refs {
+		name, ok := strings.CutPrefix(ref, "refs/tags/")
+		if !ok {
+			continue
+		}
+		tags = append(tags, TagInfo{Name: name, Hash: hash})
+	}
+	return tags, nil
+}
+
+func (r *mockRepoDataBrowse) TreeAtPath(ref, path string) ([]TreeEntry, error) {
+	startHash, err := r.resolveRef(ref)
+	if err != nil {
+		return nil, ErrNotFound
+	}
+	c, ok := r.commits[startHash]
+	if !ok {
+		return nil, ErrNotFound
+	}
+	return r.treeEntriesAt(c.treeHash, path)
+}
+
+func (r *mockRepoDataBrowse) BlobAtPath(ref, path string) (io.ReadCloser, int64, Hash, error) {
+	startHash, err := r.resolveRef(ref)
+	if err != nil {
+		return nil, 0, "", ErrNotFound
+	}
+	c, ok := r.commits[startHash]
+	if !ok {
+		return nil, 0, "", ErrNotFound
+	}
+	blobHash, err := r.blobHashAt(c.treeHash, path)
+	if err != nil {
+		return nil, 0, "", ErrNotFound
+	}
+	data, ok := r.blobs[blobHash]
+	if !ok {
+		return nil, 0, "", ErrNotFound
+	}
+	return io.NopCloser(bytes.NewReader(data)), int64(len(data)), blobHash, nil
+}
+
+func (r *mockRepoDataBrowse) CommitLog(ref, path string, limit int, after Hash, since, until *time.Time) ([]CommitMeta, error) {
+	startHash, err := r.resolveRef(ref)
+	if err != nil {
+		return nil, ErrNotFound
+	}
+	path = strings.Trim(path, "/")
+	var result []CommitMeta
+	skipping := after != ""
+	current := startHash
+	seen := make(map[Hash]bool)
+	for {
+		if seen[current] {
+			break
+		}
+		seen[current] = true
+		c, ok := r.commits[current]
+		if !ok {
+			break
+		}
+		if skipping {
+			if current == after {
+				skipping = false
+			}
+			if len(c.parents) == 0 {
+				break
+			}
+			current = c.parents[0]
+			continue
+		}
+		meta := mockCommitMeta(current, c)
+		if since != nil && meta.Date.Before(*since) {
+			if len(c.parents) == 0 {
+				break
+			}
+			current = c.parents[0]
+			continue
+		}
+		if until != nil && meta.Date.After(*until) {
+			if len(c.parents) == 0 {
+				break
+			}
+			current = c.parents[0]
+			continue
+		}
+		if path != "" {
+			var fromTreeHash Hash
+			if len(c.parents) > 0 {
+				if parent, ok := r.commits[c.parents[0]]; ok {
+					fromTreeHash = parent.treeHash
+				}
+			}
+			touched := false
+			for _, f := range r.diffTrees(fromTreeHash, c.treeHash, "") {
+				if f.Path == path || strings.HasPrefix(f.Path, path+"/") {
+					touched = true
+					break
+				}
+			}
+			if !touched {
+				if len(c.parents) == 0 {
+					break
+				}
+				current = c.parents[0]
+				continue
+			}
+		}
+		result = append(result, meta)
+		if limit > 0 && len(result) >= limit {
+			break
+		}
+		if len(c.parents) == 0 {
+			break
+		}
+		current = c.parents[0]
+	}
+	return result, nil
+}
+
+func (r *mockRepoDataBrowse) LastCommitForEntries(ref, path string, names []string) (map[string]CommitMeta, error) {
+	startHash, err := r.resolveRef(ref)
+	if err != nil {
+		return nil, ErrNotFound
+	}
+	path = strings.Trim(path, "/")
+	remaining := make(map[string]bool, len(names))
+	for _, n := range names {
+		remaining[n] = true
+	}
+	result := make(map[string]CommitMeta)
+	current := startHash
+	seen := make(map[Hash]bool)
+	for len(remaining) > 0 {
+		if seen[current] {
+			break
+		}
+		seen[current] = true
+		c, ok := r.commits[current]
+		if !ok {
+			break
+		}
+		curEntries, err := r.treeEntriesAt(c.treeHash, path)
+		if err != nil {
+			if len(c.parents) == 0 {
+				break
+			}
+			current = c.parents[0]
+			continue
+		}
+		curMap := make(map[string]Hash, len(curEntries))
+		for _, e := range curEntries {
+			curMap[e.Name] = e.Hash
+		}
+		if len(c.parents) == 0 {
+			for name := range remaining {
+				if _, ok := curMap[name]; ok {
+					result[name] = mockCommitMeta(current, c)
+					delete(remaining, name)
+				}
+			}
+			break
+		}
+		pc, ok := r.commits[c.parents[0]]
+		if !ok {
+			break
+		}
+		parentEntries, _ := r.treeEntriesAt(pc.treeHash, path)
+		parentMap := make(map[string]Hash, len(parentEntries))
+		for _, e := range parentEntries {
+			parentMap[e.Name] = e.Hash
+		}
+		for name := range remaining {
+			cur, curExists := curMap[name]
+			par, parExists := parentMap[name]
+			if curExists && (!parExists || cur != par) {
+				result[name] = mockCommitMeta(current, c)
+				delete(remaining, name)
+			}
+		}
+		current = c.parents[0]
+	}
+	return result, nil
+}
+
+func (r *mockRepoDataBrowse) CommitDetail(hash Hash) (CommitDetail, error) {
+	c, ok := r.commits[hash]
+	if !ok {
+		return CommitDetail{}, ErrNotFound
+	}
+	var fromTreeHash Hash
+	if len(c.parents) > 0 {
+		if parent, ok := r.commits[c.parents[0]]; ok {
+			fromTreeHash = parent.treeHash
+		}
+	}
+	return CommitDetail{
+		CommitMeta: mockCommitMeta(hash, c),
+		Files:      r.diffTrees(fromTreeHash, c.treeHash, ""),
+	}, nil
+}
+
+func (r *mockRepoDataBrowse) CommitFileDiff(hash Hash, filePath string) (FileDiff, error) {
+	c, ok := r.commits[hash]
+	if !ok {
+		return FileDiff{}, ErrNotFound
+	}
+	var fromTreeHash Hash
+	if len(c.parents) > 0 {
+		if parent, ok := r.commits[c.parents[0]]; ok {
+			fromTreeHash = parent.treeHash
+		}
+	}
+	files := r.diffTrees(fromTreeHash, c.treeHash, "")
+	var matched *ChangedFile
+	for i := range files {
+		if files[i].Path == filePath {
+			matched = &files[i]
+			break
+		}
+	}
+	if matched == nil {
+		return FileDiff{}, ErrNotFound
+	}
+	fd := FileDiff{
+		Path:     filePath,
+		IsNew:    matched.Status == ChangeStatusAdded,
+		IsDelete: matched.Status == ChangeStatusDeleted,
+	}
+	var oldContent, newContent []byte
+	if fromTreeHash != "" {
+		if bh, err := r.blobHashAt(fromTreeHash, filePath); err == nil {
+			oldContent = r.blobs[bh]
+		}
+	}
+	if bh, err := r.blobHashAt(c.treeHash, filePath); err == nil {
+		newContent = r.blobs[bh]
+	}
+	fd.Hunks = mockDiffHunks(oldContent, newContent)
+	return fd, nil
+}
+
+// mockDiffHunks produces a single DiffHunk using a prefix/suffix scan.
+func mockDiffHunks(old, new []byte) []DiffHunk {
+	oldLines := splitBlobLines(old)
+	newLines := splitBlobLines(new)
+	i := 0
+	for i < len(oldLines) && i < len(newLines) && oldLines[i] == newLines[i] {
+		i++
+	}
+	j, k := len(oldLines), len(newLines)
+	for j > i && k > i && oldLines[j-1] == newLines[k-1] {
+		j--
+		k--
+	}
+	if j == i && k == i {
+		return nil // no changed region
+	}
+	oldLine, newLine := 1, 1
+	var lines []DiffLine
+	for _, l := range oldLines[:i] {
+		lines = append(lines, DiffLine{Type: DiffLineContext, Content: l, OldLine: oldLine, NewLine: newLine})
+		oldLine++
+		newLine++
+	}
+	for _, l := range oldLines[i:j] {
+		lines = append(lines, DiffLine{Type: DiffLineDeleted, Content: l, OldLine: oldLine})
+		oldLine++
+	}
+	for _, l := range newLines[i:k] {
+		lines = append(lines, DiffLine{Type: DiffLineAdded, Content: l, NewLine: newLine})
+		newLine++
+	}
+	for _, l := range oldLines[j:] {
+		lines = append(lines, DiffLine{Type: DiffLineContext, Content: l, OldLine: oldLine, NewLine: newLine})
+		oldLine++
+		newLine++
+	}
+	return []DiffHunk{{OldStart: 1, OldLines: len(oldLines), NewStart: 1, NewLines: len(newLines), Lines: lines}}
+}
+
+func splitBlobLines(data []byte) []string {
+	if len(data) == 0 {
+		return nil
+	}
+	return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+}
+
 var _ RepoClock = &mockRepoClock{}
 
 type mockRepoClock struct {

repository/repo.go 🔗

@@ -29,6 +29,7 @@ type Repo interface {
 	RepoStorage
 	RepoIndex
 	RepoData
+	RepoBrowse
 
 	Close() error
 }
@@ -76,10 +77,6 @@ type RepoCommon interface {
 	// GetRemotes returns the configured remotes repositories.
 	GetRemotes() (map[string]string, error)
 
-	// GetPath returns the root directory path of the repository (the directory
-	// that contains the .git folder for bare repos, or the .git folder itself
-	// for bare clones). Returns an empty string for in-memory/mock repos.
-	GetPath() string
 }
 
 type LocalStorage interface {
@@ -188,7 +185,7 @@ type RepoData interface {
 	// ListRefs will return a list of Git ref matching the given refspec
 	ListRefs(refPrefix string) ([]string, error)
 
-	// RefExist will check if a reference exist in Git
+	// RefExist will check if a reference exists in Git
 	RefExist(ref string) (bool, error)
 
 	// CopyRef will create a new reference with the same value as another one
@@ -199,90 +196,6 @@ type RepoData interface {
 	ListCommits(ref string) ([]Hash, error)
 }
 
-// CommitMeta holds the display-relevant metadata of a git commit.
-type CommitMeta struct {
-	Hash        Hash
-	ShortHash   string
-	Message     string // first line only
-	AuthorName  string
-	AuthorEmail string
-	Date        time.Time
-	Parents     []Hash
-}
-
-// RepoBrowse extends a repo with read-only methods needed for code browsing.
-// Implemented by GoGitRepo; not part of the core Repo interface because not
-// all repository implementations (e.g. in-memory test repos) need it.
-type RepoBrowse interface {
-	// GetDefaultBranch returns the short name of the branch HEAD points to.
-	GetDefaultBranch() (string, error)
-
-	// ReadCommitMeta reads full commit metadata including author and message.
-	ReadCommitMeta(hash Hash) (CommitMeta, error)
-
-	// CommitLog returns up to limit commits reachable from ref (short name or
-	// full ref), optionally filtered to commits that touch path.
-	// If after is non-empty, results start after that commit hash (pagination).
-	CommitLog(ref string, path string, limit int, after Hash) ([]CommitMeta, error)
-
-	// LastCommitForEntries walks the commit history once and returns the most
-	// recent commit that touched each named entry inside dirPath.
-	// dirPath is the directory being browsed (empty = repo root).
-	// names are the immediate child names (files/dirs) inside that directory.
-	// The returned map is keyed by entry name; missing entries were not found.
-	LastCommitForEntries(ref string, dirPath string, names []string) (map[string]CommitMeta, error)
-
-	// CommitDetail returns the full metadata for a single commit plus the list
-	// of files it changed relative to its first parent.
-	CommitDetail(hash Hash) (CommitDetail, error)
-
-	// CommitFileDiff returns the structured diff for a single file in a commit
-	// relative to its first parent. path is the current file path (or old path
-	// for deletions).
-	CommitFileDiff(hash Hash, path string) (FileDiff, error)
-}
-
-// DiffLine is a single line in a diff hunk.
-type DiffLine struct {
-	Type    string // "context" | "added" | "deleted"
-	Content string
-	OldLine int // 0 for added lines
-	NewLine int // 0 for deleted lines
-}
-
-// DiffHunk is a contiguous block of changes with surrounding context.
-type DiffHunk struct {
-	OldStart int
-	OldLines int
-	NewStart int
-	NewLines int
-	Lines    []DiffLine
-}
-
-// FileDiff holds the diff for a single file in a commit.
-type FileDiff struct {
-	Path     string
-	OldPath  string // non-empty for renames
-	IsBinary bool
-	IsNew    bool
-	IsDelete bool
-	Hunks    []DiffHunk
-}
-
-// ChangedFile describes a single file changed by a commit.
-type ChangedFile struct {
-	Path    string // current path (or old path for deletions)
-	OldPath string // only set for renames
-	Status  string // "added" | "modified" | "deleted" | "renamed"
-}
-
-// CommitDetail extends CommitMeta with the full message body and changed files.
-type CommitDetail struct {
-	CommitMeta
-	FullMessage string
-	Files       []ChangedFile
-}
-
 // RepoClock give access to Lamport clocks
 type RepoClock interface {
 	// AllClocks return all the known clocks
@@ -299,11 +212,63 @@ type RepoClock interface {
 	Witness(name string, time lamport.Time) error
 }
 
+// RepoBrowse is implemented by all Repo implementations and provides
+// code-browsing endpoints (file tree, history, diffs).
+//
+// All methods accepting a ref parameter resolve it in order:
+// refs/heads/<ref>, refs/tags/<ref>, full ref name, raw commit hash.
+type RepoBrowse interface {
+	// Branches returns all local branches (refs/heads/*).
+	// IsDefault marks the branch HEAD points to.
+	// All other ref namespaces — including git-bug's internal refs
+	// (refs/bugs/, refs/identities/, …) — are excluded.
+	Branches() ([]BranchInfo, error)
+
+	// Tags returns all tags (refs/tags/*).
+	// All other ref namespaces are excluded.
+	Tags() ([]TagInfo, error)
+
+	// TreeAtPath returns the entries of the directory at path under ref.
+	// An empty path returns the root tree.
+	// Returns ErrNotFound if ref or path does not exist, or if path
+	// resolves to a blob rather than a tree.
+	// Symlinks appear as entries with ObjectType Symlink; they are not followed.
+	TreeAtPath(ref, path string) ([]TreeEntry, error)
+
+	// BlobAtPath returns the raw content, byte size, and git object hash of
+	// the file at path under ref. Returns ErrNotFound if ref or path does
+	// not exist, or if path resolves to a tree. Symlinks are not followed.
+	// The caller must close the reader.
+	BlobAtPath(ref, path string) (io.ReadCloser, int64, Hash, error)
+
+	// CommitLog returns at most limit commits reachable from ref, filtered
+	// to those touching path (empty = unrestricted). after is an exclusive
+	// cursor; pass Hash("") for no cursor. since and until bound the author
+	// date (inclusive); pass nil for no bound. Merge commits appear once,
+	// compared against the first parent only.
+	CommitLog(ref, path string, limit int, after Hash, since, until *time.Time) ([]CommitMeta, error)
+
+	// LastCommitForEntries returns the most recent commit that touched each
+	// name in the directory at path under ref. Entries not resolved within
+	// the implementation's depth limit are silently absent from the result.
+	LastCommitForEntries(ref, path string, names []string) (map[string]CommitMeta, error)
+
+	// CommitDetail returns the full metadata and changed-file list for a
+	// single commit identified by its hash. Diffs against the first parent
+	// only; the initial commit is diffed against the empty tree.
+	CommitDetail(hash Hash) (CommitDetail, error)
+
+	// CommitFileDiff returns the unified diff for a single file in a commit
+	// identified by its hash. Diffs against the first parent only; the
+	// initial commit is diffed against the empty tree.
+	CommitFileDiff(hash Hash, filePath string) (FileDiff, error)
+}
+
 // ClockLoader hold which logical clock need to exist for an entity and
 // how to create them if they don't.
 type ClockLoader struct {
-	// Clocks hold the name of all the clocks this loader deal with.
-	// Those clocks will be checked when the repo load. If not present or broken,
+	// Clocks hold the name of all the clocks this loader deals with.
+	// Those clocks will be checked when the repo loads. If not present or broken,
 	// Witnesser will be used to create them.
 	Clocks []string
 	// Witnesser is a function that will initialize the clocks of a repo
@@ -311,13 +276,13 @@ type ClockLoader struct {
 	Witnesser func(repo ClockedRepo) error
 }
 
-// TestedRepo is an extended ClockedRepo with function for testing only
+// TestedRepo is an extended ClockedRepo with functions for testing only
 type TestedRepo interface {
 	ClockedRepo
 	repoTest
 }
 
-// repoTest give access to test only functions
+// repoTest give access to test-only functions
 type repoTest interface {
 	// AddRemote add a new remote to the repository
 	AddRemote(name string, url string) error

repository/repo_testing.go 🔗

@@ -1,9 +1,11 @@
 package repository
 
 import (
+	"io"
 	"math/rand"
 	"os"
 	"testing"
+	"time"
 
 	"github.com/ProtonMail/go-crypto/openpgp"
 	"github.com/stretchr/testify/require"
@@ -27,6 +29,10 @@ func RepoTest(t *testing.T, creator RepoCreator) {
 				RepoDataSignatureTest(t, repo)
 			})
 
+			t.Run("Browse", func(t *testing.T) {
+				RepoBrowseTest(t, repo)
+			})
+
 			t.Run("Config", func(t *testing.T) {
 				RepoConfigTest(t, repo)
 			})
@@ -360,3 +366,377 @@ func randomData() []byte {
 	}
 	return b
 }
+
+// browsable is the interface required by RepoBrowseTest.
+type browsable interface {
+	RepoConfig
+	RepoData
+	RepoBrowse
+}
+
+// RepoBrowseTest exercises the RepoBrowse interface against any implementation.
+//
+// Commit graph (oldest → newest):
+//
+//	c1 ── c2 ── c3   refs/heads/main (default)
+//	       └────────  refs/heads/feature
+//	c1 ←── refs/tags/v1.0
+func RepoBrowseTest(t *testing.T, repo browsable) {
+	t.Helper()
+
+	require.NoError(t, repo.LocalConfig().StoreString("init.defaultBranch", "main"))
+
+	// ── build fixture ─────────────────────────────────────────────────────────
+
+	readmeV1 := []byte("# Hello\n")
+	readmeV3 := []byte("# Hello\n\n## Updated\n")
+	mainV1 := []byte("package main\n")
+	mainV2 := []byte("package main\n\n// updated\n")
+	libV1 := []byte("package lib\n")
+	utilV1 := []byte("package util\n")
+
+	hReadmeV1, err := repo.StoreData(readmeV1)
+	require.NoError(t, err)
+	hReadmeV3, err := repo.StoreData(readmeV3)
+	require.NoError(t, err)
+	hMainV1, err := repo.StoreData(mainV1)
+	require.NoError(t, err)
+	hMainV2, err := repo.StoreData(mainV2)
+	require.NoError(t, err)
+	hLibV1, err := repo.StoreData(libV1)
+	require.NoError(t, err)
+	hUtilV1, err := repo.StoreData(utilV1)
+	require.NoError(t, err)
+
+	srcTreeV1, err := repo.StoreTree([]TreeEntry{
+		{ObjectType: Blob, Hash: hLibV1, Name: "lib.go"},
+	})
+	require.NoError(t, err)
+	rootTreeV1, err := repo.StoreTree([]TreeEntry{
+		{ObjectType: Blob, Hash: hReadmeV1, Name: "README.md"},
+		{ObjectType: Blob, Hash: hMainV1, Name: "main.go"},
+		{ObjectType: Tree, Hash: srcTreeV1, Name: "src"},
+	})
+	require.NoError(t, err)
+
+	srcTreeV2, err := repo.StoreTree([]TreeEntry{
+		{ObjectType: Blob, Hash: hLibV1, Name: "lib.go"},
+		{ObjectType: Blob, Hash: hUtilV1, Name: "util.go"},
+	})
+	require.NoError(t, err)
+	rootTreeV2, err := repo.StoreTree([]TreeEntry{
+		{ObjectType: Blob, Hash: hReadmeV1, Name: "README.md"},
+		{ObjectType: Blob, Hash: hMainV2, Name: "main.go"},
+		{ObjectType: Tree, Hash: srcTreeV2, Name: "src"},
+	})
+	require.NoError(t, err)
+
+	rootTreeV3, err := repo.StoreTree([]TreeEntry{
+		{ObjectType: Blob, Hash: hReadmeV3, Name: "README.md"},
+		{ObjectType: Blob, Hash: hMainV2, Name: "main.go"},
+		{ObjectType: Tree, Hash: srcTreeV2, Name: "src"},
+	})
+	require.NoError(t, err)
+
+	c1, err := repo.StoreCommit(rootTreeV1)
+	require.NoError(t, err)
+	c2, err := repo.StoreCommit(rootTreeV2, c1)
+	require.NoError(t, err)
+	c3, err := repo.StoreCommit(rootTreeV3, c2)
+	require.NoError(t, err)
+
+	require.NoError(t, repo.UpdateRef("refs/heads/main", c3))
+	require.NoError(t, repo.UpdateRef("refs/heads/feature", c2))
+	require.NoError(t, repo.UpdateRef("refs/tags/v1.0", c1))
+
+	// ── Branches ──────────────────────────────────────────────────────────────
+
+	t.Run("Branches", func(t *testing.T) {
+		branches, err := repo.Branches()
+		require.NoError(t, err)
+		require.Len(t, branches, 2)
+
+		byName := make(map[string]BranchInfo)
+		for _, b := range branches {
+			byName[b.Name] = b
+		}
+
+		require.Equal(t, c3, byName["main"].Hash)
+		require.True(t, byName["main"].IsDefault)
+
+		require.Equal(t, c2, byName["feature"].Hash)
+		require.False(t, byName["feature"].IsDefault)
+	})
+
+	// ── Tags ──────────────────────────────────────────────────────────────────
+
+	t.Run("Tags", func(t *testing.T) {
+		tags, err := repo.Tags()
+		require.NoError(t, err)
+		require.Len(t, tags, 1)
+		require.Equal(t, "v1.0", tags[0].Name)
+		require.Equal(t, c1, tags[0].Hash)
+	})
+
+	// ── TreeAtPath ────────────────────────────────────────────────────────────
+
+	t.Run("TreeAtPath", func(t *testing.T) {
+		entries, err := repo.TreeAtPath("main", "")
+		require.NoError(t, err)
+		byName := make(map[string]TreeEntry)
+		for _, e := range entries {
+			byName[e.Name] = e
+		}
+		require.Equal(t, Blob, byName["README.md"].ObjectType)
+		require.Equal(t, Blob, byName["main.go"].ObjectType)
+		require.Equal(t, Tree, byName["src"].ObjectType)
+
+		// subdirectory
+		srcEntries, err := repo.TreeAtPath("main", "src")
+		require.NoError(t, err)
+		srcByName := make(map[string]TreeEntry)
+		for _, e := range srcEntries {
+			srcByName[e.Name] = e
+		}
+		require.Equal(t, Blob, srcByName["lib.go"].ObjectType)
+		require.Equal(t, Blob, srcByName["util.go"].ObjectType)
+
+		// v1.0 tag (at c1) predates util.go — src only has lib.go
+		v1Src, err := repo.TreeAtPath("v1.0", "src")
+		require.NoError(t, err)
+		require.Len(t, v1Src, 1)
+		require.Equal(t, "lib.go", v1Src[0].Name)
+
+		// unknown ref
+		_, err = repo.TreeAtPath("nonexistent-ref", "")
+		require.ErrorIs(t, err, ErrNotFound)
+
+		// path resolves to a blob, not a tree
+		_, err = repo.TreeAtPath("main", "README.md")
+		require.Error(t, err)
+	})
+
+	// ── BlobAtPath ────────────────────────────────────────────────────────────
+
+	t.Run("BlobAtPath", func(t *testing.T) {
+		rc, size, hash, err := repo.BlobAtPath("main", "README.md")
+		require.NoError(t, err)
+		defer rc.Close()
+		data, err := io.ReadAll(rc)
+		require.NoError(t, err)
+		require.Equal(t, readmeV3, data)
+		require.Equal(t, int64(len(readmeV3)), size)
+		require.NotEmpty(t, hash)
+
+		// feature branch still has readmeV1
+		rc2, _, _, err := repo.BlobAtPath("feature", "README.md")
+		require.NoError(t, err)
+		data2, err := io.ReadAll(rc2)
+		rc2.Close()
+		require.NoError(t, err)
+		require.Equal(t, readmeV1, data2)
+
+		// file in subdirectory
+		rc3, _, _, err := repo.BlobAtPath("main", "src/lib.go")
+		require.NoError(t, err)
+		data3, err := io.ReadAll(rc3)
+		rc3.Close()
+		require.NoError(t, err)
+		require.Equal(t, libV1, data3)
+
+		// path not found
+		_, _, _, err = repo.BlobAtPath("main", "nonexistent.go")
+		require.ErrorIs(t, err, ErrNotFound)
+
+		// hash is stable across calls for the same content
+		rc4, _, hash2, err := repo.BlobAtPath("main", "README.md")
+		require.NoError(t, err)
+		rc4.Close()
+		require.Equal(t, hash, hash2, "blob hash should be stable across calls")
+
+		// different content → different hash
+		rc5, _, hashLib, err := repo.BlobAtPath("main", "src/lib.go")
+		require.NoError(t, err)
+		rc5.Close()
+		require.NotEqual(t, hash, hashLib, "different files should have different hashes")
+	})
+
+	// ── CommitLog ─────────────────────────────────────────────────────────────
+
+	t.Run("CommitLog", func(t *testing.T) {
+		// all commits, newest first
+		commits, err := repo.CommitLog("main", "", 10, "", nil, nil)
+		require.NoError(t, err)
+		require.Len(t, commits, 3)
+		require.Equal(t, c3, commits[0].Hash)
+		require.Equal(t, c2, commits[1].Hash)
+		require.Equal(t, c1, commits[2].Hash)
+
+		// limit
+		limited, err := repo.CommitLog("main", "", 2, "", nil, nil)
+		require.NoError(t, err)
+		require.Len(t, limited, 2)
+		require.Equal(t, c3, limited[0].Hash)
+		require.Equal(t, c2, limited[1].Hash)
+
+		// after cursor (exclusive): start after c3 → get c2, c1
+		after, err := repo.CommitLog("main", "", 10, c3, nil, nil)
+		require.NoError(t, err)
+		require.Len(t, after, 2)
+		require.Equal(t, c2, after[0].Hash)
+		require.Equal(t, c1, after[1].Hash)
+
+		// feature branch only has c1, c2
+		featureLog, err := repo.CommitLog("feature", "", 10, "", nil, nil)
+		require.NoError(t, err)
+		require.Len(t, featureLog, 2)
+		require.Equal(t, c2, featureLog[0].Hash)
+
+		// path filtering: only commits that touched the given path
+		// README.md was created in c1 and updated in c3
+		readmeLog, err := repo.CommitLog("main", "README.md", 10, "", nil, nil)
+		require.NoError(t, err)
+		require.Len(t, readmeLog, 2)
+		require.Equal(t, c3, readmeLog[0].Hash)
+		require.Equal(t, c1, readmeLog[1].Hash)
+	})
+
+	t.Run("CommitLog/since-until", func(t *testing.T) {
+		// since = far future → no commits
+		future := time.Now().Add(24 * time.Hour)
+		none, err := repo.CommitLog("main", "", 10, "", &future, nil)
+		require.NoError(t, err)
+		require.Empty(t, none, "since=future should return no commits")
+
+		// until = zero time (long before any real commit) → no commits
+		zero := time.Time{}
+		none2, err := repo.CommitLog("main", "", 10, "", nil, &zero)
+		require.NoError(t, err)
+		require.Empty(t, none2, "until=zero should return no commits")
+
+		// Both bounds open → all commits returned (filtering is a no-op)
+		all, err := repo.CommitLog("main", "", 10, "", nil, nil)
+		require.NoError(t, err)
+		require.Len(t, all, 3, "nil since/until should return all commits")
+
+		// since = far past and until = far future → all commits still returned
+		past := time.Unix(0, 0)
+		all2, err := repo.CommitLog("main", "", 10, "", &past, &future)
+		require.NoError(t, err)
+		require.Len(t, all2, 3, "wide since/until bounds should return all commits")
+	})
+
+	// ── LastCommitForEntries ──────────────────────────────────────────────────
+
+	t.Run("LastCommitForEntries", func(t *testing.T) {
+		result, err := repo.LastCommitForEntries("main", "", []string{"README.md", "main.go", "src"})
+		require.NoError(t, err)
+
+		// README.md was last changed in c3
+		require.Equal(t, c3, result["README.md"].Hash)
+		// main.go was last changed in c2
+		require.Equal(t, c2, result["main.go"].Hash)
+		// src tree changed in c2 (util.go added)
+		require.Equal(t, c2, result["src"].Hash)
+
+		// subdirectory: last commits for entries in src/
+		srcResult, err := repo.LastCommitForEntries("main", "src", []string{"lib.go", "util.go"})
+		require.NoError(t, err)
+		// lib.go was added in c1 and never changed
+		require.Equal(t, c1, srcResult["lib.go"].Hash)
+		// util.go was added in c2
+		require.Equal(t, c2, srcResult["util.go"].Hash)
+
+		// requesting a name that doesn't exist returns no entry for it
+		partial, err := repo.LastCommitForEntries("main", "", []string{"README.md", "ghost.txt"})
+		require.NoError(t, err)
+		require.Contains(t, partial, "README.md")
+		require.NotContains(t, partial, "ghost.txt")
+	})
+
+	t.Run("LastCommitForEntries/cache-subset", func(t *testing.T) {
+		// First call with one name — seeds (or hits) the cache for this directory.
+		r1, err := repo.LastCommitForEntries("main", "", []string{"README.md"})
+		require.NoError(t, err)
+		require.Contains(t, r1, "README.md")
+		require.Equal(t, c3, r1["README.md"].Hash)
+
+		// Second call for the same directory but a different name.
+		// A buggy implementation that caches only the requested subset would
+		// return an empty map here (cache hit, but "main.go" was never stored).
+		r2, err := repo.LastCommitForEntries("main", "", []string{"main.go"})
+		require.NoError(t, err)
+		require.Contains(t, r2, "main.go", "second call with different name should hit correct result, not empty cache")
+		require.Equal(t, c2, r2["main.go"].Hash)
+
+		// Third call requesting both names should also work.
+		r3, err := repo.LastCommitForEntries("main", "", []string{"README.md", "main.go"})
+		require.NoError(t, err)
+		require.Equal(t, c3, r3["README.md"].Hash)
+		require.Equal(t, c2, r3["main.go"].Hash)
+	})
+
+	// ── CommitDetail ──────────────────────────────────────────────────────────
+
+	t.Run("CommitDetail", func(t *testing.T) {
+		detail, err := repo.CommitDetail(c2)
+		require.NoError(t, err)
+		require.Equal(t, c2, detail.Hash)
+		require.Equal(t, []Hash{c1}, detail.Parents)
+
+		filesByPath := make(map[string]ChangedFile)
+		for _, f := range detail.Files {
+			filesByPath[f.Path] = f
+		}
+		require.Equal(t, ChangeStatusModified, filesByPath["main.go"].Status)
+		require.Equal(t, ChangeStatusAdded, filesByPath["src/util.go"].Status)
+
+		// initial commit: diffs against empty tree, everything is "added"
+		initDetail, err := repo.CommitDetail(c1)
+		require.NoError(t, err)
+		for _, f := range initDetail.Files {
+			require.Equal(t, ChangeStatusAdded, f.Status, "file %s", f.Path)
+		}
+
+		// unknown hash
+		_, err = repo.CommitDetail(randomHash())
+		require.ErrorIs(t, err, ErrNotFound)
+	})
+
+	// ── CommitFileDiff ────────────────────────────────────────────────────────
+
+	t.Run("CommitFileDiff", func(t *testing.T) {
+		fd, err := repo.CommitFileDiff(c2, "main.go")
+		require.NoError(t, err)
+		require.Equal(t, "main.go", fd.Path)
+		require.False(t, fd.IsBinary)
+		require.False(t, fd.IsNew)
+		require.False(t, fd.IsDelete)
+		require.NotEmpty(t, fd.Hunks)
+
+		// find the added lines
+		var addedContent []string
+		for _, h := range fd.Hunks {
+			for _, l := range h.Lines {
+				if l.Type == DiffLineAdded {
+					addedContent = append(addedContent, l.Content)
+				}
+			}
+		}
+		require.Contains(t, addedContent, "// updated")
+
+		// new file in initial commit
+		initFD, err := repo.CommitFileDiff(c1, "main.go")
+		require.NoError(t, err)
+		require.True(t, initFD.IsNew)
+		require.Equal(t, "main.go", initFD.Path)
+
+		// file not in this commit's diff
+		_, err = repo.CommitFileDiff(c3, "main.go")
+		require.ErrorIs(t, err, ErrNotFound)
+
+		// unknown hash
+		_, err = repo.CommitFileDiff(randomHash(), "main.go")
+		require.ErrorIs(t, err, ErrNotFound)
+	})
+}

repository/tree_entry.go 🔗

@@ -3,6 +3,8 @@ package repository
 import (
 	"bytes"
 	"fmt"
+	"io"
+	"strconv"
 	"strings"
 )
 
@@ -15,9 +17,12 @@ type TreeEntry struct {
 type ObjectType int
 
 const (
-	Unknown ObjectType = iota
-	Blob
-	Tree
+	Unknown    ObjectType = iota
+	Blob                  // regular file      (100644)
+	Tree                  // directory         (040000)
+	Executable            // executable file   (100755)
+	Symlink               // symbolic link     (120000)
+	Submodule             // git submodule     (160000)
 )
 
 func ParseTreeEntry(line string) (TreeEntry, error) {
@@ -54,17 +59,64 @@ func (ot ObjectType) Format() string {
 		return "100644 blob"
 	case Tree:
 		return "040000 tree"
+	case Executable:
+		return "100755 blob"
+	case Symlink:
+		return "120000 blob"
+	case Submodule:
+		return "160000 commit"
 	default:
 		panic("Unknown git object type")
 	}
 }
 
+func (ot ObjectType) MarshalGQL(w io.Writer) {
+	switch ot {
+	case Tree:
+		fmt.Fprint(w, strconv.Quote("TREE"))
+	case Blob, Executable:
+		fmt.Fprint(w, strconv.Quote("BLOB"))
+	case Symlink:
+		fmt.Fprint(w, strconv.Quote("SYMLINK"))
+	case Submodule:
+		fmt.Fprint(w, strconv.Quote("SUBMODULE"))
+	default:
+		panic(fmt.Sprintf("unknown ObjectType value %d", int(ot)))
+	}
+}
+
+func (ot *ObjectType) UnmarshalGQL(v any) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+	switch str {
+	case "TREE":
+		*ot = Tree
+	case "BLOB":
+		*ot = Blob
+	case "SYMLINK":
+		*ot = Symlink
+	case "SUBMODULE":
+		*ot = Submodule
+	default:
+		return fmt.Errorf("%q is not a valid ObjectType", str)
+	}
+	return nil
+}
+
 func ParseObjectType(mode, objType string) (ObjectType, error) {
 	switch {
 	case mode == "100644" && objType == "blob":
 		return Blob, nil
 	case mode == "040000" && objType == "tree":
 		return Tree, nil
+	case mode == "100755" && objType == "blob":
+		return Executable, nil
+	case mode == "120000" && objType == "blob":
+		return Symlink, nil
+	case mode == "160000" && objType == "commit":
+		return Submodule, nil
 	default:
 		return Unknown, fmt.Errorf("Unknown git object type %s %s", mode, objType)
 	}

webui2/src/__generated__/graphql.ts 🔗

@@ -541,6 +541,233 @@ export enum EntityEventType {
   Updated = 'UPDATED'
 }
 
+/** The content of a git blob (file). */
+export type GitBlob = {
+  __typename?: 'GitBlob';
+  /**
+   * Git object hash. Can be used as a stable cache key or to construct a
+   * raw download URL.
+   */
+  hash: Scalars['String']['output'];
+  /**
+   * True when the file contains null bytes and is treated as binary.
+   * text will be null.
+   */
+  isBinary: Scalars['Boolean']['output'];
+  /**
+   * True when the file exceeds the maximum inline size and text has been
+   * omitted. Use the raw download endpoint to retrieve the full content.
+   */
+  isTruncated: Scalars['Boolean']['output'];
+  /** Path of the file relative to the repository root. */
+  path: Scalars['String']['output'];
+  /** Size in bytes. */
+  size: Scalars['Int']['output'];
+  /**
+   * UTF-8 text content of the file. Null when isBinary is true or when
+   * the file is too large to be returned inline (see isTruncated).
+   */
+  text?: Maybe<Scalars['String']['output']>;
+};
+
+/** How a file was affected by a commit. */
+export enum GitChangeStatus {
+  /** File was created in this commit. */
+  Added = 'ADDED',
+  /** File was removed in this commit. */
+  Deleted = 'DELETED',
+  /** File content changed in this commit. */
+  Modified = 'MODIFIED',
+  /** File was moved or renamed in this commit. */
+  Renamed = 'RENAMED'
+}
+
+/** A file that was changed in a commit. */
+export type GitChangedFile = {
+  __typename?: 'GitChangedFile';
+  /** Previous path, non-null only for renames. */
+  oldPath?: Maybe<Scalars['String']['output']>;
+  /** Path of the file in the new version of the commit. */
+  path: Scalars['String']['output'];
+  /** How the file was affected by the commit. */
+  status: GitChangeStatus;
+};
+
+export type GitChangedFileConnection = {
+  __typename?: 'GitChangedFileConnection';
+  nodes: Array<GitChangedFile>;
+  pageInfo: PageInfo;
+  totalCount: Scalars['Int']['output'];
+};
+
+/** Metadata for a single git commit. */
+export type GitCommit = {
+  __typename?: 'GitCommit';
+  /** Email address of the commit author. */
+  authorEmail: Scalars['String']['output'];
+  /** Name of the commit author. */
+  authorName: Scalars['String']['output'];
+  /** Timestamp from the author field (when the change was originally made). */
+  date: Scalars['Time']['output'];
+  /** Unified diff for a single file in this commit. */
+  diff?: Maybe<GitFileDiff>;
+  /**
+   * Files changed relative to the first parent (or the empty tree for the
+   * initial commit).
+   */
+  files: GitChangedFileConnection;
+  /** Full commit message. */
+  fullMessage: Scalars['String']['output'];
+  /** Full SHA-1 commit hash. */
+  hash: Scalars['String']['output'];
+  /** First line of the commit message. */
+  message: Scalars['String']['output'];
+  /** Hashes of parent commits. Empty for the initial commit. */
+  parents: Array<Scalars['String']['output']>;
+  /** Abbreviated commit hash, typically 8 characters. */
+  shortHash: Scalars['String']['output'];
+};
+
+
+/** Metadata for a single git commit. */
+export type GitCommitDiffArgs = {
+  path: Scalars['String']['input'];
+};
+
+
+/** Metadata for a single git commit. */
+export type GitCommitFilesArgs = {
+  after?: InputMaybe<Scalars['String']['input']>;
+  before?: InputMaybe<Scalars['String']['input']>;
+  first?: InputMaybe<Scalars['Int']['input']>;
+  last?: InputMaybe<Scalars['Int']['input']>;
+};
+
+/** Paginated list of commits. */
+export type GitCommitConnection = {
+  __typename?: 'GitCommitConnection';
+  nodes: Array<GitCommit>;
+  pageInfo: PageInfo;
+  totalCount: Scalars['Int']['output'];
+};
+
+/** A contiguous block of changes in a unified diff. */
+export type GitDiffHunk = {
+  __typename?: 'GitDiffHunk';
+  /** Lines in this hunk, including context, additions, and deletions. */
+  lines: Array<GitDiffLine>;
+  /** Number of lines from the new file included in this hunk. */
+  newLines: Scalars['Int']['output'];
+  /** Starting line number in the new file. */
+  newStart: Scalars['Int']['output'];
+  /** Number of lines from the old file included in this hunk. */
+  oldLines: Scalars['Int']['output'];
+  /** Starting line number in the old file. */
+  oldStart: Scalars['Int']['output'];
+};
+
+/** A single line in a unified diff hunk. */
+export type GitDiffLine = {
+  __typename?: 'GitDiffLine';
+  /** Raw line content, without the leading +/- prefix. */
+  content: Scalars['String']['output'];
+  /** Line number in the new file. 0 for deleted lines. */
+  newLine: Scalars['Int']['output'];
+  /** Line number in the old file. 0 for added lines. */
+  oldLine: Scalars['Int']['output'];
+  /** Whether this line is context, an addition, or a deletion. */
+  type: GitDiffLineType;
+};
+
+/** The role of a line within a unified diff hunk. */
+export enum GitDiffLineType {
+  /** A line added in the new version. */
+  Added = 'ADDED',
+  /** An unchanged line present in both old and new versions. */
+  Context = 'CONTEXT',
+  /** A line removed from the old version. */
+  Deleted = 'DELETED'
+}
+
+/** The diff for a single file in a commit. */
+export type GitFileDiff = {
+  __typename?: 'GitFileDiff';
+  /** Contiguous blocks of changes. Empty for binary files. */
+  hunks: Array<GitDiffHunk>;
+  /** True when the file is binary and no textual diff is available. */
+  isBinary: Scalars['Boolean']['output'];
+  /** True when the file was deleted in this commit. */
+  isDelete: Scalars['Boolean']['output'];
+  /** True when the file was created in this commit. */
+  isNew: Scalars['Boolean']['output'];
+  /** Previous path, non-null only for renames. */
+  oldPath?: Maybe<Scalars['String']['output']>;
+  /** Path of the file in the new version. */
+  path: Scalars['String']['output'];
+};
+
+/** The last commit that touched each requested entry in a directory. */
+export type GitLastCommit = {
+  __typename?: 'GitLastCommit';
+  /** Most recent commit that modified this entry. */
+  commit: GitCommit;
+  /** Entry name within the directory. */
+  name: Scalars['String']['output'];
+};
+
+/** The type of object a git tree entry points to. */
+export enum GitObjectType {
+  /** A regular or executable file. */
+  Blob = 'BLOB',
+  /** A git submodule. */
+  Submodule = 'SUBMODULE',
+  /** A symbolic link. */
+  Symlink = 'SYMLINK',
+  /** A directory. */
+  Tree = 'TREE'
+}
+
+/** A git branch or tag reference. */
+export type GitRef = {
+  __typename?: 'GitRef';
+  /** Commit hash the reference points to. */
+  hash: Scalars['String']['output'];
+  /** True for the branch HEAD currently points to. */
+  isDefault: Scalars['Boolean']['output'];
+  /** Full reference name, e.g. refs/heads/main or refs/tags/v1.0. */
+  name: Scalars['String']['output'];
+  /** Short name, e.g. main or v1.0. */
+  shortName: Scalars['String']['output'];
+  /** Whether this reference is a branch or a tag. */
+  type: GitRefType;
+};
+
+export type GitRefConnection = {
+  __typename?: 'GitRefConnection';
+  nodes: Array<GitRef>;
+  pageInfo: PageInfo;
+  totalCount: Scalars['Int']['output'];
+};
+
+/** The kind of git reference: a branch or a tag. */
+export enum GitRefType {
+  /** A local branch (refs/heads/*). */
+  Branch = 'BRANCH',
+  /** An annotated or lightweight tag (refs/tags/*). */
+  Tag = 'TAG'
+}
+
+/** An entry in a git tree (directory listing). */
+export type GitTreeEntry = {
+  __typename?: 'GitTreeEntry';
+  /** Git object hash. */
+  hash: Scalars['String']['output'];
+  /** File or directory name within the parent tree. */
+  name: Scalars['String']['output'];
+  /** Whether this entry is a file, directory, symlink, or submodule. */
+  type: GitObjectType;
+};
+
 /** Represents an identity */
 export type Identity = Entity & {
   __typename?: 'Identity';
@@ -734,7 +961,10 @@ export type Query = {
   __typename?: 'Query';
   /** List all registered repositories. */
   repositories: RepositoryConnection;
-  /** Access a repository by reference/name. If no ref is given, the default repository is returned if any. */
+  /**
+   * Access a repository by reference/name. If no ref is given, the default repository is returned if any.
+   * Returns null if the referenced repository does not exist.
+   */
   repository?: Maybe<Repository>;
   /** Server configuration and authentication mode. */
   serverConfig: ServerConfig;
@@ -759,10 +989,34 @@ export type Repository = {
   allBugs: BugConnection;
   /** All the identities */
   allIdentities: IdentityConnection;
+  /**
+   * Content of the file at path under ref. Null if the path does not exist
+   * or resolves to a tree rather than a blob.
+   */
+  blob?: Maybe<GitBlob>;
+  /** Look up a bug by id prefix. Returns null if no bug matches the prefix. */
   bug?: Maybe<Bug>;
+  /** A single commit by hash. Returns null if the hash does not exist in the repository. */
+  commit?: Maybe<GitCommit>;
+  /**
+   * Paginated commit log reachable from ref, optionally filtered to commits
+   * touching path.
+   */
+  commits: GitCommitConnection;
+  /** Look up an identity by id prefix. Returns null if no identity matches the prefix. */
   identity?: Maybe<Identity>;
-  /** The name of the repository. Null for the default (unnamed) repository. */
+  /**
+   * The most recent commit that touched each of the named entries in the
+   * directory at path under ref. Use this to populate last-commit info on a
+   * tree listing without blocking the initial tree fetch.
+   */
+  lastCommits: Array<GitLastCommit>;
+  /** The name of the repository. Null for the default (unnamed) repository in a single-repo setup. */
   name?: Maybe<Scalars['String']['output']>;
+  /** All branches and tags, optionally filtered by type. */
+  refs: GitRefConnection;
+  /** Directory listing at path under ref. An empty path returns the root tree. */
+  tree: Array<GitTreeEntry>;
   /** The identity created or selected by the user as its own */
   userIdentity?: Maybe<Identity>;
   /** List of valid labels. */
@@ -787,16 +1041,59 @@ export type RepositoryAllIdentitiesArgs = {
 };
 
 
+export type RepositoryBlobArgs = {
+  path: Scalars['String']['input'];
+  ref: Scalars['String']['input'];
+};
+
+
 export type RepositoryBugArgs = {
   prefix: Scalars['String']['input'];
 };
 
 
+export type RepositoryCommitArgs = {
+  hash: Scalars['String']['input'];
+};
+
+
+export type RepositoryCommitsArgs = {
+  after?: InputMaybe<Scalars['String']['input']>;
+  first?: InputMaybe<Scalars['Int']['input']>;
+  path?: InputMaybe<Scalars['String']['input']>;
+  ref: Scalars['String']['input'];
+  since?: InputMaybe<Scalars['Time']['input']>;
+  until?: InputMaybe<Scalars['Time']['input']>;
+};
+
+
 export type RepositoryIdentityArgs = {
   prefix: Scalars['String']['input'];
 };
 
 
+export type RepositoryLastCommitsArgs = {
+  names: Array<Scalars['String']['input']>;
+  path?: InputMaybe<Scalars['String']['input']>;
+  ref: Scalars['String']['input'];
+};
+
+
+export type RepositoryRefsArgs = {
+  after?: InputMaybe<Scalars['String']['input']>;
+  before?: InputMaybe<Scalars['String']['input']>;
+  first?: InputMaybe<Scalars['Int']['input']>;
+  last?: InputMaybe<Scalars['Int']['input']>;
+  type?: InputMaybe<GitRefType>;
+};
+
+
+export type RepositoryTreeArgs = {
+  path?: InputMaybe<Scalars['String']['input']>;
+  ref: Scalars['String']['input'];
+};
+
+
 export type RepositoryValidLabelsArgs = {
   after?: InputMaybe<Scalars['String']['input']>;
   before?: InputMaybe<Scalars['String']['input']>;

webui2/src/components/code/CommitList.tsx 🔗

@@ -1,55 +1,78 @@
-import { useState, useEffect } from 'react'
+// Paginated commit history grouped by calendar date. Each row links to the
+// commit detail page. Used in CodePage's "History" view.
+
+import { useState } from 'react'
 import { Link } from 'react-router-dom'
 import { formatDistanceToNow } from 'date-fns'
 import { GitCommit } from 'lucide-react'
+import { gql, useQuery } from '@apollo/client'
 import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
-import { getCommits } from '@/lib/gitApi'
-import type { GitCommit as GitCommitType } from '@/lib/gitApi'
 import { useRepo } from '@/lib/repo'
 
+const COMMITS_QUERY = gql`
+  query CommitList($repo: String, $ref: String!, $path: String, $after: String, $first: Int) {
+    repository(ref: $repo) {
+      commits(ref: $ref, path: $path, after: $after, first: $first) {
+        nodes {
+          hash
+          shortHash
+          message
+          authorName
+          date
+        }
+        pageInfo {
+          hasNextPage
+          endCursor
+        }
+      }
+    }
+  }
+`
+
+const PAGE_SIZE = 30
+
 interface CommitListProps {
   ref_: string
   path?: string
 }
 
-const PAGE_SIZE = 30
+type CommitNode = {
+  hash: string
+  shortHash: string
+  message: string
+  authorName: string
+  date: string
+}
 
-// Paginated commit history grouped by calendar date. Each row links to the
-// commit detail page. Used in CodePage's "History" view.
 export function CommitList({ ref_, path }: CommitListProps) {
   const repo = useRepo()
-  const [commits, setCommits] = useState<GitCommitType[]>([])
-  const [loading, setLoading] = useState(true)
+  const [cursor, setCursor] = useState<string | null>(null)
+  const [allCommits, setAllCommits] = useState<CommitNode[]>([])
+
+  const { loading, error, fetchMore } = useQuery(COMMITS_QUERY, {
+    variables: { repo, ref: ref_, path: path ?? null, after: null, first: PAGE_SIZE },
+    skip: !ref_,
+    onCompleted(data) {
+      const nodes = data?.repository?.commits?.nodes ?? []
+      setAllCommits(nodes)
+      setCursor(data?.repository?.commits?.pageInfo?.endCursor ?? null)
+    },
+  })
+
+  const hasMore = !!cursor && allCommits.length > 0 && allCommits.length % PAGE_SIZE === 0
   const [loadingMore, setLoadingMore] = useState(false)
-  const [error, setError] = useState<string | null>(null)
-  const [hasMore, setHasMore] = useState(true)
-
-  useEffect(() => {
-    setCommits([])
-    setHasMore(true)
-    setError(null)
-    setLoading(true)
-    getCommits(ref_, { path, limit: PAGE_SIZE })
-      .then((data) => {
-        setCommits(data)
-        setHasMore(data.length === PAGE_SIZE)
-      })
-      .catch((e: Error) => setError(e.message))
-      .finally(() => setLoading(false))
-  }, [ref_, path])
 
   function loadMore() {
-    const last = commits[commits.length - 1]
-    if (!last) return
+    if (!cursor) return
     setLoadingMore(true)
-    getCommits(ref_, { path, limit: PAGE_SIZE, after: last.hash })
-      .then((data) => {
-        setCommits((prev) => [...prev, ...data])
-        setHasMore(data.length === PAGE_SIZE)
-      })
-      .catch((e: Error) => setError(e.message))
-      .finally(() => setLoadingMore(false))
+    fetchMore({
+      variables: { after: cursor },
+    }).then((result) => {
+      const newNodes = result.data?.repository?.commits?.nodes ?? []
+      setAllCommits((prev) => [...prev, ...newNodes])
+      setCursor(result.data?.repository?.commits?.pageInfo?.endCursor ?? null)
+    }).finally(() => setLoadingMore(false))
   }
 
   if (loading) return <CommitListSkeleton />
@@ -57,13 +80,12 @@ export function CommitList({ ref_, path }: CommitListProps) {
   if (error) {
     return (
       <div className="rounded-md border border-border px-4 py-8 text-center text-sm text-destructive">
-        {error}
+        {error.message}
       </div>
     )
   }
 
-  // Group commits by date (YYYY-MM-DD)
-  const groups = groupByDate(commits)
+  const groups = groupByDate(allCommits)
 
   return (
     <div className="space-y-6">
@@ -91,12 +113,11 @@ export function CommitList({ ref_, path }: CommitListProps) {
   )
 }
 
-function CommitRow({ commit, repo }: { commit: GitCommitType; repo: string | null }) {
+function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }) {
   const commitPath = repo ? `/${repo}/commit/${commit.hash}` : `/commit/${commit.hash}`
   return (
     <div className="flex items-center gap-3 bg-background px-4 py-3 hover:bg-muted/30">
       <GitCommit className="size-4 shrink-0 text-muted-foreground" />
-
       <div className="min-w-0 flex-1">
         <Link
           to={commitPath}
@@ -109,7 +130,6 @@ function CommitRow({ commit, repo }: { commit: GitCommitType; repo: string | nul
           {formatDistanceToNow(new Date(commit.date), { addSuffix: true })}
         </p>
       </div>
-
       <Link
         to={commitPath}
         className="shrink-0 font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
@@ -121,8 +141,8 @@ function CommitRow({ commit, repo }: { commit: GitCommitType; repo: string | nul
   )
 }
 
-function groupByDate(commits: GitCommitType[]): [string, GitCommitType[]][] {
-  const map = new Map<string, GitCommitType[]>()
+function groupByDate(commits: CommitNode[]): [string, CommitNode[]][] {
+  const map = new Map<string, CommitNode[]>()
   for (const c of commits) {
     const date = new Date(c.date).toLocaleDateString('en-US', {
       year: 'numeric',

webui2/src/components/code/FileDiffView.tsx 🔗

@@ -1,48 +1,71 @@
 // Collapsible diff view for a single file in a commit.
-// Diff is fetched lazily on first expand to avoid loading large diffs upfront.
+// Diff is fetched lazily on first expand via GraphQL.
 
 import { useState } from 'react'
 import { ChevronRight, FilePlus, FileMinus, FileEdit } from 'lucide-react'
+import { gql, useLazyQuery } from '@apollo/client'
 import { cn } from '@/lib/utils'
-import { getCommitDiff } from '@/lib/gitApi'
-import type { FileDiff, DiffHunk } from '@/lib/gitApi'
+import { useRepo } from '@/lib/repo'
+
+const DIFF_QUERY = gql`
+  query FileDiff($repo: String, $hash: String!, $path: String!) {
+    repository(ref: $repo) {
+      commit(hash: $hash) {
+        diff(path: $path) {
+          path
+          oldPath
+          isBinary
+          isNew
+          isDelete
+          hunks {
+            oldStart
+            oldLines
+            newStart
+            newLines
+            lines {
+              type
+              content
+              oldLine
+              newLine
+            }
+          }
+        }
+      }
+    }
+  }
+`
 
 interface FileDiffViewProps {
-  sha: string
+  hash: string
   path: string
   oldPath?: string
-  status: 'added' | 'modified' | 'deleted' | 'renamed'
+  status: string
 }
 
-const statusIcon = {
-  added:    <FilePlus  className="size-3.5 text-green-600 dark:text-green-400" />,
-  deleted:  <FileMinus className="size-3.5 text-red-500  dark:text-red-400" />,
-  modified: <FileEdit  className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
-  renamed:  <FileEdit  className="size-3.5 text-blue-500  dark:text-blue-400" />,
+const statusIcon: Record<string, React.ReactNode> = {
+  ADDED:    <FilePlus  className="size-3.5 text-green-600 dark:text-green-400" />,
+  DELETED:  <FileMinus className="size-3.5 text-red-500  dark:text-red-400" />,
+  MODIFIED: <FileEdit  className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
+  RENAMED:  <FileEdit  className="size-3.5 text-blue-500  dark:text-blue-400" />,
 }
+const statusBadge: Record<string, string> = { ADDED: 'A', DELETED: 'D', MODIFIED: 'M', RENAMED: 'R' }
 
-const statusBadge = { added: 'A', deleted: 'D', modified: 'M', renamed: 'R' }
-
-export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps) {
+export function FileDiffView({ hash, path, oldPath, status }: FileDiffViewProps) {
+  const repo = useRepo()
   const [open, setOpen] = useState(false)
-  const [diff, setDiff] = useState<FileDiff | null>(null)
-  const [loading, setLoading] = useState(false)
-  const [error, setError] = useState<string | null>(null)
+  const [fetchDiff, { data, loading, error }] = useLazyQuery(DIFF_QUERY)
 
   function toggle() {
-    if (!open && diff === null && !loading) {
-      setLoading(true)
-      getCommitDiff(sha, path)
-        .then(setDiff)
-        .catch((e: Error) => setError(e.message))
-        .finally(() => setLoading(false))
+    if (!open && !data && !loading) {
+      fetchDiff({ variables: { repo, hash, path } })
     }
     setOpen((v) => !v)
   }
 
+  const diff = data?.repository?.commit?.diff
+
   return (
     <div className="divide-y divide-border">
-      {/* File header row — always visible, click to toggle */}
       <button
         onClick={toggle}
         className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
@@ -53,9 +76,9 @@ export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps)
             open && 'rotate-90',
           )}
         />
-        {statusIcon[status]}
+        {statusIcon[status] ?? <FileEdit className="size-3.5 text-muted-foreground" />}
         <span className="min-w-0 flex-1 font-mono text-sm">
-          {status === 'renamed' ? (
+          {status === 'RENAMED' ? (
             <>
               <span className="text-muted-foreground line-through">{oldPath}</span>
               {' → '}
@@ -64,18 +87,17 @@ export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps)
           ) : path}
         </span>
         <span className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
-          {statusBadge[status]}
+          {statusBadge[status] ?? '?'}
         </span>
       </button>
 
-      {/* Diff body */}
       {open && (
         <div className="overflow-x-auto">
           {loading && (
             <div className="px-4 py-3 text-xs text-muted-foreground">Loading diff…</div>
           )}
           {error && (
-            <div className="px-4 py-3 text-xs text-destructive">Failed to load diff: {error}</div>
+            <div className="px-4 py-3 text-xs text-destructive">Failed to load diff: {error.message}</div>
           )}
           {diff && (
             diff.isBinary ? (
@@ -83,7 +105,7 @@ export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps)
             ) : diff.hunks.length === 0 ? (
               <div className="px-4 py-3 text-xs text-muted-foreground">No changes</div>
             ) : (
-              diff.hunks.map((hunk, i) => <Hunk key={i} hunk={hunk} />)
+              diff.hunks.map((hunk: HunkType, i: number) => <Hunk key={i} hunk={hunk} />)
             )
           )}
         </div>
@@ -92,10 +114,12 @@ export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps)
   )
 }
 
-function Hunk({ hunk }: { hunk: DiffHunk }) {
+type LineType = { type: string; content: string; oldLine: number; newLine: number }
+type HunkType = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: LineType[] }
+
+function Hunk({ hunk }: { hunk: HunkType }) {
   return (
     <div className="font-mono text-xs leading-5">
-      {/* Hunk header */}
       <div className="bg-blue-50 px-4 py-0.5 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400 select-none">
         @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
       </div>
@@ -104,32 +128,28 @@ function Hunk({ hunk }: { hunk: DiffHunk }) {
           key={i}
           className={cn(
             'flex',
-            line.type === 'added'   && 'bg-green-50  dark:bg-green-950/30',
-            line.type === 'deleted' && 'bg-red-50    dark:bg-red-950/30',
+            line.type === 'ADDED'   && 'bg-green-50  dark:bg-green-950/30',
+            line.type === 'DELETED' && 'bg-red-50    dark:bg-red-950/30',
           )}
         >
-          {/* Old line number */}
           <span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
             {line.oldLine || ''}
           </span>
-          {/* New line number */}
           <span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
             {line.newLine || ''}
           </span>
-          {/* Sign */}
           <span className={cn(
             'w-5 shrink-0 select-none text-center',
-            line.type === 'added'   && 'text-green-600 dark:text-green-400',
-            line.type === 'deleted' && 'text-red-500   dark:text-red-400',
-            line.type === 'context' && 'text-muted-foreground/40',
+            line.type === 'ADDED'   && 'text-green-600 dark:text-green-400',
+            line.type === 'DELETED' && 'text-red-500   dark:text-red-400',
+            line.type === 'CONTEXT' && 'text-muted-foreground/40',
           )}>
-            {line.type === 'added' ? '+' : line.type === 'deleted' ? '-' : ' '}
+            {line.type === 'ADDED' ? '+' : line.type === 'DELETED' ? '-' : ' '}
           </span>
-          {/* Content */}
           <pre className={cn(
             'flex-1 overflow-visible whitespace-pre px-2',
-            line.type === 'added'   && 'text-green-900 dark:text-green-200',
-            line.type === 'deleted' && 'text-red-900   dark:text-red-200',
+            line.type === 'ADDED'   && 'text-green-900 dark:text-green-200',
+            line.type === 'DELETED' && 'text-red-900   dark:text-red-200',
           )}>
             {line.content}
           </pre>

webui2/src/components/code/FileTree.tsx 🔗

@@ -3,13 +3,22 @@ import { Link } from 'react-router-dom'
 import { formatDistanceToNow } from 'date-fns'
 import { Skeleton } from '@/components/ui/skeleton'
 import { useRepo } from '@/lib/repo'
-import type { GitTreeEntry } from '@/lib/gitApi'
+import type { GitTreeEntry } from '@/__generated__/graphql'
+
+export interface TreeEntryWithCommit extends GitTreeEntry {
+  lastCommit?: {
+    hash: string
+    shortHash: string
+    message: string
+    date: string
+  }
+}
 
 interface FileTreeProps {
-  entries: GitTreeEntry[]
+  entries: TreeEntryWithCommit[]
   path: string
   loading?: boolean
-  onNavigate: (entry: GitTreeEntry) => void
+  onNavigate: (entry: TreeEntryWithCommit) => void
   onNavigateUp: () => void
 }
 
@@ -18,7 +27,7 @@ interface FileTreeProps {
 export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: FileTreeProps) {
   // Directories first, then files — each group alphabetical
   const sorted = [...entries].sort((a, b) => {
-    if (a.type !== b.type) return a.type === 'tree' ? -1 : 1
+    if (a.type !== b.type) return a.type === 'TREE' ? -1 : 1
     return a.name.localeCompare(b.name)
   })
 
@@ -54,10 +63,10 @@ function FileTreeRow({
   entry,
   onNavigate,
 }: {
-  entry: GitTreeEntry
-  onNavigate: (entry: GitTreeEntry) => void
+  entry: TreeEntryWithCommit
+  onNavigate: (entry: TreeEntryWithCommit) => void
 }) {
-  const isDir = entry.type === 'tree'
+  const isDir = entry.type === 'TREE'
   const repo = useRepo()
 
   return (

webui2/src/components/code/FileViewer.tsx 🔗

@@ -1,23 +1,22 @@
+// Syntax-highlighted file viewer with line numbers and copy button.
+// highlight.js is loaded lazily so it doesn't bloat the initial bundle.
+
 import { useState, useEffect } from 'react'
-import { Copy, Download } from 'lucide-react'
+import { Copy } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
-import { getRawUrl } from '@/lib/gitApi'
-import type { GitBlob } from '@/lib/gitApi'
+import type { GitBlob } from '@/__generated__/graphql'
 
 interface FileViewerProps {
   blob: GitBlob
-  ref: string
   loading?: boolean
 }
 
-// Syntax-highlighted file viewer with line numbers, copy, and download buttons.
-// highlight.js is loaded lazily (dynamic import) so it doesn't bloat the initial bundle.
-export function FileViewer({ blob, ref, loading }: FileViewerProps) {
+export function FileViewer({ blob, loading }: FileViewerProps) {
   const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null)
 
   useEffect(() => {
-    if (blob.isBinary || !blob.content) {
+    if (blob.isBinary || !blob.text) {
       setHighlighted({ html: '', lineCount: 0 })
       return
     }
@@ -27,11 +26,11 @@ export function FileViewer({ blob, ref, loading }: FileViewerProps) {
       if (cancelled) return
       const ext = blob.path.split('.').pop() ?? ''
       const result = hljs.getLanguage(ext)
-        ? hljs.highlight(blob.content, { language: ext })
-        : hljs.highlightAuto(blob.content)
+        ? hljs.highlight(blob.text!, { language: ext })
+        : hljs.highlightAuto(blob.text!)
       setHighlighted({
         html: result.value,
-        lineCount: blob.content.split('\n').length,
+        lineCount: blob.text!.split('\n').length,
       })
     })
     return () => { cancelled = true }
@@ -41,32 +40,19 @@ export function FileViewer({ blob, ref, loading }: FileViewerProps) {
   const { html, lineCount } = highlighted
 
   function copyToClipboard() {
-    navigator.clipboard.writeText(blob.content)
+    if (blob.text) navigator.clipboard.writeText(blob.text)
   }
 
   return (
     <div className="overflow-hidden rounded-md border border-border">
-      {/* Metadata bar */}
       <div className="flex items-center justify-between border-b border-border bg-muted/40 px-4 py-2 text-xs text-muted-foreground">
         <span>
           {lineCount.toLocaleString()} lines · {formatBytes(blob.size)}
+          {blob.isTruncated && ' · truncated'}
         </span>
-        <div className="flex items-center gap-1">
-          <Button
-            variant="ghost"
-            size="icon"
-            className="size-7"
-            onClick={copyToClipboard}
-            title="Copy"
-          >
-            <Copy className="size-3.5" />
-          </Button>
-          <Button variant="ghost" size="icon" className="size-7" asChild title="Download">
-            <a href={getRawUrl(ref, blob.path)} download>
-              <Download className="size-3.5" />
-            </a>
-          </Button>
-        </div>
+        <Button variant="ghost" size="icon" className="size-7" onClick={copyToClipboard} title="Copy">
+          <Copy className="size-3.5" />
+        </Button>
       </div>
 
       {blob.isBinary ? (
@@ -74,8 +60,6 @@ export function FileViewer({ blob, ref, loading }: FileViewerProps) {
           Binary file — {formatBytes(blob.size)}
         </div>
       ) : (
-        // Line numbers are a fixed column; code scrolls horizontally independently.
-        // Keeping them in separate divs avoids having to split highlighted HTML by line.
         <div className="flex overflow-x-auto font-mono text-xs leading-5">
           <div
             className="select-none border-r border-border bg-muted/20 px-4 py-4 text-right text-muted-foreground/50"
@@ -86,10 +70,7 @@ export function FileViewer({ blob, ref, loading }: FileViewerProps) {
             ))}
           </div>
           <pre className="flex-1 overflow-visible px-4 py-4">
-            <code
-              className="hljs"
-              dangerouslySetInnerHTML={{ __html: html }}
-            />
+            <code className="hljs" dangerouslySetInnerHTML={{ __html: html }} />
           </pre>
         </div>
       )}

webui2/src/components/code/RefSelector.tsx 🔗

@@ -3,7 +3,7 @@ import { GitBranch, Tag, Check, ChevronsUpDown } from 'lucide-react'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
-import type { GitRef } from '@/lib/gitApi'
+import type { GitRef } from '@/__generated__/graphql'
 import { cn } from '@/lib/utils'
 
 interface RefSelectorProps {
@@ -21,8 +21,8 @@ export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) {
   const filtered = refs.filter((r) =>
     r.shortName.toLowerCase().includes(filter.toLowerCase()),
   )
-  const branches = filtered.filter((r) => r.type === 'branch')
-  const tags = filtered.filter((r) => r.type === 'tag')
+  const branches = filtered.filter((r) => r.type === 'BRANCH')
+  const tags = filtered.filter((r) => r.type === 'TAG')
 
   return (
     <Popover open={open} onOpenChange={setOpen}>
@@ -95,7 +95,7 @@ function RefItem({
         active && 'font-medium',
       )}
     >
-      {ref_.type === 'branch' ? (
+      {ref_.type === 'BRANCH' ? (
         <GitBranch className="size-3 shrink-0 text-muted-foreground" />
       ) : (
         <Tag className="size-3 shrink-0 text-muted-foreground" />

webui2/src/lib/gitApi.ts 🔗

@@ -1,127 +0,0 @@
-// REST API client for git repository browsing.
-// Endpoints are served by the Go backend under /api/repos/{owner}/{repo}/git/*.
-// "_" is the wildcard value for both owner and repo (resolves to local / default).
-
-const BASE = '/api/repos/_/_'
-
-export interface GitRef {
-  name: string       // full ref: "refs/heads/main"
-  shortName: string  // "main"
-  type: 'branch' | 'tag'
-  hash: string
-  isDefault: boolean
-}
-
-export interface GitTreeEntry {
-  name: string
-  type: 'tree' | 'blob'
-  hash: string
-  mode: string
-  // Last commit touching this entry (may be absent if expensive to compute)
-  lastCommit?: {
-    hash: string
-    shortHash: string
-    message: string
-    authorName: string
-    date: string
-  }
-}
-
-export interface GitBlob {
-  path: string
-  content: string   // UTF-8 text; empty string when isBinary is true
-  size: number
-  isBinary: boolean
-}
-
-export interface GitCommit {
-  hash: string
-  shortHash: string
-  message: string
-  authorName: string
-  authorEmail: string
-  date: string
-  parents: string[]
-}
-
-export interface GitCommitDetail extends GitCommit {
-  fullMessage: string
-  files: Array<{
-    path: string
-    oldPath?: string
-    status: 'added' | 'modified' | 'deleted' | 'renamed'
-  }>
-}
-
-export interface DiffLine {
-  type: 'context' | 'added' | 'deleted'
-  content: string
-  oldLine: number
-  newLine: number
-}
-
-export interface DiffHunk {
-  oldStart: number
-  oldLines: number
-  newStart: number
-  newLines: number
-  lines: DiffLine[]
-}
-
-export interface FileDiff {
-  path: string
-  oldPath?: string
-  isBinary: boolean
-  isNew: boolean
-  isDelete: boolean
-  hunks: DiffHunk[]
-}
-
-// ── Fetch helpers ─────────────────────────────────────────────────────────────
-
-async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
-  const search = new URLSearchParams(params).toString()
-  const url = `${BASE}${path}${search ? `?${search}` : ''}`
-  const res = await fetch(url, { credentials: 'include' })
-  if (!res.ok) {
-    const text = await res.text().catch(() => res.statusText)
-    throw new Error(text || res.statusText)
-  }
-  return res.json()
-}
-
-// ── API calls ─────────────────────────────────────────────────────────────────
-
-export function getRefs(): Promise<GitRef[]> {
-  return get('/git/refs')
-}
-
-export function getTree(ref: string, path: string): Promise<GitTreeEntry[]> {
-  return get(`/git/trees/${encodeURIComponent(ref)}`, path ? { path } : {})
-}
-
-export function getBlob(ref: string, path: string): Promise<GitBlob> {
-  return get(`/git/blobs/${encodeURIComponent(ref)}`, { path })
-}
-
-export function getRawUrl(ref: string, path: string): string {
-  return `${BASE}/git/raw/${encodeURIComponent(ref)}/${path}`
-}
-
-export function getCommits(
-  ref: string,
-  opts: { path?: string; limit?: number; after?: string } = {},
-): Promise<GitCommit[]> {
-  const params: Record<string, string> = { ref, limit: String(opts.limit ?? 20) }
-  if (opts.path) params.path = opts.path
-  if (opts.after) params.after = opts.after
-  return get('/git/commits', params)
-}
-
-export function getCommit(sha: string): Promise<GitCommitDetail> {
-  return get(`/git/commits/${sha}`)
-}
-
-export function getCommitDiff(sha: string, path: string): Promise<FileDiff> {
-  return get(`/git/commits/${sha}/diff`, { path })
-}

webui2/src/pages/CodePage.tsx 🔗

@@ -1,7 +1,10 @@
-import { useState, useEffect } from 'react'
+// Code browser page. Switches between tree view, file viewer, and commit
+// history via ?type= search param. Ref is selected via ?ref=.
+
+import { useEffect } from 'react'
 import { useSearchParams } from 'react-router-dom'
 import { gql, useQuery } from '@apollo/client'
-import { AlertCircle, Check, Copy, GitCommit } from 'lucide-react'
+import { AlertCircle, GitCommit } from 'lucide-react'
 import { CodeBreadcrumb } from '@/components/code/CodeBreadcrumb'
 import { RefSelector } from '@/components/code/RefSelector'
 import { FileTree } from '@/components/code/FileTree'
@@ -9,103 +12,147 @@ import { FileViewer } from '@/components/code/FileViewer'
 import { CommitList } from '@/components/code/CommitList'
 import { Skeleton } from '@/components/ui/skeleton'
 import { Button } from '@/components/ui/button'
-import { getRefs, getTree, getBlob } from '@/lib/gitApi'
-import type { GitRef, GitTreeEntry, GitBlob } from '@/lib/gitApi'
 import { useRepo } from '@/lib/repo'
 import { Markdown } from '@/components/content/Markdown'
+import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from '@/__generated__/graphql'
+import type { TreeEntryWithCommit } from '@/components/code/FileTree'
 
-const REPO_NAME_QUERY = gql`
-  query RepoName($ref: String) {
-    repository(ref: $ref) {
+const REFS_QUERY = gql`
+  query CodePageRefs($repo: String) {
+    repository(ref: $repo) {
       name
+      refs {
+        nodes {
+          name
+          shortName
+          type
+          hash
+          isDefault
+        }
+      }
+    }
+  }
+`
+
+const TREE_QUERY = gql`
+  query CodePageTree($repo: String, $ref: String!, $path: String) {
+    repository(ref: $repo) {
+      tree(ref: $ref, path: $path) {
+        name
+        type
+        hash
+      }
+    }
+  }
+`
+
+const LAST_COMMITS_QUERY = gql`
+  query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
+    repository(ref: $repo) {
+      lastCommits(ref: $ref, path: $path, names: $names) {
+        name
+        commit {
+          hash
+          shortHash
+          message
+          date
+        }
+      }
+    }
+  }
+`
+
+const BLOB_QUERY = gql`
+  query CodePageBlob($repo: String, $ref: String!, $path: String!) {
+    repository(ref: $repo) {
+      blob(ref: $ref, path: $path) {
+        path
+        hash
+        text
+        size
+        isBinary
+        isTruncated
+      }
     }
   }
 `
 
 type ViewMode = 'tree' | 'blob' | 'commits'
 
-// Code browser page (/:repo). Switches between tree view, file viewer, and
-// commit history via the ?type= search param. Ref is selected via ?ref=.
 export function CodePage() {
   const repo = useRepo()
   const [searchParams, setSearchParams] = useSearchParams()
 
-  const [refs, setRefs] = useState<GitRef[]>([])
-  const [refsLoading, setRefsLoading] = useState(true)
-  const [error, setError] = useState<string | null>(null)
-
-  const [entries, setEntries] = useState<GitTreeEntry[]>([])
-  const [blob, setBlob] = useState<GitBlob | null>(null)
-  const [readme, setReadme] = useState<string | null>(null)
-  const [contentLoading, setContentLoading] = useState(false)
-
   const currentRef = searchParams.get('ref') ?? ''
   const currentPath = searchParams.get('path') ?? ''
   const viewMode: ViewMode = (searchParams.get('type') as ViewMode) ?? 'tree'
 
-  // Load refs once on mount
-  useEffect(() => {
-    getRefs()
-      .then((data) => {
-        setRefs(data)
-        // If no ref in URL yet, use the default branch
-        if (!searchParams.get('ref')) {
-          const defaultRef = data.find((r) => r.isDefault) ?? data[0]
-          if (defaultRef) {
-            setSearchParams(
-              (prev) => { prev.set('ref', defaultRef.shortName); return prev },
-              { replace: true },
-            )
-          }
-        }
-      })
-      .catch((e: Error) => setError(e.message))
-      .finally(() => setRefsLoading(false))
-  }, []) // eslint-disable-line react-hooks/exhaustive-deps
+  const { data: refsData, loading: refsLoading, error: refsError } = useQuery(REFS_QUERY, {
+    variables: { repo },
+  })
+  const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? []
 
-  // Load tree or blob when ref/path/mode changes
+  // Set default ref from query result once loaded
   useEffect(() => {
-    if (!currentRef) return
-    setContentLoading(true)
-    setEntries([])
-    setBlob(null)
-    setReadme(null)
-
-    const load =
-      viewMode === 'blob'
-        ? getBlob(currentRef, currentPath).then((b) => setBlob(b))
-        : getTree(currentRef, currentPath).then((e) => {
-            setEntries(e)
-            const readmeEntry = e.find((entry) =>
-              entry.type === 'blob' &&
-              /^readme(\.md|\.txt|\.rst)?$/i.test(entry.name),
-            )
-            if (readmeEntry) {
-              const readmePath = currentPath
-                ? `${currentPath}/${readmeEntry.name}`
-                : readmeEntry.name
-              getBlob(currentRef, readmePath)
-                .then((b) => !b.isBinary && setReadme(b.content))
-                .catch(() => {/* best-effort */})
-            }
-          })
-
-    load
-      .catch((e: Error) => setError(e.message))
-      .finally(() => setContentLoading(false))
-  }, [currentRef, currentPath, viewMode])
+    if (refsLoading || refs.length === 0 || searchParams.get('ref')) return
+    const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0]
+    if (defaultRef) {
+      setSearchParams(
+        (prev) => { prev.set('ref', defaultRef.shortName); return prev },
+        { replace: true },
+      )
+    }
+  }, [refsLoading, refs.length]) // eslint-disable-line react-hooks/exhaustive-deps
+
+  const inTreeMode = viewMode === 'tree' && !!currentRef
+  const inBlobMode = viewMode === 'blob' && !!currentRef && !!currentPath
+
+  const { data: treeData, loading: treeLoading } = useQuery(TREE_QUERY, {
+    variables: { repo, ref: currentRef, path: currentPath || null },
+    skip: !inTreeMode,
+  })
+  const entries: GitTreeEntry[] = treeData?.repository?.tree ?? []
+
+  const entryNames = entries.map((e: GitTreeEntry) => e.name)
+  const { data: lastCommitsData } = useQuery(LAST_COMMITS_QUERY, {
+    variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
+    skip: !inTreeMode || entryNames.length === 0,
+  })
+  const lastCommitsByName = new Map<string, GitLastCommit>(
+    (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
+  )
+  const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
+    ...e,
+    lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
+  }))
+
+  const { data: blobData, loading: blobLoading } = useQuery(BLOB_QUERY, {
+    variables: { repo, ref: currentRef, path: currentPath },
+    skip: !inBlobMode,
+  })
+  const blob: GitBlob | null = blobData?.repository?.blob ?? null
+
+  const readmeEntry = entries.find(
+    (e: GitTreeEntry) => e.type === 'BLOB' && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
+  )
+  const readmePath = readmeEntry
+    ? (currentPath ? `${currentPath}/${readmeEntry.name}` : readmeEntry.name)
+    : null
+  const { data: readmeBlobData } = useQuery(BLOB_QUERY, {
+    variables: { repo, ref: currentRef, path: readmePath },
+    skip: !inTreeMode || !readmePath,
+  })
+  const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null
+
+  const repoName = refsData?.repository?.name ?? repo ?? 'default-repo'
 
   function navigate(path: string, type: ViewMode = 'tree') {
-    setSearchParams((prev) => {
-      prev.set('path', path)
-      prev.set('type', type)
-      return prev
-    })
+    setSearchParams((prev) => { prev.set('path', path); prev.set('type', type); return prev })
   }
 
-  function handleEntryClick(entry: GitTreeEntry) {
+  function handleEntryClick(entry: TreeEntryWithCommit) {
     const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
-    navigate(newPath, entry.type === 'blob' ? 'blob' : 'tree')
+    navigate(newPath, entry.type === 'BLOB' ? 'blob' : 'tree')
   }
 
   function handleNavigateUp() {
@@ -116,42 +163,22 @@ export function CodePage() {
 
   function handleRefSelect(ref: GitRef) {
     setSearchParams((prev) => {
-      prev.set('ref', ref.shortName)
-      prev.set('path', '')
-      prev.set('type', 'tree')
-      return prev
+      prev.set('ref', ref.shortName); prev.set('path', ''); prev.set('type', 'tree'); return prev
     })
   }
 
-  const { data: repoData } = useQuery(REPO_NAME_QUERY, { variables: { ref: repo } })
-  const repoName = repoData?.repository?.name ?? repo ?? 'default-repo'
-
-  const cloneUrl = `${window.location.origin}/api/repos/_/_`
-  const cloneCmd = `git clone ${cloneUrl} ${repoName}`
-  const [copied, setCopied] = useState(false)
-  function handleCopy() {
-    navigator.clipboard.writeText(cloneCmd).then(() => {
-      setCopied(true)
-      setTimeout(() => setCopied(false), 1500)
-    })
-  }
-
-  if (error) {
+  if (refsError) {
     return (
       <div className="flex flex-col items-center gap-3 py-16 text-center">
         <AlertCircle className="size-8 text-muted-foreground" />
         <p className="text-sm font-medium">Code browser unavailable</p>
-        <p className="max-w-sm text-xs text-muted-foreground">{error}</p>
-        <p className="max-w-sm text-xs text-muted-foreground">
-          Make sure the git-bug server is running and the repository supports code browsing.
-        </p>
+        <p className="max-w-sm text-xs text-muted-foreground">{refsError.message}</p>
       </div>
     )
   }
 
   return (
     <div className="space-y-4">
-      {/* Top bar: breadcrumb + ref selector */}
       <div className="flex flex-wrap items-center justify-between gap-3">
         {refsLoading ? (
           <Skeleton className="h-5 w-48" />
@@ -168,12 +195,7 @@ export function CodePage() {
             <Button
               variant={viewMode === 'commits' ? 'secondary' : 'outline'}
               size="sm"
-              onClick={() =>
-                navigate(
-                  currentPath,
-                  viewMode === 'commits' ? 'tree' : 'commits',
-                )
-              }
+              onClick={() => navigate(currentPath, viewMode === 'commits' ? 'tree' : 'commits')}
             >
               <GitCommit className="size-3.5" />
               History
@@ -187,24 +209,14 @@ export function CodePage() {
         </div>
       </div>
 
-      {/* Clone command */}
-      <div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-1.5">
-        <span className="text-xs text-muted-foreground shrink-0">clone</span>
-        <code className="flex-1 truncate text-xs">{cloneCmd}</code>
-        <Button variant="ghost" size="icon" className="size-6 shrink-0" onClick={handleCopy}>
-          {copied ? <Check className="size-3 text-green-600" /> : <Copy className="size-3" />}
-        </Button>
-      </div>
-
-      {/* Content */}
       {viewMode === 'commits' ? (
         <CommitList ref_={currentRef} path={currentPath || undefined} />
       ) : viewMode === 'tree' || !blob ? (
         <>
           <FileTree
-            entries={entries}
+            entries={entriesWithCommits}
             path={currentPath}
-            loading={contentLoading}
+            loading={treeLoading}
             onNavigate={handleEntryClick}
             onNavigateUp={handleNavigateUp}
           />
@@ -220,7 +232,7 @@ export function CodePage() {
           )}
         </>
       ) : (
-        <FileViewer blob={blob} ref={currentRef} loading={contentLoading} />
+        <FileViewer blob={blob} loading={blobLoading} />
       )}
     </div>
   )

webui2/src/pages/CommitPage.tsx 🔗

@@ -1,45 +1,63 @@
-import { useState, useEffect } from 'react'
+// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
+// message, parent links, and changed files with lazy diffs.
+
 import { Link, useParams, useNavigate } from 'react-router-dom'
 import { format } from 'date-fns'
 import { ArrowLeft, GitCommit } from 'lucide-react'
+import { gql, useQuery } from '@apollo/client'
 import { Skeleton } from '@/components/ui/skeleton'
-import { getCommit } from '@/lib/gitApi'
-import type { GitCommitDetail } from '@/lib/gitApi'
 import { useRepo } from '@/lib/repo'
 import { FileDiffView } from '@/components/code/FileDiffView'
 
-// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full message,
-// parent links, and the list of files changed with add/modify/delete/rename status.
+const COMMIT_QUERY = gql`
+  query CommitPageDetail($repo: String, $hash: String!) {
+    repository(ref: $repo) {
+      commit(hash: $hash) {
+        hash
+        shortHash
+        message
+        fullMessage
+        authorName
+        authorEmail
+        date
+        parents
+        files {
+          nodes {
+            path
+            oldPath
+            status
+          }
+        }
+      }
+    }
+  }
+`
+
 export function CommitPage() {
   const { hash } = useParams<{ hash: string }>()
   const navigate = useNavigate()
   const repo = useRepo()
-  const [commit, setCommit] = useState<GitCommitDetail | null>(null)
-  const [loading, setLoading] = useState(true)
-  const [error, setError] = useState<string | null>(null)
 
-  useEffect(() => {
-    setLoading(true)
-    setError(null)
-    getCommit(hash!)
-      .then(setCommit)
-      .catch((e: Error) => setError(e.message))
-      .finally(() => setLoading(false))
-  }, [hash])
+  const { data, loading, error } = useQuery(COMMIT_QUERY, {
+    variables: { repo, hash },
+    skip: !hash,
+  })
 
   if (loading) return <CommitPageSkeleton />
 
   if (error) {
     return (
       <div className="py-16 text-center text-sm text-destructive">
-        Failed to load commit: {error}
+        Failed to load commit: {error.message}
       </div>
     )
   }
 
+  const commit = data?.repository?.commit
   if (!commit) return null
 
   const date = new Date(commit.date)
+  const files = commit.files?.nodes ?? []
 
   return (
     <div>
@@ -51,14 +69,12 @@ export function CommitPage() {
         Back
       </button>
 
-      {/* Header */}
       <div className="mb-6 rounded-md border border-border p-5">
         <div className="mb-1 flex items-start gap-3">
           <GitCommit className="mt-1 size-5 shrink-0 text-muted-foreground" />
           <h1 className="text-lg font-semibold leading-snug">{commit.message}</h1>
         </div>
 
-        {/* Full message body (if multi-line) */}
         {commit.fullMessage.includes('\n') && (
           <pre className="mb-4 ml-8 mt-3 whitespace-pre-wrap font-sans text-sm text-muted-foreground">
             {commit.fullMessage.split('\n').slice(1).join('\n').trim()}
@@ -68,19 +84,16 @@ export function CommitPage() {
         <div className="ml-8 mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
           <span>
             <span className="font-medium text-foreground">{commit.authorName}</span>
-            {commit.authorEmail && (
-              <span> &lt;{commit.authorEmail}&gt;</span>
-            )}
+            {commit.authorEmail && <span> &lt;{commit.authorEmail}&gt;</span>}
           </span>
           <span title={date.toISOString()}>{format(date, 'PPP')}</span>
         </div>
 
         <div className="ml-8 mt-3 flex flex-wrap gap-3 text-xs">
           <span className="text-muted-foreground">
-            commit{' '}
-            <code className="font-mono text-foreground">{commit.hash}</code>
+            commit <code className="font-mono text-foreground">{commit.hash}</code>
           </span>
-          {commit.parents.map((p) => (
+          {commit.parents.map((p: string) => (
             <span key={p} className="text-muted-foreground">
               parent{' '}
               <Link
@@ -94,21 +107,20 @@ export function CommitPage() {
         </div>
       </div>
 
-      {/* Changed files — each row is collapsible and loads its diff lazily */}
       <div>
         <h2 className="mb-3 text-sm font-semibold text-muted-foreground">
-          {commit.files.length} file{commit.files.length !== 1 ? 's' : ''} changed
+          {files.length} file{files.length !== 1 ? 's' : ''} changed
         </h2>
         <div className="overflow-hidden rounded-md border border-border divide-y divide-border">
-          {commit.files.length === 0 && (
+          {files.length === 0 && (
             <p className="px-4 py-4 text-sm text-muted-foreground">No file changes.</p>
           )}
-          {commit.files.map((file) => (
+          {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
             <FileDiffView
               key={file.path}
-              sha={commit.hash}
+              hash={commit.hash}
               path={file.path}
-              oldPath={file.oldPath}
+              oldPath={file.oldPath ?? undefined}
               status={file.status}
             />
           ))}

webui2/vite.config.ts 🔗

@@ -31,8 +31,7 @@ export default defineConfig({
       '/graphql': { target: API_URL, changeOrigin: true },
       '/gitfile': { target: API_URL, changeOrigin: true },
       '/upload': { target: API_URL, changeOrigin: true },
-      '/api': { target: API_URL, changeOrigin: true },
-      '/auth': { target: API_URL, changeOrigin: true },
+'/auth': { target: API_URL, changeOrigin: true },
     },
   },
 })