v0.31.0 d5e0505

repos.go RawHistoryFolder

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}

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