8 files changed,
227 insertions(+),
180 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-02-03 00:54:39 +0200
Authored at:
2026-02-02 22:29:11 +0200
Change ID:
prnlunwxsrmrsosrswuwvvzlwvspmzku
Parent:
7a14044
A
internal/git/config.go
路路路 1 +package git 2 + 3 +import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "time" 9 + 10 + gitconfig "github.com/go-git/go-git/v5/config" 11 +) 12 + 13 +func (g *Repo) IsPrivate() (bool, error) { 14 + v, err := g.readOption("private") 15 + if err != nil { 16 + return false, err 17 + } 18 + return v == "true", nil 19 +} 20 + 21 +const originRemote = "origin" 22 + 23 +func (g *Repo) IsMirror() (bool, error) { 24 + r, err := g.r.Remote(originRemote) 25 + if err != nil { 26 + return false, fmt.Errorf("failed to get remote: %w", err) 27 + } 28 + return r.Config().Mirror, nil 29 +} 30 + 31 +func (g *Repo) SetMirrorRemote(url string) error { 32 + _, err := g.r.CreateRemote(&gitconfig.RemoteConfig{ 33 + Name: originRemote, 34 + URLs: []string{url}, 35 + Mirror: true, 36 + Fetch: []gitconfig.RefSpec{ 37 + "+refs/*:refs/*", 38 + }, 39 + }) 40 + if err != nil { 41 + return fmt.Errorf("failed to create origin remote: %w", err) 42 + } 43 + return nil 44 +} 45 + 46 +func (g *Repo) RemoteURL() (string, error) { 47 + r, err := g.r.Remote(originRemote) 48 + if err != nil { 49 + return "", fmt.Errorf("failed to get remote: %w", err) 50 + } 51 + return r.Config().URLs[0], nil 52 +} 53 + 54 +const defaultDescription = "Unnamed repository; edit this file 'description' to name the repository" 55 + 56 +func (g *Repo) Description() (string, error) { 57 + path := filepath.Join(g.path, "description") 58 + if _, err := os.Stat(path); err != nil { 59 + return "", nil 60 + } 61 + 62 + d, err := os.ReadFile(path) 63 + if err != nil { 64 + return "", fmt.Errorf("failed to read description file: %w", err) 65 + } 66 + 67 + desc := string(d) 68 + if strings.Contains(desc, defaultDescription) { 69 + return "", nil 70 + } 71 + return desc, nil 72 +} 73 + 74 +func (g *Repo) SetDescription(desc string) error { 75 + path := filepath.Join(g.path, "description") 76 + return os.WriteFile(path, []byte(desc), 0o644) 77 +} 78 + 79 +func (g *Repo) LastSync() (time.Time, error) { 80 + raw, err := g.readOption("last-sync") 81 + if err != nil { 82 + return time.Time{}, err 83 + } 84 + 85 + if raw == "" { 86 + return time.Time{}, fmt.Errorf("last-sync not set") 87 + } 88 + 89 + out, err := time.Parse(time.RFC3339, raw) 90 + if err != nil { 91 + return time.Time{}, fmt.Errorf("failed to parse time: %w", err) 92 + } 93 + 94 + return out, nil 95 +} 96 + 97 +func (g *Repo) SetLastSync(lastSync time.Time) error { 98 + return g.setOption("last-sync", lastSync.Format(time.RFC3339)) 99 +} 100 + 101 +func (g *Repo) readOption(key string) (string, error) { 102 + c, err := g.r.Config() 103 + if err != nil { 104 + return "", fmt.Errorf("failed to read config: %w", err) 105 + } 106 + return c.Raw.Section("mugit").Options.Get(key), nil 107 +} 108 + 109 +func (g *Repo) setOption(key, value string) error { 110 + c, err := g.r.Config() 111 + if err != nil { 112 + return fmt.Errorf("failed to read config: %w", err) 113 + } 114 + c.Raw.Section("mugit").SetOption(key, value) 115 + return g.r.SetConfig(c) 116 +}
M
internal/git/repo.go
路路路 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "os" 7 6 "path/filepath" 8 - "sort" 9 7 "strings" 10 8 "time" 11 9 12 10 "github.com/go-git/go-git/v5" 13 - gitconfig "github.com/go-git/go-git/v5/config" 14 11 "github.com/go-git/go-git/v5/plumbing" 15 12 "github.com/go-git/go-git/v5/plumbing/object" 16 13 "github.com/go-git/go-git/v5/plumbing/transport/http" 路路路 59 56 return g.h == plumbing.ZeroHash 60 57 } 61 58 62 -// Init creates a bare repo. 59 +// Init initializes a bare repo in path. 63 60 func Init(path string) error { 64 61 _, err := git.PlainInit(path, true) 65 62 return err 路路路 70 67 return strings.TrimSuffix(name, ".git") 71 68 } 72 69 73 -func (g *Repo) Commits() ([]*object.Commit, error) { 70 +type Commit struct { 71 + Message string 72 + Hash string 73 + HashShort string 74 + AuthorName string 75 + AuthorEmail string 76 + Committed time.Time 77 +} 78 + 79 +func (g *Repo) Commits() ([]*Commit, error) { 74 80 if g.IsEmpty() { 75 - return []*object.Commit{}, nil 81 + return []*Commit{}, nil 76 82 } 77 83 78 84 ci, err := g.r.Log(&git.LogOptions{ 路路路 83 89 return nil, fmt.Errorf("commits from ref: %w", err) 84 90 } 85 91 86 - commits := []*object.Commit{} 92 + var commits []*Commit 87 93 ci.ForEach(func(c *object.Commit) error { 88 - commits = append(commits, c) 94 + commits = append(commits, &Commit{ 95 + AuthorEmail: c.Author.Email, 96 + AuthorName: c.Author.Name, 97 + Committed: c.Author.When, 98 + Hash: c.Hash.String(), 99 + HashShort: c.Hash.String()[:8], 100 + Message: c.Message, 101 + }) 89 102 return nil 90 103 }) 91 104 92 105 return commits, nil 93 106 } 94 107 95 -func (g *Repo) LastCommit() (*object.Commit, error) { 108 +func (g *Repo) LastCommit() (*Commit, error) { 96 109 if g.IsEmpty() { 97 110 return nil, ErrEmptyRepo 98 111 } 路路路 101 114 if err != nil { 102 115 return nil, fmt.Errorf("last commit: %w", err) 103 116 } 104 - return c, nil 117 + 118 + return &Commit{ 119 + AuthorEmail: c.Author.Email, 120 + AuthorName: c.Author.Name, 121 + Committed: c.Author.When, 122 + Hash: c.Hash.String(), 123 + HashShort: c.Hash.String()[:8], 124 + Message: c.Message, 125 + }, nil 105 126 } 106 127 107 128 func (g *Repo) FileContent(path string) (string, error) { 路路路 128 149 } 129 150 } 130 151 131 -func (g *Repo) Tags() ([]*TagReference, error) { 132 - iter, err := g.r.Tags() 133 - if err != nil { 134 - return nil, fmt.Errorf("tag objects: %w", err) 135 - } 152 +type Branch struct{ Name string } 136 153 137 - tags := make([]*TagReference, 0) 138 - if err := iter.ForEach(func(ref *plumbing.Reference) error { 139 - obj, err := g.r.TagObject(ref.Hash()) 140 - switch err { 141 - case nil: 142 - tags = append(tags, &TagReference{ 143 - ref: ref, 144 - tag: obj, 145 - }) 146 - case plumbing.ErrObjectNotFound: 147 - tags = append(tags, &TagReference{ 148 - ref: ref, 149 - }) 150 - default: 151 - return err 152 - } 153 - return nil 154 - }); err != nil { 155 - return nil, err 156 - } 157 - 158 - tagList := &TagList{r: g.r, refs: tags} 159 - sort.Sort(tagList) 160 - return tags, nil 161 -} 162 - 163 -func (g *Repo) Branches() ([]*plumbing.Reference, error) { 154 +func (g *Repo) Branches() ([]*Branch, error) { 164 155 bi, err := g.r.Branches() 165 156 if err != nil { 166 157 return nil, fmt.Errorf("branch: %w", err) 167 158 } 168 159 169 - branches := []*plumbing.Reference{} 170 - err = bi.ForEach(func(ref *plumbing.Reference) error { 171 - branches = append(branches, ref) 160 + var branches []*Branch 161 + err = bi.ForEach(func(r *plumbing.Reference) error { 162 + branches = append(branches, &Branch{ 163 + Name: r.Name().Short(), 164 + }) 172 165 return nil 173 166 }) 174 167 return branches, err 175 168 } 176 169 177 -const defaultDescription = "Unnamed repository; edit this file 'description' to name the repository" 178 - 179 -func (g *Repo) Description() (string, error) { 180 - // TODO: ??? Support both mugit.description and /description file 181 - path := filepath.Join(g.path, "description") 182 - if _, err := os.Stat(path); err != nil { 183 - return "", nil 184 - } 185 - 186 - d, err := os.ReadFile(path) 187 - if err != nil { 188 - return "", fmt.Errorf("failed to read description file: %w", err) 189 - } 190 - 191 - desc := string(d) 192 - if strings.Contains(desc, defaultDescription) { 193 - return "", nil 194 - } 195 - 196 - return desc, nil 197 -} 198 - 199 -func (g *Repo) IsPrivate() (bool, error) { 200 - c, err := g.r.Config() 201 - if err != nil { 202 - return false, fmt.Errorf("failed to read config: %w", err) 203 - } 204 - 205 - s := c.Raw.Section("mugit") 206 - return s.Options.Get("private") == "true", nil 207 -} 208 - 209 170 func (g *Repo) IsGoMod() bool { 210 171 _, err := g.FileContent("go.mod") 211 172 return err == nil 路路路 224 185 return "", fmt.Errorf("unable to find master branch") 225 186 } 226 187 227 -type MirrorInfo struct { 228 - IsMirror bool 229 - Remote string 230 - RemoteURL string 231 -} 232 - 233 -func (g *Repo) MirrorInfo() (MirrorInfo, error) { 234 - c, err := g.r.Config() 235 - if err != nil { 236 - return MirrorInfo{}, fmt.Errorf("failed to read config: %w", err) 237 - } 238 - 239 - isMirror := c.Raw.Section("mugit").Options.Get("mirror") == "true" 240 - for _, remote := range c.Remotes { 241 - if len(remote.URLs) > 0 && (remote.Name == "upstream" || remote.Name == "origin") { 242 - return MirrorInfo{ 243 - IsMirror: isMirror, 244 - Remote: remote.Name, 245 - RemoteURL: remote.URLs[0], 246 - }, nil 247 - } 248 - } 249 - // TODO: error if mirror opt is set, but there's no remotes 250 - return MirrorInfo{}, fmt.Errorf("no mirror remote found") 251 -} 252 - 253 -func (g *Repo) ReadLastSync() (time.Time, error) { 254 - c, err := g.r.Config() 255 - if err != nil { 256 - return time.Time{}, fmt.Errorf("failed to read config: %w", err) 257 - } 258 - 259 - raw := c.Raw.Section("mugit").Options.Get("last-sync") 260 - if raw == "" { 261 - return time.Time{}, fmt.Errorf("last-sync not set") 262 - } 263 - 264 - out, err := time.Parse(time.RFC3339, string(raw)) 265 - if err != nil { 266 - return time.Time{}, fmt.Errorf("failed to parse time: %w", err) 267 - } 268 - return out, nil 269 -} 270 - 271 -func (g *Repo) SetLastSync(lastSync time.Time) error { 272 - c, err := g.r.Config() 273 - if err != nil { 274 - return fmt.Errorf("failed to read config: %w", err) 275 - } 276 - 277 - c.Raw.Section("mugit"). 278 - SetOption("last-sync", lastSync.Format(time.RFC3339)) 279 - return g.r.SetConfig(c) 280 -} 188 +func (g *Repo) Fetch() error { return g.fetch(nil) } 281 189 282 -func (g *Repo) Fetch(remote string) error { 283 - return g.fetch(remote, nil) 284 -} 285 - 286 -func (g *Repo) FetchFromGithubWithToken(remote, token string) error { 287 - return g.fetch(remote, &http.BasicAuth{ 190 +func (g *Repo) FetchFromGithubWithToken(token string) error { 191 + return g.fetch(&http.BasicAuth{ 288 192 Username: token, 289 193 Password: "x-oauth-basic", 290 194 }) 291 195 } 292 196 293 -func (g *Repo) fetch(remote string, auth http.AuthMethod) error { 294 - rmt, err := g.r.Remote(remote) 197 +func (g *Repo) fetch(auth http.AuthMethod) error { 198 + rmt, err := g.r.Remote(originRemote) 295 199 if err != nil { 296 - return fmt.Errorf("failed to get upstream remote: %w", err) 200 + return fmt.Errorf("failed to get remote: %w", err) 297 201 } 298 202 299 - if ferr := rmt.Fetch( 300 - &git.FetchOptions{ 301 - RefSpecs: []gitconfig.RefSpec{ 302 - // fetch all branches 303 - "+refs/heads/*:refs/heads/*", 304 - "+refs/tags/*:refs/tags/*", 305 - }, 306 - Auth: auth, 307 - Tags: git.AllTags, 308 - Prune: true, 309 - Force: true, 310 - }); ferr != nil && !errors.Is(ferr, git.NoErrAlreadyUpToDate) { 311 - return fmt.Errorf("fetch failed: %w", ferr) 312 - } 313 - return err 203 + return rmt.Fetch(&git.FetchOptions{ 204 + Auth: auth, 205 + Tags: git.AllTags, 206 + Prune: true, 207 + Force: true, 208 + }) 314 209 }
M
internal/handlers/repo.go
路路路 109 109 data["commits"] = commits 110 110 data["gomod"] = repo.IsGoMod() 111 111 112 - if mirrorInfo, err := repo.MirrorInfo(); err == nil && mirrorInfo.IsMirror { 113 - lastSync, _ := repo.ReadLastSync() 112 + if isMirror, err := repo.IsMirror(); err == nil && isMirror { 113 + lastSync, _ := repo.LastSync() 114 + remoteURL, _ := repo.RemoteURL() 114 115 data["mirrorinfo"] = map[string]any{ 115 116 "isMirror": true, 116 - "url": mirrorInfo.RemoteURL, 117 + "url": remoteURL, 117 118 "lastSync": lastSync, 118 119 } 119 120 } 路路路 411 412 continue 412 413 } 413 414 } else { 414 - lastCommitTime = lastCommit.Author.When 415 + lastCommitTime = lastCommit.Committed 415 416 } 416 417 417 418 repos = append(repos, repoList{
M
internal/mirror/mirror.go
路路路 89 89 name := repo.Name() 90 90 slog.Info("mirror: sync started", "repo", name) 91 91 92 - mi, err := repo.MirrorInfo() 92 + remoteURL, err := repo.RemoteURL() 93 93 if err != nil { 94 - slog.Error("mirror: failed to get info", "repo", name, "err", err) 94 + slog.Error("mirror: failed to get remote url", "repo", name, "err", err) 95 95 return err 96 96 } 97 97 98 - if err := w.isRemoteValid(mi.RemoteURL); err != nil { 98 + if err := w.isRemoteValid(remoteURL); err != nil { 99 99 slog.Error("mirror: remote is not valid", "repo", name, "err", err) 100 100 return err 101 101 } 102 102 103 - if w.isRemoteGithub(mi.RemoteURL) && w.c.Mirror.GithubToken != "" { 104 - if err := repo.FetchFromGithubWithToken(mi.Remote, w.c.Mirror.GithubToken); err != nil { 103 + if w.isRemoteGithub(remoteURL) && w.c.Mirror.GithubToken != "" { 104 + if err := repo.FetchFromGithubWithToken(w.c.Mirror.GithubToken); err != nil { 105 105 slog.Error("mirror: fetch failed (authorized)", "repo", name, "err", err) 106 106 return err 107 107 } 108 108 } else { 109 - if err := repo.Fetch(mi.Remote); err != nil { 109 + if err := repo.Fetch(); err != nil { 110 110 slog.Error("mirror: fetch failed", "repo", name, "err", err) 111 111 return err 112 112 } 路路路 139 139 continue 140 140 } 141 141 142 - mirror, err := repo.MirrorInfo() 142 + isMirror, err := repo.IsMirror() 143 143 if err != nil { 144 144 slog.Debug("skipping non-mirror repo", "path", name, "err", err) 145 145 continue 146 146 } 147 147 148 - if mirror.IsMirror { 148 + if isMirror { 149 149 repos = append(repos, repo) 150 150 } 151 151 }
M
web/templates/repo_index.html
路路路 18 18 {{ range .commits }} 19 19 <div class="box"> 20 20 <div> 21 - <a href="/{{ $repo }}/commit/{{ .Hash.String }}" class="commit-hash">{{ slice .Hash.String 0 8 }}</a> 22 - — {{ .Author.Name }} 23 - <span class="commit-date commit-info">{{ .Author.When.Format "Mon, 02 Jan 2006" }}</span> 21 + <a href="/{{ $repo }}/commit/{{ .Hash }}" class="commit-hash">{{ .HashShort }}</a> 22 + — {{ .AuthorName }} 23 + <span class="commit-date commit-info">{{ .Committed.Format "Mon, 02 Jan 2006" }}</span> 24 24 </div> 25 25 <div>{{ commitSummary .Message }}</div> 26 26 </div>
M
web/templates/repo_log.html
路路路 22 22 {{ range .commits }} 23 23 <tr> 24 24 <td class="msg"> 25 - <a href="/{{ $repo }}/commit/{{ .Hash.String }}">{{ commitSummary .Message }}</a> 25 + <a href="/{{ $repo }}/commit/{{ .Hash }}">{{ commitSummary .Message }}</a> 26 26 </td> 27 27 <td class="author"> 28 28 <span class="author-short"> 29 - {{ .Author.Name }} <a href="mailto:{{ .Author.Email }}" class="commit-email">{{ .Author.Email }}</a> 29 + {{ .AuthorName }} <a href="mailto:{{ .AuthorEmail }}" class="commit-email">{{ .AuthorEmail }}</a> 30 30 </span> 31 31 <span class="tooltip" role="tooltip"> 32 - <strong>{{ .Author.Name }}</strong><br> 33 - <a href="mailto:{{ .Author.Email }}" class="commit-email">{{ .Author.Email }}</a> 32 + <strong>{{ .AuthorName }}</strong><br> 33 + <a href="mailto:{{ .AuthorEmail }}" class="commit-email">{{ .AuthorEmail }}</a> 34 34 </span> 35 35 </td> 36 36 <td class=""> 37 - <a href="/{{ $repo }}/commit/{{ .Hash.String }}">{{ slice .Hash.String 0 8 }}</a> 37 + <a href="/{{ $repo }}/commit/{{ .Hash }}">{{ .HashShort }}</a> 38 38 </td> 39 39 <td class="age"> 40 - <span class="age-display">{{ humanizeTime .Committer.When }}</span> 41 - <span class="tooltip" role="tooltip">{{ .Committer.When }}</span> 40 + <span class="age-display">{{ humanizeTime .Committed }}</span> 41 + <span class="tooltip" role="tooltip">{{ .Committed }}</span> 42 42 </td> 43 43 </tr> 44 44 {{ end }}
M
web/templates/repo_refs.html
路路路 12 12 <div class="refs"> 13 13 {{ range .branches }} 14 14 <div> 15 - <strong>{{ .Name.Short }}</strong> 16 - <a href="/{{ $name }}/tree/{{ .Name.Short }}/">browse</a> 17 - <a href="/{{ $name }}/log/{{ .Name.Short }}">log</a> 15 + <strong>{{ .Name }}</strong> 16 + <a href="/{{ $name }}/tree/{{ .Name }}/">browse</a> 17 + <a href="/{{ $name }}/log/{{ .Name }}">log</a> 18 18 </div> 19 19 {{ end }} 20 20 </div>