Skip to content
24 changes: 24 additions & 0 deletions internal/core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ var defaultKeybindings = map[string]Keybinding{
// Config holds the application configuration.
type Config struct {
Commands Commands `yaml:"commands"`
Git GitConfig `yaml:"git"`
GitPath string `yaml:"git_path"`
Keybindings map[string]Keybinding `yaml:"keybindings"`
Hooks []Hook `yaml:"hooks"`
DataDir string `yaml:"-"` // set by caller, not from config file
}

// GitConfig holds git-related configuration.
type GitConfig struct {
StatusWorkers int `yaml:"status_workers"`
}

// Hook defines setup commands for specific repositories.
type Hook struct {
// Pattern matches against remote URL (supports glob patterns).
Expand Down Expand Up @@ -67,6 +73,9 @@ func DefaultConfig() Config {
Spawn: []string{},
Recycle: []string{"git reset --hard", "git checkout main", "git pull"},
},
Git: GitConfig{
StatusWorkers: 3,
},
GitPath: "git",
Keybindings: map[string]Keybinding{},
}
Expand Down Expand Up @@ -97,13 +106,24 @@ func Load(configPath, dataDir string) (*Config, error) {
// Merge user keybindings into defaults (user config overrides defaults)
cfg.Keybindings = mergeKeybindings(defaultKeybindings, cfg.Keybindings)

// Apply defaults for zero values
cfg.applyDefaults()

if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}

return &cfg, nil
}

// applyDefaults sets default values for any unset configuration options.
func (c *Config) applyDefaults() {
defaults := DefaultConfig()
if c.Git.StatusWorkers == 0 {
c.Git.StatusWorkers = defaults.Git.StatusWorkers
}
}

// mergeKeybindings merges user keybindings into defaults.
// User keybindings override defaults for the same key.
func mergeKeybindings(defaults, user map[string]Keybinding) map[string]Keybinding {
Expand Down Expand Up @@ -132,6 +152,10 @@ func (c *Config) Validate() error {
return fmt.Errorf("data directory cannot be empty")
}

if c.Git.StatusWorkers < 1 {
return fmt.Errorf("git.status_workers must be at least 1")
}

for key, kb := range c.Keybindings {
if kb.Action == "" && kb.Sh == "" {
return fmt.Errorf("keybinding %q must have either action or sh", key)
Expand Down
105 changes: 105 additions & 0 deletions internal/core/git/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,108 @@ func (e *Executor) IsClean(ctx context.Context, dir string) (bool, error) {
}
return len(strings.TrimSpace(string(out))) == 0, nil
}

func (e *Executor) Branch(ctx context.Context, dir string) (string, error) {
// Try to get branch name first
out, err := e.exec.RunDir(ctx, dir, e.gitPath, "branch", "--show-current")
if err != nil {
return "", fmt.Errorf("git branch: %w", err)
}

branch := strings.TrimSpace(string(out))
if branch != "" {
return branch, nil
}

// Empty branch name means detached HEAD - get short commit SHA
out, err = e.exec.RunDir(ctx, dir, e.gitPath, "rev-parse", "--short", "HEAD")
if err != nil {
return "", fmt.Errorf("git rev-parse: %w", err)
}

return strings.TrimSpace(string(out)), nil
}

func (e *Executor) DefaultBranch(ctx context.Context, dir string) (string, error) {
// Get the default branch from origin's HEAD reference
out, err := e.exec.RunDir(ctx, dir, e.gitPath, "symbolic-ref", "refs/remotes/origin/HEAD", "--short")
if err != nil {
return "", fmt.Errorf("git symbolic-ref: %w", err)
}

// Output is "origin/main" or "origin/master", strip the "origin/" prefix
branch := strings.TrimSpace(string(out))
branch = strings.TrimPrefix(branch, "origin/")

return branch, nil
}

func (e *Executor) DiffStats(ctx context.Context, dir string) (additions, deletions int, err error) {
// Get the default branch to compare against
defaultBranch, err := e.DefaultBranch(ctx, dir)
if err != nil {
// Fallback to comparing against HEAD if we can't determine default branch
defaultBranch = "HEAD"
}

var out []byte
if defaultBranch == "HEAD" {
// Compare working directory against HEAD
out, err = e.exec.RunDir(ctx, dir, e.gitPath, "diff", "--shortstat", "HEAD")
} else {
// Compare current branch against default branch (e.g., main...HEAD)
out, err = e.exec.RunDir(ctx, dir, e.gitPath, "diff", "--shortstat", defaultBranch+"...HEAD")
}

if err != nil {
return 0, 0, fmt.Errorf("git diff: %w", err)
}

return parseDiffStats(string(out))
}

// parseDiffStats parses git diff --shortstat output.
// Example: " 3 files changed, 10 insertions(+), 5 deletions(-)"
func parseDiffStats(output string) (additions, deletions int, err error) {
output = strings.TrimSpace(output)
if output == "" {
return 0, 0, nil
}

// Parse insertions
if idx := strings.Index(output, "insertion"); idx != -1 {
// Find the number before "insertion"
start := strings.LastIndex(output[:idx], ",")
if start == -1 {
start = strings.LastIndex(output[:idx], "changed")
}
if start != -1 {
numStr := strings.TrimSpace(output[start+1 : idx])
numStr = strings.Fields(numStr)[0]
additions, _ = parseInt(numStr)
}
}

// Parse deletions
if idx := strings.Index(output, "deletion"); idx != -1 {
// Find the number before "deletion"
start := strings.LastIndex(output[:idx], ",")
if start != -1 {
numStr := strings.TrimSpace(output[start+1 : idx])
numStr = strings.Fields(numStr)[0]
deletions, _ = parseInt(numStr)
}
}

return additions, deletions, nil
}

func parseInt(s string) (int, error) {
var n int
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n, nil
}
Loading
Loading