repo,graphql: get a ref from HEAD instead of a commit (#1552)

Michael Muré created

Change summary

api/graphql/graph/git.generated.go        | 176 +++++++++++++++++++++---
api/graphql/graph/repository.generated.go |  49 ++----
api/graphql/graph/root_.generated.go      |  41 ++++-
api/graphql/graphql_test.go               | 115 ++++++++++++++++
api/graphql/models/enums.go               |  58 --------
api/graphql/models/gen_models.go          |  12 -
api/graphql/models/models.go              |   7 
api/graphql/resolvers/git.go              |  17 ++
api/graphql/resolvers/repo.go             |  37 +++-
api/graphql/resolvers/root.go             |   4 
api/graphql/schema/git.graphql            |  15 +
api/graphql/schema/repository.graphql     |  11 
repository/browse.go                      |  64 +++++++++
repository/gogit.go                       |  55 ++++---
repository/mock_repo.go                   |  16 +
repository/repo.go                        |   2 
repository/repo_testing.go                |  13 +
17 files changed, 499 insertions(+), 193 deletions(-)

Detailed changes

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

@@ -28,6 +28,9 @@ type GitCommitResolver interface {
 	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)
 }
+type GitRefResolver interface {
+	Commit(ctx context.Context, obj *models.GitRef) (*models.GitCommitMeta, error)
+}
 type GitTreeEntryResolver interface {
 	LastCommit(ctx context.Context, obj *models.GitTreeEntry) (*models.GitCommitMeta, error)
 }
@@ -2257,9 +2260,9 @@ func (ec *executionContext) _GitRef_type(ctx context.Context, field graphql.Coll
 		}
 		return graphql.Null
 	}
-	res := resTmp.(models.GitRefType)
+	res := resTmp.(repository.GitRefType)
 	fc.Result = res
-	return ec.marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType(ctx, field.Selections, res)
+	return ec.marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_GitRef_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -2319,6 +2322,72 @@ func (ec *executionContext) fieldContext_GitRef_hash(_ context.Context, field gr
 	return fc, nil
 }
 
