gitshow

Log | Files | Refs

commit 748962981362d1c4de755d24d72f09a0b06319d9
Author: Thomas Vigouroux <thomas.vigouroux@univ-grenoble-alpes.fr>
Date:   Mon,  8 Apr 2024 08:50:24 +0200

Initial commit

Diffstat:
A.gitignore | 3+++
Afile.go | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agitshow.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 15+++++++++++++++
Ago.sum | 27+++++++++++++++++++++++++++
Alog.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arepo.go | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/style.css | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/file.html | 14++++++++++++++
Atemplates/log.html | 21+++++++++++++++++++++
Atemplates/repo.html | 17+++++++++++++++++
Atemplates/tree.html | 22++++++++++++++++++++++
Atemplates/wrap.html | 17+++++++++++++++++
Atree.go | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}