10 Commits

Author SHA1 Message Date
Philip Gai
8d10c36b44 feat: add --batch-size flag to push refs in batches (#173)
Some checks failed
CodeQL / Analyze (go) (push) Has been cancelled
licenses check / licensed check (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
* feat: add --batch-size flag to push refs in batches

Add support for pushing refs in smaller batches to avoid server-side
limits and timeouts when syncing large repositories with many tags/branches.

- Add --batch-size flag (default 0 = no batching, original behavior)
- Add References() method to GitRepository interface
- Implement collectRefs() and pushRefsInBatches() helpers
- Add MinBatchSize validation (must be 0 or >= 10)

This addresses issues where repositories with 1000+ refs fail to sync
to GHES with 'command error on refs/heads/<branch>: failed' errors.

* test: add tests for batch-size flag and push batching functionality

* fix: pin tool dependencies to versions compatible with Go 1.21

* ci: fix docker compose v2 syntax and update setup-ruby action

* refactor: address PR review feedback

- Remove unused RefInfo struct from git.go
- Remove redundant pushedAny variable tracking in pushRefsInBatches
- Remove incomplete TestPushRefsInBatches_PartialUpToDate test (already covered by existing test case)

* docs: add --batch-size flag to README
2026-01-27 15:19:55 -06:00
Philip Gai
e809e36ab5 chore: update CODEOWNERS with active teams (#174) 2026-01-27 15:17:06 -06:00
Francesco Renzi
b87b406210 Merge pull request #169 from boxofyellow/patch-1
Update contribution note in README.md
2025-09-15 10:21:27 +02:00
boxofyellow
766ee9a4e6 Update contribution note in README.md
Clarified the status of contributions to the GitHub action and provided guidance on following the public roadmap for updates.
2025-09-05 16:10:19 -04:00
Ben De St Paer-Gotch
350af00459 Merge pull request #166 from actions/nebuk89-patch-1
Update README.md
2025-06-09 18:20:10 +01:00
Ben De St Paer-Gotch
6f241797e1 Update README.md 2025-06-06 11:51:27 +01:00
Shawn Hartsell
9141ea72f2 Merge pull request #126 from actions/dependabot/github_actions/ruby/setup-ruby-1.174.0
Bump ruby/setup-ruby from 1.173.0 to 1.174.0
2024-04-25 10:33:50 -05:00
Shawn Hartsell
6df9de5468 Merge pull request #125 from actions/dependabot/github_actions/actions/stale-9
Bump actions/stale from 5 to 9
2024-04-25 10:33:24 -05:00
dependabot[bot]
19a0a6e383 Bump ruby/setup-ruby from 1.173.0 to 1.174.0
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.173.0 to 1.174.0.
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Commits](5f19ec79ce...6bd3d993c6)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 20:22:05 +00:00
dependabot[bot]
fe7ea535a8 Bump actions/stale from 5 to 9
Bumps [actions/stale](https://github.com/actions/stale) from 5 to 9.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v5...v9)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 20:22:03 +00:00
10 changed files with 606 additions and 25 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1,2 +1,2 @@
* @actions/actions-delivery-nexus @actions/actions-oss-maintainers
* @actions/actions-oss-maintainers @actions/actions-sync-maintainers

View File

@@ -9,8 +9,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Lint
run: docker-compose run --rm lint
run: docker compose run --rm lint
- name: Test
run: docker-compose run --rm test
run: docker compose run --rm test
- name: E2E
run: docker-compose run --rm test-build
run: docker compose run --rm test-build

View File

@@ -10,7 +10,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
- uses: actions/stale@v9
with:
days-before-issue-stale: 30
days-before-issue-close: 14

View File

@@ -35,7 +35,7 @@ jobs:
- run: go mod vendor
# Ruby is required for licensed
- uses: ruby/setup-ruby@5f19ec79cedfadb78ab837f95b87734d0003c899
- uses: ruby/setup-ruby@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # v1
with:
ruby-version: "3.2"

View File

@@ -14,6 +14,24 @@ It is designed to work when:
* The GitHub Enterprise instance is separate from the rest of the internet.
* The GitHub Enterprise instance is connected to the rest of the internet.
### Note
Thank you for your interest in this GitHub action, however, right now we are not taking contributions.
We continue to focus our resources on strategic areas that help our customers be successful while making developers' lives easier. While GitHub Actions remains a key part of this vision, we are allocating resources towards other areas of Actions and are not taking contributions to this repository at this time. The GitHub public roadmap is the best place to follow along for any updates on features were working on and what stage theyre in.
We are taking the following steps to better direct requests related to GitHub Actions, including:
1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions)
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report.
3. Security Issues should be handled as per our [security.md](security.md)
We will still provide security updates for this project and fix major breaking changes during this time.
You are welcome to still raise bugs in this repo.
## Connected instances
When there are machines which have access to both the public internet and the GHES instance run `actions-sync sync`.
@@ -38,6 +56,8 @@ When there are machines which have access to both the public internet and the GH
A path to a file containing a newline separated list of repositories to be synced. Each entry follows the format of `repo-name`.
- `actions-admin-user` _(optional)_
The name of the Actions admin user, which will be used for updating the chosen action. To use the default user, pass `actions-admin`. If not set, the impersonation is disabled. Note that `site_admin` scope is required in the token for the impersonation to work.
- `batch-size` _(optional)_
Number of refs to push in each batch. Default is 0 (no batching). Use a value like 100 if pushing fails for large repositories with many branches and tags.
**Example Usage:**
@@ -96,6 +116,8 @@ When no machine has access to both the public internet and the GHES instance:
Limit push to specific repositories in the cache directory.
- `actions-admin-user` _(optional)_
The name of the Actions admin user, which will be used for updating the chosen action. To use the default user, pass `actions-admin`. If not set, the impersonation is disabled. Note that `site_admin` scope is required in the token for the impersonation to work.
- `batch-size` _(optional)_
Number of refs to push in each batch. Default is 0 (no batching). Use a value like 100 if pushing fails for large repositories with many branches and tags.
**Example Usage:**
@@ -110,7 +132,3 @@ When no machine has access to both the public internet and the GHES instance:
When creating a personal access token include the `repo` and `workflow` scopes. Include the `site_admin` scope (optional) if you want organizations to be created as necessary or you want to use the impersonation logic for the `push` or `sync` commands.
## Contributing
If you would like to contribute your work back to the project, please see
[`CONTRIBUTING.md`](CONTRIBUTING.md).

View File

@@ -18,12 +18,12 @@ if [ ! -f go.mod ]; then
go mod init tools
fi
go get golang.org/x/tools/go/packages@master
go get golang.org/x/tools/go/packages@v0.16.0
if [ ! -f "${GOBIN}/mockgen" ]; then
echo "mockgen was not found, installing..."
go get github.com/golang/mock/gomock@master
go get github.com/golang/mock/mockgen@master
go get github.com/golang/mock/gomock@v1.6.0
go get github.com/golang/mock/mockgen@v1.6.0
fi
if [ ! -f "${GOBIN}/golangci-lint" ]; then
@@ -33,5 +33,5 @@ fi
if [ ! -f "${GOBIN}/goimports" ]; then
echo "goimports was not found, installing..."
go get golang.org/x/tools/cmd/goimports@master
go get golang.org/x/tools/cmd/goimports@v0.16.0
fi

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/storer"
)
// A really thin Git wrapper so we can stub it out in our tests
@@ -19,6 +20,7 @@ type GitRepository interface {
DeleteRemote(string) error
CreateRemote(*config.RemoteConfig) (GitRemote, error)
FetchContext(context.Context, *git.FetchOptions) error
References() (storer.ReferenceIter, error)
}
type GitRemote interface {
@@ -65,3 +67,7 @@ func (r *gitRepository) CreateRemote(c *config.RemoteConfig) (GitRemote, error)
func (r *gitRepository) FetchContext(ctx context.Context, o *git.FetchOptions) error {
return r.inner.FetchContext(ctx, o)
}
func (r *gitRepository) References() (storer.ReferenceIter, error) {
return r.inner.References()
}

75
src/git_test.go Normal file
View File

@@ -0,0 +1,75 @@
package src
import (
"context"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/stretchr/testify/assert"
)
// Tests for GitRepository interface and implementations
func TestGitRepositoryInterface(t *testing.T) {
// This test verifies that our mock implements the GitRepository interface
var _ GitRepository = &mockGitRepository{}
}
func TestGitRemoteInterface(t *testing.T) {
// This test verifies that our mock implements the GitRemote interface
var _ GitRemote = &mockGitRemote{}
}
// Ensure the mockGitRepository implements all methods of GitRepository
func TestMockGitRepository_DeleteRemote(t *testing.T) {
repo := &mockGitRepository{}
err := repo.DeleteRemote("origin")
assert.NoError(t, err)
}
func TestMockGitRepository_CreateRemote(t *testing.T) {
repo := &mockGitRepository{}
remote, err := repo.CreateRemote(&config.RemoteConfig{Name: "test"})
assert.NoError(t, err)
assert.Nil(t, remote)
}
func TestMockGitRepository_FetchContext(t *testing.T) {
repo := &mockGitRepository{}
err := repo.FetchContext(context.Background(), &git.FetchOptions{})
assert.NoError(t, err)
}
func TestMockGitRepository_References(t *testing.T) {
repo := &mockGitRepository{}
refs, err := repo.References()
assert.NoError(t, err)
assert.NotNil(t, refs)
// Verify it returns a valid iterator
_, ok := refs.(storer.ReferenceIter)
assert.True(t, ok)
}
// Ensure the mockGitRemote implements all methods of GitRemote
func TestMockGitRemote_PushContext(t *testing.T) {
remote := &mockGitRemote{}
err := remote.PushContext(context.Background(), &git.PushOptions{})
assert.NoError(t, err)
}
func TestMockGitRemote_Config(t *testing.T) {
remote := &mockGitRemote{}
cfg := remote.Config()
assert.NotNil(t, cfg)
assert.Equal(t, "test-remote", cfg.Name)
// Test with custom config
customRemote := &mockGitRemote{
remoteConfig: &config.RemoteConfig{Name: "custom-remote"},
}
cfg = customRemote.Config()
assert.Equal(t, "custom-remote", cfg.Name)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/google/go-github/v43/github"
@@ -22,9 +23,16 @@ const enterpriseAPIPath = "/api/v3"
const enterpriseVersionHeaderKey = "X-GitHub-Enterprise-Version"
const xOAuthScopesHeader = "X-OAuth-Scopes"
// DefaultBatchSize of 0 means no batching (push all refs at once, original behavior)
const DefaultBatchSize = 0
// MinBatchSize is the minimum allowed batch size when batching is enabled
const MinBatchSize = 10
type PushOnlyFlags struct {
BaseURL, Token, ActionsAdminUser string
DisableGitAuth bool
BatchSize int
}
type PushFlags struct {
@@ -42,6 +50,7 @@ func (f *PushOnlyFlags) Init(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.ActionsAdminUser, "actions-admin-user", "", "A user to impersonate for the push requests. To use the default name, pass 'actions-admin'. Note that the site_admin scope in the token is required for the impersonation to work.")
cmd.Flags().StringVar(&f.Token, "destination-token", "", "Token to access API on GHES instance")
cmd.Flags().BoolVar(&f.DisableGitAuth, "disable-push-git-auth", false, "Disables git authentication whilst pushing")
cmd.Flags().IntVar(&f.BatchSize, "batch-size", DefaultBatchSize, "Number of refs to push in each batch (0 = no batching). Use a value like 100 if pushing fails for large repositories.")
}
func (f *PushFlags) Validate() Validations {
@@ -56,6 +65,9 @@ func (f *PushOnlyFlags) Validate() Validations {
if f.Token == "" {
validations = append(validations, "--destination-token must be set")
}
if f.BatchSize != 0 && f.BatchSize < MinBatchSize {
validations = append(validations, fmt.Sprintf("--batch-size must be 0 (no batching) or at least %d", MinBatchSize))
}
return validations
}
@@ -282,16 +294,86 @@ func syncWithCachedRepository(ctx context.Context, flags *PushFlags, ghRepo *git
Password: flags.Token,
}
}
err = remote.PushContext(ctx, &git.PushOptions{
RemoteName: remote.Config().Name,
RefSpecs: []config.RefSpec{
"+refs/heads/*:refs/heads/*",
"+refs/tags/*:refs/tags/*",
},
Auth: auth,
})
if errors.Cause(err) == git.NoErrAlreadyUpToDate {
return nil
// If batch size is 0 or negative, use original wildcard approach (no batching)
if flags.BatchSize <= 0 {
err = remote.PushContext(ctx, &git.PushOptions{
RemoteName: remote.Config().Name,
RefSpecs: []config.RefSpec{
"+refs/heads/*:refs/heads/*",
"+refs/tags/*:refs/tags/*",
},
Auth: auth,
})
if errors.Cause(err) == git.NoErrAlreadyUpToDate {
return nil
}
return errors.Wrapf(err, "failed to push to repo: %s", ghRepo.GetCloneURL())
}
return errors.Wrapf(err, "failed to push to repo: %s", ghRepo.GetCloneURL())
// Batching requested - collect all refs and push in batches
refs, err := collectRefs(gitRepo)
if err != nil {
return errors.Wrap(err, "error collecting refs")
}
return pushRefsInBatches(ctx, remote, refs, flags.BatchSize, auth, ghRepo.GetCloneURL())
}
// collectRefs gathers all branch and tag refs from the repository
func collectRefs(gitRepo GitRepository) ([]plumbing.ReferenceName, error) {
refIter, err := gitRepo.References()
if err != nil {
return nil, err
}
var refs []plumbing.ReferenceName
err = refIter.ForEach(func(ref *plumbing.Reference) error {
name := ref.Name()
// Only include branches and tags
if name.IsBranch() || name.IsTag() {
refs = append(refs, name)
}
return nil
})
if err != nil {
return nil, err
}
return refs, nil
}
// pushRefsInBatches pushes refs in smaller batches to avoid server-side limits
func pushRefsInBatches(ctx context.Context, remote GitRemote, refs []plumbing.ReferenceName, batchSize int, auth transport.AuthMethod, cloneURL string) error {
totalRefs := len(refs)
for i := 0; i < totalRefs; i += batchSize {
end := i + batchSize
if end > totalRefs {
end = totalRefs
}
batch := refs[i:end]
refSpecs := make([]config.RefSpec, len(batch))
for j, ref := range batch {
// Create a refspec like "+refs/heads/main:refs/heads/main"
refSpecs[j] = config.RefSpec("+" + ref.String() + ":" + ref.String())
}
err := remote.PushContext(ctx, &git.PushOptions{
RemoteName: remote.Config().Name,
RefSpecs: refSpecs,
Auth: auth,
})
if err != nil {
if errors.Cause(err) == git.NoErrAlreadyUpToDate {
// This batch was already up to date, continue to next batch
continue
}
return errors.Wrapf(err, "failed to push batch %d-%d of %d refs to repo: %s", i+1, end, totalRefs, cloneURL)
}
}
return nil
}

400
src/push_test.go Normal file
View File

@@ -0,0 +1,400 @@
package src
import (
"context"
"fmt"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mock implementations for testing
type mockReferenceIter struct {
refs []*plumbing.Reference
index int
}
func (m *mockReferenceIter) Next() (*plumbing.Reference, error) {
if m.index >= len(m.refs) {
return nil, storer.ErrStop
}
ref := m.refs[m.index]
m.index++
return ref, nil
}
func (m *mockReferenceIter) ForEach(fn func(*plumbing.Reference) error) error {
for _, ref := range m.refs {
if err := fn(ref); err != nil {
if err == storer.ErrStop {
return nil
}
return err
}
}
return nil
}
func (m *mockReferenceIter) Close() {}
type mockGitRepository struct {
refs []*plumbing.Reference
err error
}
func (m *mockGitRepository) DeleteRemote(name string) error {
return nil
}
func (m *mockGitRepository) CreateRemote(c *config.RemoteConfig) (GitRemote, error) {
return nil, nil
}
func (m *mockGitRepository) FetchContext(ctx context.Context, o *git.FetchOptions) error {
return nil
}
func (m *mockGitRepository) References() (storer.ReferenceIter, error) {
if m.err != nil {
return nil, m.err
}
return &mockReferenceIter{refs: m.refs, index: 0}, nil
}
type mockGitRemote struct {
pushCalls [][]config.RefSpec
pushError error
alreadyUpToDate bool
remoteConfig *config.RemoteConfig
}
func (m *mockGitRemote) PushContext(ctx context.Context, o *git.PushOptions) error {
m.pushCalls = append(m.pushCalls, o.RefSpecs)
if m.alreadyUpToDate {
return git.NoErrAlreadyUpToDate
}
return m.pushError
}
func (m *mockGitRemote) Config() *config.RemoteConfig {
if m.remoteConfig != nil {
return m.remoteConfig
}
return &config.RemoteConfig{Name: "test-remote"}
}
// Tests for PushOnlyFlags.Validate batch size validation
func TestPushOnlyFlags_Validate_BatchSize(t *testing.T) {
tests := []struct {
name string
batchSize int
expectErr bool
errMessage string
}{
{
name: "batch size 0 (no batching) is valid",
batchSize: 0,
expectErr: false,
},
{
name: "batch size at minimum (10) is valid",
batchSize: MinBatchSize,
expectErr: false,
},
{
name: "batch size above minimum is valid",
batchSize: 100,
expectErr: false,
},
{
name: "batch size below minimum is invalid",
batchSize: 5,
expectErr: true,
errMessage: fmt.Sprintf("--batch-size must be 0 (no batching) or at least %d", MinBatchSize),
},
{
name: "batch size of 1 is invalid",
batchSize: 1,
expectErr: true,
errMessage: fmt.Sprintf("--batch-size must be 0 (no batching) or at least %d", MinBatchSize),
},
{
name: "batch size of 9 is invalid",
batchSize: 9,
expectErr: true,
errMessage: fmt.Sprintf("--batch-size must be 0 (no batching) or at least %d", MinBatchSize),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flags := PushOnlyFlags{
BaseURL: "https://example.com",
Token: "test-token",
BatchSize: tt.batchSize,
}
validations := flags.Validate()
if tt.expectErr {
require.NotEmpty(t, validations, "expected validation error")
found := false
for _, v := range validations {
if v == tt.errMessage {
found = true
break
}
}
assert.True(t, found, "expected error message not found: %s", tt.errMessage)
} else {
// Check that batch size validation didn't add an error
for _, v := range validations {
assert.NotContains(t, v, "batch-size", "unexpected batch-size validation error")
}
}
})
}
}
// Tests for collectRefs function
func TestCollectRefs(t *testing.T) {
tests := []struct {
name string
refs []*plumbing.Reference
expectedLen int
expectedRefs []plumbing.ReferenceName
expectErr bool
}{
{
name: "empty repository",
refs: []*plumbing.Reference{},
expectedLen: 0,
},
{
name: "branches only",
refs: []*plumbing.Reference{
plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("abc123")),
plumbing.NewHashReference(plumbing.NewBranchReferenceName("feature"), plumbing.NewHash("def456")),
},
expectedLen: 2,
expectedRefs: []plumbing.ReferenceName{
plumbing.NewBranchReferenceName("main"),
plumbing.NewBranchReferenceName("feature"),
},
},
{
name: "tags only",
refs: []*plumbing.Reference{
plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0.0"), plumbing.NewHash("abc123")),
plumbing.NewHashReference(plumbing.NewTagReferenceName("v2.0.0"), plumbing.NewHash("def456")),
},
expectedLen: 2,
expectedRefs: []plumbing.ReferenceName{
plumbing.NewTagReferenceName("v1.0.0"),
plumbing.NewTagReferenceName("v2.0.0"),
},
},
{
name: "mixed branches and tags",
refs: []*plumbing.Reference{
plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("abc123")),
plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0.0"), plumbing.NewHash("def456")),
plumbing.NewHashReference(plumbing.NewBranchReferenceName("develop"), plumbing.NewHash("ghi789")),
},
expectedLen: 3,
},
{
name: "filters out HEAD and other refs",
refs: []*plumbing.Reference{
plumbing.NewHashReference(plumbing.HEAD, plumbing.NewHash("abc123")),
plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("def456")),
plumbing.NewHashReference(plumbing.NewRemoteReferenceName("origin", "main"), plumbing.NewHash("ghi789")),
plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0.0"), plumbing.NewHash("jkl012")),
},
expectedLen: 2, // Only main branch and v1.0.0 tag
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &mockGitRepository{refs: tt.refs}
refs, err := collectRefs(repo)
if tt.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Len(t, refs, tt.expectedLen)
if tt.expectedRefs != nil {
for i, expected := range tt.expectedRefs {
assert.Equal(t, expected, refs[i])
}
}
})
}
}
func TestCollectRefs_Error(t *testing.T) {
repo := &mockGitRepository{err: fmt.Errorf("failed to get references")}
refs, err := collectRefs(repo)
require.Error(t, err)
assert.Nil(t, refs)
assert.Contains(t, err.Error(), "failed to get references")
}
// Tests for pushRefsInBatches function
func TestPushRefsInBatches(t *testing.T) {
tests := []struct {
name string
refs []plumbing.ReferenceName
batchSize int
expectedBatches int
alreadyUpToDate bool
pushError error
expectErr bool
expectedErrSubstr string
}{
{
name: "single batch - fewer refs than batch size",
refs: []plumbing.ReferenceName{
plumbing.NewBranchReferenceName("main"),
plumbing.NewBranchReferenceName("feature"),
},
batchSize: 10,
expectedBatches: 1,
},
{
name: "single batch - exact batch size",
refs: createNRefs(10),
batchSize: 10,
expectedBatches: 1,
},
{
name: "multiple batches - exactly divisible",
refs: createNRefs(30),
batchSize: 10,
expectedBatches: 3,
},
{
name: "multiple batches - not exactly divisible",
refs: createNRefs(25),
batchSize: 10,
expectedBatches: 3, // 10 + 10 + 5
},
{
name: "empty refs",
refs: []plumbing.ReferenceName{},
batchSize: 10,
expectedBatches: 0,
},
{
name: "all batches already up to date",
refs: []plumbing.ReferenceName{
plumbing.NewBranchReferenceName("main"),
},
batchSize: 10,
expectedBatches: 1,
alreadyUpToDate: true,
},
{
name: "push error",
refs: []plumbing.ReferenceName{
plumbing.NewBranchReferenceName("main"),
},
batchSize: 10,
pushError: fmt.Errorf("network error"),
expectErr: true,
expectedErrSubstr: "failed to push batch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
remote := &mockGitRemote{
alreadyUpToDate: tt.alreadyUpToDate,
pushError: tt.pushError,
}
err := pushRefsInBatches(context.Background(), remote, tt.refs, tt.batchSize, nil, "https://example.com/repo.git")
if tt.expectErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedErrSubstr)
return
}
require.NoError(t, err)
assert.Len(t, remote.pushCalls, tt.expectedBatches)
})
}
}
func TestPushRefsInBatches_RefSpecFormat(t *testing.T) {
refs := []plumbing.ReferenceName{
plumbing.NewBranchReferenceName("main"),
plumbing.NewTagReferenceName("v1.0.0"),
}
remote := &mockGitRemote{}
err := pushRefsInBatches(context.Background(), remote, refs, 10, nil, "https://example.com/repo.git")
require.NoError(t, err)
require.Len(t, remote.pushCalls, 1)
require.Len(t, remote.pushCalls[0], 2)
// Check refspec format: should be "+refs/heads/main:refs/heads/main"
assert.Equal(t, config.RefSpec("+refs/heads/main:refs/heads/main"), remote.pushCalls[0][0])
assert.Equal(t, config.RefSpec("+refs/tags/v1.0.0:refs/tags/v1.0.0"), remote.pushCalls[0][1])
}
func TestPushRefsInBatches_BatchSizes(t *testing.T) {
// Create 25 refs
refs := createNRefs(25)
batchSize := 10
remote := &mockGitRemote{}
err := pushRefsInBatches(context.Background(), remote, refs, batchSize, nil, "https://example.com/repo.git")
require.NoError(t, err)
require.Len(t, remote.pushCalls, 3)
// First batch should have 10 refs
assert.Len(t, remote.pushCalls[0], 10)
// Second batch should have 10 refs
assert.Len(t, remote.pushCalls[1], 10)
// Third batch should have 5 refs (remainder)
assert.Len(t, remote.pushCalls[2], 5)
}
// Tests for constants
func TestConstants(t *testing.T) {
assert.Equal(t, 0, DefaultBatchSize, "DefaultBatchSize should be 0 for backward compatibility")
assert.Equal(t, 10, MinBatchSize, "MinBatchSize should be 10")
}
// Helper function to create N test refs
func createNRefs(n int) []plumbing.ReferenceName {
refs := make([]plumbing.ReferenceName, n)
for i := 0; i < n; i++ {
refs[i] = plumbing.NewBranchReferenceName(fmt.Sprintf("branch-%d", i))
}
return refs
}