summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-11 18:49:14 +0200
committerPaul Buetow <paul@buetow.org>2026-03-11 18:49:14 +0200
commitad84bcb992ba0552d582f8a6d53ac330f799a955 (patch)
tree2c3d386e090a8a52a45f52b0b424abb4143c0062 /internal
parent0011f18e8494a4e57dc277b826d56c0a1df041ce (diff)
feat(sync): enforce daily repo sync intervals
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/flags.go2
-rw-r--r--internal/cli/sync_handlers.go201
-rw-r--r--internal/cli/throttle.go78
-rw-r--r--internal/cli/throttle_test.go108
-rw-r--r--internal/cmd/sync.go6
-rw-r--r--internal/state/state.go19
6 files changed, 282 insertions, 132 deletions
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)
+}