tenseleyflow/shithub / f177633

Browse files

repo: post-push home view stub with head commit summary

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f1776335870f0b016f8b1fa75a6ad9eb4d722124
Parents
bcbbbbf
Tree
d6927bf

4 changed files

StatusFile+-
M internal/repos/git/plumbing.go 61 0
M internal/web/handlers/repo/repo.go 37 7
M internal/web/static/css/shithub.css 62 0
A internal/web/templates/repo/populated.html 51 0
internal/repos/git/plumbing.gomodified
@@ -19,6 +19,7 @@ import (
1919
 	"fmt"
2020
 	"os"
2121
 	"os/exec"
22
+	"strconv"
2223
 	"strings"
2324
 	"time"
2425
 )
@@ -177,6 +178,66 @@ func (ic InitialCommit) updateRef(ctx context.Context, commit string) error {
177178
 	return nil
178179
 }
179180
 
181
+// HeadCommit is a read-only view of one commit. Returned by HeadOf for
182
+// the repo home page; richer commit-info reads belong to S17.
183
+type HeadCommit struct {
184
+	OID         string
185
+	Subject     string
186
+	AuthorName  string
187
+	AuthorEmail string
188
+	AuthorWhen  time.Time
189
+}
190
+
191
+// HasAnyBranch reports whether the bare repo at gitDir has at least one
192
+// ref under refs/heads/. Used by the repo home view to fork between the
193
+// "quick setup" empty-state and the post-push view.
194
+func HasAnyBranch(ctx context.Context, gitDir string) (bool, error) {
195
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
196
+		"for-each-ref", "--count=1", "--format=%(refname)", "refs/heads/")
197
+	out, err := cmd.Output()
198
+	if err != nil {
199
+		return false, wrapExecErr(err)
200
+	}
201
+	return strings.TrimSpace(string(out)) != "", nil
202
+}
203
+
204
+// HeadOf returns the head commit on the named branch. ok=false (no error)
205
+// when the ref doesn't exist — callers can branch on that without
206
+// distinguishing missing-ref from any other failure.
207
+func HeadOf(ctx context.Context, gitDir, branch string) (HeadCommit, bool, error) {
208
+	// Single git invocation — %x1f is ASCII unit-separator, an unambiguous
209
+	// delimiter that won't appear in commit subjects/authors.
210
+	const sep = "\x1f"
211
+	format := strings.Join([]string{"%H", "%s", "%an", "%ae", "%ct"}, sep)
212
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
213
+		"log", "-1", "--format="+format, "refs/heads/"+branch, "--")
214
+	out, err := cmd.Output()
215
+	if err != nil {
216
+		// Missing ref → exit 128 with "unknown revision" on stderr; we don't
217
+		// want callers to treat that as a real error.
218
+		var ee *exec.ExitError
219
+		if errors.As(err, &ee) {
220
+			return HeadCommit{}, false, nil
221
+		}
222
+		return HeadCommit{}, false, wrapExecErr(err)
223
+	}
224
+	parts := strings.SplitN(strings.TrimRight(string(out), "\n"), sep, 5)
225
+	if len(parts) != 5 {
226
+		return HeadCommit{}, false, fmt.Errorf("git log: malformed output: %q", string(out))
227
+	}
228
+	ts, err := strconv.ParseInt(parts[4], 10, 64)
229
+	if err != nil {
230
+		return HeadCommit{}, false, fmt.Errorf("git log: bad author timestamp %q: %w", parts[4], err)
231
+	}
232
+	return HeadCommit{
233
+		OID:         parts[0],
234
+		Subject:     parts[1],
235
+		AuthorName:  parts[2],
236
+		AuthorEmail: parts[3],
237
+		AuthorWhen:  time.Unix(ts, 0).UTC(),
238
+	}, true, nil
239
+}
240
+
180241
 // wrapExecErr unwraps an *exec.ExitError to expose stderr in the
181242
 // returned message; on other errors it passes through. Useful when the
182243
 // caller logs %w errors and we want the actual git stderr in the line.
internal/web/handlers/repo/repo.gomodified
@@ -21,6 +21,7 @@ import (
2121
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2222
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2323
 	"github.com/tenseleyFlow/shithub/internal/repos"
24
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
2425
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
2526
 	"github.com/tenseleyFlow/shithub/internal/repos/templates"
2627
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
@@ -164,9 +165,10 @@ func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, form fo
164165
 	}
