diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-07 22:07:27 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-07 22:07:27 +0200 |
| commit | ebc50a6600fcf4ebc53df4846f790bb05757b3df (patch) | |
| tree | 64e66a0cbed140a75aa580fe9ba02d510b68601c | |
| parent | 545e65fe16c761822f0999b8f4ab05f1cd325975 (diff) | |
feat(sync): add throttled sync modev0.12.0
Introduce an opt-in throttle that skips inactive repos based on local
activity and per-repo cooldowns, and bump the version to 0.12.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
| -rw-r--r-- | AGENTS.md (renamed from AGENT.md) | 0 | ||||
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | internal/cli/flags.go | 2 | ||||
| -rw-r--r-- | internal/cli/sync_handlers.go | 171 | ||||
| -rw-r--r-- | internal/cli/throttle.go | 147 | ||||
| -rw-r--r-- | internal/cmd/sync.go | 3 | ||||
| -rw-r--r-- | internal/state/state.go | 48 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
8 files changed, 385 insertions, 1 deletions
@@ -28,6 +28,7 @@ It has been vibe coded mainly using AI tools (Claude Code CLI and amp). - Never deletes branches (only adds/updates) - GitHub token validation tool - Opt-in backup mode with --backup flag for resilient offline backups +- Opt-in sync throttling with --throttle based on local activity - AI-powered project showcase generation for documentation - Weekly batch run mode with --batch-run for automated synchronization @@ -104,6 +105,18 @@ gitsyncer sync repo myproject --no-ai-release-notes gitsyncer sync repo myproject --auto-create-releases ``` +#### Throttled sync +```bash +# Throttle syncing based on local activity in ~/git/<repo> +gitsyncer sync repo myproject --throttle + +# Throttle all public repo sync modes +gitsyncer sync bidirectional --throttle +gitsyncer sync codeberg-to-github --throttle +gitsyncer sync github-to-codeberg --throttle +``` +When `--throttle` is enabled, GitSyncer checks `~/git/<repo>` for commits in the last 7 days. If no recent commits are found (or the repo is missing locally), the repo sync is allowed only once per random interval between 60 and 120 days and the next allowed date is stored. Throttle state is stored in `.gitsyncer-state.json` in the work directory. + #### Sync all configured repositories ```bash gitsyncer sync all diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 5c6914c..bb00a39 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -36,6 +36,7 @@ type Flags struct { AIReleaseNotes bool UpdateReleases bool AITool string + Throttle bool // Internal fields for batch run state management (not set by flags) BatchRunStateManager *state.Manager @@ -73,6 +74,7 @@ func ParseFlags() *Flags { flag.BoolVar(&f.AutoCreateReleases, "auto-create-releases", false, "automatically create releases without confirmation prompts") flag.BoolVar(&f.AIReleaseNotes, "ai-release-notes", false, "generate release notes using AI (amp by default) based on git diff") flag.BoolVar(&f.UpdateReleases, "update-releases", false, "update existing releases with new AI-generated notes") + flag.BoolVar(&f.Throttle, "throttle", false, "enable throttled syncing based on local activity") flag.Parse() diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 5c0c9bf..3b079a8 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -8,11 +8,37 @@ import ( "codeberg.org/snonux/gitsyncer/internal/codeberg" "codeberg.org/snonux/gitsyncer/internal/config" "codeberg.org/snonux/gitsyncer/internal/github" + "codeberg.org/snonux/gitsyncer/internal/state" "codeberg.org/snonux/gitsyncer/internal/sync" ) // HandleSync handles syncing a single repository func HandleSync(cfg *config.Config, flags *Flags) int { + var throttleManager *state.Manager + var throttleState *state.State + if flags.Throttle { + manager, st, err := loadThrottleState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load throttle state: %v\n", err) + } + throttleManager = manager + throttleState = st + + decision := evaluateThrottle(flags.SyncRepo, throttleState, flags.DryRun) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.SetNextAllowed && throttleManager != nil && !flags.DryRun { + throttleState.SetNextRepoSyncAllowed(flags.SyncRepo, decision.NextAllowed) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } + if decision.Skip { + return 0 + } + } + // If create-github-repos is enabled, create the repo if needed if flags.CreateGitHubRepos { if err := createGitHubRepoIfNeeded(cfg, flags.SyncRepo); err != nil { @@ -35,6 +61,14 @@ func HandleSync(cfg *config.Config, flags *Flags) int { log.Fatal("Sync failed:", err) return 1 } + + if flags.Throttle && throttleManager != nil { + updateRepoSyncState(flags.SyncRepo, throttleState) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } + // Also sync descriptions for this single repository descCache := loadDescriptionCache(flags.WorkDir) syncRepoDescriptions(cfg, flags.DryRun, flags.SyncRepo, "", "", descCache) @@ -51,6 +85,17 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { return 1 } + var throttleManager *state.Manager + var throttleState *state.State + if flags.Throttle { + manager, st, err := loadThrottleState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load throttle state: %v\n", err) + } + throttleManager = manager + throttleState = st + } + // Initialize GitHub client if needed var githubClient github.Client var hasGithubClient bool @@ -80,6 +125,22 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { for i, repo := range cfg.Repositories { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(cfg.Repositories), repo) + if flags.Throttle { + decision := evaluateThrottle(repo, throttleState, flags.DryRun) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.SetNextAllowed && throttleManager != nil && !flags.DryRun { + throttleState.SetNextRepoSyncAllowed(repo, decision.NextAllowed) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } + if decision.Skip { + continue + } + } + // Create GitHub repo if needed if hasGithubClient { if err := createRepoWithClient(&githubClient, repo, fmt.Sprintf("Mirror of %s", repo)); err != nil { @@ -102,6 +163,12 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { fmt.Printf("Stopping sync due to error.\n") return 1 } + if flags.Throttle && throttleManager != nil { + updateRepoSyncState(repo, throttleState) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } successCount++ // Sync descriptions after repo sync syncRepoDescriptions(cfg, flags.DryRun, repo, "", "", descCache) @@ -178,6 +245,25 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { return 0 } + if flags.Throttle && flags.DryRun { + _, throttleState, err := loadThrottleState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load throttle state: %v\n", err) + } + filtered := make([]string, 0, len(repoNames)) + for _, name := range repoNames { + decision := evaluateThrottle(name, throttleState, true) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.Skip { + continue + } + filtered = append(filtered, name) + } + repoNames = filtered + } + // Show the repositories that will be synced showReposToSync(repoNames) @@ -228,6 +314,25 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int { return 0 } + if flags.Throttle && flags.DryRun { + _, throttleState, err := loadThrottleState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load throttle state: %v\n", err) + } + filtered := make([]string, 0, len(repoNames)) + for _, name := range repoNames { + decision := evaluateThrottle(name, throttleState, true) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.Skip { + continue + } + filtered = append(filtered, name) + } + repoNames = filtered + } + // Show the repositories that will be synced showReposToSync(repoNames) @@ -356,6 +461,17 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi syncer.SetBackupEnabled(flags.Backup) successCount := 0 + var throttleManager *state.Manager + var throttleState *state.State + if flags.Throttle { + manager, st, err := loadThrottleState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load throttle state: %v\n", err) + } + throttleManager = manager + throttleState = st + } + // Create map for descriptions repoMap := make(map[string]codeberg.Repository) for _, repo := range repos { @@ -365,6 +481,22 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi for i, repoName := range repoNames { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + if flags.Throttle { + decision := evaluateThrottle(repoName, throttleState, flags.DryRun) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.SetNextAllowed && throttleManager != nil && !flags.DryRun { + throttleState.SetNextRepoSyncAllowed(repoName, decision.NextAllowed) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } + if decision.Skip { + continue + } + } + // Create GitHub repo if needed if hasGithubClient && flags.CreateGitHubRepos { codebergRepo := repoMap[repoName] @@ -385,6 +517,12 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi fmt.Printf("Stopping sync due to error.\n") return 1 } + if flags.Throttle && throttleManager != nil { + updateRepoSyncState(repoName, throttleState) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } successCount++ // After syncing, sync descriptions according to precedence @@ -464,6 +602,17 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository syncer.SetBackupEnabled(flags.Backup) successCount := 0 + var throttleManager *state.Manager + var throttleState *state.State + if flags.Throttle { + manager, st, err := loadThrottleState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load throttle state: %v\n", err) + } + throttleManager = manager + throttleState = st + } + // Create map for descriptions repoMap := make(map[string]github.Repository) for _, repo := range repos { @@ -473,6 +622,22 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository for i, repoName := range repoNames { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + if flags.Throttle { + decision := evaluateThrottle(repoName, throttleState, flags.DryRun) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.SetNextAllowed && throttleManager != nil && !flags.DryRun { + throttleState.SetNextRepoSyncAllowed(repoName, decision.NextAllowed) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } + if decision.Skip { + continue + } + } + // Create Codeberg repo if needed if hasCodebergClient && flags.CreateCodebergRepos { githubRepo := repoMap[repoName] @@ -493,6 +658,12 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository fmt.Printf("Stopping sync due to error.\n") return 1 } + if flags.Throttle && throttleManager != nil { + updateRepoSyncState(repoName, throttleState) + if err := throttleManager.Save(throttleState); err != nil { + fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + } + } successCount++ // After syncing, sync descriptions according to precedence diff --git a/internal/cli/throttle.go b/internal/cli/throttle.go new file mode 100644 index 0000000..b48094e --- /dev/null +++ b/internal/cli/throttle.go @@ -0,0 +1,147 @@ +package cli + +import ( + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/gitsyncer/internal/state" +) + +const ( + throttleMinDays = 60 + throttleMaxDays = 120 + recentDays = 7 +) + +func loadThrottleState(workDir string) (*state.Manager, *state.State, error) { + manager := state.NewManager(workDir) + st, err := manager.Load() + if err != nil { + return manager, &state.State{}, err + } + if st == nil { + st = &state.State{} + } + return manager, st, nil +} + +type throttleDecision struct { + Skip bool + Message string + NextAllowed time.Time + SetNextAllowed bool +} + +func evaluateThrottle(repoName string, st *state.State, dryRun bool) throttleDecision { + syncAction := "Syncing" + if dryRun { + syncAction = "[DRY RUN] Would sync" + } + + recent, err := hasRecentLocalCommits(repoName) + if err != nil { + actionMsg := "Sync will proceed" + if dryRun { + actionMsg = "Sync would proceed" + } + return throttleDecision{ + Skip: false, + Message: fmt.Sprintf("Warning: failed to check local activity for %s: %v. %s.", repoName, err, actionMsg), + } + } + + if recent { + return throttleDecision{ + Skip: false, + Message: fmt.Sprintf("%s %s: recent local commits within last %d days.", syncAction, repoName, recentDays), + } + } + + now := time.Now() + if st == nil { + return throttleDecision{ + Skip: false, + Message: fmt.Sprintf("%s %s: no recent local commits; throttle state unavailable.", syncAction, repoName), + } + } + nextAllowed := st.GetNextRepoSyncAllowed(repoName) + skipAction := "Skipping" + if dryRun { + skipAction = "[DRY RUN] Would skip" + } + + if nextAllowed.IsZero() { + lastSync := st.GetLastRepoSync(repoName) + if !lastSync.IsZero() { + nextAllowed = lastSync.Add(randomThrottleDuration()) + } else { + nextAllowed = now.Add(randomThrottleDuration()) + } + return throttleDecision{ + Skip: true, + NextAllowed: nextAllowed, + SetNextAllowed: true, + Message: fmt.Sprintf("%s %s: no recent local commits; throttle window set until %s.", + skipAction, repoName, nextAllowed.Format("2006-01-02")), + } + } + + if now.Before(nextAllowed) { + return throttleDecision{ + Skip: true, + Message: fmt.Sprintf("%s %s: no recent local commits; next allowed sync at %s.", skipAction, repoName, nextAllowed.Format("2006-01-02")), + } + } + + return throttleDecision{ + Skip: false, + Message: fmt.Sprintf("%s %s: throttle window elapsed (next allowed was %s).", syncAction, repoName, nextAllowed.Format("2006-01-02")), + } +} + +func updateRepoSyncState(repoName string, st *state.State) { + if st == nil { + return + } + now := time.Now() + nextAllowed := now.Add(randomThrottleDuration()) + st.SetRepoSync(repoName, now, nextAllowed) +} + +func randomThrottleDuration() time.Duration { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + days := throttleMinDays + rng.Intn(throttleMaxDays-throttleMinDays+1) + return time.Duration(days) * 24 * time.Hour +} + +func hasRecentLocalCommits(repoName string) (bool, error) { + home, err := os.UserHomeDir() + if err != nil { + return false, fmt.Errorf("failed to resolve home directory: %w", err) + } + + repoPath := filepath.Join(home, "git", repoName) + info, err := os.Stat(repoPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to stat %s: %w", repoPath, err) + } + if !info.IsDir() { + return false, nil + } + + cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--since="+fmt.Sprintf("%d.days", recentDays), "--format=%ct") + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("git log failed for %s: %w", repoPath, err) + } + + return strings.TrimSpace(string(output)) != "", nil +} diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index a28f50d..df7aa5b 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -15,6 +15,7 @@ var ( autoCreate bool noAIReleaseNotes bool syncAITool string + throttle bool ) var syncCmd = &cobra.Command{ @@ -190,6 +191,7 @@ func init() { syncCmd.PersistentFlags().BoolVar(&autoCreate, "auto-create-releases", false, "automatically create releases without confirmation") syncCmd.PersistentFlags().BoolVar(&noAIReleaseNotes, "no-ai-release-notes", false, "disable AI-generated release notes (AI notes are enabled by default)") syncCmd.PersistentFlags().StringVar(&syncAITool, "ai-tool", "amp", "AI tool to use for release notes when auto-creating (amp, claude, aichat, or hexai; amp is tried first if available)") + syncCmd.PersistentFlags().BoolVar(&throttle, "throttle", false, "throttle syncing based on local repo activity") } func buildFlags() *cli.Flags { @@ -202,6 +204,7 @@ func buildFlags() *cli.Flags { AutoCreateReleases: autoCreate, AIReleaseNotes: !noAIReleaseNotes, AITool: syncAITool, + Throttle: throttle, CreateGitHubRepos: createRepos, CreateCodebergRepos: createRepos, } diff --git a/internal/state/state.go b/internal/state/state.go index c2a62ed..3288db1 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -11,6 +11,9 @@ import ( // State represents the persistent state of gitsyncer type State struct { LastBatchRun time.Time `json:"lastBatchRun"` + // Per-repo sync tracking for throttling + LastRepoSync map[string]time.Time `json:"lastRepoSync,omitempty"` + NextRepoSyncAllowed map[string]time.Time `json:"nextRepoSyncAllowed,omitempty"` } // Manager handles state persistence @@ -77,3 +80,48 @@ func (s *State) HasRunWithinWeek() bool { func (s *State) UpdateBatchRunTime() { s.LastBatchRun = time.Now() } + +// EnsureRepoMaps initializes per-repo maps if needed +func (s *State) EnsureRepoMaps() { + if s.LastRepoSync == nil { + s.LastRepoSync = make(map[string]time.Time) + } + if s.NextRepoSyncAllowed == nil { + s.NextRepoSyncAllowed = make(map[string]time.Time) + } +} + +// GetLastRepoSync returns the last sync time for a repo +func (s *State) GetLastRepoSync(repoName string) time.Time { + if s == nil || s.LastRepoSync == nil { + return time.Time{} + } + return s.LastRepoSync[repoName] +} + +// GetNextRepoSyncAllowed returns the next allowed sync time for a repo +func (s *State) GetNextRepoSyncAllowed(repoName string) time.Time { + if s == nil || s.NextRepoSyncAllowed == nil { + return time.Time{} + } + return s.NextRepoSyncAllowed[repoName] +} + +// SetRepoSync updates the last sync time and next allowed sync time for a repo +func (s *State) SetRepoSync(repoName string, lastSync time.Time, nextAllowed time.Time) { + if s == nil { + return + } + s.EnsureRepoMaps() + s.LastRepoSync[repoName] = lastSync + s.NextRepoSyncAllowed[repoName] = nextAllowed +} + +// SetNextRepoSyncAllowed updates only the next allowed sync time for a repo +func (s *State) SetNextRepoSyncAllowed(repoName string, nextAllowed time.Time) { + if s == nil { + return + } + s.EnsureRepoMaps() + s.NextRepoSyncAllowed[repoName] = nextAllowed +} diff --git a/internal/version/version.go b/internal/version/version.go index a5fc76b..31c3a5f 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( var ( // Version is the current version of gitsyncer -Version = "0.11.0" + Version = "0.12.0" // GitCommit is the git commit hash at build time GitCommit = "unknown" |
