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 reports whether g and other represent the same git state.
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}