Add repo-detail panel and /api/repo endpoint

New <repo-detail> component listens for repo:select and shows a repo's remotes, local branches (current + upstream), and recent commits. Backed by GET /api/repo (restricted to indexed repos) and read-only git readers LocalBranches/RecentCommits/RemoteDetails via repos.BuildDetail. <repo-list> highlights the selection; page docks the two panels left/right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 16:43:01 -04:00
parent 1a2ad98c33
commit a192a05aaa
11 changed files with 393 additions and 5 deletions
+103
View File
@@ -119,3 +119,106 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
_, err := c.run(ctx, dir, "fetch", "--quiet", "--all")
return err
}
// Branch is a local branch and its upstream, if any.
type Branch struct {
Name string `json:"name"`
Current bool `json:"current"`
Upstream string `json:"upstream,omitempty"`
}
// Commit is a single log entry.
type Commit struct {
Short string `json:"short"`
Author string `json:"author"`
Date string `json:"date"`
Subject string `json:"subject"`
}
// Remote is a named remote and its fetch URL.
type Remote struct {
Name string `json:"name"`
URL string `json:"url"`
}
// LocalBranches lists local branches (refs/heads), flagging the current one and
// including each branch's upstream when set.
func (c *CLI) LocalBranches(ctx context.Context, dir string) ([]Branch, error) {
const format = "%(refname:short)%09%(HEAD)%09%(upstream:short)"
out, err := c.run(ctx, dir, "for-each-ref", "--format="+format, "refs/heads")
if err != nil {
return nil, err
}
var branches []Branch
for _, line := range splitLines(out) {
f := strings.Split(line, "\t")
if len(f) < 1 || f[0] == "" {
continue
}
b := Branch{Name: f[0]}
if len(f) > 1 {
b.Current = f[1] == "*"
}
if len(f) > 2 {
b.Upstream = f[2]
}
branches = append(branches, b)
}
return branches, nil
}
// RecentCommits returns the newest n commits reachable from HEAD.
func (c *CLI) RecentCommits(ctx context.Context, dir string, n int) ([]Commit, error) {
// Fields separated by TAB (%x09); records by newline.
const format = "%h%x09%an%x09%ad%x09%s"
out, err := c.run(ctx, dir, "log", "-n", strconv.Itoa(n), "--date=short", "--pretty=format:"+format)
if err != nil {
return nil, err
}
var commits []Commit
for _, line := range splitLines(out) {
f := strings.SplitN(line, "\t", 4)
if len(f) < 4 {
continue
}
commits = append(commits, Commit{Short: f[0], Author: f[1], Date: f[2], Subject: f[3]})
}
return commits, nil
}
// RemoteDetails returns each remote with its fetch URL.
func (c *CLI) RemoteDetails(ctx context.Context, dir string) ([]Remote, error) {
out, err := c.run(ctx, dir, "remote", "-v")
if err != nil {
return nil, err
}
seen := make(map[string]struct{})
var remotes []Remote
for _, line := range splitLines(out) {
// Format: "<name>\t<url> (fetch|push)"
f := strings.Fields(line)
if len(f) < 3 || f[2] != "(fetch)" {
continue
}
if _, ok := seen[f[0]]; ok {
continue
}
seen[f[0]] = struct{}{}
remotes = append(remotes, Remote{Name: f[0], URL: f[1]})
}
return remotes, nil
}
// splitLines splits on newlines, dropping empty lines.
func splitLines(s string) []string {
if s == "" {
return nil
}
var out []string
for _, line := range strings.Split(s, "\n") {
if line != "" {
out = append(out, line)
}
}
return out
}
+56
View File
@@ -0,0 +1,56 @@
package repos
import (
"context"
"time"
"gitmanager/internal/git"
)
// Detail is the enriched view of a single repository shown in the detail panel.
// It embeds the cached State and adds data fetched on demand via the git
// boundary (all read-only — AGENT.md §1.3).
type Detail struct {
State
Branches []git.Branch `json:"branches"`
Commits []git.Commit `json:"commits"`
RemoteDetails []git.Remote `json:"remoteDetails"`
}
// Get returns the cached State for a repo path, or false if it is not indexed.
func (i *Index) Get(path string) (State, bool) {
i.mu.RLock()
defer i.mu.RUnlock()
s, ok := i.byKey[path]
return s, ok
}
// BuildDetail enriches a cached State with branches, recent commits, and remote
// URLs. Errors on the enriching calls are non-fatal: whatever succeeds is
// returned, and the partial failure is recorded on Detail.Error.
func BuildDetail(ctx context.Context, g *git.CLI, base State) Detail {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
d := Detail{State: base}
if branches, err := g.LocalBranches(ctx, base.Path); err == nil {
d.Branches = branches
} else {
d.Error = err.Error()
}
if commits, err := g.RecentCommits(ctx, base.Path, 20); err == nil {
d.Commits = commits
} else if d.Error == "" {
d.Error = err.Error()
}
if remotes, err := g.RemoteDetails(ctx, base.Path); err == nil {
d.RemoteDetails = remotes
} else if d.Error == "" {
d.Error = err.Error()
}
return d
}