Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
44626e60bf2bac53e2e3988874d310e7882eaabf
Author
Runxi Yu <me@runxiyu.org>
Author date
Fri, 21 Mar 2025 16:55:53 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Fri, 21 Mar 2025 16:56:51 +0800
Actions
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 }} &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-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 &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-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 -}}