From: Sergey Matveev Date: Sat, 11 Jan 2020 20:20:31 +0000 (+0300) Subject: Initial working version X-Git-Tag: v0.0.1^0 X-Git-Url: http://www.git.stargrave.org/?a=commitdiff_plain;h=cd7b96f67bcc0b65ceced01b2e4b2707be3c358a;p=sgblog.git Initial working version --- diff --git a/README b/README new file mode 100644 index 0000000..41ce3ca --- /dev/null +++ b/README @@ -0,0 +1,59 @@ + SGBlog -- Git-based CGI blogging engine + ======================================= + +DESCRIPTION + +SGBlog is a simple blogging engine with a Git-based storage and CGI +interface with HTTP-server. It can be used as a cgit replacement for +readonly Git repository viewing and Atom feed generation. But it has +better features: + +* URLs are tried to be converted to clickable links +* SHA1 hashes are tried to be converted to blog links itself +* Relative links are included for easy navigation +* Each page has ETag and it is checked against the request for client-side + caching +* Pages can be gzip-compressed, depending on Accept-Encoding header + +CONFIGURATION + +SGBlog is configured via environment variables: +* SGBLOG_GIT_PATH points to .git directory you want to serve +* SGBLOG_BRANCH points to the branch in it (refs/heads/master for example) +* SGBLOG_BASE_URL points to full URL before the possible SGBLOG_URL_PREFIX +* SGBLOG_TITLE sets the index title +* SGBLOG_ATOM_ID sets Atom feed's id +* SGBLOG_ATOM_AUTHOR sets Atom feed's author name +* If SGBLOG_URL_PREFIX is set, then all link will be prefixed with that URL +* If SGBLOG_CSS is set, then link to that CSS URL will be generated +* If SGBLOG_WEBMASTER is set, then "made" link well be generated +* If SGBLOG_ABOUT is set, then about link is generated at the bottom +* If SGBLOG_GIT_URLS is set, then links to that vcs-git space-separated + URLs will be generated + +Example lighttpd's configuration: + + setenv.add-environment = ( + "SGBLOG_GIT_PATH" => "/home/git/pub/stargrave-blog.git", + "SGBLOG_BRANCH" => "refs/heads/russian", + "SGBLOG_BASE_URL" => "http://blog.stargrave.org", + "SGBLOG_TITLE" => "Russian Stargrave's blog", + "SGBLOG_ATOM_ID" => "urn:uuid:e803a056-1147-44d4-9332-5190cb78cc3d", + "SGBLOG_ATOM_AUTHOR" => "Sergey Matveev", + "SGBLOG_URL_PREFIX" => "/russian", + "SGBLOG_WEBMASTER" => "webmaster@stargrave.org", + "SGBLOG_CSS" => "http://blog.stargrave.org/style.css", + "SGBLOG_GIT_URLS" => "git://git.stargrave.org/stargrave-blog.git https://git.stargrave.org/git/stargrave-blog.git", + "SGBLOG_ABOUT" => "http://blog.stargrave.org/", + ) + +LICENCE + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. diff --git a/cmd/sgblog/main.go b/cmd/sgblog/main.go new file mode 100644 index 0000000..39641ab --- /dev/null +++ b/cmd/sgblog/main.go @@ -0,0 +1,492 @@ +/* +SGBlog -- Git-based CGI blogging engine +Copyright (C) 2020 Sergey Matveev + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Git-based CGI blogging engine +package main + +import ( + "bytes" + "compress/gzip" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "hash" + "io" + "io/ioutil" + "net/url" + "os" + "regexp" + "strconv" + "strings" + + "github.com/google/uuid" + "golang.org/x/crypto/blake2b" + "golang.org/x/tools/blog/atom" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +const ( + PageEntries = 50 + WhenFmt = "2006-01-02 15:04:05Z07:00" + AtomFeed = "feed.atom" +) + +var ( + Version = "0.0.1" + ETagVersion = []byte("1") + sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})") + defaultLinks = []string{} + repo *git.Repository + commentsTree *object.Tree + + renderableSchemes = map[string]struct{}{ + "http": struct{}{}, + "https": struct{}{}, + "ftp": struct{}{}, + "gopher": struct{}{}, + } +) + +func makeA(href, text string) string { + return fmt.Sprintf(`%s`, href, text) +} + +func etagString(etag hash.Hash) string { + return `"` + hex.EncodeToString(etag.Sum(nil)) + `"` +} + +func urlParse(what string) *url.URL { + if u, err := url.ParseRequestURI(what); err == nil { + if _, exists := renderableSchemes[u.Scheme]; exists { + return u + } + } + return nil +} + +func msgSplit(msg string) []string { + lines := strings.Split(msg, "\n") + lines = lines[:len(lines)-1] + if len(lines) < 3 { + lines = []string{lines[0], "", ""} + } + return lines +} + +func getNote(what plumbing.Hash) string { + if commentsTree == nil { + return "" + } + entry, err := commentsTree.FindEntry(what.String()) + if err != nil { + return "" + } + blob, err := repo.BlobObject(entry.Hash) + if err != nil { + return "" + } + r, err := blob.Reader() + if err != nil { + return "" + } + data, err := ioutil.ReadAll(r) + if err != nil { + return "" + } + return string(data) +} + +func startHeader(etag hash.Hash, gziped bool) string { + lines := []string{ + "Content-Type: text/html; charset=UTF-8", + "ETag: " + etagString(etag), + } + if gziped { + lines = append(lines, "Content-Encoding: gzip") + } + lines = append(lines, "") + lines = append(lines, "") + return strings.Join(lines, "\n") +} + +func startHTML(title string, additional []string) string { + return fmt.Sprintf(` + + + + %s + %s + + +`, + Version, title, + strings.Join(append(defaultLinks, additional...), "\n "), + ) +} + +func makeErr(err error) { + fmt.Println("Content-Type: text/plain; charset=UTF-8\n") + fmt.Println(err) + panic(err) +} + +func checkETag(etag hash.Hash) { + ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH") + if ifNoneMatch != "" && ifNoneMatch == etagString(etag) { + fmt.Println("Status: 304\nETag:", ifNoneMatch, "\n") + os.Exit(0) + } +} + +func main() { + gitPath, exists := os.LookupEnv("SGBLOG_GIT_PATH") + if !exists { + makeErr(errors.New("SGBLOG_GIT_PATH is unset")) + } + branchName, exists := os.LookupEnv("SGBLOG_BRANCH") + if !exists { + makeErr(errors.New("SGBLOG_BRANCH is unset")) + } + blogBaseURL, exists := os.LookupEnv("SGBLOG_BASE_URL") + if !exists { + makeErr(errors.New("SGBLOG_BASE_URL is unset")) + } + blogTitle, exists := os.LookupEnv("SGBLOG_TITLE") + if !exists { + makeErr(errors.New("SGBLOG_TITLE is unset")) + } + atomId, exists := os.LookupEnv("SGBLOG_ATOM_ID") + if !exists { + makeErr(errors.New("SGBLOG_ATOM_ID is unset")) + } + atomAuthorName, exists := os.LookupEnv("SGBLOG_ATOM_AUTHOR") + if !exists { + makeErr(errors.New("SGBLOG_ATOM_AUTHOR is unset")) + } + + etagHash, err := blake2b.New256(nil) + if err != nil { + panic(err) + } + etagHash.Write(ETagVersion) + etagHash.Write([]byte(gitPath)) + etagHash.Write([]byte(branchName)) + etagHash.Write([]byte(blogBaseURL)) + etagHash.Write([]byte(blogTitle)) + etagHash.Write([]byte(atomId)) + etagHash.Write([]byte(atomAuthorName)) + + // SGBLOG_URL_PREFIX + urlPrefix := os.Getenv("SGBLOG_URL_PREFIX") + etagHash.Write([]byte(urlPrefix)) + + // SGBLOG_CSS + if cssUrl, exists := os.LookupEnv("SGBLOG_CSS"); exists { + defaultLinks = append(defaultLinks, fmt.Sprintf( + ``, + cssUrl, + )) + etagHash.Write([]byte(cssUrl)) + } + + // SGBLOG_WEBMASTER + if webmaster, exists := os.LookupEnv("SGBLOG_WEBMASTER"); exists { + defaultLinks = append(defaultLinks, fmt.Sprintf( + ``, + webmaster, + )) + etagHash.Write([]byte(webmaster)) + } + + // SGBLOG_ABOUT + aboutUrl := os.Getenv("SGBLOG_ABOUT") + etagHash.Write([]byte(aboutUrl)) + + // SGBLOG_GIT_URLS + if gitUrls, exists := os.LookupEnv("SGBLOG_GIT_URLS"); exists { + for _, gitUrl := range strings.Split(gitUrls, " ") { + defaultLinks = append(defaultLinks, fmt.Sprintf( + ``, + gitUrl, + )) + } + etagHash.Write([]byte(gitUrls)) + } + + defaultLinks = append(defaultLinks, fmt.Sprintf( + ``, + urlPrefix, + )) + atomUrl := blogBaseURL + urlPrefix + "/" + AtomFeed + defaultLinks = append(defaultLinks, fmt.Sprintf( + ``, + atomUrl, + )) + + pathInfo, exists := os.LookupEnv("PATH_INFO") + if !exists { + pathInfo = "/" + } + queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING")) + if err != nil { + makeErr(err) + } + + repo, err = git.PlainOpen(gitPath) + if err != nil { + makeErr(err) + } + head, err := repo.Reference(plumbing.ReferenceName(branchName), false) + if err != nil { + makeErr(err) + } + if notes, err := repo.Notes(); err == nil { + var comments *plumbing.Reference + notes.ForEach(func(ref *plumbing.Reference) error { + if ref.Name() == "refs/notes/commits" { + comments = ref + } + return nil + }) + if comments != nil { + if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil { + commentsTree, _ = commentsCommit.Tree() + } + } + } + + var outBuf bytes.Buffer + var out io.Writer + out = &outBuf + var gzipWriter *gzip.Writer + acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING") + for _, encoding := range strings.Split(acceptEncoding, ", ") { + if encoding == "gzip" { + gzipWriter = gzip.NewWriter(&outBuf) + out = gzipWriter + } + } + + var commit *object.Commit + if pathInfo == "/" { + offset := 0 + if offsetRaw, exists := queryValues["offset"]; exists { + offset, err = strconv.Atoi(offsetRaw[0]) + if err != nil { + makeErr(err) + } + } + var table bytes.Buffer + table.WriteString("\n\n") + log, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + makeErr(err) + } + errOccured := false + for i := 0; i < offset; i++ { + commit, err = log.Next() + if err != nil { + break + } + } + for i := 0; i < PageEntries; i++ { + commit, err = log.Next() + if err != nil { + errOccured = true + break + } + if i == 0 { + etagHash.Write(commit.Hash[:]) + checkETag(etagHash) + } + lines := msgSplit(commit.Message) + domains := []string{} + for _, line := range lines[2:] { + if u := urlParse(line); u == nil { + break + } else { + domains = append(domains, makeA(line, u.Host)) + } + } + entry := []string{ + makeA(urlPrefix+"/"+commit.Hash.String(), lines[0]), + fmt.Sprintf("(%dL)", len(lines)-2), + } + if note := getNote(commit.Hash); note != "" { + entry = append(entry, "(N)") + } + table.WriteString(fmt.Sprintf( + "\n", + commit.Author.When.Format(WhenFmt), + strings.Join(entry, " "), + strings.Join(domains, " "), + )) + } + table.WriteString("
WhenTitleComment of
%s%s%s
") + var links []string + var refs bytes.Buffer + if offset > 0 { + offsetPrev := offset - PageEntries + if offsetPrev < 0 { + offsetPrev = 0 + } + href := urlPrefix + "/?offset=" + strconv.Itoa(offsetPrev) + links = append(links, fmt.Sprintf( + ``, href, + )) + refs.WriteString(makeA(href, "[prev]")) + } + if !errOccured { + href := urlPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries) + links = append(links, fmt.Sprintf( + ``, href, + )) + refs.WriteString(makeA(href, "[next]")) + } + os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) + out.Write([]byte(startHTML( + fmt.Sprintf("%s (%d-%d)", blogTitle, offset, offset+PageEntries), + links, + ))) + out.Write(refs.Bytes()) + out.Write(table.Bytes()) + out.Write(refs.Bytes()) + out.Write([]byte("\n")) + } else if pathInfo == "/"+AtomFeed { + commit, err = repo.CommitObject(head.Hash()) + if err != nil { + makeErr(err) + } + etagHash.Write(commit.Hash[:]) + etagHash.Write([]byte("ATOM")) + checkETag(etagHash) + feed := atom.Feed{ + Title: blogTitle, + ID: atomId, + Updated: atom.Time(commit.Author.When), + Link: []atom.Link{{ + Rel: "self", + Href: atomUrl, + }}, + Author: &atom.Person{Name: atomAuthorName}, + } + log, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + makeErr(err) + } + for i := 0; i < PageEntries; i++ { + commit, err = log.Next() + if err != nil { + break + } + lines := msgSplit(commit.Message) + feedId, err := uuid.FromBytes(commit.Hash[:16]) + if err != nil { + panic(err) + } + feed.Entry = append(feed.Entry, &atom.Entry{ + Title: lines[0], + ID: "urn:uuid:" + feedId.String(), + Link: []atom.Link{{ + Rel: "alternate", + Href: blogBaseURL + urlPrefix + "/" + commit.Hash.String(), + }}, + Published: atom.Time(commit.Author.When), + Updated: atom.Time(commit.Author.When), + Summary: &atom.Text{ + Type: "text", + Body: lines[0], + }, + Content: &atom.Text{ + Type: "text", + Body: strings.Join(lines[2:], "\n"), + }, + }) + } + data, err := xml.MarshalIndent(&feed, "", " ") + if err != nil { + makeErr(err) + } + out.Write(data) + os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n") + os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n") + if gzipWriter != nil { + os.Stdout.WriteString("Content-Encoding: gzip\n") + gzipWriter.Close() + } + os.Stdout.WriteString("\n") + os.Stdout.Write(outBuf.Bytes()) + return + } else if sha1DigestRe.MatchString(pathInfo[1:]) { + commit, err = repo.CommitObject(plumbing.NewHash(pathInfo[1:])) + if err != nil { + makeErr(err) + } + etagHash.Write(commit.Hash[:]) + checkETag(etagHash) + lines := msgSplit(commit.Message) + title := lines[0] + when := commit.Author.When.Format(WhenFmt) + os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) + parent := commit.ParentHashes[0].String() + out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), []string{ + fmt.Sprintf(``, "/"+parent), + }))) + out.Write([]byte(fmt.Sprintf( + "[%s] [%s]\n
\n", + makeA(urlPrefix+"/"+parent, "older"), + when, + ))) + out.Write([]byte(fmt.Sprintf("

