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