diff options
Diffstat (limited to 'internal')
| -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 |
6 files changed, 372 insertions, 1 deletions
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" |
