We have a repo with 50k refs and we go this error: 2026/06/18 13:17:39 WARN Permanent sync error, terminating error="sync: replicate relay failed: replicate execute: push target refs: target receive-p

Claude Code·Opus 4.8[1m]·Soph·2mo ago·2hr 55min·4 Checkpoints·12 file changes·+460/-88·166.7K tokens

We have a repo with 50k refs and we go this error: 2026/06/18 13:17:39 WARN Permanent sync error, terminating error="sync: replicate relay failed: replicate execute: push target refs: target receive-pack: post RPC stream body: http 400: https://marvin.aws-us-east-2.entire.internal:8281/git/repo/01KVCD01JNGDNFSACE4TA54ZGF/git-receive-pack [Content-Type=text/plain; charset=utf-8] too many ref-update commands: 55006 (limit 25000)"

2mo ago·29.9s·4 calls

The error string isn't in git-sync — it's enforced server-side. The server is the entire backend. Let me search there while the agent examines the push path.

entire backend is in ../entiredb

2mo ago·1h 1m·54 calls

Done. Here's a summary.

Root cause

The error is the server (entiredb) rejecting a push: receive_pack.go:53 caps a single receive-pack request at maxRefUpdateCommands = 25_000 (rejection at count > limit). Your repo's relay sync sent all 55,006 ref updates in one request because git-sync's relay strategies send every command at once with no count-based batching:

  • replicate.go:70/78, incremental.go:66/85, materialized.go:123gitproto.PushPack / PushCommands / PushObjects, each one request.

(Only the bootstrap strategy batched, and only by pack size, not command count.)

Fix — client-side batching in internal/gitproto/push.go

Centralized in the three push primitives so all four strategies are covered:

  • New maxRefUpdatesPerPush = 20_000 (headroom under the 25k cap) + chunkRefUpdates helper.
  • PushPack: full pack rides with the first ≤20k commands; remaining refs follow as ref-only PushCommands batches.
  • PushCommands: loops over chunks (extracted pushCommandsBatch).
  • PushObjects: materialized pack rides with the first update batch; remaining updates and any deletes go ref-only (extracted pushObjectsBatch).

The split is safe because the first pack carries all objects, and receive-pack commits the entire received pack — verified at both ends: entiredb's CommitQuarantinedFanout ("we commit even when some refs failed connectivity… the pack may carry objects shared with refs that did pass") and canonical git's tmp_objdir_migrate (migrates everything, no reachability pruning). So later ref-only batches resolve against objects already on the server.

Added tests (TestChunkRefUpdates, TestPushCommandsBatchesOverCap, TestPushPackBatchesOverCap). Full suite + go vet pass.

Does it work with GitHub? Yes.

  • GitHub's "limit branches/tags per push" is opt-in (default: no limit), so 20k-ref batches are fine on default repos. (push policy docs)
  • The "full pack first, ref-only rest" design relies on quarantine migration installing the whole pack, which is exactly how canonical git behaves (quarantine commit, tmp_objdir_migrate). Unreferenced objects persist (only GC reclaims them, with grace periods — not triggered between two pushes seconds apart).
  • GitHub's other relevant limit is the 2 GiB push size, already handled separately by pack-size limiting.

Caveats worth noting: (1) if a target repo explicitly enables the per-push branch/tag limit with a small value, no count-batching short of that value would satisfy it — that's a deliberate restriction, not something to design around. (2) If a sync is interrupted between batches, the target holds the objects (unreachable) plus a partial ref set; the next run re-plans and completes it.

I did not touch the server limit — client batching respects the cap and works against any target, including GitHub.

Sources: push policy, 2 GiB limit, git quarantine, tmp-objdir.c

/simplify → 4 cleanup agents in parallel → apply the fixes

You are improving the quality of the changed code, not hunting for bugs. Review it for reuse, simplification, efficiency, and altitude issues, then fix what you find. Do not look for correctness bugs — that is what /code-review is for.

Phase 0 — Gather the diff

