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 ". 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")