165166
 }
166167
 
167
-// repoHome serves GET /{owner}/{repo}. For S11 it renders the empty-
168
-// repo placeholder when the repo has zero commits; once tree views land
169
-// (S17) this path will fork between empty and code-listing.
168
+// repoHome serves GET /{owner}/{repo}. Forks on whether the bare repo
169
+// has any branches: empty → quick-setup placeholder; populated → a slim
170
+// "post-push" view with the head commit on the default branch. The full
171
+// tree/file listing is S17.
170172
 func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
171173
 	owner := chi.URLParam(r, "owner")
172174
 	name := chi.URLParam(r, "repo")
@@ -177,8 +179,17 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
177179
 		return
178180
 	}
179181
 
180
-	w.Header().Set("Content-Type", "text/html; charset=utf-8")
181
-	if err := h.d.Render.Render(w, "repo/empty", map[string]any{
182
+	diskPath, fsErr := h.d.RepoFS.RepoPath(owner, row.Name)
183
+	hasBranch := false
184
+	if fsErr == nil {
185
+		if ok, herr := repogit.HasAnyBranch(r.Context(), diskPath); herr == nil {
186
+			hasBranch = ok
187
+		} else {
188
+			h.d.Logger.WarnContext(r.Context(), "repo: HasAnyBranch", "error", herr)
189
+		}
190
+	}
191
+
192
+	common := map[string]any{
182193
 		"Title":         row.Name + " · " + owner,
183194
 		"CSRFToken":     middleware.CSRFTokenForRequest(r),
184195
 		"Owner":         owner,
@@ -187,8 +198,27 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
187198
 		"HTTPSCloneURL": h.d.CloneURLs.BaseURL + "/" + owner + "/" + row.Name + ".git",
188199
 		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
189200
 		"SSHCloneURL":   h.d.CloneURLs.SSHHost + ":" + owner + "/" + row.Name + ".git",
190
-	}); err != nil {
191
-		h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err)
201
+	}
202
+
203
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
204
+	if !hasBranch {
205
+		if err := h.d.Render.RenderPage(w, r, "repo/empty", common); err != nil {
206
+			h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err)
207
+		}
208
+		return
209
+	}
210
+
211
+	// Populated path. Look up the head of the default branch — if missing
212
+	// (push went to a non-default branch only), fall through to a
213
+	// branch-not-yet-on-default note.
214
+	head, found, herr := repogit.HeadOf(r.Context(), diskPath, row.DefaultBranch)
215
+	if herr != nil {
216
+		h.d.Logger.WarnContext(r.Context(), "repo: HeadOf", "error", herr)
217
+	}
218
+	common["HeadFound"] = found
219
+	common["Head"] = head
220
+	if err := h.d.Render.RenderPage(w, r, "repo/populated", common); err != nil {
221
+		h.d.Logger.ErrorContext(r.Context(), "repo: render populated", "error", err)
192222
 	}
193223
 }
194224
 
internal/web/static/css/shithub.cssmodified
@@ -829,3 +829,65 @@ code {
829829
   font-size: 0.85rem;
830830
   overflow-x: auto;
831831
 }