Run git diff @{upstream}...HEAD (or git diff main...HEAD / git diff HEAD~1 if there's no upstream) to get the unified diff under review. If there are uncommitted changes, or the range diff is empty, also run git diff HEAD and include the working-tree changes in scope — the review often runs before the commit. If a PR number, branch name, or file path was passed as an argument, review that target instead. Treat this diff as the review scope.

Phase 1 — Review (4 cleanup agents in parallel)

Launch 4 independent review agents via the Agent tool, all in a single message so they run concurrently. Pass each agent the diff and one of the four angles below. Each returns its findings with file, line, a one-line summary, and the concrete cost (what is duplicated, wasted, or harder to maintain).

Reuse

Flag new code that re-implements something the codebase already has — Grep shared/utility modules and files adjacent to the change, and name the existing helper to call instead.

Simplification

Flag unnecessary complexity the diff adds: redundant or derivable state, copy-paste with slight variation, deep nesting, dead code left behind. Name the simpler form that does the same job.

Efficiency

Flag wasted work the diff introduces: redundant computation or repeated I/O, independent operations run sequentially, blocking work added to startup or hot paths. Also flag long-lived objects built from closures or captured environments — they keep the entire enclosing scope alive for the object's lifetime (a memory leak when that scope holds large values); prefer a class/struct that copies only the fields it needs. Name the cheaper alternative.

Altitude

Check that each change is implemented at the right depth, not as a fragile bandaid. Special cases layered on shared infrastructure are a sign the fix isn't deep enough — prefer generalizing the underlying mechanism over adding special cases.

Phase 2 — Apply the fixes

Wait for all four agents to complete, dedup findings that point at the same line or mechanism, and fix each remaining one directly. Skip any finding whose fix would change intended behavior, require changes well outside the reviewed diff, or that you judge to be a false positive — note the skip rather than arguing with it. Finish with a brief summary of what was fixed and what was skipped (or confirm the code was already clean).

2mo ago·42.2s·6 calls

The four cleanup agents are running concurrently. I'll wait for all of them to report, then dedup and apply fixes.

<task-notification> <task-id>ab6382cc508f7ce62</task-id> <tool-use-id>toolu_01Xb37mSUCeut6FNAUaQAqut</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/76731dd6-65de-4d92-be87-716b3e380099/tasks/ab6382cc508f7ce62.output</output-file> <status>completed</status> <summary>Agent "Simplification review" came to rest</summary> <note>A task-notification fires each time this agent comes to rest with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Perfect. Now I have the full picture. Let me analyze the simplification issues:

Simplification Issues Found

  1. Lines 419-422 & 657-660: Duplicated first/rest splitting pattern

    • Cost: Copy-paste identical logic appears twice with manual []PushCommand(nil) initialization and conditional slice assignment.
    • Simpler form: Both PushObjects (lines 418-431) and PushPack (lines 657-679) manually split first, rest := commands, []PushCommand(nil); if len(commands) &gt; maxRefUpdatesPerPush { first, rest = commands[:cap], commands[cap:] }. This exact pattern could reuse chunkRefUpdates, which is already defined (lines 69-79) to handle this split-and-loop. Instead of open-coding the first/rest pattern, call chunkRefUpdates(updates) in PushObjects (line 419) and chunkRefUpdates(commands) in PushPack (line 657), then iterate the batches.
  2. Lines 418-430 (PushObjects updates handling): Incomplete chunking

    • Cost: The code manually splits updates only once (first, rest), then calls PushCommands on rest which internally calls chunkRefUpdates again (line 701). This means if updates are 2×maxRefUpdatesPerPush, the first manual split handles one batch, then PushCommands re-chunks the rest—two levels of re-chunking.
    • Simpler form: Call chunkRefUpdates(updates) and loop over all batches (first one via pushObjectsBatch, rest via PushCommands), eliminating the duplicate chunking logic.
  3. Lines 418-430 (PushObjects): Unused state—the rest variable in updates block

    • Cost: Line 426 only checks if len(rest) &gt; 0, but rest is already created on line 419 as []PushCommand(nil), then conditionally reassigned on line 421. The variable exists solely to drive the single conditional on line 426; all actual work is delegated to PushCommands.
    • Simpler form: After pushing the first batch (line 423), extract rest := updates[maxRefUpdatesPerPush:] and directly call PushCommands(rest) without the conditional first, rest := overhead, or just loop batches from chunkRefUpdates.
  4. Lines 657-660 (PushPack): First/rest split not using chunkRefUpdates

    • Cost: Code mirrors the chunking logic already implemented in chunkRefUpdates (lines 69-79). Maintaining the logic in two places increases risk of inconsistency (e.g., if max size changes, both sites must be updated).
    • Simpler form: Instead of first, rest := commands, []PushCommand(nil); if len(commands) &gt; maxRefUpdatesPerPush { first, rest = ... }, use batches := chunkRefUpdates(commands) and take first := batches[0], rest := flattenRest(batches[1:]) or loop directly. Alternatively, inline a tiny splitFirst helper that returns two slices without the full chunking overhead.

Summary: The code duplicates the first/rest splitting pattern twice (lines 419 and 657) when chunkRefUpdates is available. Both sites re-implement the same conditional split rather than reusing the helper. Additionally, PushObjects splits updates once manually, then PushCommands re-chunks them, creating a second redundant chunking pass. Consolidating both PushPack and PushObjects to use chunkRefUpdates throughout would eliminate this copy-paste and the double-chunking in PushObjects.</result> <usage><subagent_tokens>26346</subagent_tokens><tool_uses>3</tool_uses><duration_ms>17161</duration_ms></usage> </task-notification>

2mo ago·10.0s·1 call

I have the simplification findings. Waiting for the reuse, efficiency, and altitude agents to complete before I dedup and apply.

<task-notification> <task-id>ad4fe66be0defd838</task-id> <tool-use-id>REDACTED</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/76731dd6-65de-4d92-be87-716b3e380099/tasks/ad4fe66be0defd838.output</output-file> <status>completed</status> <summary>Agent "Reuse review" came to rest</summary> <note>A task-notification fires each time this agent comes to rest with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Based on my thorough analysis of the code change, here are the REUSE issues I found:

REUSE Issues Report

File: internal/gitproto/push.go

Issue 1: Duplicated manual slicing logic in PushPack and PushObjects

  • Location: Lines 657-660 (PushPack) and 419-421 (PushObjects)
  • Summary: Both functions implement identical "split first batch from rest" logic
  • Cost: Code duplication. The pattern appears twice:
  • Recommendation: Extract this into a helper function (e.g., splitFirstBatch(cmds []PushCommand) (first, rest []PushCommand)) and use it in both places.

File: internal/gitproto/push_test.go

Issue 2: Test server helper duplicates request-recording capability of existing captureReceivePackBody

  • Location: Lines 740-779 (pushRecorder type and server method)
  • Summary: The new pushRecorder struct and its server() method parse UpdateRequests and store multiple recorded pushes, but captureReceivePackBody (line 359) already captures request bodies. The parsing logic (Decode UpdateRequests, extract commands count and pack) is specific to this test's needs, but the underlying request-capture pattern is similar to existing infrastructure.
  • Cost: A more specialized server helper was introduced instead of extending the existing captureReceivePackBody pattern. However, pushRecorder's need to store multiple requests and parse them is a legitimate difference from captureReceivePackBody's single-request, channel-based design. This is marginal reuse waste since it serves a different purpose (batch testing vs. single-request assertions).
  • Note: This is a lower-severity issue than #1 since the new helper is genuinely specialized for multi-request batch testing, which the existing helpers don't support.

Summary: One clear reuse violation (duplicate manual slicing logic in push.go). The test helper is acceptable as-is since it serves a distinct purpose (recording multiple requests) that the existing helpers don't provide.</result> <usage><subagent_tokens>32939</subagent_tokens><tool_uses>28</tool_uses><duration_ms>58670</duration_ms></usage> </task-notification>

2mo ago·5.9s·1 call

I have two of four. Waiting for efficiency and altitude before applying.

<task-notification> <task-id>a5305f4bddbcddb7d</task-id> <tool-use-id>REDACTED</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/76731dd6-65de-4d92-be87-716b3e380099/tasks/a5305f4bddbcddb7d.output</output-file> <status>completed</status> <summary>Agent "Altitude review" came to rest</summary> <note>A task-notification fires each time this agent comes to rest with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Excellent! Now I have a full picture. Let me create a summary of my findings.

Altitude Review: Ref-Update Command Batching in git-sync

Summary of Implementation

The change adds batching to three functions in internal/gitproto/push.go to keep receive-pack requests under the server's 25,000 command cap:

  1. PushPack (lines 637–681): Splits into first/rest when commands exceed maxRefUpdatesPerPush
  2. PushObjects (lines 394–436): Separates updates/deletes, then splits updates into first batch and rest
  3. PushCommands (lines 693–707): Uses chunkRefUpdates() to batch all commands

Altitude Analysis: CORRECT DEPTH ✓

The batching is implemented at the correct altitude—the gitproto push-primitive layer is the right place.

Why this is correct:

  1. All strategies funnel through these three functions. Every push path (replicate, incremental, materialized, bootstrap) calls PushPack and/or PushCommands eventually. A strategy-layer solution would require duplicating batching logic across /internal/strategy/replicate, /internal/strategy/incremental, and /internal/strategy/materialized—each would need to know the server cap and implement the same split-first/rest dance. Centralizing at gitproto avoids that duplication.

  2. Strategies don't control how many refs get pushed—they control what refs and in what mode. Strategies decide whether to relay, which objects to include, whether to force-update—but not the internal mechanics of cutting a single push into batches. The batch-splitting is a protocol-level detail that should be opaque to strategies. From a strategy's perspective, calling PushPack(commands, pack) or PushCommands(commands) should "just work" regardless of command count.

  3. The split behavior is universal. Every target has a cap; the same split logic (pack rides with first batch, rest move ref-only) applies everywhere. This is not a per-strategy decision.

Internal Generalization: FRAGMENTATION (Minor Issue)

Three separate implementations of "split into first/rest" is a code smell:

  1. PushPack (lines 657–660): Simple first/rest split

  2. PushObjects (lines 418–430): Separates updates/deletes, then first/rest only for updates

  3. PushCommands (lines 701–706): Uses helper chunkRefUpdates() to batch all at once

The pattern across all three is: send first batch (with pack if present), then send remaining batches as ref-only. chunkRefUpdates() handles the general chunking, but PushPack and PushObjects duplicate the first/rest logic. A shared helper like splitFirstBatch() would reduce duplication:

This is not a correctness bug, just a missed DRY opportunity that makes the code slightly harder to maintain and audit.

Hardcoded Limit: CORRECT PLACEMENT ✓

The const maxRefUpdatesPerPush = 20_000 is correctly at the right altitude:

  • Not configurable: Unlike MaxPackBytes and TargetMaxPackBytes (which are configurable per-sync in /internal/syncer/syncer.go at lines 86–87), the ref-update cap should not be configurable. It's a server limit (entire-server hard-codes 25,000), not a tuning knob for users.
  • Well-documented: The comment (lines 53–63) explains the server cap (25,000), the safety margin (20,000), and the invariant that makes splitting safe (pack carries all objects).
  • Consistent with other protocol limits: The bitwise hardcodes in /internal/gitproto/ (sideband sizes, protocol constants) follow the same pattern—they're not exposed to config because they're not user-tunable.

The codebase has a clear precedent: DefaultMaxMaterializedObjects (materialized.go, line 40) is a constant, not a config field—users don't tune object limits, they get a sensible default or hit an error.

Test Coverage: ✓

  • TestChunkRefUpdates (push_test.go, line 793): validates chunking logic
  • TestPushCommandsBatchesOverCap (line 806): verifies PushCommands splits and preserves empty packs
  • TestPushPackBatchesOverCap (line 830): verifies PushPack sends real pack with first batch, empty packs with rest
  • Missing: No test for PushObjects batching behavior (though the function is tested elsewhere; just no explicit split test)

Verdict

The altitude is correct. Batching at the gitproto layer is the right depth because:

  • It centralizes a universal protocol constraint
  • All strategies benefit without duplication
  • It's invisible to strategy-layer code

Minor housekeeping opportunity: Extract a shared splitFirstBatch() helper to eliminate duplication in PushPack and PushObjects, reducing the surface area for future bugs.

No deeper fix needed. This is not a bandaid; it's the right architectural layer.</result> <usage><subagent_tokens>53589</subagent_tokens><tool_uses>28</tool_uses><duration_ms>66922</duration_ms></usage> </task-notification>

2mo ago·6.6s·1 call

Three of four in. Waiting for the efficiency agent before applying the deduped fixes.

<task-notification> <task-id>abc6397a2d74538b0</task-id> <tool-use-id>REDACTED</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/76731dd6-65de-4d92-be87-716b3e380099/tasks/abc6397a2d74538b0.output</output-file> <status>completed</status> <summary>Agent "Efficiency review" came to rest</summary> <note>A task-notification fires each time this agent comes to rest with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Perfect! So in HEAD, PushObjects directly called sendReceivePack with all commands. In the current code, it now:
  1. Allocates updates and deletes slices to partition commands
  2. Calls pushObjectsBatch for the first part
  3. Calls PushCommands for the rest

And PushPack also changed from doing nothing to doing the batching logic.

Now let me analyze the efficiency issues:

Efficiency Analysis

Let me examine the three key areas mentioned in the task:

1. chunkRefUpdates (lines 69-79)

For the small case (len(commands) <= maxRefUpdatesPerPush), it returns:

This allocates a single-element wrapper slice on every call, even when not needed. This hits PushCommands on every call.

Cost: Single allocation of a 24-byte slice (slice header) per small push to chunkRefUpdates.

Alternative: For pushes under the cap, callers could avoid this wrapper entirely by checking the size first. But this is a minor optimization since it's only 24 bytes once per small push.

2. PushObjects (lines 394-436)

The current code (when commands > cap) does this unnecessarily:

This always partitions into two slices, even when:

  • All commands fit in cap anyway (but the function already returned earlier at line 405 for this case, so this code only runs when len(commands) > cap)
  • The partition happens BEFORE checking if we even need to do batching

BUT WAIT - I need to reconsider. Looking at lines 404-406, there IS an early return:

So the partition code (lines 408-416) ONLY runs when len(commands) > cap.

The partition is necessary because:

  • PushObjects with the packed objects only works for creates/updates
  • Deletes must go through PushCommands
  • If there are more updates than can fit in one request, we need to split the updates/deletes anyway

Cost: Allocates two slices (updates and deletes) for any push > cap. With 20,000 item cap, this is inevitable for large pushes. The alternative would be to not partition upfront but this would complicate the logic.

3. PushPack (lines 646-681)

The new code does:

This allocates a nil slice when commands <= cap (line 657), then potentially re-assigns it.

Cost: One unnecessary []PushCommand(nil) allocation (16 bytes) per small pack push.

Better approach:

Or simpler:

4. PushObjects allocation pattern (lines 419-421)

Similar issue:

This always allocates a nil slice (line 419) even when len(updates) <= cap.

Better approach: Early return for the small case.

Now let me write my findings report:


Summary of Efficiency Findings

The diff adds ref-update batching to push functions, which is correct and necessary. However, there are two concrete inefficiencies in the hot path (small pushes):

1. PushPack: Unnecessary nil slice allocation on every small pack push

File: internal/gitproto/push.go
Lines: 657
Issue: first, rest := commands, []PushCommand(nil) allocates a nil slice unconditionally, then overwrites first and rest only if len(commands) > cap.
Cost: 16-byte slice header allocation per small push (< 20,000 commands).
Cheaper alternative: Add early return for small case before any slice assignments. For len(commands) <= cap, handle directly without allocating nil slices.

2. PushObjects: Unnecessary nil slice allocation in the partition path

File: internal/gitproto/push.go
Lines: 419
Issue: Same pattern: first, rest := updates, []PushCommand(nil) allocates unconditionally, then re-assigns if len(updates) > cap.
Cost: 16-byte slice header per large push (when entering the partition code > line 407).
Cheaper alternative: Early return if len(updates) <= cap before the slice assignments; only allocate slices in the branch where they're needed.

3. chunkRefUpdates: Single-element wrapper slice on every call

File: internal/gitproto/push.go
Lines: 71
Issue: Returns [][]PushCommand{commands} for small input, allocating a slice wrapper even for the common case.
Cost: 24-byte slice header per small ref-only push.
Assessment: Minor. This is unavoidable in the current design since chunkRefUpdates always returns [][]PushCommand for uniform handling in the caller loop. The cost is a single allocation per push and is negligible.

4. PushObjects: Unnecessary partition allocation for all pushes > cap

File: internal/gitproto/push.go
Lines: 408-416
Issue: Always allocates and populates both updates and deletes slices when len(commands) > cap, even though the partition is semantically necessary.
Assessment: Clean. This is unavoidable—the logic requires separating deletes (which need ref-only push via PushCommands) from updates (which can carry the pack). Not an efficiency regression.

Verdict

The batching implementation is well-structured overall. The two inefficiencies (items 1 & 2) are minor and localized to small-push hot paths—low impact. They could be cleaned up with early returns, but the current code is readable and the cost is negligible in practice (32 bytes per small push across two functions).</result> <usage><subagent_tokens>55427</subagent_tokens><tool_uses>25</tool_uses><duration_ms>207877</duration_ms></usage> </task-notification>

2mo ago·1m·4 calls

All green after the refactor. Here's the /simplify summary.

Applied

Extracted splitFirstBatch helper (internal/gitproto/push.go) and used it in both PushPack and PushObjects, replacing the duplicated

This was the consensus finding across three of four agents (reuse, simplification, altitude) — one DRY fix that also resolves the efficiency agent's nil-slice notes (the helper returns nil for rest in the common case). Build, vet, and the gitproto + strategy test suites all pass.

Skipped (with reasons)

  • Altitude / constant placement — agents confirmed batching at the gitproto push-primitive layer is the correct depth (all strategies funnel through these functions; a hardcoded maxRefUpdatesPerPush is right since it mirrors a server limit, not a user tunable). Nothing to change.
  • chunkRefUpdates single-element wrapper for small input (efficiency #3) — intentional: it keeps the caller loop uniform and preserves one-request behavior for the empty slice. The allocation is a stack-level slice header, negligible. Skipped as a false positive.
  • PushObjects updates/deletes partition allocation (efficiency #4) — the efficiency agent itself concluded it's unavoidable and clean (deletes must go ref-only; only runs on >cap pushes). Skipped.
  • Test helper pushRecorder vs existing captureReceivePackBody (reuse #2) — both the reuse and the reviewing agents judged it acceptable; captureReceivePackBody is single-request/channel-based and can't record multiple batches. Genuinely different purpose. Skipped.
  • Missing explicit PushObjects batching test (altitude note) — a coverage gap, not a simplification/quality issue in the changed code, and a real PushObjects test needs a populated object store + delta selection. Out of scope for /simplify; worth a follow-up if you want it.

ok, can you commit and open a PR?

2mo ago·2m·5 calls

Done. PR opened: https://github.com/entireio/git-sync/pull/94

  • Branched off mainfix/batch-ref-updates-receive-pack
  • Committed the batching change + tests (2 files, +234/−2)
  • Pushed and opened PR #94 with a writeup covering the root cause, the fix, the GitHub-compatibility verification, and test coverage.

can you give me the git-sync command to test this, basically to take github.com/entireio/large-ref-test to https://github.com/Soph/large-ref-test.git it has 55k refs

2mo ago·3m·3 calls

The fixed binary is at /tmp/git-sync (built from this branch).

1. Set a GitHub PAT (needs repo scope — read on source, write on target):

2. Preview first (no pushes) — confirms it sees all ~55k refs:

3. Run the replicate (this is the path that previously failed at 55,006 > 25,000):

Notes:

  • --all-refs mirrors every refs/* (branches, tags, notes, pulls, custom namespaces) — needed to actually push all 55k. Without it, replicate defaults to just branches.
  • Args are positional <source-url> <target-url>; the env vars supply auth (or use --source-token/--target-token). GitHub accepts the PAT as the password with the default git username.
  • With the fix, the 55k refs push as ~3 batches of ≤20k (first carries the pack, rest are ref-only) instead of one 55,006-command request.
  • plan against an empty target will show all refs as creates; if the target already has some refs from a prior attempt, it'll show only the diff.

To test against the old behavior for comparison, you'd build from main (git stash not needed — just git checkout main && go build -o /tmp/git-sync-old ./cmd/git-sync) and run the same replicate — it should fail with the too many ref-update commands error.

❯ go run ./cmd/git-sync replicate --all-refs --stats --verbose
https://github.com/entireio/large-ref-test.git
https://github.com/Soph/large-ref-test.git time=2026-06-18T17:10:33.872+02:00 level=INFO msg="bootstrap fetching refs from source" ref_count=55008 time=2026-06-18T17:10:34.837+02:00 level=INFO msg="bootstrap pushing refs to target" ref_count=55008 source: Enumerating objects: 165068, done. source: Counting objects: 100% (18018/18018), done. source: Compressing objects: 100% (12012/12012), done. source: Total 165068 (delta 5355), reused 12663 (delta 0), pack-reused 147050 (from 1) target: Resolving deltas: 100% (48302/48302), done. target: Internal Server Error target: Request ID FDA6:2C5CB9:C81186:A17850:6A340A6A target: Time 2026-06-18T15:12:37Zerror: replicate: replicate: bootstrap execute: push target refs: report-status: command error on refs/heads/main: Internal Server Error exit status 1

2mo ago·2m·5 calls

Rebuilt /tmp/git-sync with a tunable batch size. Now retry with a small batch to confirm GitHub is the bottleneck — start conservative:

(Use the freshly built /tmp/git-sync, not go run — though go run from this branch would also pick up the change.)

What this tells us:

  • If it succeeds at 2000 → confirmed: GitHub can't handle 20k ref updates per push; the fix is correct but the default batch is too big for GitHub. We then decide on a sensible default / per-target flag.
  • If it still 500s at 2000 → the cause isn't batch size (could be GitHub disliking 55k total refs in one repo, or something else), and I'll dig further.

You can sweep upward (5000, 10000) to find GitHub's actual ceiling so we pick a good default.

Two notes:

  • The pack push itself worked, so this is purely about ref-update batch size.
  • I've not committed this env-var change yet — it's a diagnostic knob for now. Once we know GitHub's threshold, I'll fold it into the PR properly (likely a --target-max-ref-updates flag plus a safer default, rather than only an env var). Want me to add the flag now, or wait for your test result?

Given this is GitHub-specific behavior worth recording, do you want me to also note the finding (GitHub 500s on large ref-update pushes) in the PR description?

❯ GITSYNC_MAX_REF_UPDATES_PER_PUSH=2000 go run ./cmd/git-sync replicate --all-refs --stats --verbose
https://github.com/entireio/large-ref-test.git
https://github.com/Soph/large-ref-test.git time=2026-06-18T17:45:17.620+02:00 level=INFO msg="bootstrap fetching refs from source" ref_count=55008 time=2026-06-18T17:45:18.571+02:00 level=INFO msg="bootstrap pushing refs to target" ref_count=55008 source: Enumerating objects: 165068, done. source: Counting objects: 100% (18018/18018), done. source: Compressing objects: 100% (12012/12012), done. source: Total 165068 (delta 5355), reused 12663 (delta 0), pack-reused 147050 (from 1) target: Resolving deltas: 100% (48302/48302), done. target: target: target: target: target: target: target: target: target: target: target: target: target:

it's still running but the output is interesting

2mo ago·27.4s

That's exactly the signal we wanted — it's past the first batch that previously 500'd. At 2,000 refs/batch GitHub is accepting the pushes, so the GitHub 500 was indeed caused by too many ref updates in one request (20k was too big; 2k works). It's now grinding through ~28 batches (55,008 ÷ 2,000), each a ref-only follow-up push.

The target: target: target: ... is cosmetic, not an error. Here's what's happening:

  • The first batch carried the pack → you saw "Resolving deltas: 100% ... done".
  • Each subsequent ref-only batch opens its own sideband progress stream. GitHub emits an empty/near-empty progress message per batch, and our progress writer prints the "target: " prefix for it with no payload behind it — so you get one bare prefix per batch (~27 of them for the remaining refs).

Let it finish — if it completes without an error line and exits 0, the mirror succeeded and all 55,008 refs are on the target. You can verify after with:

Findings confirmed:

  1. ✅ The batching fix bypasses entire-server's 25k cap (the original bug).
  2. ✅ GitHub has a much lower practical ceiling for ref updates per push — 20k → 500, 2k → fine. Worth finding the real ceiling (try 5000, 10000) so we pick a good default.

Two things I'll fold into the PR once you confirm completion:

  • A safer default and/or a --target-max-ref-updates flag (keeping the env var as an escape hatch) — 20k is fine for entire-server but clearly too high for GitHub.
  • A tiny fix to suppress the empty "target: " progress prefix on ref-only batches so the output isn't noisy.

Want me to start a batch-size sweep recommendation now, or wait until you've found GitHub's ceiling? And should I add the GitHub-500 finding to the PR description?

can you give me the git command to delete remote refs especially if it works with a wildcard? 5k finished but I want to try 10k too

2mo ago·31.6s

Git has no native wildcard delete — you can't do git push origin :refs/heads/*. You have to enumerate the refs and push deletes (the :refname refspec means "delete"). And since GitHub 500s on large ref batches, the deletes need the same batching.

For a throwaway test repo, the cleanest reset is just to recreate it:

That gives you a clean empty target instantly and sidesteps the delete-batching problem entirely.

If you'd rather delete refs in place, enumerate with ls-remote and batch the deletes with xargs (2000 per push, since that worked):

How it works:

  • git ls-remote "$URL" 'refs/heads/*' 'refs/tags/*' lists matching refs as <sha>\t<refname>.
  • awk '{print ":" $2}' turns each into a delete refspec :refs/heads/foo.
  • xargs -n 2000 deletes 2000 at a time, keeping each push under GitHub's ceiling.

Notes:

  • Don't include refs/pull/* — those are GitHub's read-only PR refs and deletes will fail. Stick to the namespaces you actually synced (heads, tags, plus any custom ones from --all-refs).
  • If you want everything except pull refs in one go: git ls-remote "$URL" | grep -v $'\trefs/pull/' | awk '{print ":" $2}' | xargs -n 2000 git push "$URL".

Given it's a test repo, I'd just gh repo delete/create — faster and guaranteed clean for your 10k run.

10k gets an internal server error, 5k works again, so I guess we should stick to 5k batches as a default and we could configure the mirror-pipeline then with a higher value

2mo ago·2m·6 calls

Done. Pushed and PR #94 updated.

What changed:

  • Default maxRefUpdatesPerPush5,000 (safe for GitHub and conservative providers).
  • GITSYNC_MAX_REF_UPDATES_PER_PUSH env var raises it for the mirror-pipeline / entire-server targets (up to their 25k cap) to cut round trips. Invalid/non-positive → falls back to default.
  • Added TestResolveMaxRefUpdatesPerPush for the override + fallback.
  • PR description now documents the GitHub-500 finding with the batch-size table (20k/10k ❌ on GitHub, 5k ✅; all fine on entire-server).

Build, vet, full suite pass; and you've confirmed it live (55,008-ref GitHub→GitHub mirror works at 5k).

For the mirror-pipeline, set GITSYNC_MAX_REF_UPDATES_PER_PUSH=20000 (or up to 25000) in its environment when the target is entire-server.

Two optional follow-ups still on the table, say the word and I'll add them to the PR:

  1. --target-max-ref-updates CLI flag in addition to the env var, for discoverability/per-invocation control.
  2. Suppress the empty target: progress prefix on ref-only batches (the cosmetic line-spam you saw) — more visible now that the default is 5k (≈11 batches for 55k refs).

target: error: GH013: Repository rule violations found for refs/entire/checkpoints/5T/01KVBJD96J55PB4WRH2EADBE5T. target: target: - GITHUB PUSH PROTECTION target: ————————————————————————————————————————— target: Resolve the following violations before pushing again target: target: - Timed out validating Secrets detected, please try again target: target: error: GH013: Repository rule violations found for refs/entire/checkpoints/5T/01KVBJD9ZCNC48MJD4DSRGPN5T. target: target: - GITHUB PUSH PROTECTION target: ————————————————————————————————————————— target: Resolve the following violations before pushing again target: target: - Timed out validating Secrets detected, please try again target: error: replicate: replicate: bootstrap execute: push target refs: report-status: command error on refs/entire/checkpoints/2Y/01KVBHQG88946KVY2MYW6RKW2Y: push declined due to repository rule violations exit status 1

2mo ago·44.0s

That's not a git-sync issue — it's GitHub Push Protection (secret scanning) on the target repo rejecting the refs/entire/checkpoints/* refs. Note the message: "Timed out validating Secrets detected, please try again" → GitHub's secret scanner is timing out validating those checkpoint commits, and push protection (GH013) declines the push.

Two things are colliding:

  1. The source's refs/entire/checkpoints/* are Entire's checkpoint/session refs — snapshots of working state, exactly the kind of content secret scanning flags.
  2. You're pushing a huge number of them, so GitHub's per-push secret validation times out rather than cleanly passing/failing.

(This earlier succeeded at 5k presumably because the freshly created repo didn't have push protection on yet, or the scanner didn't time out that run. Recreated repos pick up your org's default security settings.)

You don't need to change git-sync. Pick one:

Option A — disable push protection on the target (keeps all 55k refs, best for your batching test):

(or GitHub UI: Settings → Code security → Push protection → off)

Option B — don't mirror the checkpoint namespace (--exclude-ref-prefix subtracts from --all-refs):

For a pure batching test I'd use Option A so you keep the full 55,008 refs. Check how many you'd lose with B first:

Worth noting for real-world use: mirroring Entire refs/entire/checkpoints/* to GitHub is probably undesirable anyway (it exposes session snapshots), so excluding that namespace may be the right default for GitHub targets regardless of this test.

One product question this surfaces: replicate's contract is target==source, so a per-ref push-protection rejection fails the whole run. If you ever want git-sync to skip refs a target rejects (push-protection, protected branches) and continue, that'd be a --best-effort-style mode — not in scope here, but tell me if it's worth a separate issue.

ok, let's add a param and also supress target but maybe if verbose is on instead log that a batch finished?

2mo ago·20m·48 calls

Done. Both changes are implemented, tested, committed, and pushed to PR #94.

1. --target-max-ref-updates flag

Plumbed a per-target batch size cleanly (no global mutable state):

  • Pusher.MaxRefUpdates field → package functions take a maxRefUpdates int param → effectiveMaxRefUpdates resolves 0 → env-or-default.
  • Wired flag → unstable.Options.TargetMaxRefUpdatessyncer.ConfigPusher, on replicate, sync, plan, bootstrap.
  • Precedence: flag > GITSYNC_MAX_REF_UPDATES_PER_PUSH env > default (5,000).

Usage for entire-server targets:

2. Quieted ref-only batch progress

Ref-only follow-up batches now push with progress suppressed (no more bare target: spam). When --verbose, each batch logs one line instead:

Single-batch pushes stay silent (no behavior change for normal pushes), and the first pack batch still shows its real "Resolving deltas" progress.

Verification

  • New tests: TestEffectiveMaxRefUpdates, TestPushCommandsVerboseLogsBatches, TestPushPackUsesDefaultLimitWhenZero, plus the existing batching tests refactored to drive an explicit small limit (faster, and they now exercise the param).
  • go build, go vet, full suite (./...) all pass.
  • Flag appears in --help.

For your 10k retest: that's the value GitHub rejects, so to find/confirm the ceiling use --target-max-ref-updates 10000 (expect 500) and 5000 (works) — same as the env var, now as a first-class flag. Reset the target with gh repo delete/create between runs as discussed.

error: replicate: replicate: replicate relay failed: replicate execute: push target refs: report-status: command error on refs/entire/checkpoints/BP/01KVBJD16MVN8GJYEVSQPW1EBP: push declined due to repository rule violations

2mo ago·37.2s

Same root cause as before — GitHub secret-scanning push protection (GH013) rejecting the refs/entire/checkpoints/* refs. It's on the replicate relay path now (rather than bootstrap) only because the target is already partially populated from your earlier 5k run, so it took the incremental path. Still not a git-sync bug — and replicate's contract is strict (target == source), so one rejected ref fails the whole run.

Three ways forward:

A. Disable push protection on the target — keeps all 55k refs, clean replicate (best for a full-fidelity batching test):

B. Exclude the checkpoint namespace (drops those refs from the mirror):

C. Use sync instead of replicatesync --all-refs runs in best-effort mode, which downgrades per-ref rejections (like push protection) to warnings and continues, mirroring everything GitHub accepts and skipping what it blocks:

(replicate deliberately omits best-effort; sync includes it with --all-refs.)

For confirming the 10k-vs-5k batching ceiling specifically, I'd do A (so push protection isn't a confounder) on a freshly recreated repo:

then run the sync. Note: a brand-new private repo may have push protection on by GitHub/org default, which is why the explicit disable is worth doing up front.

One thing worth deciding for the product (not this PR): should mirroring to GitHub default to excluding refs/entire/*? Those are Entire's internal checkpoint/session refs — pushing them to an external GitHub remote both trips secret scanning and leaks session snapshots. If the mirror-pipeline targets GitHub, baking in that exclusion (or a "GitHub target ⇒ skip internal namespaces" rule) would avoid this class of failure entirely. Want me to open a separate issue for that?

gh api -X PATCH repos/Soph/large-ref-test --input - <<'JSON' {"security_and_analysis":{"secret_scanning_push_protection":{"status":"disabled"}}} JSON

does not work

2mo ago·31.0s

The most likely culprit is the heredoc: with <<'JSON' the closing JSON must be at the start of the line with no indentation — in your paste it's indented, so the body never terminated correctly. Avoid the heredoc entirely and pipe the JSON in:

Verify it took:

If that still errors, two things to check:

  • Wrong owner: is it really Soph (capital S)? Confirm with gh repo view Soph/large-ref-test. The path is case-sensitive in the API.
  • Permissions / GHAS: on a private repo, secret-scanning push protection is governed by GitHub Advanced Security / org policy and may not be repo-toggleable — you'd get a 403/422. In that case disabling won't work and you should fall back to excluding the refs.

UI fallback: repo Settings → Code security → Push protection → Disable.

If disabling is blocked, just sidestep it — these are the two that don't depend on push-protection settings:

Tell me the exact error/output from the gh api call if it still fails and I'll pin it down — but for your 10k-vs-5k batching test, Option B or C gets you unblocked right now without fighting GitHub's security settings.

lint is failing

2mo ago·2m·7 calls

Fixed and pushed. The lint failure was a gofmt issue: adding the TargetMaxRefUpdates field changed the longest-key width in two structs, so gofmt wanted the surrounding fields realigned (internal/syncer/syncer.go and unstable/client.go). golangci-lint itself was already clean.

Verified the full CI lint set now passes:

  • gofmt -l -s . → clean
  • golangci-lint run ./... → 0 issues
  • go mod tidy → no diff
  • build + tests → green

Pushed as commit 85aba74b to PR #94.

Tip to catch this locally before pushing: mise run fmt (auto-formats) or mise run lint (runs the same gofmt/gomod/golangci/shellcheck group CI uses).