Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions internal/commands/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,17 @@ func SetupCompare(cmd *cobra.Command) (*CompareSetup, error) {

// Fetch first if enabled (only makes sense with a remote)
if userCfg.GetFetchForRepo(repoRoot) {
if err := fetchWithSpinner(cmd, repoRoot, remote); err != nil {
cmd.PrintErrf("Warning: failed to fetch from %s: %v\n", remote, err)
fetchInterval := userCfg.GetFetchIntervalForRepo(repoRoot)
lastFetch, _ := git.GetLastFetchTime(repoRoot, remote)
timeSinceLastFetch := time.Since(lastFetch)

if fetchInterval > 0 && timeSinceLastFetch < fetchInterval {
// Skip fetch - within interval
cmd.PrintErrf("Skipping fetch (last fetch %s ago)\n", formatDuration(timeSinceLastFetch))
} else {
if err := fetchWithSpinner(cmd, repoRoot, remote); err != nil {
cmd.PrintErrf("Warning: failed to fetch from %s: %v\n", remote, err)
}
}
}

Expand Down Expand Up @@ -125,6 +134,9 @@ func fetchWithSpinner(cmd *cobra.Command, repoRoot, remote string) error {
return err
}

// Record successful fetch time
_ = git.SetLastFetchTime(repoRoot, remote)

// Update remote HEAD
_ = git.UpdateRemoteHead(repoRoot, remote)

Expand All @@ -133,3 +145,19 @@ func fetchWithSpinner(cmd *cobra.Command, repoRoot, remote string) error {

return nil
}

// formatDuration formats a duration in a human-readable way
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
if minutes == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, minutes)
}
58 changes: 46 additions & 12 deletions internal/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"fmt"
"strings"
"time"

"github.com/agarcher/wt/internal/config"
"github.com/agarcher/wt/internal/userconfig"
Expand Down Expand Up @@ -32,20 +33,23 @@ var configCmd = &cobra.Command{
User settings are stored in ~/.config/wt/config.yaml

Configuration keys:
remote Remote to compare against (empty = local comparison)
fetch Auto-fetch before list/cleanup (only applies when remote is set)
remote Remote to compare against (empty = local comparison)
fetch Auto-fetch before list/cleanup (only applies when remote is set)
fetch_interval Minimum time between fetches (e.g., "5m", "1h"). Default: 5m

Examples:
wt config --list # List all settings
wt config --show-origin # Show where each value comes from
wt config fetch # Get the value of 'fetch'
wt config --global remote origin # Set global remote
wt config --global fetch true # Enable auto-fetch globally
wt config remote upstream # Set remote for current repo only
wt config --unset remote # Remove per-repo remote override

Note: 'fetch' only has an effect when 'remote' is set. If remote is empty,
comparisons are done against the local branch and fetch is ignored.`,
wt config --list # List all settings
wt config --show-origin # Show where each value comes from
wt config fetch # Get the value of 'fetch'
wt config --global remote origin # Set global remote
wt config --global fetch true # Enable auto-fetch globally
wt config --global fetch_interval 10m # Set fetch interval to 10 minutes
wt config remote upstream # Set remote for current repo only
wt config fetch_interval 0 # Disable fetch caching for this repo
wt config --unset remote # Remove per-repo remote override

Note: 'fetch' and 'fetch_interval' only have an effect when 'remote' is set.
If remote is empty, comparisons are done against the local branch.`,
RunE: runConfig,
}

Expand Down Expand Up @@ -102,6 +106,9 @@ func printConfigList(cmd *cobra.Command, cfg *userconfig.UserConfig) error {
// Only show fetch=false if remote is set (otherwise it's meaningless)
_, _ = fmt.Fprintf(out, "fetch = false (global)\n")
}
if cfg.FetchInterval != "" {
_, _ = fmt.Fprintf(out, "fetch_interval = %s (global)\n", cfg.FetchInterval)
}

