gitstate.go

  1// Package gitstate provides utilities for tracking git repository state.
  2package gitstate
  3
  4import (
  5	"os"
  6	"os/exec"
  7	"strings"
  8)
  9
 10// GitState represents the current state of a git repository.
 11type GitState struct {
 12	// Worktree is the absolute path to the worktree root.
 13	// For regular repos, this is the same as the git root.
 14	// For worktrees, this is the worktree directory.
 15	Worktree string
 16
 17	// Branch is the current branch name, or empty if detached HEAD.
 18	Branch string
 19
 20	// Commit is the current commit hash (short form).
 21	Commit string
 22
 23	// Subject is the commit message subject line.
 24	Subject string
 25
 26	// IsRepo is true if the directory is inside a git repository.
 27	IsRepo bool
 28}
 29
 30// GetGitState returns the git state for the given directory.
 31// If dir is empty, uses the current working directory.
 32func GetGitState(dir string) *GitState {
 33	state := &GitState{}
 34
 35	// Get the worktree root (this works for both regular repos and worktrees)
 36	cmd := exec.Command("git", "rev-parse", "--show-toplevel")
 37	if dir != "" {
 38		cmd.Dir = dir
 39	}
 40	output, err := cmd.Output()
 41	if err != nil {
 42		// Not in a git repository
 43		return state
 44	}
 45	state.IsRepo = true
 46	state.Worktree = strings.TrimSpace(string(output))
 47
 48	// Get the current commit hash (short form)
 49	cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
 50	if dir != "" {
 51		cmd.Dir = dir
 52	}
 53	output, err = cmd.Output()
 54	if err == nil {
 55		state.Commit = strings.TrimSpace(string(output))
 56	}
 57
 58	// Get the commit subject line
 59	cmd = exec.Command("git", "log", "-1", "--format=%s")
 60	if dir != "" {
 61		cmd.Dir = dir
 62	}
 63	output, err = cmd.Output()
 64	if err == nil {
 65		state.Subject = strings.TrimSpace(string(output))
 66	}
 67
 68	// Get the current branch name
 69	// First try symbolic-ref for normal branches
 70	cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD")
 71	if dir != "" {
 72		cmd.Dir = dir
 73	}
 74	output, err = cmd.Output()
 75	if err == nil {
 76		state.Branch = strings.TrimSpace(string(output))
 77	}
 78	// If symbolic-ref fails, we're in detached HEAD state - branch stays empty
 79
 80	return state
 81}
 82
 83// Equal returns true if two git states are equal.
 84func (g *GitState) Equal(other *GitState) bool {
 85	if g == nil && other == nil {
 86		return true
 87	}
 88	if g == nil || other == nil {
 89		return false
 90	}
 91	return g.Worktree == other.Worktree &&
 92		g.Branch == other.Branch &&
 93		g.Commit == other.Commit &&
 94		g.Subject == other.Subject &&
 95		g.IsRepo == other.IsRepo
 96}
 97
 98// tildeReplace replaces the home directory prefix with ~ for display.
 99func tildeReplace(path string) string {
100	if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(path, home) {
101		return "~" + path[len(home):]
102	}
103	return path
104}
105
106// String returns a human-readable description of the git state change.
107// It's designed to be shown to users, not the LLM.
108func (g *GitState) String() string {
109	if g == nil || !g.IsRepo {
110		return ""
111	}
112
113	worktreePath := tildeReplace(g.Worktree)
114	subject := g.Subject
115	if len(subject) > 50 {
116		subject = subject[:47] + "..."
117	}
118
119	if g.Branch != "" {
120		return worktreePath + " (" + g.Branch + ") now at " + g.Commit + " \"" + subject + "\""
121	}
122	return worktreePath + " (detached) now at " + g.Commit + " \"" + subject + "\""
123}