1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "context"
7 "crypto/rand"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "io/fs"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "time"
17
18 "github.com/bnema/zerowrap"
19 "golang.org/x/sys/unix"
20
21 "bnema.dev/ermite/internal/domain"
22 "bnema.dev/ermite/internal/ports"
23)
24
25var _ ports.GitRepos = (*Git)(nil)
26
27const gitSuffix = ".git"
28
29func (g *Git) reposDir() string { return filepath.Join(g.dataDir, "repos") }
30func (g *Git) stagingDir() string { return filepath.Join(g.reposDir(), ".staging") }
31func (g *Git) trashDir() string { return filepath.Join(g.dataDir, "trash") }
32
33// Stage runs `git init --bare` in a new directory under repos/.staging/ and
34// sets the repository defaults. The initial branch is main.
35func (g *Git) Stage(ctx context.Context) (ports.StagedID, error) {
36 var raw [16]byte
37 if _, err := rand.Read(raw[:]); err != nil {
38 return "", fmt.Errorf("random staging name: %w", err)
39 }
40 id := ports.StagedID(hex.EncodeToString(raw[:]))
41 dir := filepath.Join(g.stagingDir(), string(id))
42
43 _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir)
44 if err == nil {
45 // changedPathsVersion=2: the Bloom filters of the commit-graph hash
46 // like v2, which has no signed/unsigned char ambiguity (readers on
47 // other platforms agree with the writer).
48 for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}, {"commitGraph.changedPathsVersion", "2"}} {
49 if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil {
50 break
51 }
52 }
53 }
54 if err != nil {
55 if rerr := os.RemoveAll(dir); rerr != nil {
56 log := zerowrap.FromCtx(ctx)
57 log.Error().Err(rerr).Str("dir", dir).Msg("remove failed staging directory")
58 }
59 return "", fmt.Errorf("stage repository: %w", err)
60 }
61 log := zerowrap.FromCtx(ctx)
62 log.Debug().Str("staged", string(id)).Msg("repository staged")
63 return id, nil
64}
65
66// Publish renames the staged repository to repos/<name>.git without
67// replacing anything.
68func (g *Git) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error {
69 src, err := g.stagedPath(id)
70 if err != nil {
71 return err
72 }
73 dst := filepath.Join(g.reposDir(), name.String()+gitSuffix)
74 if err := renameNoReplace(src, dst); err != nil {
75 if errors.Is(err, fs.ErrExist) {
76 return domain.ErrAlreadyExists
77 }
78 return fmt.Errorf("publish repository %q: %w", name, err)
79 }
80 log := zerowrap.FromCtx(ctx)
81 // The rename succeeded: the repository exists under its final name. A
82 // failed sync only weakens crash durability; returning an error here
83 // would make the caller roll back the metadata of a published directory.
84 if err := syncDir(g.reposDir()); err != nil {
85 log.Warn().Err(err).Str("repo", name.String()).Msg("sync repositories directory after publish")
86 }
87 log.Debug().Str("repo", name.String()).Msg("repository published")
88 return nil
89}
90
91// Discard removes a staged repository.
92func (g *Git) Discard(_ context.Context, id ports.StagedID) error {
93 dir, err := g.stagedPath(id)
94 if err != nil {
95 return err
96 }
97 if err := os.RemoveAll(dir); err != nil {
98 return fmt.Errorf("discard staged repository %s: %w", id, err)
99 }
100 return nil
101}
102
103// PurgeStaging removes everything under repos/.staging/.
104//
105//hexcheck:ignore suspicious-business-logic-in-adapter removes staging leftovers from disk, tolerates a missing directory, and logs each removal
106func (g *Git) PurgeStaging(ctx context.Context) error {
107 entries, err := os.ReadDir(g.stagingDir())
108 if err != nil {
109 if errors.Is(err, fs.ErrNotExist) {
110 return nil
111 }
112 return fmt.Errorf("read staging directory: %w", err)
113 }
114 for _, e := range entries {
115 if err := os.RemoveAll(filepath.Join(g.stagingDir(), e.Name())); err != nil {
116 return fmt.Errorf("remove staging leftover %s: %w", e.Name(), err)
117 }
118 log := zerowrap.FromCtx(ctx)
119 log.Info().Str("entry", e.Name()).Msg("removed staging leftover")
120 }
121 return nil
122}
123
124// List returns the repository directories. Entries that are not
125// "<valid name>.git" directories are reported as invalid; .staging is not an
126// entry.
127func (g *Git) List(_ context.Context) (ports.RepoDirs, error) {
128 entries, err := os.ReadDir(g.reposDir())
129 if err != nil {
130 return ports.RepoDirs{}, fmt.Errorf("read repositories directory: %w", err)
131 }
132 var out ports.RepoDirs
133 for _, e := range entries {
134 if e.Name() == ".staging" {
135 continue
136 }
137 base, ok := strings.CutSuffix(e.Name(), gitSuffix)
138 if !ok || !e.IsDir() {
139 out.Invalid = append(out.Invalid, e.Name())
140 continue
141 }
142 name, err := domain.ParseRepoName(base)
143 if err != nil {
144 out.Invalid = append(out.Invalid, e.Name())
145 continue
146 }
147 out.Names = append(out.Names, name)
148 }
149 return out, nil
150}
151
152// Trash moves repos/<name>.git to trash/<name>-<unix>.git.
153func (g *Git) Trash(ctx context.Context, name domain.RepoName) error {
154 src := filepath.Join(g.reposDir(), name.String()+gitSuffix)
155 if _, err := os.Lstat(src); err != nil {
156 if errors.Is(err, fs.ErrNotExist) {
157 return domain.ErrNotFound
158 }
159 return fmt.Errorf("trash repository %q: %w", name, err)
160 }
161 stamp := time.Now().Unix()
162 for i := range 100 {
163 dst := filepath.Join(g.trashDir(), fmt.Sprintf("%s-%d%s", name, stamp+int64(i), gitSuffix))
164 err := renameNoReplace(src, dst)
165 if errors.Is(err, fs.ErrExist) {
166 continue
167 }
168 if err != nil {
169 return fmt.Errorf("trash repository %q: %w", name, err)
170 }
171 // A cat-file process of the old directory must not serve a repository
172 // created later under the same name. This runs after the rename, not
173 // before: a rename that fails leaves the repository where it was, with
174 // its process still serving it, and a process dropped first would only
175 // be started again by the next read. Reads between the rename and the
176 // forget find no directory (repoDir fails first), and the pool is keyed
177 // by directory, so the stale process cannot answer for a new repository
178 // before it is dropped here.
179 g.cat.forget(src)
180 if fn := g.onTrash.Load(); fn != nil {
181 (*fn)(src)
182 }
183 if err := syncDir(g.reposDir()); err != nil {
184 return fmt.Errorf("trash repository %q: %w", name, err)
185 }
186 log := zerowrap.FromCtx(ctx)
187 log.Info().Str("repo", name.String()).Str("trash", filepath.Base(dst)).Msg("repository moved to trash")
188 return nil
189 }
190 return fmt.Errorf("trash repository %q: no free name in trash", name)
191}
192
193// stagedPath returns the directory of a staged repository, rejecting
194// identifiers that are not the hex names Stage produces.
195func (g *Git) stagedPath(id ports.StagedID) (string, error) {
196 if _, err := hex.DecodeString(string(id)); err != nil || len(id) != 32 {
197 return "", fmt.Errorf("invalid staged repository id %q", id)
198 }
199 return filepath.Join(g.stagingDir(), string(id)), nil
200}
201
202// renameNoReplace renames src to dst and fails with fs.ErrExist if dst
203// exists, even as an empty directory. Filesystems without RENAME_NOREPLACE
204// fall back to a check before the rename.
205func renameNoReplace(src, dst string) error {
206 err := unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
207 switch {
208 case err == nil:
209 return nil
210 case errors.Is(err, unix.EEXIST):
211 return fs.ErrExist
212 case errors.Is(err, unix.EINVAL), errors.Is(err, unix.ENOSYS), errors.Is(err, unix.ENOTSUP):
213 if _, serr := os.Lstat(dst); serr == nil {
214 return fs.ErrExist
215 }
216 return os.Rename(src, dst)
217 }
218 return &os.LinkError{Op: "rename", Old: src, New: dst, Err: err}
219}
220
221// syncDir flushes a directory so a rename in it survives a crash.
222func syncDir(dir string) error {
223 f, err := os.Open(dir)
224 if err != nil {
225 return err
226 }
227 defer func() { _ = f.Close() }()
228 return f.Sync()
229}
230
231// RepoDir returns the directory of the repository, repos/<name>.git below the
232// data directory. It does not check that it exists.
233func RepoDir(dataDir string, name domain.RepoName) string {
234 return filepath.Join(dataDir, "repos", name.String()+gitSuffix)
235}
236
237// OnTrash registers fn, which Trash calls with the directory the repository
238// had, after it moved it away. Another reader of the repository (the
239// ermitegit adapter) uses it to drop what it holds open. Call it once while
240// wiring, before the adapter serves.
241func (g *Git) OnTrash(fn func(dir string)) { g.onTrash.Store(&fn) }
242
243// repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing.
244func (g *Git) repoDir(name domain.RepoName) (string, error) {
245 dir := RepoDir(g.dataDir, name)
246 fi, err := os.Stat(dir)
247 if err != nil {
248 if errors.Is(err, fs.ErrNotExist) {
249 return "", domain.ErrNotFound
250 }
251 return "", fmt.Errorf("repository %q: %w", name, err)
252 }
253 if !fi.IsDir() {
254 return "", domain.ErrNotFound
255 }
256 return dir, nil
257}
258
259// DefaultBranch reads HEAD and checks the branch with `git for-each-ref`.
260func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string, bool, error) {
261 dir, err := g.repoDir(name)
262 if err != nil {
263 return "", false, err
264 }
265 ref, err := readHead(dir)
266 if err != nil {
267 return "", false, fmt.Errorf("read HEAD of %q: %w", name, err)
268 }
269 branch, ok := strings.CutPrefix(ref, headsRefPrefix)
270 if !ok {
271 return "", false, fmt.Errorf("HEAD of %q points outside refs/heads: %q", name, ref)
272 }
273 out, err := g.run(ctx, "-C", dir, "for-each-ref", "--count=1", "--format=%(refname)", ref)
274 if err != nil {
275 return "", false, fmt.Errorf("look up %s in %q: %w", ref, name, err)
276 }
277 return branch, strings.TrimSpace(out) == ref, nil
278}
279
280// HasBranch checks refs/heads/<branch> with `git for-each-ref` after
281// `git check-ref-format`, which rejects the glob characters for-each-ref
282// would otherwise interpret.
283func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) {
284 dir, err := g.repoDir(name)
285 if err != nil {
286 return false, err
287 }
288 ref := headsRefPrefix + branch
289 if branch == "" || strings.HasPrefix(branch, "-") {
290 return false, nil
291 }
292 if _, err := g.run(ctx, "check-ref-format", ref); err != nil {
293 var exit *exec.ExitError
294 if errors.As(err, &exit) {
295 return false, nil
296 }
297 return false, err
298 }
299 out, err := g.run(ctx, "-C", dir, "for-each-ref", "--format=%(refname)", ref)
300 if err != nil {
301 return false, fmt.Errorf("look up %s in %q: %w", ref, name, err)
302 }
303 return strings.TrimSpace(out) == ref, nil
304}
305
306// HeadBranch reads the HEAD file of the bare repository (files ref backend).
307// It is empty when HEAD is detached.
308func (g *Git) HeadBranch(_ context.Context, name domain.RepoName) (string, error) {
309 dir, err := g.repoDir(name)
310 if err != nil {
311 return "", err
312 }
313 ref, err := readHead(dir)
314 if err != nil {
315 return "", fmt.Errorf("read HEAD of %q: %w", name, err)
316 }
317 return strings.TrimPrefix(ref, headsRefPrefix), nil
318}
319
320// SetDefaultBranch runs `git symbolic-ref HEAD refs/heads/<branch>`.
321func (g *Git) SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error {
322 dir, err := g.repoDir(name)
323 if err != nil {
324 return err
325 }
326 if branch == "" || strings.HasPrefix(branch, "-") {
327 return fmt.Errorf("invalid branch name %q", branch)
328 }
329 if _, err := g.run(ctx, "check-ref-format", "--branch", branch); err != nil {
330 return fmt.Errorf("invalid branch name %q: %w", branch, err)
331 }
332 if _, err := g.run(ctx, "-C", dir, "symbolic-ref", "HEAD", headsRefPrefix+branch); err != nil {
333 return fmt.Errorf("set HEAD of %q: %w", name, err)
334 }
335 return nil
336}
337
338// Maintain runs `git commit-graph write --reachable --changed-paths --split`.
339func (g *Git) Maintain(ctx context.Context, name domain.RepoName) error {
340 dir, err := g.repoDir(name)
341 if err != nil {
342 return err
343 }
344 if _, err := g.run(ctx, "-C", dir, "commit-graph", "write", "--reachable", "--changed-paths", "--split"); err != nil {
345 return fmt.Errorf("maintain %q: %w", name, err)
346 }
347 return nil
348}