Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
958d64cc922b2688c910e339748fa28a0ff540b7
Author
Runxi Yu <me@runxiyu.org>
Author date
Tue, 25 Mar 2025 02:24:52 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Tue, 25 Mar 2025 02:24:52 +0800
Actions
Cache commit logs on the repo index page
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileContributor: Runxi Yu <https://runxiyu.org>

package main

import (
	"html/template"

	"github.com/dgraph-io/ristretto/v2"
	"go.lindenii.runxiyu.org/lindenii-common/clog"
)

type treeReadmeCacheEntry struct {
	DisplayTree    []displayTreeEntry
	ReadmeFilename string
	ReadmeRendered template.HTML
}

var treeReadmeCache *ristretto.Cache[[]byte, treeReadmeCacheEntry]

func init() {
	var err error
	treeReadmeCache, err = ristretto.NewCache(&ristretto.Config[[]byte, treeReadmeCacheEntry]{
		NumCounters: 1e4,
		MaxCost:     1 << 30,
		BufferItems: 64,
	})
	if err != nil {
		clog.Fatal(1, "Error initializing indexPageCache: "+err.Error())
	}
}

var indexCommitsDisplayCache *ristretto.Cache[[]byte, []commitDisplay]

func init() {
	var err error
	indexCommitsDisplayCache, err = ristretto.NewCache(&ristretto.Config[[]byte, []commitDisplay]{
		NumCounters: 1e4,
		MaxCost:     1 << 30,
		BufferItems: 64,
	})
	if err != nil {
		clog.Fatal(1, "Error initializing indexCommitsCache: "+err.Error())
	}
}
// 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{} //exhaustruct:ignore
		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 iterations uint
		for v := range s {
			if iterations > n-1 {
				return
			}
			if !yield(v) {
				return
			}
			iterations++
		}
	}
}

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}) //exhaustruct:ignore
	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 getRecentCommitsDisplay(repo *git.Repository, headHash plumbing.Hash, numCommits int) (recentCommits []commitDisplay, err error) {
	var commitIter object.CommitIter
	var thisCommit *object.Commit

	commitIter, err = repo.Log(&git.LogOptions{From: headHash}) //exhaustruct:ignore
	if err != nil {
		return nil, err
	}
	recentCommits = make([]commitDisplay, 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, commitDisplay{
				thisCommit.Hash,
				thisCommit.Author,
				thisCommit.Committer,
				thisCommit.Message,
				thisCommit.TreeHash,
			})
		}
	} 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, commitDisplay{
				thisCommit.Hash,
				thisCommit.Author,
				thisCommit.Committer,
				thisCommit.Message,
				thisCommit.TreeHash,
			})
		}
	}
	return recentCommits, err
}

type commitDisplay struct {
	Hash      plumbing.Hash
	Author    object.Signature
	Committer object.Signature
	Message   string
	TreeHash  plumbing.Hash
}

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)
	switch {
	case errors.Is(err, object.ErrParentNotFound):
		if commitTree, err = commit.Tree(); err != nil {
			return
		}
		if patch, err = nullTree.Patch(commitTree); err != nil {
			return
		}
	case err != nil:
		return
	default:
		parentCommitHash = parentCommit.Hash
		if patch, err = parentCommit.Patch(commit); err != nil {
			return
		}
	}
	return
}

var nullTree object.Tree
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileContributor: Runxi Yu <https://runxiyu.org>

package main

import (
	"iter"
	"net/http"
	"strings"
	"time"

	"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(writer http.ResponseWriter, _ *http.Request, params map[string]any) {
	var repo *git.Repository
	var repoName string
	var groupPath []string
	var refHash plumbing.Hash
	var refHashSlice []byte
	var err error
	var logOptions git.LogOptions
	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
	var commits []commitDisplay

	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
	}
	refHashSlice = refHash[:]

	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

	// TODO: Cache
	logOptions = git.LogOptions{From: refHash} //exhaustruct:ignore
	if commitIter, err = repo.Log(&logOptions); err != nil {
		goto no_ref
	if value, found := indexCommitsDisplayCache.Get(refHashSlice); found {
		if value != nil {
			commits = value
		} else {
			goto readme
		}
	} else {
		start := time.Now()
		commits, err = getRecentCommitsDisplay(repo, refHash, 5)
		if err != nil {
			commits = nil
		}
		cost := time.Since(start).Nanoseconds()
		indexCommitsDisplayCache.Set(refHashSlice, commits, cost)
		if err != nil {
			goto readme
		}
	}
	commitIterSeq, params["commits_err"] = commitIterSeqErr(commitIter)
	params["commits"] = iterSeqLimit(commitIterSeq, 3)

	params["commits"] = commits

readme:

	if value, found := treeReadmeCache.Get(refHashSlice); found {
		params["files"] = value.DisplayTree
		params["readme_filename"] = value.ReadmeFilename
		params["readme"] = value.ReadmeRendered
	} else {
		start := time.Now()
		if commitObj, err = repo.CommitObject(refHash); err != nil {
			goto no_ref
		}

		if tree, err = commitObj.Tree(); err != nil {
			goto no_ref
		}
		displayTree := makeDisplayTree(tree)
		readmeFilename, readmeRendered := renderReadmeAtTree(tree)
		cost := time.Since(start).Nanoseconds()

		params["files"] = displayTree
		params["readme_filename"] = readmeFilename
		params["readme"] = readmeRendered

		entry := treeReadmeCacheEntry{
			DisplayTree:    displayTree,
			ReadmeFilename: readmeFilename,
			ReadmeRendered: readmeRendered,
		}
		treeReadmeCache.Set(refHashSlice, entry, cost)
	}

no_ref:

	params["http_clone_url"] = genHTTPRemoteURL(groupPath, repoName)
	params["ssh_clone_url"] = genSSHRemoteURL(groupPath, repoName)
	params["notes"] = notes

	renderTemplate(writer, "repo_index", params)
}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
*/}}
{{- define "repo_contrib_index" -}}
<!DOCTYPE html>
<html lang="en">
	<head>
		{{- template "head_common" . -}}
		<title>Merge requests &ndash; {{ .repo_name }} &ndash; {{ template "group_path_plain" .group_path }} &ndash; {{ .global.forge_title -}}</title>
	</head>
	<body class="repo-contrib-index">
		{{- template "header" . -}}
		<div class="padding-wrapper">
			<table id="recent-merge_requests" class="wide rounded">
				<thead>
					<tr class="title-row">
						<th colspan="3">Merge requests</th>
					</tr>
				</thead>
				<tr>
					<th scope="col">Name</th>
					<th scope="col">Description</th>
					<th scope="col">Status</th>
				</tr>
				<tbody>
					{{- range .merge_requests -}}
						<tr>
							<td class="merge_request-id">{{- .ID -}}</td>
							<td class="merge_request-title"><a href="{{- .ID -}}/">{{- .Title -}}</a></td>
							<td class="merge_request-id">{{- .Hash -}}</td>
							<td class="merge_request-title"><a href="{{- .Hash -}}/">{{- .Title -}}</a></td>
							<td class="merge_request-status">{{- .Status -}}</td>
						</tr>
					{{- end -}}
				</tbody>
			</table>
		</div>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
