v0.1.0 3f267b5

repos_write.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"unicode"
10	"unicode/utf8"
11
12	"github.com/bnema/zerowrap"
13
14	"bnema.dev/ermite/internal/domain"
15)
16
17// MaxDescriptionLen is the longest repository description, in bytes.
18const MaxDescriptionLen = 500
19
20// validateDescription accepts a one-line description of valid UTF-8.
21func validateDescription(s string) error {
22	if len(s) > MaxDescriptionLen {
23		return fmt.Errorf("%w: description is longer than %d bytes", domain.ErrInvalid, MaxDescriptionLen)
24	}
25	if !utf8.ValidString(s) {
26		return fmt.Errorf("%w: description is not valid UTF-8", domain.ErrInvalid)
27	}
28	for _, c := range s {
29		if unicode.IsControl(c) {
30			return fmt.Errorf("%w: description must be one line without control characters", domain.ErrInvalid)
31		}
32	}
33	return nil
34}
35
36// RepoView is a repository with the Git state that belongs in its summary.
37type RepoView struct {
38	domain.Repo
39	// DefaultBranch is the branch HEAD points to, even when it does not exist
40	// yet; empty when HEAD is detached or the directory is missing.
41	DefaultBranch string
42}
43
44// View adds the default branch to a repository the caller has already been
45// allowed to see. A repository without a directory has no default branch.
46func (r *Repos) View(ctx context.Context, repo domain.Repo) (RepoView, error) {
47	branch, err := r.git.HeadBranch(ctx, repo.Name)
48	if err != nil && !errors.Is(err, domain.ErrNotFound) {
49		return RepoView{}, fmt.Errorf("read default branch of %q: %w", repo.Name, err)
50	}
51	return RepoView{Repo: repo, DefaultBranch: branch}, nil
52}
53
54// UpdateParams are the changes of Update; a nil field is left alone.
55type UpdateParams struct {
56	Description   *string
57	Visibility    *domain.Visibility
58	DefaultBranch *string
59}
60
61// Update changes the description, the visibility and the default branch of a
62// repository, and publishes RepoUpdated. The principal needs repo:admin on it
63// (see adminAccess for what the others learn). At least one field must be set,
64// else domain.ErrInvalid. A default branch that does not exist gives
65// domain.ErrPreconditionFailed; nothing is written before every check passed.
66// The metadata is written first, then HEAD, and RepoUpdated is published after
67// both. When only the HEAD move fails, RepoUpdated is still published for the
68// stored metadata and the HEAD error is returned.
69func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
70	repo, err := lookupRepo(ctx, r.meta, name)
71	if err != nil {
72		return domain.Repo{}, err
73	}
74	if err := adminAccess(p, repo); err != nil {
75		return domain.Repo{}, err
76	}
77	before := repo
78	if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {
79		return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
80	}
81	if params.Description != nil {
82		if err := validateDescription(*params.Description); err != nil {
83			return domain.Repo{}, err
84		}
85	}
86	if params.Visibility != nil {
87		if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
88			return domain.Repo{}, err
89		}
90	}
91	if params.DefaultBranch != nil {
92		if *params.DefaultBranch == "" {
93			return domain.Repo{}, fmt.Errorf("%w: default branch is empty", domain.ErrInvalid)
94		}
95		ok, err := r.git.HasBranch(ctx, name, *params.DefaultBranch)
96		if err != nil {
97			return domain.Repo{}, fmt.Errorf("check branch %q of %q: %w", *params.DefaultBranch, name, err)
98		}
99		if !ok {
100			return domain.Repo{}, fmt.Errorf("%w: branch %q does not exist", domain.ErrPreconditionFailed, *params.DefaultBranch)
101		}
102	}
103
104	// Metadata first, then HEAD. The two writes are not atomic: when moving
105	// HEAD fails after the metadata was stored, the stored change is still
106	// published as RepoUpdated (a repository made private must be withdrawn
107	// from the watchers) and the call then fails with the HEAD error. The
108	// branch was checked above, so the remaining causes are a vanished
109	// repository or an I/O failure.
110	metaDone := false
111	if params.Description != nil || params.Visibility != nil {
112		if repo, err = r.updateMeta(ctx, name, params); err != nil {
113			return domain.Repo{}, err
114		}
115		metaDone = true
116	}
117	var headErr error
118	if params.DefaultBranch != nil {
119		if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
120			headErr = fmt.Errorf("set default branch of %q: %w", name, err)
121			if !metaDone {
122				return domain.Repo{}, headErr
123			}
124		}
125	}
126
127	log := zerowrap.FromCtx(ctx)
128	log.Info().
129		Str("repo", name.String()).
130		Bool("description", params.Description != nil).
131		Bool("visibility", params.Visibility != nil).
132		Bool("default_branch", params.DefaultBranch != nil).
133		Str("principal", p.Label()).Str("token_id", p.TokenID()).
134		Msg("repository updated")
135	r.bus.Publish(ctx, domain.RepoUpdated{
136		Repo: name, At: r.clock.Now().UTC(),
137		Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
138	})
139	if headErr != nil {
140		return domain.Repo{}, headErr
141	}
142	return repo, nil
143}
144
145// updateMeta applies the metadata fields, retrying when a write (a push
146// recording pushed_at) gets in between.
147func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
148	for range pushRetries {
149		repo, err := r.meta.Get(ctx, name)
150		if err != nil {
151			return domain.Repo{}, err
152		}
153		if params.Description != nil {
154			repo.Description = *params.Description
155		}
156		if params.Visibility != nil {
157			repo.Visibility = *params.Visibility
158		}
159		err = r.meta.Update(ctx, repo)
160		if errors.Is(err, domain.ErrConflict) {
161			continue
162		}
163		if err != nil {
164			return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
165		}
166		return repo, nil
167	}
168	return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)
169}
170
171// Delete moves the repository directory to the trash, removes its metadata
172// and publishes RepoDeleted. The principal needs repo:admin on it (see
173// adminAccess). A repository whose directory is already gone, left by a
174// delete that failed half way, loses its metadata.
175func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
176	repo, err := lookupRepo(ctx, r.meta, name)
177	if err != nil {
178		return err
179	}
180	if err := adminAccess(p, repo); err != nil {
181		return err
182	}
183	log := zerowrap.FromCtx(ctx)
184	switch err := r.git.Trash(ctx, name); {
185	case errors.Is(err, domain.ErrNotFound):
186		log.Warn().Str("repo", name.String()).Msg("repository directory already gone, removing its metadata")
187	case err != nil:
188		return fmt.Errorf("trash repository %q: %w", name, err)
189	}
190	if err := r.meta.Delete(ctx, name); err != nil {
191		return fmt.Errorf("delete metadata of %q: %w", name, err)
192	}
193	log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
194	r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
195	return nil
196}

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