From ea3b3b2ffc4a1d3c335994e22056a957cd962d21 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 22 Jul 2026 09:54:49 +0300 Subject: 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 --- .serena/project.yml | 74 +++++++++++++++++++++++++----------- README.md | 10 +++-- internal/cli/release.go | 17 +++------ internal/cli/release_test.go | 8 ++-- internal/config/config.go | 4 ++ internal/config/config_test.go | 18 +++++++++ internal/sync/backup_test.go | 37 +++++++++++++++--- internal/sync/branch_sync.go | 2 +- internal/sync/git_operations.go | 38 ++++++++++++++---- internal/sync/git_operations_test.go | 46 ++++++++++++++++++++++ internal/sync/repository_setup.go | 14 +++---- internal/sync/sync.go | 29 +++++++------- internal/version/version.go | 2 +- 13 files changed, 222 insertions(+), 77 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index be357f7..f93bf6b 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,27 +1,30 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "gitsyncer" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown # matlab msl nix ocaml pascal -# perl php php_phpactor powershell python -# python_jedi python_ty r rego ruby -# ruby_solargraph rust scala scss solidity -# swift systemverilog terraform toml typescript -# typescript_vts vue yaml zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript # - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: @@ -119,8 +122,8 @@ ignored_memory_patterns: [] # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of mode names to be activated additionally for this project, e.g. ["query-projects"] @@ -128,13 +131,38 @@ ls_specific_settings: {} # See https://oraios.github.io/serena/02-usage/050_configuration.html#modes added_modes: -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# list of additional workspace folder paths for cross-package reference support. # Paths can be absolute or relative to the project root. # Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. # Example: # additional_workspace_folders: # - ../sibling-package # - ../shared-lib -additional_workspace_folders: [] +ls_additional_workspace_folders: [] + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- . diff --git a/README.md b/README.md index 7fb166e..6da442b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ It has been vibe coded mainly using AI tools (Claude Code CLI and amp). - Never deletes branches (only adds/updates) - GitHub token validation tool - 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 +- Per-destination backup fail-fast for a run: after a backup fails, later repos skip only that destination - 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 @@ -58,6 +58,7 @@ Create a configuration file at `~/.config/gitsyncer/config.json` (or specify a c { "host": "user@nas.local:git", "backupLocation": true, + "forcePush": true, "descriptionSyncHost": "root@nas.local", "descriptionSyncRoot": "/srv/git/repos" } @@ -169,7 +170,7 @@ gitsyncer sync bidirectional gitsyncer sync bidirectional --dry-run ``` -`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. +`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 a backup push fails because that host is offline or unavailable, GitSyncer records that failure in memory and skips that destination for the rest of the process while continuing the primary sync targets and other backups. ### Release Management @@ -347,7 +348,8 @@ You can configure SSH backup locations for one-way repository backups to private }, { "host": "paul@t450:git", - "backupLocation": true + "backupLocation": true, + "forcePush": true } ] } @@ -361,9 +363,11 @@ You can configure SSH backup locations for one-way repository backups to private - SSH into the server - Create the directory structure - Initialize a bare git repository + - Use `descriptionSyncHost` and `descriptionSyncRoot` for creation when configured, allowing the Git remote endpoint itself to remain restricted 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 +7. **Optional force push**: Set `forcePush` to `true` on a backup organization to overwrite divergent branches and tags. This setting is rejected for non-backup organizations ### SSH Backup Example diff --git a/internal/cli/release.go b/internal/cli/release.go index d57148d..c3cb1c5 100644 --- a/internal/cli/release.go +++ b/internal/cli/release.go @@ -447,7 +447,8 @@ func resolveReleaseNotes( } cacheKey := fmt.Sprintf("%s:%s", repoName, tag) - if cachedNotes, exists := aiReleaseNotesCache[cacheKey]; exists && !flags.Force { + // Force controls sync scheduling and must not invalidate release-note cache entries. + if cachedNotes, exists := aiReleaseNotesCache[cacheKey]; exists { if mode == releaseNotesModeUpdate { fmt.Printf(" Using cached AI release notes for existing release %s\n", tag) } else { @@ -456,18 +457,10 @@ func resolveReleaseNotes( return cachedNotes, true } - if flags.Force && aiReleaseNotesCache[cacheKey] != "" { - if mode == releaseNotesModeUpdate { - fmt.Printf(" Force regenerating AI release notes for existing release %s (ignoring cache)\n", tag) - } else { - fmt.Printf(" Force regenerating AI release notes for %s (ignoring cache)\n", tag) - } + if mode == releaseNotesModeUpdate { + fmt.Printf(" Generating AI release notes for existing release %s...\n", tag) } else { - if mode == releaseNotesModeUpdate { - fmt.Printf(" Generating AI release notes for existing release %s...\n", tag) - } else { - fmt.Printf(" Generating AI release notes for %s...\n", tag) - } + fmt.Printf(" Generating AI release notes for %s...\n", tag) } aiNotes, err := releaseManager.GenerateAIReleaseNotes(repoPath, repoName, tag, localTags, commits) diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go index 12876ae..a3d89a3 100644 --- a/internal/cli/release_test.go +++ b/internal/cli/release_test.go @@ -72,9 +72,9 @@ func TestResolveReleaseNotes_CreateWithoutAIUsesStandardNotes(t *testing.T) { } } -func TestResolveReleaseNotes_CreateUsesCachedAINotes(t *testing.T) { +func TestResolveReleaseNotes_CreateWithForceStillUsesCachedAINotes(t *testing.T) { gen := &fakeReleaseNotesGenerator{aiNotes: "new ai notes"} - flags := &Flags{AIReleaseNotes: true} + flags := &Flags{AIReleaseNotes: true, Force: true} cache := map[string]string{"demo:v1.0.0": "cached ai notes"} failed := []string{} @@ -111,7 +111,7 @@ func TestResolveReleaseNotes_CreateAIFailureFallsBackAndClearsCache(t *testing.T standardNotes: "fallback notes", } flags := &Flags{AIReleaseNotes: true, Force: true} - cache := map[string]string{"demo:v1.0.0": "stale"} + cache := map[string]string{} failed := []string{} saveCalls := 0 @@ -157,7 +157,7 @@ func TestResolveReleaseNotes_UpdateAIFailureSkipsUpdate(t *testing.T) { standardNotes: "unused fallback", } flags := &Flags{AIReleaseNotes: true, Force: true} - cache := map[string]string{"demo:v1.0.0": "stale"} + cache := map[string]string{} failed := []string{} saveCalls := 0 diff --git a/internal/config/config.go b/internal/config/config.go index afccf33..024513f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ type Organization struct { GitHubToken string `json:"github_token,omitempty"` CodebergToken string `json:"codeberg_token,omitempty"` BackupLocation bool `json:"backupLocation,omitempty"` // Mark this as a backup-only destination + ForcePush bool `json:"forcePush,omitempty"` // Force-update branches and tags at this backup 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 } @@ -116,6 +117,9 @@ func (c *Config) Validate() error { if org.Name == "" && !strings.HasPrefix(org.Host, "file://") && !org.IsSSH() { return fmt.Errorf("organization %d: missing name", i) } + if org.ForcePush && !org.BackupLocation { + return fmt.Errorf("organization %d: forcePush requires backupLocation", i) + } hasDescriptionSyncHost := strings.TrimSpace(org.DescriptionSyncHost) != "" hasDescriptionSyncRoot := strings.TrimSpace(org.DescriptionSyncRoot) != "" if hasDescriptionSyncHost != hasDescriptionSyncRoot { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eb81626..da700ca 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,6 +49,24 @@ func TestValidate_DescriptionSyncFieldsMustBePaired(t *testing.T) { } } +func TestValidate_ForcePushRequiresBackupLocation(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Organizations: []Organization{ + {Host: "git@github.com", Name: "test-user", ForcePush: true}, + }, + } + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() error = nil, want forcePush validation error") + } + if !strings.Contains(err.Error(), "forcePush requires backupLocation") { + t.Fatalf("Validate() error = %q, want forcePush context", err) + } +} + func TestFindOrganization_ReturnsPointerToStoredElement(t *testing.T) { t.Parallel() 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 } diff --git a/internal/version/version.go b/internal/version/version.go index 3496d4b..b06f7bf 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.18.5" + Version = "0.18.8" // GitCommit is the git commit hash at build time GitCommit = "unknown" -- cgit v1.2.3