detached 9db7dbf

feat(gitcli): stage and publish bare repositories

commit
9db7dbfea0c698a27a2e5c6ea5f4604e381ee530
author
brice · · 2026-10-10 06:13 UTC
parent
3c97d93
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +691 -3
M go.mod +2 -1
@@ -10,8 +10,10 @@ tool (
1010 )
1111
1212 require (
13+ bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1314 github.com/bnema/zerowrap v1.4.1
1415 github.com/stretchr/testify v1.12.1
16+ golang.org/x/sys v0.49.0
1517 google.golang.org/protobuf v1.36.12
1618 )
1719
@@ -120,7 +122,6 @@ require (
120122 golang.org/x/mod v0.41.0 // indirect
121123 golang.org/x/net v0.59.0 // indirect
122124 golang.org/x/sync v0.23.0 // indirect
123- golang.org/x/sys v0.48.0 // indirect
124125 golang.org/x/term v0.46.0 // indirect
125126 golang.org/x/text v0.42.0 // indirect
126127 golang.org/x/tools v0.51.0 // indirect
@@ -10,8 +10,10 @@ tool (
10 )10 )
11 11
12 require (12 require (
13+ bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
13 github.com/bnema/zerowrap v1.4.114 github.com/bnema/zerowrap v1.4.1
14 github.com/stretchr/testify v1.12.115 github.com/stretchr/testify v1.12.1
16+ golang.org/x/sys v0.49.0
15 google.golang.org/protobuf v1.36.1217 google.golang.org/protobuf v1.36.12
16 )18 )
17 19
@@ -120,7 +122,6 @@ require (
120 golang.org/x/mod v0.41.0 // indirect122 golang.org/x/mod v0.41.0 // indirect
121 golang.org/x/net v0.59.0 // indirect123 golang.org/x/net v0.59.0 // indirect
122 golang.org/x/sync v0.23.0 // indirect124 golang.org/x/sync v0.23.0 // indirect
123- golang.org/x/sys v0.48.0 // indirect
124 golang.org/x/term v0.46.0 // indirect125 golang.org/x/term v0.46.0 // indirect
125 golang.org/x/text v0.42.0 // indirect126 golang.org/x/text v0.42.0 // indirect
126 golang.org/x/tools v0.51.0 // indirect127 golang.org/x/tools v0.51.0 // indirect
M go.sum +4 -2 generated file: collapsed
A internal/adapters/gitcli/git.go +88 -0
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package gitcli implements the Git ports over the git binary. Every
4+// invocation runs in an isolated environment that is built here and never
5+// inherits the caller's.
6+package gitcli
7+
8+import (
9+ "bytes"
10+ "context"
11+ "errors"
12+ "fmt"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "time"
17+)
18+
19+// Config configures a Git adapter. The adapter never reads the process
20+// environment: the caller passes what it needs.
21+type Config struct {
22+ // Bin is the git executable: a name looked up in the process PATH, or a path.
23+ Bin string
24+ // DataDir is the data root (absolute). repos/, repos/.staging/ and
25+ // trash/ below it must exist; run/git-home/ is the HOME of git.
26+ DataDir string
27+ // Path is the PATH given to git, which it uses to find its helpers.
28+ Path string
29+}
30+
31+// Git is the git CLI adapter for ports.GitRepos.
32+type Git struct {
33+ bin string
34+ dataDir string
35+ env []string
36+}
37+
38+// New builds an adapter. It does not run git: call CheckVersion.
39+func New(cfg Config) *Git {
40+ bin := cfg.Bin
41+ if bin == "" {
42+ bin = "git"
43+ }
44+ return &Git{bin: bin, dataDir: cfg.DataDir, env: buildEnv(cfg.DataDir, cfg.Path)}
45+}
46+
47+// buildEnv returns the complete environment of every git invocation. Nothing
48+// else is passed: no GIT_* variable, no user HOME, no locale.
49+func buildEnv(dataDir, path string) []string {
50+ return []string{
51+ "GIT_CONFIG_NOSYSTEM=1",
52+ "HOME=" + filepath.Join(dataDir, "run", "git-home"),
53+ "GIT_TERMINAL_PROMPT=0",
54+ "LC_ALL=C",
55+ "PATH=" + path,
56+ }
57+}
58+
59+// commandTimeout bounds one git invocation when ctx has no deadline.
60+const commandTimeout = 2 * time.Minute
61+
62+// run executes git with args in the isolated environment and returns stdout.
63+func (g *Git) run(ctx context.Context, args ...string) (string, error) {
64+ if _, ok := ctx.Deadline(); !ok {
65+ var cancel context.CancelFunc
66+ ctx, cancel = context.WithTimeout(ctx, commandTimeout)
67+ defer cancel()
68+ }
69+ cmd := exec.CommandContext(ctx, g.bin, args...)
70+ cmd.Env = g.env
71+ cmd.Dir = g.dataDir
72+ cmd.WaitDelay = 5 * time.Second
73+ var stdout, stderr bytes.Buffer
74+ cmd.Stdout = &stdout
75+ cmd.Stderr = &stderr
76+ if err := cmd.Run(); err != nil {
77+ msg := strings.TrimSpace(stderr.String())
78+ if len(msg) > 512 {
79+ msg = msg[:512] + "…"
80+ }
81+ var exit *exec.ExitError
82+ if errors.As(err, &exit) && msg != "" {
83+ return "", fmt.Errorf("git %s: %w: %s", args[0], err, msg)
84+ }
85+ return "", fmt.Errorf("git %s: %w", args[0], err)
86+ }
87+ return stdout.String(), nil
88+}
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package gitcli implements the Git ports over the git binary. Every
4+// invocation runs in an isolated environment that is built here and never
5+// inherits the caller's.
6+package gitcli
7+
8+import (
9+ "bytes"
10+ "context"
11+ "errors"
12+ "fmt"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "time"
17+)
18+
19+// Config configures a Git adapter. The adapter never reads the process
20+// environment: the caller passes what it needs.
21+type Config struct {
22+ // Bin is the git executable: a name looked up in the process PATH, or a path.
23+ Bin string
24+ // DataDir is the data root (absolute). repos/, repos/.staging/ and
25+ // trash/ below it must exist; run/git-home/ is the HOME of git.
26+ DataDir string
27+ // Path is the PATH given to git, which it uses to find its helpers.
28+ Path string
29+}
30+
31+// Git is the git CLI adapter for ports.GitRepos.
32+type Git struct {
33+ bin string
34+ dataDir string
35+ env []string
36+}
37+
38+// New builds an adapter. It does not run git: call CheckVersion.
39+func New(cfg Config) *Git {
40+ bin := cfg.Bin
41+ if bin == "" {
42+ bin = "git"
43+ }
44+ return &Git{bin: bin, dataDir: cfg.DataDir, env: buildEnv(cfg.DataDir, cfg.Path)}
45+}
46+
47+// buildEnv returns the complete environment of every git invocation. Nothing
48+// else is passed: no GIT_* variable, no user HOME, no locale.
49+func buildEnv(dataDir, path string) []string {
50+ return []string{
51+ "GIT_CONFIG_NOSYSTEM=1",
52+ "HOME=" + filepath.Join(dataDir, "run", "git-home"),
53+ "GIT_TERMINAL_PROMPT=0",
54+ "LC_ALL=C",
55+ "PATH=" + path,
56+ }
57+}
58+
59+// commandTimeout bounds one git invocation when ctx has no deadline.
60+const commandTimeout = 2 * time.Minute
61+
62+// run executes git with args in the isolated environment and returns stdout.
63+func (g *Git) run(ctx context.Context, args ...string) (string, error) {
64+ if _, ok := ctx.Deadline(); !ok {
65+ var cancel context.CancelFunc
66+ ctx, cancel = context.WithTimeout(ctx, commandTimeout)
67+ defer cancel()
68+ }
69+ cmd := exec.CommandContext(ctx, g.bin, args...)
70+ cmd.Env = g.env
71+ cmd.Dir = g.dataDir
72+ cmd.WaitDelay = 5 * time.Second
73+ var stdout, stderr bytes.Buffer
74+ cmd.Stdout = &stdout
75+ cmd.Stderr = &stderr
76+ if err := cmd.Run(); err != nil {
77+ msg := strings.TrimSpace(stderr.String())
78+ if len(msg) > 512 {
79+ msg = msg[:512] + "…"
80+ }
81+ var exit *exec.ExitError
82+ if errors.As(err, &exit) && msg != "" {
83+ return "", fmt.Errorf("git %s: %w: %s", args[0], err, msg)
84+ }
85+ return "", fmt.Errorf("git %s: %w", args[0], err)
86+ }
87+ return stdout.String(), nil
88+}
A internal/adapters/gitcli/gitcli_test.go +321 -0
@@ -0,0 +1,321 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/adapters/gitcli"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func TestParseVersion(t *testing.T) {
24+ tests := []struct {
25+ in string
26+ want gitcli.Version
27+ wantErr bool
28+ }{
29+ {in: "git version 2.45.0\n", want: gitcli.Version{Major: 2, Minor: 45}},
30+ {in: "git version 2.56.0", want: gitcli.Version{Major: 2, Minor: 56}},
31+ {in: "git version 2.39.5 (Apple Git-154)\n", want: gitcli.Version{Major: 2, Minor: 39, Patch: 5}},
32+ {in: "git version 2.46.0.rc1\n", want: gitcli.Version{Major: 2, Minor: 46}},
33+ {in: "git version 2.47.1.windows.1", want: gitcli.Version{Major: 2, Minor: 47, Patch: 1}},
34+ {in: "git version 3.0", want: gitcli.Version{Major: 3}},
35+ {in: "git version", wantErr: true},
36+ {in: "", wantErr: true},
37+ {in: "hg version 6.0", wantErr: true},
38+ {in: "git version two.five", wantErr: true},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.in, func(t *testing.T) {
42+ got, err := gitcli.ParseVersion(tt.in)
43+ if tt.wantErr {
44+ assert.Error(t, err)
45+ return
46+ }
47+ require.NoError(t, err)
48+ assert.Equal(t, tt.want, got)
49+ })
50+ }
51+}
52+
53+func TestVersionLess(t *testing.T) {
54+ min := gitcli.MinVersion
55+ assert.True(t, gitcli.Version{Major: 2, Minor: 44, Patch: 9}.Less(min))
56+ assert.True(t, gitcli.Version{Major: 1, Minor: 99}.Less(min))
57+ assert.False(t, gitcli.Version{Major: 2, Minor: 45}.Less(min))
58+ assert.False(t, gitcli.Version{Major: 2, Minor: 46}.Less(min))
59+ assert.False(t, gitcli.Version{Major: 3}.Less(min))
60+}
61+
62+// newGit prepares a data dir and an adapter; it skips when git is missing.
63+func newGit(t *testing.T) (*gitcli.Git, string) {
64+ t.Helper()
65+ bin, err := exec.LookPath("git")
66+ if err != nil {
67+ t.Skip("git not found")
68+ }
69+ data := t.TempDir()
70+ for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
71+ require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
72+ }
73+ return gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")}), data
74+}
75+
76+func name(t *testing.T, s string) domain.RepoName {
77+ t.Helper()
78+ n, err := domain.ParseRepoName(s)
79+ require.NoError(t, err)
80+ return n
81+}
82+
83+func TestCheckVersion(t *testing.T) {
84+ g, _ := newGit(t)
85+ v, err := g.CheckVersion(context.Background())
86+ require.NoError(t, err)
87+ assert.False(t, v.Less(gitcli.MinVersion))
88+}
89+
90+func writeScript(t *testing.T, body string) string {
91+ t.Helper()
92+ p := filepath.Join(t.TempDir(), "git")
93+ require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o700))
94+ return p
95+}
96+
97+func TestCheckVersionRejects(t *testing.T) {
98+ ctx := context.Background()
99+ data := t.TempDir()
100+ cfg := func(bin string) gitcli.Config { return gitcli.Config{Bin: bin, DataDir: data, Path: "/usr/bin:/bin"} }
101+
102+ t.Run("too old", func(t *testing.T) {
103+ _, err := gitcli.New(cfg(writeScript(t, `echo "git version 2.44.9"`))).CheckVersion(ctx)
104+ require.Error(t, err)
105+ assert.Contains(t, err.Error(), "older than")
106+ })
107+ t.Run("garbage output", func(t *testing.T) {
108+ _, err := gitcli.New(cfg(writeScript(t, `echo hello`))).CheckVersion(ctx)
109+ assert.Error(t, err)
110+ })
111+ t.Run("missing binary", func(t *testing.T) {
112+ _, err := gitcli.New(cfg(filepath.Join(data, "no-such-git"))).CheckVersion(ctx)
113+ assert.Error(t, err)
114+ })
115+ t.Run("failing binary", func(t *testing.T) {
116+ _, err := gitcli.New(cfg(writeScript(t, `echo broken >&2; exit 3`))).CheckVersion(ctx)
117+ require.Error(t, err)
118+ assert.Contains(t, err.Error(), "broken")
119+ })
120+}
121+
122+func TestIsolatedEnvironment(t *testing.T) {
123+ ctx := context.Background()
124+ data := t.TempDir()
125+ out := filepath.Join(data, "env.txt")
126+ script := writeScript(t, `env > "`+out+`"; echo "git version 2.50.0"`)
127+ // Variables that must not reach git.
128+ t.Setenv("GIT_DIR", "/nonexistent")
129+ t.Setenv("GIT_AUTHOR_NAME", "leak")
130+ t.Setenv("GIT_CONFIG_GLOBAL", "/leak")
131+ t.Setenv("XDG_CONFIG_HOME", "/leak")
132+ t.Setenv("SECRET_TOKEN", "leak")
133+
134+ g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
135+ _, err := g.CheckVersion(ctx)
136+ require.NoError(t, err)
137+
138+ raw, err := os.ReadFile(out)
139+ require.NoError(t, err)
140+ got := map[string]string{}
141+ for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
142+ k, v, _ := strings.Cut(l, "=")
143+ got[k] = v
144+ }
145+ assert.Equal(t, "1", got["GIT_CONFIG_NOSYSTEM"])
146+ assert.Equal(t, filepath.Join(data, "run", "git-home"), got["HOME"])
147+ assert.Equal(t, "0", got["GIT_TERMINAL_PROMPT"])
148+ assert.Equal(t, "/usr/bin:/bin", got["PATH"])
149+ for k, v := range got {
150+ assert.NotContains(t, v, "leak", k)
151+ switch k {
152+ case "GIT_DIR", "GIT_AUTHOR_NAME", "GIT_CONFIG_GLOBAL", "XDG_CONFIG_HOME", "SECRET_TOKEN":
153+ t.Errorf("variable %s leaked into git", k)
154+ }
155+ }
156+}
157+
158+func gitOut(t *testing.T, dir string, args ...string) string {
159+ t.Helper()
160+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
161+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1"}
162+ out, err := cmd.CombinedOutput()
163+ require.NoError(t, err, string(out))
164+ return strings.TrimSpace(string(out))
165+}
166+
167+func TestStagePublish(t *testing.T) {
168+ ctx := context.Background()
169+ g, data := newGit(t)
170+
171+ id, err := g.Stage(ctx)
172+ require.NoError(t, err)
173+ staged := filepath.Join(data, "repos", ".staging", string(id))
174+ require.DirExists(t, staged)
175+
176+ assert.Equal(t, "true", gitOut(t, staged, "rev-parse", "--is-bare-repository"))
177+ assert.Equal(t, "sha1", gitOut(t, staged, "rev-parse", "--show-object-format"))
178+ assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format"))
179+ assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps"))
180+ assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "core.commitGraph"))
181+ assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))
182+
183+ n := name(t, "tools")
184+ require.NoError(t, g.Publish(ctx, id, n))
185+ assert.NoDirExists(t, staged)
186+ assert.Equal(t, "true", gitOut(t, filepath.Join(data, "repos", "tools.git"), "rev-parse", "--is-bare-repository"))
187+
188+ // A second staged repo cannot take the same name and is left for Discard.
189+ id2, err := g.Stage(ctx)
190+ require.NoError(t, err)
191+ err = g.Publish(ctx, id2, n)
192+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
193+ assert.DirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
194+ require.NoError(t, g.Discard(ctx, id2))
195+ assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
196+ require.NoError(t, g.Discard(ctx, id2), "discarding twice is not an error")
197+}
198+
199+func TestPublishOverEmptyDirectoryDoesNotReplace(t *testing.T) {
200+ ctx := context.Background()
201+ g, data := newGit(t)
202+ require.NoError(t, os.Mkdir(filepath.Join(data, "repos", "taken.git"), 0o700))
203+ id, err := g.Stage(ctx)
204+ require.NoError(t, err)
205+ assert.ErrorIs(t, g.Publish(ctx, id, name(t, "taken")), domain.ErrAlreadyExists)
206+}
207+
208+func TestInvalidStagedID(t *testing.T) {
209+ ctx := context.Background()
210+ g, data := newGit(t)
211+ for _, id := range []ports.StagedID{"", "../../trash", "zz", ports.StagedID(strings.Repeat("g", 32))} {
212+ assert.Error(t, g.Discard(ctx, id), string(id))
213+ assert.Error(t, g.Publish(ctx, id, name(t, "x")), string(id))
214+ }
215+ assert.DirExists(t, filepath.Join(data, "trash"))
216+}
217+
218+func TestConcurrentPublishSameName(t *testing.T) {
219+ ctx := context.Background()
220+ g, data := newGit(t)
221+ n := name(t, "race")
222+
223+ const workers = 6
224+ ids := make([]ports.StagedID, workers)
225+ for i := range ids {
226+ id, err := g.Stage(ctx)
227+ require.NoError(t, err)
228+ ids[i] = id
229+ }
230+ errs := make(chan error, workers)
231+ var wg sync.WaitGroup
232+ for _, id := range ids {
233+ wg.Add(1)
234+ go func() {
235+ defer wg.Done()
236+ errs <- g.Publish(ctx, id, n)
237+ }()
238+ }
239+ wg.Wait()
240+ close(errs)
241+ ok, exists := 0, 0
242+ for err := range errs {
243+ switch {
244+ case err == nil:
245+ ok++
246+ case errors.Is(err, domain.ErrAlreadyExists):
247+ exists++
248+ default:
249+ t.Fatalf("unexpected: %v", err)
250+ }
251+ }
252+ assert.Equal(t, 1, ok)
253+ assert.Equal(t, workers-1, exists)
254+ for _, id := range ids {
255+ require.NoError(t, g.Discard(ctx, id))
256+ }
257+ entries, err := os.ReadDir(filepath.Join(data, "repos", ".staging"))
258+ require.NoError(t, err)
259+ assert.Empty(t, entries)
260+}
261+
262+func TestStageFailureLeavesNothing(t *testing.T) {
263+ ctx := context.Background()
264+ data := t.TempDir()
265+ require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging"), 0o700))
266+ // A git that creates the directory and then fails.
267+ script := writeScript(t, `for last; do :; done; mkdir -p "$last"; echo nope >&2; exit 1`)
268+ g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
269+ _, err := g.Stage(ctx)
270+ require.Error(t, err)
271+ entries, rerr := os.ReadDir(filepath.Join(data, "repos", ".staging"))
272+ require.NoError(t, rerr)
273+ assert.Empty(t, entries)
274+}
275+
276+func TestPurgeStagingAndList(t *testing.T) {
277+ ctx := context.Background()
278+ g, data := newGit(t)
279+ repos := filepath.Join(data, "repos")
280+
281+ _, err := g.Stage(ctx)
282+ require.NoError(t, err)
283+ require.NoError(t, os.WriteFile(filepath.Join(repos, ".staging", "stray"), []byte("x"), 0o600))
284+ for _, d := range []string{"good.git", "also-good.git", "Bad.git", "no-suffix", ".hidden.git", "x..y.git"} {
285+ require.NoError(t, os.Mkdir(filepath.Join(repos, d), 0o700))
286+ }
287+ require.NoError(t, os.WriteFile(filepath.Join(repos, "file.git"), []byte("x"), 0o600))
288+
289+ require.NoError(t, g.PurgeStaging(ctx))
290+ entries, err := os.ReadDir(filepath.Join(repos, ".staging"))
291+ require.NoError(t, err)
292+ assert.Empty(t, entries)
293+ assert.DirExists(t, filepath.Join(repos, ".staging"))
294+
295+ dirs, err := g.List(ctx)
296+ require.NoError(t, err)
297+ assert.ElementsMatch(t, []domain.RepoName{name(t, "good"), name(t, "also-good")}, dirs.Names)
298+ assert.ElementsMatch(t, []string{"Bad.git", "no-suffix", ".hidden.git", "x..y.git", "file.git"}, dirs.Invalid)
299+}
300+
301+func TestTrash(t *testing.T) {
302+ ctx := context.Background()
303+ g, data := newGit(t)
304+ n := name(t, "old")
305+
306+ assert.ErrorIs(t, g.Trash(ctx, n), domain.ErrNotFound)
307+
308+ for range 2 {
309+ id, err := g.Stage(ctx)
310+ require.NoError(t, err)
311+ require.NoError(t, g.Publish(ctx, id, n))
312+ require.NoError(t, g.Trash(ctx, n))
313+ }
314+ assert.NoDirExists(t, filepath.Join(data, "repos", "old.git"))
315+ entries, err := os.ReadDir(filepath.Join(data, "trash"))
316+ require.NoError(t, err)
317+ require.Len(t, entries, 2, "two trashings never overwrite each other")
318+ for _, e := range entries {
319+ assert.True(t, strings.HasPrefix(e.Name(), "old-") && strings.HasSuffix(e.Name(), ".git"), e.Name())
320+ }
321+}
@@ -0,0 +1,321 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/adapters/gitcli"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func TestParseVersion(t *testing.T) {
24+ tests := []struct {
25+ in string
26+ want gitcli.Version
27+ wantErr bool
28+ }{
29+ {in: "git version 2.45.0\n", want: gitcli.Version{Major: 2, Minor: 45}},
30+ {in: "git version 2.56.0", want: gitcli.Version{Major: 2, Minor: 56}},
31+ {in: "git version 2.39.5 (Apple Git-154)\n", want: gitcli.Version{Major: 2, Minor: 39, Patch: 5}},
32+ {in: "git version 2.46.0.rc1\n", want: gitcli.Version{Major: 2, Minor: 46}},
33+ {in: "git version 2.47.1.windows.1", want: gitcli.Version{Major: 2, Minor: 47, Patch: 1}},
34+ {in: "git version 3.0", want: gitcli.Version{Major: 3}},
35+ {in: "git version", wantErr: true},
36+ {in: "", wantErr: true},
37+ {in: "hg version 6.0", wantErr: true},
38+ {in: "git version two.five", wantErr: true},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.in, func(t *testing.T) {
42+ got, err := gitcli.ParseVersion(tt.in)
43+ if tt.wantErr {
44+ assert.Error(t, err)
45+ return
46+ }
47+ require.NoError(t, err)
48+ assert.Equal(t, tt.want, got)
49+ })
50+ }
51+}
52+
53+func TestVersionLess(t *testing.T) {
54+ min := gitcli.MinVersion
55+ assert.True(t, gitcli.Version{Major: 2, Minor: 44, Patch: 9}.Less(min))
56+ assert.True(t, gitcli.Version{Major: 1, Minor: 99}.Less(min))
57+ assert.False(t, gitcli.Version{Major: 2, Minor: 45}.Less(min))
58+ assert.False(t, gitcli.Version{Major: 2, Minor: 46}.Less(min))
59+ assert.False(t, gitcli.Version{Major: 3}.Less(min))
60+}
61+
62+// newGit prepares a data dir and an adapter; it skips when git is missing.
63+func newGit(t *testing.T) (*gitcli.Git, string) {
64+ t.Helper()
65+ bin, err := exec.LookPath("git")
66+ if err != nil {
67+ t.Skip("git not found")
68+ }
69+ data := t.TempDir()
70+ for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
71+ require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
72+ }
73+ return gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")}), data
74+}
75+
76+func name(t *testing.T, s string) domain.RepoName {
77+ t.Helper()
78+ n, err := domain.ParseRepoName(s)
79+ require.NoError(t, err)
80+ return n
81+}
82+
83+func TestCheckVersion(t *testing.T) {
84+ g, _ := newGit(t)
85+ v, err := g.CheckVersion(context.Background())
86+ require.NoError(t, err)
87+ assert.False(t, v.Less(gitcli.MinVersion))
88+}
89+
90+func writeScript(t *testing.T, body string) string {
91+ t.Helper()
92+ p := filepath.Join(t.TempDir(), "git")
93+ require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o700))
94+ return p
95+}
96+
97+func TestCheckVersionRejects(t *testing.T) {
98+ ctx := context.Background()
99+ data := t.TempDir()
100+ cfg := func(bin string) gitcli.Config { return gitcli.Config{Bin: bin, DataDir: data, Path: "/usr/bin:/bin"} }
101+
102+ t.Run("too old", func(t *testing.T) {
103+ _, err := gitcli.New(cfg(writeScript(t, `echo "git version 2.44.9"`))).CheckVersion(ctx)
104+ require.Error(t, err)
105+ assert.Contains(t, err.Error(), "older than")
106+ })
107+ t.Run("garbage output", func(t *testing.T) {
108+ _, err := gitcli.New(cfg(writeScript(t, `echo hello`))).CheckVersion(ctx)
109+ assert.Error(t, err)
110+ })
111+ t.Run("missing binary", func(t *testing.T) {
112+ _, err := gitcli.New(cfg(filepath.Join(data, "no-such-git"))).CheckVersion(ctx)
113+ assert.Error(t, err)
114+ })
115+ t.Run("failing binary", func(t *testing.T) {
116+ _, err := gitcli.New(cfg(writeScript(t, `echo broken >&2; exit 3`))).CheckVersion(ctx)
117+ require.Error(t, err)
118+ assert.Contains(t, err.Error(), "broken")
119+ })
120+}
121+
122+func TestIsolatedEnvironment(t *testing.T) {
123+ ctx := context.Background()
124+ data := t.TempDir()
125+ out := filepath.Join(data, "env.txt")
126+ script := writeScript(t, `env > "`+out+`"; echo "git version 2.50.0"`)
127+ // Variables that must not reach git.
128+ t.Setenv("GIT_DIR", "/nonexistent")
129+ t.Setenv("GIT_AUTHOR_NAME", "leak")
130+ t.Setenv("GIT_CONFIG_GLOBAL", "/leak")
131+ t.Setenv("XDG_CONFIG_HOME", "/leak")
132+ t.Setenv("SECRET_TOKEN", "leak")
133+
134+ g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
135+ _, err := g.CheckVersion(ctx)
136+ require.NoError(t, err)
137+
138+ raw, err := os.ReadFile(out)
139+ require.NoError(t, err)
140+ got := map[string]string{}
141+ for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
142+ k, v, _ := strings.Cut(l, "=")
143+ got[k] = v
144+ }
145+ assert.Equal(t, "1", got["GIT_CONFIG_NOSYSTEM"])
146+ assert.Equal(t, filepath.Join(data, "run", "git-home"), got["HOME"])
147+ assert.Equal(t, "0", got["GIT_TERMINAL_PROMPT"])
148+ assert.Equal(t, "/usr/bin:/bin", got["PATH"])
149+ for k, v := range got {
150+ assert.NotContains(t, v, "leak", k)
151+ switch k {
152+ case "GIT_DIR", "GIT_AUTHOR_NAME", "GIT_CONFIG_GLOBAL", "XDG_CONFIG_HOME", "SECRET_TOKEN":
153+ t.Errorf("variable %s leaked into git", k)
154+ }
155+ }
156+}
157+
158+func gitOut(t *testing.T, dir string, args ...string) string {
159+ t.Helper()
160+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
161+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1"}
162+ out, err := cmd.CombinedOutput()
163+ require.NoError(t, err, string(out))
164+ return strings.TrimSpace(string(out))
165+}
166+
167+func TestStagePublish(t *testing.T) {
168+ ctx := context.Background()
169+ g, data := newGit(t)
170+
171+ id, err := g.Stage(ctx)
172+ require.NoError(t, err)
173+ staged := filepath.Join(data, "repos", ".staging", string(id))
174+ require.DirExists(t, staged)
175+
176+ assert.Equal(t, "true", gitOut(t, staged, "rev-parse", "--is-bare-repository"))
177+ assert.Equal(t, "sha1", gitOut(t, staged, "rev-parse", "--show-object-format"))
178+ assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format"))
179+ assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps"))
180+ assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "core.commitGraph"))
181+ assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))
182+
183+ n := name(t, "tools")
184+ require.NoError(t, g.Publish(ctx, id, n))
185+ assert.NoDirExists(t, staged)
186+ assert.Equal(t, "true", gitOut(t, filepath.Join(data, "repos", "tools.git"), "rev-parse", "--is-bare-repository"))
187+
188+ // A second staged repo cannot take the same name and is left for Discard.
189+ id2, err := g.Stage(ctx)
190+ require.NoError(t, err)
191+ err = g.Publish(ctx, id2, n)
192+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
193+ assert.DirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
194+ require.NoError(t, g.Discard(ctx, id2))
195+ assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
196+ require.NoError(t, g.Discard(ctx, id2), "discarding twice is not an error")
197+}
198+
199+func TestPublishOverEmptyDirectoryDoesNotReplace(t *testing.T) {
200+ ctx := context.Background()
201+ g, data := newGit(t)
202+ require.NoError(t, os.Mkdir(filepath.Join(data, "repos", "taken.git"), 0o700))
203+ id, err := g.Stage(ctx)
204+ require.NoError(t, err)
205+ assert.ErrorIs(t, g.Publish(ctx, id, name(t, "taken")), domain.ErrAlreadyExists)
206+}
207+
208+func TestInvalidStagedID(t *testing.T) {
209+ ctx := context.Background()
210+ g, data := newGit(t)
211+ for _, id := range []ports.StagedID{"", "../../trash", "zz", ports.StagedID(strings.Repeat("g", 32))} {
212+ assert.Error(t, g.Discard(ctx, id), string(id))
213+ assert.Error(t, g.Publish(ctx, id, name(t, "x")), string(id))
214+ }
215+ assert.DirExists(t, filepath.Join(data, "trash"))
216+}
217+
218+func TestConcurrentPublishSameName(t *testing.T) {
219+ ctx := context.Background()
220+ g, data := newGit(t)
221+ n := name(t, "race")
222+
223+ const workers = 6
224+ ids := make([]ports.StagedID, workers)
225+ for i := range ids {
226+ id, err := g.Stage(ctx)
227+ require.NoError(t, err)
228+ ids[i] = id
229+ }
230+ errs := make(chan error, workers)
231+ var wg sync.WaitGroup
232+ for _, id := range ids {
233+ wg.Add(1)
234+ go func() {
235+ defer wg.Done()
236+ errs <- g.Publish(ctx, id, n)
237+ }()
238+ }
239+ wg.Wait()
240+ close(errs)
241+ ok, exists := 0, 0
242+ for err := range errs {
243+ switch {
244+ case err == nil:
245+ ok++
246+ case errors.Is(err, domain.ErrAlreadyExists):
247+ exists++
248+ default:
249+ t.Fatalf("unexpected: %v", err)
250+ }
251+ }
252+ assert.Equal(t, 1, ok)
253+ assert.Equal(t, workers-1, exists)
254+ for _, id := range ids {
255+ require.NoError(t, g.Discard(ctx, id))
256+ }
257+ entries, err := os.ReadDir(filepath.Join(data, "repos", ".staging"))
258+ require.NoError(t, err)
259+ assert.Empty(t, entries)
260+}
261+
262+func TestStageFailureLeavesNothing(t *testing.T) {
263+ ctx := context.Background()
264+ data := t.TempDir()
265+ require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging"), 0o700))
266+ // A git that creates the directory and then fails.
267+ script := writeScript(t, `for last; do :; done; mkdir -p "$last"; echo nope >&2; exit 1`)
268+ g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
269+ _, err := g.Stage(ctx)
270+ require.Error(t, err)
271+ entries, rerr := os.ReadDir(filepath.Join(data, "repos", ".staging"))
272+ require.NoError(t, rerr)
273+ assert.Empty(t, entries)
274+}
275+
276+func TestPurgeStagingAndList(t *testing.T) {
277+ ctx := context.Background()
278+ g, data := newGit(t)
279+ repos := filepath.Join(data, "repos")
280+
281+ _, err := g.Stage(ctx)
282+ require.NoError(t, err)
283+ require.NoError(t, os.WriteFile(filepath.Join(repos, ".staging", "stray"), []byte("x"), 0o600))
284+ for _, d := range []string{"good.git", "also-good.git", "Bad.git", "no-suffix", ".hidden.git", "x..y.git"} {
285+ require.NoError(t, os.Mkdir(filepath.Join(repos, d), 0o700))
286+ }
287+ require.NoError(t, os.WriteFile(filepath.Join(repos, "file.git"), []byte("x"), 0o600))
288+
289+ require.NoError(t, g.PurgeStaging(ctx))
290+ entries, err := os.ReadDir(filepath.Join(repos, ".staging"))
291+ require.NoError(t, err)
292+ assert.Empty(t, entries)
293+ assert.DirExists(t, filepath.Join(repos, ".staging"))
294+
295+ dirs, err := g.List(ctx)
296+ require.NoError(t, err)
297+ assert.ElementsMatch(t, []domain.RepoName{name(t, "good"), name(t, "also-good")}, dirs.Names)
298+ assert.ElementsMatch(t, []string{"Bad.git", "no-suffix", ".hidden.git", "x..y.git", "file.git"}, dirs.Invalid)
299+}
300+
301+func TestTrash(t *testing.T) {
302+ ctx := context.Background()
303+ g, data := newGit(t)
304+ n := name(t, "old")
305+
306+ assert.ErrorIs(t, g.Trash(ctx, n), domain.ErrNotFound)
307+
308+ for range 2 {
309+ id, err := g.Stage(ctx)
310+ require.NoError(t, err)
311+ require.NoError(t, g.Publish(ctx, id, n))
312+ require.NoError(t, g.Trash(ctx, n))
313+ }
314+ assert.NoDirExists(t, filepath.Join(data, "repos", "old.git"))
315+ entries, err := os.ReadDir(filepath.Join(data, "trash"))
316+ require.NoError(t, err)
317+ require.Len(t, entries, 2, "two trashings never overwrite each other")
318+ for _, e := range entries {
319+ assert.True(t, strings.HasPrefix(e.Name(), "old-") && strings.HasSuffix(e.Name(), ".git"), e.Name())
320+ }
321+}
A internal/adapters/gitcli/repos.go +205 -0
@@ -0,0 +1,205 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "io/fs"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "time"
16+
17+ "github.com/bnema/zerowrap"
18+ "golang.org/x/sys/unix"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+)
23+
24+var _ ports.GitRepos = (*Git)(nil)
25+
26+const gitSuffix = ".git"
27+
28+func (g *Git) reposDir() string { return filepath.Join(g.dataDir, "repos") }
29+func (g *Git) stagingDir() string { return filepath.Join(g.reposDir(), ".staging") }
30+func (g *Git) trashDir() string { return filepath.Join(g.dataDir, "trash") }
31+
32+// Stage runs `git init --bare` in a new directory under repos/.staging/ and
33+// sets the repository defaults. The initial branch is main.
34+func (g *Git) Stage(ctx context.Context) (ports.StagedID, error) {
35+ var raw [16]byte
36+ if _, err := rand.Read(raw[:]); err != nil {
37+ return "", fmt.Errorf("random staging name: %w", err)
38+ }
39+ id := ports.StagedID(hex.EncodeToString(raw[:]))
40+ dir := filepath.Join(g.stagingDir(), string(id))
41+
42+ _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir)
43+ if err == nil {
44+ for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}} {
45+ if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil {
46+ break
47+ }
48+ }
49+ }
50+ if err != nil {
51+ if rerr := os.RemoveAll(dir); rerr != nil {
52+ zerowrap.Ctx(ctx).Error().Err(rerr).Str("dir", dir).Msg("remove failed staging directory")
53+ }
54+ return "", fmt.Errorf("stage repository: %w", err)
55+ }
56+ zerowrap.Ctx(ctx).Debug().Str("staged", string(id)).Msg("repository staged")
57+ return id, nil
58+}
59+
60+// Publish renames the staged repository to repos/<name>.git without
61+// replacing anything.
62+func (g *Git) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error {
63+ src, err := g.stagedPath(id)
64+ if err != nil {
65+ return err
66+ }
67+ dst := filepath.Join(g.reposDir(), name.String()+gitSuffix)
68+ if err := renameNoReplace(src, dst); err != nil {
69+ if errors.Is(err, fs.ErrExist) {
70+ return domain.ErrAlreadyExists
71+ }
72+ return fmt.Errorf("publish repository %q: %w", name, err)
73+ }
74+ if err := syncDir(g.reposDir()); err != nil {
75+ return fmt.Errorf("publish repository %q: %w", name, err)
76+ }
77+ zerowrap.Ctx(ctx).Debug().Str("repo", name.String()).Msg("repository published")
78+ return nil
79+}
80+
81+// Discard removes a staged repository.
82+func (g *Git) Discard(_ context.Context, id ports.StagedID) error {
83+ dir, err := g.stagedPath(id)
84+ if err != nil {
85+ return err
86+ }
87+ if err := os.RemoveAll(dir); err != nil {
88+ return fmt.Errorf("discard staged repository %s: %w", id, err)
89+ }
90+ return nil
91+}
92+
93+// PurgeStaging removes everything under repos/.staging/.
94+//
95+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
96+func (g *Git) PurgeStaging(ctx context.Context) error {
97+ entries, err := os.ReadDir(g.stagingDir())
98+ if err != nil {
99+ if errors.Is(err, fs.ErrNotExist) {
100+ return nil
101+ }
102+ return fmt.Errorf("read staging directory: %w", err)
103+ }
104+ for _, e := range entries {
105+ if err := os.RemoveAll(filepath.Join(g.stagingDir(), e.Name())); err != nil {
106+ return fmt.Errorf("remove staging leftover %s: %w", e.Name(), err)
107+ }
108+ zerowrap.Ctx(ctx).Info().Str("entry", e.Name()).Msg("removed staging leftover")
109+ }
110+ return nil
111+}
112+
113+// List returns the repository directories. Entries that are not
114+// "<valid name>.git" directories are reported as invalid; .staging is not an
115+// entry.
116+func (g *Git) List(_ context.Context) (ports.RepoDirs, error) {
117+ entries, err := os.ReadDir(g.reposDir())
118+ if err != nil {
119+ return ports.RepoDirs{}, fmt.Errorf("read repositories directory: %w", err)
120+ }
121+ var out ports.RepoDirs
122+ for _, e := range entries {
123+ if e.Name() == ".staging" {
124+ continue
125+ }
126+ base, ok := strings.CutSuffix(e.Name(), gitSuffix)
127+ if !ok || !e.IsDir() {
128+ out.Invalid = append(out.Invalid, e.Name())
129+ continue
130+ }
131+ name, err := domain.ParseRepoName(base)
132+ if err != nil {
133+ out.Invalid = append(out.Invalid, e.Name())
134+ continue
135+ }
136+ out.Names = append(out.Names, name)
137+ }
138+ return out, nil
139+}
140+
141+// Trash moves repos/<name>.git to trash/<name>-<unix>.git.
142+func (g *Git) Trash(ctx context.Context, name domain.RepoName) error {
143+ src := filepath.Join(g.reposDir(), name.String()+gitSuffix)
144+ if _, err := os.Lstat(src); err != nil {
145+ if errors.Is(err, fs.ErrNotExist) {
146+ return domain.ErrNotFound
147+ }
148+ return fmt.Errorf("trash repository %q: %w", name, err)
149+ }
150+ stamp := time.Now().Unix()
151+ for i := range 100 {
152+ dst := filepath.Join(g.trashDir(), fmt.Sprintf("%s-%d%s", name, stamp+int64(i), gitSuffix))
153+ err := renameNoReplace(src, dst)
154+ if errors.Is(err, fs.ErrExist) {
155+ continue
156+ }
157+ if err != nil {
158+ return fmt.Errorf("trash repository %q: %w", name, err)
159+ }
160+ if err := syncDir(g.reposDir()); err != nil {
161+ return fmt.Errorf("trash repository %q: %w", name, err)
162+ }
163+ zerowrap.Ctx(ctx).Info().Str("repo", name.String()).Str("trash", filepath.Base(dst)).Msg("repository moved to trash")
164+ return nil
165+ }
166+ return fmt.Errorf("trash repository %q: no free name in trash", name)
167+}
168+
169+// stagedPath returns the directory of a staged repository, rejecting
170+// identifiers that are not the hex names Stage produces.
171+func (g *Git) stagedPath(id ports.StagedID) (string, error) {
172+ if _, err := hex.DecodeString(string(id)); err != nil || len(id) != 32 {
173+ return "", fmt.Errorf("invalid staged repository id %q", id)
174+ }
175+ return filepath.Join(g.stagingDir(), string(id)), nil
176+}
177+
178+// renameNoReplace renames src to dst and fails with fs.ErrExist if dst
179+// exists, even as an empty directory. Filesystems without RENAME_NOREPLACE
180+// fall back to a check before the rename.
181+func renameNoReplace(src, dst string) error {
182+ err := unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
183+ switch {
184+ case err == nil:
185+ return nil
186+ case errors.Is(err, unix.EEXIST):
187+ return fs.ErrExist
188+ case errors.Is(err, unix.EINVAL), errors.Is(err, unix.ENOSYS), errors.Is(err, unix.ENOTSUP):
189+ if _, serr := os.Lstat(dst); serr == nil {
190+ return fs.ErrExist
191+ }
192+ return os.Rename(src, dst)
193+ }
194+ return &os.LinkError{Op: "rename", Old: src, New: dst, Err: err}
195+}
196+
197+// syncDir flushes a directory so a rename in it survives a crash.
198+func syncDir(dir string) error {
199+ f, err := os.Open(dir)
200+ if err != nil {
201+ return err
202+ }
203+ defer func() { _ = f.Close() }()
204+ return f.Sync()
205+}
@@ -0,0 +1,205 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "io/fs"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "time"
16+
17+ "github.com/bnema/zerowrap"
18+ "golang.org/x/sys/unix"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+)
23+
24+var _ ports.GitRepos = (*Git)(nil)
25+
26+const gitSuffix = ".git"
27+
28+func (g *Git) reposDir() string { return filepath.Join(g.dataDir, "repos") }
29+func (g *Git) stagingDir() string { return filepath.Join(g.reposDir(), ".staging") }
30+func (g *Git) trashDir() string { return filepath.Join(g.dataDir, "trash") }
31+
32+// Stage runs `git init --bare` in a new directory under repos/.staging/ and
33+// sets the repository defaults. The initial branch is main.
34+func (g *Git) Stage(ctx context.Context) (ports.StagedID, error) {
35+ var raw [16]byte
36+ if _, err := rand.Read(raw[:]); err != nil {
37+ return "", fmt.Errorf("random staging name: %w", err)
38+ }
39+ id := ports.StagedID(hex.EncodeToString(raw[:]))
40+ dir := filepath.Join(g.stagingDir(), string(id))
41+
42+ _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir)
43+ if err == nil {
44+ for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}} {
45+ if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil {
46+ break
47+ }
48+ }
49+ }
50+ if err != nil {
51+ if rerr := os.RemoveAll(dir); rerr != nil {
52+ zerowrap.Ctx(ctx).Error().Err(rerr).Str("dir", dir).Msg("remove failed staging directory")
53+ }
54+ return "", fmt.Errorf("stage repository: %w", err)
55+ }
56+ zerowrap.Ctx(ctx).Debug().Str("staged", string(id)).Msg("repository staged")
57+ return id, nil
58+}
59+
60+// Publish renames the staged repository to repos/<name>.git without
61+// replacing anything.
62+func (g *Git) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error {
63+ src, err := g.stagedPath(id)
64+ if err != nil {
65+ return err
66+ }
67+ dst := filepath.Join(g.reposDir(), name.String()+gitSuffix)
68+ if err := renameNoReplace(src, dst); err != nil {
69+ if errors.Is(err, fs.ErrExist) {
70+ return domain.ErrAlreadyExists
71+ }
72+ return fmt.Errorf("publish repository %q: %w", name, err)
73+ }
74+ if err := syncDir(g.reposDir()); err != nil {
75+ return fmt.Errorf("publish repository %q: %w", name, err)
76+ }
77+ zerowrap.Ctx(ctx).Debug().Str("repo", name.String()).Msg("repository published")
78+ return nil
79+}
80+
81+// Discard removes a staged repository.
82+func (g *Git) Discard(_ context.Context, id ports.StagedID) error {
83+ dir, err := g.stagedPath(id)
84+ if err != nil {
85+ return err
86+ }
87+ if err := os.RemoveAll(dir); err != nil {
88+ return fmt.Errorf("discard staged repository %s: %w", id, err)
89+ }
90+ return nil
91+}
92+
93+// PurgeStaging removes everything under repos/.staging/.
94+//
95+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
96+func (g *Git) PurgeStaging(ctx context.Context) error {
97+ entries, err := os.ReadDir(g.stagingDir())
98+ if err != nil {
99+ if errors.Is(err, fs.ErrNotExist) {
100+ return nil
101+ }
102+ return fmt.Errorf("read staging directory: %w", err)
103+ }
104+ for _, e := range entries {
105+ if err := os.RemoveAll(filepath.Join(g.stagingDir(), e.Name())); err != nil {
106+ return fmt.Errorf("remove staging leftover %s: %w", e.Name(), err)
107+ }
108+ zerowrap.Ctx(ctx).Info().Str("entry", e.Name()).Msg("removed staging leftover")
109+ }
110+ return nil
111+}
112+
113+// List returns the repository directories. Entries that are not
114+// "<valid name>.git" directories are reported as invalid; .staging is not an
115+// entry.
116+func (g *Git) List(_ context.Context) (ports.RepoDirs, error) {
117+ entries, err := os.ReadDir(g.reposDir())
118+ if err != nil {
119+ return ports.RepoDirs{}, fmt.Errorf("read repositories directory: %w", err)
120+ }
121+ var out ports.RepoDirs
122+ for _, e := range entries {
123+ if e.Name() == ".staging" {
124+ continue
125+ }
126+ base, ok := strings.CutSuffix(e.Name(), gitSuffix)
127+ if !ok || !e.IsDir() {
128+ out.Invalid = append(out.Invalid, e.Name())
129+ continue
130+ }
131+ name, err := domain.ParseRepoName(base)
132+ if err != nil {
133+ out.Invalid = append(out.Invalid, e.Name())
134+ continue
135+ }
136+ out.Names = append(out.Names, name)
137+ }
138+ return out, nil
139+}
140+
141+// Trash moves repos/<name>.git to trash/<name>-<unix>.git.
142+func (g *Git) Trash(ctx context.Context, name domain.RepoName) error {
143+ src := filepath.Join(g.reposDir(), name.String()+gitSuffix)
144+ if _, err := os.Lstat(src); err != nil {
145+ if errors.Is(err, fs.ErrNotExist) {
146+ return domain.ErrNotFound
147+ }
148+ return fmt.Errorf("trash repository %q: %w", name, err)
149+ }
150+ stamp := time.Now().Unix()
151+ for i := range 100 {
152+ dst := filepath.Join(g.trashDir(), fmt.Sprintf("%s-%d%s", name, stamp+int64(i), gitSuffix))
153+ err := renameNoReplace(src, dst)
154+ if errors.Is(err, fs.ErrExist) {
155+ continue
156+ }
157+ if err != nil {
158+ return fmt.Errorf("trash repository %q: %w", name, err)
159+ }
160+ if err := syncDir(g.reposDir()); err != nil {
161+ return fmt.Errorf("trash repository %q: %w", name, err)
162+ }
163+ zerowrap.Ctx(ctx).Info().Str("repo", name.String()).Str("trash", filepath.Base(dst)).Msg("repository moved to trash")
164+ return nil
165+ }
166+ return fmt.Errorf("trash repository %q: no free name in trash", name)
167+}
168+
169+// stagedPath returns the directory of a staged repository, rejecting
170+// identifiers that are not the hex names Stage produces.
171+func (g *Git) stagedPath(id ports.StagedID) (string, error) {
172+ if _, err := hex.DecodeString(string(id)); err != nil || len(id) != 32 {
173+ return "", fmt.Errorf("invalid staged repository id %q", id)
174+ }
175+ return filepath.Join(g.stagingDir(), string(id)), nil
176+}
177+
178+// renameNoReplace renames src to dst and fails with fs.ErrExist if dst
179+// exists, even as an empty directory. Filesystems without RENAME_NOREPLACE
180+// fall back to a check before the rename.
181+func renameNoReplace(src, dst string) error {
182+ err := unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
183+ switch {
184+ case err == nil:
185+ return nil
186+ case errors.Is(err, unix.EEXIST):
187+ return fs.ErrExist
188+ case errors.Is(err, unix.EINVAL), errors.Is(err, unix.ENOSYS), errors.Is(err, unix.ENOTSUP):
189+ if _, serr := os.Lstat(dst); serr == nil {
190+ return fs.ErrExist
191+ }
192+ return os.Rename(src, dst)
193+ }
194+ return &os.LinkError{Op: "rename", Old: src, New: dst, Err: err}
195+}
196+
197+// syncDir flushes a directory so a rename in it survives a crash.
198+func syncDir(dir string) error {
199+ f, err := os.Open(dir)
200+ if err != nil {
201+ return err
202+ }
203+ defer func() { _ = f.Close() }()
204+ return f.Sync()
205+}
A internal/adapters/gitcli/version.go +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+ "regexp"
9+ "strconv"
10+)
11+
12+// Version is a git release number.
13+type Version struct{ Major, Minor, Patch int }
14+
15+// MinVersion is the oldest supported git.
16+var MinVersion = Version{Major: 2, Minor: 45}
17+
18+// String returns "major.minor.patch".
19+func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
20+
21+// Less reports whether v is older than o.
22+func (v Version) Less(o Version) bool {
23+ if v.Major != o.Major {
24+ return v.Major < o.Major
25+ }
26+ if v.Minor != o.Minor {
27+ return v.Minor < o.Minor
28+ }
29+ return v.Patch < o.Patch
30+}
31+
32+var versionRE = regexp.MustCompile(`^git version (\d+)\.(\d+)(?:\.(\d+))?`)
33+
34+// ParseVersion parses the output of `git version`, such as
35+// "git version 2.45.0", "git version 2.39.5 (Apple Git-154)" or
36+// "git version 2.46.0.rc1".
37+func ParseVersion(out string) (Version, error) {
38+ m := versionRE.FindStringSubmatch(out)
39+ if m == nil {
40+ return Version{}, fmt.Errorf("unrecognized git version output %q", out)
41+ }
42+ var v Version
43+ for i, dst := range []*int{&v.Major, &v.Minor, &v.Patch} {
44+ if m[i+1] == "" {
45+ continue
46+ }
47+ n, err := strconv.Atoi(m[i+1])
48+ if err != nil {
49+ return Version{}, fmt.Errorf("unrecognized git version output %q: %w", out, err)
50+ }
51+ *dst = n
52+ }
53+ return v, nil
54+}
55+
56+// CheckVersion runs `git version` and fails if git is missing, unreadable or
57+// older than MinVersion. It returns the version found.
58+func (g *Git) CheckVersion(ctx context.Context) (Version, error) {
59+ out, err := g.run(ctx, "version")
60+ if err != nil {
61+ return Version{}, fmt.Errorf("run %q: %w", g.bin, err)
62+ }
63+ v, err := ParseVersion(out)
64+ if err != nil {
65+ return Version{}, err
66+ }
67+ if v.Less(MinVersion) {
68+ return v, fmt.Errorf("git %s is older than the required %s", v, MinVersion)
69+ }
70+ return v, nil
71+}
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+ "regexp"
9+ "strconv"
10+)
11+
12+// Version is a git release number.
13+type Version struct{ Major, Minor, Patch int }
14+
15+// MinVersion is the oldest supported git.
16+var MinVersion = Version{Major: 2, Minor: 45}
17+
18+// String returns "major.minor.patch".
19+func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
20+
21+// Less reports whether v is older than o.
22+func (v Version) Less(o Version) bool {
23+ if v.Major != o.Major {
24+ return v.Major < o.Major
25+ }
26+ if v.Minor != o.Minor {
27+ return v.Minor < o.Minor
28+ }
29+ return v.Patch < o.Patch
30+}
31+
32+var versionRE = regexp.MustCompile(`^git version (\d+)\.(\d+)(?:\.(\d+))?`)
33+
34+// ParseVersion parses the output of `git version`, such as
35+// "git version 2.45.0", "git version 2.39.5 (Apple Git-154)" or
36+// "git version 2.46.0.rc1".
37+func ParseVersion(out string) (Version, error) {
38+ m := versionRE.FindStringSubmatch(out)
39+ if m == nil {
40+ return Version{}, fmt.Errorf("unrecognized git version output %q", out)
41+ }
42+ var v Version
43+ for i, dst := range []*int{&v.Major, &v.Minor, &v.Patch} {
44+ if m[i+1] == "" {
45+ continue
46+ }
47+ n, err := strconv.Atoi(m[i+1])
48+ if err != nil {
49+ return Version{}, fmt.Errorf("unrecognized git version output %q: %w", out, err)
50+ }
51+ *dst = n
52+ }
53+ return v, nil
54+}
55+
56+// CheckVersion runs `git version` and fails if git is missing, unreadable or
57+// older than MinVersion. It returns the version found.
58+func (g *Git) CheckVersion(ctx context.Context) (Version, error) {
59+ out, err := g.run(ctx, "version")
60+ if err != nil {
61+ return Version{}, fmt.Errorf("run %q: %w", g.bin, err)
62+ }
63+ v, err := ParseVersion(out)
64+ if err != nil {
65+ return Version{}, err
66+ }
67+ if v.Less(MinVersion) {
68+ return v, fmt.Errorf("git %s is older than the required %s", v, MinVersion)
69+ }
70+ return v, nil
71+}

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 3

Tags 45

↑↓ moveenter openesc close