commit 748962981362d1c4de755d24d72f09a0b06319d9
Author: Thomas Vigouroux <thomas.vigouroux@univ-grenoble-alpes.fr>
Date: Mon, 8 Apr 2024 08:50:24 +0200
Initial commit
Diffstat:
14 files changed, 651 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,3 @@
+repositories
+gitshow
+project.list
diff --git a/file.go b/file.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "github.com/gorilla/mux"
+ "fmt"
+ "github.com/libgit2/git2go/v31"
+ "html/template"
+ "log"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ // Chroma
+ chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/alecthomas/chroma/v2/styles"
+)
+
+type FilePageData struct {
+ Name string
+ Content template.HTML
+ Branch *git.Branch
+}
+
+func getFile(w http.ResponseWriter, r *http.Request) {
+ logger := log.Default()
+ vars := mux.Vars(r)
+ reponame := vars["repository"]
+
+ repo, err := checkRepo(reponame)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ var branch *git.Branch
+ branch, err = repo.LookupBranch(vars["ref"], git.BranchAll)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ logger.Printf("Called %s: ref to %s", vars["ref"], branch.Shorthand())
+
+ // Now get the corresponding commit
+ ref, err := branch.Resolve()
+ if err != nil {
+ logger.Fatalf("Malformed repository")
+ }
+
+ commit, err := repo.LookupCommit(ref.Target())
+ if err != nil {
+ logger.Fatalf("Could not get commit")
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ logger.Fatalf("Could not get tree")
+ }
+
+ // Get the path of the entry
+ path := r.RequestURI[len("/repos/") + len(reponame) + len("/file/") + len(vars["ref"]) + 1:len(r.RequestURI)]
+ logger.Printf("Trying to get file: %s (%s)", path, r.RequestURI)
+ entry, err := tree.EntryByPath(path)
+ if err != nil || entry.Type != git.ObjectBlob {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ blob, err := repo.LookupBlob(entry.Id)
+ if err != nil {
+ logger.Fatalf("Could not find file")
+ }
+
+ lexer := lexers.Match(path)
+ if lexer == nil {
+ lexer = lexers.Fallback
+ }
+
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ var formatter = chromahtml.New(
+ chromahtml.WithLineNumbers(true),
+ chromahtml.WithClasses(true),
+ chromahtml.WithLinkableLineNumbers(true, ""),
+ )
+
+ if formatter == nil {
+ logger.Fatal("Could not get html formatter")
+ }
+
+ iterator, err := lexer.Tokenise(nil, fmt.Sprintf("%s", blob.Contents()))
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ sbuilder := new(strings.Builder)
+ formatter.Format(sbuilder, styles.Fallback, iterator)
+
+ w.WriteHeader(http.StatusOK)
+
+ tmpl := template.Must(template.ParseFiles(
+ filepath.Join("templates", "file.html"),
+ filepath.Join("templates", "wrap.html"),
+ ))
+
+ _, ishtmx := r.Header["Hx-Request"]
+
+ data := FilePageData {
+ Name: reponame,
+ Content: template.HTML(sbuilder.String()),
+ Branch: branch,
+ }
+
+ if ishtmx {
+ tmpl.ExecuteTemplate(w, "file_content", data)
+ } else {
+ tmpl.ExecuteTemplate(w, "file_full", data)
+ }
+}
diff --git a/gitshow.go b/gitshow.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "bufio"
+ "github.com/gorilla/mux"
+ "log"
+ "net/http"
+ "os"
+ "time"
+ "github.com/libgit2/git2go/v31"
+ "errors"
+ "path/filepath"
+)
+
+func checkRepo(reponame string) (repo *git.Repository, err error) {
+ content, err := os.Open("project.list")
+
+ if err != nil {
+ return nil, errors.New("No project list")
+ }
+ defer content.Close()
+
+ scanner := bufio.NewScanner(content)
+ for scanner.Scan() {
+ if scanner.Text() == reponame {
+ return git.OpenRepositoryExtended(filepath.Join("repositories", reponame), git.RepositoryOpenBare | git.RepositoryOpenNoSearch, "")
+ }
+ }
+
+ return nil, errors.New("Not authorized")
+}
+
+var reporouter *mux.Router
+
+// Got from mux repository
+func loggingMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Println(r.Method, r.RequestURI)
+ next.ServeHTTP(w, r)
+ })
+}
+
+func main() {
+ log.SetPrefix("gitshow: ")
+ log.SetFlags(log.Ldate | log.Ltime)
+
+ logger := log.Default()
+ logger.Print("Starting...")
+
+ r := mux.NewRouter()
+ r.StrictSlash(true)
+ r.Use(loggingMiddleware)
+
+ r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
+
+ reporouter = r.PathPrefix("/repos/{repository:[a-zA-Z-]+}").Subrouter()
+
+ reporouter.HandleFunc("/", getRepo).Methods("GET")
+
+ reporouter.HandleFunc("/log", getLog).Methods("GET")
+
+ reporouter.HandleFunc("/tree", getTree).Methods("GET")
+ reporouter.PathPrefix("/tree/{ref:[a-zA-Z0-9]+}/").HandlerFunc(getTree).Methods("GET").Name("tree")
+
+ reporouter.PathPrefix("/file/{ref:[a-z-A-Z0-9]+}/").HandlerFunc(getFile).Methods("GET").Name("file")
+
+ srv := &http.Server{
+ Handler: r,
+ Addr: "127.0.0.1:8080",
+ // Good practice: enforce timeouts for servers you create!
+ WriteTimeout: 15 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ }
+
+ logger.Print("Started.")
+ logger.Fatal(srv.ListenAndServe())
+}
diff --git a/go.mod b/go.mod
@@ -0,0 +1,15 @@
+module gitshow
+
+go 1.21.7
+
+require (
+ github.com/alecthomas/chroma/v2 v2.13.0
+ github.com/gorilla/mux v1.8.1
+ github.com/libgit2/git2go/v31 v31.7.9
+)
+
+require (
+ github.com/dlclark/regexp2 v1.11.0 // indirect
+ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c // indirect
+ golang.org/x/sys v0.0.0-20201204225414-ed752295db88 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,27 @@
+github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
+github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
+github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/libgit2/git2go/v31 v31.7.9 h1:RUDiYm7+i3GY414acI31oDD8x5P0PZyWeZZfwpPuynE=
+github.com/libgit2/git2go/v31 v31.7.9/go.mod h1:c/rkJcBcUFx6wHaT++UwNpKvIsmPNqCeQ/vzO4DrEec=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k=
+golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88 h1:KmZPnMocC93w341XZp26yTJg8Za7lhb2KhkYmixoeso=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/log.go b/log.go
@@ -0,0 +1,67 @@
+package main
+import (
+ "github.com/gorilla/mux"
+ // "fmt"
+ "log"
+ "net/http"
+ "github.com/libgit2/git2go/v31"
+ "path/filepath"
+ "html/template"
+)
+
+type LogPageData struct {
+ Name string
+ State string
+ Commits []*git.Commit
+}
+
+func getLog(w http.ResponseWriter, r *http.Request) {
+ logger := log.Default()
+
+ vars := mux.Vars(r)
+
+ repo, err := checkRepo(vars["repository"])
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ head, err := repo.Head()
+ if err != nil {
+ // TODO: handle no head
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ commit, err := repo.LookupCommit(head.Target())
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ var commits []*git.Commit = make([]*git.Commit, 0)
+
+ for commit.ParentCount() > 0 {
+ commits = append(commits, commit)
+ commit = commit.Parent(0)
+ }
+ commits = append(commits, commit)
+
+ tmpl := template.Must(template.ParseFiles(
+ filepath.Join("templates", "log.html"),
+ filepath.Join("templates", "wrap.html"),
+ ))
+
+ _, ishtmx := r.Header["Hx-Request"]
+
+ data := LogPageData {
+ Name: vars["repository"],
+ Commits: commits,
+ State: "log",
+ }
+
+ if ishtmx {
+ tmpl.ExecuteTemplate(w, "log_content", data)
+ } else {
+ tmpl.ExecuteTemplate(w, "log_full", data)
+ }
+}
diff --git a/repo.go b/repo.go
@@ -0,0 +1,87 @@
+package main
+import (
+ "github.com/gorilla/mux"
+ "fmt"
+ "log"
+ "net/http"
+ "github.com/libgit2/git2go/v31"
+ "path/filepath"
+ "html/template"
+)
+
+type RepoPageData struct {
+ Name string
+ Repo *git.Repository
+ Readme string
+ State string
+}
+
+func getRepo(w http.ResponseWriter, r *http.Request) {
+ logger := log.Default()
+ vars := mux.Vars(r)
+ reponame := vars["repository"]
+
+ repo, err := checkRepo(reponame)
+
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ head, err := repo.Head()
+ if err != nil {
+ // TODO: handle no head
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ oid := head.Target()
+
+ commit, err := repo.LookupCommit(oid)
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ entry, err := tree.EntryByPath("README")
+
+ var readme string
+ if err != nil {
+ readme = "No readme"
+ } else {
+ blob, err := repo.LookupBlob(entry.Id)
+ if err != nil {
+ logger.Fatal(err)
+ } else if blob.IsBinary() {
+ readme = "Binary readme"
+ } else {
+ readme = fmt.Sprintf("%s", blob.Contents())
+ }
+ }
+
+ w.WriteHeader(http.StatusOK)
+
+ tmpl := template.Must(template.ParseFiles(
+ filepath.Join("templates", "repo.html"),
+ filepath.Join("templates", "wrap.html"),
+ ))
+
+ _, ishtmx := r.Header["Hx-Request"]
+
+ data := RepoPageData {
+ Name: reponame,
+ Repo: repo,
+ Readme: readme,
+ State: "about",
+ }
+
+ if ishtmx {
+ tmpl.ExecuteTemplate(w, "repo_content", data)
+ } else {
+ tmpl.ExecuteTemplate(w, "repo_full", data)
+ }
+}
diff --git a/static/style.css b/static/style.css
@@ -0,0 +1,50 @@
+:root {
+ font-family: monospace;
+ --chroma-kw: #ff0000;
+}
+
+button.selected {
+ background: #00FF00;
+}
+
+.tree-file {
+ cursor: pointer;
+}
+.tree-file:hover {
+ color: blue;
+}
+
+/* Chroma related styles for file highlighting */
+.chroma .lnlinks {
+ padding-right: 10px;
+ text-decoration: none;
+ color: gray;
+}
+
+.chroma .k {
+ color: var(--chroma-kw);
+}
+
+.chroma .kc {
+ color: var(--chroma-kw);
+}
+
+.chroma .kd {
+ color: var(--chroma-kw);
+}
+
+.chroma .kn {
+ color: var(--chroma-kw);
+}
+
+.chroma .kp {
+ color: var(--chroma-kw);
+}
+
+.chroma .kr {
+ color: var(--chroma-kw);
+}
+
+:target {
+ background-color: #ffa;
+}
diff --git a/templates/file.html b/templates/file.html
@@ -0,0 +1,14 @@
+{{define "file_content"}}
+<div class="tab-list" role="tablist" hx-push-url="true">
+ <button hx-get="/repos/{{.Name}}">about</button>
+ <button hx-get="/repos/{{.Name}}/log">log</button>
+ <button hx-get="/repos/{{.Name}}/tree/{{.Branch.Shorthand}}">tree</button>
+</div>
+{{.Content}}
+{{end}}
+
+{{define "file_full"}}
+{{template "header" .}}
+{{template "file_content" .}}
+{{template "footer" .}}
+{{end}}
diff --git a/templates/log.html b/templates/log.html
@@ -0,0 +1,21 @@
+{{define "log_content"}}
+<div class="tab-list" role="tablist" hx-push-url="true">
+ <button hx-get="/repos/{{.Name}}">about</button>
+ <button hx-get="/repos/{{.Name}}/log" class="selected">log</button>
+ <button hx-get="/repos/{{.Name}}/tree">tree</button>
+</div>
+<table>
+{{range .Commits}}
+ <tr>
+ <td>{{.ShortId}}</td>
+ <td>{{.Message}}</td>
+ </tr>
+{{end}}
+</table>
+{{end}}
+
+{{define "log_full"}}
+{{template "header" .}}
+{{template "log_content" .}}
+{{template "footer" .}}
+{{end}}
diff --git a/templates/repo.html b/templates/repo.html
@@ -0,0 +1,17 @@
+{{define "repo_content"}}
+<div class="tab-list" role="tablist" hx-push-url="true">
+ <button hx-get="/repos/{{.Name}}" class="selected">about</button>
+ <button hx-get="/repos/{{.Name}}/log">log</button>
+ <button hx-get="/repos/{{.Name}}/tree/{{.Repo.Head.Shorthand}}">tree</button>
+</div>
+<par>HEAD is {{.Repo.Head.Shorthand}}</par>
+<pre>
+ {{.Readme}}
+</pre>
+{{end}}
+
+{{define "repo_full"}}
+{{template "header" .}}
+{{template "repo_content" .}}
+{{template "footer" .}}
+{{end}}
diff --git a/templates/tree.html b/templates/tree.html
@@ -0,0 +1,22 @@
+{{define "tree_content"}}
+<div class="tab-list" role="tablist" hx-push-url="true">
+ <button hx-get="/repos/{{.Name}}">about</button>
+ <button hx-get="/repos/{{.Name}}/log">log</button>
+ <button hx-get="/repos/{{.Name}}/tree/{{.Branch.Shorthand}}" class="selected">tree</button>
+</div>
+{{$repoName:=.Name}}
+{{$branchName:=.Branch.Shorthand}}
+ <table>
+ {{range .Entries}}
+ <tr>
+ <td><div{{if isFile .}} hx-push-url="true" class="tree-file" hx-get="/repos/{{$repoName}}/file/{{$branchName}}/{{.Name}}"{{end}}>{{.Name}}</div></td>
+ </tr>
+ {{end}}
+ </table>
+{{end}}
+
+{{define "tree_full"}}
+{{template "header" .}}
+{{template "tree_content" .}}
+{{template "footer" .}}
+{{end}}
diff --git a/templates/wrap.html b/templates/wrap.html
@@ -0,0 +1,17 @@
+{{define "header"}}
+<html>
+ <head>
+ <link rel="stylesheet" href="/static/style.css">
+ <script src="https://unpkg.com/htmx.org@1.9.11" integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0" crossorigin="anonymous"></script>
+ </head>
+
+ <body>
+ <h1>{{.Name}}</h1>
+
+ <div id="tabs" hx-target="#tabs" hx-swap="innerHTML">
+{{end}}
+{{define "footer"}}
+ </div>
+ </body>
+</html>
+{{end}}
diff --git a/tree.go b/tree.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+ "github.com/gorilla/mux"
+ // "fmt"
+ "github.com/libgit2/git2go/v31"
+ "html/template"
+ "log"
+ "net/http"
+ "path/filepath"
+)
+
+type TreePageData struct {
+ Name string
+ Entries []*git.TreeEntry
+ Branch *git.Branch
+}
+
+func getTree(w http.ResponseWriter, r *http.Request) {
+ logger := log.Default()
+ vars := mux.Vars(r)
+ reponame := vars["repository"]
+
+ repo, err := checkRepo(reponame)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ var branch *git.Branch
+ refname, present := vars["ref"]
+
+ if !present {
+ head, err := repo.Head()
+ if err != nil {
+ // TODO: handle no head
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ branch = head.Branch()
+ } else {
+ branch, err = repo.LookupBranch(refname, git.BranchAll)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ }
+ logger.Printf("Called %s: ref to %s", vars["ref"], branch.Shorthand())
+
+ // Now get the corresponding commit
+ ref, err := branch.Resolve()
+ if err != nil {
+ logger.Fatalf("Malformed repository")
+ }
+
+ commit, err := repo.LookupCommit(ref.Target())
+ if err != nil {
+ logger.Fatalf("Could not get commit")
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ logger.Fatalf("Could not get tree")
+ }
+
+ url, err := reporouter.Get("tree").URL("repository", reponame, "ref", branch.Shorthand())
+ if err != nil {
+ logger.Fatalf("Could not generate URL")
+ }
+
+ url.Host = r.URL.Host
+ url.Scheme = r.URL.Scheme
+
+ w.Header().Add("HX-Push-Url", url.String())
+ w.WriteHeader(http.StatusOK)
+
+ tmpl := template.Must(template.New("tree_root").Funcs(template.FuncMap{
+ "isFile": func(obj *git.TreeEntry) bool {
+ blob, err := repo.LookupBlob(obj.Id)
+ if err != nil {
+ return false
+ }
+
+
+ return obj.Type == git.ObjectBlob && !blob.IsBinary()
+ },
+ }).ParseFiles(
+ filepath.Join("templates", "tree.html"),
+ filepath.Join("templates", "wrap.html"),
+ ))
+
+ _, ishtmx := r.Header["Hx-Request"]
+
+ var entries []*git.TreeEntry = make([]*git.TreeEntry, 0)
+ for i := uint64(0); i < tree.EntryCount(); i++ {
+ entries = append(entries, tree.EntryByIndex(i))
+ }
+
+ data := TreePageData{
+ Name: reponame,
+ Entries: entries,
+ Branch: branch,
+ }
+
+ if ishtmx {
+ tmpl.ExecuteTemplate(w, "tree_content", data)
+ } else {
+ tmpl.ExecuteTemplate(w, "tree_full", data)
+ }
+}