diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-23 17:36:03 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-23 17:36:03 +0300 |
| commit | 8706e6a82819c0c16a0c157283de2f14af2664c3 (patch) | |
| tree | bacf3d7f61e14d0400cb541e36f5ab49de6d7a33 | |
| parent | 60691a6fb610cb7f7290d6ab3a26bc74f95af611 (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-- | .gitignore | 8 | ||||
| -rw-r--r-- | cmd/gitsyncer/main.go | 24 | ||||
| -rwxr-xr-x | gitsyncer | bin | 3066401 -> 3383664 bytes | |||
| -rw-r--r-- | internal/config/config.go | 4 | ||||
| -rw-r--r-- | internal/sync/sync.go | 314 | ||||
| -rwxr-xr-x | test/setup_test_repos.sh | 107 | ||||
| -rwxr-xr-x | test/test_conflict.sh | 42 |
7 files changed, 495 insertions, 4 deletions
@@ -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 Binary files differdiff --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 |
