Lindenii Project Forge
Output git logs incrementally
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileContributor: Runxi Yu <https://runxiyu.org> package main import ( "context" "errors" "io"
"iter"
"os" "strings" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/jackc/pgx/v5/pgtype" ) // openRepo opens a git repository by group and repo name. func openRepo(ctx context.Context, groupPath []string, repoName string) (repo *git.Repository, description string, repoID int, err error) { var fsPath string err = database.QueryRow(ctx, ` WITH RECURSIVE group_path_cte AS ( -- Start: match the first name in the path where parent_group IS NULL SELECT id, parent_group, name, 1 AS depth FROM groups WHERE name = ($1::text[])[1] AND parent_group IS NULL UNION ALL -- Recurse: join next segment of the path SELECT g.id, g.parent_group, g.name, group_path_cte.depth + 1 FROM groups g JOIN group_path_cte ON g.parent_group = group_path_cte.id WHERE g.name = ($1::text[])[group_path_cte.depth + 1] AND group_path_cte.depth + 1 <= cardinality($1::text[]) ) SELECT r.filesystem_path, COALESCE(r.description, ''), r.id FROM group_path_cte g JOIN repos r ON r.group_id = g.id WHERE g.depth = cardinality($1::text[]) AND r.name = $2 `, pgtype.FlatArray[string](groupPath), repoName).Scan(&fsPath, &description, &repoID) if err != nil { return } repo, err = git.PlainOpen(fsPath) return } // go-git's tree entries are not friendly for use in HTML templates. type displayTreeEntry struct { Name string Mode string Size int64 IsFile bool IsSubtree bool } func makeDisplayTree(tree *object.Tree) (displayTree []displayTreeEntry) { for _, entry := range tree.Entries { displayEntry := displayTreeEntry{} var err error var osMode os.FileMode if osMode, err = entry.Mode.ToOSFileMode(); err != nil { displayEntry.Mode = "x---------" } else { displayEntry.Mode = osMode.String() } displayEntry.IsFile = entry.Mode.IsFile() if displayEntry.Size, err = tree.Size(entry.Name); err != nil { displayEntry.Size = 0 } displayEntry.Name = strings.TrimPrefix(entry.Name, "/") displayTree = append(displayTree, displayEntry) } return displayTree
} func commitIterSeqErr(commitIter object.CommitIter) (iter.Seq[*object.Commit], *error) { var err error return func(yield func(*object.Commit) bool) { for { commit, err2 := commitIter.Next() if err2 != nil { if errors.Is(err2, io.EOF) { return } err = err2 return } if !yield(commit) { return } } }, &err } func iterSeqLimit[T any](s iter.Seq[T], n uint) iter.Seq[T] { return func(yield func(T) bool) { var i uint for v := range s { if i > n-1 { return } if !yield(v) { return } i++ } }
} func getRecentCommits(repo *git.Repository, headHash plumbing.Hash, numCommits int) (recentCommits []*object.Commit, err error) { var commitIter object.CommitIter var thisCommit *object.Commit commitIter, err = repo.Log(&git.LogOptions{From: headHash}) if err != nil { return nil, err } recentCommits = make([]*object.Commit, 0) defer commitIter.Close() if numCommits < 0 { for { thisCommit, err = commitIter.Next() if errors.Is(err, io.EOF) { return recentCommits, nil } else if err != nil { return nil, err } recentCommits = append(recentCommits, thisCommit) } } else { for range numCommits { thisCommit, err = commitIter.Next() if errors.Is(err, io.EOF) { return recentCommits, nil } else if err != nil { return nil, err } recentCommits = append(recentCommits, thisCommit) } } return recentCommits, err } func fmtCommitAsPatch(commit *object.Commit) (parentCommitHash plumbing.Hash, patch *object.Patch, err error) { var parentCommit *object.Commit var commitTree *object.Tree parentCommit, err = commit.Parent(0) if errors.Is(err, object.ErrParentNotFound) { if commitTree, err = commit.Tree(); err != nil { return } if patch, err = (&object.Tree{}).Patch(commitTree); err != nil { return } } else if err != nil { return } else { parentCommitHash = parentCommit.Hash if patch, err = parentCommit.Patch(commit); err != nil { return } } return }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileContributor: Runxi Yu <https://runxiyu.org> package main import (
"iter"
"net/http" "strings" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" ) func httpHandleRepoIndex(w http.ResponseWriter, r *http.Request, params map[string]any) { var repo *git.Repository var repoName string var groupPath []string var refHash plumbing.Hash var err error
var recentCommits []*object.Commit
var commitIter object.CommitIter var commitIterSeq iter.Seq[*object.Commit]
var commitObj *object.Commit var tree *object.Tree var notes []string var branches []string var branchesIter storer.ReferenceIter repo, repoName, groupPath = params["repo"].(*git.Repository), params["repo_name"].(string), params["group_path"].([]string) if strings.Contains(repoName, "\n") || sliceContainsNewlines(groupPath) { notes = append(notes, "Path contains newlines; HTTP Git access impossible") } refHash, err = getRefHash(repo, params["ref_type"].(string), params["ref_name"].(string)) if err != nil { goto no_ref } branchesIter, err = repo.Branches() if err == nil { _ = branchesIter.ForEach(func(branch *plumbing.Reference) error { branches = append(branches, branch.Name().Short()) return nil }) } params["branches"] = branches
if recentCommits, err = getRecentCommits(repo, refHash, 3); err != nil {
if commitIter, err = repo.Log(&git.LogOptions{From: refHash}); err != nil {
goto no_ref }
params["commits"] = recentCommits
commitIterSeq, params["commits_err"] = commitIterSeqErr(commitIter) params["commits"] = iterSeqLimit(commitIterSeq, 3)
if commitObj, err = repo.CommitObject(refHash); err != nil { goto no_ref } if tree, err = commitObj.Tree(); err != nil { goto no_ref } params["files"] = makeDisplayTree(tree) params["readme_filename"], params["readme"] = renderReadmeAtTree(tree) no_ref: params["http_clone_url"] = genHTTPRemoteURL(groupPath, repoName) params["ssh_clone_url"] = genSSHRemoteURL(groupPath, repoName) params["notes"] = notes renderTemplate(w, "repo_index", params) }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileContributor: Runxi Yu <https://runxiyu.org> package main import ( "net/url" "path" "strings" ) func firstLine(s string) string { before, _, _ := strings.Cut(s, "\n") return before } func baseName(s string) string { return path.Base(s) } func pathEscape(s string) string { return url.PathEscape(s) } func queryEscape(s string) string { return url.QueryEscape(s) }
func dereference[T any](p *T) T { return *p }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileContributor: Runxi Yu <https://runxiyu.org> package main import ( "embed" "html/template" "io/fs" "net/http" "github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2/html" ) // We embed all source for easy AGPL compliance. // //go:embed .gitignore .gitattributes //go:embed LICENSE README.md //go:embed *.go go.mod go.sum //go:embed *.scfg //go:embed Makefile //go:embed static/* templates/* scripts/* sql/* //go:embed hookc/*.c //go:embed vendor/* var sourceFS embed.FS var sourceHandler = http.StripPrefix( "/:/source/", http.FileServer(http.FS(sourceFS)), ) //go:embed templates/* static/* hookc/hookc var resourcesFS embed.FS var templates *template.Template func loadTemplates() (err error) { m := minify.New() m.Add("text/html", &html.Minifier{TemplateDelims: [2]string{"{{", "}}"}, KeepDefaultAttrVals: true}) templates = template.New("templates").Funcs(template.FuncMap{
"first_line": firstLine, "base_name": baseName, "path_escape": pathEscape, "query_escape": queryEscape,
"first_line": firstLine, "base_name": baseName, "path_escape": pathEscape, "query_escape": queryEscape, "dereference_error": dereference[error],
}) err = fs.WalkDir(resourcesFS, "templates", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { content, err := fs.ReadFile(resourcesFS, path) if err != nil { return err } minified, err := m.Bytes("text/html", content) if err != nil { return err } _, err = templates.Parse(string(minified)) if err != nil { return err } } return nil }) return err } var staticHandler http.Handler func init() { staticFS, err := fs.Sub(resourcesFS, "static") if err != nil { panic(err) } staticHandler = http.StripPrefix("/:/static/", http.FileServer(http.FS(staticFS))) }
{{/* SPDX-License-Identifier: AGPL-3.0-only SPDX-FileContributor: Runxi Yu <https://runxiyu.org> */}} {{- define "repo_index" -}} <!DOCTYPE html> <html lang="en"> <head> {{- template "head_common" . -}} <title>{{ .repo_name }} – {{ template "group_path_plain" .group_path }} – {{ .global.forge_title -}}</title> </head> <body class="repo-index"> {{- template "header" . -}} <div class="padding-wrapper"> <table id="repo-info-table" class="rounded"> <thead> <tr class="title-row"> <th colspan="2">Repo info</th> </tr> </thead> <tbody> <tr> <th scope="row">Name</th> <td>{{- .repo_name -}}</td> </tr> {{- if .repo_description -}} <tr> <th scope="row">Description</th> <td>{{- .repo_description -}}</td> </tr> {{- end -}} <tr> <th scope="row">SSH remote</th> <td><code>{{- .ssh_clone_url -}}</code></td> </tr> {{- if .notes -}} <tr> <th scope="row">Notes</th> <td><ul>{{- range .notes -}}<li>{{- . -}}</li>{{- end -}}</ul></td> </tr> {{- end -}} </tbody> </table> </div> <div class="padding-wrapper"> <table id="branches" class="rounded"> <thead> <tr class="title-row"> <th colspan="1">Branches</th> </tr> </thead> <tbody> {{ range .branches }} <tr> <td> <a href="./?branch={{ . }}">{{ . }}</a> </td> </tr> {{ end }} </tbody> </table> </div> <div class="padding-wrapper"> <p> <a href="contrib/" class="btn-normal">Merge requests</a> </p> </div> {{- if .commits -}} <div class="padding-wrapper scroll"> <table id="recent-commits" class="wide rounded"> <thead> <tr class="title-row"> <th colspan="3">Recent commits (<a href="log/{{- if .ref_type -}}?{{- .ref_type -}}={{- .ref_name -}}{{- end -}}">see all</a>)</th> </tr> <tr> <th scope="col">Title</th> <th scope="col">Author</th> <th scope="col">Author Date</th> </tr> </thead> <tbody> {{- range .commits -}} <tr> <td class="commit-title"><a href="commit/{{- .ID -}}">{{- .Message | first_line -}}</a></td> <td class="commit-author"> <a class="email-name" href="mailto:{{- .Author.Email -}}">{{- .Author.Name -}}</a> </td> <td class="commit-time"> {{- .Author.When.Format "2006-01-02 15:04:05 -0700" -}} </td> </tr> {{- end -}}
{{- if dereference_error .commits_err -}} Error while obtaining commit log: {{ .commits_err }} {{- end -}}
</tbody> </table> </div> {{- end -}} {{- if .files -}} <div class="padding-wrapper scroll"> <table id="file-tree" class="wide rounded"> <thead> <tr class="title-row"> <th colspan="3">/{{- if .ref_name -}} on {{- .ref_name -}}{{- end -}}</th> </tr> <tr> <th scope="col">Mode</th> <th scope="col">Filename</th> <th scope="col">Size</th> </tr> </thead> <tbody> {{- $ref_type := .ref_type -}} {{- $ref := .ref_name -}} {{- range .files -}} <tr> <td class="file-mode">{{- .Mode -}}</td> <td class="file-name"><a href="tree/{{- .Name -}}{{- if not .Is_file -}}/{{- end -}}{{- if $ref_type -}}?{{- $ref_type -}}={{- $ref -}}{{- end -}}">{{- .Name -}}</a>{{- if not .Is_file -}}/{{- end -}}</td> <td class="file-size">{{- .Size -}}</td> </tr> {{- end -}} </tbody> </table> </div> {{- end -}} {{- if .readme -}} <div class="padding-wrapper" id="readme"> {{- .readme -}} </div> {{- end -}} <footer> {{- template "footer" . -}} </footer> </body> </html> {{- end -}}
{{/* SPDX-License-Identifier: AGPL-3.0-only SPDX-FileContributor: Runxi Yu <https://runxiyu.org> */}} {{- define "repo_log" -}} <!DOCTYPE html> <html lang="en"> <head> {{- template "head_common" . -}} <title>Log – {{ .repo_name }} – {{ template "group_path_plain" .group_path }} – {{ .global.forge_title -}}</title> </head> <body class="repo-log"> {{- template "header" . -}} <div class="scroll"> <table id="commits" class="wide rounded"> <thead> <tr class="title-row"> <th colspan="4">Commits {{ if .ref_name }} on {{ .ref_name }}{{ end -}}</th> </tr> <tr> <th scope="col">ID</th> <th scope="col">Title</th> <th scope="col">Author</th> <th scope="col">Time</th> </tr> </thead> <tbody> {{- range .commits -}} <tr> <td class="commit-id"><a href="../commit/{{- .ID -}}">{{- .ID -}}</a></td> <td class="commit-title">{{- .Message | first_line -}}</td> <td class="commit-author"> <a class="email-name" href="mailto:{{- .Author.Email -}}">{{- .Author.Name -}}</a> </td> <td class="commit-time"> {{- .Author.When.Format "2006-01-02 15:04:05 -0700" -}} </td> </tr> {{- end -}}
{{- if dereference_error .commits_err -}} Error while obtaining commit log: {{ .commits_err }} {{- end -}}
</tbody> </table> </div> <footer> {{- template "footer" . -}} </footer> </body> </html> {{- end -}}