summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-06-23 17:36:03 +0300
committerPaul Buetow <paul@buetow.org>2025-06-23 17:36:03 +0300
commit8706e6a82819c0c16a0c157283de2f14af2664c3 (patch)
treebacf3d7f61e14d0400cb541e36f5ab49de6d7a33
parent60691a6fb610cb7f7290d6ab3a26bc74f95af611 (diff)
Add repository synchronization functionality
- Create sync package to handle git repository synchronization - Implement multi-organization sync with branch tracking - Add merge conflict detection and error handling - Support cloning, fetching, merging, and pushing across all remotes - Add --sync flag to synchronize repositories - Add --work-dir flag for working directory specification - Create test infrastructure with setup and conflict test scripts - Update config validation to support file:// URLs - Add comprehensive .gitignore entries for test artifacts The sync package automatically: - Clones repositories if not present - Fetches updates from all configured organizations - Merges changes from all remotes for each branch - Pushes synchronized changes to all organizations - Detects and reports merge conflicts for manual resolution Test with: ./test/setup_test_repos.sh && ./gitsyncer --config test/test-config.json --sync test-repo 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--.gitignore8
-rw-r--r--cmd/gitsyncer/main.go24
-rwxr-xr-xgitsyncerbin3066401 -> 3383664 bytes
-rw-r--r--internal/config/config.go4
-rw-r--r--internal/sync/sync.go314
-rwxr-xr-xtest/setup_test_repos.sh107
-rwxr-xr-xtest/test_conflict.sh42
7 files changed, 495 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index 7101fd7..9edbe9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,10 @@ go.work.sum
# OS files
.DS_Store
-Thumbs.db \ No newline at end of file
+Thumbs.db
+
+# Test artifacts
+test/work/
+test/work-*/
+test/repos/
+.gitsyncer-work/ \ No newline at end of file
diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go
index d859747..dfee39c 100644
--- a/cmd/gitsyncer/main.go
+++ b/cmd/gitsyncer/main.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"github.com/paul/gitsyncer/internal/config"
+ "github.com/paul/gitsyncer/internal/sync"
"github.com/paul/gitsyncer/internal/version"
)
@@ -16,6 +17,8 @@ func main() {
versionFlag bool
configPath string
listOrgs bool
+ syncRepo string
+ workDir string
)
// Define command line flags
@@ -24,6 +27,8 @@ func main() {
flag.StringVar(&configPath, "config", "", "path to configuration file")
flag.StringVar(&configPath, "c", "", "path to configuration file (short)")
flag.BoolVar(&listOrgs, "list-orgs", false, "list configured organizations")
+ flag.StringVar(&syncRepo, "sync", "", "repository name to sync")
+ flag.StringVar(&workDir, "work-dir", ".gitsyncer-work", "working directory for cloning repositories")
flag.Parse()
// Handle version flag
@@ -88,8 +93,23 @@ func main() {
os.Exit(0)
}
- // TODO: Implement main gitsyncer functionality
+ // Handle sync operation
+ if syncRepo != "" {
+ syncer := sync.New(cfg, workDir)
+ if err := syncer.SyncRepository(syncRepo); err != nil {
+ log.Fatal("Sync failed:", err)
+ }
+ os.Exit(0)
+ }
+
+ // Default: show usage
fmt.Println("\ngitsyncer - Git repository synchronization tool")
fmt.Printf("Configured with %d organization(s)\n", len(cfg.Organizations))
- fmt.Println("\nUse --list-orgs to display configured organizations")
+ fmt.Println("\nUsage:")
+ fmt.Println(" gitsyncer --sync <repo-name> Sync a repository across all organizations")
+ fmt.Println(" gitsyncer --list-orgs List configured organizations")
+ fmt.Println(" gitsyncer --version Show version information")
+ fmt.Println("\nOptions:")
+ fmt.Println(" --config <path> Path to configuration file")
+ fmt.Println(" --work-dir <path> Working directory for operations (default: .gitsyncer-work)")
} \ No newline at end of file
diff --git a/gitsyncer b/gitsyncer
index 9134faa..d8f95c8 100755
--- a/gitsyncer
+++ b/gitsyncer
Binary files differ
diff --git a/internal/config/config.go b/internal/config/config.go
index 91c107a..0fe1ac8 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
)
// Organization represents a git organization with its host and name
@@ -59,7 +60,8 @@ func (c *Config) Validate() error {
if org.Host == "" {
return fmt.Errorf("organization %d: missing host", i)
}
- if org.Name == "" {
+ // Name can be empty for file:// URLs
+ if org.Name == "" && !strings.HasPrefix(org.Host, "file://") {
return fmt.Errorf("organization %d: missing name", i)
}
}
diff --git a/internal/sync/sync.go b/internal/sync/sync.go
new file mode 100644
index 0000000..4fe1b13
--- /dev/null
+++ b/internal/sync/sync.go
@@ -0,0 +1,314 @@
+package sync
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/paul/gitsyncer/internal/config"
+)
+
+// Syncer handles repository synchronization between organizations
+type Syncer struct {
+ config *config.Config
+ workDir string
+ repoName string
+}
+
+// New creates a new Syncer instance
+func New(cfg *config.Config, workDir string) *Syncer {
+ return &Syncer{
+ config: cfg,
+ workDir: workDir,
+ }
+}
+
+// SyncRepository synchronizes a repository across all configured organizations
+func (s *Syncer) SyncRepository(repoName string) error {
+ s.repoName = repoName
+
+ // Create work directory if it doesn't exist
+ if err := os.MkdirAll(s.workDir, 0755); err != nil {
+ return fmt.Errorf("failed to create work directory: %w", err)
+ }
+
+ // Get all remotes
+ remotes := make(map[string]*config.Organization)
+ for i := range s.config.Organizations {
+ org := &s.config.Organizations[i]
+ remoteName := s.getRemoteName(org)
+ remotes[remoteName] = org
+ }
+
+ // Clone or update the repository
+ repoPath := filepath.Join(s.workDir, repoName)
+ if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+ // Clone from the first organization
+ if len(s.config.Organizations) == 0 {
+ return fmt.Errorf("no organizations configured")
+ }
+
+ firstOrg := &s.config.Organizations[0]
+ if err := s.cloneRepository(firstOrg, repoPath); err != nil {
+ return fmt.Errorf("failed to clone repository: %w", err)
+ }
+
+ // Rename origin to the proper remote name
+ firstRemoteName := s.getRemoteName(firstOrg)
+ cmd := exec.Command("git", "-C", repoPath, "remote", "rename", "origin", firstRemoteName)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to rename origin remote: %w", err)
+ }
+
+ // Add other organizations as remotes
+ for i := 1; i < len(s.config.Organizations); i++ {
+ org := &s.config.Organizations[i]
+ if err := s.addRemote(repoPath, org); err != nil {
+ return fmt.Errorf("failed to add remote %s: %w", s.getRemoteName(org), err)
+ }
+ }
+ }
+
+ // Change to repository directory
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("failed to get current directory: %w", err)
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(repoPath); err != nil {
+ return fmt.Errorf("failed to change to repository directory: %w", err)
+ }
+
+ // Fetch all remotes
+ fmt.Printf("Fetching updates from all remotes...\n")
+ if err := s.fetchAll(); err != nil {
+ return fmt.Errorf("failed to fetch remotes: %w", err)
+ }
+
+ // Get all branches
+ branches, err := s.getAllBranches()
+ if err != nil {
+ return fmt.Errorf("failed to get branches: %w", err)
+ }
+
+ // Sync each branch
+ for _, branch := range branches {
+ fmt.Printf("\nSyncing branch: %s\n", branch)
+ if err := s.syncBranch(branch, remotes); err != nil {
+ return fmt.Errorf("failed to sync branch %s: %w", branch, err)
+ }
+ }
+
+ fmt.Printf("\nRepository %s synchronized successfully!\n", repoName)
+ return nil
+}
+
+// cloneRepository clones a repository from an organization
+func (s *Syncer) cloneRepository(org *config.Organization, repoPath string) error {
+ // For file:// URLs, we need special handling
+ var cloneURL string
+ if strings.HasPrefix(org.Host, "file://") {
+ // For local file paths, the format is: file:///path/to/repo.git
+ cloneURL = fmt.Sprintf("%s/%s.git", org.Host, s.repoName)
+ } else {
+ // For SSH URLs, the format is: git@host:org/repo.git
+ cloneURL = fmt.Sprintf("%s/%s.git", org.GetGitURL(), s.repoName)
+ }
+
+ fmt.Printf("Cloning from %s...\n", cloneURL)
+
+ cmd := exec.Command("git", "clone", cloneURL, repoPath)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// addRemote adds a remote to the repository
+func (s *Syncer) addRemote(repoPath string, org *config.Organization) error {
+ remoteName := s.getRemoteName(org)
+
+ // For file:// URLs, we need special handling
+ var remoteURL string
+ if strings.HasPrefix(org.Host, "file://") {
+ remoteURL = fmt.Sprintf("%s/%s.git", org.Host, s.repoName)
+ } else {
+ remoteURL = fmt.Sprintf("%s/%s.git", org.GetGitURL(), s.repoName)
+ }
+
+ fmt.Printf("Adding remote %s: %s\n", remoteName, remoteURL)
+
+ cmd := exec.Command("git", "-C", repoPath, "remote", "add", remoteName, remoteURL)
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// fetchAll fetches from all remotes
+func (s *Syncer) fetchAll() error {
+ cmd := exec.Command("git", "fetch", "--all", "--prune")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+// getAllBranches gets all unique branches from all remotes
+func (s *Syncer) getAllBranches() ([]string, error) {
+ cmd := exec.Command("git", "branch", "-r")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+
+ branchMap := make(map[string]bool)
+ lines := strings.Split(string(output), "\n")
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.Contains(line, "->") {
+ continue
+ }
+
+ // Extract branch name from remote/branch format
+ parts := strings.SplitN(line, "/", 2)
+ if len(parts) == 2 {
+ branch := parts[1]
+ branchMap[branch] = true
+ }
+ }
+
+ // Convert map to slice
+ branches := make([]string, 0, len(branchMap))
+ for branch := range branchMap {
+ branches = append(branches, branch)
+ }
+
+ return branches, nil
+}
+
+// syncBranch synchronizes a specific branch across all remotes
+func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organization) error {
+ // Create or checkout the branch
+ if err := s.checkoutBranch(branch); err != nil {
+ return fmt.Errorf("failed to checkout branch %s: %w", branch, err)
+ }
+
+ // Track which remotes have this branch
+ remotesWithBranch := make(map[string]bool)
+
+ // Check which remotes have this branch
+ for remoteName := range remotes {
+ if s.remoteBranchExists(remoteName, branch) {
+ remotesWithBranch[remoteName] = true
+ }
+ }
+
+ // If no remotes have this branch, skip it
+ if len(remotesWithBranch) == 0 {
+ fmt.Printf(" Branch %s not found on any remote, skipping\n", branch)
+ return nil
+ }
+
+ // Merge changes from all remotes that have this branch
+ for remoteName := range remotesWithBranch {
+ fmt.Printf(" Merging from %s/%s...\n", remoteName, branch)
+
+ cmd := exec.Command("git", "merge", fmt.Sprintf("%s/%s", remoteName, branch), "--no-edit")
+ output, err := cmd.CombinedOutput()
+
+ if err != nil {
+ // Check if it's a merge conflict
+ if strings.Contains(string(output), "CONFLICT") {
+ return fmt.Errorf("merge conflict detected when merging %s/%s. Please resolve manually", remoteName, branch)
+ }
+ return fmt.Errorf("failed to merge %s/%s: %w\n%s", remoteName, branch, err, string(output))
+ }
+ }
+
+ // Push to all remotes
+ for remoteName, org := range remotes {
+ fmt.Printf(" Pushing to %s (%s)...\n", remoteName, org.Host)
+
+ cmd := exec.Command("git", "push", remoteName, branch)
+ output, err := cmd.CombinedOutput()
+
+ if err != nil {
+ // Check if it's because the branch doesn't exist on the remote
+ if strings.Contains(string(output), "error: src refspec") {
+ fmt.Printf(" Creating new branch on %s\n", remoteName)
+ // Try again with -u flag to set upstream
+ cmd = exec.Command("git", "push", "-u", remoteName, branch)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to push to %s: %w", remoteName, err)
+ }
+ } else {
+ return fmt.Errorf("failed to push to %s: %w\n%s", remoteName, err, string(output))
+ }
+ }
+ }
+
+ return nil
+}
+
+// checkoutBranch checks out a branch, creating it if necessary
+func (s *Syncer) checkoutBranch(branch string) error {
+ // First try to checkout existing branch
+ cmd := exec.Command("git", "checkout", branch)
+ if err := cmd.Run(); err == nil {
+ return nil
+ }
+
+ // If that fails, create a new branch tracking the first remote that has it
+ for i := range s.config.Organizations {
+ org := &s.config.Organizations[i]
+ remoteName := s.getRemoteName(org)
+
+ if s.remoteBranchExists(remoteName, branch) {
+ cmd = exec.Command("git", "checkout", "-b", branch, fmt.Sprintf("%s/%s", remoteName, branch))
+ return cmd.Run()
+ }
+ }
+
+ return fmt.Errorf("branch %s not found on any remote", branch)
+}
+
+// remoteBranchExists checks if a branch exists on a remote
+func (s *Syncer) remoteBranchExists(remoteName, branch string) bool {
+ cmd := exec.Command("git", "branch", "-r", "--list", fmt.Sprintf("%s/%s", remoteName, branch))
+ output, err := cmd.Output()
+ if err != nil {
+ return false
+ }
+ return strings.TrimSpace(string(output)) != ""
+}
+
+// getRemoteName generates a remote name for an organization
+func (s *Syncer) getRemoteName(org *config.Organization) string {
+ // Use the host without git@ or file:// prefix as remote name
+ host := org.Host
+ host = strings.TrimPrefix(host, "git@")
+ host = strings.TrimPrefix(host, "file://")
+ host = strings.ReplaceAll(host, ":", "_")
+ host = strings.ReplaceAll(host, ".", "_")
+ host = strings.ReplaceAll(host, "/", "_")
+
+ // For file URLs, create a simpler name
+ if strings.HasPrefix(org.Host, "file://") {
+ // Get the last part of the path
+ parts := strings.Split(strings.TrimPrefix(org.Host, "file://"), "/")
+ if len(parts) > 0 {
+ return parts[len(parts)-1]
+ }
+ }
+
+ return host
+} \ No newline at end of file
diff --git a/test/setup_test_repos.sh b/test/setup_test_repos.sh
new file mode 100755
index 0000000..786b3f3
--- /dev/null
+++ b/test/setup_test_repos.sh
@@ -0,0 +1,107 @@
+#!/bin/bash
+
+# Setup script for creating test git repositories
+set -e
+
+echo "Setting up test repositories..."
+
+# Create test directory structure
+TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPOS_DIR="$TEST_DIR/repos"
+mkdir -p "$REPOS_DIR"
+
+# Create two bare repositories (simulating remote repos)
+REPO1_DIR="$REPOS_DIR/org1"
+REPO2_DIR="$REPOS_DIR/org2"
+
+# Clean up if they exist
+rm -rf "$REPO1_DIR" "$REPO2_DIR"
+mkdir -p "$REPO1_DIR" "$REPO2_DIR"
+
+# Initialize bare repositories
+echo "Creating bare repository in org1..."
+cd "$REPO1_DIR"
+git init --bare test-repo.git
+
+echo "Creating bare repository in org2..."
+cd "$REPO2_DIR"
+git init --bare test-repo.git
+
+# Create a temporary working directory to add initial content
+WORK_DIR="$REPOS_DIR/work"
+rm -rf "$WORK_DIR"
+mkdir -p "$WORK_DIR"
+
+# Clone from org1 and add initial content
+echo "Adding initial content..."
+cd "$WORK_DIR"
+git clone "$REPO1_DIR/test-repo.git"
+cd test-repo
+
+# Create initial files
+echo "# Test Repository" > README.md
+echo "This is a test repository for gitsyncer" >> README.md
+
+mkdir -p src
+echo "package main
+
+import \"fmt\"
+
+func main() {
+ fmt.Println(\"Hello from test repo!\")
+}" > src/main.go
+
+# Create initial commit
+git add .
+git commit -m "Initial commit"
+
+# Create develop branch
+git checkout -b develop
+echo "Development branch" > DEVELOP.md
+git add DEVELOP.md
+git commit -m "Add develop branch marker"
+
+# Create feature branch
+git checkout -b feature/test-feature
+echo "Feature content" > feature.txt
+git add feature.txt
+git commit -m "Add feature"
+
+# Push all branches to org1
+git push origin main
+git push origin develop
+git push origin feature/test-feature
+
+# Add org2 as remote and push only main branch initially
+git remote add org2 "$REPO2_DIR/test-repo.git"
+git checkout main
+git push org2 main
+
+# Clean up work directory
+cd "$TEST_DIR"
+rm -rf "$WORK_DIR"
+
+# Create test config file
+echo "Creating test configuration..."
+cat > "$TEST_DIR/test-config.json" << EOF
+{
+ "organizations": [
+ {
+ "host": "file://$REPO1_DIR",
+ "name": ""
+ },
+ {
+ "host": "file://$REPO2_DIR",
+ "name": ""
+ }
+ ]
+}
+EOF
+
+echo "Test setup complete!"
+echo ""
+echo "Repository structure:"
+echo "- org1/test-repo.git: Has main, develop, and feature/test-feature branches"
+echo "- org2/test-repo.git: Has only main branch"
+echo ""
+echo "Test config file created at: $TEST_DIR/test-config.json" \ No newline at end of file
diff --git a/test/test_conflict.sh b/test/test_conflict.sh
new file mode 100755
index 0000000..d63375c
--- /dev/null
+++ b/test/test_conflict.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# Test script to demonstrate merge conflict handling
+set -e
+
+echo "Testing merge conflict detection..."
+
+TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPOS_DIR="$TEST_DIR/repos"
+WORK_DIR="$REPOS_DIR/conflict-work"
+
+# Clean up and create work directories
+rm -rf "$WORK_DIR"
+mkdir -p "$WORK_DIR"
+
+# Clone both repos to create conflicting changes
+echo "Creating conflicting changes in org1..."
+cd "$WORK_DIR"
+git clone "$REPOS_DIR/org1/test-repo.git" work1
+cd work1
+echo "Change from org1" > conflict.txt
+git add conflict.txt
+git commit -m "Add conflict.txt from org1"
+git push origin main
+
+echo "Creating conflicting changes in org2..."
+cd "$WORK_DIR"
+git clone "$REPOS_DIR/org2/test-repo.git" work2
+cd work2
+echo "Different change from org2" > conflict.txt
+git add conflict.txt
+git commit -m "Add conflict.txt from org2"
+git push origin main
+
+# Clean up work directories
+cd "$TEST_DIR"
+rm -rf "$WORK_DIR"
+
+echo ""
+echo "Conflicting changes created!"
+echo "Now run gitsyncer to see conflict detection:"
+echo " ./gitsyncer --config test/test-config.json --sync test-repo --work-dir test/work-conflict" \ No newline at end of file