v0.1.0 3f267b5

repos.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package app holds the use cases of ermite. It depends on the domain and on
4// the ports only.
5package app
6
7import (
8	"context"
9	"errors"
10	"fmt"
11
12	"github.com/bnema/zerowrap"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// Repos are the repository use cases.
19type Repos struct {
20	meta  ports.RepoMetaStore
21	git   ports.GitRepos
22	bus   ports.EventBus
23	clock ports.Clock
24}
25
26// NewRepos builds the repository use cases over their ports. Changes are
27// published on bus.
28func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos {
29	return &Repos{meta: meta, git: git, bus: bus, clock: clock}
30}
31
32// CreateParams are the settings of a new repository.
33type CreateParams struct {
34	Name        domain.RepoName
35	Description string
36	// Visibility defaults to private when empty.
37	Visibility domain.Visibility
38}
39
40// Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
41// metadata (which fails with domain.ErrAlreadyExists if the name is taken,
42// also when a concurrent Create wins) and publishes the directory. A failure
43// after the metadata is written removes it again and discards the staged
44// repository.
45//
46// The principal needs repo:write on the name: anonymous callers get
47// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.
48func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) {
49	if params.Name.IsZero() {
50		return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
51	}
52	if p.IsAnonymous() {
53		return domain.Repo{}, domain.ErrUnauthenticated
54	}
55	if !canWrite(p, params.Name) {
56		return domain.Repo{}, domain.ErrForbidden
57	}
58	if err := validateDescription(params.Description); err != nil {
59		return domain.Repo{}, err
60	}
61	vis := params.Visibility
62	if vis == "" {
63		vis = domain.VisibilityPrivate
64	}
65	if _, err := domain.ParseVisibility(vis.String()); err != nil {
66		return domain.Repo{}, err
67	}
68	repo := domain.Repo{
69		Name:        params.Name,
70		Description: params.Description,
71		Visibility:  vis,
72		CreatedAt:   r.clock.Now().UTC(),
73	}
74
75	log := zerowrap.FromCtx(ctx)
76	// Cleanup must finish even when the caller gives up.
77	cleanup := context.WithoutCancel(ctx)
78
79	id, err := r.git.Stage(ctx)
80	if err != nil {
81		return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err)
82	}
83	if err := r.meta.Create(ctx, repo); err != nil {
84		r.discard(cleanup, id)
85		if errors.Is(err, domain.ErrAlreadyExists) {
86			return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
87		}
88		return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err)
89	}
90	if err := r.git.Publish(ctx, id, repo.Name); err != nil {
91		if derr := r.meta.Delete(cleanup, repo.Name); derr != nil {
92			log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish")
93		}
94		r.discard(cleanup, id)
95		if errors.Is(err, domain.ErrAlreadyExists) {
96			return domain.Repo{}, fmt.Errorf("repository %q: directory %w", repo.Name, domain.ErrAlreadyExists)
97		}
98		return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
99	}
100	log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
101	r.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
102	return repo, nil
103}
104
105func (r *Repos) discard(ctx context.Context, id ports.StagedID) {
106	if err := r.git.Discard(ctx, id); err != nil {
107		log := zerowrap.FromCtx(ctx)
108		log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository")
109	}
110}
111
112// lookupRepo reads the metadata of a repository: domain.ErrInvalidName for the
113// zero name, domain.ErrNotFound for a missing repository. Access rules are the
114// caller's.
115func lookupRepo(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName) (domain.Repo, error) {
116	if name.IsZero() {
117		return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
118	}
119	return meta.Get(ctx, name)
120}
121
122// Get returns a repository. A repository the principal may not read answers
123// domain.ErrNotFound, like a missing one.
124func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
125	repo, err := lookupRepo(ctx, r.meta, name)
126	if err != nil {
127		return domain.Repo{}, err
128	}
129	if !canRead(p, repo) {
130		return domain.Repo{}, domain.ErrNotFound
131	}
132	return repo, nil
133}
134
135// List returns the repositories the principal may read, ordered by name.
136func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) {
137	all, err := r.meta.List(ctx)
138	if err != nil {
139		return nil, err
140	}
141	visible := make([]domain.Repo, 0, len(all))
142	for _, repo := range all {
143		if canRead(p, repo) {
144			visible = append(visible, repo)
145		}
146	}
147	return visible, nil
148}
149
150// ReconcileReport tells what Reconcile found.
151type ReconcileReport struct {
152	// Registered are directories without metadata, now registered private.
153	Registered []domain.RepoName
154	// MissingDirs are repositories with metadata and no directory. They are
155	// left untouched.
156	MissingDirs []domain.RepoName
157	// InvalidDirs are directory entries that cannot be repositories.
158	InvalidDirs []string
159}
160
161// Reconcile aligns the repository directories with the metadata at startup:
162// it removes staging leftovers, registers a directory without metadata as
163// private, skips invalid directory names, and reports metadata without a
164// directory at error level without touching it.
165func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
166	log := zerowrap.FromCtx(ctx)
167	var rep ReconcileReport
168
169	if err := r.git.PurgeStaging(ctx); err != nil {
170		return rep, fmt.Errorf("purge staging: %w", err)
171	}
172	dirs, err := r.git.List(ctx)
173	if err != nil {
174		return rep, fmt.Errorf("list repository directories: %w", err)
175	}
176	metas, err := r.meta.List(ctx)
177	if err != nil {
178		return rep, fmt.Errorf("list repository metadata: %w", err)
179	}
180
181	for _, entry := range dirs.Invalid {
182		log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name")
183	}
184	rep.InvalidDirs = dirs.Invalid
185
186	hasMeta := make(map[domain.RepoName]bool, len(metas))
187	for _, m := range metas {
188		hasMeta[m.Name] = true
189	}
190	hasDir := make(map[domain.RepoName]bool, len(dirs.Names))
191	for _, name := range dirs.Names {
192		hasDir[name] = true
193	}
194
195	for _, name := range dirs.Names {
196		if hasMeta[name] {
197			continue
198		}
199		repo := domain.Repo{
200			Name:       name,
201			Visibility: domain.VisibilityPrivate,
202			CreatedAt:  r.clock.Now().UTC(),
203		}
204		if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
205			return rep, fmt.Errorf("register repository %q: %w", name, err)
206		}
207		log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private")
208		rep.Registered = append(rep.Registered, name)
209	}
210	for _, m := range metas {
211		if !hasDir[m.Name] {
212			log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched")
213			rep.MissingDirs = append(rep.MissingDirs, m.Name)
214		}
215	}
216	return rep, nil
217}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close