From b2fabb6610c22f6cc632fc08e2293f7253f8a46d Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 4 Jun 2026 15:11:08 +0100 Subject: [PATCH] feat(cloud-agent): add git_signed_rewrite tool --- .../agent/src/adapters/local-tools/index.ts | 3 +- .../local-tools/tools/signed-commit.ts | 41 +-- .../local-tools/tools/signed-git-tool.ts | 48 +++ .../local-tools/tools/signed-rewrite.ts | 14 + .../src/adapters/signed-commit-shared.ts | 88 ++++- packages/agent/src/server/agent-server.ts | 13 +- packages/git/src/signed-commit.test.ts | 39 ++- packages/git/src/signed-commit.ts | 309 +++++++++++++++--- 8 files changed, 452 insertions(+), 103 deletions(-) create mode 100644 packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts create mode 100644 packages/agent/src/adapters/local-tools/tools/signed-rewrite.ts diff --git a/packages/agent/src/adapters/local-tools/index.ts b/packages/agent/src/adapters/local-tools/index.ts index 8311d7472e..6bd3ae54fb 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -1,5 +1,6 @@ import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry"; import { signedCommitTool } from "./tools/signed-commit"; +import { signedRewriteTool } from "./tools/signed-rewrite"; export { LOCAL_TOOLS_MCP_NAME, @@ -11,7 +12,7 @@ export { } from "./registry"; /** Every tool the general local MCP server can expose. Add new tools here. */ -export const LOCAL_TOOLS: LocalTool[] = [signedCommitTool]; +export const LOCAL_TOOLS: LocalTool[] = [signedCommitTool, signedRewriteTool]; /** Tools whose gate passes for the given context — the set to actually expose. */ export function enabledLocalTools( diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts index 4b38c5149b..704a8cce1b 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts @@ -1,49 +1,14 @@ -import * as path from "node:path"; -import { isCloudRun } from "../../../utils/common"; -import { resolveGithubToken } from "../../../utils/github-token"; import { runSignedCommitTool, SIGNED_COMMIT_TOOL_DESCRIPTION, SIGNED_COMMIT_TOOL_NAME, signedCommitToolSchema, } from "../../signed-commit-shared"; -import { defineLocalTool } from "../registry"; +import { defineSignedGitTool } from "./signed-git-tool"; -/** - * `git_signed_commit` as a local tool. Cloud-run only; the token is resolved - * lazily at call time so the tool stays visible even when the GitHub token - * lands in `process.env` after the session was created (e.g. an orchestrator - * injecting it post-spawn). Committing is core to cloud tasks, so keep it - * exposed past ToolSearch via `alwaysLoad`. - */ -export const signedCommitTool = defineLocalTool({ +export const signedCommitTool = defineSignedGitTool({ name: SIGNED_COMMIT_TOOL_NAME, description: SIGNED_COMMIT_TOOL_DESCRIPTION, schema: signedCommitToolSchema, - alwaysLoad: true, - isEnabled: (_ctx, meta) => isCloudRun(meta), - handler: (ctx, args) => { - // Prefer a freshly-resolved token (reads the live agentsh env file) over - // the one captured at session setup, so a mid-session credential refresh - // takes effect without rebuilding the session. - const token = resolveGithubToken() ?? ctx.token; - if (!token) { - return Promise.resolve({ - content: [ - { - type: "text" as const, - text: `${SIGNED_COMMIT_TOOL_NAME} failed: no GitHub token in env (GH_TOKEN/GITHUB_TOKEN)`, - }, - ], - isError: true, - }); - } - // Resolve an explicit `cwd` arg against the session cwd so the agent can - // commit from any clone reachable in the sandbox, not just the one the - // session was rooted at. Absolute paths fall through `path.resolve` - // unchanged; relative paths join the session cwd. - const { cwd: argCwd, ...input } = args; - const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd; - return runSignedCommitTool({ cwd, token, taskId: ctx.taskId }, input); - }, + run: runSignedCommitTool, }); diff --git a/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts b/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts new file mode 100644 index 0000000000..7e23d7744e --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts @@ -0,0 +1,48 @@ +import * as path from "node:path"; +import type { SignedCommitCtx } from "@posthog/git/signed-commit"; +import type { z } from "zod"; +import { isCloudRun } from "../../../utils/common"; +import { resolveGithubToken } from "../../../utils/github-token"; +import type { SignedCommitToolResult } from "../../signed-commit-shared"; +import { defineLocalTool, type LocalTool } from "../registry"; + +/** + * Factory for the cloud-only signed-git tools (git_signed_commit / git_signed_rewrite). + * Resolves the token lazily (live /tmp/agent-env first, so a mid-session credential + * refresh takes effect) and the optional `cwd` arg against the session cwd, then + * delegates to the tool's `run`. Kept past ToolSearch via alwaysLoad. + */ +export function defineSignedGitTool(opts: { + name: string; + description: string; + schema: S; + run: (ctx: SignedCommitCtx, input: R) => Promise; +}): LocalTool { + return defineLocalTool({ + name: opts.name, + description: opts.description, + schema: opts.schema, + alwaysLoad: true, + isEnabled: (_ctx, meta) => isCloudRun(meta), + handler: (ctx, args) => { + const token = resolveGithubToken() ?? ctx.token; + if (!token) { + return Promise.resolve({ + content: [ + { + type: "text" as const, + text: `${opts.name} failed: no GitHub token in env (GH_TOKEN/GITHUB_TOKEN)`, + }, + ], + isError: true as const, + }); + } + const { cwd: argCwd, ...input } = args as { cwd?: string } & Record< + string, + unknown + >; + const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd; + return opts.run({ cwd, token, taskId: ctx.taskId }, input as R); + }, + }); +} diff --git a/packages/agent/src/adapters/local-tools/tools/signed-rewrite.ts b/packages/agent/src/adapters/local-tools/tools/signed-rewrite.ts new file mode 100644 index 0000000000..bc49fd90b4 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/signed-rewrite.ts @@ -0,0 +1,14 @@ +import { + runSignedRewriteTool, + SIGNED_REWRITE_TOOL_DESCRIPTION, + SIGNED_REWRITE_TOOL_NAME, + signedRewriteToolSchema, +} from "../../signed-commit-shared"; +import { defineSignedGitTool } from "./signed-git-tool"; + +export const signedRewriteTool = defineSignedGitTool({ + name: SIGNED_REWRITE_TOOL_NAME, + description: SIGNED_REWRITE_TOOL_DESCRIPTION, + schema: signedRewriteToolSchema, + run: runSignedRewriteTool, +}); diff --git a/packages/agent/src/adapters/signed-commit-shared.ts b/packages/agent/src/adapters/signed-commit-shared.ts index 4f7c24fe43..d3131d6450 100644 --- a/packages/agent/src/adapters/signed-commit-shared.ts +++ b/packages/agent/src/adapters/signed-commit-shared.ts @@ -1,8 +1,10 @@ import { createSignedCommit, + createSignedRewrite, type SignedCommitCtx, type SignedCommitInput, type SignedCommitResult, + type SignedRewriteInput, } from "@posthog/git/signed-commit"; import { z } from "zod"; import { qualifiedLocalToolName } from "./local-tools/registry"; @@ -51,10 +53,38 @@ export const signedCommitToolSchema = { ), }; -export function formatSignedCommitResult(result: SignedCommitResult): string { - const list = result.commits.map((c) => `- ${c.sha} ${c.url}`).join("\n"); - return `Created ${result.commits.length} signed commit(s) on ${result.branch}:\n${list}`; -} +export const SIGNED_REWRITE_TOOL_NAME = "git_signed_rewrite"; +export const SIGNED_REWRITE_QUALIFIED_TOOL_NAME = qualifiedLocalToolName( + SIGNED_REWRITE_TOOL_NAME, +); + +export const SIGNED_REWRITE_TOOL_DESCRIPTION = + "Force-update a branch with GitHub-signed (Verified) history, the signed-commit equivalent " + + "of `git push --force`. First rebase/merge locally with normal `git` (resolving conflicts and " + + "finishing with `git rebase --continue`, NOT `git commit`); then call this to republish the " + + "branch's commits as Verified and atomically move the remote branch onto them. Use this to " + + "update an existing PR after a rebase or conflict fix. Rewrites the current branch by default."; + +export const signedRewriteToolSchema = { + branch: z + .string() + .optional() + .describe("Branch to rewrite; defaults to the current branch."), + onto: z + .string() + .optional() + .describe( + "Commit/ref the rewritten history sits on (e.g. origin/master). " + + "Defaults to the merge-base of the current branch with the repo's default branch.", + ), + cwd: z + .string() + .optional() + .describe( + "Path to the git checkout to rewrite; defaults to the session's working directory. " + + "Relative paths resolve against the session cwd.", + ), +}; export interface SignedCommitToolResult { content: { type: "text"; text: string }[]; @@ -64,27 +94,49 @@ export interface SignedCommitToolResult { [key: string]: unknown; } -/** - * Runs `git_signed_commit` and formats the MCP result. Shared by the Claude - * in-process tool and the Codex stdio server so success/error formatting (and - * the error-message prefix) can't drift between adapters. - */ -export async function runSignedCommitTool( +async function runSignedTool( + toolName: string, + op: (ctx: SignedCommitCtx, args: A) => Promise, + lead: (result: SignedCommitResult) => string, ctx: SignedCommitCtx, - args: SignedCommitInput, + args: A, ): Promise { try { - const result = await createSignedCommit(ctx, args); - return { - content: [{ type: "text", text: formatSignedCommitResult(result) }], - }; + const result = await op(ctx, args); + const list = result.commits.map((c) => `- ${c.sha} ${c.url}`).join("\n"); + return { content: [{ type: "text", text: `${lead(result)}:\n${list}` }] }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { - content: [ - { type: "text", text: `${SIGNED_COMMIT_TOOL_NAME} failed: ${message}` }, - ], + content: [{ type: "text", text: `${toolName} failed: ${message}` }], isError: true, }; } } + +export function runSignedCommitTool( + ctx: SignedCommitCtx, + args: SignedCommitInput, +): Promise { + return runSignedTool( + SIGNED_COMMIT_TOOL_NAME, + createSignedCommit, + (r) => `Created ${r.commits.length} signed commit(s) on ${r.branch}`, + ctx, + args, + ); +} + +export function runSignedRewriteTool( + ctx: SignedCommitCtx, + args: SignedRewriteInput, +): Promise { + return runSignedTool( + SIGNED_REWRITE_TOOL_NAME, + createSignedRewrite, + (r) => + `Force-updated ${r.branch} with ${r.commits.length} signed commit(s)`, + ctx, + args, + ); +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 8f6551556d..e474e786f9 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -25,7 +25,10 @@ import { type AgentErrorClassification, classifyAgentError, } from "../adapters/error-classification"; -import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../adapters/signed-commit-shared"; +import { + SIGNED_COMMIT_QUALIFIED_TOOL_NAME, + SIGNED_REWRITE_QUALIFIED_TOOL_NAME, +} from "../adapters/signed-commit-shared"; import type { PermissionMode } from "../execution-mode"; import { DEFAULT_CODEX_MODEL } from "../gateway-models"; import { HandoffCheckpointTracker } from "../handoff-checkpoint"; @@ -1699,6 +1702,13 @@ It creates a GitHub-signed ("Verified") commit on the branch and keeps your loca sync. To start a new branch, pass \`branch\` (prefixed with \`posthog-code/\`) — the tool creates it on the remote for you. +## Rewriting / force-pushing (rebases, conflict fixes) +\`git push --force\` is also blocked. To update a branch after a local rebase or conflict +resolution, rebase/merge locally with normal \`git\` (resolve conflicts and finish with +\`git rebase --continue\`, NOT \`git commit\`), then call the \`git_signed_rewrite\` tool (full +name \`${SIGNED_REWRITE_QUALIFIED_TOOL_NAME}\`). It republishes the branch's commits as Verified +and atomically force-updates the remote branch. This is how you fix conflicts on an existing PR. + ## Attribution Do NOT add "Co-Authored-By" trailers or "Generated with [Claude Code]" lines to your commit messages. The \`git_signed_commit\` tool automatically appends the only trailers @@ -1730,6 +1740,7 @@ This task already has an open pull request: ${prUrl} After completing the requested changes: 1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\` 2. Stage your changes with \`git add\`, then call the \`git_signed_commit\` tool with a clear \`message\` (do NOT use \`git commit\`/\`git push\` — they are blocked). This commits to the existing PR branch. + - If the branch has conflicts with its base, fetch and rebase locally (\`git fetch origin \`, \`git rebase origin/\`, resolve, \`git rebase --continue\`), then call the \`git_signed_rewrite\` tool to force-update this same PR branch. 3. For every PR review comment or review thread you addressed, treat the thread as done only after BOTH of these: - Reply on the thread with a short note describing what changed (reference the commit SHA when useful) using \`gh api -X POST /repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies -f body='...'\`. - Resolve the thread via the \`resolveReviewThread\` GraphQL mutation: \`gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -f id=""\`. diff --git a/packages/git/src/signed-commit.test.ts b/packages/git/src/signed-commit.test.ts index 0fb200d03c..8100632094 100644 --- a/packages/git/src/signed-commit.test.ts +++ b/packages/git/src/signed-commit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { chunkFileChanges, OversizedFileError } from "./signed-commit"; +import { + chunkFileChanges, + OversizedFileError, + splitCommitMessage, +} from "./signed-commit"; function addition(path: string, sizeBytes: number) { // base64 string of roughly `sizeBytes` length stands in for file contents. @@ -56,3 +60,36 @@ describe("chunkFileChanges", () => { ).toThrow(OversizedFileError); }); }); + +describe("splitCommitMessage", () => { + it.each([ + { + name: "subject only", + raw: "fix: handle null", + expected: { headline: "fix: handle null", body: "" }, + }, + { + name: "subject + body, dropping the blank separator line", + raw: "feat: add thing\n\nDetails here.\nMore details.", + expected: { + headline: "feat: add thing", + body: "Details here.\nMore details.", + }, + }, + { + name: "preserves existing trailers in the body", + raw: "fix: x\n\nGenerated-By: PostHog Code\nTask-Id: abc", + expected: { + headline: "fix: x", + body: "Generated-By: PostHog Code\nTask-Id: abc", + }, + }, + { + name: "trims trailing whitespace", + raw: "chore: y\n\nbody\n\n", + expected: { headline: "chore: y", body: "body" }, + }, + ])("$name", ({ raw, expected }) => { + expect(splitCommitMessage(raw)).toEqual(expected); + }); +}); diff --git a/packages/git/src/signed-commit.ts b/packages/git/src/signed-commit.ts index c36636fbde..b4ccc96938 100644 --- a/packages/git/src/signed-commit.ts +++ b/packages/git/src/signed-commit.ts @@ -2,6 +2,7 @@ // resolve this node-only module against vite's `__vite-browser-external` stub, // which has no named exports. This module never runs in the browser. import * as childProcess from "node:child_process"; +import * as crypto from "node:crypto"; import { mapWithConcurrency } from "./concurrency"; import { execGh, execGhWithRetry } from "./gh"; import { buildPostHogTrailers } from "./trailers"; @@ -56,6 +57,11 @@ export interface SignedCommitResult { commits: { sha: string; url: string }[]; } +export interface SignedRewriteInput { + branch?: string; + onto?: string; +} + export class OversizedFileError extends Error { constructor( readonly path: string, @@ -189,13 +195,25 @@ async function remoteTip( return out.split("\t")[0]?.trim() || null; } -async function createRef( +async function refApi( + ctx: SignedCommitCtx, + args: string[], + errLabel: string, +): Promise { + const res = await execGh(args, { cwd: ctx.cwd, env: ghTokenEnv(ctx.token) }); + if (res.exitCode !== 0) { + throw new Error(`${errLabel}: ${res.stderr || res.error}`); + } +} + +function createRef( ctx: SignedCommitCtx, repo: string, branch: string, sha: string, ): Promise { - const res = await execGh( + return refApi( + ctx, [ "api", "-X", @@ -206,13 +224,42 @@ async function createRef( "-f", `sha=${sha}`, ], - { cwd: ctx.cwd, env: ghTokenEnv(ctx.token) }, + `Failed to create branch '${branch}'`, + ); +} + +function forceUpdateRef( + ctx: SignedCommitCtx, + repo: string, + branch: string, + sha: string, +): Promise { + return refApi( + ctx, + [ + "api", + "-X", + "PATCH", + `/repos/${repo}/git/refs/heads/${branch}`, + "-f", + `sha=${sha}`, + "-F", + "force=true", + ], + `Failed to force-update '${branch}'`, + ); +} + +function deleteRef( + ctx: SignedCommitCtx, + repo: string, + branch: string, +): Promise { + return refApi( + ctx, + ["api", "-X", "DELETE", `/repos/${repo}/git/refs/heads/${branch}`], + `Failed to delete ref '${branch}'`, ); - if (res.exitCode !== 0) { - throw new Error( - `Failed to create branch '${branch}': ${res.stderr || res.error}`, - ); - } } /** Env var names the GitHub CLI / git credential helper read a token from, in order. */ @@ -236,19 +283,16 @@ export function ghTokenEnv(token: string): Record { // still cutting wall-clock for multi-file commits. const STAGED_READ_CONCURRENCY = 16; -async function buildFileChanges( +// Turns a `--name-status -z` diff into the `FileChanges` payload, reading each +// added/modified file's new blob via `readBlob` +async function readChangesFromDiff( ctx: SignedCommitCtx, - baseOid: string, + diffArgs: string[], + readBlob: (path: string) => string[], ): Promise { - // One `--name-status -z` diff yields additions and deletions together; output - // is `\0\0...` (no rename pairs, since `--no-renames`). Read raw - // (no trim) so paths with leading/trailing spaces survive. - const diff = await runGit( - ["diff", "--cached", "-z", "--no-renames", "--name-status", baseOid], - ctx.cwd, - ); + const diff = await runGit(diffArgs, ctx.cwd); if (diff.exitCode !== 0) { - throw new Error(`git diff --cached failed: ${diff.stderr.trim()}`); + throw new Error(`git ${diffArgs.join(" ")} failed: ${diff.stderr.trim()}`); } const tokens = diff.stdout.toString("utf8").split("\0").filter(Boolean); @@ -267,13 +311,9 @@ async function buildFileChanges( addPaths, STAGED_READ_CONCURRENCY, async (path) => { - // Read the *staged* blob (`:path`) so we commit exactly what was staged, - // not any later unstaged edits in the working tree. - const r = await runGit(["show", `:${path}`], ctx.cwd); + const r = await runGit(readBlob(path), ctx.cwd); if (r.exitCode !== 0) { - throw new Error( - `Failed to read staged file '${path}': ${r.stderr.trim()}`, - ); + throw new Error(`Failed to read file '${path}': ${r.stderr.trim()}`); } return { path, contents: r.stdout.toString("base64") }; }, @@ -281,6 +321,33 @@ async function buildFileChanges( return { additions, deletions }; } +function buildFileChanges( + ctx: SignedCommitCtx, + baseOid: string, +): Promise { + // Read the *staged* blob (`:path`) so we commit exactly what was staged, not + // any later unstaged edits in the working tree. + return readChangesFromDiff( + ctx, + ["diff", "--cached", "-z", "--no-renames", "--name-status", baseOid], + (path) => ["show", `:${path}`], + ); +} + +// The change between two arbitrary commits/trees, reading the new blob from the +// `to` side. Used by the rewrite path to replay one commit's net diff at a time. +function buildFileChangesBetween( + ctx: SignedCommitCtx, + fromOid: string, + toOid: string, +): Promise { + return readChangesFromDiff( + ctx, + ["diff", "-z", "--no-renames", "--name-status", fromOid, toOid], + (path) => ["show", `${toOid}:${path}`], + ); +} + function additionBytes(a: FileAddition): number { // base64 contents dominate; add path + per-entry JSON envelope overhead. return a.contents.length + a.path.length + 32; @@ -410,6 +477,38 @@ async function syncLocalCheckout( } } +async function publishChanges( + ctx: SignedCommitCtx, + repo: string, + branch: string, + baseOid: string, + headline: string, + body: string, + changes: FileChanges, +): Promise<{ commits: { sha: string; url: string }[]; tip: string }> { + const chunks = chunkFileChanges(changes, DEFAULT_MAX_PAYLOAD_BYTES); + const commits: { sha: string; url: string }[] = []; + let tip = baseOid; + for (let i = 0; i < chunks.length; i++) { + const hl = + chunks.length > 1 + ? `${headline} — part ${i + 1}/${chunks.length}` + : headline; + const commit = await createCommitOnBranch( + ctx, + repo, + branch, + tip, + hl, + body, + chunks[i], + ); + commits.push({ sha: commit.oid, url: commit.url }); + tip = commit.oid; + } + return { commits, tip }; +} + export async function createSignedCommit( ctx: SignedCommitCtx, input: SignedCommitInput, @@ -447,31 +546,153 @@ export async function createSignedCommit( ); } - const chunks = chunkFileChanges(changes, DEFAULT_MAX_PAYLOAD_BYTES); const body = [input.body, buildPostHogTrailers(ctx.taskId).join("\n")] .filter(Boolean) .join("\n\n"); - const commits: { sha: string; url: string }[] = []; - let expectedHeadOid = tip; - for (let i = 0; i < chunks.length; i++) { - const headline = - chunks.length > 1 - ? `${input.message} — part ${i + 1}/${chunks.length}` - : input.message; - const commit = await createCommitOnBranch( - ctx, - repo, - branch, - expectedHeadOid, - headline, - body, - chunks[i], + const { commits, tip: newTip } = await publishChanges( + ctx, + repo, + branch, + tip, + input.message, + body, + changes, + ); + + await syncLocalCheckout(ctx, branch, newTip); + return { branch, commits }; +} + +/** Splits a raw commit message into a headline and the remaining body */ +export function splitCommitMessage(raw: string): { + headline: string; + body: string; +} { + const nl = raw.indexOf("\n"); + if (nl === -1) return { headline: raw.trim(), body: "" }; + return { + headline: raw.slice(0, nl).trim(), + body: raw + .slice(nl + 1) + .replace(/^\n+/, "") + .trimEnd(), + }; +} + +async function resolveOnto( + ctx: SignedCommitCtx, + input: SignedRewriteInput, + baseBranch: string | null, +): Promise { + if (input.onto) { + return gitText(["rev-parse", `${input.onto}^{commit}`], ctx.cwd); + } + if (!baseBranch) { + throw new Error( + "Could not determine the base branch — pass `onto` explicitly (e.g. origin/master).", ); - commits.push({ sha: commit.oid, url: commit.url }); - expectedHeadOid = commit.oid; } + return gitText(["merge-base", `origin/${baseBranch}`, "HEAD"], ctx.cwd); +} - await syncLocalCheckout(ctx, branch, expectedHeadOid); - return { branch, commits }; +/** + * Republishes the current local branch as GitHub-signed history and + * force-updates the remote branch onto it — the signed-commit equivalent of `git push --force` + */ +export async function createSignedRewrite( + ctx: SignedCommitCtx, + input: SignedRewriteInput, +): Promise { + const [repo, branch] = await Promise.all([ + resolveRepoNameWithOwner(ctx), + resolveBranchName(ctx, { message: "", branch: input.branch }), + ]); + + // Rewrite only updates existing history — a brand-new branch goes through + // createSignedCommit instead. + const staleTip = await remoteTip(ctx, branch); + if (staleTip === null) { + throw new Error( + `Branch '${branch}' does not exist on the remote. Use git_signed_commit to create it first.`, + ); + } + + const baseBranch = await resolveBaseBranch(ctx); + if (baseBranch) { + await runGit(["fetch", "--no-tags", "origin", baseBranch], ctx.cwd); + } + const onto = await resolveOnto(ctx, input, baseBranch); + + // HEAD must descend from `onto` so `onto..HEAD` is exactly the set to replay. + const ancestry = await runGit( + ["merge-base", "--is-ancestor", onto, "HEAD"], + ctx.cwd, + ); + if (ancestry.exitCode !== 0) { + throw new Error( + `Local HEAD is not based on ${onto} — rebase onto the base branch first, then call git_signed_rewrite.`, + ); + } + + const list = await gitText( + ["rev-list", "--reverse", "--first-parent", `${onto}..HEAD`], + ctx.cwd, + ); + const localCommits = list ? list.split("\n").filter(Boolean) : []; + if (localCommits.length === 0) { + throw new Error(`No commits between ${onto} and HEAD to publish.`); + } + + const scratch = `posthog-code/rewrite-tmp/${crypto.randomUUID()}`; + await createRef(ctx, repo, scratch, onto); + + const commits: { sha: string; url: string }[] = []; + try { + let expectedHeadOid = onto; + let prevTree = onto; + for (const sha of localCommits) { + const changes = await buildFileChangesBetween(ctx, prevTree, sha); + prevTree = sha; + // Skip empty commits (e.g. a merge that's a no-op on the first-parent + // line) — createCommitOnBranch rejects an empty fileChanges payload. + if (changes.additions.length === 0 && changes.deletions.length === 0) { + continue; + } + const { headline, body } = splitCommitMessage( + await gitText(["log", "-1", "--format=%B", sha], ctx.cwd), + ); + const published = await publishChanges( + ctx, + repo, + scratch, + expectedHeadOid, + headline, + body, + changes, + ); + commits.push(...published.commits); + expectedHeadOid = published.tip; + } + + if (commits.length === 0) { + throw new Error( + "Nothing to publish — every commit was empty after diffing.", + ); + } + + const currentTip = await remoteTip(ctx, branch); + if (currentTip !== staleTip) { + throw new Error( + `Branch '${branch}' moved while rewriting (expected ${staleTip}, found ${currentTip}). Re-fetch and retry.`, + ); + } + await forceUpdateRef(ctx, repo, branch, expectedHeadOid); + await syncLocalCheckout(ctx, branch, expectedHeadOid); + return { branch, commits }; + } finally { + // The history is already published via the ref move; the scratch ref is just + // bookkeeping, so a delete failure is non-fatal. + await deleteRef(ctx, repo, scratch).catch(() => {}); + } }