summaryrefslogtreecommitdiff
path: root/internal/sync
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-28 10:16:18 +0200
committerPaul Buetow <paul@buetow.org>2026-03-28 10:16:18 +0200
commit73c6a37ecf0aac04711e5624455743b3493a7ef5 (patch)
treeac58cce0dcd03ccac3f5f3e313a46ebe9d352b30 /internal/sync
parent1615abaacccdbb5002404a77270fd333ce8ad718 (diff)
feat(sync): auto-sync full backups and showcase cgit linksv0.17.0main
Diffstat (limited to 'internal/sync')
-rw-r--r--internal/sync/backup_test.go63
-rw-r--r--internal/sync/branch_sync.go22
-rw-r--r--internal/sync/git_operations.go52
-rw-r--r--internal/sync/repository_setup.go12
-rw-r--r--internal/sync/sync.go72
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