Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

knotmirror/xrpc: implement `git.getEntry`

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
committer
Tangled
date (Jun 11, 2026, 10:52 AM +0300) commit e210539c parent c8067b1d change-id pxzozstp
+256 -45
+1 -43
knotmirror/xrpc/git_get_blob.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 - "context" 5 4 "crypto/sha256" 6 5 "fmt" 7 6 "io" ··· 12 11 13 12 "github.com/bluesky-social/indigo/atproto/atclient" 14 13 "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/go-git/go-git/v5/plumbing/object" 16 14 "tangled.org/core/knotmirror/xrpc/gitea" 17 15 ) 18 16 ··· 45 43 return 46 44 } 47 45 48 - entry, err := x.getFile(ctx, repoPath, ref, path) 46 + entry, err := gitea.GetEntry(ctx, repoPath, ref, path) 49 47 if err != nil { 50 48 l.Warn("local mirror failed", "err", err) 51 49 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) ··· 109 107 return 110 108 } 111 109 w.Write(contents) 112 - } 113 - 114 - func (x *Xrpc) getFile(ctx context.Context, repoPath, ref, path string) (*object.TreeEntry, error) { 115 - rev := ref 116 - if rev == "" { 117 - rev = "HEAD" 118 - } 119 - 120 - head, err := gitea.GetCommit(ctx, repoPath, rev) 121 - if err != nil { 122 - return nil, fmt.Errorf("get head commit: %w", err) 123 - } 124 - 125 - treePath := filepath.Dir(path) 126 - name := filepath.Base(path) 127 - 128 - // find subTree 129 - subRev := head.Hash.String() + "^{tree}" 130 - if treePath != "." { 131 - subRev = head.Hash.String() + ":" + treePath 132 - } 133 - subTree, err := gitea.GetTree(ctx, repoPath, subRev) 134 - if err != nil { 135 - return nil, fmt.Errorf("get subtree %s: %w", subRev, err) 136 - } 137 - 138 - // find entry 139 - entry, err := func(subTree *object.Tree) (*object.TreeEntry, error) { 140 - for _, entry := range subTree.Entries { 141 - if entry.Name == name { 142 - return &entry, nil 143 - } 144 - } 145 - return nil, fmt.Errorf("object doesn't exist") 146 - }(subTree) 147 - if err != nil { 148 - return nil, fmt.Errorf("get file: %w", err) 149 - } 150 - 151 - return entry, nil 152 110 } 153 111 154 112 var textualMimeTypes = []string{
+120
knotmirror/xrpc/git_get_entry.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "cmp" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atclient" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/go-git/go-git/v5/plumbing/filemode" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/knotmirror/xrpc/gitea" 15 + ) 16 + 17 + func (x *Xrpc) GetEntry(w http.ResponseWriter, r *http.Request) { 18 + var ( 19 + repoQuery = r.URL.Query().Get("repo") 20 + ref = cmp.Or(r.URL.Query().Get("ref"), "HEAD") 21 + path = r.URL.Query().Get("path") 22 + ) 23 + 24 + repo, err := syntax.ParseDID(repoQuery) 25 + if err != nil { 26 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 27 + return 28 + } 29 + 30 + if path == "" { 31 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing path parameter"}) 32 + return 33 + } 34 + 35 + l := x.logger.With("method", "git.getEntry", "repo", repo, "ref", ref, "path", path) 36 + l.Debug("request") 37 + 38 + ctx := r.Context() 39 + 40 + repoPath, err := x.makeRepoPath(ctx, repo) 41 + if err != nil { 42 + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", repo)}) 43 + return 44 + } 45 + 46 + // resolve ref 47 + commit, err := gitea.GetCommit(ctx, repoPath, ref) 48 + if err != nil { 49 + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RefNotFound", Message: fmt.Sprintf("unknown ref: %q", ref)}) 50 + return 51 + } 52 + ref = commit.Hash.String() 53 + 54 + entry, err := gitea.GetEntryFromCommit(ctx, repoPath, commit, path) 55 + if err != nil { 56 + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "EntryNotFound", Message: fmt.Sprintf("entry %q not found", path)}) 57 + return 58 + } 59 + 60 + var outLastCommit *tangled.GitTempDefs_Commit 61 + var outSubmodule *tangled.GitTempDefs_Submodule 62 + 63 + lastCommit, err := gitea.GetCommitByPathWithID(ctx, commit.Hash, repoPath, path) 64 + if err != nil { 65 + l.Error("failed to find last commit", "err", err, "repoPath", repoPath) 66 + } else { 67 + outLastCommit = &tangled.GitTempDefs_Commit{ 68 + Hash: refString(lastCommit.Hash.String()), 69 + Tree: refString(lastCommit.TreeHash.String()), 70 + Author: &tangled.GitTempDefs_Signature{ 71 + Name: lastCommit.Author.Name, 72 + Email: lastCommit.Author.Email, 73 + When: lastCommit.Author.When.Format(time.RFC3339), 74 + }, 75 + Committer: &tangled.GitTempDefs_Signature{ 76 + Name: lastCommit.Committer.Name, 77 + Email: lastCommit.Committer.Email, 78 + When: lastCommit.Committer.When.Format(time.RFC3339), 79 + }, 80 + Message: lastCommit.Message, 81 + } 82 + } 83 + 84 + if entry.Mode == filemode.Submodule { 85 + modules, err := gitea.GetSubmodules(ctx, repoPath, ref) 86 + if err != nil && !(errors.Is(err, gitea.ErrMissingGitModules) || errors.Is(err, gitea.ErrInvalidGitModules)) { 87 + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read .gitmodules"}) 88 + return 89 + } 90 + 91 + if modules != nil { 92 + for _, submodule := range modules.Submodules { 93 + if submodule.Path == path { 94 + outSubmodule = &tangled.GitTempDefs_Submodule{ 95 + Name: submodule.Name, 96 + Url: submodule.URL, 97 + Branch: refOptionalString(submodule.Branch), 98 + } 99 + break 100 + } 101 + } 102 + } 103 + } 104 + 105 + writeJson(w, http.StatusOK, tangled.GitTempGetEntry_Output{ 106 + Name: entry.Name, 107 + Mode: entry.Mode.String(), 108 + Oid: entry.Hash.String(), 109 + LastCommit: outLastCommit, 110 + Submodule: outSubmodule, 111 + }) 112 + } 113 + 114 + func refString(s string) *string { return &s } 115 + func refOptionalString(s string) *string { 116 + if s == "" { 117 + return nil 118 + } 119 + return &s 120 + }
+42
knotmirror/xrpc/gitea/commit.go
··· 1 + package gitea 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "os/exec" 8 + "strings" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + ) 13 + 14 + func GetCommitByPathWithID(ctx context.Context, oid plumbing.Hash, repoPath, relpath string) (*object.Commit, error) { 15 + if len(relpath) == 0 { 16 + return nil, errors.New("relpath should not be empty") 17 + } 18 + // File name starts with ':' must be escaped. 19 + if relpath[0] == ':' { 20 + relpath = `\` + relpath 21 + } 22 + 23 + out, err := exec.CommandContext(ctx, 24 + "git", 25 + "-C", repoPath, 26 + "log", 27 + "-1", 28 + "--pretty=format:%H", 29 + oid.String(), 30 + "--", relpath, 31 + ).Output() 32 + if err != nil { 33 + return nil, err 34 + } 35 + 36 + rev := plumbing.NewHash(strings.TrimSpace(string(out))) 37 + if rev.IsZero() { 38 + return nil, fmt.Errorf("invalid commit id: %q", string(out)) 39 + } 40 + 41 + return GetCommit(ctx, repoPath, rev.String()) 42 + }
+52
knotmirror/xrpc/gitea/entry.go
··· 1 + package gitea 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "path/filepath" 7 + 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + ) 10 + 11 + func GetEntry(ctx context.Context, repoPath, ref, path string) (*object.TreeEntry, error) { 12 + if ref == "" { 13 + ref = "HEAD" 14 + } 15 + 16 + head, err := GetCommit(ctx, repoPath, ref) 17 + if err != nil { 18 + return nil, fmt.Errorf("get head commit: %w", err) 19 + } 20 + 21 + return GetEntryFromCommit(ctx, repoPath, head, path) 22 + } 23 + 24 + func GetEntryFromCommit(ctx context.Context, repoPath string, commit *object.Commit, path string) (*object.TreeEntry, error) { 25 + treePath := filepath.Dir(path) 26 + name := filepath.Base(path) 27 + 28 + // find subTree 29 + subRev := commit.Hash.String() + "^{tree}" 30 + if treePath != "." { 31 + subRev = commit.Hash.String() + ":" + treePath 32 + } 33 + subTree, err := GetTree(ctx, repoPath, subRev) 34 + if err != nil { 35 + return nil, fmt.Errorf("get subtree %s: %w", subRev, err) 36 + } 37 + 38 + // find entry 39 + entry, err := func(subTree *object.Tree) (*object.TreeEntry, error) { 40 + for _, entry := range subTree.Entries { 41 + if entry.Name == name { 42 + return &entry, nil 43 + } 44 + } 45 + return nil, fmt.Errorf("object doesn't exist") 46 + }(subTree) 47 + if err != nil { 48 + return nil, fmt.Errorf("get file: %w", err) 49 + } 50 + 51 + return entry, nil 52 + }
+39
knotmirror/xrpc/gitea/submodule.go
··· 1 + package gitea 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "io" 7 + 8 + "github.com/go-git/go-git/v5/config" 9 + ) 10 + 11 + var ( 12 + ErrMissingGitModules = errors.New("no .gitmodules file found") 13 + ErrInvalidGitModules = errors.New("invalid .gitmodules file") 14 + ) 15 + 16 + func GetSubmodules(ctx context.Context, repoPath, ref string) (*config.Modules, error) { 17 + modulesEntry, err := GetEntry(ctx, repoPath, ref, ".gitmodules") 18 + if err != nil { 19 + return nil, ErrMissingGitModules 20 + } 21 + 22 + // at the moment we do not strictly limit the size of the .gitmodules file because some users would have huge .gitmodules files (>1MB) 23 + _, reader, err := ReadBlob(ctx, repoPath, modulesEntry.Hash) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + modulesContent, err := io.ReadAll(reader) 29 + if err != nil { 30 + return nil, err 31 + } 32 + 33 + modules := config.NewModules() 34 + if err := modules.Unmarshal(modulesContent); err != nil { 35 + return nil, ErrInvalidGitModules 36 + } 37 + 38 + return modules, nil 39 + }
+1 -1
knotmirror/xrpc/repo_blob.go
··· 57 57 return 58 58 } 59 59 60 - entry, err := x.getFile(ctx, repoPath, ref, path) 60 + entry, err := gitea.GetEntry(ctx, repoPath, ref, path) 61 61 if err != nil { 62 62 l.Warn("local mirror failed, trying proxy", "err", err) 63 63 if x.proxyToKnot(w, r, repo) {
+1 -1
knotmirror/xrpc/xrpc.go
··· 64 64 r.Get("/"+tangled.GitTempGetBranchNSID, x.GetBranch) 65 65 // r.Get("/"+tangled.GitTempGetCommitNSID, x.GetCommit) // todo 66 66 // r.Get("/"+tangled.GitTempGetDiffNSID, x.GetDiff) // todo 67 - // r.Get("/"+tangled.GitTempGetEntityNSID, x.GetEntity) // todo 67 + r.Get("/"+tangled.GitTempGetEntryNSID, x.GetEntry) // todo 68 68 // r.Get("/"+tangled.GitTempGetHeadNSID, x.GetHead) // todo 69 69 r.Get("/"+tangled.GitTempGetTagNSID, x.GetTag) // using types.Response 70 70 r.Get("/"+tangled.GitTempGetTreeNSID, x.GetTree)