%s

\n
\n", title)))
+		for _, line := range lines[2:] {
+			line = strings.ReplaceAll(line, "&", "&")
+			line = strings.ReplaceAll(line, "<", "<")
+			line = strings.ReplaceAll(line, ">", ">")
+			cols := strings.Split(line, " ")
+			for i, col := range cols {
+				if u := urlParse(col); u != nil {
+					cols[i] = makeA(col, col)
+					continue
+				}
+				cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
+			}
+			line = strings.Join(cols, " ")
+			out.Write([]byte(line + "\n"))
+		}
+		out.Write([]byte("
\n")) + if note := getNote(commit.Hash); note != "" { + out.Write([]byte(fmt.Sprintf("Note:\n
\n%s
\n", note))) + } + } else { + makeErr(errors.New("unknown URL action")) + } + if aboutUrl != "" { + out.Write([]byte(fmt.Sprintf( + "
%s %s\n", + makeA(aboutUrl, "About"), + blogTitle, + ))) + } + out.Write([]byte("\n")) + if gzipWriter != nil { + gzipWriter.Close() + } + os.Stdout.Write(outBuf.Bytes()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e9ff030 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module go.stargrave.org/sgblog + +go 1.13 + +require ( + github.com/google/uuid v1.1.1 + golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 + golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a + gopkg.in/src-d/go-git.v4 v4.13.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71eda25 --- /dev/null +++ b/go.sum @@ -0,0 +1,78 @@ +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= +github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e h1:D5TXcfTk7xF7hvieo4QErS3qqCB4teTffacDWr7CI+0= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a h1:mEQZbbaBjWyLNy0tmZmgEuQAR8XOQ3hL8GYi3J/NG64= +golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= +gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= +gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=