2021-10-23 02:00:44 +00:00
|
|
|
|
package history
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/bouncepaw/mycorrhiza/files"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Revision represents a revision, duh. Hash is usually short. Username is extracted from email.
|
|
|
|
|
type Revision struct {
|
|
|
|
|
Hash string
|
|
|
|
|
Username string
|
|
|
|
|
Time time.Time
|
|
|
|
|
Message string
|
|
|
|
|
filesAffectedBuf []string
|
|
|
|
|
hyphaeAffectedBuf []string
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-27 02:45:38 +00:00
|
|
|
|
// gitLog calls `git log` and parses the results.
|
|
|
|
|
func gitLog(args ...string) ([]Revision, error) {
|
|
|
|
|
args = append([]string{
|
|
|
|
|
"log", "--abbrev-commit", "--no-merges",
|
|
|
|
|
"--pretty=format:%h\t%ae\t%at\t%s",
|
|
|
|
|
}, args...)
|
|
|
|
|
out, err := silentGitsh(args...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2021-10-23 02:00:44 +00:00
|
|
|
|
}
|
2021-10-27 02:45:38 +00:00
|
|
|
|
|
|
|
|
|
outStr := out.String()
|
|
|
|
|
if outStr == "" {
|
|
|
|
|
// if there are no commits to return
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var revs []Revision
|
|
|
|
|
for _, line := range strings.Split(outStr, "\n") {
|
|
|
|
|
revs = append(revs, parseRevisionLine(line))
|
|
|
|
|
}
|
|
|
|
|
return revs, nil
|
2021-10-23 02:00:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-27 02:45:38 +00:00
|
|
|
|
type recentChangesStream struct {
|
|
|
|
|
currHash string
|
2021-10-23 02:00:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-27 02:45:38 +00:00
|
|
|
|
func newRecentChangesStream() recentChangesStream {
|
|
|
|
|
// next returns the next n revisions from the stream, ordered most recent first.
|
|
|
|
|
// If there are less than n revisions remaining, it will return only those.
|
|
|
|
|
return recentChangesStream{currHash: ""}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (stream *recentChangesStream) next(n int) []Revision {
|
|
|
|
|
args := []string{"--max-count=" + strconv.Itoa(n)}
|
|
|
|
|
if stream.currHash == "" {
|
|
|
|
|
args = append(args, "HEAD")
|
|
|
|
|
} else {
|
|
|
|
|
// currHash is the last revision from the last call, so skip it
|
|
|
|
|
args = append(args, "--skip=1", stream.currHash)
|
|
|
|
|
}
|
|
|
|
|
// I don't think this can fail, so ignore the error
|
|
|
|
|
res, _ := gitLog(args...)
|
|
|
|
|
if len(res) != 0 {
|
|
|
|
|
stream.currHash = res[len(res)-1].Hash
|
|
|
|
|
}
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// recentChangesIterator returns a function that returns successive revisions from the stream.
|
|
|
|
|
// It buffers revisions to avoid calling git every time.
|
|
|
|
|
func (stream recentChangesStream) iterator() func() (Revision, bool) {
|
|
|
|
|
var buf []Revision
|
|
|
|
|
return func() (Revision, bool) {
|
|
|
|
|
if len(buf) == 0 {
|
|
|
|
|
// no real reason to choose 30, just needs some large number
|
|
|
|
|
buf = stream.next(30)
|
|
|
|
|
if len(buf) == 0 {
|
|
|
|
|
// revs has no revisions left
|
|
|
|
|
return Revision{}, true
|
2021-10-23 02:00:44 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2021-10-27 02:45:38 +00:00
|
|
|
|
rev := buf[0]
|
|
|
|
|
buf = buf[1:]
|
|
|
|
|
return rev, false
|
2021-10-23 02:00:44 +00:00
|
|
|
|
}
|
2021-10-27 02:45:38 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RecentChanges gathers an arbitrary number of latest changes in form of revisions slice, ordered most recent first.
|
|
|
|
|
func RecentChanges(n int) []Revision {
|
|
|
|
|
stream := newRecentChangesStream()
|
|
|
|
|
revs := stream.next(n)
|
|
|
|
|
log.Printf("Found %d recent changes", len(revs))
|
|
|
|
|
return revs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Revisions returns slice of revisions for the given hypha name, ordered most recent first.
|
|
|
|
|
func Revisions(hyphaName string) ([]Revision, error) {
|
|
|
|
|
revs, err := gitLog("--", hyphaName+".*")
|
2021-10-23 02:00:44 +00:00
|
|
|
|
log.Printf("Found %d revisions for ‘%s’\n", len(revs), hyphaName)
|
|
|
|
|
return revs, err
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-27 02:45:38 +00:00
|
|
|
|
// FileChanged tells you if the file has been changed since the last commit.
|
|
|
|
|
func FileChanged(path string) bool {
|
|
|
|
|
_, err := gitsh("diff", "--exit-code", path)
|
|
|
|
|
return err != nil
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-26 22:34:11 +00:00
|
|
|
|
// Return time like dd — 13:42
|
2021-10-23 02:00:44 +00:00
|
|
|
|
func (rev *Revision) timeToDisplay() string {
|
|
|
|
|
D := rev.Time.Day()
|
|
|
|
|
h, m, _ := rev.Time.Clock()
|
|
|
|
|
return fmt.Sprintf("%02d — %02d:%02d", D, h, m)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var revisionLinePattern = regexp.MustCompile("(.*)\t(.*)@.*\t(.*)\t(.*)")
|
|
|
|
|
|
|
|
|
|
// Convert a UNIX timestamp as string into a time. If nil is returned, it means that the timestamp could not be converted.
|
|
|
|
|
func unixTimestampAsTime(ts string) *time.Time {
|
|
|
|
|
i, err := strconv.ParseInt(ts, 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
tm := time.Unix(i, 0)
|
|
|
|
|
return &tm
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseRevisionLine(line string) Revision {
|
|
|
|
|
results := revisionLinePattern.FindStringSubmatch(line)
|
|
|
|
|
return Revision{
|
|
|
|
|
Hash: results[1],
|
|
|
|
|
Username: results[2],
|
|
|
|
|
Time: *unixTimestampAsTime(results[3]),
|
|
|
|
|
Message: results[4],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// filesAffected tells what files have been affected by the revision.
|
|
|
|
|
func (rev *Revision) filesAffected() (filenames []string) {
|
|
|
|
|
if nil != rev.filesAffectedBuf {
|
|
|
|
|
return rev.filesAffectedBuf
|
|
|
|
|
}
|
|
|
|
|
// List of files affected by this revision, one per line.
|
|
|
|
|
out, err := silentGitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash)
|
|
|
|
|
// There's an error? Well, whatever, let's just assign an empty slice, who cares.
|
|
|
|
|
if err != nil {
|
|
|
|
|
rev.filesAffectedBuf = []string{}
|
|
|
|
|
} else {
|
|
|
|
|
rev.filesAffectedBuf = strings.Split(out.String(), "\n")
|
|
|
|
|
}
|
|
|
|
|
return rev.filesAffectedBuf
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// determine what hyphae were affected by this revision
|
|
|
|
|
func (rev *Revision) hyphaeAffected() (hyphae []string) {
|
|
|
|
|
if nil != rev.hyphaeAffectedBuf {
|
|
|
|
|
return rev.hyphaeAffectedBuf
|
|
|
|
|
}
|
|
|
|
|
hyphae = make([]string, 0)
|
|
|
|
|
var (
|
|
|
|
|
// set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most currently).
|
|
|
|
|
set = make(map[string]bool)
|
|
|
|
|
isNewName = func(hyphaName string) bool {
|
|
|
|
|
if _, present := set[hyphaName]; present {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
set[hyphaName] = true
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
filesAffected = rev.filesAffected()
|
|
|
|
|
)
|
|
|
|
|
for _, filename := range filesAffected {
|
2022-04-02 08:09:37 +00:00
|
|
|
|
if strings.ContainsRune(filename, '.') {
|
2021-10-23 02:00:44 +00:00
|
|
|
|
dotPos := strings.LastIndexByte(filename, '.')
|
|
|
|
|
hyphaName := string([]byte(filename)[0:dotPos]) // is it safe?
|
|
|
|
|
if isNewName(hyphaName) {
|
|
|
|
|
hyphae = append(hyphae, hyphaName)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rev.hyphaeAffectedBuf = hyphae
|
|
|
|
|
return hyphae
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TimeString returns a human readable time representation.
|
|
|
|
|
func (rev Revision) TimeString() string {
|
|
|
|
|
return rev.Time.Format(time.RFC822)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// textDiff generates a good enough diff to display in a web feed. It is not html-escaped.
|
|
|
|
|
func (rev *Revision) textDiff() (diff string) {
|
|
|
|
|
filenames, ok := rev.mycoFiles()
|
|
|
|
|
if !ok {
|
|
|
|
|
return "No text changes"
|
|
|
|
|
}
|
|
|
|
|
for _, filename := range filenames {
|
|
|
|
|
text, err := PrimitiveDiffAtRevision(filename, rev.Hash)
|
|
|
|
|
if err != nil {
|
|
|
|
|
diff += "\nAn error has occurred with " + filename + "\n"
|
|
|
|
|
}
|
|
|
|
|
diff += text + "\n"
|
|
|
|
|
}
|
|
|
|
|
return diff
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mycoFiles returns filenames of .myco file. It is not ok if there are no myco files.
|
|
|
|
|
func (rev *Revision) mycoFiles() (filenames []string, ok bool) {
|
|
|
|
|
filenames = []string{}
|
|
|
|
|
for _, filename := range rev.filesAffected() {
|
|
|
|
|
if strings.HasSuffix(filename, ".myco") {
|
|
|
|
|
filenames = append(filenames, filename)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return filenames, len(filenames) > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try and guess what link is the most important by looking at the message.
|
|
|
|
|
func (rev *Revision) bestLink() string {
|
|
|
|
|
var (
|
|
|
|
|
revs = rev.hyphaeAffected()
|
|
|
|
|
renameRes = renameMsgPattern.FindStringSubmatch(rev.Message)
|
|
|
|
|
)
|
|
|
|
|
switch {
|
|
|
|
|
case renameRes != nil:
|
|
|
|
|
return "/hypha/" + renameRes[1]
|
|
|
|
|
case len(revs) == 0:
|
|
|
|
|
return ""
|
|
|
|
|
default:
|
|
|
|
|
return "/hypha/" + revs[0]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FileAtRevision shows how the file with the given file path looked at the commit with the hash. It may return an error if git fails.
|
|
|
|
|
func FileAtRevision(filepath, hash string) (string, error) {
|
|
|
|
|
out, err := gitsh("show", hash+":"+strings.TrimPrefix(filepath, files.HyphaeDir()+"/"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return out.String(), err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PrimitiveDiffAtRevision generates a plain-text diff for the given filepath at the commit with the given hash. It may return an error if git fails.
|
|
|
|
|
func PrimitiveDiffAtRevision(filepath, hash string) (string, error) {
|
|
|
|
|
out, err := silentGitsh("diff", "--unified=1", "--no-color", hash+"~", hash, "--", filepath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return out.String(), err
|
|
|
|
|
}
|