Skip to content

Fix commitAndPush hanging on non-fast-forward push against large/rewritten histories - #26

Open
timrogers wants to merge 3 commits into
mainfrom
timrogers/fast-non-fast-forward-push-fallback
Open

Fix commitAndPush hanging on non-fast-forward push against large/rewritten histories#26
timrogers wants to merge 3 commits into
mainfrom
timrogers/fast-non-fast-forward-push-fallback

Conversation

@timrogers

Copy link
Copy Markdown

Problem

commitAndPush's push fallback (pushWithRebaseFallback) handles a rejected push (remote branch ahead) by running git fetch + git rebase origin/<branch>.

This SDK clones repositories shallowly (--depth 2, see cloneRepo). When the remote has advanced far enough - or been rewritten (squash-merge, force-push, branch protection auto-merge workflows) - that the local shallow history and the freshly fetched remote tip share no common ancestor, git rebase can't find a merge-base and falls back to replaying every local commit individually (patch-id search + full three-way merge per commit, running hooks each time).

On a large monorepo this is slow and scales with the number of local commits being replayed. In production (github/sweagentd -> github/github), this manifested as CCA (Copilot Cloud Agent) push-retry finalization silently hanging for 10+ minutes with no further log output, eventually getting killed by an unrelated outer job timeout and surfacing as a confusing "connection refused" error instead of a clear failure.

Reproduction

I reproduced this locally: a shallow clone whose local branch has 202 trivial one-line commits, rebased against a disjoint (force-rewritten) upstream history, took 40+ seconds just for the rebase step - with real diffs, hooks, and a genuinely large history (like github/github), this is consistent with the multi-minute+ hangs seen in production.

Fix

Replace the rebase-based reconciliation with a fetch + squash-diff + reapply strategy:

  1. Determine our previous known base (<branch>@{upstream}, i.e. the remote tip we last forked from).
  2. Fetch just the new remote tip, matching the shallow clone depth (--depth 2).
  3. Compute the diff between our previous base and our local tip (git diff --binary) - i.e., the net content change from all of our own local commits, regardless of how many there are.
  4. Reset onto the new remote tip and reapply that diff as a single commit via git apply -3 --index (three-way apply, so content-level conflicts are still detected and reported).
  5. Push.

