Skip to content

Replace format-patch/git-am pipeline with tree diff + GraphQL commit API #18900

@strawgate

Description

@strawgate

Summary

The current safe-outputs pipeline uses git format-patch / git am to transfer commits from the agent job to the safe-outputs job. This pipeline fundamentally cannot handle merge commits (format-patch silently skips them) or rebased history (SHAs diverge, breaking incremental patch generation). This means agents cannot resolve merge conflicts on PRs — the only git operations that clear GitHub's conflict indicator (merge commits, rebases) are incompatible with the pipeline.

Separately, commits pushed via git push with GITHUB_TOKEN are unverified, while commits created through the GraphQL API (createCommitOnBranch) are signed/verified.

This issue proposes replacing the patch pipeline with a GraphQL-based commit approach that solves both problems at once, preserving the agent's exact commit history (individual commits, merge commits, and commit messages).

Current Architecture

Agent (Job A)                    Safe Outputs (Job B)
─────────────                    ────────────────────
agent makes commits              fetch origin/<branch>
         │                       checkout origin/<branch>
         ▼                              │
git format-patch                        ▼
  origin/<branch>..<branch>      git am --3way <patch>
  --stdout > patch                      │
         │                              ▼
         └──── patch file ─────► git push origin <branch>

Limitations:

  • format-patch skips merge commits (multiple parents) → empty/incomplete patches
  • Rebased branches break incremental mode (origin/<branch> is no longer an ancestor)
  • Commits pushed via git push are unverified

Proposed Architecture

Replace the patch pipeline with per-commit manifest capture + GraphQL API replay. The agent's exact commit sequence is preserved — regular commits, merge commits, commit messages, and order.

Agent (Job A)                         Safe Outputs (Job B)
─────────────                         ────────────────────
agent makes commits                   
(edit, merge, rebase, etc.)           
         │                            
MCP handler walks commit history:     
  git rev-list --reverse              
    origin/<branch>..HEAD             
  For each commit:                    
    - message, parents, file changes  
         │                            
         └──── manifest.json ───────► For each commit in order:
                                        Regular? → createCommitOnBranch (signed ✓)
                                        Merge?   → mergeBranch (signed ✓)
                                                   + fixup if resolution differs

Capture (MCP handler in Job A)

Walks the commit history and captures each commit individually — message, type (regular vs merge), and file changes relative to the first parent:

const commits = [];
const shas = execGitSync(
  ["rev-list", "--reverse", `origin/$main..HEAD`], { cwd }
).trim().split("\n").filter(Boolean);

for (const sha of shas) {
  const parents = execGitSync(
    ["log", "-1", "--format=%P", sha], { cwd }
  ).trim().split(" ").filter(Boolean);

  const message = execGitSync(
    ["log", "-1", "--format=%B", sha], { cwd }
  ).trim();

  const isMerge = parents.length > 1;

  // Diff against first parent — shows what THIS commit changed
  const nameStatus = execGitSync(
    ["diff", "--name-status", parents[0], sha], { cwd }
  ).trim();

  const files = [];
  for (const line of nameStatus.split("\n").filter(Boolean)) {
    const [status, ...pathParts] = line.split("\t");
    const filePath = pathParts.join("\t");
    if (status.startsWith("D")) {
      files.push({ path: filePath, deleted: true });
    } else {
      const content = execGitSync(["show", `${sha}:${filePath}`], { cwd });
      files.push({ path: filePath, content: Buffer.from(content).toString("base64") });
    }
  }

  commits.push({
    message,
    isMerge,
    mergeParent: isMerge ? parents[1] : null,
    files,
  });
}

const manifest = { commits };

Application (Safe outputs handler in Job B)

Replays each commit in order, using the appropriate GraphQL mutation:

for (const commit of manifest.commits) {
  const head = await getCurrentHead(branch);

  if (!commit.isMerge) {
    // Regular commit → createCommitOnBranch (signed)
    await createCommitOnBranch(branch, head, commit.message, commit.files);

  } else {
    // Merge commit → mergeBranch, then fixup if auto-merge differs from agent's resolution
    const result = await mergeBranch(repoId, branch, commit.mergeParent);

    if (result.success) {
      // Auto-merge worked — check if it matches agent's resolution
      if (commit.files.length > 0) {
        const mergeHead = await getCurrentHead(branch);
        const needsFixup = await filesDiffer(branch, commit.files);
        if (needsFixup) {
          await createCommitOnBranch(branch, mergeHead, commit.message, commit.files);
        }
      }
    } else {
      // Conflicts — 3-phase approach (all signed):
      // 1. Accept base branch version for conflicting files
      const mainFiles = await getFilesAtRef(owner, repo, commit.mergeParent,
        commit.files.map(f => f.path));
      await createCommitOnBranch(branch, head, "Sync with base branch", mainFiles);

      // 2. mergeBranch — guaranteed to succeed now
      await mergeBranch(repoId, branch, commit.mergeParent);

      // 3. Apply agent's actual resolution
      const mergeHead = await getCurrentHead(branch);
      await createCommitOnBranch(branch, mergeHead, commit.message, commit.files);
    }
  }
}

What This Preserves

Property Current (format-patch) Proposed (GraphQL replay)
Individual commits ✅ — one API call per agent commit
Commit messages ✅ — captured per commit
Commit order ✅ — rev-list --reverse
Merge commits ❌ (skipped) ✅ — mergeBranch creates real two-parent commits
Conflict resolutions ✅ — fixup commit applies agent's resolution if auto-merge differs
Rebase support ❌ (SHA divergence) ✅ — tree diff is topology-agnostic
Signed/verified commits ❌ (git push) ✅ — all via GraphQL

Example: Agent Session Replay

Agent session: edit → commit → edit → commit → merge main → resolve conflicts → commit post-merge fix

Agent (Job A)                          Job B replay
──────────                             ────────────
commit "Fix parser bug"          →     createCommitOnBranch("Fix parser bug", files)
commit "Add tests"               →     createCommitOnBranch("Add tests", files)
merge origin/main (conflicts)    →     mergeBranch(branch, main)
  resolve conflicts                      → if conflicts: 3-phase approach
commit "Update config"           →     createCommitOnBranch("Update config", files)

Each step is signed. The PR history matches what the agent did.

Files to Change

  • actions/setup/js/generate_git_patch.cjs → replace format-patch with per-commit manifest capture
  • actions/setup/js/push_to_pull_request_branch.cjs → replace git am + git push with GraphQL replay loop
  • actions/setup/js/safe_outputs_handlers.cjs → update MCP handler to emit manifest instead of patch
  • actions/setup/sh/generate_git_patch.sh → update or remove shell version
  • actions/setup/js/create_pull_request.cjs → same pattern for PR creation

Trade-offs

  • File size: Manifest contains full file contents (base64), not diffs. Larger than patches for big changes. createCommitOnBranch has API size limits.
  • API rate limits: N GraphQL calls (one per commit) vs 1 git push. Unlikely to be an issue for typical agent sessions.
  • Merge fixup commits: When the agent's conflict resolution differs from auto-merge, an extra fixup commit appears. Could be noted in the commit message.

Related Issues

This proposal addresses both issues: #18801 becomes moot (no more format-patch) and #18565 is resolved (all commits via GraphQL are signed).

Real-World Failure

See elastic/ai-github-actions-playground#590 (comment) — agent attempted to resolve merge conflicts but was blocked because the pipeline cannot handle merge commits or rebased history.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions