diff --git a/.changeset/patch-fallback-incremental-patch-fetch.md b/.changeset/patch-fallback-incremental-patch-fetch.md new file mode 100644 index 00000000000..c22a490b299 --- /dev/null +++ b/.changeset/patch-fallback-incremental-patch-fetch.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fixed incremental patch generation for `push_to_pull_request_branch` by falling back to an existing `origin/` tracking ref when `git fetch` fails, and added integration coverage for the fallback path. diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index 50684574e29..c71f00c3a1c 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -165,7 +165,9 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { // INCREMENTAL MODE (for push_to_pull_request_branch): // Only include commits that are new since origin/branchName. // This prevents including commits that already exist on the PR branch. - // We must explicitly fetch origin/branchName and fail if it doesn't exist. + // Prefer a fresh fetch of origin/branchName; fall back to the existing + // remote tracking ref (set up by the initial shallow checkout) when the + // fetch fails (e.g. due to shallow clone limitations or missing credentials). debugLog(`Strategy 1 (incremental): Fetching origin/${branchName}`); // Configure git authentication via GIT_CONFIG_* environment variables. @@ -183,15 +185,29 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { baseRef = `origin/${branchName}`; debugLog(`Strategy 1 (incremental): Successfully fetched, baseRef=${baseRef}`); } catch (fetchError) { - // In incremental mode, we MUST have origin/branchName - no fallback - debugLog(`Strategy 1 (incremental): Fetch failed - ${getErrorMessage(fetchError)}`); - errorMessage = `Cannot generate incremental patch: failed to fetch origin/${branchName}. This typically happens when the remote branch doesn't exist yet or was force-pushed. Error: ${getErrorMessage(fetchError)}`; - // Don't try other strategies in incremental mode - return { - success: false, - error: errorMessage, - patchPath: patchPath, - }; + // Fetch failed. Check if origin/branchName already exists from the initial shallow checkout. + // This handles cases where git fetch fails due to shallow clone limitations or when + // GITHUB_TOKEN is unavailable in the MCP server process (e.g. after clean_git_credentials.sh). + // Using the existing remote tracking ref as a fallback is safe: it represents the state + // of the branch at checkout time, so the incremental patch will include all commits + // made by the agent since then. + debugLog(`Strategy 1 (incremental): Fetch failed - ${getErrorMessage(fetchError)}, checking for existing remote tracking ref`); + try { + execGitSync(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`], { cwd }); + // Remote tracking ref exists from initial shallow checkout — use it as base + baseRef = `origin/${branchName}`; + debugLog(`Strategy 1 (incremental): Using existing remote tracking ref as fallback, baseRef=${baseRef}`); + } catch (refCheckError) { + // No remote tracking ref at all — cannot safely generate an incremental patch. + // Report both errors: the original fetch failure and the missing ref. + debugLog(`Strategy 1 (incremental): No existing remote tracking ref found (${getErrorMessage(refCheckError)}), failing`); + errorMessage = `Cannot generate incremental patch: failed to fetch origin/${branchName} and no existing remote tracking ref found. This typically happens when the remote branch doesn't exist yet or was force-pushed. Fetch error: ${getErrorMessage(fetchError)}`; + return { + success: false, + error: errorMessage, + patchPath: patchPath, + }; + } } } else { // FULL MODE (for create_pull_request): diff --git a/actions/setup/js/git_patch_integration.test.cjs b/actions/setup/js/git_patch_integration.test.cjs index 17cbb8f8374..c998b3301ac 100644 --- a/actions/setup/js/git_patch_integration.test.cjs +++ b/actions/setup/js/git_patch_integration.test.cjs @@ -739,6 +739,51 @@ describe("git patch integration tests", () => { } }); + it("should fall back to existing remote tracking ref when fetch fails in incremental mode", async () => { + // Simulate a shallow checkout scenario: + // 1. feature-branch is created, first commit pushed to origin (origin/feature-branch exists) + // 2. Agent adds a new commit locally + // 3. Remote URL is broken so git fetch fails + // 4. We expect patch generation to succeed using the existing origin/feature-branch ref + execGit(["checkout", "-b", "shallow-fetch-fail"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "base.txt"), "Base content\n"); + execGit(["add", "base.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Base commit (already on remote)"], { cwd: workingRepo }); + + // Push so origin/shallow-fetch-fail tracking ref is set up (simulating shallow checkout) + execGit(["push", "-u", "origin", "shallow-fetch-fail"], { cwd: workingRepo }); + + // Add a new commit (the agent's work) + fs.writeFileSync(path.join(workingRepo, "agent-change.txt"), "Agent change\n"); + execGit(["add", "agent-change.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Agent commit - should appear in patch"], { cwd: workingRepo }); + + // Break the remote URL to simulate fetch failure (e.g. missing credentials or network issue) + execGit(["remote", "set-url", "origin", "https://invalid.example.invalid/nonexistent-repo.git"], { cwd: workingRepo }); + + const restore = setTestEnv(workingRepo); + try { + // origin/shallow-fetch-fail still points to the base commit even though fetch will fail + const result = await generateGitPatch("shallow-fetch-fail", "main", { mode: "incremental" }); + + // Should succeed using the existing remote tracking ref as the base + expect(result.success).toBe(true); + expect(result.patchPath).toBeDefined(); + + const patchContent = fs.readFileSync(result.patchPath, "utf8"); + + // Should contain the agent's new commit + expect(patchContent).toContain("Agent commit - should appear in patch"); + + // Should NOT include the already-pushed base commit + expect(patchContent).not.toContain("Base commit (already on remote)"); + } finally { + // Restore remote URL before cleanup so afterEach can delete the directory + execGit(["remote", "set-url", "origin", bareRepoDir], { cwd: workingRepo }); + restore(); + } + }); + it("should include all commits in full mode even when origin/branch exists", async () => { // Create a feature branch with first commit execGit(["checkout", "-b", "full-mode-branch"], { cwd: workingRepo }); diff --git a/go.mod b/go.mod index 4e7e9a0d876..0baf5dc664f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2 github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc github.com/charmbracelet/x/exp/golden v0.0.0-20251215102626-e0db08df7383 github.com/cli/go-gh/v2 v2.13.0 github.com/creack/pty v1.1.24 @@ -42,7 +43,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect