Small task automation and simplification tool for bare-bones git hosting. https://aphrodite.dev/~notebook/projects/gitmgr.html
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

328 lines
6.9 KiB

package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io/fs"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"text/template"
"time"
)
const (
errorRepoAlreadyExists = "repository already exists"
commandRunTimeout = 5 * time.Second
mirrorRunTimeout = 30 * time.Second
)
type RefType = int
const (
RefTypeUnknown RefType = iota
RefTypeBranch
RefTypeTag
RefTypePR
)
var (
RefFormat = regexp.MustCompile(`ref: refs/([\w-.]+(/[\w-.]+)*)`)
DefaultHead string
)
type RefGroup map[RefType][]string
func RefDecode(input string) string {
matches := RefFormat.FindStringSubmatch(input)
if len(matches) < 2 {
return DefaultHead
}
return matches[1]
}
func RefEncode(input string) string {
return fmt.Sprintf("ref: refs/%s", input)
}
type Repo struct {
Name string
Path string
IsExportedFile string
CloneUrlTemplate *template.Template
}
func (r Repo) file(name string) string {
filePath := path.Join(r.Path, name)
stat, err := os.Stat(filePath)
if err != nil || !stat.Mode().IsRegular() {
return ""
}
res, _ := ioutil.ReadFile(filePath)
return strings.TrimSpace(string(res))
}
func (r Repo) edit(name, value string) error {
filePath := path.Join(r.Path, name)
fd, err := os.Create(filePath)
if err != nil {
return err
}
defer func() { _ = fd.Close() }()
_, err = fmt.Fprint(fd, value)
return err
}
func (r Repo) ToggleVisibility() error {
filePath := path.Join(r.Path, r.IsExportedFile)
if _, err := os.Stat(filePath); err != nil {
visibilityFile, err := os.Create(filePath)
defer func() { _ = visibilityFile.Close() }()
return err
} else {
return os.Remove(filePath)
}
}
func (r Repo) IsExported() bool {
_, err := os.Stat(path.Join(
r.Path,
r.IsExportedFile,
))
return err == nil
}
func (r Repo) Description() string {
return r.file("description")
}
func (r Repo) Category() string {
return r.file("category")
}
func (r Repo) DefaultRef() string {
return RefDecode(r.file("HEAD"))
}
func sortRefs(_refs *RefGroup) func(p string) {
return func(p string) {
refs := *_refs
if strings.HasPrefix(p, "heads/") {
refs[RefTypeBranch] = append(refs[RefTypeBranch], p)
} else if strings.HasPrefix(p, "tags/") {
refs[RefTypeTag] = append(refs[RefTypeTag], p)
} else if strings.HasPrefix(p, "pull/") ||
strings.HasPrefix(p, "merge-requests/") {
refs[RefTypePR] = append(refs[RefTypePR], p)
} else {
refs[RefTypeUnknown] = append(refs[RefTypeUnknown], p)
}
}
}
func (r Repo) RefGroup() RefGroup {
refs := RefGroup{}
sorter := sortRefs(&refs)
basePath := path.Join(r.Path, "refs")
// Ref tree support
_ = filepath.Walk(basePath, func(p string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
p = strings.TrimPrefix(p, basePath+"/")
if !info.IsDir() && !strings.HasPrefix(info.Name(), ".") && basePath != p {
sorter(p)
}
return nil
})
// Packed refs index support
packed := r.file("packed-refs")
scanner := bufio.NewScanner(strings.NewReader(packed))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") || !strings.ContainsRune(line, ' ') {
continue // Malformed or unknown line.
}
sorter(strings.TrimPrefix(strings.SplitN(line, " ", 2)[1], "refs/"))
}
// Sorting PRs by their numerical ID
sort.Slice(refs[RefTypePR], func(i int, j int) bool {
si, sj := strings.SplitN(refs[RefTypePR][i], "/", 3)[1],
strings.SplitN(refs[RefTypePR][j], "/", 3)[1]
ires, ierr := strconv.Atoi(si)
jres, jerr := strconv.Atoi(sj)
if ierr != nil || jerr != nil {
return false
}
return ires < jres
})
return refs
}
func (r *Repo) Rename(name string) error {
gitRoot := path.Dir(r.Path)
err := os.Rename(
path.Join(gitRoot, r.Name),
path.Join(gitRoot, name),
)
if err == nil {
r.Name = name
r.Path = path.Join(gitRoot, name)
err = r.UpdateCloneUrl()
}
return err
}
func (r Repo) UpdateMetadata(description, category, defaultRef string) error {
if r.Description() != description {
err := r.edit("description", description)
if err != nil {
return err
}
}
if r.Category() != category {
err := r.edit("category", category)
if err != nil {
return err
}
}
if len(defaultRef) != 0 && r.DefaultRef() != defaultRef {
err := r.edit("HEAD", RefEncode(defaultRef))
if err != nil {
return err
}
}
return nil
}
func (r Repo) UpdateCloneUrl() error {
var tpl bytes.Buffer
err := r.CloneUrlTemplate.Execute(&tpl, struct{ Name string }{r.Name})
if err != nil {
return err
}
return r.edit("cloneurl", tpl.String())
}
type RepoList []Repo
func (r RepoList) Len() int { return len(r) }
func (r RepoList) Swap(a, b int) { r[a], r[b] = r[b], r[a] }
func (r RepoList) Less(a, b int) bool { return strings.ToLower(r[a].Name) < strings.ToLower(r[b].Name) }
type Git struct {
GitRoot string
IsExported string
CloneUrlTemplate *template.Template
}
// returns true if an error happened
func (g Git) InitRepo(name, defaultRef string) (bool, string) {
if g.Exists(name) {
return true, errorRepoAlreadyExists
}
ctx, cancel := context.WithTimeout(context.Background(), commandRunTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "init", "--bare", name)
cmd.Dir = g.GitRoot
output, err := cmd.CombinedOutput()
stdout := string(output)
if err != nil {
return true, stdout
}
repo := g.GetRepo(name)
err = repo.edit("HEAD", RefEncode(defaultRef))
if err != nil {
return true, stdout + err.Error()
}
err = repo.UpdateCloneUrl()
if err != nil {
return true, stdout + err.Error()
}
return false, stdout
}
func (g Git) MirrorRepo(name, upstream string) (bool, string) {
if g.Exists(name) {
return true, errorRepoAlreadyExists
}
ctx, cancel := context.WithTimeout(context.Background(), mirrorRunTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", upstream, name)
cmd.Dir = g.GitRoot
output, err := cmd.CombinedOutput()
return err != nil, string(output)
}
func (g Git) Exists(name string) bool {
_, err := os.Stat(path.Join(g.GitRoot, name))
return err == nil
}
func (g Git) Delete(name string) error {
toDelete := path.Join(g.GitRoot, name)
return os.RemoveAll(toDelete)
}
func (g Git) GetRepo(repo string) *Repo {
return &Repo{
Name: repo,
Path: path.Join(g.GitRoot, repo),
IsExportedFile: g.IsExported,
CloneUrlTemplate: g.CloneUrlTemplate,
}
}
func (g Git) ListRepos() (RepoList, error) {
in, err := ioutil.ReadDir(g.GitRoot)
if err != nil {
return nil, err
}
var repos RepoList
for _, f := range in {
if !f.IsDir() {
continue
}
// Ignoring .dirs
if strings.HasPrefix(f.Name(), ".") {
continue
}
repos = append(repos, *g.GetRepo(f.Name()))
}
sort.Sort(repos)
return repos, nil
}
func GroupReposByCategory(repos RepoList) map[string]RepoList {
res := map[string]RepoList{}
for _, repo := range repos {
res[repo.Category()] = append(res[repo.Category()], repo)
}
return res
}