Add web commit history views

This commit is contained in:
2026-06-08 15:26:06 -05:00
parent 2059bd028d
commit c025fbe946
5 changed files with 310 additions and 16 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ Open the minimal web UI at the server URL, for example:
http://localhost:8080/repos
```
Web UI routes include `/repos`, `/login`, `/register`, `/repos/new`, `/{username}`, `/{username}/{repo}`, `/{username}/{repo}/branches`, and pull request pages.
Web UI routes include `/repos`, `/login`, `/register`, `/repos/new`, `/{username}`, `/{username}/{repo}`, `/{username}/{repo}/branches`, `/{username}/{repo}/commits`, `/{username}/{repo}/commit`, and pull request pages.
## Storage
@@ -48,7 +48,7 @@ CLI auth config defaults to:
## Web UI
The web UI is intentionally minimal and server-rendered. It supports repository search, user profile pages with public repositories, login/register/logout, repository creation, branch selection, a branches page for branch creation/deletion, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing for users with write access, pull request list/create/view/comment/close/merge workflows, and forking public repositories. Private repositories cannot be forked. Branch renaming is intentionally unsupported in both the API and web UI.
The web UI is intentionally minimal and server-rendered. It supports repository search, user profile pages with public repositories, login/register/logout, repository creation, branch selection, a branches page for branch creation/deletion, selected-branch latest commit and commit count status, commit history pages, single commit pages with limited diff rendering, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing for users with write access, pull request list/create/view/comment/close/merge workflows, and forking public repositories. Private repositories cannot be forked. Branch renaming is intentionally unsupported in both the API and web UI.
## Development
+276 -12
View File
@@ -28,6 +28,7 @@ const (
webAuthCookie = "gitocean_web_token"
webCSRFCookie = "gitocean_csrf"
maxWebFileBytes = 1024 * 1024
maxWebDiffBytes = 200 * 1024
)
type webPageData struct {
@@ -46,16 +47,18 @@ type webReposData struct {
}
type webRepoData struct {
Repo Repository
Ref string
Path string
Entries []webTreeEntry
CanWrite bool
CanFork bool
Branches []RefInfo
CloneURL string
ParentPath string
PRs []PullRequest
Repo Repository
Ref string
Path string
Entries []webTreeEntry
CanWrite bool
CanFork bool
Branches []RefInfo
CloneURL string
ParentPath string
PRs []PullRequest
LatestCommit *webCommit
CommitCount int
}
type webBlobData struct {
@@ -91,6 +94,30 @@ type webBranchesData struct {
CanWrite bool
}
type webCommit struct {
Hash string
Subject string
Body string
AuthorName string
AuthorEmail string
Date time.Time
Diff string
DiffTruncated bool
}
type webCommitHistoryData struct {
Repo Repository
Ref string
Branches []RefInfo
Commits []webCommit
}
type webCommitData struct {
Repo Repository
Ref string
Commit webCommit
}
type webPRListData struct {
Repo Repository
PRs []PullRequest
@@ -115,7 +142,9 @@ type webProfileData struct {
}
var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
"urlquery": url.QueryEscape,
"urlquery": url.QueryEscape,
"shortHash": shortHash,
"truncate": truncateString,
}).Parse(`{{define "layout"}}<!doctype html>
<html><head><meta charset="utf-8"><title>{{.Title}} - gitocean</title></head>
<body>
@@ -200,6 +229,10 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
<input type="hidden" name="path" value="{{$d.Path}}">
<button type="submit">switch</button>
</form>
{{if $d.LatestCommit}}
<p>Latest commit on {{$d.Ref}}: <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commit?ref={{urlquery $d.Ref}}&id={{urlquery $d.LatestCommit.Hash}}"><code>{{shortHash $d.LatestCommit.Hash}}</code> {{truncate $d.LatestCommit.Subject 72}}</a></p>
{{end}}
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commits?ref={{urlquery $d.Ref}}">{{$d.CommitCount}} commits</a> on {{$d.Ref}}</p>
{{if $d.CanWrite}}
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}">create/edit file</a></p>
{{end}}
@@ -245,6 +278,44 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
{{end}}
{{end}}
{{define "Commit History"}}
{{$d := .Data}}
<h1>Commits for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}">repo</a></p>
<form method="get" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commits" style="margin-bottom: 1rem">
<label>Branch
<select name="ref">
{{range $d.Branches}}<option value="{{.Name}}" {{if eq .Name $d.Ref}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<button type="submit">switch</button>
</form>
{{if $d.Commits}}
<table cellpadding="6"><tr><th align="left">Commit</th><th align="left">Author</th><th align="left">Date</th></tr>
{{range $d.Commits}}
<tr>
<td><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commit?ref={{urlquery $d.Ref}}&id={{urlquery .Hash}}"><code>{{shortHash .Hash}}</code> {{.Subject}}</a></td>
<td>{{.AuthorName}}</td>
<td>{{.Date}}</td>
</tr>
{{end}}
</table>
{{else}}<p>No commits.</p>{{end}}
{{end}}
{{define "Commit"}}
{{$d := .Data}}
<h1>Commit {{shortHash $d.Commit.Hash}}</h1>
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commits?ref={{urlquery $d.Ref}}">commit history</a> | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}">tree</a></p>
<h2>{{$d.Commit.Subject}}</h2>
<p><code>{{$d.Commit.Hash}}</code></p>
<p>{{$d.Commit.AuthorName}} &lt;{{$d.Commit.AuthorEmail}}&gt; authored {{$d.Commit.Date}}</p>
{{if $d.Commit.Body}}<pre style="white-space: pre-wrap">{{$d.Commit.Body}}</pre>{{end}}
<h2>Diff</h2>
{{if $d.Commit.DiffTruncated}}<p>Diff truncated.</p>{{end}}
<pre style="white-space: pre-wrap">{{$d.Commit.Diff}}</pre>
{{end}}
{{define "File"}}
{{$d := .Data}}
<h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}</h1>
@@ -588,6 +659,18 @@ func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []st
return
}
s.webRepoRaw(w, r, owner, name)
case "commits":
if r.Method != http.MethodGet {
webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
s.webCommitHistory(w, r, owner, name)
case "commit":
if r.Method != http.MethodGet {
webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
s.webCommitView(w, r, owner, name)
case "edit":
if r.Method == http.MethodGet {
s.webRepoEdit(w, r, owner, name, "")
@@ -656,7 +739,15 @@ func (s *Server) webRepoTree(w http.ResponseWriter, r *http.Request, owner, name
return
}
branches, _ := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads")
data := webRepoData{Repo: repo, Ref: ref, Path: p, Entries: entries, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived, CanFork: authed && repo.Visibility == "public" && user.ID != repo.OwnerUserID, Branches: branches, CloneURL: s.publicURL + "/" + repo.Owner + "/" + repo.Name + ".git", ParentPath: parentRepoPath(p)}
var latest *webCommit
for _, b := range branches {
if b.Name == ref && b.Commit != "" {
latest = &webCommit{Hash: b.Commit, Subject: b.Message, Date: b.Date}
break
}
}
commitCount, _ := gitCommitCount(s.repoPath(repo.Owner, repo.Name), ref)
data := webRepoData{Repo: repo, Ref: ref, Path: p, Entries: entries, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived, CanFork: authed && repo.Visibility == "public" && user.ID != repo.OwnerUserID, Branches: branches, CloneURL: s.publicURL + "/" + repo.Owner + "/" + repo.Name + ".git", ParentPath: parentRepoPath(p), LatestCommit: latest, CommitCount: commitCount}
s.renderWeb(w, r, "Repository", data, "")
}
@@ -747,6 +838,40 @@ func (s *Server) webBranchDeletePost(w http.ResponseWriter, r *http.Request, own
http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/branches", http.StatusSeeOther)
}
func (s *Server) webCommitHistory(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, _, _, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
ref := webRef(r, repo)
branches, _ := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads")
commits, err := gitCommitHistory(s.repoPath(repo.Owner, repo.Name), ref, 100)
if err != nil {
webError(w, r, http.StatusBadRequest, err.Error())
return
}
s.renderWeb(w, r, "Commit History", webCommitHistoryData{Repo: repo, Ref: ref, Branches: branches, Commits: commits}, "")
}
func (s *Server) webCommitView(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, _, _, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
ref := webRef(r, repo)
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
webError(w, r, http.StatusBadRequest, "commit id is required")
return
}
commit, err := gitCommitDetail(s.repoPath(repo.Owner, repo.Name), ref, id)
if err != nil {
webError(w, r, http.StatusNotFound, err.Error())
return
}
s.renderWeb(w, r, "Commit", webCommitData{Repo: repo, Ref: ref, Commit: commit}, "")
}
func (s *Server) webRepoBlob(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok {
@@ -1265,6 +1390,21 @@ func webRef(r *http.Request, repo Repository) string {
return ref
}
func shortHash(hash string) string {
if len(hash) > 10 {
return hash[:10]
}
return hash
}
func truncateString(s string, n int) string {
if n <= 0 || len([]rune(s)) <= n {
return s
}
r := []rune(s)
return string(r[:n-1]) + "…"
}
func cleanRepoFilePath(p string, allowEmpty bool) (string, error) {
p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
if p == "" {
@@ -1343,6 +1483,130 @@ func gitListTree(repoPath, ref, p string) ([]webTreeEntry, error) {
return entries, nil
}
func gitCommitCount(repoPath, ref string) (int, error) {
if !branchRE.MatchString(ref) {
return 0, errors.New("invalid branch")
}
if !gitBranchExists(repoPath, ref) {
return 0, nil
}
out, err := exec.Command("git", "--git-dir", repoPath, "rev-list", "--count", "refs/heads/"+ref).CombinedOutput()
if err != nil {
return 0, fmt.Errorf("git rev-list failed: %s", strings.TrimSpace(string(out)))
}
count, _ := strconv.Atoi(strings.TrimSpace(string(out)))
return count, nil
}
func gitCommitHistory(repoPath, ref string, limit int) ([]webCommit, error) {
if !branchRE.MatchString(ref) {
return nil, errors.New("invalid branch")
}
if !gitBranchExists(repoPath, ref) {
return nil, errors.New("branch not found")
}
if limit <= 0 || limit > 500 {
limit = 100
}
format := "%H%x00%an%x00%ae%x00%ct%x00%s%x00%b%x1e"
out, err := exec.Command("git", "--git-dir", repoPath, "log", "-n", strconv.Itoa(limit), "--format="+format, "refs/heads/"+ref).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("git log failed: %s", strings.TrimSpace(string(out)))
}
return parseGitCommits(string(out)), nil
}
func gitCommitDetail(repoPath, ref, id string) (webCommit, error) {
full, err := resolveCommitInBranch(repoPath, ref, id)
if err != nil {
return webCommit{}, err
}
format := "%H%x00%an%x00%ae%x00%ct%x00%s%x00%b%x1e"
out, err := exec.Command("git", "--git-dir", repoPath, "show", "-s", "--format="+format, full).CombinedOutput()
if err != nil {
return webCommit{}, fmt.Errorf("git show failed: %s", strings.TrimSpace(string(out)))
}
commits := parseGitCommits(string(out))
if len(commits) == 0 {
return webCommit{}, errors.New("commit not found")
}
commit := commits[0]
diff, truncated, err := gitCommitDiff(repoPath, full)
if err != nil {
return webCommit{}, err
}
commit.Diff = diff
commit.DiffTruncated = truncated
return commit, nil
}
func parseGitCommits(out string) []webCommit {
var commits []webCommit
for _, rec := range strings.Split(out, "\x1e") {
rec = strings.Trim(rec, "\n")
if rec == "" {
continue
}
parts := strings.SplitN(rec, "\x00", 6)
if len(parts) < 6 {
continue
}
unix, _ := strconv.ParseInt(strings.TrimSpace(parts[3]), 10, 64)
commits = append(commits, webCommit{Hash: parts[0], AuthorName: parts[1], AuthorEmail: parts[2], Date: time.Unix(unix, 0).UTC(), Subject: parts[4], Body: strings.TrimSpace(parts[5])})
}
return commits
}
func resolveCommitInBranch(repoPath, ref, id string) (string, error) {
if !branchRE.MatchString(ref) {
return "", errors.New("invalid branch")
}
if !gitBranchExists(repoPath, ref) {
return "", errors.New("branch not found")
}
if !validCommitID(id) {
return "", errors.New("invalid commit id")
}
out, err := exec.Command("git", "--git-dir", repoPath, "rev-parse", "--verify", id+"^{commit}").CombinedOutput()
if err != nil {
return "", fmt.Errorf("commit not found")
}
full := strings.TrimSpace(string(out))
if err := exec.Command("git", "--git-dir", repoPath, "merge-base", "--is-ancestor", full, "refs/heads/"+ref).Run(); err != nil {
return "", errors.New("commit is not on branch")
}
return full, nil
}
func validCommitID(id string) bool {
if len(id) < 4 || len(id) > 64 {
return false
}
for _, r := range id {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
continue
}
return false
}
return true
}
func gitCommitDiff(repoPath, full string) (string, bool, error) {
out, err := exec.Command("git", "--git-dir", repoPath, "show", "--format=", "--patch", "--find-renames", "--no-ext-diff", "--unified=3", "--no-color", full).CombinedOutput()
if err != nil {
return "", false, fmt.Errorf("git show diff failed: %s", strings.TrimSpace(string(out)))
}
truncated := false
if len(out) > maxWebDiffBytes {
out = out[:maxWebDiffBytes]
for !utf8.Valid(out) && len(out) > 0 {
out = out[:len(out)-1]
}
truncated = true
}
return string(out), truncated, nil
}
func gitReadBlob(repoPath, ref, p string) (string, error) {
out, err := gitReadBlobBytes(repoPath, ref, p)
if err != nil {
+28 -1
View File
@@ -122,10 +122,37 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
expectLoadRepo(mock, "alice", "demo", repo)
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "README.md") || !strings.Contains(rr.Body.String(), "Clone:") {
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "README.md") || !strings.Contains(rr.Body.String(), "Clone:") || !strings.Contains(rr.Body.String(), "add render files") || !strings.Contains(rr.Body.String(), "2 commits") {
t.Fatalf("repo page status=%d body=%s", rr.Code, rr.Body.String())
}
csrf := csrfFromResponse(t, rr)
refs, err := gitRefs(bare, "refs/heads")
if err != nil {
t.Fatal(err)
}
var mainHash string
for _, ref := range refs {
if ref.Name == "main" {
mainHash = ref.Commit
}
}
if mainHash == "" {
t.Fatal("missing main branch hash")
}
expectLoadRepo(mock, "alice", "demo", repo)
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/commits?ref=main", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Commits for alice/demo") || !strings.Contains(rr.Body.String(), "add render files") || !strings.Contains(rr.Body.String(), "initial") || !strings.Contains(rr.Body.String(), "/alice/demo/commit?ref=main") {
t.Fatalf("commit history status=%d body=%s", rr.Code, rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/commit?ref=main&id="+mainHash, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Commit ") || !strings.Contains(rr.Body.String(), "add render files") || !strings.Contains(rr.Body.String(), "diff --git") || !strings.Contains(rr.Body.String(), "docs.md") {
t.Fatalf("commit view status=%d body=%s", rr.Code, rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
rr = httptest.NewRecorder()
+1 -1
View File
@@ -15,7 +15,7 @@ go test -cover ./...
Current default coverage snapshot after Phase 4 implementation:
```text
internal/app 59.8%
internal/app 60.2%
internal/backup 42.3%
internal/config 79.5%
internal/dbutil 8.6%
@@ -10,6 +10,9 @@ Goal: extend the minimal web UI with branch workflows, pull request workflows, a
- [x] Create an empty branch with an empty root commit
- [x] Delete non-default branches from the branches page
- [x] No branch rename support in API or web UI
- [x] Show latest selected-branch commit and selected-branch commit count on repository pages
- [x] Commit history page for selected branches
- [x] Single commit page with limited diff rendering
- [x] List pull requests in the web UI
- [x] Create pull requests in the web UI
- [x] View pull request details and comments