diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-28 10:16:18 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-28 10:16:18 +0200 |
| commit | 73c6a37ecf0aac04711e5624455743b3493a7ef5 (patch) | |
| tree | ac58cce0dcd03ccac3f5f3e313a46ebe9d352b30 | |
| parent | 1615abaacccdbb5002404a77270fd333ce8ad718 (diff) | |
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | internal/cli/description_sync.go | 71 | ||||
| -rw-r--r-- | internal/cli/description_sync_test.go | 57 | ||||
| -rw-r--r-- | internal/cli/sync_handlers.go | 14 | ||||
| -rw-r--r-- | internal/cli/sync_handlers_test.go | 19 | ||||
| -rw-r--r-- | internal/config/config.go | 17 | ||||
| -rw-r--r-- | internal/config/config_test.go | 22 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 12 | ||||
| -rw-r--r-- | internal/showcase/showcase_test.go | 19 | ||||
| -rw-r--r-- | internal/sync/backup_test.go | 63 | ||||
| -rw-r--r-- | internal/sync/branch_sync.go | 22 | ||||
| -rw-r--r-- | internal/sync/git_operations.go | 52 | ||||
| -rw-r--r-- | internal/sync/repository_setup.go | 12 | ||||
| -rw-r--r-- | internal/sync/sync.go | 72 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
15 files changed, 425 insertions, 46 deletions
@@ -27,7 +27,8 @@ It has been vibe coded mainly using AI tools (Claude Code CLI and amp). - Merge conflict detection with clear error messages - Never deletes branches (only adds/updates) - GitHub token validation tool -- Opt-in backup mode with --backup flag for resilient offline backups +- Backup sync for full-sync modes, with `--backup` available for single-repo and `sync all` runs +- In-memory backup fail-fast for a run: after the first backup failure, later repos skip backup attempts - 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 @@ -56,7 +57,9 @@ Create a configuration file at `~/.config/gitsyncer/config.json` (or specify a c }, { "host": "user@nas.local:git", - "backupLocation": true + "backupLocation": true, + "descriptionSyncHost": "root@nas.local", + "descriptionSyncRoot": "/srv/git/repos" } ], "repositories": [ @@ -96,7 +99,7 @@ gitsyncer sync --help ```bash gitsyncer sync repo myproject -# Include backup locations +# Include backup locations for a single repository run gitsyncer sync repo myproject --backup # Preview what would be synced @@ -130,7 +133,7 @@ When `--throttle` is enabled, GitSyncer still applies the default once-daily lim ```bash gitsyncer sync all -# Include backup locations +# Include backup locations when running sync all gitsyncer sync all --backup ``` @@ -162,11 +165,10 @@ gitsyncer sync bidirectional # Preview what would be synced gitsyncer sync bidirectional --dry-run - -# Include backup locations -gitsyncer sync bidirectional --backup ``` +`sync bidirectional`, `sync codeberg-to-github`, `sync github-to-codeberg`, and `manage batch-run` now always try configured backup locations when `backupLocation: true` is present in the config. If the first backup push fails because the backup host is offline or unavailable, GitSyncer records that failure in memory and skips backup attempts for the rest of that process while continuing the primary sync targets. + ### Release Management #### Check for missing releases @@ -362,6 +364,7 @@ You can configure SSH backup locations for one-way repository backups to private - Initialize a bare git repository 4. **Archive functionality**: Repositories that exist only on the backup location are considered archived and won't be synced to other organizations 5. **All branches and tags**: Every branch and tag is pushed to the backup location when `--backup` is used +6. **Optional cgit description sync**: Set `descriptionSyncHost` and `descriptionSyncRoot` on a backup organization to mirror the canonical repository description into the bare repo `description` file used by cgit ### SSH Backup Example diff --git a/internal/cli/description_sync.go b/internal/cli/description_sync.go index 4904b56..317bcdf 100644 --- a/internal/cli/description_sync.go +++ b/internal/cli/description_sync.go @@ -2,6 +2,10 @@ package cli import ( "fmt" + "os" + "os/exec" + "path" + "path/filepath" "strings" "codeberg.org/snonux/gitsyncer/internal/codeberg" @@ -105,8 +109,75 @@ func syncRepoDescriptions(cfg *config.Config, dryRun bool, repoName, knownCBDesc } } + syncBackupDescriptions(cfg, dryRun, repoName, canonical) + // Update cache if cache != nil { cache[repoName] = canonical } } + +func syncBackupDescriptions(cfg *config.Config, dryRun bool, repoName, canonical string) { + if cfg == nil || canonical == "" { + return + } + + for i := range cfg.Organizations { + org := &cfg.Organizations[i] + if !org.BackupLocation { + continue + } + + supported, err := syncBackupDescription(org, repoName, canonical, dryRun) + if err != nil { + fmt.Printf(" Warning: Failed to update backup description on %s: %v\n", org.Host, err) + continue + } + if supported && !dryRun { + fmt.Printf(" Updated backup description for %s on %s\n", repoName, org.Host) + } + } +} + +func syncBackupDescription(org *config.Organization, repoName, description string, dryRun bool) (bool, error) { + if org == nil || !org.BackupLocation { + return false, nil + } + + description = strings.TrimSpace(description) + if description == "" { + return false, nil + } + + if strings.HasPrefix(org.Host, "file://") { + descriptionPath := filepath.Join(strings.TrimPrefix(org.Host, "file://"), repoName+".git", "description") + if dryRun { + fmt.Printf(" [DRY RUN] Would update backup description for %s on %s -> %q\n", repoName, org.Host, description) + return true, nil + } + return true, os.WriteFile(descriptionPath, []byte(description+"\n"), 0644) + } + + if strings.TrimSpace(org.DescriptionSyncHost) == "" || strings.TrimSpace(org.DescriptionSyncRoot) == "" { + return false, nil + } + + descriptionPath := path.Join(org.DescriptionSyncRoot, repoName+".git", "description") + if dryRun { + fmt.Printf(" [DRY RUN] Would update backup description for %s on %s -> %q\n", repoName, org.Host, description) + return true, nil + } + + cmd := exec.Command("ssh", org.DescriptionSyncHost, fmt.Sprintf("cat > %s", shellSingleQuote(descriptionPath))) + cmd.Stdin = strings.NewReader(description + "\n") + output, err := cmd.CombinedOutput() + if err != nil { + return true, fmt.Errorf("ssh write failed: %w\n%s", err, strings.TrimSpace(string(output))) + } + + return true, nil +} + +func shellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/internal/cli/description_sync_test.go b/internal/cli/description_sync_test.go new file mode 100644 index 0000000..8f92349 --- /dev/null +++ b/internal/cli/description_sync_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "codeberg.org/snonux/gitsyncer/internal/config" +) + +func TestSyncBackupDescription_FileURLWritesDescription(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + repoDir := filepath.Join(rootDir, "sample.git") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatalf("mkdir repo dir: %v", err) + } + + org := &config.Organization{ + Host: "file://" + rootDir, + BackupLocation: true, + } + + supported, err := syncBackupDescription(org, "sample", "Sample description", false) + if err != nil { + t.Fatalf("syncBackupDescription() error = %v", err) + } + if !supported { + t.Fatal("expected file backup description sync to be supported") + } + + content, err := os.ReadFile(filepath.Join(repoDir, "description")) + if err != nil { + t.Fatalf("read description: %v", err) + } + if string(content) != "Sample description\n" { + t.Fatalf("description = %q, want %q", string(content), "Sample description\n") + } +} + +func TestSyncBackupDescription_SSHWithoutDescriptionSyncConfigIsUnsupported(t *testing.T) { + t.Parallel() + + org := &config.Organization{ + Host: "ssh://git@example.com/repos", + BackupLocation: true, + } + + supported, err := syncBackupDescription(org, "sample", "Sample description", false) + if err != nil { + t.Fatalf("syncBackupDescription() error = %v", err) + } + if supported { + t.Fatal("expected SSH backup description sync without config to be unsupported") + } +} diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 6a96b92..e791b45 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -12,6 +12,14 @@ import ( "codeberg.org/snonux/gitsyncer/internal/sync" ) +func shouldEnableBackupSync(flags *Flags) bool { + if flags == nil { + return false + } + + return flags.Backup || flags.FullSync +} + // HandleSync handles syncing a single repository func HandleSync(cfg *config.Config, flags *Flags) int { stateManager, syncState, err := loadSyncState(flags.WorkDir) @@ -50,7 +58,7 @@ func HandleSync(cfg *config.Config, flags *Flags) int { } syncer := sync.New(cfg, flags.WorkDir) - syncer.SetBackupEnabled(flags.Backup) + syncer.SetBackupEnabled(shouldEnableBackupSync(flags)) if err := syncer.SyncRepository(flags.SyncRepo); err != nil { fmt.Printf("ERROR: Sync failed: %v\n", err) return 1 @@ -107,7 +115,7 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { } syncer := sync.New(cfg, flags.WorkDir) - syncer.SetBackupEnabled(flags.Backup) + syncer.SetBackupEnabled(shouldEnableBackupSync(flags)) successCount := 0 // Load descriptions cache descCache := loadDescriptionCache(flags.WorkDir) @@ -421,7 +429,7 @@ func newSyncExecution(cfg *config.Config, flags *Flags) *syncExecution { descCache: loadDescriptionCache(flags.WorkDir), syncer: sync.New(cfg, flags.WorkDir), } - execution.syncer.SetBackupEnabled(flags.Backup) + execution.syncer.SetBackupEnabled(shouldEnableBackupSync(flags)) manager, st, err := loadSyncState(flags.WorkDir) if err != nil { diff --git a/internal/cli/sync_handlers_test.go b/internal/cli/sync_handlers_test.go new file mode 100644 index 0000000..2eaad67 --- /dev/null +++ b/internal/cli/sync_handlers_test.go @@ -0,0 +1,19 @@ +package cli + +import "testing" + +func TestShouldEnableBackupSync_FullSyncImplicitlyEnablesBackup(t *testing.T) { + t.Parallel() + + if !shouldEnableBackupSync(&Flags{FullSync: true}) { + t.Fatal("expected full sync to enable backup sync implicitly") + } + + if !shouldEnableBackupSync(&Flags{Backup: true}) { + t.Fatal("expected explicit --backup to enable backup sync") + } + + if shouldEnableBackupSync(&Flags{}) { + t.Fatal("did not expect backup sync to be enabled by default") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4e40cdf..02f2fee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,11 +10,13 @@ import ( // Organization represents a git organization with its host and name type Organization struct { - Host string `json:"host"` - Name string `json:"name"` - GitHubToken string `json:"github_token,omitempty"` - CodebergToken string `json:"codeberg_token,omitempty"` - BackupLocation bool `json:"backupLocation,omitempty"` // Mark this as a backup-only destination + Host string `json:"host"` + Name string `json:"name"` + GitHubToken string `json:"github_token,omitempty"` + CodebergToken string `json:"codeberg_token,omitempty"` + BackupLocation bool `json:"backupLocation,omitempty"` // Mark this as a backup-only destination + DescriptionSyncHost string `json:"descriptionSyncHost,omitempty"` // SSH host with shell access for updating backup descriptions + DescriptionSyncRoot string `json:"descriptionSyncRoot,omitempty"` // Filesystem path on DescriptionSyncHost where bare repos live } // Config holds the application configuration @@ -101,6 +103,11 @@ func (c *Config) Validate() error { if org.Name == "" && !strings.HasPrefix(org.Host, "file://") && !org.IsSSH() { return fmt.Errorf("organization %d: missing name", i) } + hasDescriptionSyncHost := strings.TrimSpace(org.DescriptionSyncHost) != "" + hasDescriptionSyncRoot := strings.TrimSpace(org.DescriptionSyncRoot) != "" + if hasDescriptionSyncHost != hasDescriptionSyncRoot { + return fmt.Errorf("organization %d: descriptionSyncHost and descriptionSyncRoot must be set together", i) + } } for repo, branch := range c.ShowcaseStatsBranches { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index db70457..30b59df 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,3 +25,25 @@ func TestValidate_ShowcaseStatsBranchesRejectsEmptyBranch(t *testing.T) { t.Fatalf("Validate() error = %q, want showcase_stats_branches context", err) } } + +func TestValidate_DescriptionSyncFieldsMustBePaired(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Organizations: []Organization{ + { + Host: "ssh://git@example.com/repos", + BackupLocation: true, + DescriptionSyncHost: "root@example.com", + }, + }, + } + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() error = nil, want description sync validation error") + } + if !strings.Contains(err.Error(), "descriptionSyncHost") { + t.Fatalf("Validate() error = %q, want descriptionSyncHost context", err) + } +} diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 9cf43a6..c6154f9 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -27,6 +27,7 @@ type ProjectSummary struct { Summary string CodebergURL string GitHubURL string + CgitURL string Metadata *RepoMetadata RankHistory []RepoRankHistory // Latest 5 weekly rank points, newest first Images []string // Relative paths to images in showcase directory @@ -669,9 +670,10 @@ func (g *Generator) getRepositories() ([]string, error) { return repos, nil } -func (g *Generator) buildProjectLinks(repoName string) (string, string) { +func (g *Generator) buildProjectLinks(repoName string) (string, string, string) { codebergURL := "" githubURL := "" + cgitURL := fmt.Sprintf("https://cgit.f3s.buetow.org/%s/", repoName) if codebergOrg := g.config.FindCodebergOrg(); codebergOrg != nil { codebergURL = fmt.Sprintf("https://codeberg.org/%s/%s", codebergOrg.Name, repoName) @@ -681,7 +683,7 @@ func (g *Generator) buildProjectLinks(repoName string) (string, string) { githubURL = fmt.Sprintf("https://github.com/%s/%s", githubOrg.Name, repoName) } - return codebergURL, githubURL + return codebergURL, githubURL, cgitURL } func (g *Generator) prepareStatsRepoPath(repoName, repoPath string) (string, func() error, error) { @@ -833,7 +835,7 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool summary = sanitizeSummaryForGemtext(summary) // Build URLs - codebergURL, githubURL := g.buildProjectLinks(repoName) + codebergURL, githubURL, cgitURL := g.buildProjectLinks(repoName) // Always extract images from README (not cached) fmt.Printf("Extracting images from README...\n") @@ -865,6 +867,7 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool Summary: summary, CodebergURL: codebergURL, GitHubURL: githubURL, + CgitURL: cgitURL, Metadata: metadata, Images: images, CodeSnippet: codeSnippet, @@ -1078,6 +1081,9 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { if summary.GitHubURL != "" { builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL)) } + if summary.CgitURL != "" { + builder.WriteString(fmt.Sprintf("=> %s View in cgit\n", summary.CgitURL)) + } } diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go index fa18799..cd571f8 100644 --- a/internal/showcase/showcase_test.go +++ b/internal/showcase/showcase_test.go @@ -148,6 +148,25 @@ func TestFormatGemtext_SanitizesMarkdownHeadingsInSummary(t *testing.T) { } } +func TestFormatGemtext_IncludesCgitLink(t *testing.T) { + t.Parallel() + + g := &Generator{config: &config.Config{}} + content := g.formatGemtext([]ProjectSummary{ + { + Name: "cpuinfo", + Summary: "summary", + CodebergURL: "https://codeberg.org/snonux/cpuinfo", + GitHubURL: "https://github.com/snonux/cpuinfo", + CgitURL: "https://cgit.f3s.buetow.org/cpuinfo/", + }, + }) + + if !strings.Contains(content, "=> https://cgit.f3s.buetow.org/cpuinfo/ View in cgit\n") { + t.Fatalf("cgit link was not rendered: %s", content) + } +} + func TestFindReadmeContent_UsesRepoPathWithoutChangingCWD(t *testing.T) { t.Parallel() diff --git a/internal/sync/backup_test.go b/internal/sync/backup_test.go new file mode 100644 index 0000000..9bdbff3 --- /dev/null +++ b/internal/sync/backup_test.go @@ -0,0 +1,63 @@ +package sync + +import ( + "errors" + "testing" + + "codeberg.org/snonux/gitsyncer/internal/config" +) + +func TestHandlePushError_DisablesBackupForSession(t *testing.T) { + resetBackupSessionState() + t.Cleanup(resetBackupSessionState) + + syncer := &Syncer{} + syncer.SetBackupEnabled(true) + + err := syncer.handlePushError("backup", &config.Organization{BackupLocation: true}, errors.New("dial tcp: connection refused")) + if err != nil { + t.Fatalf("expected backup push failure to be downgraded, got %v", err) + } + if syncer.backupActive() { + t.Fatal("expected backup sync to be disabled for the remainder of the session") + } +} + +func TestHandlePushError_PropagatesPrimaryRemoteFailure(t *testing.T) { + resetBackupSessionState() + t.Cleanup(resetBackupSessionState) + + syncer := &Syncer{} + syncer.SetBackupEnabled(true) + + pushErr := errors.New("push rejected") + err := syncer.handlePushError("origin", &config.Organization{}, pushErr) + if !errors.Is(err, pushErr) { + t.Fatalf("expected primary remote error to be returned, got %v", err) + } +} + +func TestParseSSHLocation_SupportsSSHURLWithPort(t *testing.T) { + t.Parallel() + + userHost, sshArgs, basePath, err := parseSSHLocation("ssh://git@r0:30022/repos") + if err != nil { + t.Fatalf("parseSSHLocation() error = %v", err) + } + if userHost != "git@r0" { + t.Fatalf("userHost = %q, want %q", userHost, "git@r0") + } + if basePath != "/repos" { + t.Fatalf("basePath = %q, want %q", basePath, "/repos") + } + + wantArgs := []string{"-p", "30022", "git@r0"} + if len(sshArgs) != len(wantArgs) { + t.Fatalf("sshArgs = %#v, want %#v", sshArgs, wantArgs) + } + for i := range wantArgs { + if sshArgs[i] != wantArgs[i] { + t.Fatalf("sshArgs = %#v, want %#v", sshArgs, wantArgs) + } + } +} diff --git a/internal/sync/branch_sync.go b/internal/sync/branch_sync.go index a667ee2..a053e78 100644 --- a/internal/sync/branch_sync.go +++ b/internal/sync/branch_sync.go @@ -40,9 +40,27 @@ func mergeFromRemotes(repoPath, branch string, remotesWithBranch map[string]bool return nil } +// handlePushError decides whether a push error should stop sync or only disable backup for this session. +func (s *Syncer) handlePushError(remoteName string, org *config.Organization, err error) error { + if err == nil { + return nil + } + + if org != nil && org.BackupLocation { + s.disableBackupForSession(remoteName, err) + return nil + } + + return err +} + // pushToAllRemotes pushes the branch to all configured remotes -func pushToAllRemotes(repoPath, branch string, remotes map[string]*config.Organization, remotesWithBranch map[string]bool) error { +func (s *Syncer) pushToAllRemotes(repoPath, branch string, remotes map[string]*config.Organization, remotesWithBranch map[string]bool) error { for remoteName, org := range remotes { + if org.BackupLocation && !s.backupActive() { + continue + } + // Check if this remote has the branch remoteHasBranch := remotesWithBranch[remoteName] @@ -52,7 +70,7 @@ func pushToAllRemotes(repoPath, branch string, remotes map[string]*config.Organi fmt.Printf(" Pushing to %s (%s)...\n", remoteName, org.Host) } - if err := pushBranchWithBackupSupport(repoPath, remoteName, branch, remoteHasBranch, org); err != nil { + if err := s.handlePushError(remoteName, org, pushBranchWithBackupSupport(repoPath, remoteName, branch, remoteHasBranch, org)); err != nil { return err } } diff --git a/internal/sync/git_operations.go b/internal/sync/git_operations.go index 62a530b..6619349 100644 --- a/internal/sync/git_operations.go +++ b/internal/sync/git_operations.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "net/url" "os/exec" "regexp" "strings" @@ -245,23 +246,19 @@ func getAllUniqueBranches(output []byte) []string { // createSSHBareRepository creates a bare repository on an SSH server func createSSHBareRepository(sshHost, repoPath string) error { - // Extract user@host and path components - parts := strings.Split(sshHost, ":") - if len(parts) != 2 { - return fmt.Errorf("invalid SSH host format: %s", sshHost) + userHost, sshArgs, basePath, err := parseSSHLocation(sshHost) + if err != nil { + return err } - userHost := parts[0] - basePath := parts[1] - // Full path to the repository - fullRepoPath := fmt.Sprintf("%s/%s.git", basePath, repoPath) + fullRepoPath := strings.TrimRight(basePath, "/") + "/" + repoPath + ".git" fmt.Printf("Creating bare repository at %s:%s\n", userHost, fullRepoPath) // Create the repository directory and initialize as bare - commands := fmt.Sprintf("mkdir -p %s && cd %s && git init --bare", fullRepoPath, fullRepoPath) - cmd := exec.Command("ssh", userHost, commands) + commands := fmt.Sprintf("mkdir -p %q && cd %q && git init --bare", fullRepoPath, fullRepoPath) + cmd := exec.Command("ssh", append(sshArgs, commands)...) output, err := cmd.CombinedOutput() if err != nil { @@ -272,6 +269,41 @@ func createSSHBareRepository(sshHost, repoPath string) error { return nil } +func parseSSHLocation(sshHost string) (string, []string, string, error) { + if strings.HasPrefix(sshHost, "ssh://") { + parsed, err := url.Parse(sshHost) + if err != nil { + return "", nil, "", fmt.Errorf("invalid SSH host format: %w", err) + } + + host := parsed.Hostname() + if host == "" || parsed.Path == "" { + return "", nil, "", fmt.Errorf("invalid SSH host format: %s", sshHost) + } + + userHost := host + if parsed.User != nil && parsed.User.Username() != "" { + userHost = parsed.User.Username() + "@" + host + } + + sshArgs := make([]string, 0, 3) + if port := parsed.Port(); port != "" { + sshArgs = append(sshArgs, "-p", port) + } + sshArgs = append(sshArgs, userHost) + + return userHost, sshArgs, parsed.Path, nil + } + + parts := strings.SplitN(sshHost, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", nil, "", fmt.Errorf("invalid SSH host format: %s", sshHost) + } + + userHost := parts[0] + return userHost, []string{userHost}, parts[1], nil +} + // pushBranchWithBackupSupport pushes a branch to a remote, creating SSH repos if needed func pushBranchWithBackupSupport(repoPath, remoteName, branch string, remoteHasBranch bool, org *config.Organization) error { cmd := gitCommand(repoPath, "push", remoteName, branch, "--tags") diff --git a/internal/sync/repository_setup.go b/internal/sync/repository_setup.go index e255274..9bb0c2f 100644 --- a/internal/sync/repository_setup.go +++ b/internal/sync/repository_setup.go @@ -55,8 +55,8 @@ func (s *Syncer) setupNewRepository(repoPath string) error { } org := &s.config.Organizations[i] - // Skip backup locations if backup is not enabled - if org.BackupLocation && !s.backupEnabled { + // Skip backup locations unless backup sync is currently active. + if org.BackupLocation && !s.backupActive() { continue } @@ -76,8 +76,8 @@ func (s *Syncer) setupExistingRepository(repoPath string) error { for i := range s.config.Organizations { org := &s.config.Organizations[i] - // Skip backup locations if backup is not enabled - if org.BackupLocation && !s.backupEnabled { + // Skip backup locations unless backup sync is currently active. + if org.BackupLocation && !s.backupActive() { continue } @@ -102,8 +102,8 @@ func (s *Syncer) getRemotesMap() map[string]*config.Organization { for i := range s.config.Organizations { org := &s.config.Organizations[i] - // Skip backup locations if backup is not enabled - if org.BackupLocation && !s.backupEnabled { + // Skip backup locations unless backup sync is currently active. + if org.BackupLocation && !s.backupActive() { continue } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index ba03e0f..e0a8215 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -6,10 +6,19 @@ import ( "os/exec" "path/filepath" "strings" + stdsync "sync" "codeberg.org/snonux/gitsyncer/internal/config" ) +type backupSessionState struct { + mu stdsync.Mutex + disabled bool + reason string +} + +var currentBackupSession backupSessionState + // Syncer handles repository synchronization between organizations type Syncer struct { config *config.Config @@ -45,6 +54,55 @@ func (s *Syncer) SetBackupEnabled(enabled bool) { s.backupEnabled = enabled } +func (s *Syncer) backupActive() bool { + if !s.backupEnabled { + return false + } + + disabled, _ := currentBackupSession.status() + return !disabled +} + +func (s *Syncer) disableBackupForSession(remoteName string, err error) { + if !s.backupEnabled { + return + } + + reason := fmt.Sprintf("%s: %v", remoteName, err) + if currentBackupSession.disable(reason) { + fmt.Printf("Warning: Backup sync to %s failed: %v\n", remoteName, err) + fmt.Println("Warning: Disabling backup sync for the remainder of this session.") + } +} + +func (b *backupSessionState) disable(reason string) bool { + b.mu.Lock() + defer b.mu.Unlock() + + if b.disabled { + return false + } + + b.disabled = true + b.reason = reason + return true +} + +func (b *backupSessionState) status() (bool, string) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.disabled, b.reason +} + +func resetBackupSessionState() { + currentBackupSession.mu.Lock() + defer currentBackupSession.mu.Unlock() + + currentBackupSession.disabled = false + currentBackupSession.reason = "" +} + // SyncRepository synchronizes a repository across all configured organizations func (s *Syncer) SyncRepository(repoName string) error { s.repoName = repoName @@ -234,7 +292,7 @@ func (s *Syncer) fetchAll() error { for remote := range remotes { // Check if this remote is a backup location if org, exists := allOrgsMap[remote]; exists && org.BackupLocation { - if !s.backupEnabled { + if !s.backupActive() { // Silently skip - don't even print a message since backup is not enabled continue } @@ -260,13 +318,9 @@ func (s *Syncer) getAllBranches() ([]string, error) { return nil, err } - // If backup is disabled, filter out branches from backup locations - if !s.backupEnabled { - filteredOutput := s.filterBackupBranches(output) - return getAllUniqueBranches(filteredOutput), nil - } - - return getAllUniqueBranches(output), nil + // Backup remotes are push-only and must never influence branch discovery. + filteredOutput := s.filterBackupBranches(output) + return getAllUniqueBranches(filteredOutput), nil } // syncBranch synchronizes a specific branch across all remotes @@ -296,7 +350,7 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati } // Push to all remotes - return pushToAllRemotes(repoPath, branch, remotes, remotesWithBranch) + return s.pushToAllRemotes(repoPath, branch, remotes, remotesWithBranch) } // handleWorkingDirectoryState checks for conflicts and stashes changes if needed diff --git a/internal/version/version.go b/internal/version/version.go index afd3d4e..189f928 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.16.0" + Version = "0.17.0" // GitCommit is the git commit hash at build time GitCommit = "unknown" |
