feat: add --batch-size flag to push refs in batches (#173)
CodeQL / Analyze (go) (push) Has been cancelled
licenses check / licensed check (push) Has been cancelled
goreleaser / goreleaser (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
This commit is contained in:
Philip Gai
2026-01-27 15:19:55 -06:00
committed by GitHub
parent e809e36ab5
commit 8d10c36b44
8 changed files with 586 additions and 19 deletions
+93 -11
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
}