snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 Annotations about the code that implements koment.

internal/repository/repository.go

1 // Package repository holds the set of repositories a koment deployment serves.
2 // Identity is assigned rather than computed, so a repository that moves keeps
3 // its index and its history (ADR 0024).
4 package repository
5
6 import (
7 "crypto/sha256"
8 "encoding/hex"
9 "fmt"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14
15 yaml "go.yaml.in/yaml/v3"
16
17 "github.com/janpuc/koment/internal/store"
18 )
19
20 const (
21 EnvConfig = "KOMENT_CONFIG"
22 EnvRepos = "KOMENT_REPOS"
23 EnvRepo = "KOMENT_REPO"
24 )
25
26 type Repository struct {
27 ID string `yaml:"id"`
28 Name string `yaml:"name,omitempty"`
29 Root string `yaml:"root"`
30 CloneURL string `yaml:"clone_url,omitempty"`
31 DefaultBranch string `yaml:"default_branch,omitempty"`
32 }
33
34 func (r Repository) Store() *store.Store { return store.Open(r.Root) }
35
36 func (r Repository) Display() string {
37 if r.Name != "" {
38 return r.Name
39 }
40 return r.ID
41 }
42
43 // Set is what koment serves. It is ordered so that listings and exports are
44 // deterministic rather than however a map felt like iterating.
45 type Set struct{ repositories []Repository }
46
47 type file struct {
48 Repositories []Repository `yaml:"repositories"`
49 }
50
51 // Load reads the registry, preferring the richest source that is configured so
52 // that a laptop needs no configuration and a deployment can say more.
53 func Load(workingDirectory string) (*Set, error) {
54 if path := os.Getenv(EnvConfig); path != "" {
55 return loadFile(path)
56 }
57 if list := os.Getenv(EnvRepos); list != "" {
58 return loadList(list)
59 }
60 if root := os.Getenv(EnvRepo); root != "" {
61 return discover(root)
62 }
63 return discover(workingDirectory)
64 }
65
66 func loadFile(path string) (*Set, error) {
67 content, err := os.ReadFile(path)
68 if err != nil {
69 return nil, fmt.Errorf("reading %s: %w", path, err)
70 }
71
72 var parsed file
73 decoder := yaml.NewDecoder(strings.NewReader(string(content)))
74 decoder.KnownFields(true)
75 if err := decoder.Decode(&parsed); err != nil {
76 return nil, fmt.Errorf("parsing %s: %w", path, err)
77 }
78 if len(parsed.Repositories) == 0 {
79 return nil, fmt.Errorf("%s lists no repositories", path)
80 }
81
82 set := &Set{}
83 for i := range parsed.Repositories {
84 entry := parsed.Repositories[i]
85 if entry.Root, err = absolute(entry.Root, filepath.Dir(path)); err != nil {
86 return nil, err
87 }
88 if err := set.add(entry); err != nil {
89 return nil, fmt.Errorf("in %s: %w", path, err)
90 }
91 }
92 return set, nil
93 }
94
95 func loadList(list string) (*Set, error) {
96 set := &Set{}
97 for entry := range strings.SplitSeq(list, ",") {
98 entry = strings.TrimSpace(entry)
99 if entry == "" {
100 continue
101 }
102 id, root, found := strings.Cut(entry, "=")
103 if !found {
104 return nil, fmt.Errorf("%s entry %q must look like name=/path", EnvRepos, entry)
105 }
106 absoluteRoot, err := absolute(strings.TrimSpace(root), "")
107 if err != nil {
108 return nil, err
109 }
110 if err := set.add(Repository{ID: strings.TrimSpace(id), Root: absoluteRoot}); err != nil {
111 return nil, fmt.Errorf("in %s: %w", EnvRepos, err)
112 }
113 }
114 if len(set.repositories) == 0 {
115 return nil, fmt.Errorf("%s is set but lists no repositories", EnvRepos)
116 }
117 return set, nil
118 }
119
120 func discover(start string) (*Set, error) {
121 root, err := store.FindRoot(start)
122 if err != nil {
123 return nil, err
124 }
125
126 set := &Set{}
127 return set, set.add(Repository{ID: identifier(root), Root: root})
128 }
129
130 func identifier(root string) string {
131 base := strings.ToLower(filepath.Base(root))
132 cleaned := strings.Map(func(r rune) rune {
133 switch {
134 case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
135 return r
136 case r == ' ' || r == '.':
137 return '-'
138 }
139 return -1
140 }, base)
141
142 if cleaned == "" || cleaned == "-" {
143 sum := sha256.Sum256([]byte(root))
144 return hex.EncodeToString(sum[:])[:16]
145 }
146 return cleaned
147 }
148
149 func (s *Set) add(entry Repository) error {
150 switch {
151 case entry.ID == "":
152 return fmt.Errorf("repository at %s has no id", entry.Root)
153 case entry.Root == "":
154 return fmt.Errorf("repository %s has no root", entry.ID)
155 }
156 if _, taken := s.ByID(entry.ID); taken {
157 return fmt.Errorf("duplicate repository id %s", entry.ID)
158 }
159 s.repositories = append(s.repositories, entry)
160 return nil
161 }
162
163 // Of narrows a set to one repository, which is how --repository restricts what
164 // a server exposes without changing how anything downstream is written.
165 func Of(only Repository) *Set { return &Set{repositories: []Repository{only}} }
166
167 func (s *Set) All() []Repository {
168 ordered := append([]Repository(nil), s.repositories...)
169 sort.Slice(ordered, func(i, j int) bool { return ordered[i].ID < ordered[j].ID })
170 return ordered
171 }
172
173 func (s *Set) Len() int { return len(s.repositories) }
174
175 func (s *Set) ByID(id string) (Repository, bool) {
176 for _, entry := range s.repositories {
177 if entry.ID == id {
178 return entry, true
179 }
180 }
181 return Repository{}, false
182 }
183
184 // Resolve accepts an id or a display name, because an agent reading a listing
185 // may reasonably send either back.
186 func (s *Set) Resolve(reference string) (Repository, bool) {
187 if entry, found := s.ByID(reference); found {
188 return entry, true
189 }
190 for _, entry := range s.repositories {
191 if entry.Name != "" && strings.EqualFold(entry.Name, reference) {
192 return entry, true
193 }
194 }
195 return Repository{}, false
196 }
197
198 // Only returns the repository when there is exactly one, which is how the
199 // single-repository case keeps working without anyone naming it.
200 func (s *Set) Only() (Repository, bool) {
201 if len(s.repositories) == 1 {
202 return s.repositories[0], true
203 }
204 return Repository{}, false
205 }
206
207 // IDs is for error messages that have to tell a caller what it could have said.
208 func (s *Set) IDs() []string {
209 ids := make([]string, 0, len(s.repositories))
210 for _, entry := range s.All() {
211 ids = append(ids, entry.ID)
212 }
213 return ids
214 }
215
216 func absolute(path, relativeTo string) (string, error) {
217 if path == "" {
218 return "", fmt.Errorf("repository root is empty")
219 }
220 if filepath.IsAbs(path) {
221 return filepath.Clean(path), nil
222 }
223 if relativeTo != "" {
224 return filepath.Clean(filepath.Join(relativeTo, path)), nil
225 }
226 resolved, err := filepath.Abs(path)
227 if err != nil {
228 return "", fmt.Errorf("resolving %s: %w", path, err)
229 }
230 return resolved, nil
231 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close