syw/repo.go

372 lines
8.6 KiB
Go

package syw
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"tildegit.org/tjp/sliderule/logging"
)
// Repository represents a git repository.
type Repository string
// Open produces a git repository from a directory path.
//
// It will also try a few variations (dirpath.git, dirpath/.git) and use the first
// path found to be a git repository.
//
// It returns nil if neither dirpath nor any of its variations are a valid git
// repository.
func Open(dirpath string) *Repository {
check := []string{dirpath}
if !strings.HasSuffix(dirpath, ".git") {
check = append(check, dirpath+".git")
}
check = append(check, filepath.Join(dirpath, ".git"))
for _, p := range check {
if st, err := os.Stat(filepath.Join(p, "objects")); err != nil || !st.IsDir() {
continue
}
if st, err := os.Stat(filepath.Join(p, "refs")); err != nil || !st.IsDir() {
continue
}
r := Repository(p)
return &r
}
return nil
}
// Name is the repository name, defined by the directory path.
func (r *Repository) Name() string {
name := filepath.Base(string(*r))
if name == ".git" {
name = filepath.Base(filepath.Dir(string(*r)))
}
return strings.TrimSuffix(name, ".git")
}
// NameBytes returns a byte slice of the repository name.
func (r *Repository) NameBytes() []byte {
return []byte(r.Name())
}
func (r *Repository) cmd(ctx context.Context, cmdname string, args ...string) (*cmdResult, error) {
args = append([]string{"--git-dir=" + string(*r), cmdname}, args...)
start := time.Now()
result, err := runCmd(ctx, args)
log, ok := ctx.Value("debuglog").(logging.Logger)
if ok {
_ = log.Log("msg", "ran git command", "args", fmt.Sprintf("%+v", args), "dur", time.Since(start))
}
return result, err
}
// Type returns the result of "git cat-file -t <hash>".
func (r *Repository) Type(ctx context.Context, hash string) (string, error) {
res, err := r.cmd(ctx, "cat-file", "-t", hash)
if err != nil {
return "", err
}
if res.status != 0 {
return "", errors.New(res.err.String())
}
return strings.Trim(res.out.String(), "\n"), nil
}
// Refs returns a list of branch and tag references.
func (r *Repository) Refs(ctx context.Context) ([]Ref, error) {
res, err := r.cmd(ctx, "show-ref", "--head", "--heads", "--tags")
if err != nil {
return nil, err
}
lines := strings.Split(res.out.String(), "\n")
branches := make([]Ref, 0, len(lines))
for _, line := range lines {
hash, name, found := strings.Cut(line, " ")
if !found {
continue
}
branches = append(branches, Ref{
Repo: r,
Name: name,
Hash: hash,
})
}
return branches, nil
}
var badRevListOutput = errors.New("unexpected 'git rev-list' output")
// Commits lists commits backwards from a given head.
func (r *Repository) Commits(ctx context.Context, head string, count int) ([]Commit, error) {
res, err := r.cmd(ctx, "rev-list",
"--format=%an%n%ae%n%aI%n%cn%n%ce%n%cI%n%P%n%B$$END$$",
"-n", strconv.Itoa(count),
head,
)
if err != nil {
return nil, err
}
commits := make([]Commit, 0, count)
for _, revstr := range strings.Split(res.out.String(), "\n$$END$$\n") {
if revstr == "" {
continue
}
commits = append(commits, Commit{Repo: r})
commit := &commits[len(commits)-1]
commitHash, rest, found := strings.Cut(revstr, "\n")
if !found {
return nil, badRevListOutput
}
commit.Hash = commitHash[7:]
commit.AuthorName, rest, found = strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
commit.AuthorEmail, rest, found = strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
adate, rest, found := strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
commit.AuthorDate, err = time.Parse(time.RFC3339, adate)
if err != nil {
return nil, err
}
commit.CommitterName, rest, found = strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
commit.CommitterEmail, rest, found = strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
cdate, rest, found := strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
commit.CommitDate, err = time.Parse(time.RFC3339, cdate)
if err != nil {
return nil, err
}
parents, rest, found := strings.Cut(rest, "\n")
if !found {
return nil, badRevListOutput
}
commit.Parents = strings.Split(parents, " ")
if len(commit.Parents) == 1 && commit.Parents[0] == "" {
commit.Parents = nil
}
commit.Message = rest
}
return commits, nil
}
// Commit gathers a single commit by a reference string.
func (r *Repository) Commit(ctx context.Context, ref string) (*Commit, error) {
commits, err := r.Commits(ctx, ref, 1)
if err != nil {
return nil, err
}
return &commits[0], nil
}
// Diffstat produces a diffstat of two trees by their references.
func (r *Repository) Diffstat(ctx context.Context, fromref, toref string) (string, error) {
res, err := r.cmd(ctx, "diff-tree", "-r", "--stat", fromref, toref)
if err != nil {
return "", err
}
if res.status != 0 {
return "", errors.New(res.err.String())
}
return res.out.String(), nil
}
// Diff produces a diff of two trees by their references.
func (r *Repository) Diff(ctx context.Context, fromref, toref string) (string, error) {
res, err := r.cmd(ctx, "diff-tree", "-r", "-p", "-u", fromref, toref)
if err != nil {
return "", err
}
if res.status != 0 {
return "", errors.New(res.err.String())
}
return res.out.String(), nil
}
// Readme represents a README file.
type Readme struct {
Filename string
RawContents string
}
// GeminiEscapedContent produces the file contents with any ```-leading lines prefixed with a space.
func (r Readme) GeminiEscapedContents() string {
body := r.RawContents
if strings.HasPrefix(body, "```") {
body = " " + body
}
return strings.ReplaceAll(body, "\n```", "\n ```")
}
// GopherEscapedContent produces the file formatted as gophermap with every line an info-message line.
func (r Readme) GopherEscapedContents(selector, host, port string) string {
return gopherRawtext(selector, host, port, r.RawContents)
}
// Readme finds a README blob in the root path under a ref string.
func (r *Repository) Readme(ctx context.Context, ref string) (*Readme, error) {
dir, err := r.Tree(ctx, ref, "")
if err != nil {
return nil, err
}
filename := ""
for i := range dir {
if dir[i].Type == "blob" && strings.HasPrefix(strings.ToLower(dir[i].Path), "readme") {
filename = dir[i].Path
break
}
}
if filename != "" {
body, err := r.Blob(ctx, ref, filename)
if err != nil {
return nil, err
}
return &Readme{
Filename: filename,
RawContents: string(body),
}, nil
}
return nil, nil
}
// Description reads the "description" file from in the git repository.
func (r *Repository) Description() string {
f, err := os.Open(filepath.Join(string(*r), "description"))
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
b, err := io.ReadAll(f)
if err != nil {
return ""
}
return strings.TrimRight(string(b), "\n")
}
// Blob returns the contents of a blob at a given ref (commit) and path.
func (r *Repository) Blob(ctx context.Context, ref, path string) ([]byte, error) {
res, err := r.cmd(ctx, "cat-file", "blob", ref+":"+path)
switch {
case res == nil:
return nil, err
case res.status == 0:
return res.out.Bytes(), nil
case res.status == 128:
return nil, objectDoesNotExist
default:
return nil, errors.New(res.err.String())
}
}
// ObjectDescription represents an object within a git tree (directory).
type ObjectDescription struct {
Mode int
Type string
Hash string
Size int
Path string
}
// Tree lists the contents of a given directory (path) in a commit (ref).
func (r *Repository) Tree(ctx context.Context, ref, path string) ([]ObjectDescription, error) {
pattern := ref
if path != "" && path != "." {
pattern += ":" + path
}
res, err := r.cmd(ctx, "ls-tree", "-l", pattern)
if err != nil {
return nil, err
}
out := []ObjectDescription{}
for _, line := range strings.Split(res.out.String(), "\n") {
if line == "" {
continue
}
spl := dropEmpty(strings.Split(strings.ReplaceAll(line, "\t", " "), " "))
mode, err := strconv.ParseInt(spl[0], 8, 64)
if err != nil {
return nil, err
}
var size int
if spl[3] != "-" {
size, err = strconv.Atoi(spl[3])
if err != nil {
return nil, err
}
}
out = append(out, ObjectDescription{
Mode: int(mode),
Type: spl[1],
Hash: spl[2],
Size: size,
Path: spl[4],
})
}
return out, nil
}
func dropEmpty(sl []string) []string {
n := 0
for i := 0; i < len(sl); i += 1 {
if sl[i] == "" {
copy(sl[i:], sl[i+1:])
i -= 1
n += 1
}
}
return sl[:len(sl)-n]
}
var objectDoesNotExist = errors.New("object does not exist")