// Print per-repo values
for repoPath, repoConfig := range cfg.Repos {
Expand All @@ -111,6 +118,9 @@ func printConfigList(cmd *cobra.Command, cfg *userconfig.UserConfig) error {
if repoConfig.Fetch != nil {
_, _ = fmt.Fprintf(out, "repos.%s.fetch = %v\n", repoPath, *repoConfig.Fetch)
}
if repoConfig.FetchInterval != nil {
_, _ = fmt.Fprintf(out, "repos.%s.fetch_interval = %s\n", repoPath, *repoConfig.FetchInterval)
}
}

return nil
Expand All @@ -131,6 +141,7 @@ func printConfigShowOrigin(cmd *cobra.Command, cfg *userconfig.UserConfig) error
if repoRoot != "" {
remote := cfg.GetRemoteForRepo(repoRoot)
fetch := cfg.GetFetchForRepo(repoRoot)
fetchInterval := cfg.GetFetchIntervalForRepo(repoRoot)

// Determine source of remote
if repoConfig, ok := cfg.Repos[repoRoot]; ok && repoConfig.Remote != "" {
Expand All @@ -150,6 +161,15 @@ func printConfigShowOrigin(cmd *cobra.Command, cfg *userconfig.UserConfig) error
_, _ = fmt.Fprintf(out, "fetch = %-21v (default)\n", false)
}

// Determine source of fetch_interval
if repoConfig, ok := cfg.Repos[repoRoot]; ok && repoConfig.FetchInterval != nil {
_, _ = fmt.Fprintf(out, "fetch_interval = %-14s %s (repos.%s)\n", *repoConfig.FetchInterval, configPath, repoRoot)
} else if cfg.FetchInterval != "" {
_, _ = fmt.Fprintf(out, "fetch_interval = %-14s %s (global)\n", cfg.FetchInterval, configPath)
} else {
_, _ = fmt.Fprintf(out, "fetch_interval = %-14s (default)\n", fetchInterval)
}

// Show repo's default_branch if set
if repoCfg, err := config.Load(repoRoot); err == nil && repoCfg.DefaultBranch != "" {
_, _ = fmt.Fprintf(out, "default_branch = %-14s .wt.yaml (repo)\n", repoCfg.DefaultBranch)
Expand All @@ -158,6 +178,11 @@ func printConfigShowOrigin(cmd *cobra.Command, cfg *userconfig.UserConfig) error
// Not in a repo, just show global values
_, _ = fmt.Fprintf(out, "remote = %-20s %s (global)\n", cfg.Remote, configPath)
_, _ = fmt.Fprintf(out, "fetch = %-21v %s (global)\n", cfg.Fetch, configPath)
fetchInterval := cfg.FetchInterval
if fetchInterval == "" {
fetchInterval = userconfig.DefaultFetchInterval
}
_, _ = fmt.Fprintf(out, "fetch_interval = %-14s %s (global)\n", fetchInterval, configPath)
}

return nil
Expand Down Expand Up @@ -188,6 +213,8 @@ func getConfig(cmd *cobra.Command, cfg *userconfig.UserConfig, key string) error
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cfg.GetRemoteForRepo(repoRoot))
case "fetch":
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cfg.GetFetchForRepo(repoRoot))
case "fetch_interval":
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cfg.GetFetchIntervalForRepo(repoRoot))
}
}

Expand All @@ -205,6 +232,13 @@ func setConfig(cmd *cobra.Command, cfg *userconfig.UserConfig, key, value string
return fmt.Errorf("fetch must be 'true' or 'false'")
}

// Validate fetch_interval value (must be a valid duration)
if key == "fetch_interval" {
if _, err := time.ParseDuration(value); err != nil {
return fmt.Errorf("fetch_interval must be a valid duration (e.g., '5m', '1h', '30s')")
}
}

