fix(#2489): retry scaffold push with force on non-fast-forward#2491
fix(#2489): retry scaffold push with force on non-fast-forward#2491rh-hemartin wants to merge 1 commit into
Conversation
PR Summary by QodoRetry scaffold ref update with force on GitHub non-fast-forward (422) Description
Diagram
High-Level Assessment
Files changed (2)
|
Site previewPreview: https://35a7b833-site.fullsend-ai.workers.dev Commit: |
Code Review by Qodo
1. Unscoped force ref retry
|
|
🤖 Finished Review · ✅ Success · Started 9:45 AM UTC · Completed 9:59 AM UTC |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
ReviewFindingsLow
Previous runReviewFindingsMedium
Low
Labels: PR fixes a bug in the forge/github install scaffold flow, directly addressing e2e test flakiness Previous runReviewFindingsMedium
Low
Previous run (2)ReviewFindingsHigh
Medium
Low
Labels: PR fixes a bug in the forge/github install scaffold flow, directly addressing e2e test flakiness |
de388e8 to
a2348de
Compare
|
🤖 Finished Review · ✅ Success · Started 11:14 AM UTC · Completed 11:27 AM UTC |
CommitFiles now detects 422 "Update is not a fast forward" errors during scaffold pushes and retries with `force: true`. Fixes race where GitHub's async auto_init creates a commit between ref fetch and ref update. Root cause: CreateRepo sets auto_init: true. GitHub materializes initial commit async — can complete after commitFilesTo fetches current ref SHA (line 658) but before updating ref (line 795). Tree built from stale parent → non-fast-forward. Force push safe for scaffold — repo just created, we own only commit. Separate from branch protection errors (still rejected). Closes #2489 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Hector Martinez <hemartin@redhat.com>
a2348de to
e17e485
Compare
|
🤖 Finished Review · ✅ Success · Started 11:34 AM UTC · Completed 11:47 AM UTC |
| strings.Contains(msg, "rule violation") | ||
| } | ||
|
|
||
| func isNonFastForwardError(apiErr *APIError) bool { |
There was a problem hiding this comment.
[low] fail-open
isNonFastForwardError matches on substrings 'not a fast forward' and 'not a fast-forward'. A false positive on an unrelated 422 error message containing these words would trigger an unintended force push. Mitigated by the status-code gate, the isBranchProtectionError pre-filter, and the allowForce flag. Consistent with existing patterns but higher stakes due to force-push consequence.
| } | ||
|
|
||
| return c.commitFilesTo(ctx, owner, repo, repoInfo.DefaultBranch, message, files) | ||
| return c.commitFilesTo(ctx, owner, repo, repoInfo.DefaultBranch, message, files, false) |
There was a problem hiding this comment.
[low] intent-scope-mismatch
CommitFiles passes allowForce=false, so it does not benefit from the force-retry logic. The auto_init race could theoretically affect CommitFiles when called shortly after CreateRepo. Consider documenting why the default-branch path is not affected or extending the fix.
| } | ||
| if isBranchProtectionError(apiErr) { | ||
| return false, fmt.Errorf("%w: %w", forge.ErrBranchProtected, err) | ||
| } |
There was a problem hiding this comment.
[low] solution-selection-justification
Issue #2489 identified option 2 (auto_init:false) as 'likely cleanest' but this PR implements option 1 (force:true retry). Both approaches are defensible. A brief code comment explaining why option 1 was chosen would help future maintainers.
ralphbean
left a comment
There was a problem hiding this comment.
I think this needs a change before we can merge. See inline.
| } | ||
|
|
||
| return c.commitFilesTo(ctx, owner, repo, repoInfo.DefaultBranch, message, files) | ||
| return c.commitFilesTo(ctx, owner, repo, repoInfo.DefaultBranch, message, files, false) |
There was a problem hiding this comment.
[high] I traced the e2e error from #2489 through the call chain — I think the retry lands on a path that doesn't get hit.
CommitScaffoldFiles calls CommitFiles first (commit.go:19), which passes allowForce=false here. A non-fast-forward 422 isn't a branch-protection error, so commit.go:20 doesn't fall through to CommitFilesToBranch — it returns the error directly at commit.go:68.
CommitFilesToBranch(allowForce=true) only runs when the default branch is protected, but a freshly created repo's default branch never is.
Could be worth considering option 2 from the issue (auto_init: false) — eliminates the race instead of retrying through it.
waynesun09
left a comment
There was a problem hiding this comment.
Multi-Agent Review — 6 agents (Claude, Gemini, Codex)
5 of 6 agents independently confirmed @ralphbean's inline finding: the force-retry path is unreachable in the actual failing scaffold flow. Three additional unique findings below (HIGH, 2x MEDIUM) that are not covered by existing review comments.
Assisted-by: Claude (review), Gemini (review), Codex (review)
waynesun09
left a comment
There was a problem hiding this comment.
3 new findings (1 HIGH, 2 MEDIUM) from multi-agent review — unique issues not covered by existing comments. See inline.
Assisted-by: Claude (review), Gemini (review), Codex (review)
| if !allowForce || !isNonFastForwardError(apiErr) { | ||
| return false, fmt.Errorf("update ref: %w", err) | ||
| } | ||
| forcePayload := map[string]any{"sha": newCommit.SHA, "force": true} |
There was a problem hiding this comment.
[high] stale-parent-on-force
The force retry pushes newCommit.SHA — the same commit object built in step 6 with the stale commitSHA (fetched in step 1) as its parent. If auto_init created a commit between step 1 and step 7, the force-pushed commit's parent chain doesn't include the auto_init commit, leaving it orphaned in the object store. The scaffold commit history won't properly descend from the initial commit.
Secondary to the CRITICAL (this path is currently unreachable), but if force-retry is kept: re-fetch the current ref SHA and recreate the commit object with the correct parent before pushing. Moot if auto_init: false is adopted.
| err := client.DeleteIssueComment(context.Background(), "org", "repo", 42) | ||
| require.NoError(t, err) | ||
| } | ||
|
|
There was a problem hiding this comment.
[medium] wrong-path-tested
These tests exercise CommitFilesToBranch (allowForce=true), but the actual e2e failure goes through CommitFiles (allowForce=false). TestCommitFiles_NonFastForwardNoRetry below confirms CommitFiles does NOT retry — effectively documenting the bug rather than validating the fix.
No test exercises the full CommitScaffoldFiles flow to show the non-fast-forward is handled. Consider adding an integration test in internal/layers/ that calls CommitScaffoldFiles with a mock client returning a non-fast-forward 422 from CommitFiles.
| } | ||
| refUpdateResp, err := c.patch(ctx, fmt.Sprintf("/repos/%s/%s/git/refs/heads/%s", owner, repo, branch), refPayload) | ||
| refURL := fmt.Sprintf("/repos/%s/%s/git/refs/heads/%s", owner, repo, branch) | ||
| refUpdateResp, err := c.patch(ctx, refURL, refPayload) |
There was a problem hiding this comment.
[medium] stale-comment
The comment above (line 792) says "A 422 here typically means branch protection rules prevent the push" but the code below now dispatches on three distinct 422 variants: non-422 errors (line 801), branch protection (line 804), and non-fast-forward (line 811). The comment should reflect the expanded handling.
Fixes #2489
Summary
Scaffold pushes now retry with
force: truewhen hitting "Update is not a fast forward" errors. Resolves race where GitHub's async auto_init materializes a commit between ref fetch and ref update.Root cause
CreateReposetsauto_init: true(line 377). GitHub creates initial commit async — can complete aftercommitFilesTofetches current ref SHA (line 658) but before updating ref (line 795). Scaffold commit built from stale parent → non-fast-forward rejection.Fix
isNonFastForwardErrorhelperforce: trueTesting
TestCommitFiles_NonFastForwardRetryverifies retry logicCommitFilestests passinternal/forge/github+internal/layerstest suites passImpact
Should eliminate most common e2e flake — appeared in nearly every failed run.
TestVendorFromSubdirectoryandTestAdminInstallUninstallhit this repeatedly.