This is O(size of our diff), not O(local commit count x remote history since diverging), and works correctly even when local and remote history are fully disjoint. Genuine content conflicts still fail loudly (via git apply's exit code) with the working tree restored to a clean prior state, matching the safety guarantees of the old rebase-based approach - we just no longer pay the cost of walking commit history to detect them.

Also adds a bounded timeout to the git subprocesses on this path, so a stalled network call fails fast instead of hanging indefinitely.

Verified locally against:

  • Normal remote advance (fast, single fetch + squash + push)
  • Full disjoint history rewrite (force-push / squash-merge simulation) - succeeds in under a second vs 40+ seconds with the old rebase code on a tiny reproduction, and would be much worse on a real diff/hook set
  • A genuine content conflict (same line edited both locally and upstream) - fails cleanly, repo left in a clean, recoverable state
  • A binary file addition (--binary diff)
  • The existing fast-forward (non-rejected) push path is unaffected

Context

Found via a live production incident investigation (sweagentd session), where a CCA job's final push-retry step hung until the job's own timeout killed it. Local reproduction of the shallow-clone + rebase-against-disjoint-history scenario confirmed the root cause before writing this fix.

Replaces the fetch+rebase retry in commitAndPush's push fallback with a
fetch + squash-diff + reapply strategy.

Problem: this SDK clones repositories shallowly (--depth 2). When a push is
rejected because the remote branch is ahead, the previous code ran
`git fetch` + `git rebase origin/<branch>`. On a large monorepo, once the
remote has advanced (or been rewritten via squash-merge/force-push/branch
protection workflows) enough that the local shallow history and the fetched
remote tip share no common ancestor, git rebase falls back to replaying
every local commit individually via patch-id search and a full three-way
merge per commit, invoking hooks each time. This is slow and scales with the
number of local commits and the size of the diffs. In production this
showed up as multi-minute-to-tens-of-minutes silent hangs during agent
session finalization on github/github (a very large monorepo), which then
got killed by an unrelated outer job timeout with a confusing
'connection refused' error instead of a clear failure.

Fix: instead of rebasing, compute the diff between our local tip and the
remote tip we last knew about (tracked via the branch's upstream ref), fetch
just the new remote tip (still shallow, matching clone depth), reset onto
it, and reapply that diff as a single new commit (using `git apply -3` so
content-level conflicts are still detected and reported, just without
walking commit history to find them). This is O(size of the diff) rather
than O(local commit count x remote history since diverging), and works
correctly even with fully disjoint local/remote histories. Verified locally
against reproductions of: a normal remote advance, a full history rewrite
with completely disjoint ancestry (this took 40+ real seconds with the old
rebase code against ~200 trivial commits and would be much worse with a real
diff/hook set on a large repo; the new code stays under a second), a genuine
content conflict (fails cleanly with the working tree restored rather than
silently applying partial changes), and a binary file change.

Also adds a bounded execFileSync timeout on the git subprocesses in this
path so a stalled network call still fails fast instead of blocking
indefinitely if something upstream of git itself stalls (e.g. a flaky
proxy).
@timrogers
timrogers requested a review from a team as a code owner August 28, 2026 21:27
Copilot AI balanced review requested due to automatic review settings August 28, 2026 21:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Balanced
Findings: 1 High severity · 2 Medium severity

新建议题s introduced by this change (3)
Severity Finding
Medium severity src/​git.tsgit() uses execFileSync's default 1 MiB output buffer, but this new call captures the entire…
High severity src/​git.ts — In the no-upstream case handled above, this fetch may not create origin/&lt;branch&gt;. Shallow clones…
Medium severity src/​git.ts — The fallback does not actually bound every subprocess: its rev-parse, diff, reset, log, and…
What changed in this PR

Replaces slow non-fast-forward rebasing with bounded, squash-diff reconciliation.

Changes:

  • Fetches the latest remote tip and reapplies local changes as one commit.
  • Adds subprocess timeouts and conflict cleanup.
  • Preserves binary changes and commit messaging.
File Description
src/​git.ts Implements the new push-reconciliation fallback.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/git.ts Outdated
// single diff, rather than replaying each commit. We only need the net
// content change to end up on the remote - the commit boundaries in
// between are an implementation detail of how the agent got there.
const diff = git(["diff", "--binary", previousBase, localTip], repoLocation);
Comment thread src/git.ts Outdated
Comment on lines +328 to +329
gitWithTimeout(["fetch", "--depth", "2", "--no-tags", "origin", branch], repoLocation);
const newRemoteTip = git(["rev-parse", `origin/${branch}`], repoLocation).trim();
Comment thread src/git.ts
Comment on lines +249 to +253
* Timeout (ms) applied to every git subprocess spawned by the non-fast-forward
* fallback below. Repos are cloned shallowly (see `cloneRepo`'s `--depth 2`),
* so any of these operations should complete in seconds; a large value here
* only exists to fail fast instead of hanging silently until the caller's own
* (often much longer) timeout kills the process out from under us.
Fix three issues identified in Copilot review:

1. (HIGH) Shallow clone fetch may not create remote-tracking ref for new branches:
   - Changed fetch refspec from implicit to explicit: +refs/heads/<branch>:refs/remotes/origin/<branch>
   - Ensures remote-tracking ref is created even when remote.origin.fetch only targets the default branch
   - Supports rewritten histories with + prefix

2. (HIGH) Large diffs exceed execFileSync 1 MiB buffer:
   - Generate diff to temp file instead of capturing directly
   - Pass diff file path to git apply instead of stdin
   - Avoids ENOBUFS errors on large monorepo changes
   - Clean up temp file in finally block

3. (MEDIUM) Not all subprocesses use timeout helper:
   - Wrapped all git calls in pushWithRebaseFallback with gitWithTimeout
   - Includes rev-parse, log, commit operations that can run hooks
   - Ensures consistent 60-second timeout prevents indefinite hangs
   - Maintains best-effort cleanup timeout on reset operations

All changes preserve the bounded O(diff size) reapply strategy and content-level
conflict detection while addressing operational concerns for large repositories.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 57c3ac2b-e726-4ad3-bf7d-278c6da551db

@timrogers timrogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed review! I've addressed all three issues in the new commit:

Issue 1 (HIGH): Shallow clone fetch for new branches

  • Changed fetch refspec from implicit origin <branch> to explicit +refs/heads/<branch>:refs/remotes/origin/<branch>
  • This ensures the remote-tracking ref is created even for newly-created branches where single-branch fetch only targets the default branch
  • The + prefix properly handles rewritten histories

Issue 2 (HIGH): Buffer overflow on large diffs

  • Switched from capturing diff to stdin to writing it to a temp file
  • Pass file path to git apply instead of using stdin/input
  • Added proper cleanup in finally block
  • Set maxBuffer: undefined on the git diff itself to allow unbounded output

Issue 3 (MEDIUM): Incomplete timeout coverage

  • Wrapped ALL git subprocess calls in pushWithRebaseFallback with gitWithTimeout
  • This includes rev-parse, log, and commit which can run hooks
  • Added timeout to cleanup reset --hard in error handler
  • Ensures consistent 60-second fail-fast guarantee throughout the path

The fix maintains the core O(diff size) reapply strategy while addressing production concerns for large monorepos.

注册 for free to join this conversation on GitHub. Already have an account? 登录 to comment

标签

None yet

项目

None yet

Development

Successfully merging this pull request may close these issues.

2 participants