summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
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"