From 587d13c0d21565de1d6aa39cfb50df0fcc49229e Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Thu, 30 Jul 2026 21:13:24 -0700 Subject: [PATCH] Include lastCommit & /diff route for diff between base and head --- Dockerfile | 5 +- README.md | 113 +++-- internal/domain/contents.go | 66 ++- internal/gitbackend/backend.go | 3 +- internal/gitbackend/local.go | 132 +++++- internal/gitbackend/local_diff.go | 422 ++++++++++++++++++ internal/gitbackend/local_test.go | 393 +++++++++++++++- internal/gitbackend/types.go | 73 ++- .../server/control/repositories/contents.go | 11 +- internal/server/control/repositories/diff.go | 39 ++ .../server/control/repositories/handlers.go | 4 +- .../control/repositories/handlers_test.go | 276 ++++++++++++ internal/server/control/repositories/types.go | 77 +++- internal/services/repositories/service.go | 63 ++- .../services/repositories/service_test.go | 154 +++++++ 15 files changed, 1759 insertions(+), 72 deletions(-) create mode 100644 internal/gitbackend/local_diff.go create mode 100644 internal/server/control/repositories/diff.go diff --git a/Dockerfile b/Dockerfile index 1f064e8..9c97ecc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,13 @@ COPY . . ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/headlessgit ./cmd/app -FROM alpine:3.20 +FROM alpine:3.23 # git provides git-upload-pack / git-receive-pack, which the server shells out to # for both SSH and smart HTTP. No git-daemon/git-http-backend needed: the HTTP # transport frames the smart protocol itself. No openssh: the SSH server is built in. -RUN apk add --no-cache git ca-certificates +RUN apk add --no-cache git ca-certificates \ + && git help -a | grep -q last-modified # Bare repos arrive via a bind mount owned by the host UID; allow git to use # them regardless of owner. diff --git a/README.md b/README.md index 2764f50..f45def9 100644 --- a/README.md +++ b/README.md @@ -116,48 +116,49 @@ Every request requires `Authorization: Bearer `. Responses are enve **Accounts & credentials** -| Method | Path | Body | Description | -| -------- | ------------------------------ | -------------------- | ------------------------------------------------------------ | -| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`); `409 user_exists` if the username is taken. | -| `GET` | `/users/{id}` | — | Get an account. | -| `GET` | `/users/by-username/{username}` | — | Look up an account by username (name -> id resolution). | -| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. | -| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. | -| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. | -| `DELETE` | `/users/{id}/ssh-keys/{keyId}` | — | Revoke an SSH key. | -| `POST` | `/users/{id}/tokens` | `{title}` | Mint a token; the raw value is returned **once**. | -| `GET` | `/users/{id}/tokens` | — | List the account's tokens (never the secret). | -| `DELETE` | `/users/{id}/tokens/{tokenId}` | — | Revoke a single token. | -| `DELETE` | `/users/{id}/tokens` | — | Revoke **all** of the account's tokens. | +| Method | Path | Body | Description | +| -------- | ------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- | +| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`); `409 user_exists` if the username is taken. | +| `GET` | `/users/{id}` | — | Get an account. | +| `GET` | `/users/by-username/{username}` | — | Look up an account by username (name -> id resolution). | +| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. | +| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. | +| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. | +| `DELETE` | `/users/{id}/ssh-keys/{keyId}` | — | Revoke an SSH key. | +| `POST` | `/users/{id}/tokens` | `{title}` | Mint a token; the raw value is returned **once**. | +| `GET` | `/users/{id}/tokens` | — | List the account's tokens (never the secret). | +| `DELETE` | `/users/{id}/tokens/{tokenId}` | — | Revoke a single token. | +| `DELETE` | `/users/{id}/tokens` | — | Revoke **all** of the account's tokens. | **Repositories & permissions** -| Method | Path | Body | Description | -| -------- | --------------------------------------------- | ----------------------------- | ----------------------------------------------------------------- | -| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`); `409 repository_exists` if the owner already has one with that name. | -| `GET` | `/repositories/{id}` | — | Get repository metadata. | -| `GET` | `/repositories/by-path/{namespace}/{name}` | — | Look up a repository by owner username + name (name -> id resolution). | -| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). | -| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). | -| `GET` | `/repositories/{id}/permissions` | — | List collaborators. | -| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). | -| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. | +| Method | Path | Body | Description | +| -------- | --------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`); `409 repository_exists` if the owner already has one with that name. | +| `GET` | `/repositories/{id}` | — | Get repository metadata. | +| `GET` | `/repositories/by-path/{namespace}/{name}` | — | Look up a repository by owner username + name (name -> id resolution). | +| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). | +| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). | +| `GET` | `/repositories/{id}/permissions` | — | List collaborators. | +| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). | +| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. | | `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. `409 webhook_exists` if the URL is already registered on the repo. | -| `GET` | `/repositories/{id}/webhooks` | — | List the repository's webhooks (never the secret). | -| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. | -| `GET` | `/repositories/{id}/path-policies` | — | List the repository's path policies. | -| `POST` | `/repositories/{id}/path-policies` | `{pattern, reason?}` | Block a path; see [Path policies](#path-policies). | -| `DELETE` | `/repositories/{id}/path-policies/{policyId}` | — | Remove a policy. | +| `GET` | `/repositories/{id}/webhooks` | — | List the repository's webhooks (never the secret). | +| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. | +| `GET` | `/repositories/{id}/path-policies` | — | List the repository's path policies. | +| `POST` | `/repositories/{id}/path-policies` | `{pattern, reason?}` | Block a path; see [Path policies](#path-policies). | +| `DELETE` | `/repositories/{id}/path-policies/{policyId}` | — | Remove a policy. | **Repository contents & commits** -| Method | Path | Body | Description | -| ------ | ---------------------------------------------- | ----------- | ---------------------------------------------------------------------- | -| `GET` | `/repositories/{id}/contents?ref=&path=` | — | List one directory level of the tree at a ref. | -| `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. | -| `POST` | `/repositories/{id}/blobs` | _raw bytes_ | Upload content into the repo's object database; returns `{sha, size}`. | -| `POST` | `/repositories/{id}/commits` | JSON | Create a commit on a branch from uploaded blobs. | +| 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}/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. | +| `POST` | `/repositories/{id}/blobs` | _raw bytes_ | Upload content into the repo's object database; returns `{sha, size}`. | +| `POST` | `/repositories/{id}/commits` | JSON | Create a commit on a branch from uploaded blobs. | ### Reading a repository @@ -178,7 +179,12 @@ Every request requires `Authorization: Bearer `. Responses are enve "type": "file", "mode": "100644", "size": 1234, - "sha": "..." + "sha": "...", + "lastCommit": { + "sha": "7786adb...", + "message": "Change server difficulty", + "committedAt": "2026-07-30T18:42:00Z" + } }, { "name": "vendor", @@ -192,7 +198,38 @@ Every request requires `Authorization: Bearer `. Responses are enve } ``` -`type` is `file` | `dir` | `symlink` | `submodule`. Listings over 10k entries set `"truncated": true`. +`type` is `file` | `dir` | `symlink` | `submodule`. Add `include=lastCommit` to populate the optional `lastCommit` object for every entry. + +`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 +{ + "data": { + "baseSha": "...", + "headSha": "...", + "files": [ + { + "status": "renamed", + "oldPath": "config/default.properties", + "newPath": "config/server.properties", + "oldBlobSha": "...", + "newBlobSha": "...", + "oldMode": "100644", + "newMode": "100644", + "additions": 2, + "deletions": 1, + "binary": false, + "patch": "diff --git a/config/default.properties b/config/server.properties\n...\n@@ -1,3 +1,4 @@\n ...\n" + } + ], + "truncated": false + } +} +``` + +`patch` and `binary` are always present. Binary files return `null` for `patch`, `additions`, and `deletions`, with `"patchOmittedReason": "binary"`. A patch larger than 1 MiB, or one that would take the response over its 10 MiB patch budget, is omitted completely with `"patchOmittedReason": "too_large"`—the API never returns a partially cut patch. Non-UTF-8 patches use `"unsupported_encoding"`. Diffs over 10k files set `"truncated": true` and omit patches as `"too_large"`. + +The patch is intended for normal unified-diff renderers. Consumers that need complete old and new file bodies can fetch them through `/blob` using `base + oldPath` and `head + newPath`. `GET /blob` streams the file bytes with `Content-Length`, a strong `ETag` (the blob sha — content-addressed, so `If-None-Match` caching works perfectly), and `X-HeadlessGit-Commit` carrying the resolved commit. With `lfs=true`, an LFS pointer file is replaced by the real object; a missing object is a `404` rather than silently serving the pointer. @@ -319,6 +356,8 @@ The secret is generated server-side and shown **once** in the registration respo ## Development +Git 2.52 or newer is required for `include=lastCommit`. The container image bundles a compatible Git version. + ```sh ./dev.sh up # build and run the stack (docker compose) ./dev.sh gen # regenerate sqlc code diff --git a/internal/domain/contents.go b/internal/domain/contents.go index cf37056..7f9f3a6 100644 --- a/internal/domain/contents.go +++ b/internal/domain/contents.go @@ -1,5 +1,7 @@ package domain +import "time" + type TreeEntryType string const ( @@ -23,12 +25,23 @@ func TreeEntryTypeFromMode(mode string) TreeEntryType { } type TreeEntry struct { - Name string - Path string // full path from the repo root - Type TreeEntryType - Mode string - SHA string - Size int64 // object size in bytes, -1 for non-blobs (dirs, submodules) + Name string + Path string // full path from the repo root + Type TreeEntryType + Mode string + SHA string + Size int64 // object size in bytes, -1 for non-blobs (dirs, submodules) + LastCommit *CommitSummary +} + +type CommitSummary struct { + SHA string + Message string + CommittedAt time.Time +} + +type ContentsOptions struct { + IncludeLastCommit bool } type RepositoryContents struct { @@ -38,3 +51,44 @@ type RepositoryContents struct { Entries []TreeEntry Truncated bool } + +type DiffStatus string + +const ( + DiffAdded DiffStatus = "added" + DiffModified DiffStatus = "modified" + DiffDeleted DiffStatus = "deleted" + DiffRenamed DiffStatus = "renamed" + DiffCopied DiffStatus = "copied" + DiffTypeChanged DiffStatus = "type_changed" +) + +type DiffPatchOmittedReason string + +const ( + DiffPatchBinary DiffPatchOmittedReason = "binary" + DiffPatchTooLarge DiffPatchOmittedReason = "too_large" + DiffPatchUnsupportedEncoding DiffPatchOmittedReason = "unsupported_encoding" +) + +type DiffFile struct { + Status DiffStatus + OldPath string + NewPath string + OldBlobSHA string + NewBlobSHA string + OldMode string + NewMode string + Additions int64 + Deletions int64 + Binary bool + Patch *string + PatchOmittedReason DiffPatchOmittedReason +} + +type RepositoryDiff struct { + BaseSHA string + HeadSHA string + Files []DiffFile + Truncated bool +} diff --git a/internal/gitbackend/backend.go b/internal/gitbackend/backend.go index 5fd4dd6..f3aa096 100644 --- a/internal/gitbackend/backend.go +++ b/internal/gitbackend/backend.go @@ -12,7 +12,8 @@ type Backend interface { AdvertiseRefs(ctx context.Context, storagePath string, svc Service, stdout io.Writer) error UploadPack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error 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) (TreeListing, error) + ListTree(ctx context.Context, storagePath, rev, treePath string, opts ListTreeOptions) (TreeListing, error) + Diff(ctx context.Context, storagePath, base, head string) (DiffResult, 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 062aa60..e82506b 100644 --- a/internal/gitbackend/local.go +++ b/internal/gitbackend/local.go @@ -205,7 +205,7 @@ func (l *Local) listRefs(ctx context.Context, storagePath string) (map[string]st return refs, nil } -func (l *Local) ListTree(ctx context.Context, storagePath, rev, treePath string) (TreeListing, error) { +func (l *Local) ListTree(ctx context.Context, storagePath, rev, treePath string, opts ListTreeOptions) (TreeListing, error) { dir, err := l.resolve(storagePath) if err != nil { return TreeListing{}, err @@ -226,19 +226,82 @@ func (l *Local) ListTree(ctx context.Context, storagePath, rev, treePath string) treeish += ":" + treePath } - out, err := l.runGit(ctx, dir, nil, nil, "ls-tree", "--long", "-z", "--end-of-options", treeish) + out, err := l.runGitBytes(ctx, dir, nil, nil, "ls-tree", "--long", "-z", "--end-of-options", treeish) if err != nil { // the rev already resolved, so this is a missing path or a non-directory return TreeListing{}, fmt.Errorf("%w: %q", ErrPathNotFound, treePath) } - entries, truncated, err := parseLsTree([]byte(out), treePath) + entries, truncated, err := parseLsTree(out, treePath) if err != nil { return TreeListing{}, err } + if opts.IncludeLastCommit && len(entries) > 0 { + if err := l.addLastCommits(ctx, dir, commitSHA, treePath, entries); err != nil { + return TreeListing{}, err + } + } return TreeListing{CommitSHA: commitSHA, Entries: entries, Truncated: truncated}, nil } +func (l *Local) addLastCommits(ctx context.Context, dir, commitSHA, treePath string, entries []TreeEntry) error { + args := []string{"last-modified", "--show-trees", "-z"} + if treePath == "" { + args = append(args, "--max-depth=0", commitSHA) + } else { + args = append(args, "--max-depth=1", commitSHA, "--", ":(top,literal)"+treePath) + } + + out, err := l.runGitBytes(ctx, dir, nil, nil, args...) + if err != nil { + return fmt.Errorf("list last-modified commits: %w", err) + } + + byPath, err := parseLastModified(out) + if err != nil { + return err + } + + var in strings.Builder + seen := make(map[string]bool) + entrySHAs := make([]string, len(entries)) + for i, entry := range entries { + sha, ok := byPath[entry.Path] + if !ok { + return fmt.Errorf("last-modified output missing path %q", entry.Path) + } + + entrySHAs[i] = sha + if !seen[sha] { + in.WriteString(sha) + in.WriteByte('\n') + seen[sha] = true + } + } + + out, err = l.runGitBytes(ctx, dir, nil, strings.NewReader(in.String()), + "log", "--no-walk=unsorted", "--stdin", "-z", "--format=%H%x00%s%x00%cI", + ) + if err != nil { + return fmt.Errorf("read last-modified commits: %w", err) + } + + commits, err := parseCommitSummaries(out) + if err != nil { + return err + } + + for i, sha := range entrySHAs { + commit, ok := commits[sha] + if !ok { + return fmt.Errorf("commit metadata missing for %s", sha) + } + entries[i].LastCommit = &commit + } + + return nil +} + // streams an uncompressed tar archive of the repo tree, // the tar entries carries LFS pointers files as-is, smudging is a service concern! func (l *Local) ArchiveTar(ctx context.Context, storagePath, rev string, out io.Writer) (string, error) { @@ -718,6 +781,15 @@ func (l *Local) updateIndex(ctx context.Context, dir string, env []string, ops [ // runGit executes one short-lived git command with the repo as context, // applying the standard timeout, and returns its trimmed stdout func (l *Local) runGit(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) (string, error) { + out, err := l.runGitBytes(ctx, dir, env, stdin, args...) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// runGitBytes is the raw-output variant for NUL-delimited git protocols +func (l *Local) runGitBytes(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, l.timeout) defer cancel() @@ -730,9 +802,9 @@ func (l *Local) runGit(ctx context.Context, dir string, env []string, stdin io.R cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return "", fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(stderr.String())) + return nil, fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(stderr.String())) } - return strings.TrimSpace(out.String()), nil + return out.Bytes(), nil } // revParse resolves a rev expression to an object id, failing when the object does not exist @@ -782,6 +854,56 @@ func parseLsTree(out []byte, treePath string) ([]TreeEntry, bool, error) { return entries, false, nil } +func parseLastModified(out []byte) (map[string]string, error) { + commits := make(map[string]string) + for record := range bytes.SplitSeq(out, []byte{0}) { + if len(record) == 0 { + continue + } + + sha, filePath, ok := bytes.Cut(record, []byte{'\t'}) + if !ok || !isHexSHA(string(sha)) || len(filePath) == 0 { + return nil, fmt.Errorf("malformed last-modified record: %q", record) + } + + commits[string(filePath)] = string(sha) + } + + return commits, nil +} + +func parseCommitSummaries(out []byte) (map[string]CommitSummary, 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)%3 != 0 { + return nil, fmt.Errorf("malformed git log output: got %d fields", len(fields)) + } + + commits := make(map[string]CommitSummary, len(fields)/3) + for i := 0; i < len(fields); i += 3 { + sha := string(fields[i]) + if !isHexSHA(sha) { + return nil, fmt.Errorf("malformed commit sha %q", sha) + } + + committedAt, err := time.Parse(time.RFC3339, string(fields[i+2])) + if err != nil { + return nil, fmt.Errorf("malformed commit time %q: %w", fields[i+2], err) + } + + commits[sha] = CommitSummary{ + SHA: sha, + Message: string(fields[i+1]), + CommittedAt: committedAt.UTC(), + } + } + + return commits, nil +} + // normalizeRev validates an untrusted revision expression; empty means HEAD func normalizeRev(rev string) (string, error) { if rev == "" { diff --git a/internal/gitbackend/local_diff.go b/internal/gitbackend/local_diff.go new file mode 100644 index 0000000..c3ba1d2 --- /dev/null +++ b/internal/gitbackend/local_diff.go @@ -0,0 +1,422 @@ +package gitbackend + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + "unicode/utf8" +) + +func (l *Local) Diff(ctx context.Context, storagePath, base, head string) (DiffResult, error) { + dir, err := l.resolve(storagePath) + if err != nil { + return DiffResult{}, err + } + + emptyTreeSHA := "" + if base == zeroSHA || head == zeroSHA { + emptyTreeSHA, err = l.runGit(ctx, dir, nil, strings.NewReader(""), "hash-object", "-t", "tree", "--stdin") + if err != nil { + return DiffResult{}, fmt.Errorf("resolve empty tree: %w", err) + } + if !isHexSHA(emptyTreeSHA) { + return DiffResult{}, fmt.Errorf("git returned invalid empty tree sha %q", emptyTreeSHA) + } + } + + baseSHA, baseTree, err := l.resolveDiffRevision(ctx, storagePath, base, emptyTreeSHA) + if err != nil { + return DiffResult{}, err + } + + headSHA, headTree, err := l.resolveDiffRevision(ctx, storagePath, head, emptyTreeSHA) + if err != nil { + return DiffResult{}, err + } + + commonArgs := []string{ + "-r", + "--no-commit-id", + "--find-renames", + "--find-copies", + "--no-ext-diff", + "--no-textconv", + } + rawArgs := append([]string{"diff-tree"}, commonArgs...) + rawArgs = append(rawArgs, "--raw", "-z", "--abbrev=40", baseTree, headTree, "--") + out, err := l.runGitBytes(ctx, dir, nil, nil, rawArgs...) + if err != nil { + return DiffResult{}, err + } + + files, truncated, err := parseRawDiff(out) + if err != nil { + return DiffResult{}, err + } + if len(files) == 0 { + return DiffResult{BaseSHA: baseSHA, HeadSHA: headSHA, Files: []DiffFile{}}, nil + } + + statArgs := append([]string{"diff-tree"}, commonArgs...) + statArgs = append(statArgs, "--numstat", "-z", baseTree, headTree, "--") + out, err = l.runGitBytes(ctx, dir, nil, nil, statArgs...) + if err != nil { + return DiffResult{}, err + } + + stats, err := parseNumstat(out, len(files)) + if err != nil { + return DiffResult{}, err + } + + for i := range files { + stat, ok := stats[diffFileKey(files[i])] + if !ok { + return DiffResult{}, fmt.Errorf("numstat output missing file %q", files[i].NewPath) + } + files[i].Additions = stat.additions + files[i].Deletions = stat.deletions + files[i].Binary = stat.binary + } + + if truncated { + for i := range files { + if files[i].Binary { + files[i].PatchOmittedReason = DiffPatchBinary + } else { + files[i].PatchOmittedReason = DiffPatchTooLarge + } + } + } else if err := l.addDiffPatches(ctx, dir, baseTree, headTree, files); err != nil { + return DiffResult{}, err + } + + return DiffResult{ + BaseSHA: baseSHA, + HeadSHA: headSHA, + Files: files, + Truncated: truncated, + }, nil +} + +func (l *Local) resolveDiffRevision(ctx context.Context, storagePath, rev, emptyTreeSHA string) (string, string, error) { + if rev == zeroSHA { + return zeroSHA, emptyTreeSHA, nil + } + + commitSHA, err := l.ResolveCommit(ctx, storagePath, rev) + if err != nil { + return "", "", err + } + return commitSHA, commitSHA, nil +} + +func (l *Local) addDiffPatches(ctx context.Context, dir, baseTree, headTree string, files []DiffFile) error { + ctx, cancel := context.WithTimeout(ctx, l.timeout) + defer cancel() + + args := []string{ + "-C", + dir, + "diff-tree", + "-r", + "--no-commit-id", + "--patch", + "--full-index", + "--unified=3", + "--no-color", + "--src-prefix=a/", + "--dst-prefix=b/", + "--find-renames", + "--find-copies", + "--no-ext-diff", + "--no-textconv", + baseTree, + headTree, + "--", + } + cmd := exec.CommandContext(ctx, l.gitPath, args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("git diff-tree stdout: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("git diff-tree: %w", err) + } + + collector := newDiffPatchCollector(files) + reader := bufio.NewReaderSize(stdout, 64<<10) + atLineStart := true + var readErr error + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + if atLineStart && bytes.HasPrefix(chunk, []byte("diff --git ")) { + collector.startSection() + } + collector.add(chunk) + atLineStart = chunk[len(chunk)-1] == '\n' + } + + switch err { + case nil: + continue + case bufio.ErrBufferFull: + continue + case io.EOF: + default: + readErr = err + } + break + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("git diff-tree: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if readErr != nil { + return fmt.Errorf("read git diff-tree patch: %w", readErr) + } + if err := collector.finish(); err != nil { + return err + } + return nil +} + +func parseRawDiff(out []byte) ([]DiffFile, bool, error) { + var files []DiffFile + offset := 0 + for offset < len(out) { + header, ok := nextNULRecord(out, &offset) + if !ok { + return nil, false, errors.New("malformed raw diff header") + } + if len(header) == 0 { + continue + } + + fields := strings.Fields(string(header)) + if len(fields) != 5 || !strings.HasPrefix(fields[0], ":") { + return nil, false, fmt.Errorf("malformed raw diff header: %q", header) + } + if !isHexSHA(fields[2]) || !isHexSHA(fields[3]) || fields[4] == "" { + return nil, false, fmt.Errorf("malformed raw diff header: %q", header) + } + + file := DiffFile{ + OldMode: strings.TrimPrefix(fields[0], ":"), + NewMode: fields[1], + OldBlobSHA: fields[2], + NewBlobSHA: fields[3], + } + if file.OldMode == "000000" { + file.OldMode = "" + } + if file.NewMode == "000000" { + file.NewMode = "" + } + if file.OldBlobSHA == zeroSHA { + file.OldBlobSHA = "" + } + if file.NewBlobSHA == zeroSHA { + file.NewBlobSHA = "" + } + + firstPath, ok := nextNULRecord(out, &offset) + if !ok || len(firstPath) == 0 { + return nil, false, errors.New("malformed raw diff path") + } + switch fields[4][0] { + case 'A': + file.Status = DiffAdded + file.NewPath = string(firstPath) + case 'M': + file.Status = DiffModified + file.OldPath = string(firstPath) + file.NewPath = string(firstPath) + case 'D': + file.Status = DiffDeleted + file.OldPath = string(firstPath) + case 'R', 'C': + secondPath, ok := nextNULRecord(out, &offset) + if !ok || len(secondPath) == 0 { + return nil, false, errors.New("malformed rename or copy path") + } + if fields[4][0] == 'R' { + file.Status = DiffRenamed + } else { + file.Status = DiffCopied + } + file.OldPath = string(firstPath) + file.NewPath = string(secondPath) + case 'T': + file.Status = DiffTypeChanged + file.OldPath = string(firstPath) + file.NewPath = string(firstPath) + default: + return nil, false, fmt.Errorf("unsupported diff status %q", fields[4]) + } + + if len(files) == maxDiffEntries { + return files, true, nil + } + files = append(files, file) + } + return files, false, nil +} + +type diffStat struct { + additions int64 + deletions int64 + binary bool +} + +func parseNumstat(out []byte, limit int) (map[string]diffStat, error) { + stats := make(map[string]diffStat, limit) + offset := 0 + for offset < len(out) && len(stats) < limit { + record, ok := nextNULRecord(out, &offset) + if !ok { + return nil, errors.New("malformed numstat record") + } + addRaw, rest, ok := bytes.Cut(record, []byte{'\t'}) + if !ok { + return nil, fmt.Errorf("malformed numstat record: %q", record) + } + deleteRaw, filePath, ok := bytes.Cut(rest, []byte{'\t'}) + if !ok { + return nil, fmt.Errorf("malformed numstat record: %q", record) + } + + key := "p\x00" + string(filePath) + if len(filePath) == 0 { + oldPath, ok := nextNULRecord(out, &offset) + if !ok || len(oldPath) == 0 { + return nil, errors.New("malformed numstat old path") + } + newPath, ok := nextNULRecord(out, &offset) + if !ok || len(newPath) == 0 { + return nil, errors.New("malformed numstat new path") + } + key = "r\x00" + string(oldPath) + "\x00" + string(newPath) + } + + stat := diffStat{} + switch { + case string(addRaw) == "-" && string(deleteRaw) == "-": + stat.binary = true + default: + additions, err := strconv.ParseInt(string(addRaw), 10, 64) + if err != nil { + return nil, fmt.Errorf("malformed addition count %q: %w", addRaw, err) + } + deletions, err := strconv.ParseInt(string(deleteRaw), 10, 64) + if err != nil { + return nil, fmt.Errorf("malformed deletion count %q: %w", deleteRaw, err) + } + stat.additions = additions + stat.deletions = deletions + } + stats[key] = stat + } + return stats, nil +} + +type diffPatchCollector struct { + files []DiffFile + current int + sections int + totalBytes int + currentPatch []byte + tooLarge bool + err error +} + +func newDiffPatchCollector(files []DiffFile) *diffPatchCollector { + return &diffPatchCollector{files: files, current: -1} +} + +func (c *diffPatchCollector) startSection() { + c.finalizeSection() + c.current = c.sections + c.sections++ + c.currentPatch = nil + c.tooLarge = false + if c.current >= len(c.files) && c.err == nil { + c.err = fmt.Errorf("git patch output has more than %d files", len(c.files)) + } +} + +func (c *diffPatchCollector) add(chunk []byte) { + if c.current < 0 || c.current >= len(c.files) || c.files[c.current].Binary || c.tooLarge { + return + } + if len(c.currentPatch)+len(chunk) > maxFilePatchBytes || + c.totalBytes+len(c.currentPatch)+len(chunk) > maxDiffPatchBytes { + c.currentPatch = nil + c.tooLarge = true + return + } + c.currentPatch = append(c.currentPatch, chunk...) +} + +func (c *diffPatchCollector) finalizeSection() { + if c.current < 0 || c.current >= len(c.files) { + return + } + + file := &c.files[c.current] + switch { + case file.Binary: + file.PatchOmittedReason = DiffPatchBinary + case c.tooLarge: + file.PatchOmittedReason = DiffPatchTooLarge + case !utf8.Valid(c.currentPatch): + file.PatchOmittedReason = DiffPatchUnsupportedEncoding + default: + patch := string(c.currentPatch) + file.Patch = &patch + c.totalBytes += len(c.currentPatch) + } +} + +func (c *diffPatchCollector) finish() error { + c.finalizeSection() + if c.err != nil { + return c.err + } + if c.sections != len(c.files) { + return fmt.Errorf("git patch output has %d files, expected %d", c.sections, len(c.files)) + } + return nil +} + +func diffFileKey(file DiffFile) string { + if file.Status == DiffRenamed || file.Status == DiffCopied { + return "r\x00" + file.OldPath + "\x00" + file.NewPath + } + if file.NewPath != "" { + return "p\x00" + file.NewPath + } + return "p\x00" + file.OldPath +} + +func nextNULRecord(out []byte, offset *int) ([]byte, bool) { + if *offset >= len(out) { + return nil, false + } + end := bytes.IndexByte(out[*offset:], 0) + if end < 0 { + return nil, false + } + record := out[*offset : *offset+end] + *offset += end + 1 + return record, true +} diff --git a/internal/gitbackend/local_test.go b/internal/gitbackend/local_test.go index 0946177..8deb82e 100644 --- a/internal/gitbackend/local_test.go +++ b/internal/gitbackend/local_test.go @@ -13,9 +13,9 @@ import ( "sort" "strings" "testing" + "time" "github.com/Axenos-dev/HeadlessGit/internal/domain" - "time" ) // runs a git command in dir with a deterministic identity, failing the test on error @@ -42,6 +42,11 @@ func gitOut(t *testing.T, dir string, args ...string) string { return strings.TrimSpace(string(out)) } +func gitSupportsLastModified() bool { + out, err := exec.Command("git", "help", "-a").Output() + return err == nil && strings.Contains(string(out), "last-modified") +} + func TestResolveContainment(t *testing.T) { root := "/srv/repos" l := &Local{root: root} @@ -177,6 +182,171 @@ func TestParseLsTreeTruncates(t *testing.T) { } } +func TestParseLastModified(t *testing.T) { + firstSHA := strings.Repeat("a", 40) + secondSHA := strings.Repeat("b", 40) + out := []byte(firstSHA + "\tREADME.md\x00" + + secondSHA + "\tsrc/with\ttab\nand newline\x00") + + got, err := parseLastModified(out) + if err != nil { + t.Fatal(err) + } + if got["README.md"] != firstSHA { + t.Errorf("README.md sha = %q", got["README.md"]) + } + if got["src/with\ttab\nand newline"] != secondSHA { + t.Errorf("special path sha = %q", got["src/with\ttab\nand newline"]) + } + + if _, err := parseLastModified([]byte("bad record\x00")); err == nil { + t.Error("malformed output accepted") + } +} + +func TestParseCommitSummaries(t *testing.T) { + sha := strings.Repeat("a", 40) + out := []byte(sha + "\x00Change difficulty\x002026-07-30T11:42:00-07:00\x00") + + got, err := parseCommitSummaries(out) + if err != nil { + t.Fatal(err) + } + commit := got[sha] + if commit.SHA != sha || commit.Message != "Change difficulty" { + t.Errorf("commit = %+v", commit) + } + if want := "2026-07-30T18:42:00Z"; commit.CommittedAt.Format(time.RFC3339) != want { + t.Errorf("committedAt = %s, want %s", commit.CommittedAt.Format(time.RFC3339), want) + } + + if _, err := parseCommitSummaries([]byte(sha + "\x00missing time\x00")); err == nil { + t.Error("malformed output accepted") + } +} + +func TestParseDiffOutput(t *testing.T) { + oldSHA := strings.Repeat("a", 40) + newSHA := strings.Repeat("b", 40) + raw := []byte( + ":000000 100644 " + zeroSHA + " " + newSHA + " A\x00added.txt\x00" + + ":100644 000000 " + oldSHA + " " + zeroSHA + " D\x00deleted.txt\x00" + + ":100644 100644 " + oldSHA + " " + newSHA + " R100\x00old name.txt\x00new name.txt\x00" + + ":100644 100755 " + oldSHA + " " + newSHA + " M\x00script.sh\x00" + + ":100644 120000 " + oldSHA + " " + newSHA + " T\x00link\x00", + ) + + files, truncated, err := parseRawDiff(raw) + if err != nil { + t.Fatal(err) + } + if truncated || len(files) != 5 { + t.Fatalf("files = %d, truncated = %v", len(files), truncated) + } + if files[0].Status != DiffAdded || files[0].OldPath != "" || files[0].NewPath != "added.txt" || files[0].OldBlobSHA != "" { + t.Errorf("added file = %+v", files[0]) + } + if files[1].Status != DiffDeleted || files[1].OldPath != "deleted.txt" || files[1].NewPath != "" || files[1].NewMode != "" { + t.Errorf("deleted file = %+v", files[1]) + } + if files[2].Status != DiffRenamed || files[2].OldPath != "old name.txt" || files[2].NewPath != "new name.txt" { + t.Errorf("renamed file = %+v", files[2]) + } + if files[4].Status != DiffTypeChanged { + t.Errorf("type-changed file = %+v", files[4]) + } + + numstat := []byte( + "1\t0\tadded.txt\x00" + + "0\t1\tdeleted.txt\x00" + + "0\t0\t\x00old name.txt\x00new name.txt\x00" + + "2\t1\tscript.sh\x00" + + "-\t-\tlink\x00", + ) + stats, err := parseNumstat(numstat, len(files)) + if err != nil { + t.Fatal(err) + } + if got := stats[diffFileKey(files[2])]; got.additions != 0 || got.deletions != 0 || got.binary { + t.Errorf("rename stats = %+v", got) + } + if got := stats[diffFileKey(files[3])]; got.additions != 2 || got.deletions != 1 { + t.Errorf("script stats = %+v", got) + } + if got := stats[diffFileKey(files[4])]; !got.binary { + t.Errorf("link stats = %+v, want binary", got) + } +} + +func TestParseRawDiffTruncates(t *testing.T) { + sha := strings.Repeat("a", 40) + var out strings.Builder + for i := 0; i < maxDiffEntries+1; i++ { + fmt.Fprintf( + &out, + ":100644 100644 %s %s M\x00file-%d\x00", + sha, + sha, + i, + ) + } + + files, truncated, err := parseRawDiff([]byte(out.String())) + if err != nil { + t.Fatal(err) + } + if !truncated || len(files) != maxDiffEntries { + t.Errorf("files = %d, truncated = %v", len(files), truncated) + } +} + +func TestDiffPatchCollector(t *testing.T) { + files := []DiffFile{ + {Status: DiffModified, OldPath: "ok.txt", NewPath: "ok.txt"}, + {Status: DiffModified, OldPath: "binary.dat", NewPath: "binary.dat", Binary: true}, + {Status: DiffModified, OldPath: "large.txt", NewPath: "large.txt"}, + {Status: DiffModified, OldPath: "encoding.txt", NewPath: "encoding.txt"}, + } + collector := newDiffPatchCollector(files) + + collector.startSection() + collector.add([]byte("diff --git a/ok.txt b/ok.txt\n@@ -1 +1 @@\n-old\n+new\n")) + collector.startSection() + collector.add([]byte("diff --git a/binary.dat b/binary.dat\nBinary files differ\n")) + collector.startSection() + collector.add(bytes.Repeat([]byte{'x'}, maxFilePatchBytes+1)) + collector.startSection() + collector.add([]byte("diff --git a/encoding.txt b/encoding.txt\n+\xff\n")) + if err := collector.finish(); err != nil { + t.Fatal(err) + } + + if files[0].Patch == nil || !strings.Contains(*files[0].Patch, "+new") || files[0].PatchOmittedReason != "" { + t.Errorf("text patch = %+v", files[0]) + } + if files[1].Patch != nil || files[1].PatchOmittedReason != DiffPatchBinary { + t.Errorf("binary patch = %+v", files[1]) + } + if files[2].Patch != nil || files[2].PatchOmittedReason != DiffPatchTooLarge { + t.Errorf("large patch = %+v", files[2]) + } + if files[3].Patch != nil || files[3].PatchOmittedReason != DiffPatchUnsupportedEncoding { + t.Errorf("unsupported encoding patch = %+v", files[3]) + } + + totalLimited := []DiffFile{{Status: DiffModified, OldPath: "last.txt", NewPath: "last.txt"}} + collector = newDiffPatchCollector(totalLimited) + collector.totalBytes = maxDiffPatchBytes + collector.startSection() + collector.add([]byte("diff --git a/last.txt b/last.txt\n")) + if err := collector.finish(); err != nil { + t.Fatal(err) + } + if totalLimited[0].Patch != nil || totalLimited[0].PatchOmittedReason != DiffPatchTooLarge { + t.Errorf("total-limited patch = %+v", totalLimited[0]) + } +} + // end-to-end against a real git binary, following the repo convention of // skipping when git is not on PATH func TestListTree(t *testing.T) { @@ -196,7 +366,7 @@ func TestListTree(t *testing.T) { } // empty repo: HEAD is unborn - if _, err := l.ListTree(ctx, "1/test.git", "", ""); !errors.Is(err, ErrRevNotFound) { + if _, err := l.ListTree(ctx, "1/test.git", "", "", ListTreeOptions{}); !errors.Is(err, ErrRevNotFound) { t.Fatalf("empty repo: want ErrRevNotFound, got %v", err) } @@ -221,7 +391,7 @@ func TestListTree(t *testing.T) { gitRun(t, wt, "push", "origin", "HEAD:refs/heads/main") t.Run("root listing", func(t *testing.T) { - listing, err := l.ListTree(ctx, "1/test.git", "main", "") + listing, err := l.ListTree(ctx, "1/test.git", "main", "", ListTreeOptions{}) if err != nil { t.Fatal(err) } @@ -243,7 +413,7 @@ func TestListTree(t *testing.T) { }) t.Run("subdir listing", func(t *testing.T) { - listing, err := l.ListTree(ctx, "1/test.git", "main", "src") + listing, err := l.ListTree(ctx, "1/test.git", "main", "src", ListTreeOptions{}) if err != nil { t.Fatal(err) } @@ -271,12 +441,225 @@ func TestListTree(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if _, err := l.ListTree(ctx, "1/test.git", tc.rev, tc.path); !errors.Is(err, tc.want) { + if _, err := l.ListTree(ctx, "1/test.git", tc.rev, tc.path, ListTreeOptions{}); !errors.Is(err, tc.want) { t.Errorf("ListTree(%q, %q) = %v, want %v", tc.rev, tc.path, err, tc.want) } }) } }) + + t.Run("last commit metadata", func(t *testing.T) { + if !gitSupportsLastModified() { + t.Skip("git last-modified requires Git 2.52+") + } + + initialSHA := gitOut(t, wt, "rev-parse", "HEAD") + writeFile("README.md", "hello v2\n", 0o644) + gitRun(t, wt, "add", "README.md") + gitRun(t, wt, "commit", "-m", "update readme") + gitRun(t, wt, "push", "origin", "HEAD:refs/heads/main") + headSHA := gitOut(t, wt, "rev-parse", "HEAD") + + listing, err := l.ListTree(ctx, "1/test.git", "main", "", ListTreeOptions{IncludeLastCommit: true}) + if err != nil { + t.Fatal(err) + } + if len(listing.Entries) != 2 { + t.Fatalf("want 2 entries, got %+v", listing.Entries) + } + if got := listing.Entries[0].LastCommit; got == nil || got.SHA != headSHA || got.Message != "update readme" || got.CommittedAt.IsZero() { + t.Errorf("README.md last commit = %+v", got) + } + if got := listing.Entries[1].LastCommit; got == nil || got.SHA != initialSHA || got.Message != "init" || got.CommittedAt.IsZero() { + t.Errorf("src last commit = %+v", got) + } + + subdir, err := l.ListTree(ctx, "1/test.git", "main", "src", ListTreeOptions{IncludeLastCommit: true}) + if err != nil { + t.Fatal(err) + } + if len(subdir.Entries) != 2 { + t.Fatalf("want 2 src entries, got %+v", subdir.Entries) + } + for _, entry := range subdir.Entries { + if entry.LastCommit == nil || entry.LastCommit.SHA != initialSHA || entry.LastCommit.Message != "init" { + t.Errorf("%s last commit = %+v", entry.Path, entry.LastCommit) + } + } + }) +} + +func TestDiff(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) + for name, body := range map[string]string{ + "keep.txt": "one\n", + "rename.txt": "same\n", + "delete.txt": "gone\n", + "binary.dat": "\x00old", + } { + if err := os.WriteFile(filepath.Join(wt, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + gitRun(t, wt, "add", "-A") + gitRun(t, wt, "commit", "-m", "base") + gitRun(t, wt, "push", "origin", "HEAD:refs/heads/main") + baseSHA := gitOut(t, wt, "rev-parse", "HEAD") + + if err := os.WriteFile(filepath.Join(wt, "keep.txt"), []byte("one\ntwo\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wt, "added.txt"), []byte("new\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wt, "binary.dat"), []byte("\x00new"), 0o644); err != nil { + t.Fatal(err) + } + gitRun(t, wt, "mv", "rename.txt", "moved.txt") + gitRun(t, wt, "rm", "delete.txt") + gitRun(t, wt, "add", "-A") + gitRun(t, wt, "commit", "-m", "head") + gitRun(t, wt, "push", "origin", "HEAD:refs/heads/main") + headSHA := gitOut(t, wt, "rev-parse", "HEAD") + + diff, err := l.Diff(ctx, repo, baseSHA, "main") + if err != nil { + t.Fatal(err) + } + if diff.BaseSHA != baseSHA || diff.HeadSHA != headSHA || diff.Truncated { + t.Errorf("diff metadata = %+v", diff) + } + if len(diff.Files) != 5 { + t.Fatalf("want 5 files, got %+v", diff.Files) + } + + byPath := make(map[string]DiffFile) + for _, file := range diff.Files { + key := file.NewPath + if key == "" { + key = file.OldPath + } + byPath[key] = file + } + if file := byPath["added.txt"]; file.Status != DiffAdded || file.OldBlobSHA != "" || file.NewBlobSHA == "" || file.Additions != 1 { + t.Errorf("added.txt = %+v", file) + } else if file.Patch == nil || !strings.Contains(*file.Patch, "diff --git a/added.txt b/added.txt") || + !strings.Contains(*file.Patch, "+new") { + t.Errorf("added.txt patch = %v", file.Patch) + } + if file := byPath["delete.txt"]; file.Status != DiffDeleted || file.NewBlobSHA != "" || file.Deletions != 1 { + t.Errorf("delete.txt = %+v", file) + } else if file.Patch == nil || !strings.Contains(*file.Patch, "diff --git a/delete.txt b/delete.txt") || + !strings.Contains(*file.Patch, "-gone") { + t.Errorf("delete.txt patch = %v", file.Patch) + } + if file := byPath["moved.txt"]; file.Status != DiffRenamed || file.OldPath != "rename.txt" || file.Additions != 0 || file.Deletions != 0 { + t.Errorf("moved.txt = %+v", file) + } else if file.Patch == nil || !strings.Contains(*file.Patch, "rename from rename.txt") || + !strings.Contains(*file.Patch, "rename to moved.txt") { + t.Errorf("moved.txt patch = %v", file.Patch) + } + if file := byPath["keep.txt"]; file.Status != DiffModified || file.Additions != 1 || file.Deletions != 0 { + t.Errorf("keep.txt = %+v", file) + } else if file.Patch == nil || !strings.Contains(*file.Patch, "@@") || !strings.Contains(*file.Patch, "+two") { + t.Errorf("keep.txt patch = %v", file.Patch) + } + if file := byPath["binary.dat"]; file.Status != DiffModified || !file.Binary || + file.Patch != nil || file.PatchOmittedReason != DiffPatchBinary { + t.Errorf("binary.dat = %+v", file) + } + + same, err := l.Diff(ctx, repo, "main", "main") + if err != nil { + t.Fatal(err) + } + if len(same.Files) != 0 || same.Files == nil { + t.Errorf("same-commit diff = %+v", same) + } + + created, err := l.Diff(ctx, repo, zeroSHA, headSHA) + if err != nil { + t.Fatal(err) + } + if created.BaseSHA != zeroSHA || created.HeadSHA != headSHA || len(created.Files) != 4 { + t.Fatalf("empty-to-head diff = %+v", created) + } + for _, file := range created.Files { + if file.Status != DiffAdded || file.OldPath != "" || file.OldBlobSHA != "" || file.NewPath == "" || file.NewBlobSHA == "" { + t.Errorf("empty-to-head file = %+v", file) + } + } + createdByPath := make(map[string]DiffFile) + for _, file := range created.Files { + createdByPath[file.NewPath] = file + } + if file := createdByPath["added.txt"]; file.Patch == nil || + !strings.Contains(*file.Patch, "new file mode 100644") || + !strings.Contains(*file.Patch, "+new") { + t.Errorf("empty-to-head added.txt patch = %v", file.Patch) + } + + deleted, err := l.Diff(ctx, repo, headSHA, zeroSHA) + if err != nil { + t.Fatal(err) + } + if deleted.BaseSHA != headSHA || deleted.HeadSHA != zeroSHA || len(deleted.Files) != 4 { + t.Fatalf("head-to-empty diff = %+v", deleted) + } + for _, file := range deleted.Files { + if file.Status != DiffDeleted || file.OldPath == "" || file.OldBlobSHA == "" || file.NewPath != "" || file.NewBlobSHA != "" { + t.Errorf("head-to-empty file = %+v", file) + } + } + deletedByPath := make(map[string]DiffFile) + for _, file := range deleted.Files { + deletedByPath[file.OldPath] = file + } + if file := deletedByPath["added.txt"]; file.Patch == nil || + !strings.Contains(*file.Patch, "deleted file mode 100644") || + !strings.Contains(*file.Patch, "-new") { + t.Errorf("head-to-empty added.txt patch = %v", file.Patch) + } + + empty, err := l.Diff(ctx, repo, zeroSHA, zeroSHA) + if err != nil { + t.Fatal(err) + } + if empty.BaseSHA != zeroSHA || empty.HeadSHA != zeroSHA || len(empty.Files) != 0 || empty.Files == nil { + t.Errorf("empty-to-empty diff = %+v", empty) + } + + for _, tc := range []struct { + name, base, head string + want error + }{ + {"missing base", "nope", "main", ErrRevNotFound}, + {"missing head", baseSHA, "nope", ErrRevNotFound}, + {"hostile base", "--help", "main", ErrInvalidRev}, + {"hostile head", baseSHA, "--help", ErrInvalidRev}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := l.Diff(ctx, repo, tc.base, tc.head); !errors.Is(err, tc.want) { + t.Errorf("Diff(%q, %q) = %v, want %v", tc.base, tc.head, err, tc.want) + } + }) + } } func TestArchiveTar(t *testing.T) { diff --git a/internal/gitbackend/types.go b/internal/gitbackend/types.go index 97f349a..1422679 100644 --- a/internal/gitbackend/types.go +++ b/internal/gitbackend/types.go @@ -1,5 +1,7 @@ package gitbackend +import "time" + // the all-zero object id git uses to denote a missing ref // its zero in before, if it was created after // or its zero after, if it was deleted before @@ -8,6 +10,15 @@ const zeroSHA = "0000000000000000000000000000000000000000" // hard cap on entries returned per directory level const maxTreeEntries = 10_000 +// hard cap on files returned by one diff +const maxDiffEntries = 10_000 + +// patch bodies stay bounded independently per file and across the whole response +const ( + maxFilePatchBytes = 1 << 20 + maxDiffPatchBytes = 10 << 20 +) + // hard cap on operations per commit const maxCommitOps = 1000 @@ -32,12 +43,23 @@ type RefChange struct { NewSHA string } +type CommitSummary struct { + SHA string + Message string + CommittedAt time.Time +} + type TreeEntry struct { - Mode string // "100644", "100755", "040000", "120000", "160000" - Type string // "blob" | "tree" | "commit" - SHA string - Size int64 // object size in bytes, -1 where git reports none (trees, submodules) - Path string // full path from the repo root + Mode string // "100644", "100755", "040000", "120000", "160000" + Type string // "blob" | "tree" | "commit" + SHA string + Size int64 // object size in bytes, -1 where git reports none (trees, submodules) + Path string // full path from the repo root + LastCommit *CommitSummary +} + +type ListTreeOptions struct { + IncludeLastCommit bool } type TreeListing struct { @@ -46,6 +68,47 @@ type TreeListing struct { Truncated bool } +type DiffStatus string + +const ( + DiffAdded DiffStatus = "added" + DiffModified DiffStatus = "modified" + DiffDeleted DiffStatus = "deleted" + DiffRenamed DiffStatus = "renamed" + DiffCopied DiffStatus = "copied" + DiffTypeChanged DiffStatus = "type_changed" +) + +type DiffPatchOmittedReason string + +const ( + DiffPatchBinary DiffPatchOmittedReason = "binary" + DiffPatchTooLarge DiffPatchOmittedReason = "too_large" + DiffPatchUnsupportedEncoding DiffPatchOmittedReason = "unsupported_encoding" +) + +type DiffFile struct { + Status DiffStatus + OldPath string + NewPath string + OldBlobSHA string + NewBlobSHA string + OldMode string + NewMode string + Additions int64 + Deletions int64 + Binary bool + Patch *string + PatchOmittedReason DiffPatchOmittedReason +} + +type DiffResult struct { + BaseSHA string + HeadSHA string + Files []DiffFile + Truncated bool +} + type BlobInfo struct { CommitSHA string BlobSHA string diff --git a/internal/server/control/repositories/contents.go b/internal/server/control/repositories/contents.go index 73ea467..80551be 100644 --- a/internal/server/control/repositories/contents.go +++ b/internal/server/control/repositories/contents.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" + "github.com/Axenos-dev/HeadlessGit/internal/domain" "github.com/Axenos-dev/HeadlessGit/internal/server/response" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" "github.com/go-chi/chi/v5" @@ -22,7 +23,15 @@ func (h *handlers) getContents(w http.ResponseWriter, r *http.Request) error { // path defaults to the repo root treePath := r.URL.Query().Get("path") - contents, err := h.service.Contents(r.Context(), id, ref, treePath) + opts := domain.ContentsOptions{} + if values, ok := r.URL.Query()["include"]; ok { + if len(values) != 1 || values[0] != "lastCommit" { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "include must be 'lastCommit'") + } + opts.IncludeLastCommit = true + } + + contents, err := h.service.Contents(r.Context(), id, ref, treePath, opts) switch { case errors.Is(err, reposervice.ErrRepositoryNotFound): return response.NewError(http.StatusNotFound, response.CodeRepositoryNotFound, "repository not found") diff --git a/internal/server/control/repositories/diff.go b/internal/server/control/repositories/diff.go new file mode 100644 index 0000000..6cf9d48 --- /dev/null +++ b/internal/server/control/repositories/diff.go @@ -0,0 +1,39 @@ +package repositories + +import ( + "errors" + "net/http" + "strconv" + + "github.com/Axenos-dev/HeadlessGit/internal/server/response" + reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +func (h *handlers) getDiff(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") + } + + base, head := r.URL.Query().Get("base"), r.URL.Query().Get("head") + if base == "" || head == "" { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "base and head are required") + } + + diff, err := h.service.Diff(r.Context(), id, base, head) + switch { + case errors.Is(err, reposervice.ErrRepositoryNotFound): + return response.NewError(http.StatusNotFound, response.CodeRepositoryNotFound, "repository not found") + case errors.Is(err, reposervice.ErrRefNotFound): + return response.NewError(http.StatusNotFound, response.CodeRefNotFound, "ref not found") + case errors.Is(err, reposervice.ErrInvalidRef): + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid ref") + case err != nil: + h.logger.Error("failed to diff repository", zap.Error(err)) + return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to diff repository") + } + + return response.Data(w, http.StatusOK, newDiff(diff)) +} diff --git a/internal/server/control/repositories/handlers.go b/internal/server/control/repositories/handlers.go index a065c7d..f4bbe9f 100644 --- a/internal/server/control/repositories/handlers.go +++ b/internal/server/control/repositories/handlers.go @@ -17,7 +17,8 @@ type RepositoryManager interface { Delete(ctx context.Context, repositoryID int64) error SetVisibility(ctx context.Context, repositoryID int64, visibility domain.RepoVisibility) (domain.Repository, error) ListByOwner(ctx context.Context, ownerID int64) ([]domain.Repository, error) - Contents(ctx context.Context, repositoryID int64, ref, treePath string) (domain.RepositoryContents, 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) 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) @@ -49,6 +50,7 @@ func (h *handlers) RegisterRoutes(parent chi.Router) { r.Post("/{repositoryID}/commits", response.Handler(h.logger, h.createCommit)) 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)) r.Get("/{repositoryID}/archive", response.Handler(h.logger, h.getArchive)) r.Get("/{repositoryID}/blob", response.Handler(h.logger, h.getBlob)) r.Get("/{repositoryID}/path-policies", response.Handler(h.logger, h.listPathPolicies)) diff --git a/internal/server/control/repositories/handlers_test.go b/internal/server/control/repositories/handlers_test.go index 4574e1b..2d275f2 100644 --- a/internal/server/control/repositories/handlers_test.go +++ b/internal/server/control/repositories/handlers_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/Axenos-dev/HeadlessGit/internal/domain" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" @@ -23,6 +24,17 @@ const testSHA = "aaaabbbbccccddddeeeeffff0000111122223333" // and override only what the endpoint under test touches type fakeManager struct { RepositoryManager + contents domain.RepositoryContents + contentsErr error + contentsRef string + contentsPath string + contentsOpts domain.ContentsOptions + + diffResult domain.RepositoryDiff + diffErr error + diffBase string + diffHead string + prepareReq domain.ArchiveRequest prepareErr error prefix string @@ -54,6 +66,19 @@ type fakeManager struct { repoByPathErr error } +func (f *fakeManager) Contents(ctx context.Context, repositoryID int64, ref, treePath string, opts domain.ContentsOptions) (domain.RepositoryContents, error) { + f.contentsRef = ref + f.contentsPath = treePath + f.contentsOpts = opts + return f.contents, f.contentsErr +} + +func (f *fakeManager) Diff(ctx context.Context, repositoryID int64, base, head string) (domain.RepositoryDiff, error) { + f.diffBase = base + f.diffHead = head + return f.diffResult, f.diffErr +} + func (f *fakeManager) Create(ctx context.Context, ownerID int64, info domain.RepositoryInfo) (domain.Repository, error) { return f.createdRepo, f.createErr } @@ -146,6 +171,257 @@ func testArchiveRequest() domain.ArchiveRequest { } } +func TestGetContents(t *testing.T) { + committedAt := time.Date(2026, 7, 30, 18, 42, 0, 0, time.UTC) + svc := &fakeManager{contents: domain.RepositoryContents{ + Ref: "main", + CommitSHA: testSHA, + Path: "config", + Entries: []domain.TreeEntry{{ + Name: "server.properties", + Path: "config/server.properties", + Type: domain.TreeEntryFile, + Mode: "100644", + SHA: "1111222233334444555566667777888899990000", + Size: 192, + LastCommit: &domain.CommitSummary{ + SHA: testSHA, + Message: "Change difficulty", + CommittedAt: committedAt, + }, + }}, + }} + + rec := httptest.NewRecorder() + newTestRouter(svc).ServeHTTP(rec, httptest.NewRequest( + http.MethodGet, + "/repositories/7/contents?ref=main&path=config&include=lastCommit", + nil, + )) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if svc.contentsRef != "main" || svc.contentsPath != "config" || !svc.contentsOpts.IncludeLastCommit { + t.Errorf("Contents args = ref %q, path %q, opts %+v", svc.contentsRef, svc.contentsPath, svc.contentsOpts) + } + + var body struct { + Data Contents `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Data.SHA != testSHA || len(body.Data.Entries) != 1 { + t.Fatalf("contents = %+v", body.Data) + } + entry := body.Data.Entries[0] + if entry.LastCommit == nil || entry.LastCommit.SHA != testSHA || entry.LastCommit.Message != "Change difficulty" || !entry.LastCommit.CommittedAt.Equal(committedAt) { + t.Errorf("lastCommit = %+v", entry.LastCommit) + } +} + +func TestGetContentsErrors(t *testing.T) { + cases := []struct { + name string + target string + serviceErr error + wantStatus int + wantCode string + }{ + {"bad id", "/repositories/nope/contents", nil, http.StatusBadRequest, "invalid_request"}, + {"bad include", "/repositories/7/contents?include=commits", nil, http.StatusBadRequest, "invalid_request"}, + {"duplicate include", "/repositories/7/contents?include=lastCommit&include=lastCommit", nil, http.StatusBadRequest, "invalid_request"}, + {"repository not found", "/repositories/7/contents", reposervice.ErrRepositoryNotFound, http.StatusNotFound, "repository_not_found"}, + {"ref not found", "/repositories/7/contents", reposervice.ErrRefNotFound, http.StatusNotFound, "ref_not_found"}, + {"path not found", "/repositories/7/contents", reposervice.ErrPathNotFound, http.StatusNotFound, "path_not_found"}, + {"invalid ref", "/repositories/7/contents", reposervice.ErrInvalidRef, http.StatusBadRequest, "invalid_request"}, + {"invalid path", "/repositories/7/contents", reposervice.ErrInvalidPath, http.StatusBadRequest, "invalid_request"}, + {"internal", "/repositories/7/contents", io.ErrUnexpectedEOF, http.StatusInternalServerError, "internal_error"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + newTestRouter(&fakeManager{contentsErr: 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 TestGetDiff(t *testing.T) { + headSHA := "1111222233334444555566667777888899990000" + patch := "diff --git a/old.txt b/new.txt\n" + svc := &fakeManager{diffResult: domain.RepositoryDiff{ + BaseSHA: testSHA, + HeadSHA: headSHA, + Files: []domain.DiffFile{ + { + Status: domain.DiffRenamed, + OldPath: "old.txt", + NewPath: "new.txt", + OldBlobSHA: "2222333344445555666677778888999900001111", + NewBlobSHA: "3333444455556666777788889999000011112222", + OldMode: "100644", + NewMode: "100755", + Additions: 2, + Deletions: 1, + Patch: &patch, + }, + { + Status: domain.DiffModified, + OldPath: "image.png", + NewPath: "image.png", + Binary: true, + PatchOmittedReason: domain.DiffPatchBinary, + }, + }, + }} + + rec := httptest.NewRecorder() + newTestRouter(svc).ServeHTTP(rec, httptest.NewRequest( + http.MethodGet, + "/repositories/7/diff?base=main~1&head=main", + nil, + )) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if svc.diffBase != "main~1" || svc.diffHead != "main" { + t.Errorf("Diff args = %q, %q", svc.diffBase, svc.diffHead) + } + + var body struct { + Data Diff `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Data.BaseSHA != testSHA || body.Data.HeadSHA != headSHA || len(body.Data.Files) != 2 { + t.Fatalf("diff = %+v", body.Data) + } + if file := body.Data.Files[0]; file.Status != domain.DiffRenamed || file.OldPath != "old.txt" || file.NewPath != "new.txt" || + file.OldBlobSHA == "" || file.NewBlobSHA == "" || file.Additions == nil || *file.Additions != 2 || + file.Patch == nil || *file.Patch != patch { + t.Errorf("renamed file = %+v", file) + } + if file := body.Data.Files[1]; !file.Binary || file.Additions != nil || file.Deletions != nil || + file.Patch != nil || file.PatchOmittedReason != "binary" { + t.Errorf("binary file = %+v", file) + } + + var required struct { + Data struct { + Truncated *bool `json:"truncated"` + Files []struct { + Binary *bool `json:"binary"` + Patch json.RawMessage `json:"patch"` + } `json:"files"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &required); err != nil { + t.Fatal(err) + } + if required.Data.Truncated == nil || *required.Data.Truncated { + t.Errorf("truncated must be present and false: %s", rec.Body.String()) + } + for i, file := range required.Data.Files { + if file.Binary == nil { + t.Errorf("files[%d].binary is missing: %s", i, rec.Body.String()) + } + } + if got := string(required.Data.Files[1].Patch); got != "null" { + t.Errorf("binary patch = %s, want null", got) + } +} + +func TestGetDiffAcceptsZeroSHA(t *testing.T) { + zeroSHA := strings.Repeat("0", 40) + for _, tc := range []struct { + name string + base string + head string + }{ + {"empty base", zeroSHA, "main"}, + {"empty head", "main", zeroSHA}, + {"both empty", zeroSHA, zeroSHA}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := &fakeManager{diffResult: domain.RepositoryDiff{Files: []domain.DiffFile{}}} + rec := httptest.NewRecorder() + newTestRouter(svc).ServeHTTP(rec, httptest.NewRequest( + http.MethodGet, + "/repositories/7/diff?base="+tc.base+"&head="+tc.head, + nil, + )) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if svc.diffBase != tc.base || svc.diffHead != tc.head { + t.Errorf("Diff args = %q, %q", svc.diffBase, svc.diffHead) + } + }) + } +} + +func TestGetDiffErrors(t *testing.T) { + cases := []struct { + name string + target string + serviceErr error + wantStatus int + wantCode string + }{ + {"bad id", "/repositories/nope/diff?base=a&head=b", nil, http.StatusBadRequest, "invalid_request"}, + {"missing base", "/repositories/7/diff?head=main", nil, http.StatusBadRequest, "invalid_request"}, + {"missing head", "/repositories/7/diff?base=main~1", nil, http.StatusBadRequest, "invalid_request"}, + {"repository not found", "/repositories/7/diff?base=a&head=b", reposervice.ErrRepositoryNotFound, http.StatusNotFound, "repository_not_found"}, + {"ref not found", "/repositories/7/diff?base=a&head=b", reposervice.ErrRefNotFound, http.StatusNotFound, "ref_not_found"}, + {"invalid ref", "/repositories/7/diff?base=a&head=b", reposervice.ErrInvalidRef, http.StatusBadRequest, "invalid_request"}, + {"internal", "/repositories/7/diff?base=a&head=b", io.ErrUnexpectedEOF, http.StatusInternalServerError, "internal_error"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + newTestRouter(&fakeManager{diffErr: 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 1ae2803..8333483 100644 --- a/internal/server/control/repositories/types.go +++ b/internal/server/control/repositories/types.go @@ -64,12 +64,19 @@ type Contents struct { } type ContentEntry struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` // file | dir | symlink | submodule - Mode string `json:"mode"` - Size *int64 `json:"size,omitempty"` // blobs only; note: LFS pointers report pointer size - SHA string `json:"sha"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // file | dir | symlink | submodule + Mode string `json:"mode"` + Size *int64 `json:"size,omitempty"` // blobs only; note: LFS pointers report pointer size + SHA string `json:"sha"` + LastCommit *CommitSummary `json:"lastCommit,omitempty"` +} + +type CommitSummary struct { + SHA string `json:"sha"` + Message string `json:"message"` + CommittedAt time.Time `json:"committedAt"` } func newContents(c domain.RepositoryContents) Contents { @@ -94,6 +101,13 @@ func newContentEntry(e domain.TreeEntry) ContentEntry { Mode: e.Mode, SHA: e.SHA, } + if e.LastCommit != nil { + entry.LastCommit = &CommitSummary{ + SHA: e.LastCommit.SHA, + Message: e.LastCommit.Message, + CommittedAt: e.LastCommit.CommittedAt, + } + } if e.Size >= 0 { size := e.Size entry.Size = &size @@ -101,6 +115,57 @@ func newContentEntry(e domain.TreeEntry) ContentEntry { return entry } +type Diff struct { + BaseSHA string `json:"baseSha"` + HeadSHA string `json:"headSha"` + Files []DiffFile `json:"files"` + Truncated bool `json:"truncated"` +} + +type DiffFile struct { + Status domain.DiffStatus `json:"status"` + OldPath string `json:"oldPath,omitempty"` + NewPath string `json:"newPath,omitempty"` + OldBlobSHA string `json:"oldBlobSha,omitempty"` + NewBlobSHA string `json:"newBlobSha,omitempty"` + OldMode string `json:"oldMode,omitempty"` + NewMode string `json:"newMode,omitempty"` + Additions *int64 `json:"additions"` + Deletions *int64 `json:"deletions"` + Binary bool `json:"binary"` + Patch *string `json:"patch"` + PatchOmittedReason domain.DiffPatchOmittedReason `json:"patchOmittedReason,omitempty"` +} + +func newDiff(diff domain.RepositoryDiff) Diff { + files := make([]DiffFile, len(diff.Files)) + for i, file := range diff.Files { + files[i] = DiffFile{ + Status: file.Status, + OldPath: file.OldPath, + NewPath: file.NewPath, + OldBlobSHA: file.OldBlobSHA, + NewBlobSHA: file.NewBlobSHA, + OldMode: file.OldMode, + NewMode: file.NewMode, + Binary: file.Binary, + Patch: file.Patch, + PatchOmittedReason: file.PatchOmittedReason, + } + if !file.Binary { + additions, deletions := file.Additions, file.Deletions + files[i].Additions = &additions + files[i].Deletions = &deletions + } + } + return Diff{ + BaseSHA: diff.BaseSHA, + HeadSHA: diff.HeadSHA, + Files: files, + Truncated: diff.Truncated, + } +} + type UpdateVisibilityRequest struct { Visibility string `json:"visibility"` } diff --git a/internal/services/repositories/service.go b/internal/services/repositories/service.go index 45c9931..875dbd6 100644 --- a/internal/services/repositories/service.go +++ b/internal/services/repositories/service.go @@ -36,7 +36,8 @@ type Registry interface { type RepositoryStorage interface { InitBare(ctx context.Context, storagePath string) error Remove(ctx context.Context, storagePath string) error - ListTree(ctx context.Context, storagePath, rev, treePath string) (gitbackend.TreeListing, 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) 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) @@ -207,7 +208,7 @@ func (s *Service) ListByOwner(ctx context.Context, ownerID int64) ([]domain.Repo return out, nil } -func (s *Service) Contents(ctx context.Context, repositoryID int64, ref, treePath string) (domain.RepositoryContents, error) { +func (s *Service) Contents(ctx context.Context, repositoryID int64, ref, treePath string, opts domain.ContentsOptions) (domain.RepositoryContents, error) { repo, err := s.registry.GetRepository(ctx, repositoryID) if errors.Is(err, sql.ErrNoRows) { return domain.RepositoryContents{}, ErrRepositoryNotFound @@ -216,7 +217,9 @@ func (s *Service) Contents(ctx context.Context, repositoryID int64, ref, treePat return domain.RepositoryContents{}, err } - listing, err := s.storage.ListTree(ctx, repo.StoragePath, ref, treePath) + listing, err := s.storage.ListTree(ctx, repo.StoragePath, ref, treePath, gitbackend.ListTreeOptions{ + IncludeLastCommit: opts.IncludeLastCommit, + }) switch { case errors.Is(err, gitbackend.ErrInvalidRev): return domain.RepositoryContents{}, ErrInvalidRef @@ -236,6 +239,27 @@ func (s *Service) Contents(ctx context.Context, repositoryID int64, ref, treePat return toContents(ref, treePath, listing), nil } +func (s *Service) Diff(ctx context.Context, repositoryID int64, base, head string) (domain.RepositoryDiff, error) { + repo, err := s.registry.GetRepository(ctx, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return domain.RepositoryDiff{}, ErrRepositoryNotFound + } + if err != nil { + return domain.RepositoryDiff{}, err + } + + diff, err := s.storage.Diff(ctx, repo.StoragePath, base, head) + switch { + case errors.Is(err, gitbackend.ErrInvalidRev): + return domain.RepositoryDiff{}, ErrInvalidRef + case errors.Is(err, gitbackend.ErrRevNotFound): + return domain.RepositoryDiff{}, ErrRefNotFound + case err != nil: + return domain.RepositoryDiff{}, err + } + return toDiff(diff), 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) { @@ -687,6 +711,13 @@ func toContents(ref, treePath string, listing gitbackend.TreeListing) domain.Rep SHA: e.SHA, Size: e.Size, } + if e.LastCommit != nil { + entries[i].LastCommit = &domain.CommitSummary{ + SHA: e.LastCommit.SHA, + Message: e.LastCommit.Message, + CommittedAt: e.LastCommit.CommittedAt, + } + } } return domain.RepositoryContents{ Ref: ref, @@ -696,3 +727,29 @@ func toContents(ref, treePath string, listing gitbackend.TreeListing) domain.Rep Truncated: listing.Truncated, } } + +func toDiff(diff gitbackend.DiffResult) domain.RepositoryDiff { + files := make([]domain.DiffFile, len(diff.Files)) + for i, file := range diff.Files { + files[i] = domain.DiffFile{ + Status: domain.DiffStatus(file.Status), + OldPath: file.OldPath, + NewPath: file.NewPath, + OldBlobSHA: file.OldBlobSHA, + NewBlobSHA: file.NewBlobSHA, + OldMode: file.OldMode, + NewMode: file.NewMode, + Additions: file.Additions, + Deletions: file.Deletions, + Binary: file.Binary, + Patch: file.Patch, + PatchOmittedReason: domain.DiffPatchOmittedReason(file.PatchOmittedReason), + } + } + return domain.RepositoryDiff{ + BaseSHA: diff.BaseSHA, + HeadSHA: diff.HeadSHA, + Files: files, + Truncated: diff.Truncated, + } +} diff --git a/internal/services/repositories/service_test.go b/internal/services/repositories/service_test.go index 5d07083..1144027 100644 --- a/internal/services/repositories/service_test.go +++ b/internal/services/repositories/service_test.go @@ -12,6 +12,7 @@ import ( "io" "strings" "testing" + "time" "github.com/Axenos-dev/HeadlessGit/internal/db/gen" "github.com/Axenos-dev/HeadlessGit/internal/domain" @@ -69,6 +70,13 @@ type fakeStorage struct { resolveErr error tarBytes []byte + listing gitbackend.TreeListing + listErr error + listFn func(storagePath, rev, treePath string, opts gitbackend.ListTreeOptions) + + diffResult gitbackend.DiffResult + diffErr error + blobInfo gitbackend.BlobInfo blobStatErr error blobContent string @@ -91,6 +99,17 @@ func (f fakeStorage) ResolveCommit(ctx context.Context, storagePath, rev string) return f.sha, nil } +func (f fakeStorage) ListTree(ctx context.Context, storagePath, rev, treePath string, opts gitbackend.ListTreeOptions) (gitbackend.TreeListing, error) { + if f.listFn != nil { + f.listFn(storagePath, rev, treePath, opts) + } + return f.listing, f.listErr +} + +func (f fakeStorage) Diff(ctx context.Context, storagePath, base, head string) (gitbackend.DiffResult, error) { + return f.diffResult, f.diffErr +} + 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 @@ -194,6 +213,141 @@ func TestCreateRepository(t *testing.T) { }) } +func TestContents(t *testing.T) { + row := gen.Repository{ID: 7, RepositoryName: "myrepo", StoragePath: "7/myrepo.git", Visibility: "private"} + committedAt := time.Date(2026, 7, 30, 18, 42, 0, 0, time.UTC) + lastCommit := gitbackend.CommitSummary{ + SHA: testSHA, + Message: "Change difficulty", + CommittedAt: committedAt, + } + listing := gitbackend.TreeListing{ + CommitSHA: testSHA, + Entries: []gitbackend.TreeEntry{{ + Mode: "100644", + Type: "blob", + SHA: "1111222233334444555566667777888899990000", + Size: 192, + Path: "config/server.properties", + LastCommit: &lastCommit, + }}, + } + + var called bool + storage := fakeStorage{ + listing: listing, + listFn: func(storagePath, rev, treePath string, opts gitbackend.ListTreeOptions) { + called = true + if storagePath != row.StoragePath || rev != "main" || treePath != "config" || !opts.IncludeLastCommit { + t.Errorf("ListTree(%q, %q, %q, %+v)", storagePath, rev, treePath, opts) + } + }, + } + svc := NewService(zap.NewNop(), fakeRegistry{repo: row}, storage, nil, nil) + got, err := svc.Contents(context.Background(), row.ID, "main", "config", domain.ContentsOptions{IncludeLastCommit: true}) + if err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("ListTree was not called") + } + if got.Ref != "main" || got.CommitSHA != testSHA || got.Path != "config" || len(got.Entries) != 1 { + t.Fatalf("Contents = %+v", got) + } + entry := got.Entries[0] + if entry.Name != "server.properties" || entry.Type != domain.TreeEntryFile || entry.Size != 192 { + t.Errorf("entry = %+v", entry) + } + if entry.LastCommit == nil || entry.LastCommit.SHA != testSHA || entry.LastCommit.Message != "Change difficulty" || !entry.LastCommit.CommittedAt.Equal(committedAt) { + t.Errorf("last commit = %+v", entry.LastCommit) + } + + for _, tc := range []struct { + name string + regErr error + listErr error + want error + }{ + {"repository not found", sql.ErrNoRows, nil, ErrRepositoryNotFound}, + {"invalid ref", nil, gitbackend.ErrInvalidRev, ErrInvalidRef}, + {"ref not found", nil, gitbackend.ErrRevNotFound, ErrRefNotFound}, + {"invalid path", nil, gitbackend.ErrInvalidPath, ErrInvalidPath}, + {"path not found", nil, gitbackend.ErrPathNotFound, ErrPathNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := NewService( + zap.NewNop(), + fakeRegistry{repo: row, err: tc.regErr}, + fakeStorage{listErr: tc.listErr}, + nil, + nil, + ) + if _, err := svc.Contents(context.Background(), row.ID, "main", "", domain.ContentsOptions{}); !errors.Is(err, tc.want) { + t.Errorf("Contents error = %v, want %v", err, tc.want) + } + }) + } +} + +func TestDiff(t *testing.T) { + row := gen.Repository{ID: 7, RepositoryName: "myrepo", StoragePath: "7/myrepo.git", Visibility: "private"} + headSHA := "1111222233334444555566667777888899990000" + patch := "diff --git a/old.txt b/new.txt\n" + result := gitbackend.DiffResult{ + BaseSHA: testSHA, + HeadSHA: headSHA, + Files: []gitbackend.DiffFile{{ + Status: gitbackend.DiffRenamed, + OldPath: "old.txt", + NewPath: "new.txt", + OldBlobSHA: "2222333344445555666677778888999900001111", + NewBlobSHA: "3333444455556666777788889999000011112222", + OldMode: "100644", + NewMode: "100755", + Additions: 2, + Deletions: 1, + Patch: &patch, + }}, + } + svc := NewService(zap.NewNop(), fakeRegistry{repo: row}, fakeStorage{diffResult: result}, nil, nil) + got, err := svc.Diff(context.Background(), row.ID, "main~1", "main") + if err != nil { + t.Fatal(err) + } + if got.BaseSHA != testSHA || got.HeadSHA != headSHA || len(got.Files) != 1 { + t.Fatalf("Diff = %+v", got) + } + if file := got.Files[0]; file.Status != domain.DiffRenamed || file.OldPath != "old.txt" || file.NewPath != "new.txt" || + file.OldBlobSHA == "" || file.NewBlobSHA == "" || file.Additions != 2 || file.Deletions != 1 || + file.Patch == nil || *file.Patch != patch { + t.Errorf("diff file = %+v", file) + } + + for _, tc := range []struct { + name string + regErr error + diffErr error + want error + }{ + {"repository not found", sql.ErrNoRows, nil, ErrRepositoryNotFound}, + {"invalid ref", nil, gitbackend.ErrInvalidRev, ErrInvalidRef}, + {"ref not found", nil, gitbackend.ErrRevNotFound, ErrRefNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := NewService( + zap.NewNop(), + fakeRegistry{repo: row, err: tc.regErr}, + fakeStorage{diffErr: tc.diffErr}, + nil, + nil, + ) + if _, err := svc.Diff(context.Background(), row.ID, "base", "head"); !errors.Is(err, tc.want) { + t.Errorf("Diff 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"