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 /internal/sync | |
| parent | 1615abaacccdbb5002404a77270fd333ce8ad718 (diff) | |
Diffstat (limited to 'internal/sync')
| -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 |
5 files changed, 194 insertions, 27 deletions
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 |
