Files
GitManager/internal/git/git.go
T
TBNilles 1a2ad98c33 Scaffold GitManager multi-repo dashboard
Runnable skeleton per AGENT.md: Echo server (/, /help, /healthz, /api/repos), read-only repo scanner with in-memory index, the internal/git boundary, the <repo-list> web component with design tokens, and dev tooling (Dockerfile, docker-compose, air, .env.example).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:38:59 -04:00

122 lines
3.5 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
}