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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve
| Method | Path | Body | Description |
| ------ | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| `GET` | `/repositories/{id}/contents?ref=&path=&include=lastCommit` | — | List one directory level, optionally with each entry's last commit. |
| `GET` | `/repositories/{id}/commits/{sha}` | — | Get metadata for one commit by its full SHA. |
| `GET` | `/repositories/{id}/diff?base=&head=` | — | Compare two refs with per-file metadata and unified patches. |
| `GET` | `/repositories/{id}/blob?ref=&path=&lfs=` | — | Stream one file's raw content. |
| `GET` | `/repositories/{id}/archive?ref=&format=&lfs=&prefix=` | — | Stream a `zip` (default) or `tar.gz` archive of the tree. |
Expand Down Expand Up @@ -200,6 +201,26 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve

`type` is `file` | `dir` | `symlink` | `submodule`. Add `include=lastCommit` to populate the optional `lastCommit` object for every entry.

`GET /commits/{sha}` requires commit SHA and returns the complete commit message and metadata:

```json
{
"data": {
"sha": "9fb037999f264ba9a7fc6274d15fa3ae2ab98312",
"parents": ["7786adb11411791e94b04f5f672e50df2a472a65"],
"message": "Update server configuration",
"author": {
"name": "Alex Developer",
"email": "alex@example.com"
},
"authoredAt": "2026-07-30T18:40:00Z",
"committedAt": "2026-07-30T18:42:00Z"
}
}
```

`parents` is an empty array for a root commit and contains multiple SHAs for a merge commit. A missing commit returns `404 commit_not_found`.

`GET /diff` requires `base` and `head`, each accepting the same ref syntax as the other read endpoints. The all-zero SHA is also accepted on either side as the empty tree, so a ref creation can be diffed as `base=0000...&head=<commit>` and a ref deletion as `base=<commit>&head=0000...`.

