cmd/gitfed-web/handlers_browse.go
diff --git a/cmd/gitfed-web/handlers_browse.go b/cmd/gitfed-web/handlers_browse.go
deleted file mode 100644
index 9096fb8..0000000
--- a/cmd/gitfed-web/handlers_browse.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package main
-
-import (
- "bytes"
- "html/template"
- "net/http"
- "strings"
-
- "gitfed/internal/gitexec"
-)
-
-// breadcrumb builds clickable path segments: "cmd/gitfed-web/main.go" becomes
-// repo → cmd → gitfed-web → main.go, each linking back into the tree at that
-// depth (the last segment doesn't link anywhere, it's the current page).
-type crumb struct {
- Name, Path string
- Last bool
-}
-
-func breadcrumbs(path string) []crumb {
- if path == "" {
- return nil
- }
- parts := strings.Split(path, "/")
- crumbs := make([]crumb, len(parts))
- for i, part := range parts {
- crumbs[i] = crumb{
- Name: part,
- Path: strings.Join(parts[:i+1], "/"),
- Last: i == len(parts)-1,
- }
- }
- return crumbs
-}
-
-func parentPath(path string) string {
- i := strings.LastIndex(path, "/")
- if i < 0 {
- return ""
- }
- return path[:i]
-}
-
-var treeTpl = template.Must(template.New("tree").Parse(`
-{{.Flash}}
-<p><a href="/r/{{.Repo}}">← {{.Repo}}</a></p>
-<h1>
- <a href="/repo-tree/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/repo-tree/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
-</h1>
-<table>
-<tr><th>Name</th><th></th></tr>
-{{if .ShowUp}}
-<tr><td><a href="/repo-tree/{{.Repo}}?path={{.ParentPath}}">..</a></td><td class="muted">directory</td></tr>
-{{end}}
-{{range .Entries}}
-<tr>
- {{if eq .Type "tree"}}
- <td><a href="/repo-tree/{{$.Repo}}?path={{.FullPath}}">{{.Name}}/</a></td>
- <td class="muted">directory</td>
- {{else}}
- <td><a href="/repo-blob/{{$.Repo}}?path={{.FullPath}}">{{.Name}}</a></td>
- <td class="muted">file</td>
- {{end}}
-</tr>
-{{else}}
-{{if not .ShowUp}}<tr><td colspan="2" class="muted">Empty.</td></tr>{{end}}
-{{end}}
-</table>
-`))
-
-type treeEntryView struct {
- gitexec.TreeEntry
- FullPath string
-}
-
-func (s *server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
- name := r.PathValue("repo")
- path := strings.Trim(r.URL.Query().Get("path"), "/")
-
- repo, err := s.ops.GetRepo(name)
- if err != nil || !s.canView(r, repo) {
- http.NotFound(w, r)
- return
- }
-
- entries, found, err := s.ops.ListRepoTree(name, path)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if !found {
- http.NotFound(w, r)
- return
- }
-
- views := make([]treeEntryView, len(entries))
- for i, e := range entries {
- full := e.Name
- if path != "" {
- full = path + "/" + e.Name
- }
- views[i] = treeEntryView{TreeEntry: e, FullPath: full}
- }
-
- var buf bytes.Buffer
- _ = treeTpl.Execute(&buf, struct {
- Repo string
- Crumbs []crumb
- Entries []treeEntryView
- ShowUp bool
- ParentPath string
- Flash template.HTML
- }{name, breadcrumbs(path), views, path != "", parentPath(path), flash(r)})
-
- title := name
- if path != "" {
- title = path + " — " + name
- }
- s.render(w, r, title, "home", template.HTML(buf.String()))
-}
-
-var blobTpl = template.Must(template.New("blob").Parse(`
-{{.Flash}}
-<p><a href="/r/{{.Repo}}">← {{.Repo}}</a></p>
-<h1>
- <a href="/repo-tree/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/repo-tree/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
-</h1>
-<div class="markdown-body">
-{{.Content}}
-</div>
-`))
-
-func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
- name := r.PathValue("repo")
- path := strings.Trim(r.URL.Query().Get("path"), "/")
- if path == "" {
- http.NotFound(w, r)
- return
- }
-
- repo, err := s.ops.GetRepo(name)
- if err != nil || !s.canView(r, repo) {
- http.NotFound(w, r)
- return
- }
-
- content, found, err := s.ops.GetRepoFile(name, path)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if !found {
- http.NotFound(w, r)
- return
- }
-
- var rendered template.HTML
- if strings.Contains(content, "\x00") {
- rendered = "<p class=\"muted\">Binary file — not shown.</p>"
- } else {
- fileName := path
- if i := strings.LastIndex(fileName, "/"); i >= 0 {
- fileName = fileName[i+1:]
- }
- rendered, err = renderFileContent(fileName, content)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
-
- var buf bytes.Buffer
- _ = blobTpl.Execute(&buf, struct {
- Repo string
- Crumbs []crumb
- Content template.HTML
- Flash template.HTML
- }{name, breadcrumbs(path), rendered, flash(r)})
-
- s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
-}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go
index f2cde68..11b67b3 100644
--- a/cmd/gitfed-web/handlers_repo.go
+++ b/cmd/gitfed-web/handlers_repo.go
@@ -7,6 +7,7 @@ import (
"net/url"
"strings"
+ "gitfed/internal/gitexec"
"gitfed/internal/store"
)
@@ -38,44 +39,110 @@ func (s *server) canAdminister(r *http.Request, repoName string) (string, bool)
return sess.Principal, err == nil && allowed
}
+// crumb is one clickable segment of an in-repo path breadcrumb.
+type crumb struct {
+ Name, Path string
+ Last bool
+}
+
+func breadcrumbs(path string) []crumb {
+ if path == "" {
+ return nil
+ }
+ parts := strings.Split(path, "/")
+ crumbs := make([]crumb, len(parts))
+ for i, part := range parts {
+ crumbs[i] = crumb{Name: part, Path: strings.Join(parts[:i+1], "/"), Last: i == len(parts)-1}
+ }
+ return crumbs
+}
+
+func parentPath(path string) string {
+ i := strings.LastIndex(path, "/")
+ if i < 0 {
+ return ""
+ }
+ return path[:i]
+}
+
+type treeEntryView struct {
+ gitexec.TreeEntry
+ FullPath string
+}
+
+// repoTpl renders the repo's file tree at the current path, GitLab-style:
+// files/dirs at the top, README (and, root only, LICENSE) rendered directly
+// below — no separate "browse files" page.
var repoTpl = template.Must(template.New("repo").Parse(`
{{.Flash}}
-<p><a href="/">← All repos</a></p>
-<h1>{{.Repo.Name}} {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</h1>
-<p class="muted">owner: {{.Repo.Owner}}
-{{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}}
-· <a href="/repo-tree/{{.Repo.Name}}">browse files</a>
-{{if .CanAdminister}} · <a href="/repo-settings/{{.Repo.Name}}">settings</a>{{end}}</p>
-
-<section>
-<code>ssh://git@{{.Domain}}:2222/{{.Repo.Name}}.git</code>
-<p class="muted">Cloning requires a gitfed SSH identity — this web login is separate from git access.</p>
-</section>
+<div class="gf-crumbs"><a href="/">Explore</a><span class="sep">/</span>{{.Repo.Name}}</div>
+
+<div class="gf-repo-head">
+ <h1>{{.Repo.Name}}</h1>
+ {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}
+ {{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}}
+</div>
+<p class="muted">owner: {{.Repo.Owner}}</p>
+
+<div class="gf-action-bar">
+ {{if .Branch}}<span class="gf-btn">⎇ {{.Branch}}</span>{{end}}
+ <div class="gf-clone-url">
+ <code>{{.CloneURL}}</code>
+ <button type="button" class="linklike" data-copy="{{.CloneURL}}" title="Copy clone URL" aria-label="Copy clone URL">⧉</button>
+ </div>
+ {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">Settings</a>{{end}}
+</div>
+
+{{if .Crumbs}}
+<div class="gf-crumbs">
+ <a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo.Name}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
+</div>
+{{end}}
+
+{{if .Empty}}
+<div class="gf-card" style="padding: 1.5rem;">
+ <p class="muted" style="margin: 0 0 0.6rem;">This repository is empty.</p>
+ <code>git clone {{.CloneURL}}</code>
+</div>
+{{else}}
+<div class="gf-card">
+ <table class="gf-file-table">
+ {{if .ShowUp}}<tr><td class="icon">📁</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}}
+ {{range .Entries}}
+ {{if eq .Type "tree"}}
+ <tr><td class="icon">📁</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">directory</td></tr>
+ {{else}}
+ <tr><td class="icon">📄</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">file</td></tr>
+ {{end}}
+ {{else}}
+ {{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">Nothing here.</td></tr>{{end}}
+ {{end}}
+ </table>
+</div>
+{{end}}
{{if .Tags}}
-<section>
-<h3>Tags</h3>
-{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}
-</section>
+<section><h3>Tags</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section>
{{end}}
{{if .ReadmeHTML}}
-<section class="markdown-body">
-<h3>README</h3>
-{{.ReadmeHTML}}
-</section>
+<div class="gf-card">
+ <div class="gf-readme-head">📄 README</div>
+ <div class="gf-readme-body markdown-body">{{.ReadmeHTML}}</div>
+</div>
{{end}}
{{if .LicenseHTML}}
-<section class="markdown-body">
-<h3>License ({{.LicenseFile}})</h3>
-{{.LicenseHTML}}
-</section>
+<div class="gf-card">
+ <div class="gf-readme-head">📄 License ({{.LicenseFile}})</div>
+ <div class="gf-readme-body markdown-body">{{.LicenseHTML}}</div>
+</div>
{{end}}
`))
func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
+ path := strings.Trim(r.URL.Query().Get("path"), "/")
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
@@ -84,34 +151,66 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
}
_, canAdminister := s.canAdminister(r, name)
- tags, err := s.ops.ListRepoTags(name)
+ entries, found, err := s.ops.ListRepoTree(name, path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ if !found && path != "" {
+ // A real path that doesn't exist — an empty repo at the root is
+ // handled below instead of 404ing (there's a legitimate page to
+ // show: the clone command to get started).
+ http.NotFound(w, r)
+ return
+ }
+
+ views := make([]treeEntryView, len(entries))
+ for i, e := range entries {
+ full := e.Name
+ if path != "" {
+ full = path + "/" + e.Name
+ }
+ views[i] = treeEntryView{TreeEntry: e, FullPath: full}
+ }
- readmeContent, readmeFound, err := s.ops.GetRepoReadme(name)
+ branch, _, err := s.ops.GetRepoBranch(name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- var readmeHTML template.HTML
- if readmeFound {
- readmeHTML, err = renderFileContent("README.md", readmeContent)
+
+ var readmeHTML, licenseHTML template.HTML
+ var licenseFile string
+ var tags []string
+ if path == "" {
+ readmeContent, readmeFound, err := s.ops.GetRepoReadme(name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- }
+ if readmeFound {
+ readmeHTML, err = renderFileContent("README.md", readmeContent)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
- licenseContent, licenseFile, licenseFound, err := s.ops.GetRepoLicense(name)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- var licenseHTML template.HTML
- if licenseFound {
- licenseHTML, err = renderFileContent(licenseFile, licenseContent)
+ licenseContent, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if licenseFound {
+ licenseFile = foundLicenseFile
+ licenseHTML, err = renderFileContent(licenseFile, licenseContent)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ tags, err = s.ops.ListRepoTags(name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -122,14 +221,92 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
_ = repoTpl.Execute(&buf, struct {
Repo store.Repo
Domain string
+ CloneURL string
+ Branch string
+ Crumbs []crumb
+ Entries []treeEntryView
+ ShowUp bool
+ ParentPath string
+ Empty bool
Tags []string
ReadmeHTML template.HTML
LicenseHTML template.HTML
LicenseFile string
CanAdminister bool
Flash template.HTML
- }{repo, s.domain, tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r)})
- s.render(w, r, name, "home", template.HTML(buf.String()))
+ }{
+ repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", branch,
+ breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found,
+ tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r),
+ })
+
+ title := name
+ if path != "" {
+ title = path + " — " + name
+ }
+ s.render(w, r, title, "home", template.HTML(buf.String()))
+}
+
+var blobTpl = template.Must(template.New("blob").Parse(`
+{{.Flash}}
+<div class="gf-crumbs">
+ <a href="/r/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
+</div>
+<div class="gf-card">
+ <div class="gf-readme-body markdown-body">
+ {{.Content}}
+ </div>
+</div>
+`))
+
+func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+ path := strings.Trim(r.URL.Query().Get("path"), "/")
+ if path == "" {
+ http.NotFound(w, r)
+ return
+ }
+
+ repo, err := s.ops.GetRepo(name)
+ if err != nil || !s.canView(r, repo) {
+ http.NotFound(w, r)
+ return
+ }
+
+ content, found, err := s.ops.GetRepoFile(name, path)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+
+ var rendered template.HTML
+ if strings.Contains(content, "\x00") {
+ rendered = "<p class=\"muted\">Binary file — not shown.</p>"
+ } else {
+ fileName := path
+ if i := strings.LastIndex(fileName, "/"); i >= 0 {
+ fileName = fileName[i+1:]
+ }
+ rendered, err = renderFileContent(fileName, content)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ var buf bytes.Buffer
+ _ = blobTpl.Execute(&buf, struct {
+ Repo string
+ Crumbs []crumb
+ Content template.HTML
+ Flash template.HTML
+ }{name, breadcrumbs(path), rendered, flash(r)})
+
+ s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
}
func renderFileContent(filename, content string) (template.HTML, error) {
cmd/gitfed-web/handlers_search.go
diff --git a/cmd/gitfed-web/handlers_search.go b/cmd/gitfed-web/handlers_search.go
new file mode 100644
index 0000000..0e54766
--- /dev/null
+++ b/cmd/gitfed-web/handlers_search.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+ "strings"
+
+ "gitfed/internal/store"
+)
+
+var searchTpl = template.Must(template.New("search").Parse(`
+<h1>Search{{if .Query}}: "{{.Query}}"{{end}}</h1>
+<form action="/search" method="get" style="max-width:420px;">
+ <input type="search" name="q" value="{{.Query}}" placeholder="Search repos…" autofocus>
+</form>
+<table>
+<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr>
+{{range .Results}}
+<tr>
+ <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
+ <td class="muted">{{.Owner}}</td>
+ <td>{{range .Topics}}<span class="badge plain">{{.}}</span>{{end}}</td>
+</tr>
+{{else}}
+<tr><td colspan="3" class="muted">{{if .Query}}No matches.{{else}}Type something to search.{{end}}</td></tr>
+{{end}}
+</table>
+`))
+
+// searchVisible reports whether repo should be considered for search
+// results for the given (possibly anonymous) session: same visibility rule
+// as everywhere else — public repos to anyone, private ones only to an
+// owner/collaborator.
+func (s *server) searchVisible(r *http.Request, repo store.Repo) bool {
+ return s.canView(r, repo)
+}
+
+func (s *server) handleSearch(w http.ResponseWriter, r *http.Request) {
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+
+ var results []store.Repo
+ if q != "" {
+ all, err := s.ops.ListRepos()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ needle := strings.ToLower(q)
+ for _, repo := range all {
+ if !s.searchVisible(r, repo) {
+ continue
+ }
+ if matchesSearch(repo, needle) {
+ results = append(results, repo)
+ }
+ }
+ }
+
+ var buf bytes.Buffer
+ _ = searchTpl.Execute(&buf, struct {
+ Query string
+ Results []store.Repo
+ }{q, results})
+ s.render(w, r, "Search", "", template.HTML(buf.String()))
+}
+
+func matchesSearch(repo store.Repo, lowerNeedle string) bool {
+ if strings.Contains(strings.ToLower(repo.Name), lowerNeedle) {
+ return true
+ }
+ for _, t := range repo.Topics {
+ if strings.Contains(strings.ToLower(t), lowerNeedle) {
+ return true
+ }
+ }
+ return false
+}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go
index ec5a7fb..518e719 100644
--- a/cmd/gitfed-web/render.go
+++ b/cmd/gitfed-web/render.go
@@ -5,6 +5,7 @@ import (
"html/template"
"net/http"
"strings"
+ "unicode"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
@@ -30,94 +31,198 @@ func renderMarkdown(src string) (template.HTML, error) {
// forming a git fork, no gradient or curve. Used inline in the header (via
// currentColor, so it follows the link color) and, percent-encoded, as the
// favicon below.
-const brandMark = `<svg class="brand-mark" width="20" height="20" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>`
+const brandMark = `<svg class="brand-mark" width="18" height="18" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>`
const shellSrc = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — gitfed</title>
<link rel="icon" media="(prefers-color-scheme: light)" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect x='41' y='46' width='14' height='36' fill='%2317181a'/%3E%3Crect x='14' y='14' width='14' height='34' fill='%2317181a' transform='rotate(35 21 31)'/%3E%3Crect x='68' y='14' width='14' height='34' fill='%2317181a' transform='rotate(-35 75 31)'/%3E%3C/svg%3E">
<link rel="icon" media="(prefers-color-scheme: dark)" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect x='41' y='46' width='14' height='36' fill='%23f0efec'/%3E%3Crect x='14' y='14' width='14' height='34' fill='%23f0efec' transform='rotate(35 21 31)'/%3E%3Crect x='68' y='14' width='14' height='34' fill='%23f0efec' transform='rotate(-35 75 31)'/%3E%3C/svg%3E">
<style>
- body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #0f1115; color: #e6e6e6; }
- header { background: #171a21; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; border-bottom: 1px solid #2a2f3a; }
- header h1 { font-size: 1rem; margin: 0; }
- header h1 a { color: #8ab4f8; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; }
+ :root {
+ --canvas: #0d0f13; --surface: #161a21; --surface-2: #1c212a; --surface-3: #232933;
+ --border: #262c36; --border-strong: #333a46;
+ --text: #e8eaed; --text-dim: #9aa1ac; --text-faint: #6b7280;
+ --accent: #6c9df5; --accent-dim: #3a5a8f; --accent-ink: #0d0f13;
+ --ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8;
+ --pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e;
+ --danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82;
+ --mono: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
+ }
+ * { box-sizing: border-box; }
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--canvas); color: var(--text); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
+ a { color: inherit; }
+
+ /* ---------- nav ---------- */
+ .gf-nav { display: flex; align-items: center; gap: 0.5rem; height: 52px; padding: 0 1rem; background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
+ .gf-nav-brand { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--accent); text-decoration: none; font-weight: 700; font-size: 0.95rem; flex-shrink: 0; }
.brand-mark { flex-shrink: 0; }
- nav { display: flex; gap: 1rem; flex: 1; }
- nav a { color: #cfd3dc; text-decoration: none; font-size: 0.9rem; }
- nav a.active { color: #8ab4f8; font-weight: bold; }
- nav form { margin: 0; }
- nav button.linklike { background: none; border: none; color: #cfd3dc; font-size: 0.9rem; cursor: pointer; padding: 0; font-family: inherit; }
- main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
- table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
- th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2f3a; font-size: 0.9rem; }
- th { color: #9aa1ac; font-weight: 600; }
+ .gf-nav-burger { display: none; background: none; border: none; color: var(--text-dim); padding: 0.4rem; cursor: pointer; margin-left: auto; }
+ .gf-nav-panel { display: flex; align-items: center; gap: 0.75rem; flex: 1; margin-left: 0.75rem; min-width: 0; }
+ .gf-nav-links { display: flex; align-items: center; gap: 0.15rem; flex-shrink: 0; }
+ .gf-nav-links a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; padding: 0.4rem 0.6rem; border-radius: 6px; }
+ .gf-nav-links a:hover { color: var(--text); }
+ .gf-nav-links a.active { color: var(--text); background: var(--surface-2); font-weight: 600; }
+ .gf-nav-search { flex: 1; display: flex; justify-content: center; margin: 0; }
+ .gf-nav-search-inner { display: flex; align-items: center; gap: 0.5rem; width: 100%; max-width: 340px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.38rem 0.65rem; color: var(--text-faint); }
+ .gf-nav-search-inner:focus-within { border-color: var(--accent); }
+ .gf-nav-search input { border: none; background: none; color: var(--text); font-size: 0.84rem; padding: 0; margin: 0; flex: 1; min-width: 0; }
+ .gf-nav-search input:focus { outline: none; }
+ .gf-nav-search input::placeholder { color: var(--text-faint); }
+ .gf-nav-search kbd { font-family: var(--mono); font-size: 0.66rem; background: var(--surface-3); border: 1px solid var(--border-strong); border-radius: 4px; padding: 0.05rem 0.35rem; color: var(--text-faint); flex-shrink: 0; }
+ .gf-nav-right { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
+ .gf-nav-right a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; }
+ .gf-nav-right a:hover { color: var(--text); }
+ .gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; }
+ .linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; }
+ .linklike:hover { color: var(--text); }
+
+ @media (max-width: 720px) {
+ .gf-nav-burger { display: flex; align-items: center; }
+ .gf-nav-panel { display: none; position: absolute; top: 52px; left: 0; right: 0; background: var(--surface); border-bottom: 1px solid var(--border); flex-direction: column; align-items: stretch; gap: 0.75rem; padding: 0.85rem 1rem; margin: 0; }
+ .gf-nav-panel.open { display: flex; }
+ .gf-nav-links { flex-direction: column; align-items: stretch; gap: 0.15rem; }
+ .gf-nav-search-inner { max-width: none; }
+ .gf-nav-right { justify-content: space-between; }
+ }
+
+ /* ---------- layout ---------- */
+ main { padding: 1.5rem 1rem; max-width: 980px; margin: 0 auto; }
+ section { margin-bottom: 2rem; }
+
+ /* ---------- repo page ---------- */
+ .gf-crumbs { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); }
+ .gf-crumbs a { color: var(--text-dim); text-decoration: none; }
+ .gf-crumbs a:hover { color: var(--text); }
+ .gf-crumbs .sep { color: var(--text-faint); margin: 0 0.35em; }
+ .gf-repo-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-top: 0.6rem; }
+ .gf-repo-head h1 { font-size: 1.3rem; margin: 0; font-weight: 700; }
+ .gf-action-bar { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 1rem 0; }
+ .gf-action-bar .gf-btn, .gf-action-bar .gf-clone-url { margin-top: 0; }
+ .gf-clone-url {
+ flex: 1; min-width: 200px; font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim);
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.4rem 0.7rem;
+ display: flex; align-items: center; gap: 0.5rem; overflow: hidden;
+ }
+ .gf-clone-url code { background: none; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .gf-clone-url button { margin: 0 0 0 auto; padding: 0; background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; font-size: 0.95rem; }
+ .gf-clone-url button:hover { color: var(--text); }
+ .gf-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; }
+ .gf-file-table { width: 100%; border-collapse: collapse; margin: 0; }
+ .gf-file-table tr { border-bottom: 1px solid var(--border); }
+ .gf-file-table tr:last-child { border-bottom: none; }
+ .gf-file-table td { padding: 0.55rem 0.9rem; font-size: 0.86rem; vertical-align: middle; border: none; }
+ .gf-file-table td.icon { width: 1.4rem; padding-right: 0; }
+ .gf-file-table td.name { font-family: var(--mono); font-size: 0.84rem; }
+ .gf-file-table td.name a { color: var(--text); text-decoration: none; }
+ .gf-file-table td.name a:hover { color: var(--accent); text-decoration: underline; }
+ .gf-file-table td.meta { color: var(--text-faint); font-size: 0.78rem; text-align: right; white-space: nowrap; }
+ .gf-readme-head { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.1rem; border-bottom: 1px solid var(--border); font-size: 0.86rem; color: var(--text-dim); font-family: var(--mono); }
+ .gf-readme-body { padding: 1.3rem; }
+
+ @media (max-width: 620px) {
+ .gf-action-bar { flex-direction: column; align-items: stretch; }
+ .gf-clone-url { order: 2; }
+ .gf-file-table td.meta { display: none; }
+ }
+
+ /* ---------- tables ---------- */
+ .table-wrap { overflow-x: auto; }
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; display: block; overflow-x: auto; max-width: 100%; }
+ th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); font-size: 0.88rem; }
+ th { color: var(--text-dim); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.02em; }
+ tr:hover td { background: var(--surface-2); }
+
+ /* ---------- forms & buttons ---------- */
form.inline { display: inline; }
- form.card { background: #171a21; border: 1px solid #2a2f3a; border-radius: 8px; padding: 1rem; margin: 1rem 0; max-width: 480px; }
- form.card label { display: block; font-size: 0.85rem; color: #9aa1ac; margin-top: 0.6rem; }
- input, select { width: 100%; box-sizing: border-box; padding: 0.4rem; margin-top: 0.2rem; background: #0f1115; border: 1px solid #333944; color: #e6e6e6; border-radius: 4px; }
- button { margin-top: 0.8rem; padding: 0.4rem 0.9rem; background: #8ab4f8; border: none; border-radius: 4px; color: #0f1115; font-weight: 600; cursor: pointer; }
- button.danger { background: #f28b82; }
- .msg { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
- .msg.ok { background: #1e3a2a; color: #9ae6b4; }
- .msg.err { background: #3a1e1e; color: #f28b82; }
- .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 10px; font-size: 0.75rem; }
- .badge.pending { background: #3a3320; color: #f5cf5b; }
- .badge.trusted { background: #1e3a2a; color: #9ae6b4; }
- .badge.plain { background: #1e3a2a; color: #9ae6b4; margin-right: 0.3rem; }
- .muted { color: #9aa1ac; font-size: 0.85rem; }
- code, pre { background: #171a21; border-radius: 4px; font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 0.86em; }
+ form.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; margin: 1rem 0; max-width: 480px; }
+ form.card label { display: block; font-size: 0.85rem; color: var(--text-dim); margin-top: 0.6rem; }
+ input, select { width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; margin-top: 0.25rem; background: var(--canvas); border: 1px solid var(--border-strong); color: var(--text); border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
+ button, .gf-btn { margin-top: 0.8rem; padding: 0.45rem 0.9rem; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--text); font-weight: 600; font-size: 0.86rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; }
+ button:hover, .gf-btn:hover { background: var(--surface-3); }
+ button[type="submit"]:not(.linklike), .gf-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
+ button[type="submit"]:not(.linklike):hover, .gf-btn.primary:hover { filter: brightness(1.08); }
+ button.danger { background: var(--danger-bg); border-color: transparent; color: var(--danger-fg); }
+ button.danger:hover { background: rgba(242,139,130,0.24); }
+
+ /* ---------- misc components ---------- */
+ .msg { padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
+ .msg.ok { background: var(--ok-bg); color: var(--ok-fg); }
+ .msg.err { background: var(--danger-bg); color: var(--danger-fg); }
+ .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); }
+ .badge.pending { background: var(--pending-bg); color: var(--pending-fg); }
+ .badge.trusted { background: var(--ok-bg); color: var(--ok-fg); }
+ .badge.plain { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); margin-right: 0.3rem; }
+ .muted { color: var(--text-dim); font-size: 0.85rem; }
+ code, pre { background: var(--surface-2); border-radius: 6px; font-family: var(--mono); font-size: 0.86em; }
code { padding: 0.15rem 0.4rem; }
- pre { padding: 1rem; overflow-x: auto; border: 1px solid #2a2f3a; line-height: 1.5; }
+ pre { padding: 1rem; overflow-x: auto; border: 1px solid var(--border); line-height: 1.5; }
pre code { background: none; padding: 0; font-size: 1em; }
- section { margin-bottom: 2rem; }
.markdown-body { line-height: 1.65; font-size: 0.96rem; }
.markdown-body > *:first-child { margin-top: 0; }
- .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
- border-bottom: 1px solid #2a2f3a; padding-bottom: 0.4rem; margin: 1.8rem 0 1rem;
- }
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { border-bottom: 1px solid var(--border); padding-bottom: 0.4rem; margin: 1.8rem 0 1rem; }
.markdown-body h1 { font-size: 1.6rem; }
.markdown-body h2 { font-size: 1.3rem; }
.markdown-body h3 { font-size: 1.1rem; border-bottom: none; }
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre { margin: 0.9rem 0; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5rem; }
.markdown-body li + li { margin-top: 0.25rem; }
- .markdown-body a { color: #8ab4f8; }
+ .markdown-body a { color: var(--accent); }
.markdown-body img { max-width: 100%; }
- .markdown-body hr { border: none; border-top: 1px solid #2a2f3a; margin: 1.8rem 0; }
- .markdown-body blockquote {
- margin-left: 0; padding: 0.2rem 1rem; border-left: 3px solid #333944; color: #9aa1ac;
- }
+ .markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 1.8rem 0; }
+ .markdown-body blockquote { margin-left: 0; padding: 0.2rem 1rem; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
.markdown-body blockquote p { margin: 0.5rem 0; }
.markdown-body table { display: block; overflow-x: auto; border-collapse: collapse; width: auto; margin: 1rem 0; }
- .markdown-body th, .markdown-body td { border: 1px solid #2a2f3a; padding: 0.4rem 0.8rem; text-align: left; }
- .markdown-body th { background: #171a21; }
+ .markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; }
+ .markdown-body th { background: var(--surface-2); }
.markdown-body input[type="checkbox"] { width: auto; margin: 0 0.4em 0 0; }
- footer { max-width: 960px; margin: 2rem auto 1.5rem; padding: 0 1.5rem; }
- footer a { color: #6b7078; font-size: 0.78rem; text-decoration: none; }
- footer a:hover { color: #9aa1ac; }
+
+ footer { max-width: 980px; margin: 2rem auto 1.5rem; padding: 0 1rem; }
+ footer a { color: var(--text-faint); font-size: 0.78rem; text-decoration: none; }
+ footer a:hover { color: var(--text-dim); }
+
+ @media (max-width: 480px) {
+ main { padding: 1rem 0.85rem; }
+ form.card { max-width: none; }
+ }
</style>
</head>
<body>
-<header>
- <h1><a href="/">{{.BrandMark}} gitfed — {{.Domain}}</a></h1>
- <nav>
- <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a>
- {{if .LoggedIn}}
- <a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a>
- <a href="/settings"{{if eq .Active "settings"}} class="active"{{end}}>Settings</a>
- {{if .IsAdmin}}<a href="/admin"{{if eq .Active "admin"}} class="active"{{end}}>Admin</a>{{end}}
- {{end}}
- </nav>
- {{if .LoggedIn}}
- <span class="muted">{{.Username}}</span>
- <form method="post" action="/logout"><button class="linklike" type="submit">Log out</button></form>
- {{else}}
- <a href="/login">Log in</a>
- {{end}}
+<header class="gf-nav">
+ <a href="/" class="gf-nav-brand">{{.BrandMark}}<span>gitfed</span></a>
+ <button class="gf-nav-burger" id="navBurger" aria-label="Toggle menu" aria-expanded="false" aria-controls="navPanel">
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 5h14M3 10h14M3 15h14"/></svg>
+ </button>
+ <div class="gf-nav-panel" id="navPanel">
+ <nav class="gf-nav-links">
+ <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a>
+ {{if .LoggedIn}}
+ <a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a>
+ <a href="/settings"{{if eq .Active "settings"}} class="active"{{end}}>Settings</a>
+ {{if .IsAdmin}}<a href="/admin"{{if eq .Active "admin"}} class="active"{{end}}>Admin</a>{{end}}
+ {{end}}
+ </nav>
+ <form class="gf-nav-search" action="/search" method="get" role="search">
+ <div class="gf-nav-search-inner">
+ <svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><line x1="14" y1="14" x2="18" y2="18"/></svg>
+ <input type="search" name="q" id="navSearch" placeholder="Search repos…" value="{{.SearchQuery}}" autocomplete="off">
+ <kbd>⌘K</kbd>
+ </div>
+ </form>
+ <div class="gf-nav-right">
+ {{if .LoggedIn}}
+ <span class="gf-avatar" title="{{.Username}}">{{.Initials}}</span>
+ <form method="post" action="/logout"><button class="linklike" type="submit">Log out</button></form>
+ {{else}}
+ <a href="/login">Log in</a>
+ {{end}}
+ </div>
+ </div>
</header>
<main>
{{.Body}}
@@ -125,6 +230,33 @@ const shellSrc = `<!doctype html>
<footer>
<a href="/changelog">gitfed {{.Version}}</a>
</footer>
+<script>
+(function () {
+ var burger = document.getElementById('navBurger');
+ var panel = document.getElementById('navPanel');
+ if (burger && panel) {
+ burger.addEventListener('click', function () {
+ var open = panel.classList.toggle('open');
+ burger.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ }
+ document.addEventListener('keydown', function (e) {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+ var s = document.getElementById('navSearch');
+ if (s) { e.preventDefault(); s.focus(); }
+ }
+ });
+ document.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-copy]');
+ if (!btn || !navigator.clipboard) return;
+ navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () {
+ var orig = btn.textContent;
+ btn.textContent = '✓';
+ setTimeout(function () { btn.textContent = orig; }, 1200);
+ });
+ });
+})();
+</script>
</body>
</html>`
@@ -134,10 +266,31 @@ func (s *server) render(w http.ResponseWriter, r *http.Request, title, active st
sess, loggedIn := s.currentSession(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = shellTpl.Execute(w, struct {
- Title, Domain, Active, Username, Version string
- LoggedIn, IsAdmin bool
- Body, BrandMark template.HTML
- }{title, s.domain, active, sess.Username, version.Version, loggedIn, sess.IsAdmin, body, template.HTML(brandMark)})
+ Title, Domain, Active, Username, Version, Initials, SearchQuery string
+ LoggedIn, IsAdmin bool
+ Body, BrandMark template.HTML
+ }{title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), loggedIn, sess.IsAdmin, body, template.HTML(brandMark)})
+}
+
+// initials turns a username into a one-or-two-letter avatar label:
+// "bastien-mrq" -> "BM", "alice" -> "AL".
+func initials(username string) string {
+ var parts []string
+ for _, p := range strings.FieldsFunc(username, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
+ if p != "" {
+ parts = append(parts, p)
+ }
+ }
+ switch {
+ case len(parts) >= 2:
+ return strings.ToUpper(parts[0][:1] + parts[1][:1])
+ case len(parts) == 1 && len(parts[0]) >= 2:
+ return strings.ToUpper(parts[0][:2])
+ case len(parts) == 1:
+ return strings.ToUpper(parts[0])
+ default:
+ return "?"
+ }
}
// flash renders the ?msg=&err= query params (set by handlers that redirect
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go
index bb01302..62d5117 100644
--- a/cmd/gitfed-web/routes.go
+++ b/cmd/gitfed-web/routes.go
@@ -6,12 +6,12 @@ func (s *server) routes(mux *http.ServeMux) {
// Public — no login required.
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /r/{repo...}", s.handleRepoView)
- mux.HandleFunc("GET /repo-tree/{repo...}", s.handleRepoTree)
mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /changelog", s.handleChangelog)
+ mux.HandleFunc("GET /search", s.handleSearch)
// Self-service — any logged-in user, scoped to their own stuff via
// CheckAccess inside the handlers.
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index a81706c..fb954ca 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -50,6 +50,7 @@ type Ops interface {
ListRepoTags(name string) ([]string, error)
ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error)
GetRepoFile(name, path string) (content string, found bool, err error)
+ GetRepoBranch(name string) (branch string, found bool, err error)
GetACL(repoName string) (store.ACL, error)
GrantCollaborator(repoName, principal string, role store.Role) error
@@ -274,6 +275,15 @@ func (a *Admin) GetRepoFile(name, path string) (string, bool, error) {
return gitexec.ReadFileAtHEAD(repo.Path, path)
}
+func (a *Admin) GetRepoBranch(name string) (string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", false, err
+ }
+ branch, ok := gitexec.DefaultBranchName(repo.Path)
+ return branch, ok, nil
+}
+
// GrantCollaborator adds/updates a collaborator's role on a repo. If the
// principal belongs to a remote domain, it first resolves trust for that
// domain (§5.2/§6); for the whitelist policy this leaves the domain pending
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go
index 80a64b0..2254eab 100644
--- a/internal/adminrpc/client.go
+++ b/internal/adminrpc/client.go
@@ -184,6 +184,12 @@ func (c *Client) GetRepoFile(name, path string) (string, bool, error) {
return out.Content, out.Found, err
}
+func (c *Client) GetRepoBranch(name string) (string, bool, error) {
+ var out branchResult
+ err := c.call(methodGetRepoBranch, nameArgs{Name: name}, &out)
+ return out.Branch, out.Found, err
+}
+
func (c *Client) GetUser(username string) (store.User, error) {
var out userResult
err := c.call(methodGetUser, nameArgs{Name: username}, &out)
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go
index 678818e..34a1bf0 100644
--- a/internal/adminrpc/protocol.go
+++ b/internal/adminrpc/protocol.go
@@ -36,6 +36,7 @@ const (
methodListRepoTags = "ListRepoTags"
methodListRepoTree = "ListRepoTree"
methodGetRepoFile = "GetRepoFile"
+ methodGetRepoBranch = "GetRepoBranch"
methodGetUser = "GetUser"
methodSetUserAdmin = "SetUserAdmin"
methodSetPassword = "SetPassword"
@@ -142,6 +143,11 @@ type fileResult struct {
Found bool `json:"found"`
}
+type branchResult struct {
+ Branch string `json:"branch"`
+ Found bool `json:"found"`
+}
+
type userResult struct {
User store.User `json:"user"`
}
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go
index 319103a..a5a4092 100644
--- a/internal/adminrpc/server.go
+++ b/internal/adminrpc/server.go
@@ -217,6 +217,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
content, found, err := s.ops.GetRepoFile(a.Name, a.Path)
return fileResult{Content: content, Found: found}, err
+ case methodGetRepoBranch:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ branch, found, err := s.ops.GetRepoBranch(a.Name)
+ return branchResult{Branch: branch, Found: found}, err
+
case methodGetUser:
var a nameArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go
index c300b2a..2482b25 100644
--- a/internal/gitexec/gitexec.go
+++ b/internal/gitexec/gitexec.go
@@ -141,6 +141,24 @@ func resolveDefaultRef(repoPath string) (ref string, ok bool) {
return refs[0], true
}
+// DefaultBranchName returns the short branch name (e.g. "main") backing
+// resolveDefaultRef, for display purposes. ok is false under the same
+// conditions as resolveDefaultRef.
+func DefaultBranchName(repoPath string) (string, bool) {
+ ref, ok := resolveDefaultRef(repoPath)
+ if !ok {
+ return "", false
+ }
+ if ref == "HEAD" {
+ out, err := exec.Command("git", "--git-dir="+repoPath, "symbolic-ref", "--short", "HEAD").Output()
+ if err != nil {
+ return "", false
+ }
+ return strings.TrimSpace(string(out)), true
+ }
+ return strings.TrimPrefix(ref, "refs/heads/"), true
+}
+
// ReadFileAtHEAD returns the content of filename as it exists in the tree at
// the repo's default branch (see resolveDefaultRef). found is false (with a
// nil error) if the repo has no commits yet or the file doesn't exist there
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go
index 189e0cb..054d7cf 100644
--- a/internal/gitexec/gitexec_test.go
+++ b/internal/gitexec/gitexec_test.go
@@ -181,4 +181,46 @@ func TestPushToMismatchedDefaultBranch(t *testing.T) {
if !found || len(root) != 1 || root[0].Name != "README.md" {
t.Fatalf("ListTree: found=%v root=%+v, want [README.md]", found, root)
}
+
+ branch, ok := DefaultBranchName(barePath)
+ if !ok || branch != "trunk" {
+ t.Fatalf("DefaultBranchName: got %q ok=%v, want \"trunk\"", branch, ok)
+ }
+}
+
+func TestDefaultBranchNameViaHEAD(t *testing.T) {
+ tmp := t.TempDir()
+ barePath := filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+
+ work := filepath.Join(tmp, "work")
+ if err := os.Mkdir(work, 0755); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "init", "-q", "-b", "main")
+ if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", "f")
+ run(t, work, "git", "commit", "-q", "-m", "initial")
+ run(t, work, "git", "remote", "add", "origin", barePath)
+ run(t, work, "git", "push", "-q", "origin", "main")
+
+ branch, ok := DefaultBranchName(barePath)
+ if !ok || branch != "main" {
+ t.Fatalf("DefaultBranchName: got %q ok=%v, want \"main\"", branch, ok)
+ }
+}
+
+func TestDefaultBranchNameEmptyRepo(t *testing.T) {
+ tmp := t.TempDir()
+ barePath := filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+ if _, ok := DefaultBranchName(barePath); ok {
+ t.Fatal("empty repo should not report a default branch")
+ }
}