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
6
import (
7
"crypto/sha256"
8
"encoding/hex"
9
"fmt"
10
"os"
11
"path/filepath"
12
"sort"
13
"strings"
15
yaml "go.yaml.in/yaml/v3"
17
"github.com/janpuc/koment/internal/store"
18
)
20
const (
21
EnvConfig = "KOMENT_CONFIG"
22
EnvRepos = "KOMENT_REPOS"
23
EnvRepo = "KOMENT_REPO"
24
)
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
}
34
func (r Repository) Store() *store.Store { return store.Open(r.Root) }
36
func (r Repository) Display() string {
37
if r.Name != "" {
38
return r.Name
39
}
40
return r.ID
41
}
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 }
47
type file struct {
48
Repositories []Repository `yaml:"repositories"`
49
}
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
}
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
}
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
}
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
}
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
}
120
func discover(start string) (*Set, error) {
121
root, err := store.FindRoot(start)
122
if err != nil {
123
return nil, err
124
}
126
set := &Set{}
127
return set, set.add(Repository{ID: identifier(root), Root: root})
128
}
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)
142
if cleaned == "" || cleaned == "-" {
143
sum := sha256.Sum256([]byte(root))
144
return hex.EncodeToString(sum[:])[:16]
145
}
146
return cleaned
147
}
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
}
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}} }
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
}
173
func (s *Set) Len() int { return len(s.repositories) }
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
}
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
}
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
}
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
}
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
}