+func (ec *executionContext) _GitRef_commit(ctx context.Context, field graphql.CollectedField, obj *models.GitRef) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_GitRef_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 ec.resolvers.GitRef().Commit(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.(*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_GitRef_commit(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "GitRef",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		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) _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 {
@@ -2366,6 +2435,8 @@ func (ec *executionContext) fieldContext_GitRefConnection_nodes(_ context.Contex
 				return ec.fieldContext_GitRef_type(ctx, field)
 			case "hash":
 				return ec.fieldContext_GitRef_hash(ctx, field)
+			case "commit":
+				return ec.fieldContext_GitRef_commit(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type GitRef", field.Name)
 		},
@@ -3351,23 +3422,59 @@ func (ec *executionContext) _GitRef(ctx context.Context, sel ast.SelectionSet, o
 		case "name":
 			out.Values[i] = ec._GitRef_name(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
-				out.Invalids++
+				atomic.AddUint32(&out.Invalids, 1)
 			}
 		case "shortName":
 			out.Values[i] = ec._GitRef_shortName(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
-				out.Invalids++
+				atomic.AddUint32(&out.Invalids, 1)
 			}
 		case "type":
 			out.Values[i] = ec._GitRef_type(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
-				out.Invalids++
+				atomic.AddUint32(&out.Invalids, 1)
 			}
 		case "hash":
 			out.Values[i] = ec._GitRef_hash(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
-				out.Invalids++
+				atomic.AddUint32(&out.Invalids, 1)
 			}
+		case "commit":
+			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._GitRef_commit(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) })
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -3604,6 +3711,10 @@ func (ec *executionContext) marshalNGitChangedFileConnection2ᚖgithubᚗcomᚋg
 	return ec._GitChangedFileConnection(ctx, sel, v)
 }
 
+func (ec *executionContext) marshalNGitCommit2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMeta(ctx context.Context, sel ast.SelectionSet, v models.GitCommitMeta) graphql.Marshaler {
+	return ec._GitCommit(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
@@ -3910,15 +4021,15 @@ func (ec *executionContext) marshalNGitRefConnection2ᚖgithubᚗcomᚋgitᚑbug
 	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) {
+func (ec *executionContext) unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType(ctx context.Context, v any) (repository.GitRefType, error) {
 	tmp, err := graphql.UnmarshalString(v)
-	res := unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType[tmp]
+	res := unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐ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 {
+func (ec *executionContext) marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType(ctx context.Context, sel ast.SelectionSet, v repository.GitRefType) graphql.Marshaler {
 	_ = sel
-	res := graphql.MarshalString(marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType[v])
+	res := graphql.MarshalString(marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐ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")
@@ -3928,13 +4039,15 @@ func (ec *executionContext) marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑ
 }
 
 var (
-	unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType = map[string]models.GitRefType{
-		"BRANCH": models.GitRefTypeBranch,
-		"TAG":    models.GitRefTypeTag,
+	unmarshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType = map[string]repository.GitRefType{
+		"BRANCH": repository.GitRefTypeBranch,
+		"TAG":    repository.GitRefTypeTag,
+		"COMMIT": repository.GitRefTypeCommit,
 	}
-	marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRefType = map[models.GitRefType]string{
-		models.GitRefTypeBranch: "BRANCH",
-		models.GitRefTypeTag:    "TAG",
+	marshalNGitRefType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType = map[repository.GitRefType]string{
+		repository.GitRefTypeBranch: "BRANCH",
+		repository.GitRefTypeTag:    "TAG",
+		repository.GitRefTypeCommit: "COMMIT",
 	}
 )
 
@@ -4013,33 +4126,42 @@ func (ec *executionContext) marshalOGitFileDiff2ᚖgithubᚗcomᚋgitᚑbugᚋgi
 	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) {
+func (ec *executionContext) marshalOGitRef2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRef(ctx context.Context, sel ast.SelectionSet, v *models.GitRef) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return ec._GitRef(ctx, sel, v)
+}
+
+func (ec *executionContext) unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType(ctx context.Context, v any) (*repository.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]
+	res := unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐ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 {
+func (ec *executionContext) marshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType(ctx context.Context, sel ast.SelectionSet, v *repository.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])
+	res := graphql.MarshalString(marshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐ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",
+	unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType = map[string]repository.GitRefType{
+		"BRANCH": repository.GitRefTypeBranch,
+		"TAG":    repository.GitRefTypeTag,
+		"COMMIT": repository.GitRefTypeCommit,
+	}
+	marshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType = map[repository.GitRefType]string{
+		repository.GitRefTypeBranch: "BRANCH",
+		repository.GitRefTypeTag:    "TAG",
+		repository.GitRefTypeCommit: "COMMIT",
 	}
 )
 

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

@@ -13,6 +13,7 @@ import (
 
 	"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"
 )
 
@@ -25,13 +26,13 @@ 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)
+	Refs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, typeArg *repository.GitRefType) (*models.GitRefConnection, error)
 	Tree(ctx context.Context, obj *models.Repository, ref string, path *string) ([]*models.GitTreeEntry, 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)
-	Head(ctx context.Context, obj *models.Repository) (*models.GitCommitMeta, error)
+	Head(ctx context.Context, obj *models.Repository) (*models.GitRef, error)
 	ValidLabels(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error)
 }
 
@@ -713,18 +714,18 @@ func (ec *executionContext) field_Repository_refs_argsLast(
 func (ec *executionContext) field_Repository_refs_argsType(
 	ctx context.Context,
 	rawArgs map[string]any,
-) (*models.GitRefType, error) {
+) (*repository.GitRefType, error) {
 	if _, ok := rawArgs["type"]; !ok {
-		var zeroVal *models.GitRefType
+		var zeroVal *repository.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)
+		return ec.unmarshalOGitRefType2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐGitRefType(ctx, tmp)
 	}
 
-	var zeroVal *models.GitRefType
+	var zeroVal *repository.GitRefType
 	return zeroVal, nil
 }
 
@@ -1278,7 +1279,7 @@ func (ec *executionContext) _Repository_refs(ctx context.Context, field graphql.
 	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
 		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Repository().Refs(rctx, obj, fc.Args["after"].(*string), fc.Args["before"].(*string), fc.Args["first"].(*int), fc.Args["last"].(*int), fc.Args["type"].(*models.GitRefType))
+		return ec.resolvers.Repository().Refs(rctx, obj, fc.Args["after"].(*string), fc.Args["before"].(*string), fc.Args["first"].(*int), fc.Args["last"].(*int), fc.Args["type"].(*repository.GitRefType))
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -1679,9 +1680,9 @@ func (ec *executionContext) _Repository_head(ctx context.Context, field graphql.
 	if resTmp == nil {
 		return graphql.Null
 	}
-	res := resTmp.(*models.GitCommitMeta)
+	res := resTmp.(*models.GitRef)
 	fc.Result = res
-	return ec.marshalOGitCommit2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitCommitMeta(ctx, field.Selections, res)
+	return ec.marshalOGitRef2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐGitRef(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_Repository_head(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -1692,28 +1693,18 @@ func (ec *executionContext) fieldContext_Repository_head(_ context.Context, fiel
 		IsResolver: true,
 		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_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 ec.fieldContext_GitRef_hash(ctx, field)
+			case "commit":
+				return ec.fieldContext_GitRef_commit(ctx, field)
 			}
-			return nil, fmt.Errorf("no field named %q was found under type GitCommit", field.Name)
+			return nil, fmt.Errorf("no field named %q was found under type GitRef", field.Name)
 		},
 	}
 	return fc, nil

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

@@ -12,6 +12,7 @@ import (
 	"github.com/99designs/gqlgen/graphql"
 	"github.com/99designs/gqlgen/graphql/introspection"
 	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/repository"
 	gqlparser "github.com/vektah/gqlparser/v2"
 	"github.com/vektah/gqlparser/v2/ast"
 )
@@ -50,6 +51,7 @@ type ResolverRoot interface {
 	BugSetTitleTimelineItem() BugSetTitleTimelineItemResolver
 	Color() ColorResolver
 	GitCommit() GitCommitResolver
+	GitRef() GitRefResolver
 	GitTreeEntry() GitTreeEntryResolver
 	Identity() IdentityResolver
 	Label() LabelResolver
@@ -366,6 +368,7 @@ type ComplexityRoot struct {
 	}
 
 	GitRef struct {
+		Commit    func(childComplexity int) int
 		Hash      func(childComplexity int) int
 		Name      func(childComplexity int) int
 		ShortName func(childComplexity int) int
@@ -482,7 +485,7 @@ type ComplexityRoot struct {
 		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
+		Refs          func(childComplexity int, after *string, before *string, first *int, last *int, typeArg *repository.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
@@ -1814,6 +1817,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.GitLastCommit.Name(childComplexity), true
 
+	case "GitRef.commit":
+		if e.complexity.GitRef.Commit == nil {
+			break
+		}
+
+		return e.complexity.GitRef.Commit(childComplexity), true
+
 	case "GitRef.hash":
 		if e.complexity.GitRef.Hash == nil {
 			break
@@ -2395,7 +2405,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 			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
+		return e.complexity.Repository.Refs(childComplexity, args["after"].(*string), args["before"].(*string), args["first"].(*int), args["last"].(*int), args["type"].(*repository.GitRefType)), true
 
 	case "Repository.tree":
 		if e.complexity.Repository.Tree == nil {
@@ -3166,7 +3176,8 @@ directive @goEnum(
 ) on ENUM_VALUE
 `, BuiltIn: false},
 	{Name: "../schema/git.graphql", Input: `"""A git branch or tag reference."""
-type GitRef {
+type GitRef
+@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitRef") {
     """Full reference name, e.g. refs/heads/main or refs/tags/v1.0."""
     name: String!
     """Short name, e.g. main or v1.0."""
@@ -3175,6 +3186,8 @@ type GitRef {
     type: GitRefType!
     """Commit hash the reference points to."""
     hash: String!
+    """Git commit the reference points to."""
+    commit: GitCommit!
 }
 
 """An entry in a git tree (directory listing)."""
@@ -3335,13 +3348,15 @@ type GitDiffLine
 
 # ── enums ─────────────────────────────────────────────────────────────────────
 
-"""The kind of git reference: a branch or a tag."""
+"""The kind of git reference: a branch, a tag, or a detached commit."""
 enum GitRefType
-@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitRefType") {
+@goModel(model: "github.com/git-bug/git-bug/repository.GitRefType") {
     """A local branch (refs/heads/*)."""
-    BRANCH @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeBranch")
+    BRANCH @goEnum(value: "github.com/git-bug/git-bug/repository.GitRefTypeBranch")
     """An annotated or lightweight tag (refs/tags/*)."""
-    TAG @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeTag")
+    TAG @goEnum(value: "github.com/git-bug/git-bug/repository.GitRefTypeTag")
+    """A detached HEAD pointing directly at a commit."""
+    COMMIT @goEnum(value: "github.com/git-bug/git-bug/repository.GitRefTypeCommit")
 }
 
 """The type of object a git tree entry points to."""
@@ -3515,7 +3530,8 @@ type OperationEdge {
     """The identity created or selected by the user as its own"""
     userIdentity: Identity
 
-    """All branches and tags, optionally filtered by type."""
+    """All branches and tags, optionally filtered by type. BRANCH and TAG are
+    the only accepted filter values; passing COMMIT returns an error."""
     refs(
         """Returns the elements in the list that come after the specified cursor."""
         after: String
@@ -3525,7 +3541,7 @@ type OperationEdge {
         first: Int
         """Returns the last _n_ elements from the list."""
         last: Int
-        """Restrict to references of this type."""
+        """Restrict to references of this type. Accepts BRANCH or TAG only."""
         type: GitRefType
     ): GitRefConnection!
 
@@ -3562,9 +3578,10 @@ type OperationEdge {
     tree listing without blocking the initial tree fetch."""
     lastCommits(ref: String!, path: String, names: [String!]!): [GitLastCommit!]!
 
-    """The currently checked-out commit (branch, tag, hash ...) in the git repository.
-    Null if there is none (bare repo)."""
-    head: GitCommit
+    """The reference pointed to by HEAD in the git repository.
+    Null if HEAD cannot be resolved, for example in an empty or unborn
+    repository, or if HEAD is missing or invalid."""
+    head: GitRef
 
     """List of valid labels."""
     validLabels(

api/graphql/graphql_test.go 🔗

@@ -440,6 +440,121 @@ func TestGitBrowseQueries(t *testing.T) {
 		require.Equal(t, string(c2), got.Nodes[1].Hash)
 	})
 
+	// ── refs ─────────────────────────────────────────────────────────────────
+
+	t.Run("refs_all", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Refs struct {
+					TotalCount int
+					Nodes      []struct {
+						Name      string
+						ShortName string
+						Type      string `json:"type"`
+						Hash      string
+					}
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { refs { totalCount nodes { name shortName type hash } } }
+		}`, &resp))
+		nodes := resp.Repository.Refs.Nodes
+		require.Equal(t, 3, resp.Repository.Refs.TotalCount)
+		byShort := make(map[string]struct {
+			Name string
+			Type string
+			Hash string
+		})
+		for _, n := range nodes {
+			byShort[n.ShortName] = struct {
+				Name string
+				Type string
+				Hash string
+			}{n.Name, n.Type, n.Hash}
+		}
+		require.Equal(t, "refs/heads/feature", byShort["feature"].Name)
+		require.Equal(t, "BRANCH", byShort["feature"].Type)
+		require.Equal(t, string(c2), byShort["feature"].Hash)
+		require.Equal(t, "refs/heads/main", byShort["main"].Name)
+		require.Equal(t, "BRANCH", byShort["main"].Type)
+		require.Equal(t, string(c3), byShort["main"].Hash)
+		require.Equal(t, "refs/tags/v1.0", byShort["v1.0"].Name)
+		require.Equal(t, "TAG", byShort["v1.0"].Type)
+		require.Equal(t, string(c1), byShort["v1.0"].Hash)
+	})
+
+	t.Run("refs_branch_filter", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Refs struct {
+					TotalCount int
+					Nodes      []struct{ ShortName string }
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { refs(type: BRANCH) { totalCount nodes { shortName } } }
+		}`, &resp))
+		require.Equal(t, 2, resp.Repository.Refs.TotalCount)
+		names := make([]string, len(resp.Repository.Refs.Nodes))
+		for i, n := range resp.Repository.Refs.Nodes {
+			names[i] = n.ShortName
+		}
+		require.ElementsMatch(t, []string{"main", "feature"}, names)
+	})
+
+	t.Run("refs_tag_filter", func(t *testing.T) {
+		var resp struct {
+			Repository struct {
+				Refs struct {
+					TotalCount int
+					Nodes      []struct{ ShortName string }
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { refs(type: TAG) { totalCount nodes { shortName } } }
+		}`, &resp))
+		require.Equal(t, 1, resp.Repository.Refs.TotalCount)
+		require.Equal(t, "v1.0", resp.Repository.Refs.Nodes[0].ShortName)
+	})
+
+	t.Run("refs_commit_filter_error", func(t *testing.T) {
+		var resp struct {
+			Repository struct{ Refs *struct{ TotalCount int } }
+		}
+		err := c.Post(`query {
+			repository { refs(type: COMMIT) { totalCount } }
+		}`, &resp)
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "COMMIT")
+	})
+
+	// ── head ─────────────────────────────────────────────────────────────────
+
+	t.Run("head_detached", func(t *testing.T) {
+		require.NoError(t, repo.UpdateRef("HEAD", c3))
+		var resp struct {
+			Repository struct {
+				Head struct {
+					Name      string
+					ShortName string
+					Type      string `json:"type"`
+					Hash      string
+				}
+			}
+		}
+		require.NoError(t, c.Post(`query {
+			repository { head { name shortName type hash } }
+		}`, &resp))
+		got := resp.Repository.Head
+		require.Equal(t, "HEAD", got.Name)
+		require.Equal(t, "HEAD", got.ShortName)
+		require.Equal(t, "COMMIT", got.Type)
+		require.Equal(t, string(c3), got.Hash)
+	})
+
 	// ── lastCommits ───────────────────────────────────────────────────────────
 
 	t.Run("lastCommits", func(t *testing.T) {

api/graphql/models/enums.go 🔗

@@ -1,58 +0,0 @@
-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 🔗

@@ -310,18 +310,6 @@ type GitLastCommit struct {
 	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"`
-}
-
 type GitRefConnection struct {
 	Nodes      []*GitRef `json:"nodes"`
 	PageInfo   *PageInfo `json:"pageInfo"`

api/graphql/models/models.go 🔗

@@ -17,6 +17,13 @@ type Repository struct {
 	Repo *cache.RepoCache
 }
 
+// GitRef is a wrapper around a RefMeta that includes the Repo,
+// to keep the repo context in sub-resolvers.
+type GitRef struct {
+	Repo *cache.RepoCache
+	repository.RefMeta
+}
+
 // GitCommitMeta is a wrapper around a CommitMeta that includes the Repo,
 // to keep the repo context in sub-resolvers.
 type GitCommitMeta struct {

api/graphql/resolvers/git.go 🔗

@@ -2,6 +2,7 @@ package resolvers
 
 import (
 	"context"
+	"errors"
 
 	"github.com/git-bug/git-bug/api/graphql/connections"
 	"github.com/git-bug/git-bug/api/graphql/graph"
@@ -90,3 +91,19 @@ func (r gitTreeEntryResolver) LastCommit(_ context.Context, obj *models.GitTreeE
 	}
 	return &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: meta}, nil
 }
+
+var _ graph.GitRefResolver = &gitRefResolver{}
+
+type gitRefResolver struct{}
+
+func (g gitRefResolver) Commit(ctx context.Context, obj *models.GitRef) (*models.GitCommitMeta, error) {
+	repo := obj.Repo.BrowseRepo()
+	detail, err := repo.CommitDetail(repository.Hash(obj.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
+}

api/graphql/resolvers/repo.go 🔗

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"fmt"
 	"io"
 	"math"
 	"sort"
@@ -206,37 +207,47 @@ 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) {
+func (repoResolver) Refs(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, typeArg *repository.GitRefType) (*models.GitRefConnection, error) {
 	repo := obj.Repo.BrowseRepo()
 
 	var refs []*models.GitRef
 
-	if typeArg == nil || *typeArg == models.GitRefTypeBranch {
+	if typeArg != nil && *typeArg == repository.GitRefTypeCommit {
+		return nil, fmt.Errorf("refs: COMMIT is not a valid filter; use BRANCH or TAG")
+	}
+
+	if typeArg == nil || *typeArg == repository.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),
+				Repo: obj.Repo,
+				RefMeta: repository.RefMeta{
+					Name:      "refs/heads/" + b.Name,
+					ShortName: b.Name,
+					Type:      repository.GitRefTypeBranch,
+					Hash:      string(b.Hash),
+				},
 			})
 		}
 	}
 
-	if typeArg == nil || *typeArg == models.GitRefTypeTag {
+	if typeArg == nil || *typeArg == repository.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),
+				Repo: obj.Repo,
+				RefMeta: repository.RefMeta{
+					Name:      "refs/tags/" + t.Name,
+					ShortName: t.Name,
+					Type:      repository.GitRefTypeTag,
+					Hash:      string(t.Hash),
+				},
 			})
 		}
 	}
@@ -422,7 +433,7 @@ func (repoResolver) LastCommits(_ context.Context, obj *models.Repository, ref s
 	return result, nil
 }
 
-func (repoResolver) Head(_ context.Context, obj *models.Repository) (*models.GitCommitMeta, error) {
+func (repoResolver) Head(_ context.Context, obj *models.Repository) (*models.GitRef, error) {
 	meta, err := obj.Repo.BrowseRepo().Head()
 	if errors.Is(err, repository.ErrNotFound) {
 		return nil, nil
@@ -430,5 +441,5 @@ func (repoResolver) Head(_ context.Context, obj *models.Repository) (*models.Git
 	if err != nil {
 		return nil, err
 	}
-	return &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: meta}, nil
+	return &models.GitRef{Repo: obj.Repo, RefMeta: meta}, nil
 }

api/graphql/resolvers/root.go 🔗

@@ -57,6 +57,10 @@ func (RootResolver) Bug() graph.BugResolver {
 	return &bugResolver{}
 }
 
+func (r RootResolver) GitRef() graph.GitRefResolver {
+	return &gitRefResolver{}
+}
+
 func (r RootResolver) GitCommit() graph.GitCommitResolver {
 	return &gitCommitResolver{}
 }

api/graphql/schema/git.graphql 🔗

@@ -1,5 +1,6 @@
 """A git branch or tag reference."""
-type GitRef {
+type GitRef
+@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitRef") {
     """Full reference name, e.g. refs/heads/main or refs/tags/v1.0."""
     name: String!
     """Short name, e.g. main or v1.0."""
@@ -8,6 +9,8 @@ type GitRef {
     type: GitRefType!
     """Commit hash the reference points to."""
     hash: String!
+    """Git commit the reference points to."""
+    commit: GitCommit!
 }
 
 """An entry in a git tree (directory listing)."""
@@ -168,13 +171,15 @@ type GitDiffLine
 
 # ── enums ─────────────────────────────────────────────────────────────────────
 
-"""The kind of git reference: a branch or a tag."""
+"""The kind of git reference: a branch, a tag, or a detached commit."""
 enum GitRefType
-@goModel(model: "github.com/git-bug/git-bug/api/graphql/models.GitRefType") {
+@goModel(model: "github.com/git-bug/git-bug/repository.GitRefType") {
     """A local branch (refs/heads/*)."""
-    BRANCH @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeBranch")
+    BRANCH @goEnum(value: "github.com/git-bug/git-bug/repository.GitRefTypeBranch")
     """An annotated or lightweight tag (refs/tags/*)."""
-    TAG @goEnum(value: "github.com/git-bug/git-bug/api/graphql/models.GitRefTypeTag")
+    TAG @goEnum(value: "github.com/git-bug/git-bug/repository.GitRefTypeTag")
+    """A detached HEAD pointing directly at a commit."""
+    COMMIT @goEnum(value: "github.com/git-bug/git-bug/repository.GitRefTypeCommit")
 }
 
 """The type of object a git tree entry points to."""

api/graphql/schema/repository.graphql 🔗

@@ -37,7 +37,8 @@ type Repository {
     """The identity created or selected by the user as its own"""
     userIdentity: Identity
 
-    """All branches and tags, optionally filtered by type."""
+    """All branches and tags, optionally filtered by type. BRANCH and TAG are
+    the only accepted filter values; passing COMMIT returns an error."""
     refs(
         """Returns the elements in the list that come after the specified cursor."""
         after: String
@@ -47,7 +48,7 @@ type Repository {
         first: Int
         """Returns the last _n_ elements from the list."""
         last: Int
-        """Restrict to references of this type."""
+        """Restrict to references of this type. Accepts BRANCH or TAG only."""
         type: GitRefType
     ): GitRefConnection!
 
@@ -84,10 +85,10 @@ type Repository {
     tree listing without blocking the initial tree fetch."""
     lastCommits(ref: String!, path: String, names: [String!]!): [GitLastCommit!]!
 
-    """The commit pointed to by HEAD in the git repository.
-    Null if HEAD cannot be resolved to a commit, for example in an empty or unborn
+    """The reference pointed to by HEAD in the git repository.
+    Null if HEAD cannot be resolved, for example in an empty or unborn
     repository, or if HEAD is missing or invalid."""
-    head: GitCommit
+    head: GitRef
 
     """List of valid labels."""
     validLabels(

repository/browse.go 🔗

@@ -1,6 +1,7 @@
 package repository
 
 import (
+	"bytes"
 	"fmt"
 	"io"
 	"strconv"
@@ -92,6 +93,69 @@ func (t *DiffLineType) UnmarshalGQL(v any) error {
 	return nil
 }
 
+// GitRefType is the kind of git reference: a branch, a tag, or a detached commit.
+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"
+	// GitRefTypeCommit represents a detached HEAD pointing directly at a commit.
+	GitRefTypeCommit GitRefType = "COMMIT"
+)
+
+func (e GitRefType) IsValid() bool {
+	switch e {
+	case GitRefTypeBranch, GitRefTypeTag, GitRefTypeCommit:
+		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
+}
+
+type RefMeta 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"`
+}
+
 // CommitMeta holds the metadata for a single commit, suitable for listing.
 type CommitMeta struct {
 	Hash        Hash

repository/gogit.go 🔗

@@ -1550,30 +1550,6 @@ func (repo *GoGitRepo) CommitFileDiff(hash Hash, filePath string) (FileDiff, err
 	return FileDiff{}, ErrNotFound
 }
 
-// Head returns the commit that HEAD currently points to.
-func (repo *GoGitRepo) Head() (CommitMeta, error) {
-	repo.rMutex.Lock()
-	defer repo.rMutex.Unlock()
-
-	ref, err := repo.r.Head()
-	if err == plumbing.ErrReferenceNotFound {
-		return CommitMeta{}, ErrNotFound
-	}
-	if err != nil {
-		return CommitMeta{}, err
-	}
-
-	c, err := repo.r.CommitObject(ref.Hash())
-	if err == plumbing.ErrObjectNotFound {
-		return CommitMeta{}, ErrNotFound
-	}
-	if err != nil {
-		return CommitMeta{}, err
-	}
-
-	return commitToMeta(c), nil
-}
-
 // buildDiffHunks converts a go-git FilePatch into DiffHunks with line numbers
 // and context grouping.
 func buildDiffHunks(fp fdiff.FilePatch) []DiffHunk {
@@ -1674,6 +1650,37 @@ func buildDiffHunks(fp fdiff.FilePatch) []DiffHunk {
 	return hunks
 }
 
+// Head returns the ref that HEAD currently points to.
+func (repo *GoGitRepo) Head() (RefMeta, error) {
+	repo.rMutex.Lock()
+	defer repo.rMutex.Unlock()
+
+	ref, err := repo.r.Head()
+	if err == plumbing.ErrReferenceNotFound {
+		return RefMeta{}, ErrNotFound
+	}
+	if err != nil {
+		return RefMeta{}, err
+	}
+
+	var refType GitRefType
+	switch {
+	case ref.Name().IsBranch():
+		refType = GitRefTypeBranch
+	case ref.Name().IsTag():
+		refType = GitRefTypeTag
+	default:
+		refType = GitRefTypeCommit
+	}
+
+	return RefMeta{
+		Name:      ref.Name().String(),
+		ShortName: ref.Name().Short(),
+		Type:      refType,
+		Hash:      ref.Hash().String(),
+	}, nil
+}
+
 // AddRemote add a new remote to the repository
 // Not in the interface because it's only used for testing
 func (repo *GoGitRepo) AddRemote(name string, url string) error {

repository/mock_repo.go 🔗

@@ -794,16 +794,20 @@ func (r *mockRepoDataBrowse) CommitFileDiff(hash Hash, filePath string) (FileDif
 	return fd, nil
 }
 
-func (r *mockRepoDataBrowse) Head() (CommitMeta, error) {
+func (r *mockRepoDataBrowse) Head() (RefMeta, error) {
 	hash, ok := r.refs["HEAD"]
 	if !ok {
-		return CommitMeta{}, ErrNotFound
+		return RefMeta{}, ErrNotFound
 	}
-	c, ok := r.commits[hash]
-	if !ok {
-		return CommitMeta{}, ErrNotFound
+	if _, ok := r.commits[hash]; !ok {
+		return RefMeta{}, ErrNotFound
 	}
-	return mockCommitMeta(hash, c), nil
+	return RefMeta{
+		Name:      "HEAD",
+		ShortName: "HEAD",
+		Type:      GitRefTypeCommit,
+		Hash:      string(hash),
+	}, nil
 }
 
 // mockDiffHunks produces a single DiffHunk using a prefix/suffix scan.

repository/repo.go 🔗

@@ -265,7 +265,7 @@ type RepoBrowse interface {
 	// Head returns the commit that HEAD currently points to.
 	// Returns ErrNotFound if HEAD cannot be resolved to a commit, including
 	// for an empty (unborn) repository.
-	Head() (CommitMeta, error)
+	Head() (RefMeta, error)
 }
 
 // ClockLoader hold which logical clock need to exist for an entity and

repository/repo_testing.go 🔗

@@ -772,10 +772,21 @@ func RepoBrowseTest(t *testing.T, repo browsable) {
 	// ── Head ──────────────────────────────────────────────────────────────────
 
 	t.Run("Head", func(t *testing.T) {
+		// Detached HEAD: UpdateRef sets HEAD to a bare hash.
 		require.NoError(t, repo.UpdateRef("HEAD", c3))
 
 		meta, err := repo.Head()
 		require.NoError(t, err)
-		require.Equal(t, c3, meta.Hash)
+		require.Equal(t, string(c3), meta.Hash)
+		require.Equal(t, GitRefTypeCommit, meta.Type)
+		// Detached HEAD has no branch/tag name; both name fields should be "HEAD".
+		require.Equal(t, "HEAD", meta.Name)
+		require.Equal(t, "HEAD", meta.ShortName)
+
+		// Moving HEAD to a different commit should be reflected immediately.
+		require.NoError(t, repo.UpdateRef("HEAD", c1))
+		meta2, err := repo.Head()
+		require.NoError(t, err)
+		require.Equal(t, string(c1), meta2.Hash)
 	})
 }