8 files changed,
296 insertions(+),
38 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-01-22 01:48:03 +0200
Authored at:
2026-01-21 02:03:02 +0200
Change ID:
knnxsuoupszlmxyuvkqnsrkokuqmknoy
Parent:
2efe21e
jump to
| D | config.yml |
| M | go.mod |
| M | go.sum |
| M | internal/config/config.go |
| M | internal/git/repo.go |
| M | internal/handlers/repo.go |
| A | internal/mirror/mirror.go |
| M | main.go |
D
config.yml
路路路 1 -server: 2 - host: 0.0.0.0 3 - port: 8008 4 - 5 -meta: 6 - description: hey kid, come get your free software 7 - title: git.olexsmir.xyz 8 - host: git.olexsmir.xyz 9 - 10 -ssh: 11 - enable: true 12 - port: 2222 13 - host_key: /home/olex/.ssh/mugit 14 - keys: 15 - - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPLLJdkVYKZgsayw+sHanKPKZbI0RMS2CakqBCEi5Trz 16 - - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPQ0Qz0DFB+rGrD8ScUqbUTZ1/O8FHrOBF5bIAGQgMj olex@rachlinux 17 - 18 -repo: 19 - dir: /home/olex/mugit-test/ 20 - readmes: 21 - - README 22 - - README.md 23 - - readme 24 - - readme.md 25 - - readme.txt 26 - masters: 27 - - master 28 - - main
M
go.sum
路路路 79 79 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 80 80 golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 81 81 golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 82 +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 83 +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 82 84 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 85 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 86 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
M
internal/config/config.go
路路路 18 18 Description string `yaml:"description"` 19 19 Host string `yaml:"host"` 20 20 } `yaml:"meta"` 21 + Repo struct { 22 + Dir string `yaml:"dir"` 23 + Readmes []string `yaml:"readmes"` 24 + Masters []string `yaml:"masters"` 25 + } `yaml:"repo"` 21 26 SSH struct { 22 27 Enable bool `yaml:"enable"` 23 28 Port int `yaml:"port"` 24 29 HostKey string `yaml:"host_key"` 25 30 Keys []string `yaml:"keys"` 26 31 } `yaml:"ssh"` 27 - Repo struct { 28 - Dir string `yaml:"dir"` 29 - Readmes []string `yaml:"readmes"` 30 - Masters []string `yaml:"masters"` 31 - } `yaml:"repo"` 32 + Mirror struct { 33 + Enable bool `yaml:"enable"` 34 + Interval string `yaml:"interval"` 35 + GithubToken string `yaml:"github_token"` 36 + } `yaml:"mirror"` 32 37 } 33 38 34 39 func Load(fpath string) (*Config, error) {
M
internal/git/repo.go
路路路 1 1 package git 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "os" 6 7 "path/filepath" 7 8 "sort" 9 + "time" 8 10 9 11 "github.com/go-git/go-git/v5" 12 + gitconfig "github.com/go-git/go-git/v5/config" 10 13 "github.com/go-git/go-git/v5/plumbing" 11 14 "github.com/go-git/go-git/v5/plumbing/object" 15 + "github.com/go-git/go-git/v5/plumbing/transport/http" 12 16 ) 13 17 14 18 // Thanks https://git.icyphox.sh/legit/blob/master/git/git.go 路路路 43 47 g.h = *hash 44 48 } 45 49 return &g, nil 50 +} 51 + 52 +func (g *Repo) Name() string { 53 + return filepath.Base(g.path) 46 54 } 47 55 48 56 func (g *Repo) Commits() ([]*object.Commit, error) { 路路路 180 188 } 181 189 return "", fmt.Errorf("unable to find master branch") 182 190 } 191 + 192 +type MirrorInfo struct { 193 + IsMirror bool 194 + Remote string 195 + RemoteURL string 196 +} 197 + 198 +func (g *Repo) MirrorInfo() (MirrorInfo, error) { 199 + c, err := g.r.Config() 200 + if err != nil { 201 + return MirrorInfo{}, fmt.Errorf("failed to read config: %w", err) 202 + } 203 + 204 + isMirror := c.Raw.Section("mugit").Options.Get("mirror") == "true" 205 + for _, remote := range c.Remotes { 206 + if len(remote.URLs) > 0 && (remote.Name == "upstream" || remote.Name == "origin") { 207 + return MirrorInfo{ 208 + IsMirror: isMirror, 209 + Remote: remote.Name, 210 + RemoteURL: remote.URLs[0], 211 + }, nil 212 + } 213 + } 214 + // TODO: error if mirror opt is set, but there's no remotes 215 + return MirrorInfo{}, fmt.Errorf("no mirror remote found") 216 +} 217 + 218 +func (g *Repo) ReadLastSync() (time.Time, error) { 219 + c, err := g.r.Config() 220 + if err != nil { 221 + return time.Time{}, fmt.Errorf("failed to read config: %w", err) 222 + } 223 + 224 + raw := c.Raw.Section("mugit").Options.Get("last-sync") 225 + if raw == "" { 226 + return time.Time{}, fmt.Errorf("last-sync not set") 227 + } 228 + 229 + out, err := time.Parse(time.RFC3339, string(raw)) 230 + if err != nil { 231 + return time.Time{}, fmt.Errorf("failed to parse time: %w", err) 232 + } 233 + return out, nil 234 +} 235 + 236 +func (g *Repo) SetLastSync(lastSync time.Time) error { 237 + c, err := g.r.Config() 238 + if err != nil { 239 + return fmt.Errorf("failed to read config: %w", err) 240 + } 241 + 242 + c.Raw.Section("mugit"). 243 + SetOption("last-sync", lastSync.Format(time.RFC3339)) 244 + return g.r.SetConfig(c) 245 +} 246 + 247 +func (g *Repo) Fetch(remote string) error { 248 + return g.FetchWithAuth(remote, "") 249 +} 250 + 251 +// FetchWithAuth fetches but with auth. Works only with github's auth 252 +func (g *Repo) FetchWithAuth(remote string, token string) error { 253 + rmt, err := g.r.Remote(remote) 254 + if err != nil { 255 + return fmt.Errorf("failed to get upstream remote: %w", err) 256 + } 257 + 258 + opts := &git.FetchOptions{ 259 + RefSpecs: []gitconfig.RefSpec{ 260 + // fetch all branches 261 + "+refs/heads/*:refs/heads/*", 262 + "+refs/tags/*:refs/tags/*", 263 + }, 264 + Tags: git.AllTags, 265 + Prune: true, 266 + Force: true, 267 + } 268 + 269 + if token != "" { 270 + opts.Auth = &http.BasicAuth{ 271 + Username: token, 272 + Password: "x-oauth-basic", 273 + } 274 + } 275 + 276 + if err := rmt.Fetch(opts); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { 277 + return fmt.Errorf("fetch failed: %w", err) 278 + } 279 + return nil 280 +}
A
internal/mirror/mirror.go
路路路 1 +package mirror 2 + 3 +import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + "path/filepath" 10 + "strings" 11 + "sync" 12 + "time" 13 + 14 + "golang.org/x/sync/semaphore" 15 + "olexsmir.xyz/mugit/internal/config" 16 + "olexsmir.xyz/mugit/internal/git" 17 +) 18 + 19 +type Worker struct { 20 + c *config.Config 21 +} 22 + 23 +func NewWorker(cfg *config.Config) *Worker { 24 + return &Worker{ 25 + c: cfg, 26 + } 27 +} 28 + 29 +func (w *Worker) Start(ctx context.Context) error { 30 + interval, err := time.ParseDuration(w.c.Mirror.Interval) 31 + if err != nil { 32 + slog.Error("couldn't parse interval time", "err", err) 33 + return err 34 + } 35 + 36 + ticker := time.NewTicker(interval) 37 + defer ticker.Stop() 38 + 39 + if err := w.mirror(ctx); err != nil { 40 + slog.Error("initial mirror sync failed", "err", err) 41 + } 42 + 43 + for { 44 + select { 45 + case <-ctx.Done(): 46 + return nil 47 + case <-ticker.C: 48 + if err := w.mirror(ctx); err != nil { 49 + slog.Error("mirror sync failed", "err", err) 50 + } 51 + } 52 + } 53 +} 54 + 55 +func (w *Worker) mirror(ctx context.Context) error { 56 + repos, err := w.findMirrorRepos() 57 + if err != nil { 58 + return err 59 + } 60 + 61 + var wg sync.WaitGroup 62 + sem := semaphore.NewWeighted(5) 63 + errCh := make(chan error, len(repos)) 64 + 65 + for _, repo := range repos { 66 + wg.Go(func() { 67 + if err := sem.Acquire(ctx, 1); err != nil { 68 + errCh <- err 69 + return 70 + } 71 + defer sem.Release(1) 72 + 73 + if err := w.syncRepo(ctx, repo); err != nil { 74 + errCh <- err 75 + } 76 + }) 77 + } 78 + wg.Wait() 79 + close(errCh) 80 + 81 + var errs []error 82 + for err := range errCh { 83 + errs = append(errs, err) 84 + } 85 + return errors.Join(errs...) 86 +} 87 + 88 +func (w *Worker) syncRepo(_ context.Context, repo *git.Repo) error { 89 + name := repo.Name() 90 + slog.Info("mirror: sync started", "repo", name) 91 + 92 + mi, err := repo.MirrorInfo() 93 + if err != nil { 94 + slog.Error("mirror: failed to get info", "repo", name, "err", err) 95 + return err 96 + } 97 + 98 + if err := w.isRemoteValid(mi.RemoteURL); err != nil { 99 + slog.Error("mirror: remote is not valid", "repo", name, "err", err) 100 + return err 101 + } 102 + 103 + if w.isRemoteGithub(mi.RemoteURL) && w.c.Mirror.GithubToken != "" { 104 + if err := repo.FetchWithAuth(mi.Remote, w.c.Mirror.GithubToken); err != nil { 105 + slog.Error("mirror: fetch failed (authorized)", "repo", name, "err", err) 106 + return err 107 + } 108 + } else { 109 + if err := repo.Fetch(mi.Remote); err != nil { 110 + slog.Error("mirror: fetch failed", "repo", name, "err", err) 111 + return err 112 + } 113 + } 114 + 115 + if err := repo.SetLastSync(time.Now()); err != nil { 116 + slog.Error("mirror: failed to set last sync time", "repo", name, "err", err) 117 + } 118 + 119 + slog.Info("mirror: sync completed", "repo", repo.Name()) 120 + return nil 121 +} 122 + 123 +func (w *Worker) findMirrorRepos() ([]*git.Repo, error) { 124 + dirs, err := os.ReadDir(w.c.Repo.Dir) 125 + if err != nil { 126 + return nil, err 127 + } 128 + 129 + var repos []*git.Repo 130 + for _, dir := range dirs { 131 + if !dir.IsDir() { 132 + continue 133 + } 134 + 135 + name := dir.Name() 136 + repo, err := git.Open(filepath.Join(w.c.Repo.Dir, filepath.Clean(name)), "") 137 + if err != nil { 138 + slog.Debug("skipping non-git directory", "path", name, "err", err) 139 + continue 140 + } 141 + 142 + mirror, err := repo.MirrorInfo() 143 + if err != nil { 144 + slog.Debug("skipping non-mirror repo", "path", name, "err", err) 145 + continue 146 + } 147 + 148 + if mirror.IsMirror { 149 + repos = append(repos, repo) 150 + } 151 + } 152 + 153 + return repos, nil 154 +} 155 + 156 +func (w *Worker) isRemoteValid(remote string) error { 157 + if !strings.HasPrefix(remote, "http") { 158 + return fmt.Errorf("only http and https remotes are supported") 159 + } 160 + return nil 161 +} 162 + 163 +func (w *Worker) isRemoteGithub(remoteURL string) bool { 164 + return strings.Contains(remoteURL, "github.com") 165 +}
M
main.go
路路路 13 13 14 14 "olexsmir.xyz/mugit/internal/config" 15 15 "olexsmir.xyz/mugit/internal/handlers" 16 + "olexsmir.xyz/mugit/internal/mirror" 16 17 "olexsmir.xyz/mugit/internal/ssh" 17 18 ) 18 19 路路路 24 25 } 25 26 26 27 func run() error { 27 - cfg, err := config.Load("/home/olex/code/mugit/config.yml") 28 + cfg, err := config.Load("/home/olex/mugit-test/config.yml") 28 29 if err != nil { 29 30 slog.Error("config error", "err", err) 30 31 return err 路路路 45 46 46 47 sshServer := ssh.NewServer(cfg) 47 48 if cfg.SSH.Enable { 48 - slog.Info("starting ssh server", "port", cfg.SSH.Port) 49 - if err := sshServer.Start(); err != nil { 50 - slog.Error("ssh server error", "err", err) 51 - } 49 + go func() { 50 + slog.Info("starting ssh server", "port", cfg.SSH.Port) 51 + if err := sshServer.Start(); err != nil { 52 + slog.Error("ssh server error", "err", err) 53 + } 54 + }() 55 + } 56 + 57 + mirrorer := mirror.NewWorker(cfg) 58 + if cfg.Mirror.Enable { 59 + go func() { 60 + slog.Info("starting mirroring worker") 61 + mirrorer.Start(context.TODO()) 62 + }() 52 63 } 53 64 54 65 // Wait for interrupt signal