372 lines
8.6 KiB
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")
|