diff --git a/README.md b/README.md index f45def9..19d1d00 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Every request requires `Authorization: Bearer `. 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. | @@ -200,6 +201,26 @@ Every request requires `Authorization: Bearer `. 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=` and a ref deletion as `base=&head=0000...`. ```json diff --git a/internal/domain/commit.go b/internal/domain/commit.go index 868358e..6498135 100644 --- a/internal/domain/commit.go +++ b/internal/domain/commit.go @@ -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 diff --git a/internal/gitbackend/backend.go b/internal/gitbackend/backend.go index f3aa096..72a1f50 100644 --- a/internal/gitbackend/backend.go +++ b/internal/gitbackend/backend.go @@ -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) diff --git a/internal/gitbackend/local.go b/internal/gitbackend/local.go index e82506b..0825f91 100644 --- a/internal/gitbackend/local.go +++ b/internal/gitbackend/local.go @@ -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 { @@ -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 == "" { diff --git a/internal/gitbackend/local_test.go b/internal/gitbackend/local_test.go index 8deb82e..ec5fda3 100644 --- a/internal/gitbackend/local_test.go +++ b/internal/gitbackend/local_test.go @@ -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) @@ -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") diff --git a/internal/gitbackend/types.go b/internal/gitbackend/types.go index 1422679..463955f 100644 --- a/internal/gitbackend/types.go +++ b/internal/gitbackend/types.go @@ -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" diff --git a/internal/server/control/repositories/commits.go b/internal/server/control/repositories/commits.go index aa8e908..c82bc41 100644 --- a/internal/server/control/repositories/commits.go +++ b/internal/server/control/repositories/commits.go @@ -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 { diff --git a/internal/server/control/repositories/handlers.go b/internal/server/control/repositories/handlers.go index f4bbe9f..2603ff7 100644 --- a/internal/server/control/repositories/handlers.go +++ b/internal/server/control/repositories/handlers.go @@ -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) @@ -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)) diff --git a/internal/server/control/repositories/handlers_test.go b/internal/server/control/repositories/handlers_test.go index 2d275f2..63c57e9 100644 --- a/internal/server/control/repositories/handlers_test.go +++ b/internal/server/control/repositories/handlers_test.go @@ -35,6 +35,11 @@ type fakeManager struct { diffBase string diffHead string + commitDetails domain.CommitDetails + getCommitErr error + getCommitSHA string + getCommitRepoID int64 + prepareReq domain.ArchiveRequest prepareErr error prefix string @@ -79,6 +84,12 @@ func (f *fakeManager) Diff(ctx context.Context, repositoryID int64, base, head s return f.diffResult, f.diffErr } +func (f *fakeManager) GetCommit(ctx context.Context, repositoryID int64, sha string) (domain.CommitDetails, error) { + f.getCommitRepoID = repositoryID + f.getCommitSHA = sha + return f.commitDetails, f.getCommitErr +} + func (f *fakeManager) Create(ctx context.Context, ownerID int64, info domain.RepositoryInfo) (domain.Repository, error) { return f.createdRepo, f.createErr } @@ -422,6 +433,101 @@ func TestGetDiffErrors(t *testing.T) { } } +func TestGetCommit(t *testing.T) { + authoredAt := time.Date(2026, 7, 30, 18, 40, 0, 0, time.UTC) + committedAt := time.Date(2026, 7, 30, 18, 42, 0, 0, time.UTC) + parentSHA := strings.Repeat("b", 40) + svc := &fakeManager{commitDetails: domain.CommitDetails{ + SHA: testSHA, + Parents: []string{parentSHA}, + Message: "Update server configuration\n\nFull message.", + Author: domain.CommitIdentity{Name: "Alex Developer", Email: "alex@example.com"}, + AuthoredAt: authoredAt, + CommittedAt: committedAt, + }} + + rec := httptest.NewRecorder() + newTestRouter(svc).ServeHTTP(rec, httptest.NewRequest( + http.MethodGet, + "/repositories/7/commits/"+testSHA, + nil, + )) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if svc.getCommitRepoID != 7 || svc.getCommitSHA != testSHA { + t.Errorf("GetCommit args = %d, %q", svc.getCommitRepoID, svc.getCommitSHA) + } + + var body struct { + Data CommitDetails `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + got := body.Data + if got.SHA != testSHA || len(got.Parents) != 1 || got.Parents[0] != parentSHA || + got.Message != svc.commitDetails.Message || got.Author.Name != "Alex Developer" || got.Author.Email != "alex@example.com" || + !got.AuthoredAt.Equal(authoredAt) || !got.CommittedAt.Equal(committedAt) { + t.Errorf("commit = %+v", got) + } +} + +func TestGetRootCommitParentsIsArray(t *testing.T) { + rec := httptest.NewRecorder() + newTestRouter(&fakeManager{commitDetails: domain.CommitDetails{ + SHA: testSHA, + Parents: []string{}, + }}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/repositories/7/commits/"+testSHA, nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"parents":[]`) { + t.Errorf("parents must be an array: %s", rec.Body.String()) + } +} + +func TestGetCommitErrors(t *testing.T) { + cases := []struct { + name string + target string + serviceErr error + wantStatus int + wantCode string + }{ + {"bad id", "/repositories/nope/commits/" + testSHA, nil, http.StatusBadRequest, "invalid_request"}, + {"invalid sha", "/repositories/7/commits/nope", reposervice.ErrInvalidCommitSHA, http.StatusBadRequest, "invalid_request"}, + {"repository not found", "/repositories/7/commits/" + testSHA, reposervice.ErrRepositoryNotFound, http.StatusNotFound, "repository_not_found"}, + {"commit not found", "/repositories/7/commits/" + testSHA, reposervice.ErrCommitNotFound, http.StatusNotFound, "commit_not_found"}, + {"internal", "/repositories/7/commits/" + testSHA, io.ErrUnexpectedEOF, http.StatusInternalServerError, "internal_error"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + newTestRouter(&fakeManager{getCommitErr: tc.serviceErr}).ServeHTTP( + rec, + httptest.NewRequest(http.MethodGet, tc.target, nil), + ) + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body.String()) + } + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Error.Code != tc.wantCode { + t.Errorf("code = %q, want %q", body.Error.Code, tc.wantCode) + } + }) + } +} + func TestGetArchive(t *testing.T) { router := newTestRouter(&fakeManager{prepareReq: testArchiveRequest(), streamBody: "hello"}) diff --git a/internal/server/control/repositories/types.go b/internal/server/control/repositories/types.go index 8333483..b83641e 100644 --- a/internal/server/control/repositories/types.go +++ b/internal/server/control/repositories/types.go @@ -187,6 +187,29 @@ type CommitAuthor struct { Email string `json:"email"` } +type CommitDetails struct { + SHA string `json:"sha"` + Parents []string `json:"parents"` + Message string `json:"message"` + Author CommitAuthor `json:"author"` + AuthoredAt time.Time `json:"authoredAt"` + CommittedAt time.Time `json:"committedAt"` +} + +func newCommitDetails(commit domain.CommitDetails) CommitDetails { + return CommitDetails{ + SHA: commit.SHA, + Parents: commit.Parents, + Message: commit.Message, + Author: CommitAuthor{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + AuthoredAt: commit.AuthoredAt, + CommittedAt: commit.CommittedAt, + } +} + type CommitOperation struct { Op string `json:"op"` // "put" | "delete" Path string `json:"path"` diff --git a/internal/server/response/codes.go b/internal/server/response/codes.go index 4c1eed7..9a078d4 100644 --- a/internal/server/response/codes.go +++ b/internal/server/response/codes.go @@ -11,6 +11,7 @@ const ( CodeSSHKeyNotFound = "ssh_key_not_found" CodeTokenNotFound = "token_not_found" CodeRefNotFound = "ref_not_found" + CodeCommitNotFound = "commit_not_found" CodePathNotFound = "path_not_found" CodeLFSObjectNotFound = "lfs_object_not_found" diff --git a/internal/services/repositories/errors.go b/internal/services/repositories/errors.go index 54c3868..66e4195 100644 --- a/internal/services/repositories/errors.go +++ b/internal/services/repositories/errors.go @@ -13,6 +13,9 @@ var ( ErrInvalidRef = errors.New("invalid ref") ErrInvalidPath = errors.New("invalid path") + ErrCommitNotFound = errors.New("commit not found") + ErrInvalidCommitSHA = errors.New("invalid commit sha") + ErrUnsupportedFormat = errors.New("unsupported archive format") ErrInvalidArchivePrefix = errors.New("invalid archive prefix") ErrLFSNotEnabled = errors.New("lfs is not enabled") diff --git a/internal/services/repositories/service.go b/internal/services/repositories/service.go index 875dbd6..210b37b 100644 --- a/internal/services/repositories/service.go +++ b/internal/services/repositories/service.go @@ -38,6 +38,7 @@ type RepositoryStorage interface { Remove(ctx context.Context, storagePath string) error ListTree(ctx context.Context, storagePath, rev, treePath string, opts gitbackend.ListTreeOptions) (gitbackend.TreeListing, error) Diff(ctx context.Context, storagePath, base, head string) (gitbackend.DiffResult, error) + GetCommit(ctx context.Context, storagePath, sha string) (gitbackend.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) (gitbackend.BlobInfo, error) @@ -260,6 +261,27 @@ func (s *Service) Diff(ctx context.Context, repositoryID int64, base, head strin return toDiff(diff), nil } +func (s *Service) GetCommit(ctx context.Context, repositoryID int64, sha string) (domain.CommitDetails, error) { + repo, err := s.registry.GetRepository(ctx, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return domain.CommitDetails{}, ErrRepositoryNotFound + } + if err != nil { + return domain.CommitDetails{}, err + } + + commit, err := s.storage.GetCommit(ctx, repo.StoragePath, sha) + switch { + case errors.Is(err, gitbackend.ErrInvalidRev): + return domain.CommitDetails{}, ErrInvalidCommitSHA + case errors.Is(err, gitbackend.ErrRevNotFound): + return domain.CommitDetails{}, ErrCommitNotFound + case err != nil: + return domain.CommitDetails{}, err + } + return toCommitDetails(commit), nil +} + func (s *Service) GetRepositoryByPath(ctx context.Context, namespace, name string) (domain.Repository, error) { repo, err := s.registry.GetRepositoryByPath(ctx, namespace, name) if errors.Is(err, sql.ErrNoRows) { @@ -728,6 +750,20 @@ func toContents(ref, treePath string, listing gitbackend.TreeListing) domain.Rep } } +func toCommitDetails(commit gitbackend.CommitDetails) domain.CommitDetails { + return domain.CommitDetails{ + SHA: commit.SHA, + Parents: commit.Parents, + Message: commit.Message, + Author: domain.CommitIdentity{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + AuthoredAt: commit.AuthoredAt, + CommittedAt: commit.CommittedAt, + } +} + func toDiff(diff gitbackend.DiffResult) domain.RepositoryDiff { files := make([]domain.DiffFile, len(diff.Files)) for i, file := range diff.Files { diff --git a/internal/services/repositories/service_test.go b/internal/services/repositories/service_test.go index 1144027..d6c0164 100644 --- a/internal/services/repositories/service_test.go +++ b/internal/services/repositories/service_test.go @@ -77,6 +77,10 @@ type fakeStorage struct { diffResult gitbackend.DiffResult diffErr error + commitDetails gitbackend.CommitDetails + commitErr error + commitFn func(storagePath, sha string) + blobInfo gitbackend.BlobInfo blobStatErr error blobContent string @@ -110,6 +114,13 @@ func (f fakeStorage) Diff(ctx context.Context, storagePath, base, head string) ( return f.diffResult, f.diffErr } +func (f fakeStorage) GetCommit(ctx context.Context, storagePath, sha string) (gitbackend.CommitDetails, error) { + if f.commitFn != nil { + f.commitFn(storagePath, sha) + } + return f.commitDetails, f.commitErr +} + func (f fakeStorage) ArchiveTar(ctx context.Context, storagePath, rev string, out io.Writer) (string, error) { if _, err := out.Write(f.tarBytes); err != nil { return "", err @@ -348,6 +359,70 @@ func TestDiff(t *testing.T) { } } +func TestGetCommit(t *testing.T) { + row := gen.Repository{ID: 7, RepositoryName: "myrepo", StoragePath: "7/myrepo.git", Visibility: "private"} + authoredAt := time.Date(2026, 7, 30, 18, 40, 0, 0, time.UTC) + committedAt := time.Date(2026, 7, 30, 18, 42, 0, 0, time.UTC) + parentSHA := strings.Repeat("b", 40) + details := gitbackend.CommitDetails{ + SHA: testSHA, + Parents: []string{parentSHA}, + Message: "Update server configuration", + Author: gitbackend.Identity{Name: "Alex Developer", Email: "alex@example.com"}, + AuthoredAt: authoredAt, + CommittedAt: committedAt, + } + + var called bool + storage := fakeStorage{ + commitDetails: details, + commitFn: func(storagePath, sha string) { + called = true + if storagePath != row.StoragePath || sha != testSHA { + t.Errorf("GetCommit(%q, %q)", storagePath, sha) + } + }, + } + svc := NewService(zap.NewNop(), fakeRegistry{repo: row}, storage, nil, nil) + got, err := svc.GetCommit(context.Background(), row.ID, testSHA) + if err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("GetCommit was not called") + } + if got.SHA != testSHA || len(got.Parents) != 1 || got.Parents[0] != parentSHA || + got.Message != details.Message || got.Author.Name != details.Author.Name || got.Author.Email != details.Author.Email || + !got.AuthoredAt.Equal(authoredAt) || !got.CommittedAt.Equal(committedAt) { + t.Errorf("commit = %+v", got) + } + + for _, tc := range []struct { + name string + regErr error + commitErr error + want error + }{ + {"repository not found", sql.ErrNoRows, nil, ErrRepositoryNotFound}, + {"invalid sha", nil, gitbackend.ErrInvalidRev, ErrInvalidCommitSHA}, + {"commit not found", nil, gitbackend.ErrRevNotFound, ErrCommitNotFound}, + {"backend failure", nil, io.ErrUnexpectedEOF, io.ErrUnexpectedEOF}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := NewService( + zap.NewNop(), + fakeRegistry{repo: row, err: tc.regErr}, + fakeStorage{commitErr: tc.commitErr}, + nil, + nil, + ) + if _, err := svc.GetCommit(context.Background(), row.ID, testSHA); !errors.Is(err, tc.want) { + t.Errorf("GetCommit error = %v, want %v", err, tc.want) + } + }) + } +} + func TestPrepareArchive(t *testing.T) { row := gen.Repository{ID: 7, RepositoryName: "myrepo", StoragePath: "7/myrepo.git", Visibility: "private"} customPrefix := "release/source"