Skip to content

Commit a39befe

Browse files
petr-mullerclaude
andcommitted
refactor(private-org-sync): extract syncRepo method for testability
Extract the per-repo sync logic from main() into a syncRepo method on gitSyncer. This makes the repo initialization, ls-remote batching, and branch mirroring flow independently testable. Add TestSyncRepo with 6 test cases covering: branches in sync, one branch needing sync, dst/src ls-remote failures (with and without failOnNonexistentDst), and init failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 136df2b commit a39befe

2 files changed

Lines changed: 201 additions & 71 deletions

File tree

cmd/private-org-sync/main.go

Lines changed: 81 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,85 @@ func (g gitSyncer) initRepo(repoDir, org, repo string) error {
364364
return nil
365365
}
366366

367+
// syncRepo initializes a local git repo, fetches branch heads from both source
368+
// and destination via ls-remote, and mirrors each branch that needs syncing.
369+
func (g gitSyncer) syncRepo(org, repo, targetOrg, dstRepo string, branches []location) []error {
370+
var errs []error
371+
repoLogger := g.logger
372+
373+
gitDir, err := g.makeGitDir(org, repo)
374+
if err != nil {
375+
for _, source := range branches {
376+
errs = append(errs, fmt.Errorf("%s: %w", source.String(), err))
377+
}
378+
return errs
379+
}
380+
381+
if err := g.initRepo(gitDir, org, repo); err != nil {
382+
for _, source := range branches {
383+
errs = append(errs, fmt.Errorf("%s: %w", source.String(), err))
384+
}
385+
return errs
386+
}
387+
388+
// ls-remote destination once per repo
389+
destUrlRaw := fmt.Sprintf("%s/%s/%s", g.prefix, targetOrg, dstRepo)
390+
destUrl, err := url.Parse(destUrlRaw)
391+
if err != nil {
392+
repoLogger.WithField("remote-url", destUrlRaw).WithError(err).Error("Failed to construct URL for the destination remote")
393+
for _, source := range branches {
394+
errs = append(errs, fmt.Errorf("%s: failed to construct URL for the destination remote", source.String()))
395+
}
396+
return errs
397+
}
398+
if g.token != "" {
399+
destUrl.User = url.User(g.token)
400+
}
401+
402+
dstHeads, err := getRemoteBranchHeads(repoLogger, g.git, gitDir, destUrl.String())
403+
if err != nil {
404+
message := "destination repository does not exist or we cannot access it"
405+
if g.failOnNonexistentDst {
406+
repoLogger.Errorf("%s", message)
407+
for _, source := range branches {
408+
errs = append(errs, fmt.Errorf("%s: %s", source.String(), message))
409+
}
410+
} else {
411+
repoLogger.Warn(message)
412+
}
413+
return errs
414+
}
415+
416+
// ls-remote source once per repo
417+
srcRemote := fmt.Sprintf("%s-%s", org, repo)
418+
srcHeads, err := getRemoteBranchHeads(repoLogger, withRetryOnNonzero(g.git, 5), gitDir, srcRemote)
419+
if err != nil {
420+
repoLogger.WithError(err).Error("Failed to determine branch HEADs in source")
421+
for _, source := range branches {
422+
errs = append(errs, fmt.Errorf("%s: failed to determine branch HEADs in source", source.String()))
423+
}
424+
return errs
425+
}
426+
427+
for _, source := range branches {
428+
g.logger = config.LoggerForInfo(config.Info{
429+
Metadata: api.Metadata{
430+
Org: source.org,
431+
Repo: source.repo,
432+
Branch: source.branch,
433+
},
434+
})
435+
436+
destination := location{org: targetOrg, repo: dstRepo, branch: source.branch}
437+
438+
if err := g.mirror(gitDir, source, destination, srcHeads, dstHeads, destUrl); err != nil {
439+
errs = append(errs, fmt.Errorf("%s->%s: %w", source.String(), destination.String(), err))
440+
}
441+
}
442+
443+
return errs
444+
}
445+
367446
// mirror syncs a single branch from source to destination, using pre-fetched
368447
// branch head information. The `repoDir` must have been previously initialized
369448
// with git init and remote setup. The `srcHeads` and `dstHeads` must have been
@@ -651,83 +730,14 @@ func main() {
651730
}
652731

