summaryrefslogtreecommitdiff
path: root/internal/sync
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-22 09:54:49 +0300
committerPaul Buetow <paul@buetow.org>2026-07-22 09:54:49 +0300
commitea3b3b2ffc4a1d3c335994e22056a957cd962d21 (patch)
tree601aecf10b5d4a4721e895698d64e97b47561b5f /internal/sync
parent813077134132a62d0c9c73c7020ed734ff9add0b (diff)
feat(sync): make backup fail-fast per-destination and add forcePush/descriptionSync creation support
Backup failures now disable retries only for the failing remote instead of the whole session, add an opt-in forcePush flag for backup organizations, and allow repository creation to go through descriptionSyncHost/Root when configured so the git remote endpoint can stay restricted. Also fixes the AI release-notes cache being bypassed by --force, which is meant to control sync scheduling, not cache invalidation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'internal/sync')
-rw-r--r--internal/sync/backup_test.go37
-rw-r--r--internal/sync/branch_sync.go2
-rw-r--r--internal/sync/git_operations.go38
-rw-r--r--internal/sync/git_operations_test.go46
-rw-r--r--internal/sync/repository_setup.go14
-rw-r--r--internal/sync/sync.go29
6 files changed, 132 insertions, 34 deletions
diff --git a/internal/sync/backup_test.go b/internal/sync/backup_test.go
index fd15d04..7c2c225 100644
--- a/internal/sync/backup_test.go
+++ b/internal/sync/backup_test.go
@@ -18,9 +18,12 @@ func TestHandlePushError_DisablesBackupForSession(t *testing.T) {
if err != nil {
t.Fatalf("expected backup push failure to be downgraded, got %v", err)
}
- if syncer.backupActive() {
+ if syncer.backupActive("backup") {
t.Fatal("expected backup sync to be disabled for the remainder of the session")
}
+ if !syncer.backupActive("other-backup") {
+ t.Fatal("expected another backup remote to remain active")
+ }
}
func TestHandlePushError_PropagatesPrimaryRemoteFailure(t *testing.T) {
@@ -48,10 +51,10 @@ func TestHandlePushError_BackupDisableIsIsolatedPerSyncer(t *testing.T) {
t.Fatalf("expected backup push failure to be downgraded, got %v", err)
}
- if syncerA.backupActive() {
+ if syncerA.backupActive("backup-a") {
t.Fatal("expected syncerA backup sync to be disabled for the remainder of the session")
}
- if !syncerB.backupActive() {
+ if !syncerB.backupActive("backup-a") {
t.Fatal("expected syncerB backup session to remain active")
}
}
@@ -67,7 +70,7 @@ func TestBackupSessionState_DisableIsThreadSafe(t *testing.T) {
for i := 0; i < workers; i++ {
go func(i int) {
defer wg.Done()
- if session.disable(fmt.Sprintf("reason-%d", i)) {
+ if session.disable("backup", fmt.Sprintf("reason-%d", i)) {
firstDisableCount.Add(1)
}
}(i)
@@ -79,7 +82,7 @@ func TestBackupSessionState_DisableIsThreadSafe(t *testing.T) {
t.Fatalf("expected exactly one successful disable transition, got %d", got)
}
- disabled, reason := session.status()
+ disabled, reason := session.status("backup")
if !disabled {
t.Fatal("expected backup session to be disabled")
}
@@ -112,3 +115,27 @@ func TestParseSSHLocation_SupportsSSHURLWithPort(t *testing.T) {
}
}
}
+
+func TestRepositoryCreationLocation_UsesDescriptionSyncShellAccess(t *testing.T) {
+ t.Parallel()
+
+ org := &config.Organization{
+ Host: "ssh://git@r0:30022/repos",
+ DescriptionSyncHost: "root@r0",
+ DescriptionSyncRoot: "/srv/git/repos",
+ }
+
+ userHost, sshArgs, basePath, err := repositoryCreationLocation(org)
+ if err != nil {
+ t.Fatalf("repositoryCreationLocation() error = %v", err)
+ }
+ if userHost != "root@r0" {
+ t.Fatalf("userHost = %q, want %q", userHost, "root@r0")
+ }
+ if len(sshArgs) != 1 || sshArgs[0] != "root@r0" {
+ t.Fatalf("sshArgs = %#v, want %#v", sshArgs, []string{"root@r0"})
+ }
+ if basePath != "/srv/git/repos" {
+ t.Fatalf("basePath = %q, want %q", basePath, "/srv/git/repos")
+ }
+}
diff --git a/internal/sync/branch_sync.go b/internal/sync/branch_sync.go
index a053e78..601d8b0 100644
--- a/internal/sync/branch_sync.go
+++ b/internal/sync/branch_sync.go
@@ -57,7 +57,7 @@ func (s *Syncer) handlePushError(remoteName string, org *config.Organization, er
// pushToAllRemotes pushes the branch to all configured remotes
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() {
+ if org.BackupLocation && !s.backupActive(remoteName) {
continue
}
diff --git a/internal/sync/git_operations.go b/internal/sync/git_operations.go
index e5c68d9..ef41731 100644
--- a/internal/sync/git_operations.go
+++ b/internal/sync/git_operations.go
@@ -268,9 +268,9 @@ func getAllUniqueBranches(output []byte) []string {
return branches
}
-// createSSHBareRepository creates a bare repository on an SSH server
-func createSSHBareRepository(sshHost, repoPath string) error {
- userHost, sshArgs, basePath, err := parseSSHLocation(sshHost)
+// createSSHBareRepository creates a bare repository on an SSH server.
+func createSSHBareRepository(org *config.Organization, repoPath string) error {
+ userHost, sshArgs, basePath, err := repositoryCreationLocation(org)
if err != nil {
return err
}
@@ -293,6 +293,18 @@ func createSSHBareRepository(sshHost, repoPath string) error {
return nil
}
+func repositoryCreationLocation(org *config.Organization) (string, []string, string, error) {
+ if org == nil {
+ return "", nil, "", fmt.Errorf("backup organization is required")
+ }
+
+ if org.DescriptionSyncHost != "" && org.DescriptionSyncRoot != "" {
+ return org.DescriptionSyncHost, []string{org.DescriptionSyncHost}, org.DescriptionSyncRoot, nil
+ }
+
+ return parseSSHLocation(org.Host)
+}
+
func parseSSHLocation(sshHost string) (string, []string, string, error) {
if strings.HasPrefix(sshHost, "ssh://") {
parsed, err := url.Parse(sshHost)
@@ -330,7 +342,7 @@ func parseSSHLocation(sshHost string) (string, []string, string, error) {
// 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")
+ cmd := gitCommand(repoPath, pushBranchArgs(remoteName, branch, false, org)...)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -352,12 +364,12 @@ func pushBranchWithBackupSupport(repoPath, remoteName, branch string, remoteHasB
}
// Create the bare repository
- if err := createSSHBareRepository(org.Host, repoName); err != nil {
+ if err := createSSHBareRepository(org, repoName); err != nil {
return fmt.Errorf("failed to create SSH repository: %w", err)
}
// Try pushing again
- cmd = gitCommand(repoPath, "push", remoteName, branch, "--tags")
+ cmd = gitCommand(repoPath, pushBranchArgs(remoteName, branch, false, org)...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to push after creating repository: %w", err)
}
@@ -374,7 +386,7 @@ func pushBranchWithBackupSupport(repoPath, remoteName, branch string, remoteHasB
if isBranchMissing(outputStr) {
fmt.Printf(" Creating new branch on %s\n", remoteName)
// Try again with -u flag to set upstream
- cmd = gitCommand(repoPath, "push", "-u", remoteName, branch, "--tags")
+ cmd = gitCommand(repoPath, pushBranchArgs(remoteName, branch, true, org)...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to push to %s: %w", remoteName, err)
}
@@ -391,6 +403,18 @@ func pushBranchWithBackupSupport(repoPath, remoteName, branch string, remoteHasB
return nil
}
+func pushBranchArgs(remoteName, branch string, setUpstream bool, org *config.Organization) []string {
+ args := []string{"push"}
+ if setUpstream {
+ args = append(args, "-u")
+ }
+ args = append(args, remoteName, branch, "--tags")
+ if org != nil && org.BackupLocation && org.ForcePush {
+ args = append(args, "--force")
+ }
+ return args
+}
+
// getRemoteURL gets the URL for a given remote
func getRemoteURL(repoPath, remoteName string) (string, error) {
cmd := gitCommand(repoPath, "remote", "get-url", remoteName)
diff --git a/internal/sync/git_operations_test.go b/internal/sync/git_operations_test.go
index 4ca630d..ba40525 100644
--- a/internal/sync/git_operations_test.go
+++ b/internal/sync/git_operations_test.go
@@ -4,8 +4,11 @@ import (
"os"
"os/exec"
"path/filepath"
+ "reflect"
"strings"
"testing"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
)
func TestGitCommand_SetsDir(t *testing.T) {
@@ -24,6 +27,49 @@ func TestGitCommand_LeavesDirEmptyForGlobalCommands(t *testing.T) {
}
}
+func TestPushBranchArgs(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setUpstream bool
+ org *config.Organization
+ want []string
+ }{
+ {
+ name: "regular backup push",
+ org: &config.Organization{BackupLocation: true},
+ want: []string{"push", "backup", "main", "--tags"},
+ },
+ {
+ name: "forced backup push",
+ org: &config.Organization{BackupLocation: true, ForcePush: true},
+ want: []string{"push", "backup", "main", "--tags", "--force"},
+ },
+ {
+ name: "forced backup push with upstream",
+ setUpstream: true,
+ org: &config.Organization{BackupLocation: true, ForcePush: true},
+ want: []string{"push", "-u", "backup", "main", "--tags", "--force"},
+ },
+ {
+ name: "force ignored for primary remote",
+ org: &config.Organization{ForcePush: true},
+ want: []string{"push", "backup", "main", "--tags"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := pushBranchArgs("backup", "main", tt.setUpstream, tt.org)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("pushBranchArgs() = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestParseTagHashOutput_EmptyOutput(t *testing.T) {
for _, in := range [][]byte{nil, []byte(""), []byte(" \n\t \n")} {
hash, err := parseTagHashOutput(in, "v1.0.0", "origin")
diff --git a/internal/sync/repository_setup.go b/internal/sync/repository_setup.go
index 9bb0c2f..a6f01a9 100644
--- a/internal/sync/repository_setup.go
+++ b/internal/sync/repository_setup.go
@@ -54,14 +54,15 @@ func (s *Syncer) setupNewRepository(repoPath string) error {
continue // Skip the first org we already cloned from
}
org := &s.config.Organizations[i]
+ remoteName := s.getRemoteName(org)
// Skip backup locations unless backup sync is currently active.
- if org.BackupLocation && !s.backupActive() {
+ if org.BackupLocation && !s.backupActive(remoteName) {
continue
}
if err := s.addRemote(repoPath, org); err != nil {
- return fmt.Errorf("failed to add remote %s: %w", s.getRemoteName(org), err)
+ return fmt.Errorf("failed to add remote %s: %w", remoteName, err)
}
}
@@ -75,14 +76,13 @@ func (s *Syncer) setupExistingRepository(repoPath string) error {
// Check and add any missing remotes
for i := range s.config.Organizations {
org := &s.config.Organizations[i]
+ remoteName := s.getRemoteName(org)
// Skip backup locations unless backup sync is currently active.
- if org.BackupLocation && !s.backupActive() {
+ if org.BackupLocation && !s.backupActive(remoteName) {
continue
}
- remoteName := s.getRemoteName(org)
-
// Check if remote exists
cmd := exec.Command("git", "-C", repoPath, "remote", "get-url", remoteName)
if err := cmd.Run(); err != nil {
@@ -101,13 +101,13 @@ func (s *Syncer) getRemotesMap() map[string]*config.Organization {
remotes := make(map[string]*config.Organization)
for i := range s.config.Organizations {
org := &s.config.Organizations[i]
+ remoteName := s.getRemoteName(org)
// Skip backup locations unless backup sync is currently active.
- if org.BackupLocation && !s.backupActive() {
+ if org.BackupLocation && !s.backupActive(remoteName) {
continue
}
- remoteName := s.getRemoteName(org)
remotes[remoteName] = org
}
return remotes
diff --git a/internal/sync/sync.go b/internal/sync/sync.go
index 048803e..00f2032 100644
--- a/internal/sync/sync.go
+++ b/internal/sync/sync.go
@@ -13,8 +13,7 @@ import (
type backupSessionState struct {
mu stdsync.Mutex
- disabled bool
- reason string
+ disabled map[string]string
}
// Syncer handles repository synchronization between organizations
@@ -53,12 +52,12 @@ func (s *Syncer) SetBackupEnabled(enabled bool) {
s.backupEnabled = enabled
}
-func (s *Syncer) backupActive() bool {
+func (s *Syncer) backupActive(remoteName string) bool {
if !s.backupEnabled {
return false
}
- disabled, _ := s.backupSession.status()
+ disabled, _ := s.backupSession.status(remoteName)
return !disabled
}
@@ -67,31 +66,33 @@ func (s *Syncer) disableBackupForSession(remoteName string, err error) {
return
}
- reason := fmt.Sprintf("%s: %v", remoteName, err)
- if s.backupSession.disable(reason) {
+ if s.backupSession.disable(remoteName, err.Error()) {
fmt.Printf("Warning: Backup sync to %s failed: %v\n", remoteName, err)
- fmt.Println("Warning: Disabling backup sync for the remainder of this session.")
+ fmt.Printf("Warning: Disabling backup sync to %s for the remainder of this session.\n", remoteName)
}
}
-func (b *backupSessionState) disable(reason string) bool {
+func (b *backupSessionState) disable(remoteName, reason string) bool {
b.mu.Lock()
defer b.mu.Unlock()
- if b.disabled {
+ if _, disabled := b.disabled[remoteName]; disabled {
return false
}
- b.disabled = true
- b.reason = reason
+ if b.disabled == nil {
+ b.disabled = make(map[string]string)
+ }
+ b.disabled[remoteName] = reason
return true
}
-func (b *backupSessionState) status() (bool, string) {
+func (b *backupSessionState) status(remoteName string) (bool, string) {
b.mu.Lock()
defer b.mu.Unlock()
- return b.disabled, b.reason
+ reason, disabled := b.disabled[remoteName]
+ return disabled, reason
}
// SyncRepository synchronizes a repository across all configured organizations
@@ -283,7 +284,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.backupActive() {
+ if !s.backupActive(remote) {
// Silently skip - don't even print a message since backup is not enabled
continue
}