summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md9
-rw-r--r--README.md115
-rw-r--r--cmd/gitsyncer/main.go211
-rw-r--r--internal/codeberg/codeberg.go133
-rw-r--r--internal/config/config.go38
-rw-r--r--internal/github/github.go175
-rw-r--r--internal/sync/sync.go113
-rwxr-xr-xtest/test_branch_creation.sh92
-rwxr-xr-xtest/test_codeberg_list.sh22
9 files changed, 833 insertions, 75 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 8b9a1d2..cefe274 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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