]> Sergey Matveev's repositories - sgblog.git/commitdiff
Initial working version v0.0.1
authorSergey Matveev <stargrave@stargrave.org>
Sat, 11 Jan 2020 20:20:31 +0000 (23:20 +0300)
committerSergey Matveev <stargrave@stargrave.org>
Sun, 12 Jan 2020 15:13:19 +0000 (18:13 +0300)
README [new file with mode: 0644]
cmd/sgblog/main.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]

diff --git a/README b/README
new file mode 100644 (file)
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 <link rel> 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 (file)
index 0000000..39641ab
--- /dev/null
@@ -0,0 +1,492 @@
+/*
+SGBlog -- Git-based CGI blogging engine
+Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
+
+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 <http://www.gnu.org/licenses/>.
+*/
+
+// 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(`<a href="%s">%s</a>`, 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(`<html>
+<head>
+       <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+       <meta name="generator" content="SGBlog %s">
+       <title>%s</title>
+       %s
+</head>
+<body>
+`,
+               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(
+                       `<link rel="stylesheet" type="text/css" href="%s">`,
+                       cssUrl,
+               ))
+               etagHash.Write([]byte(cssUrl))
+       }
+
+       // SGBLOG_WEBMASTER
+       if webmaster, exists := os.LookupEnv("SGBLOG_WEBMASTER"); exists {
+               defaultLinks = append(defaultLinks, fmt.Sprintf(
+                       `<link rev="made" href="mailto:%s">`,
+                       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(
+                               `<link rel="vcs-git" href="%s" title="Git repository">`,
+                               gitUrl,
+                       ))
+               }
+               etagHash.Write([]byte(gitUrls))
+       }
+
+       defaultLinks = append(defaultLinks, fmt.Sprintf(
+               `<link rel="top" href="%s/" title="top">`,
+               urlPrefix,
+       ))
+       atomUrl := blogBaseURL + urlPrefix + "/" + AtomFeed
+       defaultLinks = append(defaultLinks, fmt.Sprintf(
+               `<link rel="alternate" title="Atom feed" href="%s" type="application/atom+xml">`,
+               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("<table border=1>\n<tr><th>When</th><th>Title</th><th>Comment of</th></tr>\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(
+                               "<tr><td><tt>%s</tt></td><td>%s</td><td>%s</td></tr>\n",
+                               commit.Author.When.Format(WhenFmt),
+                               strings.Join(entry, " "),
+                               strings.Join(domains, " "),
+                       ))
+               }
+               table.WriteString("</table>")
+               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(
+                               `<link rel="prev" href="%s" title="newer">`, href,
+                       ))
+                       refs.WriteString(makeA(href, "[prev]"))
+               }
+               if !errOccured {
+                       href := urlPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
+                       links = append(links, fmt.Sprintf(
+                               `<link rel="next" href="%s" title="older">`, 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(`<link rel="prev" href="%s" title="older">`, "/"+parent),
+               })))
+               out.Write([]byte(fmt.Sprintf(
+                       "[%s]&nbsp;[<tt>%s</tt>]\n<hr/>\n",
+                       makeA(urlPrefix+"/"+parent, "older"),
+                       when,
+               )))
+               out.Write([]byte(fmt.Sprintf("<h2>%s</h2>\n<pre>\n", title)))
+               for _, line := range lines[2:] {
+                       line = strings.ReplaceAll(line, "&", "&amp;")
+                       line = strings.ReplaceAll(line, "<", "&lt;")
+                       line = strings.ReplaceAll(line, ">", "&gt;")
+                       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("</pre>\n"))
+               if note := getNote(commit.Hash); note != "" {
+                       out.Write([]byte(fmt.Sprintf("Note:\n<pre>\n%s</pre>\n", note)))
+               }
+       } else {
+               makeErr(errors.New("unknown URL action"))
+       }
+       if aboutUrl != "" {
+               out.Write([]byte(fmt.Sprintf(
+                       "<hr/>%s %s\n",
+                       makeA(aboutUrl, "About"),
+                       blogTitle,
+               )))
+       }
+       out.Write([]byte("</body></html>\n"))
+       if gzipWriter != nil {
+               gzipWriter.Close()
+       }
+       os.Stdout.Write(outBuf.Bytes())
+}
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
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 (file)
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=