summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-07 22:07:27 +0200
committerPaul Buetow <paul@buetow.org>2026-02-07 22:07:27 +0200
commitebc50a6600fcf4ebc53df4846f790bb05757b3df (patch)
tree64e66a0cbed140a75aa580fe9ba02d510b68601c
parent545e65fe16c761822f0999b8f4ab05f1cd325975 (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.md13
-rw-r--r--internal/cli/flags.go2
-rw-r--r--internal/cli/sync_handlers.go171
-rw-r--r--internal/cli/throttle.go147
-rw-r--r--internal/cmd/sync.go3
-rw-r--r--internal/state/state.go48
-rw-r--r--internal/version/version.go2
8 files changed, 385 insertions, 1 deletions
diff --git a/AGENT.md b/AGENTS.md
index 354820a..354820a 100644
--- a/AGENT.md
+++ b/AGENTS.md
diff --git a/README.md b/README.md
index 38c9e85..13175d1 100644
--- a/README.md
+++ b/README.md
@@ -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"