653732
for key, branches := range grouped {
654-
repoLogger := logrus.WithFields(logrus.Fields{"org": key.org, "repo": key.repo})
655-
syncer.logger = repoLogger
733+
syncer.logger = logrus.WithFields(logrus.Fields{"org": key.org, "repo": key.repo})
656734

657735
dstRepo := key.repo
658736
if !flattenedOrgs.Has(key.org) {
659737
dstRepo = fmt.Sprintf("%s-%s", key.org, key.repo)
660738
}
661739

662-
gitDir, err := syncer.makeGitDir(key.org, key.repo)
663-
if err != nil {
664-
for _, source := range branches {
665-
errs = append(errs, fmt.Errorf("%s: %w", source.String(), err))
666-
}
667-
continue
668-
}
669-
670-
if err := syncer.initRepo(gitDir, key.org, key.repo); err != nil {
671-
for _, source := range branches {
672-
errs = append(errs, fmt.Errorf("%s: %w", source.String(), err))
673-
}
674-
continue
675-
}
676-
677-
// ls-remote destination once per repo
678-
destUrlRaw := fmt.Sprintf("%s/%s/%s", syncer.prefix, o.targetOrg, dstRepo)
679-
destUrl, err := url.Parse(destUrlRaw)
680-
if err != nil {
681-
repoLogger.WithField("remote-url", destUrlRaw).WithError(err).Error("Failed to construct URL for the destination remote")
682-
for _, source := range branches {
683-
errs = append(errs, fmt.Errorf("%s: failed to construct URL for the destination remote", source.String()))
684-
}
685-
continue
686-
}
687-
if syncer.token != "" {
688-
destUrl.User = url.User(syncer.token)
689-
}
690-
691-
dstHeads, err := getRemoteBranchHeads(repoLogger, syncer.git, gitDir, destUrl.String())
692-
if err != nil {
693-
message := "destination repository does not exist or we cannot access it"
694-
if syncer.failOnNonexistentDst {
695-
repoLogger.Errorf("%s", message)
696-
for _, source := range branches {
697-
errs = append(errs, fmt.Errorf("%s: %s", source.String(), message))
698-
}
699-
} else {
700-
repoLogger.Warn(message)
701-
}
702-
continue
703-
}
704-
705-
// ls-remote source once per repo
706-
srcRemote := fmt.Sprintf("%s-%s", key.org, key.repo)
707-
srcHeads, err := getRemoteBranchHeads(repoLogger, withRetryOnNonzero(syncer.git, 5), gitDir, srcRemote)
708-
if err != nil {
709-
repoLogger.WithError(err).Error("Failed to determine branch HEADs in source")
710-
for _, source := range branches {
711-
errs = append(errs, fmt.Errorf("%s: failed to determine branch HEADs in source", source.String()))
712-
}
713-
continue
714-
}
715-
716-
for _, source := range branches {
717-
syncer.logger = config.LoggerForInfo(config.Info{
718-
Metadata: api.Metadata{
719-
Org: source.org,
720-
Repo: source.repo,
721-
Branch: source.branch,
722-
},
723-
})
724-
725-
destination := location{org: o.targetOrg, repo: dstRepo, branch: source.branch}
726-
727-
if err := syncer.mirror(gitDir, source, destination, srcHeads, dstHeads, destUrl); err != nil {
728-
errs = append(errs, fmt.Errorf("%s->%s: %w", source.String(), destination.String(), err))
729-
}
730-
}
740+
errs = append(errs, syncer.syncRepo(key.org, key.repo, o.targetOrg, dstRepo, branches)...)
731741
}
732742

733743
if len(errs) > 0 {

cmd/private-org-sync/main_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,126 @@ func TestInitRepo(t *testing.T) {
666666
}
667667
}
668668