if configGlobal {
// Set global value
if err := cfg.SetGlobal(key, value); err != nil {
Expand Down
16 changes: 14 additions & 2 deletions internal/commands/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/agarcher/wt/internal/config"
"github.com/agarcher/wt/internal/git"
Expand Down Expand Up @@ -146,8 +147,19 @@ func runDelete(cmd *cobra.Command, args []string) error {
remoteRef := remote + "/" + comparisonBranch

if userCfg.GetFetchForRepo(repoRoot) {
if err := git.FetchRemoteQuiet(repoRoot, remote); err != nil {
cmd.PrintErrf("Warning: failed to fetch from %s: %v\n", remote, err)
fetchInterval := userCfg.GetFetchIntervalForRepo(repoRoot)
lastFetch, _ := git.GetLastFetchTime(repoRoot, remote)
timeSinceLastFetch := time.Since(lastFetch)

if fetchInterval > 0 && timeSinceLastFetch < fetchInterval {
// Skip fetch - within interval
cmd.PrintErrf("Skipping fetch (last fetch %s ago)\n", formatDuration(timeSinceLastFetch))
} else {
if err := git.FetchRemoteQuiet(repoRoot, remote); err != nil {
cmd.PrintErrf("Warning: failed to fetch from %s: %v\n", remote, err)
} else {
_ = git.SetLastFetchTime(repoRoot, remote)
}
}
}

Expand Down
21 changes: 21 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,27 @@ func UpdateRemoteHead(repoRoot, remote string) error {
return cmd.Run()
}

// GetLastFetchTime returns the time of the last fetch for a remote
func GetLastFetchTime(repoRoot, remote string) (time.Time, error) {
fetchFile := filepath.Join(repoRoot, ".git", "wt-last-fetch-"+remote)
data, err := os.ReadFile(fetchFile)
if err != nil {
return time.Time{}, err
}
timestamp, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(timestamp, 0), nil
}

// SetLastFetchTime records the current time as the last fetch time for a remote
func SetLastFetchTime(repoRoot, remote string) error {
fetchFile := filepath.Join(repoRoot, ".git", "wt-last-fetch-"+remote)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
return os.WriteFile(fetchFile, []byte(timestamp+"\n"), 0644)
}

// GetDefaultBranch returns the default branch name (main or master)
func GetDefaultBranch(repoRoot string) (string, error) {
// Try to get the default branch from remote
Expand Down
57 changes: 50 additions & 7 deletions internal/userconfig/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"

"gopkg.in/yaml.v3"
)
Expand All @@ -17,8 +18,9 @@ const (

// RepoConfig holds per-repository user settings
type RepoConfig struct {
Remote string `yaml:"remote,omitempty"`
Fetch *bool `yaml:"fetch,omitempty"` // pointer to distinguish unset from false
Remote string `yaml:"remote,omitempty"`
Fetch *bool `yaml:"fetch,omitempty"` // pointer to distinguish unset from false
FetchInterval *string `yaml:"fetch_interval,omitempty"` // pointer to distinguish unset from empty
}

// UserConfig holds user-level configuration
Expand All @@ -27,16 +29,22 @@ type UserConfig struct {
Remote string `yaml:"remote,omitempty"`
// Fetch enables auto-fetch before list/cleanup (only applies when remote is set)
Fetch bool `yaml:"fetch,omitempty"`
// FetchInterval is the minimum time between fetches (e.g., "5m", "1h")
FetchInterval string `yaml:"fetch_interval,omitempty"`
// Repos holds per-repository overrides keyed by absolute repo path
Repos map[string]RepoConfig `yaml:"repos,omitempty"`
}

// DefaultFetchInterval is the default minimum time between fetches
const DefaultFetchInterval = "5m"

// DefaultUserConfig returns a config with default values
func DefaultUserConfig() *UserConfig {
return &UserConfig{
Remote: "", // default to local comparison
Fetch: false, // no automatic network calls
Repos: make(map[string]RepoConfig),
Remote: "", // default to local comparison
Fetch: false, // no automatic network calls
FetchInterval: DefaultFetchInterval, // default 5 minutes between fetches
Repos: make(map[string]RepoConfig),
}
}

Expand Down Expand Up @@ -148,13 +156,33 @@ func (c *UserConfig) GetFetchForRepo(repoPath string) bool {
return c.Fetch
}

// GetFetchIntervalForRepo returns the effective fetch interval for a given repo path
// Returns per-repo override if set, otherwise global default, otherwise DefaultFetchInterval
func (c *UserConfig) GetFetchIntervalForRepo(repoPath string) time.Duration {
intervalStr := c.FetchInterval
if intervalStr == "" {
intervalStr = DefaultFetchInterval
}

// Check for per-repo override
if repoConfig, ok := c.Repos[repoPath]; ok && repoConfig.FetchInterval != nil {
intervalStr = *repoConfig.FetchInterval
}

// Parse duration, return 0 on error (which means always fetch)
d, _ := time.ParseDuration(intervalStr)
return d
}

// SetGlobal sets a global config value
func (c *UserConfig) SetGlobal(key, value string) error {
switch key {
case "remote":
c.Remote = value
case "fetch":
c.Fetch = value == "true"
case "fetch_interval":
c.FetchInterval = value
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand All @@ -168,6 +196,8 @@ func (c *UserConfig) UnsetGlobal(key string) error {
c.Remote = ""
case "fetch":
c.Fetch = false
case "fetch_interval":
c.FetchInterval = ""
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand All @@ -188,6 +218,8 @@ func (c *UserConfig) SetForRepo(repoPath, key, value string) error {
case "fetch":
fetchVal := value == "true"
repoConfig.Fetch = &fetchVal
case "fetch_interval":
repoConfig.FetchInterval = &value
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand All @@ -212,12 +244,14 @@ func (c *UserConfig) UnsetForRepo(repoPath, key string) error {
repoConfig.Remote = ""
case "fetch":
repoConfig.Fetch = nil
case "fetch_interval":
repoConfig.FetchInterval = nil
default:
return fmt.Errorf("unknown config key: %s", key)
}

// If repo config is now empty, remove it entirely
if repoConfig.Remote == "" && repoConfig.Fetch == nil {
if repoConfig.Remote == "" && repoConfig.Fetch == nil && repoConfig.FetchInterval == nil {
delete(c.Repos, repoPath)
} else {
c.Repos[repoPath] = repoConfig
Expand All @@ -236,6 +270,11 @@ func (c *UserConfig) GetGlobal(key string) (string, error) {
return "true", nil
}
return "false", nil
case "fetch_interval":
if c.FetchInterval != "" {
return c.FetchInterval, nil
}
return DefaultFetchInterval, nil
default:
return "", fmt.Errorf("unknown config key: %s", key)
}
Expand All @@ -261,12 +300,16 @@ func (c *UserConfig) GetForRepo(repoPath, key string) (string, bool) {
}
return "false", true
}
case "fetch_interval":
if repoConfig.FetchInterval != nil {
return *repoConfig.FetchInterval, true
}
}

return "", false
}

// ValidKeys returns the list of valid configuration keys
func ValidKeys() []string {
return []string{"remote", "fetch"}
return []string{"remote", "fetch", "fetch_interval"}
}
6 changes: 3 additions & 3 deletions internal/userconfig/userconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,11 @@ func TestLoadNonexistent(t *testing.T) {

func TestValidKeys(t *testing.T) {
keys := ValidKeys()
if len(keys) != 2 {
t.Errorf("expected 2 valid keys, got %d", len(keys))
if len(keys) != 3 {
t.Errorf("expected 3 valid keys, got %d", len(keys))
}

expected := map[string]bool{"remote": true, "fetch": true}
expected := map[string]bool{"remote": true, "fetch": true, "fetch_interval": true}
for _, key := range keys {
if !expected[key] {
t.Errorf("unexpected key: %s", key)
Expand Down
Loading