v0.2.0 41cf2ec

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		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}

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