From c025fbe94627cd413d839ff4d07c0fd54e551fb3 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 8 Jun 2026 15:26:06 -0500 Subject: [PATCH] Add web commit history views --- README.md | 4 +- internal/app/web.go | 288 ++++++++++++++++++++- internal/app/web_test.go | 29 ++- notes/PHASE_4_COVERAGE_NOTES.md | 2 +- plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md | 3 + 5 files changed, 310 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d5799b6..44d605e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/app/web.go b/internal/app/web.go index f597ab3..0e6e538 100644 --- a/internal/app/web.go +++ b/internal/app/web.go @@ -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"}} {{.Title}} - gitocean @@ -200,6 +229,10 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ +{{if $d.LatestCommit}} +

Latest commit on {{$d.Ref}}: {{shortHash $d.LatestCommit.Hash}} {{truncate $d.LatestCommit.Subject 72}}

+{{end}} +

{{$d.CommitCount}} commits on {{$d.Ref}}

{{if $d.CanWrite}}

create/edit file

{{end}} @@ -245,6 +278,44 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ {{end}} {{end}} +{{define "Commit History"}} +{{$d := .Data}} +

Commits for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

+

repo

+
+ + +
+{{if $d.Commits}} + +{{range $d.Commits}} + + + + + +{{end}} +
CommitAuthorDate
{{shortHash .Hash}} {{.Subject}}{{.AuthorName}}{{.Date}}
+{{else}}

No commits.

{{end}} +{{end}} + +{{define "Commit"}} +{{$d := .Data}} +

Commit {{shortHash $d.Commit.Hash}}

+

commit history | tree

+

{{$d.Commit.Subject}}

+

{{$d.Commit.Hash}}

+

{{$d.Commit.AuthorName}} <{{$d.Commit.AuthorEmail}}> authored {{$d.Commit.Date}}

+{{if $d.Commit.Body}}
{{$d.Commit.Body}}
{{end}} +

Diff

+{{if $d.Commit.DiffTruncated}}

Diff truncated.

{{end}} +
{{$d.Commit.Diff}}
+{{end}} + {{define "File"}} {{$d := .Data}}

{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}

@@ -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 { diff --git a/internal/app/web_test.go b/internal/app/web_test.go index 37d7fb6..40a5133 100644 --- a/internal/app/web_test.go +++ b/internal/app/web_test.go @@ -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() diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md index 6deaa90..2e2f228 100644 --- a/notes/PHASE_4_COVERAGE_NOTES.md +++ b/notes/PHASE_4_COVERAGE_NOTES.md @@ -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% diff --git a/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md b/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md index 956c310..ae30191 100644 --- a/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md +++ b/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md @@ -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