*/}}
{{- define "repo_contrib_one" -}}
<!DOCTYPE html>
<html lang="en">
	<head>
		{{- template "head_common" . -}}
		<title>Merge requests &ndash; {{ .repo_name }} &ndash; {{ template "group_path_plain" .group_path }} &ndash; {{ .global.forge_title -}}</title>
	</head>
	<body class="repo-contrib-one">
		{{- template "header" . -}}
		<div class="padding-wrapper">
			<table id="mr-info-table" class="rounded">
				<thead>
					<tr class="title-row">
						<th colspan="2">Merge request info</th>
					</tr>
				</thead>
				<tbody>
					<tr>
						<th scope="row">ID</th>
						<td>{{- .mr_id -}}</td>
					</tr>
					<tr>
						<th scope="row">Status</th>
						<td>{{- .mr_status -}}</td>
					</tr>
					<tr>
						<th scope="row">Title</th>
						<td>{{- .mr_title -}}</td>
					</tr>
					<tr>
						<th scope="row">Source ref</th>
						<td>{{- .mr_source_ref -}}</td>
					</tr>
					<tr>
						<th scope="row">Destination branch</th>
						<td>{{- .mr_destination_branch -}}</td>
					</tr>
					<tr>
						<th scope="row">Merge base</th>
						<td>{{- .merge_base.ID.String -}}</td>
						<td>{{- .merge_base.Hash.String -}}</td>
					</tr>
				</tbody>
			</table>
		</div>
		<div class="padding-wrapper">
			{{- $merge_base := .merge_base -}}
			{{- $source_commit := .source_commit -}}
			{{- range .file_patches -}}
				<div class="file-patch toggle-on-wrapper">
					<input type="checkbox" id="toggle-{{- .From.Hash -}}{{- .To.Hash -}}" class="file-toggle toggle-on-toggle">
					<label for="toggle-{{- .From.Hash -}}{{- .To.Hash -}}" class="file-header toggle-on-header">
						<div>
							{{- if eq .From.Path "" -}}
								--- /dev/null
							{{- else -}}
								--- a/<a href="../../tree/{{- .From.Path -}}?commit={{- $merge_base.Hash -}}">{{- .From.Path -}}</a> {{ .From.Mode -}}
							{{- end -}}
							<br />
							{{- if eq .To.Path "" -}}
								+++ /dev/null
							{{- else -}}
								+++ b/<a href="../../tree/{{- .To.Path -}}?commit={{- $source_commit.Hash -}}">{{- .To.Path -}}</a> {{ .To.Mode -}}
							{{- end -}}
						</div>
					</label>
					<div class="file-content toggle-on-content scroll">
						{{- range .Chunks -}}
							{{- if eq .Operation 0 -}}
								<pre class="chunk chunk-unchanged">{{ .Content }}</pre>
							{{- else if eq .Operation 1 -}}
								<pre class="chunk chunk-addition">{{ .Content }}</pre>
							{{- else if eq .Operation 2 -}}
								<pre class="chunk chunk-deletion">{{ .Content }}</pre>
							{{- else -}}
								<pre class="chunk chunk-unknown">{{ .Content }}</pre>
							{{- end -}}
						{{- end -}}
					</div>
				</div>
			{{- end -}}
		</div>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}
{{/*
	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 }} &ndash; {{ template "group_path_plain" .group_path }} &ndash; {{ .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-title"><a href="commit/{{- .Hash -}}">{{- .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 .IsFile -}}/{{- end -}}{{- if $ref_type -}}?{{- $ref_type -}}={{- $ref -}}{{- end -}}">{{- .Name -}}</a>{{- if not .IsFile -}}/{{- 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 &ndash; {{ .repo_name }} &ndash; {{ template "group_path_plain" .group_path }} &ndash; {{ .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-id"><a href="../commit/{{- .Hash -}}">{{- .Hash -}}</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 -}}