diff --git a/cmd/private-org-sync/main.go b/cmd/private-org-sync/main.go index 6c232ffc1a..ece6f5b616 100644 --- a/cmd/private-org-sync/main.go +++ b/cmd/private-org-sync/main.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/sirupsen/logrus" @@ -65,6 +66,7 @@ type options struct { confirm bool failOnNonexistentDst bool debug bool + parallelism int } const defaultPrefix = "https://github.com" @@ -144,6 +146,7 @@ func gatherOptions() options { fs.BoolVar(&o.failOnNonexistentDst, "fail-on-missing-destination", false, "Set true to make the tool to consider missing sync destination as an error") fs.BoolVar(&o.debug, "debug", false, "Set true to enable debug logging level") + fs.IntVar(&o.parallelism, "parallelism", 4, "Number of repos to sync in parallel") o.Options.Bind(fs) o.WhitelistOptions.Bind(fs) @@ -170,6 +173,27 @@ func withRetryOnNonzero(f gitFunc, retries int) gitFunc { } } +func withRetryOnTransientError(f gitFunc, retries int) gitFunc { + return func(logger *logrus.Entry, dir string, command ...string) (string, int, error) { + var out string + var exitCode int + var commandErr error + for attempt := 1; attempt <= retries; attempt++ { + out, exitCode, commandErr = f(logger, dir, command...) + if commandErr == nil && exitCode == 0 { + return out, exitCode, nil + } + if attempt < retries && isTransientNetworkError(out) { + logger.Infof("Transient network error, retrying git command (%d/%d)", attempt, retries) + time.Sleep(5 * time.Second) + continue + } + break + } + return out, exitCode, commandErr + } +} + func gitExec(logger *logrus.Entry, dir string, command ...string) (string, int, error) { cmdLogger := logger.WithField("command", fmt.Sprintf("git %s", strings.Join(command, " "))) cmd := exec.Command("git", command...) @@ -319,11 +343,30 @@ func maybeTooShallow(pushOutput string) bool { return false } +func isTransientNetworkError(output string) bool { + patterns := []string{ + "Could not resolve host", + "Failed to connect to", + "Connection timed out", + "Connection refused", + "Connection reset by peer", + "The requested URL returned error: 5", + } + for _, pattern := range patterns { + if strings.Contains(output, pattern) { + return true + } + } + return false +} + // location specifies a GitHub repository branch used as a source or destination type location struct { org, repo, branch string } +type repoKey struct{ org, repo string } + func (l location) String() string { return fmt.Sprintf("%s/%s@%s", l.org, l.repo, l.branch) } @@ -364,58 +407,126 @@ func (g gitSyncer) initRepo(repoDir, org, repo string) error { return nil } -// mirror syncs content from source location to destination one, using a local -// repository in the given path. The `repoDir` must have been previously -// initialized via initRepo(). The git content from the `src` location will -// be fetched to this local repository and then pushed to the `dst` location. -// Multiple `mirror` calls over the same `repoDir` will reuse the content -// fetched in previous calls, acting like a cache. -func (g gitSyncer) mirror(repoDir string, src, dst location) error { - mirrorFields := logrus.Fields{ - "source": src.String(), - "destination": dst.String(), - "local-repo": repoDir, +// syncRepo initializes a local git repo, fetches branch heads from both source +// and destination via ls-remote, and mirrors each branch that needs syncing. +func (g gitSyncer) syncRepo(org, repo, targetOrg, dstRepo string, branches []location) []error { + var errs []error + repoLogger := g.logger + + gitDir, err := g.makeGitDir(org, repo) + if err != nil { + for _, source := range branches { + errs = append(errs, fmt.Errorf("%s: %w", source.String(), err)) + } + return errs + } + + if err := g.initRepo(gitDir, org, repo); err != nil { + for _, source := range branches { + errs = append(errs, fmt.Errorf("%s: %w", source.String(), err)) + } + return errs } - logger := g.logger.WithFields(mirrorFields) - logger.Info("Syncing content between locations") - // We ls-remote destination first thing because when it does not exist - // we do not need to do any of the remaining operations. - logger.Debug("Determining HEAD of destination branch") - destUrlRaw := fmt.Sprintf("%s/%s/%s", g.prefix, dst.org, dst.repo) + // ls-remote source and destination in parallel + destUrlRaw := fmt.Sprintf("%s/%s/%s", g.prefix, targetOrg, dstRepo) destUrl, err := url.Parse(destUrlRaw) if err != nil { - logger.WithField("remote-url", destUrlRaw).WithError(err).Error("Failed to construct URL for the destination remote") - return fmt.Errorf("failed to construct URL for the destination remote") + repoLogger.WithField("remote-url", destUrlRaw).WithError(err).Error("Failed to construct URL for the destination remote") + for _, source := range branches { + errs = append(errs, fmt.Errorf("%s: failed to construct URL for the destination remote", source.String())) + } + return errs } if g.token != "" { destUrl.User = url.User(g.token) } - dstHeads, err := getRemoteBranchHeads(logger, g.git, repoDir, destUrl.String()) - if err != nil { + srcRemote := fmt.Sprintf("%s-%s", org, repo) + + type lsRemoteResult struct { + heads RemoteBranchHeads + err error + } + dstResult := make(chan lsRemoteResult, 1) + srcResult := make(chan lsRemoteResult, 1) + go func() { + heads, err := getRemoteBranchHeads(repoLogger, g.git, gitDir, destUrl.String()) + dstResult <- lsRemoteResult{heads, err} + }() + go func() { + heads, err := getRemoteBranchHeads(repoLogger, withRetryOnNonzero(g.git, 5), gitDir, srcRemote) + srcResult <- lsRemoteResult{heads, err} + }() + + dst := <-dstResult + src := <-srcResult + + if dst.err != nil { message := "destination repository does not exist or we cannot access it" if g.failOnNonexistentDst { - logger.Errorf("%s", message) - return fmt.Errorf("%s", message) + repoLogger.Errorf("%s", message) + for _, source := range branches { + errs = append(errs, fmt.Errorf("%s: %s", source.String(), message)) + } + } else { + repoLogger.Warn(message) } + return errs + } - logger.Warn(message) - return nil + if src.err != nil { + repoLogger.WithError(src.err).Error("Failed to determine branch HEADs in source") + for _, source := range branches { + errs = append(errs, fmt.Errorf("%s: failed to determine branch HEADs in source", source.String())) + } + return errs + } + + dstHeads := dst.heads + srcHeads := src.heads + + for _, source := range branches { + g.logger = config.LoggerForInfo(config.Info{ + Metadata: api.Metadata{ + Org: source.org, + Repo: source.repo, + Branch: source.branch, + }, + }) + + destination := location{org: targetOrg, repo: dstRepo, branch: source.branch} + + if err := g.mirror(gitDir, source, destination, srcHeads, dstHeads, destUrl); err != nil { + errs = append(errs, fmt.Errorf("%s->%s: %w", source.String(), destination.String(), err)) + } + } + + return errs +} + +// mirror syncs a single branch from source to destination, using pre-fetched +// branch head information. The `repoDir` must have been previously initialized +// with git init and remote setup. The `srcHeads` and `dstHeads` must have been +// obtained from ls-remote calls against the source and destination repos. +// Multiple `mirror` calls over the same `repoDir` will reuse the content +// fetched in previous calls, acting like a cache. +func (g gitSyncer) mirror(repoDir string, src, dst location, srcHeads, dstHeads RemoteBranchHeads, destUrl *url.URL) error { + mirrorFields := logrus.Fields{ + "source": src.String(), + "destination": dst.String(), + "local-repo": repoDir, } + logger := g.logger.WithFields(mirrorFields) + logger.Info("Syncing content between locations") + dstCommitHash := dstHeads[dst.branch] srcRemote := fmt.Sprintf("%s-%s", src.org, src.repo) - logger.Debug("Determining HEAD of source branch") - srcHeads, err := getRemoteBranchHeads(logger, withRetryOnNonzero(g.git, 5), repoDir, srcRemote) - if err != nil { - logger.WithError(err).Error("Failed to determine branch HEADs in source") - return fmt.Errorf("failed to determine branch HEADs in source") - } srcCommitHash, ok := srcHeads[src.branch] if !ok { - logger.WithError(err).Error("Branch does not exist in source remote") + logger.Error("Branch does not exist in source remote") return fmt.Errorf("branch does not exist in source remote") } @@ -641,7 +752,7 @@ func main() { token: token, root: o.gitDir, confirm: o.confirm, - git: gitExec, + git: withRetryOnTransientError(gitExec, 3), failOnNonexistentDst: o.failOnNonexistentDst, gitName: o.gitName, gitEmail: o.gitEmail, @@ -667,7 +778,6 @@ func main() { } // Group locations by (org, repo) so we can initialize each repo once - type repoKey struct{ org, repo string } grouped := make(map[repoKey][]location) for source := range locations { key := repoKey{org: source.org, repo: source.repo} @@ -680,46 +790,40 @@ func main() { flattenedOrgs.Insert(o.org) } - for key, branches := range grouped { - gitDir, err := syncer.makeGitDir(key.org, key.repo) - if err != nil { - for _, source := range branches { - errs = append(errs, fmt.Errorf("%s: %w", source.String(), err)) - } - continue - } - - syncer.logger = logrus.WithFields(logrus.Fields{ - "org": key.org, - "repo": key.repo, - }) - if err := syncer.initRepo(gitDir, key.org, key.repo); err != nil { - for _, source := range branches { - errs = append(errs, fmt.Errorf("%s: %w", source.String(), err)) - } - continue - } - - for _, source := range branches { - syncer.logger = config.LoggerForInfo(config.Info{ - Metadata: api.Metadata{ - Org: source.org, - Repo: source.repo, - Branch: source.branch, - }, - }) - - destination := source - destination.org = o.targetOrg - if !flattenedOrgs.Has(source.org) { - destination.repo = fmt.Sprintf("%s-%s", source.org, source.repo) + type repoWork struct { + key repoKey + branches []location + } + work := make(chan repoWork) + var errsMu sync.Mutex + var wg sync.WaitGroup + + for i := 0; i < o.parallelism; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for item := range work { + repoSyncer := syncer + repoSyncer.logger = logrus.WithFields(logrus.Fields{"org": item.key.org, "repo": item.key.repo}) + dstRepo := item.key.repo + if !flattenedOrgs.Has(item.key.org) { + dstRepo = fmt.Sprintf("%s-%s", item.key.org, item.key.repo) + } + repoErrs := repoSyncer.syncRepo(item.key.org, item.key.repo, o.targetOrg, dstRepo, item.branches) + if len(repoErrs) > 0 { + errsMu.Lock() + errs = append(errs, repoErrs...) + errsMu.Unlock() + } } + }() + } - if err := syncer.mirror(gitDir, source, destination); err != nil { - errs = append(errs, fmt.Errorf("%s->%s: %w", source.String(), destination.String(), err)) - } - } + for key, branches := range grouped { + work <- repoWork{key: key, branches: branches} } + close(work) + wg.Wait() if len(errs) > 0 { logrus.WithError(utilerrors.NewAggregate(errs)).Fatal("There were failures") diff --git a/cmd/private-org-sync/main_test.go b/cmd/private-org-sync/main_test.go index 1ffaaf1d0d..00057b143a 100644 --- a/cmd/private-org-sync/main_test.go +++ b/cmd/private-org-sync/main_test.go @@ -2,7 +2,9 @@ package main import ( "fmt" + "net/url" "strings" + "sync" "testing" "time" @@ -218,6 +220,7 @@ type mockGitCall struct { } type mockGit struct { + mu sync.Mutex next int expected []mockGitCall @@ -225,14 +228,29 @@ type mockGit struct { } func (m *mockGit) exec(_ *logrus.Entry, _ string, command ...string) (string, int, error) { + m.mu.Lock() + defer m.mu.Unlock() cmd := strings.Join(command, " ") if m.next >= len(m.expected) { m.t.Fatalf("unexpected git call: %s", cmd) return "", 0, nil } - if m.expected[m.next].call != cmd { - m.t.Fatalf("unexpected git call:\n expected: %s\n called: %s", m.expected[m.next].call, cmd) - return "", 0, nil + // Try strict match first; if that fails, search ahead for a match. + // This handles non-deterministic ordering from concurrent goroutines. + idx := m.next + if m.expected[idx].call != cmd { + found := false + for i := idx + 1; i < len(m.expected); i++ { + if m.expected[i].call == cmd { + m.expected[idx], m.expected[i] = m.expected[i], m.expected[idx] + found = true + break + } + } + if !found { + m.t.Fatalf("unexpected git call:\n expected: %s\n called: %s", m.expected[m.next].call, cmd) + return "", 0, nil + } } out := m.expected[m.next].output @@ -242,7 +260,7 @@ func (m *mockGit) exec(_ *logrus.Entry, _ string, command ...string) (string, in return out, exitCode, nil } -func (m mockGit) check() error { +func (m *mockGit) check() error { if m.next != len(m.expected) { return fmt.Errorf("unexpected number of git calls: expected %d, done %d", len(m.expected), m.next) } @@ -254,13 +272,16 @@ func TestMirror(t *testing.T) { token := "TOKEN" org, repo, branch := "org", "repo", "branch" destOrg := "dest" + destUrl, _ := url.Parse(fmt.Sprintf("https://%s@github.com/%s/%s", token, destOrg, repo)) testCases := []struct { description string - src location - dst location - failOnNonexistentDst bool - confirm bool + src location + dst location + confirm bool + + srcHeads RemoteBranchHeads + dstHeads RemoteBranchHeads expectedGitCalls []mockGitCall expectError bool @@ -270,9 +291,9 @@ func TestMirror(t *testing.T) { src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, confirm: true, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2"}, {call: "push --tags https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch"}, }, @@ -281,20 +302,9 @@ func TestMirror(t *testing.T) { description: "no confirm, success -> push with dry run", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, - {call: "fetch --tags org-repo branch --depth=2"}, - {call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch"}, - }, - }, - { - description: "no confirm, source has more branches -> push with dry run", - src: location{org: org, repo: repo, branch: branch}, - dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch\nanother-sha refs/heads/another-branch"}, {call: "fetch --tags org-repo branch --depth=2"}, {call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch"}, }, @@ -303,9 +313,9 @@ func TestMirror(t *testing.T) { description: "fails to fetch -> error", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2", exitCode: 1}, }, expectError: true, @@ -314,9 +324,9 @@ func TestMirror(t *testing.T) { description: "fetch fails with shallow file changed -> retries and succeeds", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2", exitCode: 128, output: "fatal: shallow file has changed since we read it\n"}, {call: "fetch --tags org-repo branch --depth=2"}, {call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch"}, @@ -326,9 +336,9 @@ func TestMirror(t *testing.T) { description: "fetch fails with shallow file changed repeatedly -> error after retries exhausted", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2", exitCode: 128, output: "fatal: shallow file has changed since we read it\n"}, {call: "fetch --tags org-repo branch --depth=2", exitCode: 128, output: "fatal: shallow file has changed since we read it\n"}, {call: "fetch --tags org-repo branch --depth=2", exitCode: 128, output: "fatal: shallow file has changed since we read it\n"}, @@ -339,9 +349,9 @@ func TestMirror(t *testing.T) { description: "no confirm, fails to push -> error", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2"}, {call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch", exitCode: 1}, }, @@ -351,74 +361,24 @@ func TestMirror(t *testing.T) { description: "branches are in sync -> no fetch, no push", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "source-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, - }, - }, - { - description: "ls-remote source fails with retries -> error", - src: location{org: org, repo: repo, branch: branch}, - dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "source-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", exitCode: 1}, - {call: "ls-remote --heads org-repo", exitCode: 1}, - {call: "ls-remote --heads org-repo", exitCode: 1}, - {call: "ls-remote --heads org-repo", exitCode: 1}, - {call: "ls-remote --heads org-repo", exitCode: 1}, - }, - expectError: true, - }, - { - description: "ls-remote source succeeds after retries -> success", - src: location{org: org, repo: repo, branch: branch}, - dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "source-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", exitCode: 1}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, - }, + srcHeads: RemoteBranchHeads{branch: "same-sha"}, + dstHeads: RemoteBranchHeads{branch: "same-sha"}, }, { description: "source branch does not exist -> error", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "source-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "some-sha refs/heads/not-the-branch"}, - }, + srcHeads: RemoteBranchHeads{"not-the-branch": "some-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectError: true, }, { - // If git ls-remote fails, destination repository does not exist - // This is not an error unless failOnNonexistentDst is set - description: "warm cache, ls-remote destination fails on git -> no error when configured", + description: "destination is empty repo -> full fetch then success", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", exitCode: 1}, - }, - }, - { - // If git ls-remote fails, destination repository does not exist - // This is an error when failOnNonexistentDst is set - description: "warm cache, ls-remote destination fails on git -> error when configured", - src: location{org: org, repo: repo, branch: branch}, - dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", exitCode: 1}, - }, - failOnNonexistentDst: true, - expectError: true, - }, - { - description: "destination is empty repo, needs many commits -> full fetch then success", - src: location{org: org, repo: repo, branch: branch}, - dst: location{org: destOrg, repo: repo, branch: branch}, - expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch"}, {call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch"}, }, @@ -427,9 +387,9 @@ func TestMirror(t *testing.T) { description: "destination needs 50 commits -> retries deepening fetches, then success", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2"}, { call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch", @@ -473,9 +433,9 @@ func TestMirror(t *testing.T) { description: "destination needs to merge with source -> retries exceeded, then perform merge after fetching --unshallow", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2"}, { call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch", @@ -534,9 +494,9 @@ func TestMirror(t *testing.T) { description: "destination needs to merge with source -> retries exceeded, merge fails and performs merge --abort", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{branch: "dest-sha"}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "dest-sha refs/heads/branch"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch --depth=2"}, { call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch", @@ -598,9 +558,9 @@ func TestMirror(t *testing.T) { description: "conflicting histories after a force-push result in an error", src: location{org: org, repo: repo, branch: branch}, dst: location{org: destOrg, repo: repo, branch: branch}, + srcHeads: RemoteBranchHeads{branch: "source-sha"}, + dstHeads: RemoteBranchHeads{}, expectedGitCalls: []mockGitCall{ - {call: "ls-remote --heads https://TOKEN@github.com/dest/repo"}, - {call: "ls-remote --heads org-repo", output: "source-sha refs/heads/branch"}, {call: "fetch --tags org-repo branch"}, { call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/branch", @@ -625,17 +585,16 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details. t: t, } m := gitSyncer{ - logger: logrus.WithField("test", tc.description), - prefix: defaultPrefix, - token: token, - confirm: tc.confirm, - root: "git-dir", - git: git.exec, - gitName: "openshift-bot", - gitEmail: "openshift-bot@redhat.com", - failOnNonexistentDst: tc.failOnNonexistentDst, + logger: logrus.WithField("test", tc.description), + prefix: defaultPrefix, + token: token, + confirm: tc.confirm, + root: "git-dir", + git: git.exec, + gitName: "openshift-bot", + gitEmail: "openshift-bot@redhat.com", } - err := m.mirror("repo-dir", tc.src, tc.dst) + err := m.mirror("repo-dir", tc.src, tc.dst, tc.srcHeads, tc.dstHeads, destUrl) if err == nil && tc.expectError { t.Error("expected error, got nil") } @@ -724,6 +683,130 @@ func TestInitRepo(t *testing.T) { } } +func TestSyncRepo(t *testing.T) { + second = time.Millisecond + token := "TOKEN" + org, repo := "org", "repo" + targetOrg := "dest" + branches := []location{ + {org: org, repo: repo, branch: "main"}, + {org: org, repo: repo, branch: "release-4.18"}, + } + + testCases := []struct { + description string + branches []location + failOnNonexistentDst bool + expectedGitCalls []mockGitCall + expectedErrors int + }{ + { + description: "all branches in sync -> init, ls-remote, no fetch/push", + branches: branches, + expectedGitCalls: []mockGitCall{ + {call: "init"}, + {call: "remote get-url org-repo", exitCode: 1}, + {call: "remote add org-repo https://TOKEN@github.com/org/repo"}, + {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"}, + {call: "ls-remote --heads org-repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"}, + }, + }, + { + description: "one branch needs sync -> fetches and pushes that branch only", + branches: branches, + expectedGitCalls: []mockGitCall{ + {call: "init"}, + {call: "remote get-url org-repo", exitCode: 1}, + {call: "remote add org-repo https://TOKEN@github.com/org/repo"}, + {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "aaa refs/heads/main\nbbb refs/heads/release-4.18\n"}, + {call: "ls-remote --heads org-repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"}, + // only release-4.18 needs sync (bbb != aaa) + {call: "fetch --tags org-repo release-4.18 --depth=2"}, + {call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/release-4.18"}, + }, + }, + { + description: "dst ls-remote fails, failOnNonexistentDst=true -> errors for all branches", + branches: branches, + failOnNonexistentDst: true, + expectedGitCalls: []mockGitCall{ + {call: "init"}, + {call: "remote get-url org-repo", exitCode: 1}, + {call: "remote add org-repo https://TOKEN@github.com/org/repo"}, + // both ls-remote calls run in parallel + {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", exitCode: 1}, + {call: "ls-remote --heads org-repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"}, + }, + expectedErrors: 2, + }, + { + description: "dst ls-remote fails, failOnNonexistentDst=false -> no errors (skip)", + branches: branches, + failOnNonexistentDst: false, + expectedGitCalls: []mockGitCall{ + {call: "init"}, + {call: "remote get-url org-repo", exitCode: 1}, + {call: "remote add org-repo https://TOKEN@github.com/org/repo"}, + // both ls-remote calls run in parallel + {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", exitCode: 1}, + {call: "ls-remote --heads org-repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"}, + }, + expectedErrors: 0, + }, + { + description: "src ls-remote fails -> errors for all branches", + branches: branches, + expectedGitCalls: []mockGitCall{ + {call: "init"}, + {call: "remote get-url org-repo", exitCode: 1}, + {call: "remote add org-repo https://TOKEN@github.com/org/repo"}, + {call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "aaa refs/heads/main\n"}, + // src ls-remote fails with retries (withRetryOnNonzero does 5 retries) + {call: "ls-remote --heads org-repo", exitCode: 1}, + {call: "ls-remote --heads org-repo", exitCode: 1}, + {call: "ls-remote --heads org-repo", exitCode: 1}, + {call: "ls-remote --heads org-repo", exitCode: 1}, + {call: "ls-remote --heads org-repo", exitCode: 1}, + }, + expectedErrors: 2, + }, + { + description: "init fails -> errors for all branches", + branches: branches, + expectedGitCalls: []mockGitCall{ + {call: "init", exitCode: 1}, + }, + expectedErrors: 2, + }, + } + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + git := mockGit{ + expected: tc.expectedGitCalls, + t: t, + } + s := gitSyncer{ + logger: logrus.WithField("test", tc.description), + prefix: defaultPrefix, + token: token, + root: "git-dir", + git: git.exec, + confirm: false, + failOnNonexistentDst: tc.failOnNonexistentDst, + gitName: "openshift-bot", + gitEmail: "openshift-bot@redhat.com", + } + errs := s.syncRepo(org, repo, targetOrg, repo, tc.branches) + if len(errs) != tc.expectedErrors { + t.Errorf("expected %d errors, got %d: %v", tc.expectedErrors, len(errs), errs) + } + if err := git.check(); err != nil { + t.Errorf("bad git operation: %v", err) + } + }) + } +} + func TestDestinationNaming(t *testing.T) { testCases := []struct { name string