```json
Expand Down
11 changes: 11 additions & 0 deletions internal/domain/commit.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
package domain

import "time"

type CommitIdentity struct {
Name string
Email string
}

type CommitDetails struct {
SHA string
Parents []string
Message string
Author CommitIdentity
AuthoredAt time.Time
CommittedAt time.Time
}

type CommitFileOp struct {
Delete bool
Path string
Expand Down
1 change: 1 addition & 0 deletions internal/gitbackend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Backend interface {
ReceivePack(ctx context.Context, storagePath string, stateless bool, hookEnv []string, stdin io.Reader, stdout, stderr io.Writer) ([]RefChange, error)
ListTree(ctx context.Context, storagePath, rev, treePath string, opts ListTreeOptions) (TreeListing, error)
Diff(ctx context.Context, storagePath, base, head string) (DiffResult, error)
GetCommit(ctx context.Context, storagePath, sha string) (CommitDetails, error)
ResolveCommit(ctx context.Context, storagePath, rev string) (string, error)
ArchiveTar(ctx context.Context, storagePath, rev string, out io.Writer) (string, error)
StatBlob(ctx context.Context, storagePath, rev, treePath string) (BlobInfo, error)
Expand Down
76 changes: 76 additions & 0 deletions internal/gitbackend/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,37 @@ func (l *Local) ResolveCommit(ctx context.Context, storagePath, rev string) (str
return commitSHA, nil
}

func (l *Local) GetCommit(ctx context.Context, storagePath, sha string) (CommitDetails, error) {
dir, err := l.resolve(storagePath)
if err != nil {
return CommitDetails{}, err
}
if !isHexSHA(sha) {
return CommitDetails{}, fmt.Errorf("%w: %q", ErrInvalidRev, sha)
}

commitSHA, err := l.revParse(ctx, dir, sha+"^{commit}")
if err != nil {
return CommitDetails{}, fmt.Errorf("%w: %s", ErrRevNotFound, sha)
}

out, err := l.runGitBytes(ctx, dir, nil, nil,
"log", "--no-walk", "-z", "--format=%H%x00%P%x00%an%x00%ae%x00%aI%x00%cI%x00%B", "--end-of-options", commitSHA,
)
if err != nil {
return CommitDetails{}, fmt.Errorf("read commit details: %w", err)
}

details, err := parseCommitDetails(out)
if err != nil {
return CommitDetails{}, err
}
if details.SHA != commitSHA {
return CommitDetails{}, fmt.Errorf("commit metadata mismatch: got %s, want %s", details.SHA, commitSHA)
}
return details, nil
}

func (l *Local) WriteBlob(ctx context.Context, storagePath string, r io.Reader) (string, int64, error) {
dir, err := l.resolve(storagePath)
if err != nil {
Expand Down Expand Up @@ -904,6 +935,51 @@ func parseCommitSummaries(out []byte) (map[string]CommitSummary, error) {
return commits, nil
}

func parseCommitDetails(out []byte) (CommitDetails, error) {
fields := bytes.Split(out, []byte{0})
if len(fields) > 0 && len(fields[len(fields)-1]) == 0 {
fields = fields[:len(fields)-1]
}
if len(fields) != 7 {
return CommitDetails{}, fmt.Errorf("malformed git log output: got %d fields", len(fields))
}

sha := string(fields[0])
if !isHexSHA(sha) {
return CommitDetails{}, fmt.Errorf("malformed commit sha %q", sha)
}

parents := make([]string, 0)
for parent := range strings.FieldsSeq(string(fields[1])) {
if !isHexSHA(parent) {
return CommitDetails{}, fmt.Errorf("malformed parent sha %q", parent)
}
parents = append(parents, parent)
}

authoredAt, err := time.Parse(time.RFC3339, string(fields[4]))
if err != nil {
return CommitDetails{}, fmt.Errorf("malformed author time %q: %w", fields[4], err)
}

committedAt, err := time.Parse(time.RFC3339, string(fields[5]))
if err != nil {
return CommitDetails{}, fmt.Errorf("malformed commit time %q: %w", fields[5], err)
}

return CommitDetails{
SHA: sha,
Parents: parents,
Message: strings.TrimSuffix(string(fields[6]), "\n"),
Author: Identity{
Name: string(fields[2]),
Email: string(fields[3]),
},
AuthoredAt: authoredAt.UTC(),
CommittedAt: committedAt.UTC(),
}, nil
}

// normalizeRev validates an untrusted revision expression; empty means HEAD
func normalizeRev(rev string) (string, error) {
if rev == "" {
Expand Down
117 changes: 117 additions & 0 deletions internal/gitbackend/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,38 @@ func TestParseCommitSummaries(t *testing.T) {
}
}

func TestParseCommitDetails(t *testing.T) {
sha := strings.Repeat("a", 40)
firstParent := strings.Repeat("b", 40)
secondParent := strings.Repeat("c", 40)
out := []byte(sha + "\x00" + firstParent + " " + secondParent +
"\x00Alex Developer\x00alex@example.com" +
"\x002026-07-30T11:40:00-07:00\x002026-07-30T11:42:00-07:00" +
"\x00Update server configuration\n\nKeep the full message.\x00")

got, err := parseCommitDetails(out)
if err != nil {
t.Fatal(err)
}
if got.SHA != sha || len(got.Parents) != 2 || got.Parents[0] != firstParent || got.Parents[1] != secondParent {
t.Errorf("commit identity = %+v", got)
}
if got.Message != "Update server configuration\n\nKeep the full message." ||
got.Author.Name != "Alex Developer" || got.Author.Email != "alex@example.com" {
t.Errorf("commit metadata = %+v", got)
}
if want := "2026-07-30T18:40:00Z"; got.AuthoredAt.Format(time.RFC3339) != want {
t.Errorf("authoredAt = %s, want %s", got.AuthoredAt.Format(time.RFC3339), want)
}
if want := "2026-07-30T18:42:00Z"; got.CommittedAt.Format(time.RFC3339) != want {
t.Errorf("committedAt = %s, want %s", got.CommittedAt.Format(time.RFC3339), want)
}

if _, err := parseCommitDetails([]byte(sha + "\x00missing fields\x00")); err == nil {
t.Error("malformed output accepted")
}
}

func TestParseDiffOutput(t *testing.T) {
oldSHA := strings.Repeat("a", 40)
newSHA := strings.Repeat("b", 40)
Expand Down Expand Up @@ -489,6 +521,91 @@ func TestListTree(t *testing.T) {
})
}

func TestGetCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
}

root := t.TempDir()
l, err := NewLocal(root)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
const repo = "1/test.git"

if err := l.InitBare(ctx, repo); err != nil {
t.Fatal(err)
}
wt := filepath.Join(t.TempDir(), "wt")
gitRun(t, ".", "clone", filepath.Join(root, repo), wt)

if err := os.WriteFile(filepath.Join(wt, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, wt, "add", "README.md")
gitRun(t, wt, "commit", "-m", "Initial commit", "-m", "With a body.")
rootSHA := gitOut(t, wt, "rev-parse", "HEAD")
baseBranch := gitOut(t, wt, "symbolic-ref", "--short", "HEAD")
gitRun(t, wt, "push", "origin", "HEAD:refs/heads/main")

rootCommit, err := l.GetCommit(ctx, repo, rootSHA)
if err != nil {
t.Fatal(err)
}
if rootCommit.SHA != rootSHA || len(rootCommit.Parents) != 0 || rootCommit.Parents == nil {
t.Errorf("root commit = %+v", rootCommit)
}
if rootCommit.Message != "Initial commit\n\nWith a body." ||
rootCommit.Author.Name != "t" || rootCommit.Author.Email != "t@t" ||
rootCommit.AuthoredAt.IsZero() || rootCommit.CommittedAt.IsZero() {
t.Errorf("root commit metadata = %+v", rootCommit)
}

gitRun(t, wt, "checkout", "-b", "feature")
if err := os.WriteFile(filepath.Join(wt, "feature.txt"), []byte("feature\n"), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, wt, "add", "feature.txt")
gitRun(t, wt, "commit", "-m", "feature")
featureSHA := gitOut(t, wt, "rev-parse", "HEAD")

gitRun(t, wt, "checkout", baseBranch)
if err := os.WriteFile(filepath.Join(wt, "main.txt"), []byte("main\n"), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, wt, "add", "main.txt")
gitRun(t, wt, "commit", "-m", "main")
mainSHA := gitOut(t, wt, "rev-parse", "HEAD")
gitRun(t, wt, "merge", "--no-ff", "feature", "-m", "Merge feature")
mergeSHA := gitOut(t, wt, "rev-parse", "HEAD")
gitRun(t, wt, "push", "origin", "HEAD:refs/heads/main")

mergeCommit, err := l.GetCommit(ctx, repo, mergeSHA)
if err != nil {
t.Fatal(err)
}
if len(mergeCommit.Parents) != 2 || mergeCommit.Parents[0] != mainSHA || mergeCommit.Parents[1] != featureSHA {
t.Errorf("merge parents = %v, want [%s %s]", mergeCommit.Parents, mainSHA, featureSHA)
}

blobSHA := gitOut(t, wt, "rev-parse", "HEAD:README.md")
for _, tc := range []struct {
name, sha string
want error
}{
{"invalid sha", "main", ErrInvalidRev},
{"missing commit", strings.Repeat("f", 40), ErrRevNotFound},
{"blob is not a commit", blobSHA, ErrRevNotFound},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := l.GetCommit(ctx, repo, tc.sha); !errors.Is(err, tc.want) {
t.Errorf("GetCommit(%q) = %v, want %v", tc.sha, err, tc.want)
}
})
}
}

func TestDiff(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
Expand Down
9 changes: 9 additions & 0 deletions internal/gitbackend/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ type CommitSummary struct {
CommittedAt time.Time
}

type CommitDetails struct {
SHA string
Parents []string
Message string
Author Identity
AuthoredAt time.Time
CommittedAt time.Time
}

type TreeEntry struct {
Mode string // "100644", "100755", "040000", "120000", "160000"
Type string // "blob" | "tree" | "commit"
Expand Down
22 changes: 22 additions & 0 deletions internal/server/control/repositories/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,28 @@ import (
"go.uber.org/zap"
)

func (h *handlers) getCommit(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(chi.URLParam(r, "repositoryID"), 10, 64)
if err != nil {
return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid repository id")
}

commit, err := h.service.GetCommit(r.Context(), id, chi.URLParam(r, "sha"))
switch {
case errors.Is(err, reposervice.ErrRepositoryNotFound):
return response.NewError(http.StatusNotFound, response.CodeRepositoryNotFound, "repository not found")
case errors.Is(err, reposervice.ErrCommitNotFound):
return response.NewError(http.StatusNotFound, response.CodeCommitNotFound, "commit not found")
case errors.Is(err, reposervice.ErrInvalidCommitSHA):
return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid commit sha")
case err != nil:
h.logger.Error("failed to get commit", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to get commit")
}

return response.Data(w, http.StatusOK, newCommitDetails(commit))
}

func (h *handlers) createCommit(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(chi.URLParam(r, "repositoryID"), 10, 64)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/server/control/repositories/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type RepositoryManager interface {
ListByOwner(ctx context.Context, ownerID int64) ([]domain.Repository, error)
Contents(ctx context.Context, repositoryID int64, ref, treePath string, opts domain.ContentsOptions) (domain.RepositoryContents, error)
Diff(ctx context.Context, repositoryID int64, base, head string) (domain.RepositoryDiff, error)
GetCommit(ctx context.Context, repositoryID int64, sha string) (domain.CommitDetails, error)
PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool, prefix *string) (domain.ArchiveRequest, error)
StreamArchive(ctx context.Context, req domain.ArchiveRequest, out io.Writer) error
PrepareBlob(ctx context.Context, repositoryID int64, ref, treePath string, includeLFS bool) (domain.BlobRequest, error)
Expand Down Expand Up @@ -48,6 +49,7 @@ func (h *handlers) RegisterRoutes(parent chi.Router) {
r.Get("/by-path/{namespace}/{name}", response.Handler(h.logger, h.getRepositoryByPath))
r.Post("/{repositoryID}/blobs", response.Handler(h.logger, h.uploadBlob))
r.Post("/{repositoryID}/commits", response.Handler(h.logger, h.createCommit))
r.Get("/{repositoryID}/commits/{sha}", response.Handler(h.logger, h.getCommit))
r.Get("/{repositoryID}", response.Handler(h.logger, h.getRepository))
r.Get("/{repositoryID}/contents", response.Handler(h.logger, h.getContents))
r.Get("/{repositoryID}/diff", response.Handler(h.logger, h.getDiff))
Expand Down
Loading
Loading