832
+
833
+/* Populated-repo placeholder (S17 will replace with real tree view) */
834
+.shithub-repo-populated {
835
+  max-width: 56rem;
836
+  margin: 2rem auto;
837
+  padding: 0 1rem;
838
+}
839
+.shithub-repo-populated-head h1 {
840
+  margin: 0 0 0.5rem;
841
+  font-size: 1.4rem;
842
+  font-weight: 400;
843
+  display: flex;
844
+  align-items: center;
845
+  gap: 0.4rem;
846
+}
847
+.shithub-repo-populated-sep { color: var(--fg-muted); }
848
+.shithub-repo-populated-desc { margin: 0 0 1rem; color: var(--fg-muted); }
849
+.shithub-repo-headcommit {
850
+  margin-top: 1rem;
851
+  padding: 0.85rem 1rem;
852
+  border: 1px solid var(--border-default);
853
+  border-radius: 8px;
854
+  background: var(--canvas-subtle);
855
+}
856
+.shithub-repo-headcommit-meta {
857
+  display: flex;
858
+  flex-wrap: wrap;
859
+  gap: 0.75rem;
860
+  font-size: 0.85rem;
861
+  color: var(--fg-muted);
862
+  align-items: center;
863
+}
864
+.shithub-repo-headcommit-branch {
865
+  font-weight: 600;
866
+  color: var(--fg-default);
867
+  padding: 0.1rem 0.5rem;
868
+  border: 1px solid var(--border-default);
869
+  border-radius: 999px;
870
+  background: var(--canvas-default);
871
+  font-size: 0.75rem;
872
+}
873
+.shithub-repo-headcommit-oid { font-family: monospace; }
874
+.shithub-repo-headcommit-author { color: var(--fg-default); }
875
+.shithub-repo-headcommit-subject { margin: 0.5rem 0 0; font-size: 0.95rem; }
876
+.shithub-repo-headcommit-other-branch p { margin: 0; font-size: 0.9rem; color: var(--fg-muted); }
877
+.shithub-repo-populated-clone {
878
+  margin-top: 1rem;
879
+  display: grid;
880
+  gap: 0.5rem;
881
+}
882
+.shithub-repo-populated-clone label { display: grid; grid-template-columns: 70px 1fr; gap: 0.5rem; align-items: center; }
883
+.shithub-repo-populated-clone span { font-size: 0.8rem; color: var(--fg-muted); font-weight: 600; }
884
+.shithub-repo-populated-clone input {
885
+  font: inherit;
886
+  font-family: monospace;
887
+  font-size: 0.85rem;
888
+  padding: 0.5rem 0.75rem;
889
+  border: 1px solid var(--border-default);
890
+  border-radius: 6px;
891
+  background: var(--canvas-default);
892
+}
893
+.shithub-repo-populated-note { margin-top: 1rem; font-size: 0.8rem; color: var(--fg-muted); }
internal/web/templates/repo/populated.htmladded
@@ -0,0 +1,51 @@
1
+{{ define "page" -}}
2
+<section class="shithub-repo-populated">
3
+  <header class="shithub-repo-populated-head">
4
+    <h1>
5
+      <a href="/{{ .Owner }}">{{ .Owner }}</a>
6
+      <span class="shithub-repo-populated-sep">/</span>
7
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a>
8
+      {{ if eq (printf "%s" .Repo.Visibility) "private" }}
9
+        <span class="shithub-pill shithub-pill-private">private</span>
10
+      {{ else }}
11
+        <span class="shithub-pill">public</span>
12
+      {{ end }}
13
+    </h1>
14
+    {{ if .Repo.Description }}<p class="shithub-repo-populated-desc">{{ .Repo.Description }}</p>{{ end }}
15
+  </header>
16
+
17
+  {{ if .HeadFound }}
18
+  <section class="shithub-repo-headcommit" aria-label="Latest commit">
19
+    <div class="shithub-repo-headcommit-meta">
20
+      <span class="shithub-repo-headcommit-branch">{{ .DefaultBranch }}</span>
21
+      <code class="shithub-repo-headcommit-oid" title="{{ .Head.OID }}">{{ slice .Head.OID 0 7 }}</code>
22
+      <span class="shithub-repo-headcommit-author">{{ .Head.AuthorName }}</span>
23
+      <time datetime="{{ .Head.AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Head.AuthorWhen }}</time>
24
+    </div>
25
+    <p class="shithub-repo-headcommit-subject">{{ .Head.Subject }}</p>
26
+  </section>
27
+  {{ else }}
28
+  <section class="shithub-repo-headcommit shithub-repo-headcommit-other-branch">
29
+    <p>This repository has commits, but the default branch <code>{{ .DefaultBranch }}</code> hasn't been pushed yet.
30
+    Push <code>{{ .DefaultBranch }}</code> or change the default in repo settings to see content here.</p>
31
+  </section>
32
+  {{ end }}
33
+
34
+  <aside class="shithub-repo-populated-clone" aria-label="Clone URLs">
35
+    <label>
36
+      <span>HTTPS</span>
37
+      <input type="text" readonly value="{{ .HTTPSCloneURL }}" onclick="this.select()">
38
+    </label>
39
+    {{ if .SSHEnabled }}
40
+    <label>
41
+      <span>SSH</span>
42
+      <input type="text" readonly value="{{ .SSHCloneURL }}" onclick="this.select()">
43
+    </label>
44
+    {{ end }}
45
+  </aside>
46
+
47
+  <p class="shithub-repo-populated-note">
48
+    Tree view, file blobs, branch list, and commit history land in S17. For now this view confirms a push has reached the server.
49
+  </p>
50
+</section>
51
+{{- end }}