diff options
| -rw-r--r-- | CLAUDE.md | 9 | ||||
| -rw-r--r-- | README.md | 115 | ||||
| -rw-r--r-- | cmd/gitsyncer/main.go | 211 | ||||
| -rw-r--r-- | internal/codeberg/codeberg.go | 133 | ||||
| -rw-r--r-- | internal/config/config.go | 38 | ||||
| -rw-r--r-- | internal/github/github.go | 175 | ||||
| -rw-r--r-- | internal/sync/sync.go | 113 | ||||
| -rwxr-xr-x | test/test_branch_creation.sh | 92 | ||||
| -rwxr-xr-x | test/test_codeberg_list.sh | 22 |
9 files changed, 833 insertions, 75 deletions
@@ -70,8 +70,7 @@ The application currently provides: ## Next Steps The project needs: -1. Implementation of the main git synchronization functionality -2. Configuration handling (config files, environment variables) -3. Git repository management logic -4. Sync strategies implementation -5. Tests for all components
\ No newline at end of file +1. Support for other platforms (GitLab, Gitea, etc.) +2. Webhook support for automatic syncing +3. Conflict resolution strategies +4. Better handling of large repositories
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fc6a4e --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# GitSyncer + +GitSyncer is a tool for synchronizing git repositories between multiple organizations (e.g., GitHub and Codeberg). It automatically keeps all branches in sync across different git hosting platforms. + +## Features + +- Sync repositories between multiple git organizations +- Automatic branch creation on remotes that don't have them +- Batch sync multiple repositories with a single command +- Sync all public repositories from Codeberg to other platforms +- Merge conflict detection +- Never deletes branches (only adds/updates) + +## Installation + +```bash +go build -o gitsyncer ./cmd/gitsyncer +``` + +## Configuration + +Create a `gitsyncer.json` file: + +```json +{ + "organizations": [ + { + "host": "git@codeberg.org", + "name": "yourusername" + }, + { + "host": "git@github.com", + "name": "yourusername" + } + ], + "repositories": [ + "repo1", + "repo2" + ] +} +``` + +## Usage + +### Sync a single repository +```bash +./gitsyncer --sync repo-name +``` + +### Sync all configured repositories +```bash +./gitsyncer --sync-all +``` + +### Sync all public Codeberg repositories +```bash +# Dry run - see what would be synced +./gitsyncer --sync-codeberg-public --dry-run + +# Actually sync all public repos +./gitsyncer --sync-codeberg-public +``` + +### List configured organizations +```bash +./gitsyncer --list-orgs +``` + +### List configured repositories +```bash +./gitsyncer --list-repos +``` + +### Show version +```bash +./gitsyncer --version +``` + +## How It Works + +1. GitSyncer clones the repository from the first configured organization +2. Adds all other organizations as git remotes +3. For each branch: + - Fetches from all remotes + - Merges changes from remotes that have the branch + - Pushes to all remotes (creating branches if needed) + +## Example Workflows + +### Sync specific repositories +1. Create repositories on all platforms (GitHub, Codeberg, etc.) +2. Add the repository name to your `gitsyncer.json` +3. Run `./gitsyncer --sync repo-name` +4. GitSyncer will: + - Clone from the first organization + - Push all branches to other organizations + - Keep them in sync going forward + +### Sync all public Codeberg repositories +1. Ensure Codeberg is in your organizations list +2. Run `./gitsyncer --sync-codeberg-public` +3. GitSyncer will: + - Fetch all public repositories from your Codeberg account + - Sync each one to all other configured organizations + - Skip any that fail (e.g., don't exist on other platforms) + +## Error Handling + +- **Merge conflicts**: GitSyncer will detect conflicts and exit with an error message +- **Missing repositories**: Must be created manually on all platforms +- **Missing branches**: Automatically created on remotes that don't have them + +## License + +[Add your license here]
\ No newline at end of file diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go index 63ce0b0..b60fda0 100644 --- a/cmd/gitsyncer/main.go +++ b/cmd/gitsyncer/main.go @@ -7,20 +7,25 @@ import ( "os" "path/filepath" + "github.com/paul/gitsyncer/internal/codeberg" "github.com/paul/gitsyncer/internal/config" + "github.com/paul/gitsyncer/internal/github" "github.com/paul/gitsyncer/internal/sync" "github.com/paul/gitsyncer/internal/version" ) func main() { var ( - versionFlag bool - configPath string - listOrgs bool - listRepos bool - syncRepo string - syncAll bool - workDir string + versionFlag bool + configPath string + listOrgs bool + listRepos bool + syncRepo string + syncAll bool + syncCodebergPublic bool + createGitHubRepos bool + dryRun bool + workDir string ) // Define command line flags @@ -32,6 +37,9 @@ func main() { flag.BoolVar(&listRepos, "list-repos", false, "list configured repositories") flag.StringVar(&syncRepo, "sync", "", "repository name to sync") flag.BoolVar(&syncAll, "sync-all", false, "sync all configured repositories") + flag.BoolVar(&syncCodebergPublic, "sync-codeberg-public", false, "sync all public Codeberg repositories") + flag.BoolVar(&createGitHubRepos, "create-github-repos", false, "automatically create missing GitHub repositories") + flag.BoolVar(&dryRun, "dry-run", false, "show what would be synced without actually syncing") flag.StringVar(&workDir, "work-dir", ".gitsyncer-work", "working directory for cloning repositories") flag.Parse() @@ -116,6 +124,25 @@ func main() { // Handle sync operation if syncRepo != "" { + // If create-github-repos is enabled, create the repo if needed + if createGitHubRepos { + githubOrg := cfg.FindGitHubOrg() + if githubOrg != nil { + fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name) + githubClient := github.NewClient(githubOrg.GitHubToken, githubOrg.Name) + if githubClient.HasToken() { + fmt.Println("Checking/creating GitHub repository...") + err := githubClient.CreateRepo(syncRepo, fmt.Sprintf("Mirror of %s", syncRepo), false) + if err != nil { + fmt.Printf("ERROR: Failed to create GitHub repo %s: %v\n", syncRepo, err) + os.Exit(1) + } + } else { + fmt.Println("Warning: No GitHub token found. Cannot create repository.") + } + } + } + syncer := sync.New(cfg, workDir) if err := syncer.SyncRepository(syncRepo); err != nil { log.Fatal("Sync failed:", err) @@ -130,14 +157,43 @@ func main() { os.Exit(1) } + // Initialize GitHub client if needed + var githubClient *github.Client + if createGitHubRepos { + githubOrg := cfg.FindGitHubOrg() + if githubOrg != nil { + fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name) + githubClient = github.NewClient(githubOrg.GitHubToken, githubOrg.Name) + if !githubClient.HasToken() { + fmt.Println("Warning: No GitHub token found. Cannot create repositories.") + githubClient = nil + } else { + fmt.Println("GitHub client initialized successfully with token") + } + } + } + syncer := sync.New(cfg, workDir) failedRepos := []string{} for i, repo := range cfg.Repositories { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(cfg.Repositories), repo) + + // Create GitHub repo if needed + if githubClient != nil { + fmt.Printf("Checking/creating GitHub repository %s...\n", repo) + err := githubClient.CreateRepo(repo, fmt.Sprintf("Mirror of %s", repo), false) + if err != nil { + fmt.Printf("ERROR: Failed to create GitHub repo %s: %v\n", repo, err) + fmt.Printf("Stopping sync due to error.\n") + os.Exit(1) + } + } + if err := syncer.SyncRepository(repo); err != nil { - fmt.Printf("Failed to sync %s: %v\n", repo, err) - failedRepos = append(failedRepos, repo) + fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err) + fmt.Printf("Stopping sync due to error.\n") + os.Exit(1) } } @@ -153,17 +209,140 @@ func main() { os.Exit(0) } + // Handle sync Codeberg public repos + if syncCodebergPublic { + codebergOrg := cfg.FindCodebergOrg() + if codebergOrg == nil { + fmt.Println("No Codeberg organization found in configuration") + os.Exit(1) + } + + fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name) + + client := codeberg.NewClient(codebergOrg.Name) + + // Try fetching as organization first + repos, err := client.ListPublicRepos() + if err != nil { + // If that fails, try as user + fmt.Println("Trying as user account...") + repos, err = client.ListUserPublicRepos() + if err != nil { + log.Fatal("Failed to fetch repositories:", err) + } + } + + repoNames := codeberg.GetRepoNames(repos) + fmt.Printf("Found %d public repositories on Codeberg\n", len(repoNames)) + + if len(repoNames) == 0 { + fmt.Println("No public repositories found") + os.Exit(0) + } + + // Show the repositories that will be synced + fmt.Println("\nRepositories to sync:") + for _, name := range repoNames { + fmt.Printf(" - %s\n", name) + } + + if dryRun { + fmt.Printf("\n[DRY RUN] Would sync %d repositories\n", len(repoNames)) + if createGitHubRepos { + fmt.Println("Would create missing GitHub repositories") + } + os.Exit(0) + } + + // If create-github-repos is enabled, pre-create repos on GitHub + var githubClient *github.Client + if createGitHubRepos { + githubOrg := cfg.FindGitHubOrg() + if githubOrg == nil { + fmt.Println("Warning: --create-github-repos specified but no GitHub organization found in config") + } else { + fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name) + githubClient = github.NewClient(githubOrg.GitHubToken, githubOrg.Name) + if !githubClient.HasToken() { + fmt.Println("Warning: No GitHub token found. Set GITHUB_TOKEN env var or create ~/.gitsyncer_github_token") + fmt.Println(" or add github_token to your config file") + githubClient = nil + } else { + fmt.Println("GitHub client initialized successfully with token") + } + } + } + + fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + + syncer := sync.New(cfg, workDir) + failedRepos := []string{} + successCount := 0 + + // Get Codeberg repos for description + codebergRepoMap := make(map[string]codeberg.Repository) + for _, repo := range repos { + codebergRepoMap[repo.Name] = repo + } + + for i, repoName := range repoNames { + fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + + // Create GitHub repo if needed + if githubClient != nil && createGitHubRepos { + codebergRepo := codebergRepoMap[repoName] + description := codebergRepo.Description + if description == "" { + description = fmt.Sprintf("Mirror of %s from Codeberg", repoName) + } + + fmt.Printf("Checking/creating GitHub repository %s...\n", repoName) + err := githubClient.CreateRepo(repoName, description, false) // public repos + if err != nil { + fmt.Printf("ERROR: Failed to create GitHub repo %s: %v\n", repoName, err) + fmt.Printf("Stopping sync due to error.\n") + os.Exit(1) + } + } + + if err := syncer.SyncRepository(repoName); err != nil { + fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err) + fmt.Printf("Stopping sync due to error.\n") + os.Exit(1) + } else { + successCount++ + } + } + + fmt.Printf("\n=== Summary ===\n") + fmt.Printf("Successfully synced: %d repositories\n", successCount) + + if len(failedRepos) > 0 { + fmt.Printf("Failed to sync: %d repositories\n", len(failedRepos)) + for _, repo := range failedRepos { + fmt.Printf(" - %s\n", repo) + } + } + + os.Exit(0) + } + // Default: show usage fmt.Println("\ngitsyncer - Git repository synchronization tool") fmt.Printf("Configured with %d organization(s) and %d repository(ies)\n", len(cfg.Organizations), len(cfg.Repositories)) fmt.Println("\nUsage:") - fmt.Println(" gitsyncer --sync <repo-name> Sync a specific repository") - fmt.Println(" gitsyncer --sync-all Sync all configured repositories") - fmt.Println(" gitsyncer --list-orgs List configured organizations") - fmt.Println(" gitsyncer --list-repos List configured repositories") - fmt.Println(" gitsyncer --version Show version information") + fmt.Println(" gitsyncer --sync <repo-name> Sync a specific repository") + fmt.Println(" gitsyncer --sync-all Sync all configured repositories") + fmt.Println(" gitsyncer --sync-codeberg-public Sync all public Codeberg repositories") + fmt.Println(" gitsyncer --list-orgs List configured organizations") + fmt.Println(" gitsyncer --list-repos List configured repositories") + 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)") + fmt.Println(" --config <path> Path to configuration file") + fmt.Println(" --work-dir <path> Working directory for operations (default: .gitsyncer-work)") + fmt.Println(" --create-github-repos Create missing GitHub repositories automatically") + fmt.Println(" --dry-run Show what would be done without doing it") + fmt.Println("\nGitHub Token:") + fmt.Println(" Set via: config file, GITHUB_TOKEN env var, or ~/.gitsyncer_github_token file") }
\ No newline at end of file diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go new file mode 100644 index 0000000..288d1e7 --- /dev/null +++ b/internal/codeberg/codeberg.go @@ -0,0 +1,133 @@ +package codeberg + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Repository represents a Codeberg/Gitea repository +type Repository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + Fork bool `json:"fork"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CloneURL string `json:"clone_url"` + SSHURL string `json:"ssh_url"` + Size int `json:"size"` + Archived bool `json:"archived"` + Empty bool `json:"empty"` +} + +// Client handles Codeberg API operations +type Client struct { + baseURL string + org string +} + +// CLAUDE: Is there a rason, that we return a pointer of Client? +// NewClient creates a new Codeberg API client +func NewClient(org string) *Client { + return &Client{ + baseURL: "https://codeberg.org/api/v1", + org: org, + } +} + +// ListPublicRepos lists all public repositories for an organization +func (c *Client) ListPublicRepos() ([]Repository, error) { + var allRepos []Repository + page := 1 + perPage := 50 + + for { + url := fmt.Sprintf("%s/orgs/%s/repos?page=%d&limit=%d", c.baseURL, c.org, page, perPage) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var repos []Repository + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Filter only public, non-fork, non-archived, non-empty repos + for _, repo := range repos { + if !repo.Private && !repo.Fork && !repo.Archived && !repo.Empty { + allRepos = append(allRepos, repo) + } + } + + // If we got fewer repos than requested, we've reached the end + if len(repos) < perPage { + break + } + + page++ + } + + return allRepos, nil +} + +// ListUserPublicRepos lists all public repositories for a user +func (c *Client) ListUserPublicRepos() ([]Repository, error) { + var allRepos []Repository + page := 1 + perPage := 50 + + for { + url := fmt.Sprintf("%s/users/%s/repos?page=%d&limit=%d", c.baseURL, c.org, page, perPage) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var repos []Repository + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Filter only public, non-fork, non-archived, non-empty repos + for _, repo := range repos { + if !repo.Private && !repo.Fork && !repo.Archived && !repo.Empty { + allRepos = append(allRepos, repo) + } + } + + // If we got fewer repos than requested, we've reached the end + if len(repos) < perPage { + break + } + + page++ + } + + return allRepos, nil +} + +// GetRepoNames returns just the repository names +func GetRepoNames(repos []Repository) []string { + names := make([]string, 0, len(repos)) + for _, repo := range repos { + names = append(names, repo.Name) + } + return names +} diff --git a/internal/config/config.go b/internal/config/config.go index 754e226..1bfcedc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,8 +10,9 @@ import ( // Organization represents a git organization with its host and name type Organization struct { - Host string `json:"host"` - Name string `json:"name"` + Host string `json:"host"` + Name string `json:"name"` + GitHubToken string `json:"github_token,omitempty"` } // Config holds the application configuration @@ -83,4 +84,35 @@ func (c *Config) FindOrganization(host string) *Organization { } } return nil -}
\ No newline at end of file +} + +// IsCodeberg checks if the organization is Codeberg +func (o *Organization) IsCodeberg() bool { + return o.Host == "git@codeberg.org" || strings.Contains(o.Host, "codeberg.org") +} + +// FindCodebergOrg finds the first Codeberg organization +func (c *Config) FindCodebergOrg() *Organization { + for i := range c.Organizations { + if c.Organizations[i].IsCodeberg() { + return &c.Organizations[i] + } + } + return nil +} + +// IsGitHub checks if the organization is GitHub +func (o *Organization) IsGitHub() bool { + return o.Host == "git@github.com" || strings.Contains(o.Host, "github.com") +} + +// FindGitHubOrg finds the first GitHub organization +func (c *Config) FindGitHubOrg() *Organization { + for i := range c.Organizations { + if c.Organizations[i].IsGitHub() { + return &c.Organizations[i] + } + } + return nil +} + diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..c0d3ffc --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,175 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +// Client handles GitHub API operations +type Client struct { + token string + org string +} + +// NewClient creates a new GitHub API client +func NewClient(token, org string) *Client { + // If no token provided, try other sources + if token == "" { + // Try environment variable + token = os.Getenv("GITHUB_TOKEN") + + // If still no token, try reading from file + if token == "" { + home, err := os.UserHomeDir() + if err == nil { + tokenFile := filepath.Join(home, ".gitsyncer_github_token") + data, err := os.ReadFile(tokenFile) + if err == nil { + token = strings.TrimSpace(string(data)) + } + } + } + } + return &Client{ + token: token, + org: org, + } +} + +// CreateRepoRequest represents the request to create a repository +type CreateRepoRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` +} + +// CreateRepoResponse represents the response from creating a repository +type CreateRepoResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Private bool `json:"private"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` +} + +// ErrorResponse represents an error response from GitHub API +type ErrorResponse struct { + Message string `json:"message"` + Errors []struct { + Resource string `json:"resource"` + Field string `json:"field"` + Code string `json:"code"` + } `json:"errors,omitempty"` +} + +// RepoExists checks if a repository exists +func (c *Client) RepoExists(repoName string) (bool, error) { + if c.token == "" { + return false, fmt.Errorf("GitHub token required") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return true, nil + } else if resp.StatusCode == 404 { + return false, nil + } + + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) +} + +// CreateRepo creates a new repository +func (c *Client) CreateRepo(repoName, description string, private bool) error { + if c.token == "" { + return fmt.Errorf("GitHub token required to create repository") + } + + fmt.Printf(" Checking if GitHub repo %s/%s exists...\n", c.org, repoName) + // First check if it already exists + exists, err := c.RepoExists(repoName) + if err != nil { + return fmt.Errorf("failed to check if repo exists: %w", err) + } + if exists { + fmt.Printf(" GitHub repo already exists, skipping creation\n") + // Repo already exists, nothing to do + return nil + } + + url := fmt.Sprintf("https://api.github.com/user/repos") + + reqBody := CreateRepoRequest{ + Name: repoName, + Description: description, + Private: private, + AutoInit: false, // Don't auto-init, we'll push content + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 201 { + var createResp CreateRepoResponse + if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + fmt.Printf("Created GitHub repository: %s\n", createResp.FullName) + return nil + } + + // Handle error response + var errResp ErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + if errResp.Message != "" { + return fmt.Errorf("GitHub API error: %s", errResp.Message) + } + + return fmt.Errorf("failed to create repository: status %d", resp.StatusCode) +} + +// HasToken returns whether a token is configured +func (c *Client) HasToken() bool { + return c.token != "" +}
\ No newline at end of file diff --git a/internal/sync/sync.go b/internal/sync/sync.go index e35dad7..7f9b81e 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -17,6 +17,7 @@ type Syncer struct { repoName string } +// CLAUDE: Is there a reason, we return a pointer to Syncer? // New creates a new Syncer instance func New(cfg *config.Config, workDir string) *Syncer { return &Syncer{ @@ -28,7 +29,7 @@ func New(cfg *config.Config, workDir string) *Syncer { // 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) @@ -49,19 +50,19 @@ func (s *Syncer) SyncRepository(repoName string) error { 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] @@ -72,12 +73,12 @@ func (s *Syncer) SyncRepository(repoName string) error { } else { // Repository exists, ensure all remotes are configured fmt.Printf("Using existing repository at %s\n", repoPath) - + // Check and add any missing remotes for i := range s.config.Organizations { org := &s.config.Organizations[i] remoteName := s.getRemoteName(org) - + // Check if remote exists cmd := exec.Command("git", "-C", repoPath, "remote", "get-url", remoteName) if err := cmd.Run(); err != nil { @@ -95,7 +96,7 @@ func (s *Syncer) SyncRepository(repoName string) error { 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) } @@ -135,24 +136,24 @@ func (s *Syncer) cloneRepository(org *config.Organization, repoPath string) erro // 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://") { @@ -160,18 +161,19 @@ func (s *Syncer) addRemote(repoPath string, org *config.Organization) error { } 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 +// Note: We use individual fetches instead of --all to handle missing repositories gracefully func (s *Syncer) fetchAll() error { // First, check which remotes actually exist cmd := exec.Command("git", "remote", "-v") @@ -201,14 +203,14 @@ func (s *Syncer) fetchAll() error { if err != nil { // Check if it's because the repository doesn't exist if strings.Contains(string(output), "does not appear to be a git repository") || - strings.Contains(string(output), "Could not read from remote repository") { + strings.Contains(string(output), "Could not read from remote repository") { fmt.Printf(" Warning: Remote repository %s does not exist yet\n", remote) continue } return fmt.Errorf("failed to fetch from %s: %w\n%s", remote, err, string(output)) } } - + return nil } @@ -219,16 +221,16 @@ func (s *Syncer) getAllBranches() ([]string, error) { 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 { @@ -236,13 +238,13 @@ func (s *Syncer) getAllBranches() ([]string, error) { branchMap[branch] = true } } - + // Convert map to slice branches := make([]string, 0, len(branchMap)) for branch := range branchMap { branches = append(branches, branch) } - + return branches, nil } @@ -255,7 +257,7 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati // 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) { @@ -263,45 +265,52 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati } } - // If no remotes have this branch, skip it + // If no remotes have this branch, it means it's a local branch that needs to be pushed if len(remotesWithBranch) == 0 { - fmt.Printf(" Branch %s not found on any remote, skipping\n", branch) - return nil - } + fmt.Printf(" Branch %s is local only, will push to all remotes\n", branch) + } else { + // Merge changes from all remotes that have this branch + for remoteName := range remotesWithBranch { + fmt.Printf(" Merging from %s/%s...\n", remoteName, branch) - // 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) + 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)) } - 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) - + // Check if this remote has the branch + remoteHasBranch := remotesWithBranch[remoteName] + + if !remoteHasBranch { + fmt.Printf(" Creating branch on %s (%s)...\n", remoteName, org.Host) + } else { + fmt.Printf(" Pushing to %s (%s)...\n", remoteName, org.Host) + } + cmd := exec.Command("git", "push", remoteName, branch) output, err := cmd.CombinedOutput() - + if err != nil { outputStr := string(output) // Check if it's because the repository doesn't exist if strings.Contains(outputStr, "does not appear to be a git repository") || - strings.Contains(outputStr, "Could not read from remote repository") { - fmt.Printf(" Note: Remote repository %s does not exist - creating it first would be needed\n", remoteName) - fmt.Printf(" Skipping push to %s (repository must be created manually)\n", remoteName) + strings.Contains(outputStr, "Could not read from remote repository") { + fmt.Printf(" Note: Remote repository %s does not exist - must be created manually\n", remoteName) + fmt.Printf(" Skipping push to %s\n", remoteName) continue } // Check if it's because the branch doesn't exist on the remote + // This shouldn't happen with our logic, but keep it as a fallback if strings.Contains(outputStr, "error: src refspec") { fmt.Printf(" Creating new branch on %s\n", remoteName) // Try again with -u flag to set upstream @@ -312,6 +321,8 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati } else { return fmt.Errorf("failed to push to %s: %w\n%s", remoteName, err, outputStr) } + } else if !remoteHasBranch { + fmt.Printf(" Successfully created branch %s on %s\n", branch, remoteName) } } @@ -325,18 +336,18 @@ func (s *Syncer) checkoutBranch(branch string) error { 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) } @@ -359,7 +370,7 @@ func (s *Syncer) getRemoteName(org *config.Organization) string { 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 @@ -368,6 +379,6 @@ func (s *Syncer) getRemoteName(org *config.Organization) string { return parts[len(parts)-1] } } - + return host -}
\ No newline at end of file +} diff --git a/test/test_branch_creation.sh b/test/test_branch_creation.sh new file mode 100755 index 0000000..cf02426 --- /dev/null +++ b/test/test_branch_creation.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# Test script for branch creation functionality +set -e + +echo "Testing automatic branch creation on remotes..." + +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +REPOS_DIR="$TEST_DIR/repos" +mkdir -p "$REPOS_DIR" + +# Clean up if they exist +rm -rf "$REPOS_DIR/org1" "$REPOS_DIR/org2" +mkdir -p "$REPOS_DIR/org1" "$REPOS_DIR/org2" + +# Create a repository in org1 with multiple branches +echo "Creating repository in org1 with branches..." +cd "$REPOS_DIR/org1" +git init --bare test-branch-repo.git + +# Create only the same repository in org2 but with just main branch +echo "Creating repository in org2..." +cd "$REPOS_DIR/org2" +git init --bare test-branch-repo.git + +# Add content and branches to org1 +WORK_DIR="$REPOS_DIR/work" +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +echo "Creating branches in org1..." +cd "$WORK_DIR" +git clone "$REPOS_DIR/org1/test-branch-repo.git" +cd test-branch-repo + +# Create main branch +echo "# Test Repo" > README.md +git add README.md +git commit -m "Initial commit" +git push origin main + +# Create feature branch +git checkout -b feature/new-feature +echo "New feature" > feature.txt +git add feature.txt +git commit -m "Add new feature" +git push origin feature/new-feature + +# Create hotfix branch +git checkout -b hotfix/urgent-fix +echo "Urgent fix" > hotfix.txt +git add hotfix.txt +git commit -m "Apply urgent fix" +git push origin hotfix/urgent-fix + +# Push only main to org2 +git checkout main +git remote add org2 "$REPOS_DIR/org2/test-branch-repo.git" +git push org2 main + +# Clean up work directory +cd "$TEST_DIR" +rm -rf "$WORK_DIR" + +# Create test config +cat > "$TEST_DIR/branch-test-config.json" << EOF +{ + "organizations": [ + { + "host": "file://$REPOS_DIR/org1", + "name": "" + }, + { + "host": "file://$REPOS_DIR/org2", + "name": "" + } + ], + "repositories": [ + "test-branch-repo" + ] +} +EOF + +echo "" +echo "Test setup complete!" +echo "" +echo "Initial state:" +echo "- org1 has: main, feature/new-feature, hotfix/urgent-fix" +echo "- org2 has: main only" +echo "" +echo "Run sync to see branch creation:" +echo " ./gitsyncer --config test/branch-test-config.json --sync test-branch-repo"
\ No newline at end of file diff --git a/test/test_codeberg_list.sh b/test/test_codeberg_list.sh new file mode 100755 index 0000000..d285be6 --- /dev/null +++ b/test/test_codeberg_list.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Test script to list Codeberg public repos without syncing +set -e + +echo "Testing Codeberg API to list public repositories..." + +# Use curl to test the API directly +USER="snonux" +echo "Fetching public repos for user: $USER" + +# Try as user +echo "" +echo "Trying user endpoint..." +curl -s "https://codeberg.org/api/v1/users/$USER/repos?limit=50" | \ + jq -r '.[] | select(.private == false and .fork == false and .archived == false) | .name' | \ + sort + +echo "" +echo "Total public repos (non-fork, non-archived):" +curl -s "https://codeberg.org/api/v1/users/$USER/repos?limit=50" | \ + jq '[.[] | select(.private == false and .fork == false and .archived == false)] | length'
\ No newline at end of file |
