summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/description_sync.go71
-rw-r--r--internal/cli/description_sync_test.go57
-rw-r--r--internal/cli/sync_handlers.go14
-rw-r--r--internal/cli/sync_handlers_test.go19
-rw-r--r--internal/config/config.go17
-rw-r--r--internal/config/config_test.go22
-rw-r--r--internal/showcase/showcase.go12
-rw-r--r--internal/showcase/showcase_test.go19
-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
-rw-r--r--internal/version/version.go2
14 files changed, 415 insertions, 39 deletions
diff --git a/internal/cli/description_sync.go b/internal/cli/description_sync.go
index 4904b56..317bcdf 100644
--- a/internal/cli/description_sync.go
+++ b/internal/cli/description_sync.go
@@ -2,6 +2,10 @@ package cli
import (
"fmt"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
"strings"
"codeberg.org/snonux/gitsyncer/internal/codeberg"
@@ -105,8 +109,75 @@ func syncRepoDescriptions(cfg *config.Config, dryRun bool, repoName, knownCBDesc
}
}
+ syncBackupDescriptions(cfg, dryRun, repoName, canonical)
+
// Update cache
if cache != nil {
cache[repoName] = canonical
}
}
+
+func syncBackupDescriptions(cfg *config.Config, dryRun bool, repoName, canonical string) {
+ if cfg == nil || canonical == "" {
+ return
+ }
+
+ for i := range cfg.Organizations {
+ org := &cfg.Organizations[i]
+ if !org.BackupLocation {
+ continue
+ }
+
+ supported, err := syncBackupDescription(org, repoName, canonical, dryRun)
+ if err != nil {
+ fmt.Printf(" Warning: Failed to update backup description on %s: %v\n", org.Host, err)
+ continue
+ }
+ if supported && !dryRun {
+ fmt.Printf(" Updated backup description for %s on %s\n", repoName, org.Host)
+ }
+ }
+}
+
+func syncBackupDescription(org *config.Organization, repoName, description string, dryRun bool) (bool, error) {
+ if org == nil || !org.BackupLocation {
+ return false, nil
+ }
+
+ description = strings.TrimSpace(description)
+ if description == "" {
+ return false, nil
+ }
+
+ if strings.HasPrefix(org.Host, "file://") {
+ descriptionPath := filepath.Join(strings.TrimPrefix(org.Host, "file://"), repoName+".git", "description")
+ if dryRun {
+ fmt.Printf(" [DRY RUN] Would update backup description for %s on %s -> %q\n", repoName, org.Host, description)
+ return true, nil
+ }
+ return true, os.WriteFile(descriptionPath, []byte(description+"\n"), 0644)
+ }
+
+ if strings.TrimSpace(org.DescriptionSyncHost) == "" || strings.TrimSpace(org.DescriptionSyncRoot) == "" {
+ return false, nil
+ }
+
+ descriptionPath := path.Join(org.DescriptionSyncRoot, repoName+".git", "description")
+ if dryRun {
+ fmt.Printf(" [DRY RUN] Would update backup description for %s on %s -> %q\n", repoName, org.Host, description)
+ return true, nil
+ }
+
+ cmd := exec.Command("ssh", org.DescriptionSyncHost, fmt.Sprintf("cat > %s", shellSingleQuote(descriptionPath)))
+ cmd.Stdin = strings.NewReader(description + "\n")
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return true, fmt.Errorf("ssh write failed: %w\n%s", err, strings.TrimSpace(string(output)))
+ }
+
+ return true, nil
+}
+
+func shellSingleQuote(value string) string {
+ return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
+}
diff --git a/internal/cli/description_sync_test.go b/internal/cli/description_sync_test.go
new file mode 100644
index 0000000..8f92349
--- /dev/null
+++ b/internal/cli/description_sync_test.go
@@ -0,0 +1,57 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
+)
+
+func TestSyncBackupDescription_FileURLWritesDescription(t *testing.T) {
+ t.Parallel()
+
+ rootDir := t.TempDir()
+ repoDir := filepath.Join(rootDir, "sample.git")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatalf("mkdir repo dir: %v", err)
+ }
+
+ org := &config.Organization{
+ Host: "file://" + rootDir,
+ BackupLocation: true,
+ }
+
+ supported, err := syncBackupDescription(org, "sample", "Sample description", false)
+ if err != nil {
+ t.Fatalf("syncBackupDescription() error = %v", err)
+ }
+ if !supported {
+ t.Fatal("expected file backup description sync to be supported")
+ }
+
+ content, err := os.ReadFile(filepath.Join(repoDir, "description"))
+ if err != nil {
+ t.Fatalf("read description: %v", err)
+ }
+ if string(content) != "Sample description\n" {
+ t.Fatalf("description = %q, want %q", string(content), "Sample description\n")
+ }
+}
+
+func TestSyncBackupDescription_SSHWithoutDescriptionSyncConfigIsUnsupported(t *testing.T) {
+ t.Parallel()
+
+ org := &config.Organization{
+ Host: "ssh://git@example.com/repos",
+ BackupLocation: true,
+ }
+
+ supported, err := syncBackupDescription(org, "sample", "Sample description", false)
+ if err != nil {
+ t.Fatalf("syncBackupDescription() error = %v", err)
+ }
+ if supported {
+ t.Fatal("expected SSH backup description sync without config to be unsupported")
+ }
+}
diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go
index 6a96b92..e791b45 100644
--- a/internal/cli/sync_handlers.go
+++ b/internal/cli/sync_handlers.go
@@ -12,6 +12,14 @@ import (
"codeberg.org/snonux/gitsyncer/internal/sync"
)
+func shouldEnableBackupSync(flags *Flags) bool {
+ if flags == nil {
+ return false
+ }
+
+ return flags.Backup || flags.FullSync
+}
+
// HandleSync handles syncing a single repository
func HandleSync(cfg *config.Config, flags *Flags) int {
stateManager, syncState, err := loadSyncState(flags.WorkDir)
@@ -50,7 +58,7 @@ func HandleSync(cfg *config.Config, flags *Flags) int {
}
syncer := sync.New(cfg, flags.WorkDir)
- syncer.SetBackupEnabled(flags.Backup)
+ syncer.SetBackupEnabled(shouldEnableBackupSync(flags))
if err := syncer.SyncRepository(flags.SyncRepo); err != nil {
fmt.Printf("ERROR: Sync failed: %v\n", err)
return 1
@@ -107,7 +115,7 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int {
}
syncer := sync.New(cfg, flags.WorkDir)
- syncer.SetBackupEnabled(flags.Backup)
+ syncer.SetBackupEnabled(shouldEnableBackupSync(flags))
successCount := 0
// Load descriptions cache
descCache := loadDescriptionCache(flags.WorkDir)
@@ -421,7 +429,7 @@ func newSyncExecution(cfg *config.Config, flags *Flags) *syncExecution {
descCache: loadDescriptionCache(flags.WorkDir),
syncer: sync.New(cfg, flags.WorkDir),
}
- execution.syncer.SetBackupEnabled(flags.Backup)
+ execution.syncer.SetBackupEnabled(shouldEnableBackupSync(flags))
manager, st, err := loadSyncState(flags.WorkDir)
if err != nil {
diff --git a/internal/cli/sync_handlers_test.go b/internal/cli/sync_handlers_test.go
new file mode 100644
index 0000000..2eaad67
--- /dev/null
+++ b/internal/cli/sync_handlers_test.go
@@ -0,0 +1,19 @@
+package cli
+
+import "testing"
+
+func TestShouldEnableBackupSync_FullSyncImplicitlyEnablesBackup(t *testing.T) {
+ t.Parallel()
+
+ if !shouldEnableBackupSync(&Flags{FullSync: true}) {
+ t.Fatal("expected full sync to enable backup sync implicitly")
+ }
+
+ if !shouldEnableBackupSync(&Flags{Backup: true}) {
+ t.Fatal("expected explicit --backup to enable backup sync")
+ }
+
+ if shouldEnableBackupSync(&Flags{}) {
+ t.Fatal("did not expect backup sync to be enabled by default")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 4e40cdf..02f2fee 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -10,11 +10,13 @@ import (
// Organization represents a git organization with its host and name
type Organization struct {
- Host string `json:"host"`
- Name string `json:"name"`
- GitHubToken string `json:"github_token,omitempty"`
- CodebergToken string `json:"codeberg_token,omitempty"`
- BackupLocation bool `json:"backupLocation,omitempty"` // Mark this as a backup-only destination
+ Host string `json:"host"`
+ Name string `json:"name"`
+ GitHubToken string `json:"github_token,omitempty"`
+ CodebergToken string `json:"codeberg_token,omitempty"`
+ BackupLocation bool `json:"backupLocation,omitempty"` // Mark this as a backup-only 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
}
// Config holds the application configuration
@@ -101,6 +103,11 @@ func (c *Config) Validate() error {
if org.Name == "" && !strings.HasPrefix(org.Host, "file://") && !org.IsSSH() {
return fmt.Errorf("organization %d: missing name", i)
}
+ hasDescriptionSyncHost := strings.TrimSpace(org.DescriptionSyncHost) != ""
+ hasDescriptionSyncRoot := strings.TrimSpace(org.DescriptionSyncRoot) != ""
+ if hasDescriptionSyncHost != hasDescriptionSyncRoot {
+ return fmt.Errorf("organization %d: descriptionSyncHost and descriptionSyncRoot must be set together", i)
+ }
}
for repo, branch := range c.ShowcaseStatsBranches {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index db70457..30b59df 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -25,3 +25,25 @@ func TestValidate_ShowcaseStatsBranchesRejectsEmptyBranch(t *testing.T) {
t.Fatalf("Validate() error = %q, want showcase_stats_branches context", err)
}
}
+
+func TestValidate_DescriptionSyncFieldsMustBePaired(t *testing.T) {
+ t.Parallel()
+
+ cfg := &Config{
+ Organizations: []Organization{
+ {
+ Host: "ssh://git@example.com/repos",
+ BackupLocation: true,
+ DescriptionSyncHost: "root@example.com",
+ },
+ },
+ }
+
+ err := cfg.Validate()
+ if err == nil {
+ t.Fatal("Validate() error = nil, want description sync validation error")
+ }
+ if !strings.Contains(err.Error(), "descriptionSyncHost") {
+ t.Fatalf("Validate() error = %q, want descriptionSyncHost context", err)
+ }
+}
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index 9cf43a6..c6154f9 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -27,6 +27,7 @@ type ProjectSummary struct {
Summary string
CodebergURL string
GitHubURL string
+ CgitURL string
Metadata *RepoMetadata
RankHistory []RepoRankHistory // Latest 5 weekly rank points, newest first
Images []string // Relative paths to images in showcase directory
@@ -669,9 +670,10 @@ func (g *Generator) getRepositories() ([]string, error) {
return repos, nil
}
-func (g *Generator) buildProjectLinks(repoName string) (string, string) {
+func (g *Generator) buildProjectLinks(repoName string) (string, string, string) {
codebergURL := ""
githubURL := ""
+ cgitURL := fmt.Sprintf("https://cgit.f3s.buetow.org/%s/", repoName)
if codebergOrg := g.config.FindCodebergOrg(); codebergOrg != nil {
codebergURL = fmt.Sprintf("https://codeberg.org/%s/%s", codebergOrg.Name, repoName)
@@ -681,7 +683,7 @@ func (g *Generator) buildProjectLinks(repoName string) (string, string) {
githubURL = fmt.Sprintf("https://github.com/%s/%s", githubOrg.Name, repoName)
}
- return codebergURL, githubURL
+ return codebergURL, githubURL, cgitURL
}
func (g *Generator) prepareStatsRepoPath(repoName, repoPath string) (string, func() error, error) {
@@ -833,7 +835,7 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
summary = sanitizeSummaryForGemtext(summary)
// Build URLs
- codebergURL, githubURL := g.buildProjectLinks(repoName)
+ codebergURL, githubURL, cgitURL := g.buildProjectLinks(repoName)
// Always extract images from README (not cached)
fmt.Printf("Extracting images from README...\n")
@@ -865,6 +867,7 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
Summary: summary,
CodebergURL: codebergURL,
GitHubURL: githubURL,
+ CgitURL: cgitURL,
Metadata: metadata,
Images: images,
CodeSnippet: codeSnippet,
@@ -1078,6 +1081,9 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
if summary.GitHubURL != "" {
builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL))
}
+ if summary.CgitURL != "" {
+ builder.WriteString(fmt.Sprintf("=> %s View in cgit\n", summary.CgitURL))
+ }
}
diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go
index fa18799..cd571f8 100644
--- a/internal/showcase/showcase_test.go
+++ b/internal/showcase/showcase_test.go
@@ -148,6 +148,25 @@ func TestFormatGemtext_SanitizesMarkdownHeadingsInSummary(t *testing.T) {
}
}
+func TestFormatGemtext_IncludesCgitLink(t *testing.T) {
+ t.Parallel()
+
+ g := &Generator{config: &config.Config{}}
+ content := g.formatGemtext([]ProjectSummary{
+ {
+ Name: "cpuinfo",
+ Summary: "summary",
+ CodebergURL: "https://codeberg.org/snonux/cpuinfo",
+ GitHubURL: "https://github.com/snonux/cpuinfo",
+ CgitURL: "https://cgit.f3s.buetow.org/cpuinfo/",
+ },
+ })
+
+ if !strings.Contains(content, "=> https://cgit.f3s.buetow.org/cpuinfo/ View in cgit\n") {
+ t.Fatalf("cgit link was not rendered: %s", content)
+ }
+}
+
func TestFindReadmeContent_UsesRepoPathWithoutChangingCWD(t *testing.T) {
t.Parallel()
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
diff --git a/internal/version/version.go b/internal/version/version.go
index afd3d4e..189f928 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.16.0"
+ Version = "0.17.0"
// GitCommit is the git commit hash at build time
GitCommit = "unknown"