From ad84bcb992ba0552d582f8a6d53ac330f799a955 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 11 Mar 2026 18:49:14 +0200 Subject: feat(sync): enforce daily repo sync intervals --- README.md | 8 +- internal/cli/flags.go | 2 +- internal/cli/sync_handlers.go | 201 ++++++++++++++++++------------------------ internal/cli/throttle.go | 78 ++++++++++++---- internal/cli/throttle_test.go | 108 +++++++++++++++++++++++ internal/cmd/sync.go | 6 ++ internal/state/state.go | 19 +++- 7 files changed, 289 insertions(+), 133 deletions(-) create mode 100644 internal/cli/throttle_test.go diff --git a/README.md b/README.md index 6529d13..aeccbee 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 +- Default once-daily sync limit with --force override - 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 @@ -98,6 +99,9 @@ gitsyncer sync repo myproject --backup # Preview what would be synced gitsyncer sync repo myproject --dry-run +# Override sync interval checks +gitsyncer sync repo myproject --force + # Sync without AI-generated release notes gitsyncer sync repo myproject --no-ai-release-notes @@ -105,6 +109,8 @@ gitsyncer sync repo myproject --no-ai-release-notes gitsyncer sync repo myproject --auto-create-releases ``` +Each repository is synced at most once every 24 hours by default. Successful sync times are stored in `.gitsyncer-state.json` inside the work directory. Use `--force` to bypass sync interval checks. + #### Throttled sync ```bash # Throttle syncing based on local activity in ~/git/ @@ -115,7 +121,7 @@ gitsyncer sync bidirectional --throttle gitsyncer sync codeberg-to-github --throttle gitsyncer sync github-to-codeberg --throttle ``` -When `--throttle` is enabled, GitSyncer checks `~/git/` 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. +When `--throttle` is enabled, GitSyncer still applies the default once-daily limit first, then checks `~/git/` 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. Sync state is stored in `.gitsyncer-state.json` in the work directory. Use `--force` to bypass both interval checks. #### Sync all configured repositories ```bash diff --git a/internal/cli/flags.go b/internal/cli/flags.go index bb00a39..8c69797 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -67,7 +67,7 @@ func ParseFlags() *Flags { flag.StringVar(&f.DeleteRepo, "delete-repo", "", "delete specified repository from all configured organizations (with confirmation)") flag.BoolVar(&f.Backup, "backup", false, "enable syncing to backup locations") flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using AI (amp by default) after syncing") - flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data") + flag.BoolVar(&f.Force, "force", false, "force operations even when cache or sync interval checks would skip work") flag.BoolVar(&f.BatchRun, "batch-run", false, "enable --full and --showcase (runs only once per week)") flag.BoolVar(&f.CheckReleases, "check-releases", false, "manually check for version tags without releases and create them (with confirmation)") flag.BoolVar(&f.NoCheckReleases, "no-check-releases", false, "disable automatic release checking after sync operations") diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 8fb3a93..6a96b92 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -14,30 +14,24 @@ import ( // 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 + stateManager, syncState, err := loadSyncState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load sync state: %v\n", err) + } - 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 + decision := evaluateSyncPolicy(flags.SyncRepo, syncState, flags.DryRun, flags.Force, flags.Throttle) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.SetNextAllowed && stateManager != nil && !flags.DryRun { + syncState.SetNextRepoSyncAllowed(flags.SyncRepo, decision.NextAllowed) + if err := stateManager.Save(syncState); err != nil { + fmt.Printf("Warning: Failed to save sync state: %v\n", err) } } + if decision.Skip { + return 0 + } // If create-github-repos is enabled, create the repo if needed if flags.CreateGitHubRepos { @@ -62,10 +56,10 @@ func HandleSync(cfg *config.Config, flags *Flags) int { 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) + if stateManager != nil { + recordRepoSync(flags.SyncRepo, syncState, flags.Throttle) + if err := stateManager.Save(syncState); err != nil { + fmt.Printf("Warning: Failed to save sync state: %v\n", err) } } @@ -87,15 +81,9 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { repoNames := shuffledRepoNames(cfg.Repositories) - 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 + stateManager, syncState, err := loadSyncState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load sync state: %v\n", err) } // Initialize GitHub client if needed @@ -127,21 +115,19 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { for i, repo := range repoNames { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), 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 + decision := evaluateSyncPolicy(repo, syncState, flags.DryRun, flags.Force, flags.Throttle) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.SetNextAllowed && stateManager != nil && !flags.DryRun { + syncState.SetNextRepoSyncAllowed(repo, decision.NextAllowed) + if err := stateManager.Save(syncState); err != nil { + fmt.Printf("Warning: Failed to save sync state: %v\n", err) } } + if decision.Skip { + continue + } // Create GitHub repo if needed if hasGithubClient { @@ -165,10 +151,10 @@ 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) + if stateManager != nil { + recordRepoSync(repo, syncState, flags.Throttle) + if err := stateManager.Save(syncState); err != nil { + fmt.Printf("Warning: Failed to save sync state: %v\n", err) } } successCount++ @@ -223,23 +209,8 @@ 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 + if flags.DryRun { + repoNames = filterDryRunRepoNames(repoNames, flags) } repoNames = shuffledRepoNames(repoNames) @@ -295,23 +266,8 @@ 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 + if flags.DryRun { + repoNames = filterDryRunRepoNames(repoNames, flags) } repoNames = shuffledRepoNames(repoNames) @@ -418,6 +374,27 @@ func showReposToSync(repoNames []string) { } } +func filterDryRunRepoNames(repoNames []string, flags *Flags) []string { + _, syncState, err := loadSyncState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load sync state: %v\n", err) + } + + filtered := make([]string, 0, len(repoNames)) + for _, repoName := range repoNames { + decision := evaluateSyncPolicy(repoName, syncState, true, flags.Force, flags.Throttle) + if decision.Message != "" { + fmt.Println(decision.Message) + } + if decision.Skip { + continue + } + filtered = append(filtered, repoName) + } + + return filtered +} + func shuffledRepoNames(repoNames []string) []string { shuffled := append([]string(nil), repoNames...) rand.Shuffle(len(shuffled), func(i, j int) { @@ -433,10 +410,10 @@ func printFullSyncSeparator() { } type syncExecution struct { - syncer *sync.Syncer - descCache map[string]string - throttleManager *state.Manager - throttleState *state.State + syncer *sync.Syncer + descCache map[string]string + stateManager *state.Manager + syncState *state.State } func newSyncExecution(cfg *config.Config, flags *Flags) *syncExecution { @@ -446,45 +423,39 @@ func newSyncExecution(cfg *config.Config, flags *Flags) *syncExecution { } execution.syncer.SetBackupEnabled(flags.Backup) - if flags.Throttle { - manager, st, err := loadThrottleState(flags.WorkDir) - if err != nil { - fmt.Printf("Warning: Failed to load throttle state: %v\n", err) - } - execution.throttleManager = manager - execution.throttleState = st + manager, st, err := loadSyncState(flags.WorkDir) + if err != nil { + fmt.Printf("Warning: Failed to load sync state: %v\n", err) } + execution.stateManager = manager + execution.syncState = st return execution } -func (e *syncExecution) maybeThrottle(repoName string, flags *Flags) bool { - if !flags.Throttle { - return false - } - - decision := evaluateThrottle(repoName, e.throttleState, flags.DryRun) +func (e *syncExecution) maybeSkipRepo(repoName string, flags *Flags) bool { + decision := evaluateSyncPolicy(repoName, e.syncState, flags.DryRun, flags.Force, flags.Throttle) if decision.Message != "" { fmt.Println(decision.Message) } - if decision.SetNextAllowed && e.throttleManager != nil && !flags.DryRun { - e.throttleState.SetNextRepoSyncAllowed(repoName, decision.NextAllowed) - if err := e.throttleManager.Save(e.throttleState); err != nil { - fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + if decision.SetNextAllowed && e.stateManager != nil && !flags.DryRun { + e.syncState.SetNextRepoSyncAllowed(repoName, decision.NextAllowed) + if err := e.stateManager.Save(e.syncState); err != nil { + fmt.Printf("Warning: Failed to save sync state: %v\n", err) } } return decision.Skip } -func (e *syncExecution) markSynced(repoName string, flags *Flags) { - if !flags.Throttle || e.throttleManager == nil { +func (e *syncExecution) markRepoSynced(repoName string, flags *Flags) { + if e.stateManager == nil || flags.DryRun { return } - updateRepoSyncState(repoName, e.throttleState) - if err := e.throttleManager.Save(e.throttleState); err != nil { - fmt.Printf("Warning: Failed to save throttle state: %v\n", err) + recordRepoSync(repoName, e.syncState, flags.Throttle) + if err := e.stateManager.Save(e.syncState); err != nil { + fmt.Printf("Warning: Failed to save sync state: %v\n", err) } } @@ -556,7 +527,7 @@ 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 execution.maybeThrottle(repoName, flags) { + if execution.maybeSkipRepo(repoName, flags) { continue } @@ -580,7 +551,7 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi fmt.Printf("Stopping sync due to error.\n") return 1 } - execution.markSynced(repoName, flags) + execution.markRepoSynced(repoName, flags) successCount++ // After syncing, sync descriptions according to precedence @@ -627,7 +598,7 @@ 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 execution.maybeThrottle(repoName, flags) { + if execution.maybeSkipRepo(repoName, flags) { continue } @@ -651,7 +622,7 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository fmt.Printf("Stopping sync due to error.\n") return 1 } - execution.markSynced(repoName, flags) + execution.markRepoSynced(repoName, flags) successCount++ // After syncing, sync descriptions according to precedence diff --git a/internal/cli/throttle.go b/internal/cli/throttle.go index b48094e..d285350 100644 --- a/internal/cli/throttle.go +++ b/internal/cli/throttle.go @@ -13,12 +13,13 @@ import ( ) const ( - throttleMinDays = 60 - throttleMaxDays = 120 - recentDays = 7 + defaultSyncInterval = 24 * time.Hour + throttleMinDays = 60 + throttleMaxDays = 120 + recentDays = 7 ) -func loadThrottleState(workDir string) (*state.Manager, *state.State, error) { +func loadSyncState(workDir string) (*state.Manager, *state.State, error) { manager := state.NewManager(workDir) st, err := manager.Load() if err != nil { @@ -30,14 +31,57 @@ func loadThrottleState(workDir string) (*state.Manager, *state.State, error) { return manager, st, nil } -type throttleDecision struct { +type syncDecision struct { Skip bool Message string NextAllowed time.Time SetNextAllowed bool } -func evaluateThrottle(repoName string, st *state.State, dryRun bool) throttleDecision { +func evaluateSyncPolicy(repoName string, st *state.State, dryRun bool, force bool, throttle bool) syncDecision { + if force { + return syncDecision{} + } + + decision := evaluateDailySync(repoName, st, dryRun) + if decision.Skip || !throttle { + return decision + } + + return evaluateThrottle(repoName, st, dryRun) +} + +func evaluateDailySync(repoName string, st *state.State, dryRun bool) syncDecision { + if st == nil { + return syncDecision{} + } + + lastSync := st.GetLastRepoSync(repoName) + if lastSync.IsZero() { + return syncDecision{} + } + + nextAllowed := lastSync.Add(defaultSyncInterval) + if time.Now().Before(nextAllowed) { + skipAction := "Skipping" + if dryRun { + skipAction = "[DRY RUN] Would skip" + } + + return syncDecision{ + Skip: true, + Message: fmt.Sprintf("%s %s: last synced at %s; next sync after %s. Use --force to override.", + skipAction, + repoName, + lastSync.Format("2006-01-02 15:04"), + nextAllowed.Format("2006-01-02 15:04")), + } + } + + return syncDecision{} +} + +func evaluateThrottle(repoName string, st *state.State, dryRun bool) syncDecision { syncAction := "Syncing" if dryRun { syncAction = "[DRY RUN] Would sync" @@ -49,14 +93,14 @@ func evaluateThrottle(repoName string, st *state.State, dryRun bool) throttleDec if dryRun { actionMsg = "Sync would proceed" } - return throttleDecision{ + return syncDecision{ Skip: false, Message: fmt.Sprintf("Warning: failed to check local activity for %s: %v. %s.", repoName, err, actionMsg), } } if recent { - return throttleDecision{ + return syncDecision{ Skip: false, Message: fmt.Sprintf("%s %s: recent local commits within last %d days.", syncAction, repoName, recentDays), } @@ -64,7 +108,7 @@ func evaluateThrottle(repoName string, st *state.State, dryRun bool) throttleDec now := time.Now() if st == nil { - return throttleDecision{ + return syncDecision{ Skip: false, Message: fmt.Sprintf("%s %s: no recent local commits; throttle state unavailable.", syncAction, repoName), } @@ -82,7 +126,7 @@ func evaluateThrottle(repoName string, st *state.State, dryRun bool) throttleDec } else { nextAllowed = now.Add(randomThrottleDuration()) } - return throttleDecision{ + return syncDecision{ Skip: true, NextAllowed: nextAllowed, SetNextAllowed: true, @@ -92,25 +136,29 @@ func evaluateThrottle(repoName string, st *state.State, dryRun bool) throttleDec } if now.Before(nextAllowed) { - return throttleDecision{ + return syncDecision{ 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{ + return syncDecision{ 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) { +func recordRepoSync(repoName string, st *state.State, throttle bool) { if st == nil { return } now := time.Now() - nextAllowed := now.Add(randomThrottleDuration()) - st.SetRepoSync(repoName, now, nextAllowed) + st.SetLastRepoSync(repoName, now) + if throttle { + st.SetNextRepoSyncAllowed(repoName, now.Add(randomThrottleDuration())) + return + } + st.ClearNextRepoSyncAllowed(repoName) } func randomThrottleDuration() time.Duration { diff --git a/internal/cli/throttle_test.go b/internal/cli/throttle_test.go new file mode 100644 index 0000000..6833fe4 --- /dev/null +++ b/internal/cli/throttle_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "strings" + "testing" + "time" + + "codeberg.org/snonux/gitsyncer/internal/state" +) + +func TestEvaluateSyncPolicy_SkipsRepoSyncedWithinDay(t *testing.T) { + st := &state.State{} + st.SetLastRepoSync("repo", time.Now().Add(-23*time.Hour)) + + decision := evaluateSyncPolicy("repo", st, false, false, false) + + if !decision.Skip { + t.Fatal("expected repo synced within 24 hours to be skipped") + } + if !strings.Contains(decision.Message, "Use --force to override.") { + t.Fatalf("expected force override hint, got %q", decision.Message) + } +} + +func TestEvaluateSyncPolicy_AllowsRepoAfterDailyWindow(t *testing.T) { + st := &state.State{} + st.SetLastRepoSync("repo", time.Now().Add(-25*time.Hour)) + + decision := evaluateSyncPolicy("repo", st, false, false, false) + + if decision.Skip { + t.Fatalf("expected repo synced more than 24 hours ago to proceed, got %q", decision.Message) + } +} + +func TestEvaluateSyncPolicy_ForceBypassesDailyAndThrottleLimits(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + st := &state.State{} + now := time.Now() + st.SetRepoSync("repo", now.Add(-1*time.Hour), now.Add(30*24*time.Hour)) + + decision := evaluateSyncPolicy("repo", st, false, true, true) + + if decision.Skip { + t.Fatalf("expected --force to bypass sync limits, got %q", decision.Message) + } + if decision.SetNextAllowed { + t.Fatal("did not expect --force to request throttle-window persistence") + } +} + +func TestEvaluateSyncPolicy_ThrottleSetsWindowWhenRepoIsIdle(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + start := time.Now() + decision := evaluateSyncPolicy("repo", &state.State{}, false, false, true) + end := time.Now() + + if !decision.Skip { + t.Fatal("expected idle repo to be skipped when throttle is enabled") + } + if !decision.SetNextAllowed { + t.Fatal("expected throttle evaluation to request a persisted next-allowed time") + } + + minAllowed := start.Add(throttleMinDays * 24 * time.Hour) + maxAllowed := end.Add(throttleMaxDays*24*time.Hour + time.Minute) + if decision.NextAllowed.Before(minAllowed) || decision.NextAllowed.After(maxAllowed) { + t.Fatalf("expected throttle window between %s and %s, got %s", minAllowed, maxAllowed, decision.NextAllowed) + } +} + +func TestRecordRepoSync_ClearsThrottleWindowWhenThrottleDisabled(t *testing.T) { + st := &state.State{} + st.SetRepoSync("repo", time.Now().Add(-72*time.Hour), time.Now().Add(72*time.Hour)) + + recordRepoSync("repo", st, false) + + if st.GetLastRepoSync("repo").IsZero() { + t.Fatal("expected last sync time to be recorded") + } + if !st.GetNextRepoSyncAllowed("repo").IsZero() { + t.Fatal("expected throttle window to be cleared when throttle is disabled") + } +} + +func TestRecordRepoSync_SetsThrottleWindowWhenThrottleEnabled(t *testing.T) { + st := &state.State{} + + recordRepoSync("repo", st, true) + + lastSync := st.GetLastRepoSync("repo") + if lastSync.IsZero() { + t.Fatal("expected last sync time to be recorded") + } + + nextAllowed := st.GetNextRepoSyncAllowed("repo") + if nextAllowed.IsZero() { + t.Fatal("expected throttle window to be recorded") + } + + minAllowed := lastSync.Add(throttleMinDays * 24 * time.Hour) + maxAllowed := lastSync.Add(throttleMaxDays*24*time.Hour + time.Minute) + if nextAllowed.Before(minAllowed) || nextAllowed.After(maxAllowed) { + t.Fatalf("expected throttle window between %s and %s, got %s", minAllowed, maxAllowed, nextAllowed) + } +} diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index df7aa5b..5681ccc 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -16,6 +16,7 @@ var ( noAIReleaseNotes bool syncAITool string throttle bool + syncForce bool ) var syncCmd = &cobra.Command{ @@ -39,6 +40,9 @@ var syncRepoCmd = &cobra.Command{ # Preview what would be synced gitsyncer sync repo myproject --dry-run + + # Override sync interval checks + gitsyncer sync repo myproject --force # Sync without AI-generated release notes gitsyncer sync repo myproject --no-ai-release-notes @@ -191,6 +195,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().BoolVarP(&syncForce, "force", "f", false, "force sync even if normal sync interval checks would skip a repository") syncCmd.PersistentFlags().BoolVar(&throttle, "throttle", false, "throttle syncing based on local repo activity") } @@ -204,6 +209,7 @@ func buildFlags() *cli.Flags { AutoCreateReleases: autoCreate, AIReleaseNotes: !noAIReleaseNotes, AITool: syncAITool, + Force: syncForce, Throttle: throttle, CreateGitHubRepos: createRepos, CreateCodebergRepos: createRepos, diff --git a/internal/state/state.go b/internal/state/state.go index 3288db1..4d5f197 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -11,7 +11,7 @@ import ( // State represents the persistent state of gitsyncer type State struct { LastBatchRun time.Time `json:"lastBatchRun"` - // Per-repo sync tracking for throttling + // Per-repo sync tracking for default daily sync limits and optional throttling LastRepoSync map[string]time.Time `json:"lastRepoSync,omitempty"` NextRepoSyncAllowed map[string]time.Time `json:"nextRepoSyncAllowed,omitempty"` } @@ -117,6 +117,15 @@ func (s *State) SetRepoSync(repoName string, lastSync time.Time, nextAllowed tim s.NextRepoSyncAllowed[repoName] = nextAllowed } +// SetLastRepoSync updates only the last sync time for a repo. +func (s *State) SetLastRepoSync(repoName string, lastSync time.Time) { + if s == nil { + return + } + s.EnsureRepoMaps() + s.LastRepoSync[repoName] = lastSync +} + // SetNextRepoSyncAllowed updates only the next allowed sync time for a repo func (s *State) SetNextRepoSyncAllowed(repoName string, nextAllowed time.Time) { if s == nil { @@ -125,3 +134,11 @@ func (s *State) SetNextRepoSyncAllowed(repoName string, nextAllowed time.Time) { s.EnsureRepoMaps() s.NextRepoSyncAllowed[repoName] = nextAllowed } + +// ClearNextRepoSyncAllowed removes the next allowed sync time for a repo. +func (s *State) ClearNextRepoSyncAllowed(repoName string) { + if s == nil || s.NextRepoSyncAllowed == nil { + return + } + delete(s.NextRepoSyncAllowed, repoName) +} -- cgit v1.2.3