a192a05aaa
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>
225 lines
6.2 KiB
Go
225 lines
6.2 KiB
Go
// Package git is THE boundary for every Git operation (AGENT.md §1.3). All Git
|
|
// access — read and, later, write — goes through here by shelling out to the
|
|
// system `git` binary via os/exec, so the user's credential helpers, SSH keys,
|
|
// hooks, and config apply exactly.
|
|
//
|
|
// The methods below are all READ-ONLY. Mutating operations (checkout, commit,
|
|
// push, …) will be added here as they are built; destructive ones must obey
|
|
// AGENT.md §1.4 (explicit, confirmed, never automatic, never a default).
|
|
package git
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// CLI runs Git commands via the system binary.
|
|
type CLI struct {
|
|
Bin string // path to git; "git" resolves from PATH
|
|
}
|
|
|
|
// New returns a CLI using the given binary (defaults to "git").
|
|
func New(bin string) *CLI {
|
|
if bin == "" {
|
|
bin = "git"
|
|
}
|
|
return &CLI{Bin: bin}
|
|
}
|
|
|
|
// run executes `git <args...>` in dir and returns trimmed stdout. On failure it
|
|
// returns an error whose message includes stderr.
|
|
func (c *CLI) run(ctx context.Context, dir string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, c.Bin, args...)
|
|
cmd.Dir = dir
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
return "", &Error{Args: args, Stderr: msg, Err: err}
|
|
}
|
|
return "", &Error{Args: args, Err: err}
|
|
}
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// Error describes a failed git invocation.
|
|
type Error struct {
|
|
Args []string
|
|
Stderr string
|
|
Err error
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
s := "git " + strings.Join(e.Args, " ") + ": " + e.Err.Error()
|
|
if e.Stderr != "" {
|
|
s += ": " + e.Stderr
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (e *Error) Unwrap() error { return e.Err }
|
|
|
|
// Version returns the installed git version string.
|
|
func (c *CLI) Version(ctx context.Context) (string, error) {
|
|
return c.run(ctx, "", "version")
|
|
}
|
|
|
|
// CurrentBranch returns the checked-out branch, or "HEAD" when detached.
|
|
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
|
|
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
}
|
|
|
|
// IsDirty reports whether the working tree has staged or unstaged changes.
|
|
func (c *CLI) IsDirty(ctx context.Context, dir string) (bool, error) {
|
|
out, err := c.run(ctx, dir, "status", "--porcelain")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return out != "", nil
|
|
}
|
|
|
|
// AheadBehind returns how many commits HEAD is ahead of and behind its upstream.
|
|
// Both are 0 with no error when there is no configured upstream.
|
|
func (c *CLI) AheadBehind(ctx context.Context, dir string) (ahead, behind int, err error) {
|
|
out, err := c.run(ctx, dir, "rev-list", "--left-right", "--count", "@{u}...HEAD")
|
|
if err != nil {
|
|
// No upstream is a normal state, not a failure to report.
|
|
return 0, 0, nil
|
|
}
|
|
fields := strings.Fields(out)
|
|
if len(fields) != 2 {
|
|
return 0, 0, nil
|
|
}
|
|
behind, _ = strconv.Atoi(fields[0])
|
|
ahead, _ = strconv.Atoi(fields[1])
|
|
return ahead, behind, nil
|
|
}
|
|
|
|
// Remotes returns the configured remote names.
|
|
func (c *CLI) Remotes(ctx context.Context, dir string) ([]string, error) {
|
|
out, err := c.run(ctx, dir, "remote")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == "" {
|
|
return nil, nil
|
|
}
|
|
return strings.Split(out, "\n"), nil
|
|
}
|
|
|
|
// Fetch updates remote-tracking refs. It does not modify the working tree, but
|
|
// it does touch the network, so the scanner only calls it when explicitly
|
|
// enabled (AGENT.md §5).
|
|
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
|
|
}
|