diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-22 09:54:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-22 09:54:49 +0300 |
| commit | ea3b3b2ffc4a1d3c335994e22056a957cd962d21 (patch) | |
| tree | 601aecf10b5d4a4721e895698d64e97b47561b5f /internal/sync | |
| parent | 813077134132a62d0c9c73c7020ed734ff9add0b (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.go | 37 | ||||
| -rw-r--r-- | internal/sync/branch_sync.go | 2 | ||||
| -rw-r--r-- | internal/sync/git_operations.go | 38 | ||||
| -rw-r--r-- | internal/sync/git_operations_test.go | 46 | ||||
| -rw-r--r-- | internal/sync/repository_setup.go | 14 | ||||
| -rw-r--r-- | internal/sync/sync.go | 29 |
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 } |
