From 1763ce4c9a8e5ef2981875723e806db8e908ab8e Mon Sep 17 00:00:00 2001 From: Adam Archer Date: Wed, 14 Jan 2026 20:43:00 -0500 Subject: [PATCH] Add fetch_interval config to prevent repeated fetches Adds a configurable minimum interval between fetches (default: 5m). When a fetch was performed recently, subsequent commands skip fetching and print "Skipping fetch (last fetch Xs ago)" instead. - fetch_interval config option with Go duration format (e.g., "5m", "1h") - Last fetch time stored per-remote in .git/wt-last-fetch- - Setting interval to "0" disables caching (always fetch) - Works in list, cleanup, and delete commands Co-Authored-By: Claude Opus 4.5 --- internal/commands/compare.go | 32 +++++++++++++- internal/commands/config.go | 58 ++++++++++++++++++++------ internal/commands/delete.go | 16 ++++++- internal/git/git.go | 21 ++++++++++ internal/userconfig/userconfig.go | 57 +++++++++++++++++++++---- internal/userconfig/userconfig_test.go | 6 +-- 6 files changed, 164 insertions(+), 26 deletions(-) diff --git a/internal/commands/compare.go b/internal/commands/compare.go index b6d3402..d2f979d 100644 --- a/internal/commands/compare.go +++ b/internal/commands/compare.go @@ -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) + } } } @@ -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) @@ -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) +} diff --git a/internal/commands/config.go b/internal/commands/config.go index c3cd388..2f2868d 100644 --- a/internal/commands/config.go +++ b/internal/commands/config.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "strings" + "time" "github.com/agarcher/wt/internal/config" "github.com/agarcher/wt/internal/userconfig" @@ -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, } @@ -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 { @@ -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 @@ -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 != "" { @@ -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) @@ -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 @@ -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)) } } @@ -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 { diff --git a/internal/commands/delete.go b/internal/commands/delete.go index 9709662..c52e498 100644 --- a/internal/commands/delete.go +++ b/internal/commands/delete.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/agarcher/wt/internal/config" "github.com/agarcher/wt/internal/git" @@ -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) + } } } diff --git a/internal/git/git.go b/internal/git/git.go index 6d983b6..f10e68f 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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 diff --git a/internal/userconfig/userconfig.go b/internal/userconfig/userconfig.go index cb217df..8c1ce5d 100644 --- a/internal/userconfig/userconfig.go +++ b/internal/userconfig/userconfig.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "gopkg.in/yaml.v3" ) @@ -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 @@ -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), } } @@ -148,6 +156,24 @@ 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 { @@ -155,6 +181,8 @@ func (c *UserConfig) SetGlobal(key, value string) error { c.Remote = value case "fetch": c.Fetch = value == "true" + case "fetch_interval": + c.FetchInterval = value default: return fmt.Errorf("unknown config key: %s", key) } @@ -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) } @@ -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) } @@ -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 @@ -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) } @@ -261,6 +300,10 @@ 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 @@ -268,5 +311,5 @@ func (c *UserConfig) GetForRepo(repoPath, key string) (string, bool) { // ValidKeys returns the list of valid configuration keys func ValidKeys() []string { - return []string{"remote", "fetch"} + return []string{"remote", "fetch", "fetch_interval"} } diff --git a/internal/userconfig/userconfig_test.go b/internal/userconfig/userconfig_test.go index 46c2fc2..f6ad90e 100644 --- a/internal/userconfig/userconfig_test.go +++ b/internal/userconfig/userconfig_test.go @@ -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)