Skip to content

Commit 614b4ec

Browse files
authored
feat: git status tracking (#6)
Fixes #4 ## Purpose Adds support to see git information in the hive status
1 parent 5bd0cd0 commit 614b4ec

11 files changed

Lines changed: 777 additions & 21 deletions

File tree

internal/core/config/config.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,18 @@ var defaultKeybindings = map[string]Keybinding{
3232
// Config holds the application configuration.
3333
type Config struct {
3434
Commands Commands `yaml:"commands"`
35+
Git GitConfig `yaml:"git"`
3536
GitPath string `yaml:"git_path"`
3637
Keybindings map[string]Keybinding `yaml:"keybindings"`
3738
Hooks []Hook `yaml:"hooks"`
3839
DataDir string `yaml:"-"` // set by caller, not from config file
3940
}
4041

42+
// GitConfig holds git-related configuration.
43+
type GitConfig struct {
44+
StatusWorkers int `yaml:"status_workers"`
45+
}
46+
4147
// Hook defines setup commands for specific repositories.
4248
type Hook struct {
4349
// Pattern matches against remote URL (supports glob patterns).
@@ -67,6 +73,9 @@ func DefaultConfig() Config {
6773
Spawn: []string{},
6874
Recycle: []string{"git reset --hard", "git checkout main", "git pull"},
6975
},
76+
Git: GitConfig{
77+
StatusWorkers: 3,
78+
},
7079
GitPath: "git",
7180
Keybindings: map[string]Keybinding{},
7281
}
@@ -97,13 +106,24 @@ func Load(configPath, dataDir string) (*Config, error) {
97106
// Merge user keybindings into defaults (user config overrides defaults)
98107
cfg.Keybindings = mergeKeybindings(defaultKeybindings, cfg.Keybindings)
99108

109+
// Apply defaults for zero values
110+
cfg.applyDefaults()
111+
100112
if err := cfg.Validate(); err != nil {
101113
return nil, fmt.Errorf("invalid config: %w", err)
102114
}
103115

104116
return &cfg, nil
105117
}
106118

119+
// applyDefaults sets default values for any unset configuration options.
120+
func (c *Config) applyDefaults() {
121+
defaults := DefaultConfig()
122+
if c.Git.StatusWorkers == 0 {
123+
c.Git.StatusWorkers = defaults.Git.StatusWorkers
124+
}
125+
}
126+
107127
// mergeKeybindings merges user keybindings into defaults.
108128
// User keybindings override defaults for the same key.
109129
func mergeKeybindings(defaults, user map[string]Keybinding) map[string]Keybinding {
@@ -132,6 +152,10 @@ func (c *Config) Validate() error {
132152
return fmt.Errorf("data directory cannot be empty")
133153
}
134154

155+
if c.Git.StatusWorkers < 1 {
156+
return fmt.Errorf("git.status_workers must be at least 1")
157+
}
158+
135159
for key, kb := range c.Keybindings {
136160
if kb.Action == "" && kb.Sh == "" {
137161
return fmt.Errorf("keybinding %q must have either action or sh", key)

internal/core/git/executor.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,108 @@ func (e *Executor) IsClean(ctx context.Context, dir string) (bool, error) {
6262
}
6363
return len(strings.TrimSpace(string(out))) == 0, nil
6464
}
65+
66+
func (e *Executor) Branch(ctx context.Context, dir string) (string, error) {
67+
// Try to get branch name first
68+
out, err := e.exec.RunDir(ctx, dir, e.gitPath, "branch", "--show-current")
69+
if err != nil {
70+
return "", fmt.Errorf("git branch: %w", err)
71+
}
72+
73+
branch := strings.TrimSpace(string(out))
74+
if branch != "" {
75+
return branch, nil
76+
}
77+
78+
// Empty branch name means detached HEAD - get short commit SHA
79+
out, err = e.exec.RunDir(ctx, dir, e.gitPath, "rev-parse", "--short", "HEAD")
80+
if err != nil {
81+
return "", fmt.Errorf("git rev-parse: %w", err)
82+
}
83+
84+
return strings.TrimSpace(string(out)), nil
85+
}
86+
87+
func (e *Executor) DefaultBranch(ctx context.Context, dir string) (string, error) {
88+
// Get the default branch from origin's HEAD reference
89+
out, err := e.exec.RunDir(ctx, dir, e.gitPath, "symbolic-ref", "refs/remotes/origin/HEAD", "--short")
90+
if err != nil {
91+
return "", fmt.Errorf("git symbolic-ref: %w", err)
92+
}
93+
94+
// Output is "origin/main" or "origin/master", strip the "origin/" prefix
95+
branch := strings.TrimSpace(string(out))
96+
branch = strings.TrimPrefix(branch, "origin/")
97+
98+
return branch, nil
99+
}
100+
101+
func (e *Executor) DiffStats(ctx context.Context, dir string) (additions, deletions int, err error) {
102+
// Get the default branch to compare against
103+
defaultBranch, err := e.DefaultBranch(ctx, dir)
104+
if err != nil {
105+
// Fallback to comparing against HEAD if we can't determine default branch
106+
defaultBranch = "HEAD"
107+
}
108+
109+
var out []byte
110+
if defaultBranch == "HEAD" {
111+
// Compare working directory against HEAD
112+
out, err = e.exec.RunDir(ctx, dir, e.gitPath, "diff", "--shortstat", "HEAD")
113+
} else {
114+
// Compare current branch against default branch (e.g., main...HEAD)
115+
out, err = e.exec.RunDir(ctx, dir, e.gitPath, "diff", "--shortstat", defaultBranch+"...HEAD")
116+
}
117+
118+
if err != nil {
119+
return 0, 0, fmt.Errorf("git diff: %w", err)
120+
}
121+
122+
return parseDiffStats(string(out))
123+
}
124+
125+
// parseDiffStats parses git diff --shortstat output.
126+
// Example: " 3 files changed, 10 insertions(+), 5 deletions(-)"
127+
func parseDiffStats(output string) (additions, deletions int, err error) {
128+
output = strings.TrimSpace(output)
129+
if output == "" {
130+
return 0, 0, nil
131+
}
132+
133+
// Parse insertions
134+
if idx := strings.Index(output, "insertion"); idx != -1 {
135+
// Find the number before "insertion"
136+
start := strings.LastIndex(output[:idx], ",")
137+
if start == -1 {
138+
start = strings.LastIndex(output[:idx], "changed")
139+
}
140+
if start != -1 {
141+
numStr := strings.TrimSpace(output[start+1 : idx])
142+
numStr = strings.Fields(numStr)[0]
143+
additions, _ = parseInt(numStr)
144+
}
145+
}
146+
147+
// Parse deletions
148+
if idx := strings.Index(output, "deletion"); idx != -1 {
149+
// Find the number before "deletion"
150+
start := strings.LastIndex(output[:idx], ",")
151+
if start != -1 {
152+
numStr := strings.TrimSpace(output[start+1 : idx])
153+
numStr = strings.Fields(numStr)[0]
154+
deletions, _ = parseInt(numStr)
155+
}
156+
}
157+
158+
return additions, deletions, nil
159+
}
160+
161+
func parseInt(s string) (int, error) {
162+
var n int
163+
for _, c := range s {
164+
if c >= '0' && c <= '9' {
165+
n = n*10 + int(c-'0')
166+
}
167+
}
168+
return n, nil
169+
}

0 commit comments

Comments
 (0)