// 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 ` 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: "\t (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 }