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}}
+
+{{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