feat(gitcli): stage and publish bare repositories
- commit
- 9db7dbfea0c698a27a2e5c6ea5f4604e381ee530
- author
- brice · · 2026-10-10 06:13 UTC
- parent
- 3c97d93
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +691 -3
M go.mod +2 -1
| @@ -10,8 +10,10 @@ tool ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | require ( | |
| 13 | + | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 13 | 14 | github.com/bnema/zerowrap v1.4.1 | |
| 14 | 15 | github.com/stretchr/testify v1.12.1 | |
| 16 | + | golang.org/x/sys v0.49.0 | |
| 15 | 17 | google.golang.org/protobuf v1.36.12 | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| @@ -120,7 +122,6 @@ require ( | |||
| 120 | 122 | golang.org/x/mod v0.41.0 // indirect | |
| 121 | 123 | golang.org/x/net v0.59.0 // indirect | |
| 122 | 124 | golang.org/x/sync v0.23.0 // indirect | |
| 123 | - | golang.org/x/sys v0.48.0 // indirect | |
| 124 | 125 | golang.org/x/term v0.46.0 // indirect | |
| 125 | 126 | golang.org/x/text v0.42.0 // indirect | |
| 126 | 127 | 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.1 | 14 | github.com/bnema/zerowrap v1.4.1 | ||
| 14 | github.com/stretchr/testify v1.12.1 | 15 | github.com/stretchr/testify v1.12.1 | ||
| 16 | + | golang.org/x/sys v0.49.0 | |||
| 15 | google.golang.org/protobuf v1.36.12 | 17 | google.golang.org/protobuf v1.36.12 | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| @@ -120,7 +122,6 @@ require ( | |||||
| 120 | golang.org/x/mod v0.41.0 // indirect | 122 | golang.org/x/mod v0.41.0 // indirect | ||
| 121 | golang.org/x/net v0.59.0 // indirect | 123 | golang.org/x/net v0.59.0 // indirect | ||
| 122 | golang.org/x/sync v0.23.0 // indirect | 124 | 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 // indirect | 125 | golang.org/x/term v0.46.0 // indirect | ||
| 125 | golang.org/x/text v0.42.0 // indirect | 126 | golang.org/x/text v0.42.0 // indirect | ||
| 126 | golang.org/x/tools v0.51.0 // indirect | 127 | golang.org/x/tools v0.51.0 // indirect | ||
M go.sum +4 -2
| @@ -1,3 +1,5 @@ | |||
| 1 | + | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | |
| 2 | + | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3/go.mod h1:5Tb7lsUx00GVtTC6rGRWG9JbBMKlZ8hi/klP8L/DcEA= | |
| 1 | 3 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= | |
| 2 | 4 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc= | |
| 3 | 5 | buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 h1:tE4A2RVukQOSd4K+6ndK+8s65FbjiBt2k5CiddXxCCo= | |
| @@ -252,8 +254,8 @@ golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= | |||
| 252 | 254 | golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= | |
| 253 | 255 | golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | |
| 254 | 256 | golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | |
| 255 | - | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= | |
| 256 | - | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | |
| 257 | + | golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= | |
| 258 | + | golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | |
| 257 | 259 | golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= | |
| 258 | 260 | golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= | |
| 259 | 261 | golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | |||
| 2 | + | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3/go.mod h1:5Tb7lsUx00GVtTC6rGRWG9JbBMKlZ8hi/klP8L/DcEA= | |||
| 1 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= | 3 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= | ||
| 2 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc= | 4 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc= | ||
| 3 | buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 h1:tE4A2RVukQOSd4K+6ndK+8s65FbjiBt2k5CiddXxCCo= | 5 | buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 h1:tE4A2RVukQOSd4K+6ndK+8s65FbjiBt2k5CiddXxCCo= | ||
| @@ -252,8 +254,8 @@ golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= | |||||
| 252 | golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= | 254 | golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= | ||
| 253 | golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | 255 | golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | ||
| 254 | golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | 256 | golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | ||
| 255 | - | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= | 257 | + | golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= |
| 256 | - | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | 258 | + | golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= |
| 257 | golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= | 259 | golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= | ||
| 258 | golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= | 260 | golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= | ||
| 259 | golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= | 261 | golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= | ||
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 | + | } | |||