669+
func TestSyncRepo(t *testing.T) {
670+
second = time.Millisecond
671+
token := "TOKEN"
672+
org, repo := "org", "repo"
673+
targetOrg := "dest"
674+
branches := []location{
675+
{org: org, repo: repo, branch: "main"},
676+
{org: org, repo: repo, branch: "release-4.18"},
677+
}
678+
679+
testCases := []struct {
680+
description string
681+
branches []location
682+
failOnNonexistentDst bool
683+
expectedGitCalls []mockGitCall
684+
expectedErrors int
685+
}{
686+
{
687+
description: "all branches in sync -> init, ls-remote, no fetch/push",
688+
branches: branches,
689+
expectedGitCalls: []mockGitCall{
690+
{call: "init"},
691+
{call: "remote get-url org-repo", exitCode: 1},
692+
{call: "remote add org-repo https://TOKEN@github.com/org/repo"},
693+
{call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"},
694+
{call: "ls-remote --heads org-repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"},
695+
},
696+
},
697+
{
698+
description: "one branch needs sync -> fetches and pushes that branch only",
699+
branches: branches,
700+
expectedGitCalls: []mockGitCall{
701+
{call: "init"},
702+
{call: "remote get-url org-repo", exitCode: 1},
703+
{call: "remote add org-repo https://TOKEN@github.com/org/repo"},
704+
{call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "aaa refs/heads/main\nbbb refs/heads/release-4.18\n"},
705+
{call: "ls-remote --heads org-repo", output: "aaa refs/heads/main\naaa refs/heads/release-4.18\n"},
706+
// only release-4.18 needs sync (bbb != aaa)
707+
{call: "fetch --tags org-repo release-4.18 --depth=2"},
708+
{call: "push --tags --dry-run https://TOKEN@github.com/dest/repo FETCH_HEAD:refs/heads/release-4.18"},
709+
},
710+
},
711+
{
712+
description: "dst ls-remote fails, failOnNonexistentDst=true -> errors for all branches",
713+
branches: branches,
714+
failOnNonexistentDst: true,
715+
expectedGitCalls: []mockGitCall{
716+
{call: "init"},
717+
{call: "remote get-url org-repo", exitCode: 1},
718+
{call: "remote add org-repo https://TOKEN@github.com/org/repo"},
719+
{call: "ls-remote --heads https://TOKEN@github.com/dest/repo", exitCode: 1},
720+
},
721+
expectedErrors: 2,
722+
},
723+
{
724+
description: "dst ls-remote fails, failOnNonexistentDst=false -> no errors (skip)",
725+
branches: branches,
726+
failOnNonexistentDst: false,
727+
expectedGitCalls: []mockGitCall{
728+
{call: "init"},
729+
{call: "remote get-url org-repo", exitCode: 1},
730+
{call: "remote add org-repo https://TOKEN@github.com/org/repo"},
731+
{call: "ls-remote --heads https://TOKEN@github.com/dest/repo", exitCode: 1},
732+
},
733+
expectedErrors: 0,
734+
},
735+
{
736+
description: "src ls-remote fails -> errors for all branches",
737+
branches: branches,
738+
expectedGitCalls: []mockGitCall{
739+
{call: "init"},
740+
{call: "remote get-url org-repo", exitCode: 1},
741+
{call: "remote add org-repo https://TOKEN@github.com/org/repo"},
742+
{call: "ls-remote --heads https://TOKEN@github.com/dest/repo", output: "aaa refs/heads/main\n"},
743+
// src ls-remote fails with retries (withRetryOnNonzero does 5 retries)
744+
{call: "ls-remote --heads org-repo", exitCode: 1},
745+
{call: "ls-remote --heads org-repo", exitCode: 1},
746+
{call: "ls-remote --heads org-repo", exitCode: 1},
747+
{call: "ls-remote --heads org-repo", exitCode: 1},
748+
{call: "ls-remote --heads org-repo", exitCode: 1},
749+
},
750+
expectedErrors: 2,
751+
},
752+
{
753+
description: "init fails -> errors for all branches",
754+
branches: branches,
755+
expectedGitCalls: []mockGitCall{
756+
{call: "init", exitCode: 1},
757+
},
758+
expectedErrors: 2,
759+
},
760+
}
761+
for _, tc := range testCases {
762+
t.Run(tc.description, func(t *testing.T) {
763+
git := mockGit{
764+
expected: tc.expectedGitCalls,
765+
t: t,
766+
}
767+
s := gitSyncer{
768+
logger: logrus.WithField("test", tc.description),
769+
prefix: defaultPrefix,
770+
token: token,
771+
root: "git-dir",
772+
git: git.exec,
773+
confirm: false,
774+
failOnNonexistentDst: tc.failOnNonexistentDst,
775+
gitName: "openshift-bot",
776+
gitEmail: "openshift-bot@redhat.com",
777+
}
778+
errs := s.syncRepo(org, repo, targetOrg, repo, tc.branches)
779+
if len(errs) != tc.expectedErrors {
780+
t.Errorf("expected %d errors, got %d: %v", tc.expectedErrors, len(errs), errs)
781+
}
782+
if err := git.check(); err != nil {
783+
t.Errorf("bad git operation: %v", err)
784+
}
785+
})
786+
}
787+
}
788+
669789
func TestDestinationNaming(t *testing.T) {
670790
testCases := []struct {
671791
name string

0 commit comments

Comments
 (0)