detached c97ed57

feat(app): add repository use cases with access rule

commit
c97ed5709e6103ab2cd9adf87f3cb466d1264076
author
brice · · 2026-10-10 06:09 UTC
parent
4c5e5e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +545 -0
A internal/app/access.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import "bnema.dev/ermite/internal/domain"
6+
7+// canRead is the access rule for reading a repository, the single place that
8+// decides it: root reads everything, anonymous callers read public
9+// repositories only. Callers answer domain.ErrNotFound when it is false, so
10+// a private repository never shows itself.
11+func canRead(p domain.Principal, repo domain.Repo) bool {
12+ if p.IsRoot() {
13+ return true
14+ }
15+ return repo.Visibility == domain.VisibilityPublic
16+}
17+
18+// canCreate is the access rule for creating a repository: root only.
19+func canCreate(p domain.Principal) bool { return p.IsRoot() }
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import "bnema.dev/ermite/internal/domain"
6+
7+// canRead is the access rule for reading a repository, the single place that
8+// decides it: root reads everything, anonymous callers read public
9+// repositories only. Callers answer domain.ErrNotFound when it is false, so
10+// a private repository never shows itself.
11+func canRead(p domain.Principal, repo domain.Repo) bool {
12+ if p.IsRoot() {
13+ return true
14+ }
15+ return repo.Visibility == domain.VisibilityPublic
16+}
17+
18+// canCreate is the access rule for creating a repository: root only.
19+func canCreate(p domain.Principal) bool { return p.IsRoot() }
A internal/app/repos.go +197 -0
@@ -0,0 +1,197 @@
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.
5+package app
6+
7+import (
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.
19+type Repos struct {
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ clock ports.Clock
23+}
24+
25+// NewRepos builds the repository use cases over their ports.
26+func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, clock ports.Clock) *Repos {
27+ return &Repos{meta: meta, git: git, clock: clock}
28+}
29+
30+// CreateParams are the settings of a new repository.
31+type CreateParams struct {
32+ Name domain.RepoName
33+ Description string
34+ // Visibility defaults to private when empty.
35+ Visibility domain.Visibility
36+}
37+
38+// Create makes a repository: it stages a bare repository, records the
39+// metadata (which fails with domain.ErrAlreadyExists if the name is taken,
40+// also when a concurrent Create wins) and publishes the directory. A failure
41+// after the metadata is written removes it again and discards the staged
42+// repository.
43+//
44+// Only root may create; anonymous callers get domain.ErrUnauthenticated.
45+func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) {
46+ if !canCreate(p) {
47+ return domain.Repo{}, domain.ErrUnauthenticated
48+ }
49+ if params.Name.IsZero() {
50+ return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
51+ }
52+ vis := params.Visibility
53+ if vis == "" {
54+ vis = domain.VisibilityPrivate
55+ }
56+ if _, err := domain.ParseVisibility(vis.String()); err != nil {
57+ return domain.Repo{}, err
58+ }
59+ repo := domain.Repo{
60+ Name: params.Name,
61+ Description: params.Description,
62+ Visibility: vis,
63+ CreatedAt: r.clock.Now().UTC(),
64+ }
65+
66+ log := zerowrap.FromCtx(ctx)
67+ // Cleanup must finish even when the caller gives up.
68+ cleanup := context.WithoutCancel(ctx)
69+
70+ id, err := r.git.Stage(ctx)
71+ if err != nil {
72+ return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err)
73+ }
74+ if err := r.meta.Create(ctx, repo); err != nil {
75+ r.discard(cleanup, id)
76+ if errors.Is(err, domain.ErrAlreadyExists) {
77+ return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
78+ }
79+ return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err)
80+ }
81+ if err := r.git.Publish(ctx, id, repo.Name); err != nil {
82+ if derr := r.meta.Delete(cleanup, repo.Name); derr != nil {
83+ log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish")
84+ }
85+ r.discard(cleanup, id)
86+ if errors.Is(err, domain.ErrAlreadyExists) {
87+ return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
88+ }
89+ return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
90+ }
91+ log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
92+ return repo, nil
93+}
94+
95+func (r *Repos) discard(ctx context.Context, id ports.StagedID) {
96+ if err := r.git.Discard(ctx, id); err != nil {
97+ log := zerowrap.FromCtx(ctx)
98+ log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository")
99+ }
100+}
101+
102+// Get returns a repository. A repository the principal may not read answers
103+// domain.ErrNotFound, like a missing one.
104+func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
105+ repo, err := r.meta.Get(ctx, name)
106+ if err != nil {
107+ return domain.Repo{}, err
108+ }
109+ if !canRead(p, repo) {
110+ return domain.Repo{}, domain.ErrNotFound
111+ }
112+ return repo, nil
113+}
114+
115+// List returns the repositories the principal may read, ordered by name.
116+func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) {
117+ all, err := r.meta.List(ctx)
118+ if err != nil {
119+ return nil, err
120+ }
121+ visible := make([]domain.Repo, 0, len(all))
122+ for _, repo := range all {
123+ if canRead(p, repo) {
124+ visible = append(visible, repo)
125+ }
126+ }
127+ return visible, nil
128+}
129+
130+// ReconcileReport tells what Reconcile found.
131+type ReconcileReport struct {
132+ // Registered are directories without metadata, now registered private.
133+ Registered []domain.RepoName
134+ // MissingDirs are repositories with metadata and no directory. They are
135+ // left untouched.
136+ MissingDirs []domain.RepoName
137+ // InvalidDirs are directory entries that cannot be repositories.
138+ InvalidDirs []string
139+}
140+
141+// Reconcile aligns the repository directories with the metadata at startup:
142+// it removes staging leftovers, registers a directory without metadata as
143+// private, skips invalid directory names, and reports metadata without a
144+// directory at error level without touching it.
145+func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
146+ log := zerowrap.FromCtx(ctx)
147+ var rep ReconcileReport
148+
149+ if err := r.git.PurgeStaging(ctx); err != nil {
150+ return rep, fmt.Errorf("purge staging: %w", err)
151+ }
152+ dirs, err := r.git.List(ctx)
153+ if err != nil {
154+ return rep, fmt.Errorf("list repository directories: %w", err)
155+ }
156+ metas, err := r.meta.List(ctx)
157+ if err != nil {
158+ return rep, fmt.Errorf("list repository metadata: %w", err)
159+ }
160+
161+ for _, entry := range dirs.Invalid {
162+ log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name")
163+ }
164+ rep.InvalidDirs = dirs.Invalid
165+
166+ hasMeta := make(map[domain.RepoName]bool, len(metas))
167+ for _, m := range metas {
168+ hasMeta[m.Name] = true
169+ }
170+ hasDir := make(map[domain.RepoName]bool, len(dirs.Names))
171+ for _, name := range dirs.Names {
172+ hasDir[name] = true
173+ }
174+
175+ for _, name := range dirs.Names {
176+ if hasMeta[name] {
177+ continue
178+ }
179+ repo := domain.Repo{
180+ Name: name,
181+ Visibility: domain.VisibilityPrivate,
182+ CreatedAt: r.clock.Now().UTC(),
183+ }
184+ if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
185+ return rep, fmt.Errorf("register repository %q: %w", name, err)
186+ }
187+ log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private")
188+ rep.Registered = append(rep.Registered, name)
189+ }
190+ for _, m := range metas {
191+ if !hasDir[m.Name] {
192+ log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched")
193+ rep.MissingDirs = append(rep.MissingDirs, m.Name)
194+ }
195+ }
196+ return rep, nil
197+}
@@ -0,0 +1,197 @@
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.
5+package app
6+
7+import (
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.
19+type Repos struct {
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ clock ports.Clock
23+}
24+
25+// NewRepos builds the repository use cases over their ports.
26+func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, clock ports.Clock) *Repos {
27+ return &Repos{meta: meta, git: git, clock: clock}
28+}
29+
30+// CreateParams are the settings of a new repository.
31+type CreateParams struct {
32+ Name domain.RepoName
33+ Description string
34+ // Visibility defaults to private when empty.
35+ Visibility domain.Visibility
36+}
37+
38+// Create makes a repository: it stages a bare repository, records the
39+// metadata (which fails with domain.ErrAlreadyExists if the name is taken,
40+// also when a concurrent Create wins) and publishes the directory. A failure
41+// after the metadata is written removes it again and discards the staged
42+// repository.
43+//
44+// Only root may create; anonymous callers get domain.ErrUnauthenticated.
45+func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) {
46+ if !canCreate(p) {
47+ return domain.Repo{}, domain.ErrUnauthenticated
48+ }
49+ if params.Name.IsZero() {
50+ return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
51+ }
52+ vis := params.Visibility
53+ if vis == "" {
54+ vis = domain.VisibilityPrivate
55+ }
56+ if _, err := domain.ParseVisibility(vis.String()); err != nil {
57+ return domain.Repo{}, err
58+ }
59+ repo := domain.Repo{
60+ Name: params.Name,
61+ Description: params.Description,
62+ Visibility: vis,
63+ CreatedAt: r.clock.Now().UTC(),
64+ }
65+
66+ log := zerowrap.FromCtx(ctx)
67+ // Cleanup must finish even when the caller gives up.
68+ cleanup := context.WithoutCancel(ctx)
69+
70+ id, err := r.git.Stage(ctx)
71+ if err != nil {
72+ return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err)
73+ }
74+ if err := r.meta.Create(ctx, repo); err != nil {
75+ r.discard(cleanup, id)
76+ if errors.Is(err, domain.ErrAlreadyExists) {
77+ return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
78+ }
79+ return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err)
80+ }
81+ if err := r.git.Publish(ctx, id, repo.Name); err != nil {
82+ if derr := r.meta.Delete(cleanup, repo.Name); derr != nil {
83+ log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish")
84+ }
85+ r.discard(cleanup, id)
86+ if errors.Is(err, domain.ErrAlreadyExists) {
87+ return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
88+ }
89+ return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
90+ }
91+ log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
92+ return repo, nil
93+}
94+
95+func (r *Repos) discard(ctx context.Context, id ports.StagedID) {
96+ if err := r.git.Discard(ctx, id); err != nil {
97+ log := zerowrap.FromCtx(ctx)
98+ log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository")
99+ }
100+}
101+
102+// Get returns a repository. A repository the principal may not read answers
103+// domain.ErrNotFound, like a missing one.
104+func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
105+ repo, err := r.meta.Get(ctx, name)
106+ if err != nil {
107+ return domain.Repo{}, err
108+ }
109+ if !canRead(p, repo) {
110+ return domain.Repo{}, domain.ErrNotFound
111+ }
112+ return repo, nil
113+}
114+
115+// List returns the repositories the principal may read, ordered by name.
116+func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) {
117+ all, err := r.meta.List(ctx)
118+ if err != nil {
119+ return nil, err
120+ }
121+ visible := make([]domain.Repo, 0, len(all))
122+ for _, repo := range all {
123+ if canRead(p, repo) {
124+ visible = append(visible, repo)
125+ }
126+ }
127+ return visible, nil
128+}
129+
130+// ReconcileReport tells what Reconcile found.
131+type ReconcileReport struct {
132+ // Registered are directories without metadata, now registered private.
133+ Registered []domain.RepoName
134+ // MissingDirs are repositories with metadata and no directory. They are
135+ // left untouched.
136+ MissingDirs []domain.RepoName
137+ // InvalidDirs are directory entries that cannot be repositories.
138+ InvalidDirs []string
139+}
140+
141+// Reconcile aligns the repository directories with the metadata at startup:
142+// it removes staging leftovers, registers a directory without metadata as
143+// private, skips invalid directory names, and reports metadata without a
144+// directory at error level without touching it.
145+func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
146+ log := zerowrap.FromCtx(ctx)
147+ var rep ReconcileReport
148+
149+ if err := r.git.PurgeStaging(ctx); err != nil {
150+ return rep, fmt.Errorf("purge staging: %w", err)
151+ }
152+ dirs, err := r.git.List(ctx)
153+ if err != nil {
154+ return rep, fmt.Errorf("list repository directories: %w", err)
155+ }
156+ metas, err := r.meta.List(ctx)
157+ if err != nil {
158+ return rep, fmt.Errorf("list repository metadata: %w", err)
159+ }
160+
161+ for _, entry := range dirs.Invalid {
162+ log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name")
163+ }
164+ rep.InvalidDirs = dirs.Invalid
165+
166+ hasMeta := make(map[domain.RepoName]bool, len(metas))
167+ for _, m := range metas {
168+ hasMeta[m.Name] = true
169+ }
170+ hasDir := make(map[domain.RepoName]bool, len(dirs.Names))
171+ for _, name := range dirs.Names {
172+ hasDir[name] = true
173+ }
174+
175+ for _, name := range dirs.Names {
176+ if hasMeta[name] {
177+ continue
178+ }
179+ repo := domain.Repo{
180+ Name: name,
181+ Visibility: domain.VisibilityPrivate,
182+ CreatedAt: r.clock.Now().UTC(),
183+ }
184+ if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
185+ return rep, fmt.Errorf("register repository %q: %w", name, err)
186+ }
187+ log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private")
188+ rep.Registered = append(rep.Registered, name)
189+ }
190+ for _, m := range metas {
191+ if !hasDir[m.Name] {
192+ log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched")
193+ rep.MissingDirs = append(rep.MissingDirs, m.Name)
194+ }
195+ }
196+ return rep, nil
197+}
A internal/app/repos_test.go +329 -0
@@ -0,0 +1,329 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/app"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
23+
24+type fixture struct {
25+ meta *mocks.RepoMetaStore
26+ git *mocks.GitRepos
27+ clock *mocks.Clock
28+ repos *app.Repos
29+}
30+
31+func newFixture(t *testing.T) *fixture {
32+ t.Helper()
33+ f := &fixture{
34+ meta: mocks.NewRepoMetaStore(t),
35+ git: mocks.NewGitRepos(t),
36+ clock: mocks.NewClock(t),
37+ }
38+ f.clock.EXPECT().Now().Return(now).Maybe()
39+ f.repos = app.NewRepos(f.meta, f.git, f.clock)
40+ return f
41+}
42+
43+func name(t *testing.T, s string) domain.RepoName {
44+ t.Helper()
45+ n, err := domain.ParseRepoName(s)
46+ require.NoError(t, err)
47+ return n
48+}
49+
50+func TestCreate(t *testing.T) {
51+ ctx := context.Background()
52+ errBoom := errors.New("boom")
53+
54+ t.Run("success is private by default", func(t *testing.T) {
55+ f := newFixture(t)
56+ n := name(t, "tools")
57+ want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now}
58+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
59+ f.meta.EXPECT().Create(ctx, want).Return(nil)
60+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
61+
62+ got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})
63+ require.NoError(t, err)
64+ assert.Equal(t, want, got)
65+ })
66+
67+ t.Run("anonymous is refused before anything runs", func(t *testing.T) {
68+ f := newFixture(t)
69+ _, err := f.repos.Create(ctx, domain.Anonymous(), app.CreateParams{Name: name(t, "tools")})
70+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
71+ })
72+
73+ t.Run("zero name is invalid", func(t *testing.T) {
74+ f := newFixture(t)
75+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})
76+ assert.ErrorIs(t, err, domain.ErrInvalidName)
77+ })
78+
79+ t.Run("unknown visibility is invalid", func(t *testing.T) {
80+ f := newFixture(t)
81+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools"), Visibility: "secret"})
82+ assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
83+ })
84+
85+ t.Run("stage failure stores nothing", func(t *testing.T) {
86+ f := newFixture(t)
87+ f.git.EXPECT().Stage(ctx).Return("", errBoom)
88+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
89+ assert.ErrorIs(t, err, errBoom)
90+ })
91+
92+ t.Run("existing name discards the staged repository", func(t *testing.T) {
93+ f := newFixture(t)
94+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
95+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
96+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
97+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
98+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
99+ })
100+
101+ t.Run("failed publish removes metadata and staging", func(t *testing.T) {
102+ f := newFixture(t)
103+ n := name(t, "tools")
104+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
105+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
106+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom)
107+ f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
108+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
109+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
110+ assert.ErrorIs(t, err, errBoom)
111+ })
112+
113+ t.Run("cleanup survives a canceled context", func(t *testing.T) {
114+ f := newFixture(t)
115+ n := name(t, "tools")
116+ cctx, cancel := context.WithCancel(ctx)
117+ f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil)
118+ f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil)
119+ f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n).
120+ RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
121+ cancel()
122+ return errBoom
123+ })
124+ f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error {
125+ return c.Err()
126+ })
127+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error {
128+ return c.Err()
129+ })
130+ _, err := f.repos.Create(cctx, domain.Root(), app.CreateParams{Name: n})
131+ assert.ErrorIs(t, err, errBoom)
132+ })
133+}
134+
135+// A fake store that enforces absence, to model concurrent creates.
136+type memMeta struct {
137+ mu sync.Mutex
138+ m map[domain.RepoName]domain.Repo
139+}
140+
141+func (s *memMeta) create(repo domain.Repo) error {
142+ s.mu.Lock()
143+ defer s.mu.Unlock()
144+ if _, ok := s.m[repo.Name]; ok {
145+ return domain.ErrAlreadyExists
146+ }
147+ s.m[repo.Name] = repo
148+ return nil
149+}
150+
151+func TestCreateConcurrentSameName(t *testing.T) {
152+ ctx := context.Background()
153+ n := name(t, "tools")
154+ store := &memMeta{m: map[domain.RepoName]domain.Repo{}}
155+
156+ f := newFixture(t)
157+ var mu sync.Mutex
158+ staged, discarded, published := 0, 0, 0
159+ f.git.EXPECT().Stage(mock.Anything).RunAndReturn(func(context.Context) (ports.StagedID, error) {
160+ mu.Lock()
161+ defer mu.Unlock()
162+ staged++
163+ return ports.StagedID(string(rune('a' + staged))), nil
164+ })
165+ f.meta.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r domain.Repo) error {
166+ return store.create(r)
167+ })
168+ f.git.EXPECT().Publish(mock.Anything, mock.Anything, n).RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
169+ mu.Lock()
170+ defer mu.Unlock()
171+ published++
172+ return nil
173+ })
174+ f.git.EXPECT().Discard(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.StagedID) error {
175+ mu.Lock()
176+ defer mu.Unlock()
177+ discarded++
178+ return nil
179+ })
180+
181+ const workers = 8
182+ errs := make(chan error, workers)
183+ var wg sync.WaitGroup
184+ for range workers {
185+ wg.Add(1)
186+ go func() {
187+ defer wg.Done()
188+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
189+ errs <- err
190+ }()
191+ }
192+ wg.Wait()
193+ close(errs)
194+
195+ ok, exists := 0, 0
196+ for err := range errs {
197+ switch {
198+ case err == nil:
199+ ok++
200+ case errors.Is(err, domain.ErrAlreadyExists):
201+ exists++
202+ default:
203+ t.Fatalf("unexpected error: %v", err)
204+ }
205+ }
206+ assert.Equal(t, 1, ok)
207+ assert.Equal(t, workers-1, exists)
208+ assert.Equal(t, 1, published)
209+ assert.Equal(t, workers-1, discarded)
210+}
211+
212+func TestGetAndListAccess(t *testing.T) {
213+ ctx := context.Background()
214+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
215+ priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
216+
217+ tests := []struct {
218+ name string
219+ principal domain.Principal
220+ get domain.Repo
221+ wantErr error
222+ }{
223+ {"anonymous reads public", domain.Anonymous(), pub, nil},
224+ {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
225+ {"root reads public", domain.Root(), pub, nil},
226+ {"root reads private", domain.Root(), priv, nil},
227+ }
228+ for _, tt := range tests {
229+ t.Run("get/"+tt.name, func(t *testing.T) {
230+ f := newFixture(t)
231+ f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil)
232+ got, err := f.repos.Get(ctx, tt.principal, tt.get.Name)
233+ if tt.wantErr != nil {
234+ assert.ErrorIs(t, err, tt.wantErr)
235+ assert.Zero(t, got)
236+ return
237+ }
238+ require.NoError(t, err)
239+ assert.Equal(t, tt.get, got)
240+ })
241+ }
242+
243+ t.Run("get missing is not found", func(t *testing.T) {
244+ f := newFixture(t)
245+ f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound)
246+ _, err := f.repos.Get(ctx, domain.Root(), pub.Name)
247+ assert.ErrorIs(t, err, domain.ErrNotFound)
248+ })
249+
250+ t.Run("private missing and hidden look the same", func(t *testing.T) {
251+ f := newFixture(t)
252+ f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
253+ _, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name)
254+ f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
255+ _, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone"))
256+ assert.Equal(t, errMissing, errHidden)
257+ })
258+
259+ listTests := []struct {
260+ name string
261+ principal domain.Principal
262+ want []domain.Repo
263+ }{
264+ {"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}},
265+ {"root lists all", domain.Root(), []domain.Repo{priv, pub}},
266+ }
267+ for _, tt := range listTests {
268+ t.Run("list/"+tt.name, func(t *testing.T) {
269+ f := newFixture(t)
270+ f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil)
271+ got, err := f.repos.List(ctx, tt.principal)
272+ require.NoError(t, err)
273+ assert.Equal(t, tt.want, got)
274+ })
275+ }
276+
277+ t.Run("list error", func(t *testing.T) {
278+ f := newFixture(t)
279+ boom := errors.New("boom")
280+ f.meta.EXPECT().List(ctx).Return(nil, boom)
281+ _, err := f.repos.List(ctx, domain.Root())
282+ assert.ErrorIs(t, err, boom)
283+ })
284+}
285+
286+func TestReconcile(t *testing.T) {
287+ ctx := context.Background()
288+
289+ t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) {
290+ f := newFixture(t)
291+ orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost")
292+ f.git.EXPECT().PurgeStaging(ctx).Return(nil)
293+ f.git.EXPECT().List(ctx).Return(ports.RepoDirs{
294+ Names: []domain.RepoName{orphan, kept},
295+ Invalid: []string{"Bad Name.git"},
296+ }, nil)
297+ f.meta.EXPECT().List(ctx).Return([]domain.Repo{
298+ {Name: ghost, Visibility: domain.VisibilityPublic},
299+ {Name: kept, Visibility: domain.VisibilityPublic},
300+ }, nil)
301+ f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil)
302+
303+ rep, err := f.repos.Reconcile(ctx)
304+ require.NoError(t, err)
305+ assert.Equal(t, []domain.RepoName{orphan}, rep.Registered)
306+ assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs)
307+ assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs)
308+ })
309+
310+ t.Run("purge failure stops", func(t *testing.T) {
311+ f := newFixture(t)
312+ boom := errors.New("boom")
313+ f.git.EXPECT().PurgeStaging(ctx).Return(boom)
314+ _, err := f.repos.Reconcile(ctx)
315+ assert.ErrorIs(t, err, boom)
316+ })
317+
318+ t.Run("register failure stops", func(t *testing.T) {
319+ f := newFixture(t)
320+ boom := errors.New("boom")
321+ orphan := name(t, "orphan")
322+ f.git.EXPECT().PurgeStaging(ctx).Return(nil)
323+ f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil)
324+ f.meta.EXPECT().List(ctx).Return(nil, nil)
325+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom)
326+ _, err := f.repos.Reconcile(ctx)
327+ assert.ErrorIs(t, err, boom)
328+ })
329+}
@@ -0,0 +1,329 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/app"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
23+
24+type fixture struct {
25+ meta *mocks.RepoMetaStore
26+ git *mocks.GitRepos
27+ clock *mocks.Clock
28+ repos *app.Repos
29+}
30+
31+func newFixture(t *testing.T) *fixture {
32+ t.Helper()
33+ f := &fixture{
34+ meta: mocks.NewRepoMetaStore(t),
35+ git: mocks.NewGitRepos(t),
36+ clock: mocks.NewClock(t),
37+ }
38+ f.clock.EXPECT().Now().Return(now).Maybe()
39+ f.repos = app.NewRepos(f.meta, f.git, f.clock)
40+ return f
41+}
42+
43+func name(t *testing.T, s string) domain.RepoName {
44+ t.Helper()
45+ n, err := domain.ParseRepoName(s)
46+ require.NoError(t, err)
47+ return n
48+}
49+
50+func TestCreate(t *testing.T) {
51+ ctx := context.Background()
52+ errBoom := errors.New("boom")
53+
54+ t.Run("success is private by default", func(t *testing.T) {
55+ f := newFixture(t)
56+ n := name(t, "tools")
57+ want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now}
58+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
59+ f.meta.EXPECT().Create(ctx, want).Return(nil)
60+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
61+
62+ got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})
63+ require.NoError(t, err)
64+ assert.Equal(t, want, got)
65+ })
66+
67+ t.Run("anonymous is refused before anything runs", func(t *testing.T) {
68+ f := newFixture(t)
69+ _, err := f.repos.Create(ctx, domain.Anonymous(), app.CreateParams{Name: name(t, "tools")})
70+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
71+ })
72+
73+ t.Run("zero name is invalid", func(t *testing.T) {
74+ f := newFixture(t)
75+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})
76+ assert.ErrorIs(t, err, domain.ErrInvalidName)
77+ })
78+
79+ t.Run("unknown visibility is invalid", func(t *testing.T) {
80+ f := newFixture(t)
81+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools"), Visibility: "secret"})
82+ assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
83+ })
84+
85+ t.Run("stage failure stores nothing", func(t *testing.T) {
86+ f := newFixture(t)
87+ f.git.EXPECT().Stage(ctx).Return("", errBoom)
88+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
89+ assert.ErrorIs(t, err, errBoom)
90+ })
91+
92+ t.Run("existing name discards the staged repository", func(t *testing.T) {
93+ f := newFixture(t)
94+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
95+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
96+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
97+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
98+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
99+ })
100+
101+ t.Run("failed publish removes metadata and staging", func(t *testing.T) {
102+ f := newFixture(t)
103+ n := name(t, "tools")
104+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
105+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
106+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom)
107+ f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
108+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
109+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
110+ assert.ErrorIs(t, err, errBoom)
111+ })
112+
113+ t.Run("cleanup survives a canceled context", func(t *testing.T) {
114+ f := newFixture(t)
115+ n := name(t, "tools")
116+ cctx, cancel := context.WithCancel(ctx)
117+ f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil)
118+ f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil)
119+ f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n).
120+ RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
121+ cancel()
122+ return errBoom
123+ })
124+ f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error {
125+ return c.Err()
126+ })
127+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error {
128+ return c.Err()
129+ })
130+ _, err := f.repos.Create(cctx, domain.Root(), app.CreateParams{Name: n})
131+ assert.ErrorIs(t, err, errBoom)
132+ })
133+}
134+
135+// A fake store that enforces absence, to model concurrent creates.
136+type memMeta struct {
137+ mu sync.Mutex
138+ m map[domain.RepoName]domain.Repo
139+}
140+
141+func (s *memMeta) create(repo domain.Repo) error {
142+ s.mu.Lock()
143+ defer s.mu.Unlock()
144+ if _, ok := s.m[repo.Name]; ok {
145+ return domain.ErrAlreadyExists
146+ }
147+ s.m[repo.Name] = repo
148+ return nil
149+}
150+
151+func TestCreateConcurrentSameName(t *testing.T) {
152+ ctx := context.Background()
153+ n := name(t, "tools")
154+ store := &memMeta{m: map[domain.RepoName]domain.Repo{}}
155+
156+ f := newFixture(t)
157+ var mu sync.Mutex
158+ staged, discarded, published := 0, 0, 0
159+ f.git.EXPECT().Stage(mock.Anything).RunAndReturn(func(context.Context) (ports.StagedID, error) {
160+ mu.Lock()
161+ defer mu.Unlock()
162+ staged++
163+ return ports.StagedID(string(rune('a' + staged))), nil
164+ })
165+ f.meta.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r domain.Repo) error {
166+ return store.create(r)
167+ })
168+ f.git.EXPECT().Publish(mock.Anything, mock.Anything, n).RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
169+ mu.Lock()
170+ defer mu.Unlock()
171+ published++
172+ return nil
173+ })
174+ f.git.EXPECT().Discard(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.StagedID) error {
175+ mu.Lock()
176+ defer mu.Unlock()
177+ discarded++
178+ return nil
179+ })
180+
181+ const workers = 8
182+ errs := make(chan error, workers)
183+ var wg sync.WaitGroup
184+ for range workers {
185+ wg.Add(1)
186+ go func() {
187+ defer wg.Done()
188+ _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
189+ errs <- err
190+ }()
191+ }
192+ wg.Wait()
193+ close(errs)
194+
195+ ok, exists := 0, 0
196+ for err := range errs {
197+ switch {
198+ case err == nil:
199+ ok++
200+ case errors.Is(err, domain.ErrAlreadyExists):
201+ exists++
202+ default:
203+ t.Fatalf("unexpected error: %v", err)
204+ }
205+ }
206+ assert.Equal(t, 1, ok)
207+ assert.Equal(t, workers-1, exists)
208+ assert.Equal(t, 1, published)
209+ assert.Equal(t, workers-1, discarded)
210+}
211+
212+func TestGetAndListAccess(t *testing.T) {
213+ ctx := context.Background()
214+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
215+ priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
216+
217+ tests := []struct {
218+ name string
219+ principal domain.Principal
220+ get domain.Repo
221+ wantErr error
222+ }{
223+ {"anonymous reads public", domain.Anonymous(), pub, nil},
224+ {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
225+ {"root reads public", domain.Root(), pub, nil},
226+ {"root reads private", domain.Root(), priv, nil},
227+ }
228+ for _, tt := range tests {
229+ t.Run("get/"+tt.name, func(t *testing.T) {
230+ f := newFixture(t)
231+ f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil)
232+ got, err := f.repos.Get(ctx, tt.principal, tt.get.Name)
233+ if tt.wantErr != nil {
234+ assert.ErrorIs(t, err, tt.wantErr)
235+ assert.Zero(t, got)
236+ return
237+ }
238+ require.NoError(t, err)
239+ assert.Equal(t, tt.get, got)
240+ })
241+ }
242+
243+ t.Run("get missing is not found", func(t *testing.T) {
244+ f := newFixture(t)
245+ f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound)
246+ _, err := f.repos.Get(ctx, domain.Root(), pub.Name)
247+ assert.ErrorIs(t, err, domain.ErrNotFound)
248+ })
249+
250+ t.Run("private missing and hidden look the same", func(t *testing.T) {
251+ f := newFixture(t)
252+ f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
253+ _, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name)
254+ f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
255+ _, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone"))
256+ assert.Equal(t, errMissing, errHidden)
257+ })
258+
259+ listTests := []struct {
260+ name string
261+ principal domain.Principal
262+ want []domain.Repo
263+ }{
264+ {"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}},
265+ {"root lists all", domain.Root(), []domain.Repo{priv, pub}},
266+ }
267+ for _, tt := range listTests {
268+ t.Run("list/"+tt.name, func(t *testing.T) {
269+ f := newFixture(t)
270+ f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil)
271+ got, err := f.repos.List(ctx, tt.principal)
272+ require.NoError(t, err)
273+ assert.Equal(t, tt.want, got)
274+ })
275+ }
276+
277+ t.Run("list error", func(t *testing.T) {
278+ f := newFixture(t)
279+ boom := errors.New("boom")
280+ f.meta.EXPECT().List(ctx).Return(nil, boom)
281+ _, err := f.repos.List(ctx, domain.Root())
282+ assert.ErrorIs(t, err, boom)
283+ })
284+}
285+
286+func TestReconcile(t *testing.T) {
287+ ctx := context.Background()
288+
289+ t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) {
290+ f := newFixture(t)
291+ orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost")
292+ f.git.EXPECT().PurgeStaging(ctx).Return(nil)
293+ f.git.EXPECT().List(ctx).Return(ports.RepoDirs{
294+ Names: []domain.RepoName{orphan, kept},
295+ Invalid: []string{"Bad Name.git"},
296+ }, nil)
297+ f.meta.EXPECT().List(ctx).Return([]domain.Repo{
298+ {Name: ghost, Visibility: domain.VisibilityPublic},
299+ {Name: kept, Visibility: domain.VisibilityPublic},
300+ }, nil)
301+ f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil)
302+
303+ rep, err := f.repos.Reconcile(ctx)
304+ require.NoError(t, err)
305+ assert.Equal(t, []domain.RepoName{orphan}, rep.Registered)
306+ assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs)
307+ assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs)
308+ })
309+
310+ t.Run("purge failure stops", func(t *testing.T) {
311+ f := newFixture(t)
312+ boom := errors.New("boom")
313+ f.git.EXPECT().PurgeStaging(ctx).Return(boom)
314+ _, err := f.repos.Reconcile(ctx)
315+ assert.ErrorIs(t, err, boom)
316+ })
317+
318+ t.Run("register failure stops", func(t *testing.T) {
319+ f := newFixture(t)
320+ boom := errors.New("boom")
321+ orphan := name(t, "orphan")
322+ f.git.EXPECT().PurgeStaging(ctx).Return(nil)
323+ f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil)
324+ f.meta.EXPECT().List(ctx).Return(nil, nil)
325+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom)
326+ _, err := f.repos.Reconcile(ctx)
327+ assert.ErrorIs(t, err, boom)
328+ })
329+}

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