8 files changed,
227 insertions(+),
180 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-02-03 00:54:39 +0200
Change ID:
prnlunwxsrmrsosrswuwvvzlwvspmzku
Parent:
7a14044
A
internal/git/config.go
@@ -0,0 +1,116 @@
+package git + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + gitconfig "github.com/go-git/go-git/v5/config" +) + +func (g *Repo) IsPrivate() (bool, error) { + v, err := g.readOption("private") + if err != nil { + return false, err + } + return v == "true", nil +} + +const originRemote = "origin" + +func (g *Repo) IsMirror() (bool, error) { + r, err := g.r.Remote(originRemote) + if err != nil { + return false, fmt.Errorf("failed to get remote: %w", err) + } + return r.Config().Mirror, nil +} + +func (g *Repo) SetMirrorRemote(url string) error { + _, err := g.r.CreateRemote(&gitconfig.RemoteConfig{ + Name: originRemote, + URLs: []string{url}, + Mirror: true, + Fetch: []gitconfig.RefSpec{ + "+refs/*:refs/*", + }, + }) + if err != nil { + return fmt.Errorf("failed to create origin remote: %w", err) + } + return nil +} + +func (g *Repo) RemoteURL() (string, error) { + r, err := g.r.Remote(originRemote) + if err != nil { + return "", fmt.Errorf("failed to get remote: %w", err) + } + return r.Config().URLs[0], nil +} + +const defaultDescription = "Unnamed repository; edit this file 'description' to name the repository" + +func (g *Repo) Description() (string, error) { + path := filepath.Join(g.path, "description") + if _, err := os.Stat(path); err != nil { + return "", nil + } + + d, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read description file: %w", err) + } + + desc := string(d) + if strings.Contains(desc, defaultDescription) { + return "", nil + } + return desc, nil +} + +func (g *Repo) SetDescription(desc string) error { + path := filepath.Join(g.path, "description") + return os.WriteFile(path, []byte(desc), 0o644) +} + +func (g *Repo) LastSync() (time.Time, error) { + raw, err := g.readOption("last-sync") + if err != nil { + return time.Time{}, err + } + + if raw == "" { + return time.Time{}, fmt.Errorf("last-sync not set") + } + + out, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse time: %w", err) + } + + return out, nil +} + +func (g *Repo) SetLastSync(lastSync time.Time) error { + return g.setOption("last-sync", lastSync.Format(time.RFC3339)) +} + +func (g *Repo) readOption(key string) (string, error) { + c, err := g.r.Config() + if err != nil { + return "", fmt.Errorf("failed to read config: %w", err) + } + return c.Raw.Section("mugit").Options.Get(key), nil +} + +func (g *Repo) setOption(key, value string) error { + c, err := g.r.Config() + if err != nil { + return fmt.Errorf("failed to read config: %w", err) + } + c.Raw.Section("mugit").SetOption(key, value) + return g.r.SetConfig(c) +}
M
internal/git/repo.go
@@ -3,14 +3,11 @@
import ( "errors" "fmt" - "os" "path/filepath" - "sort" "strings" "time" "github.com/go-git/go-git/v5" - gitconfig "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/http"@@ -59,7 +56,7 @@ func (g *Repo) IsEmpty() bool {
return g.h == plumbing.ZeroHash } -// Init creates a bare repo. +// Init initializes a bare repo in path. func Init(path string) error { _, err := git.PlainInit(path, true) return err@@ -70,9 +67,18 @@ name := filepath.Base(g.path)
return strings.TrimSuffix(name, ".git") } -func (g *Repo) Commits() ([]*object.Commit, error) { +type Commit struct { + Message string + Hash string + HashShort string + AuthorName string + AuthorEmail string + Committed time.Time +} + +func (g *Repo) Commits() ([]*Commit, error) { if g.IsEmpty() { - return []*object.Commit{}, nil + return []*Commit{}, nil } ci, err := g.r.Log(&git.LogOptions{@@ -83,16 +89,23 @@ if err != nil {
return nil, fmt.Errorf("commits from ref: %w", err) } - commits := []*object.Commit{} + var commits []*Commit ci.ForEach(func(c *object.Commit) error { - commits = append(commits, c) + commits = append(commits, &Commit{ + AuthorEmail: c.Author.Email, + AuthorName: c.Author.Name, + Committed: c.Author.When, + Hash: c.Hash.String(), + HashShort: c.Hash.String()[:8], + Message: c.Message, + }) return nil }) return commits, nil } -func (g *Repo) LastCommit() (*object.Commit, error) { +func (g *Repo) LastCommit() (*Commit, error) { if g.IsEmpty() { return nil, ErrEmptyRepo }@@ -101,7 +114,15 @@ c, err := g.r.CommitObject(g.h)
if err != nil { return nil, fmt.Errorf("last commit: %w", err) } - return c, nil + + return &Commit{ + AuthorEmail: c.Author.Email, + AuthorName: c.Author.Name, + Committed: c.Author.When, + Hash: c.Hash.String(), + HashShort: c.Hash.String()[:8], + Message: c.Message, + }, nil } func (g *Repo) FileContent(path string) (string, error) {@@ -128,84 +149,24 @@ return "Not displaying binary file", nil
} } -func (g *Repo) Tags() ([]*TagReference, error) { - iter, err := g.r.Tags() - if err != nil { - return nil, fmt.Errorf("tag objects: %w", err) - } +type Branch struct{ Name string } - tags := make([]*TagReference, 0) - if err := iter.ForEach(func(ref *plumbing.Reference) error { - obj, err := g.r.TagObject(ref.Hash()) - switch err { - case nil: - tags = append(tags, &TagReference{ - ref: ref, - tag: obj, - }) - case plumbing.ErrObjectNotFound: - tags = append(tags, &TagReference{ - ref: ref, - }) - default: - return err - } - return nil - }); err != nil { - return nil, err - } - - tagList := &TagList{r: g.r, refs: tags} - sort.Sort(tagList) - return tags, nil -} - -func (g *Repo) Branches() ([]*plumbing.Reference, error) { +func (g *Repo) Branches() ([]*Branch, error) { bi, err := g.r.Branches() if err != nil { return nil, fmt.Errorf("branch: %w", err) } - branches := []*plumbing.Reference{} - err = bi.ForEach(func(ref *plumbing.Reference) error { - branches = append(branches, ref) + var branches []*Branch + err = bi.ForEach(func(r *plumbing.Reference) error { + branches = append(branches, &Branch{ + Name: r.Name().Short(), + }) return nil }) return branches, err } -const defaultDescription = "Unnamed repository; edit this file 'description' to name the repository" - -func (g *Repo) Description() (string, error) { - // TODO: ??? Support both mugit.description and /description file - path := filepath.Join(g.path, "description") - if _, err := os.Stat(path); err != nil { - return "", nil - } - - d, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("failed to read description file: %w", err) - } - - desc := string(d) - if strings.Contains(desc, defaultDescription) { - return "", nil - } - - return desc, nil -} - -func (g *Repo) IsPrivate() (bool, error) { - c, err := g.r.Config() - if err != nil { - return false, fmt.Errorf("failed to read config: %w", err) - } - - s := c.Raw.Section("mugit") - return s.Options.Get("private") == "true", nil -} - func (g *Repo) IsGoMod() bool { _, err := g.FileContent("go.mod") return err == nil@@ -224,91 +185,25 @@ }
return "", fmt.Errorf("unable to find master branch") } -type MirrorInfo struct { - IsMirror bool - Remote string - RemoteURL string -} - -func (g *Repo) MirrorInfo() (MirrorInfo, error) { - c, err := g.r.Config() - if err != nil { - return MirrorInfo{}, fmt.Errorf("failed to read config: %w", err) - } - - isMirror := c.Raw.Section("mugit").Options.Get("mirror") == "true" - for _, remote := range c.Remotes { - if len(remote.URLs) > 0 && (remote.Name == "upstream" || remote.Name == "origin") { - return MirrorInfo{ - IsMirror: isMirror, - Remote: remote.Name, - RemoteURL: remote.URLs[0], - }, nil - } - } - // TODO: error if mirror opt is set, but there's no remotes - return MirrorInfo{}, fmt.Errorf("no mirror remote found") -} - -func (g *Repo) ReadLastSync() (time.Time, error) { - c, err := g.r.Config() - if err != nil { - return time.Time{}, fmt.Errorf("failed to read config: %w", err) - } - - raw := c.Raw.Section("mugit").Options.Get("last-sync") - if raw == "" { - return time.Time{}, fmt.Errorf("last-sync not set") - } - - out, err := time.Parse(time.RFC3339, string(raw)) - if err != nil { - return time.Time{}, fmt.Errorf("failed to parse time: %w", err) - } - return out, nil -} - -func (g *Repo) SetLastSync(lastSync time.Time) error { - c, err := g.r.Config() - if err != nil { - return fmt.Errorf("failed to read config: %w", err) - } - - c.Raw.Section("mugit"). - SetOption("last-sync", lastSync.Format(time.RFC3339)) - return g.r.SetConfig(c) -} +func (g *Repo) Fetch() error { return g.fetch(nil) } -func (g *Repo) Fetch(remote string) error { - return g.fetch(remote, nil) -} - -func (g *Repo) FetchFromGithubWithToken(remote, token string) error { - return g.fetch(remote, &http.BasicAuth{ +func (g *Repo) FetchFromGithubWithToken(token string) error { + return g.fetch(&http.BasicAuth{ Username: token, Password: "x-oauth-basic", }) } -func (g *Repo) fetch(remote string, auth http.AuthMethod) error { - rmt, err := g.r.Remote(remote) +func (g *Repo) fetch(auth http.AuthMethod) error { + rmt, err := g.r.Remote(originRemote) if err != nil { - return fmt.Errorf("failed to get upstream remote: %w", err) + return fmt.Errorf("failed to get remote: %w", err) } - if ferr := rmt.Fetch( - &git.FetchOptions{ - RefSpecs: []gitconfig.RefSpec{ - // fetch all branches - "+refs/heads/*:refs/heads/*", - "+refs/tags/*:refs/tags/*", - }, - Auth: auth, - Tags: git.AllTags, - Prune: true, - Force: true, - }); ferr != nil && !errors.Is(ferr, git.NoErrAlreadyUpToDate) { - return fmt.Errorf("fetch failed: %w", ferr) - } - return err + return rmt.Fetch(&git.FetchOptions{ + Auth: auth, + Tags: git.AllTags, + Prune: true, + Force: true, + }) }
M
internal/handlers/repo.go
@@ -109,11 +109,12 @@ data["readme"] = readmeContents
data["commits"] = commits data["gomod"] = repo.IsGoMod() - if mirrorInfo, err := repo.MirrorInfo(); err == nil && mirrorInfo.IsMirror { - lastSync, _ := repo.ReadLastSync() + if isMirror, err := repo.IsMirror(); err == nil && isMirror { + lastSync, _ := repo.LastSync() + remoteURL, _ := repo.RemoteURL() data["mirrorinfo"] = map[string]any{ "isMirror": true, - "url": mirrorInfo.RemoteURL, + "url": remoteURL, "lastSync": lastSync, } }@@ -411,7 +412,7 @@ errs = append(errs, err)
continue } } else { - lastCommitTime = lastCommit.Author.When + lastCommitTime = lastCommit.Committed } repos = append(repos, repoList{
M
internal/mirror/mirror.go
@@ -89,24 +89,24 @@ func (w *Worker) syncRepo(_ context.Context, repo *git.Repo) error {
name := repo.Name() slog.Info("mirror: sync started", "repo", name) - mi, err := repo.MirrorInfo() + remoteURL, err := repo.RemoteURL() if err != nil { - slog.Error("mirror: failed to get info", "repo", name, "err", err) + slog.Error("mirror: failed to get remote url", "repo", name, "err", err) return err } - if err := w.isRemoteValid(mi.RemoteURL); err != nil { + if err := w.isRemoteValid(remoteURL); err != nil { slog.Error("mirror: remote is not valid", "repo", name, "err", err) return err } - if w.isRemoteGithub(mi.RemoteURL) && w.c.Mirror.GithubToken != "" { - if err := repo.FetchFromGithubWithToken(mi.Remote, w.c.Mirror.GithubToken); err != nil { + if w.isRemoteGithub(remoteURL) && w.c.Mirror.GithubToken != "" { + if err := repo.FetchFromGithubWithToken(w.c.Mirror.GithubToken); err != nil { slog.Error("mirror: fetch failed (authorized)", "repo", name, "err", err) return err } } else { - if err := repo.Fetch(mi.Remote); err != nil { + if err := repo.Fetch(); err != nil { slog.Error("mirror: fetch failed", "repo", name, "err", err) return err }@@ -139,13 +139,13 @@ slog.Debug("skipping non-git directory", "path", name, "err", err)
continue } - mirror, err := repo.MirrorInfo() + isMirror, err := repo.IsMirror() if err != nil { slog.Debug("skipping non-mirror repo", "path", name, "err", err) continue } - if mirror.IsMirror { + if isMirror { repos = append(repos, repo) } }
M
web/templates/repo_index.html
@@ -18,9 +18,9 @@ <div class="repo-index-main">
{{ range .commits }} <div class="box"> <div> - <a href="/{{ $repo }}/commit/{{ .Hash.String }}" class="commit-hash">{{ slice .Hash.String 0 8 }}</a> - — {{ .Author.Name }} - <span class="commit-date commit-info">{{ .Author.When.Format "Mon, 02 Jan 2006" }}</span> + <a href="/{{ $repo }}/commit/{{ .Hash }}" class="commit-hash">{{ .HashShort }}</a> + — {{ .AuthorName }} + <span class="commit-date commit-info">{{ .Committed.Format "Mon, 02 Jan 2006" }}</span> </div> <div>{{ commitSummary .Message }}</div> </div>
M
web/templates/repo_log.html
@@ -22,23 +22,23 @@ <tbody>
{{ range .commits }} <tr> <td class="msg"> - <a href="/{{ $repo }}/commit/{{ .Hash.String }}">{{ commitSummary .Message }}</a> + <a href="/{{ $repo }}/commit/{{ .Hash }}">{{ commitSummary .Message }}</a> </td> <td class="author"> <span class="author-short"> - {{ .Author.Name }} <a href="mailto:{{ .Author.Email }}" class="commit-email">{{ .Author.Email }}</a> + {{ .AuthorName }} <a href="mailto:{{ .AuthorEmail }}" class="commit-email">{{ .AuthorEmail }}</a> </span> <span class="tooltip" role="tooltip"> - <strong>{{ .Author.Name }}</strong><br> - <a href="mailto:{{ .Author.Email }}" class="commit-email">{{ .Author.Email }}</a> + <strong>{{ .AuthorName }}</strong><br> + <a href="mailto:{{ .AuthorEmail }}" class="commit-email">{{ .AuthorEmail }}</a> </span> </td> <td class=""> - <a href="/{{ $repo }}/commit/{{ .Hash.String }}">{{ slice .Hash.String 0 8 }}</a> + <a href="/{{ $repo }}/commit/{{ .Hash }}">{{ .HashShort }}</a> </td> <td class="age"> - <span class="age-display">{{ humanizeTime .Committer.When }}</span> - <span class="tooltip" role="tooltip">{{ .Committer.When }}</span> + <span class="age-display">{{ humanizeTime .Committed }}</span> + <span class="tooltip" role="tooltip">{{ .Committed }}</span> </td> </tr> {{ end }}
M
web/templates/repo_refs.html
@@ -12,9 +12,9 @@ <h3>branches</h3>
<div class="refs"> {{ range .branches }} <div> - <strong>{{ .Name.Short }}</strong> - <a href="/{{ $name }}/tree/{{ .Name.Short }}/">browse</a> - <a href="/{{ $name }}/log/{{ .Name.Short }}">log</a> + <strong>{{ .Name }}</strong> + <a href="/{{ $name }}/tree/{{ .Name }}/">browse</a> + <a href="/{{ $name }}/log/{{ .Name }}">log</a> </div> {{ end }} </div>