mcp/git.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { regex } from "arkregex";
import { type } from "arktype";
import { NON_COMMITTING_MODES } from "../modes.ts";
import {
primaryRepoState,
type RepoAccess,
type RepoToolState,
requireRepoState,
type StoredPushDest,
} from "../toolState.ts";
import {
assertApiCommittable,
createSignedCommit,
detectWorkingTreeChanges,
} from "../utils/apiCommit.ts";
import { log } from "../utils/cli.ts";
import {
$git,
$gitFetchWithDeepen,
TRANSIENT_AUTH_PATTERNS,
WORKING_TREE_GIT_ENV,
} from "../utils/gitAuth.ts";
import { executeLifecycleHook, type LifecycleHookFailure } from "../utils/lifecycle.ts";
import { $ } from "../utils/shell.ts";
import { resolveRepoCtx } from "./resolveRepoCtx.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
type PushDestination = {
remoteName: string;
remoteBranch: string;
url: string;
};
/**
* get where git would actually push this branch.
* prefers the stored destination from toolState (set by checkout_pr) when it
* matches the current branch, because git config reads can silently fail in
* certain environments causing pushes to the wrong remote branch.
*
* falls back to reading branch.X.pushRemote and branch.X.merge from git config,
* and finally to origin/<branch> for branches created without checkout_pr.
*/
function getPushDestination(
branch: string,
storedDest: StoredPushDest | undefined,
cwd: string
): PushDestination {
// prefer stored destination from checkout_pr when it matches the current branch
if (storedDest && storedDest.localBranch === branch) {
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
cwd,
log: false,
}).trim();
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
}
// fall back to git config (for branches not created by checkout_pr)
try {
const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], {
cwd,
log: false,
}).trim();
const merge = $("git", ["config", `branch.${branch}.merge`], { cwd, log: false }).trim();
const remoteBranch = merge.replace(/^refs\/heads\//, "");
const url = $("git", ["remote", "get-url", "--push", pushRemote], { cwd, log: false }).trim();
return { remoteName: pushRemote, remoteBranch, url };
} catch {
// no push config - branch was created locally without checkout_pr
log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
const url = $("git", ["remote", "get-url", "--push", "origin"], { cwd, log: false }).trim();
return { remoteName: "origin", remoteBranch: branch, url };
}
}
/**
* normalize URL for comparison (handle .git suffix, case)
*/
function normalizeUrl(url: string): string {
return url.replace(/\.git$/, "").toLowerCase();
}
// SECURITY: reject refs/branch names that begin with "-". git's parseopt
// accepts options intermixed with positional args, so a ref like
// "--upload-pack=evil" could be interpreted as a flag rather than a refspec.
export function rejectIfLeadingDash(value: string, kind: string): void {
if (value.startsWith("-")) {
throw new Error(`Blocked: ${kind} '${value}' starts with '-' — git could parse it as a flag.`);
}
}
// SECURITY: branch inputs to push/delete must be bare branch names. a branch
// name like "refs/heads/main" bypasses the restricted-mode default-branch
// check below (which does exact-string compare against "main"), and symbolic
// refs (HEAD / FETCH_HEAD / ORIG_HEAD / MERGE_HEAD) would resolve to
// whatever commit those refs point at — both routes let an agent push to
// protected branches even under push: restricted. checkout_pr only ever
// stores bare names like "pr-123", so nothing legitimate relies on the
// refs/... form here.
const SYMBOLIC_REFS = new Set(["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD"]);
export function rejectSpecialRef(value: string, kind: string): void {
rejectIfLeadingDash(value, kind);
if (value.startsWith("refs/")) {
throw new Error(
`Blocked: ${kind} '${value}' is a fully-qualified ref path. Use a bare branch name (e.g. 'feature/foo' or 'main'), not a 'refs/heads/...' form.`
);
}
if (SYMBOLIC_REFS.has(value)) {
throw new Error(
`Blocked: ${kind} '${value}' is a git symbolic ref, not a branch name. Pass the resolved branch name (e.g. 'main'), or omit branchName to push the current branch.`
);
}
// SECURITY: git interprets ':' and leading '+' as refspec syntax, not as
// part of a branch name. without this check, an agent under push:restricted
// can smuggle a full refspec through branchName:
// - "evil:refs/heads/main" → pushes local 'evil' to remote main
// - ":refs/heads/main" → deletes remote main
// - ":other" → deletes remote 'other' under push:restricted
// - "+main" → force-push refspec
// the default-branch guard downstream is an exact-string compare, so any
// character that lets git parse the value as <src>:<dst> (or as a force
// prefix) bypasses it. git's own check-ref-format forbids ':', '+', '^',
// '~', '?', '*', '[', '\\', and whitespace in branch names, so rejecting
// them here cannot false-positive against a legitimate branch name.
const BAD = /[:+^~?*[\\\s]/;
const badMatch = value.match(BAD);
if (badMatch) {
throw new Error(
`Blocked: ${kind} '${value}' contains '${badMatch[0]}', which git interprets as refspec/revision syntax, not as part of a branch name.`
);
}
}
// SECURITY: validate tag names so the push_tags refspec can't be split into
// a <src>:<dst> refspec that targets a non-tag ref. without this, a tag like
// "foo:refs/heads/main" becomes "refs/tags/foo:refs/heads/main" and git
// pushes the local tag's commit to remote main — a back door around the
// branch-push rules in push_branch. keep the allow-list conservative (git's
// own check-ref-format forbids far more, but we only need enough to block
// refspec injection).
export function validateTagName(tag: string): void {
rejectIfLeadingDash(tag, "tag");
if (!/^[A-Za-z0-9._/-]+$/.test(tag)) {
throw new Error(
`Blocked: tag '${tag}' contains characters that could be parsed as a refspec or flag. Tags must match [A-Za-z0-9._/-]+.`
);
}
}
/**
* whether this run pushes to the base repo (vs a contributor's fork). signed
* commits only apply to the base repo — the app can't API-commit to a fork.
* keyed off `toolState.pushUrl` (set by setupGit to the base URL, updated by
* checkout_pr to the fork URL for fork PRs) rather than the remote *name*,
* which is mutable git config an agent could rename.
*/
function pushesToBaseRepo(ctx: ToolContext): boolean {
const baseUrl = `https://github.com/${ctx.repo.owner}/${ctx.repo.name}.git`;
return normalizeUrl(primaryRepoState(ctx.toolState).pushUrl ?? "") === normalizeUrl(baseUrl);
}
/**
* validate that the push destination matches expected URL.
* pushUrl is set at checkout configuration (setupGit for the primary, checkout_repo for
* secondaries) and updated by checkout_pr (fork repo).
*/
function validatePushDestination(
repoState: RepoToolState,
branch: string,
cwd: string
): PushDestination {
const pushUrl = repoState.pushUrl;
if (!pushUrl) throw new Error("pushUrl not set - setupGit must run before push_branch");
const dest = getPushDestination(branch, repoState.pushDest, cwd);
if (normalizeUrl(dest.url) !== normalizeUrl(pushUrl)) {
throw new Error(
`Push blocked: destination does not match expected repository.\n` +
`Expected: ${pushUrl}\n` +
`Actual: remote '${dest.remoteName}' -> ${dest.url}\n` +
`Git configuration may have been tampered with, or a url.*.insteadOf rewrite is in effect.`
);
}
return dest;
}
export const PushBranch = type({
branchName: type.string
.describe("The branch name to push (defaults to current branch)")
.optional(),
force: type.boolean.describe("Force push (use with caution)").default(false),
"repo?": type.string.describe(
"cross-repo runs only: the writable secondary repo whose checkout to push from (bare name, from list_repos). omit for the primary repo."
),
});
/**
* review-family modes (`NON_COMMITTING_MODES`: Review, IncrementalReview,
* Plan) complete by submitting a review or a plan comment — they must never
* write code to a branch. the behavioral mode is chosen at runtime via
* `select_mode`, AFTER the tool set is registered, so the git-write tools
* enforce this at call time rather than being withheld at build time. this is
* the only reachable write path: git credentials are ASKPASS-ephemeral per
* `$git()` call (no ambient credential for a raw shell `git push`), and the
* native git tool blocks `push` outright. an undefined mode (select_mode not
* yet called) is allowed — the block only fires once a review/plan mode is
* committed. `commit_changes` writes via the API, not push, so it needs the
* same gate.
*/
function assertWritableMode(ctx: ToolContext, toolName: string): void {
const mode = ctx.toolState?.selectedMode;
if (mode && NON_COMMITTING_MODES.has(mode)) {
throw new Error(
`${toolName} is blocked in ${mode} mode — review and plan runs must not push or commit code. ` +
`finish by submitting your review (create_pull_request_review) or plan. ` +
`if this PR genuinely needs a code change, that is a separate task from reviewing it.`
);
}
}
/** target guards shared by push_branch and commit_changes: the cross-PR
* backstop and the default-branch block. the default-branch block fires in
* restricted mode (any repo) and for every non-primary secondary — cross-repo
* secondaries are PR-only by design, so a writable secondary must never push
* straight to its default branch even when the primary's `push` is `enabled`. */
function assertPushTarget(
ctx: ToolContext,
params: {
branch: string;
pushDest: PushDestination;
defaultBranch: string;
access: RepoAccess;
}
): void {
const branch = params.branch;
const pushDest = params.pushDest;
// backstop against subagent-induced cross-PR clobbers: a subagent
// shares cwd + toolState with the orchestrator, so its `checkout_pr(N)`
// moves HEAD to pr-N and persists pushDest pointing at the foreign
// PR's remote branch. refuse pr-N → origin/<other> pushes unless this
// run is itself scoped to PR N (zed-industries/cloud, 2026-05-18).
const prBranchMatch = branch.match(/^pr-(\d+)$/);
if (prBranchMatch && pushDest.remoteBranch !== branch) {
const prNumber = Number(prBranchMatch[1]);
const event = ctx.payload.event;
const runScoped = event.is_pr === true && event.issue_number === prNumber;
if (!runScoped) {
throw new Error(
`push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', ` +
`but this run is not scoped to PR #${prNumber}. ` +
`the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent — subagents share the working tree and toolState with the orchestrator). ` +
`you have probably landed your commit on the wrong branch. ` +
`switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. ` +
`if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`
);
}
}
// block default-branch pushes in restricted mode, and always on secondaries
// (cross-repo write checkouts are PR-only — never a direct default-branch push).
const blockDefaultBranch = ctx.payload.push === "restricted" || params.access !== "primary";
if (blockDefaultBranch && pushDest.remoteBranch === params.defaultBranch) {
const where = params.access === "primary" ? "" : ` of secondary repo '${pushDest.remoteName}'`;
throw new Error(
`Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'${where}. ` +
`Create a feature branch and open a PR instead.`
);
}
}
/** run the repo's best-effort prepush hook with the per-run failure latch.
* returns true when the hook was skipped due to an earlier failure.
* `retryTool` names the tool the agent should re-invoke on failure. */
async function runPrepushHook(ctx: ToolContext, retryTool: string): Promise<boolean> {
if (ctx.toolState.prepushFailureCount > 0) {
log.info(`» skipping prepush hook (failed earlier this run)`);
return true;
}
if (!ctx.prepushScript) return false;
const prepushHook = await executeLifecycleHook({
event: "prepush",
script: ctx.prepushScript,
shell: ctx.payload.shell,
});
if (prepushHook.failure) {
ctx.toolState.prepushFailureCount += 1;
throw new Error(
buildPrepushFailureMessage({
failure: prepushHook.failure,
shell: ctx.payload.shell,
retryTool,
})
);
}
return false;
}
// classify an error from `$git("push", ...)` to decide retry vs. recovery
// vs. rethrow. exported for tests.
//
// - `concurrent-push`: server-side compare-and-swap failed because the ref
// advanced between fetch and push. recovery is fetch + integrate + retry.
// matches both the client-side detection (`fetch first` /
// `non-fast-forward`) and the server-side detection (`cannot lock ref`
// with `is at <SHA1> but expected <SHA2>`).
// - `transient`: network or upstream server hiccup (RPC failed mid-stream,
// HTTP 5xx, early EOF, reset, timeout, dns flake). push is idempotent so
// verbatim retry with backoff is safe.
// - `unknown`: anything else (including auth/permission/protected-branch
// rejections). retrying these wastes time; surface to the caller.
//
// kept conservative: a misclassification of `unknown` -> `transient` would
// cause two extra round-trips on a permanently-failing push, while the
// reverse (true transient labeled `unknown`) just falls back to current
// behavior. so we only mark as transient when the error string is
// unambiguously a network/server-side fault, not a refusal.
export type PushErrorKind = "concurrent-push" | "transient" | "transient-auth" | "unknown";
const CONCURRENT_PUSH_PATTERNS = ["fetch first", "non-fast-forward", "cannot lock ref"] as const;
// network/server-side faults — the token is fine, the wire blipped. retry the
// same token with backoff.
const TRANSIENT_PATTERNS: RegExp[] = [
/RPC failed/i,
/early EOF/,
/the remote end hung up unexpectedly/,
/Connection reset/i,
/Could not resolve host/i,
/Operation timed out/i,
/HTTP\/2 stream \d+ was not closed cleanly/i,
/unexpected disconnect while reading sideband packet/i,
// libcurl HTTP 5xx surfaced by git over https. matches both the
// libcurl-style "The requested URL returned error: 502" and the more
// recent "HTTP 502" wording. most 4xx is intentionally excluded —
// 401/403/404 indicate auth/permission problems that are not
// retry-safe — but 429 (rate-limited / abuse detection) IS retry-safe
// and GitHub occasionally surfaces it on git push, so it's included
// explicitly below.
/HTTP 5\d\d/,
/returned error: 5\d\d/i,
/HTTP 429/,
/returned error: 429/i,
];
export function classifyPushError(msg: string): PushErrorKind {
if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p))) return "concurrent-push";
if (TRANSIENT_AUTH_PATTERNS.some((p) => p.test(msg))) return "transient-auth";
if (TRANSIENT_PATTERNS.some((p) => p.test(msg))) return "transient";
return "unknown";
}
// exponential backoff delays before retry attempts 2-6. attempt 1 is the
// original push. used for network transients (5xx/reset/timeout) where the
// token is fine and the wire blipped. auth transients don't wait this out —
// they re-mint the token and retry on a short delay.
const TRANSIENT_RETRY_DELAYS_MS = [2000, 4000, 8000, 16000, 30000];
/**
* push with retry on transient failures. two transient classes:
*
* - network/server-side (5xx, connection reset, timeout — TRANSIENT_PATTERNS):
* the token is valid, so back off and retry the SAME token.
* - installation-token 401 ("Invalid username or token" — TRANSIENT_AUTH_PATTERNS):
* GitHub intermittently hands out a token its git edge never accepts; the
* token is poisoned for its whole life, so re-mint a fresh token via
* `refreshGitToken` and retry with it (the actual cure). when no refresh is
* available (external GH_TOKEN), it falls back to same-token backoff.
*
* concurrent-push and permission rejections are not retried — they need caller
* intervention. shared by push_branch, push_tags, and delete_branch.
*/
async function pushWithRetry(
args: string[],
token: string,
refreshGitToken: ((stale: string) => Promise<string>) | undefined,
cwd: string = process.cwd()
): Promise<void> {
let currentToken = token;
let lastErr: unknown;
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
try {
await $git("push", args, { token: currentToken, cwd });
if (attempt > 0) log.info(`push succeeded on attempt ${attempt + 1}`);
return;
} catch (err) {
lastErr = err;
const msg = err instanceof Error ? err.message : String(err);
const kind = classifyPushError(msg);
if (
(kind === "transient" || kind === "transient-auth") &&
attempt < TRANSIENT_RETRY_DELAYS_MS.length
) {
// auth-class: re-mint a fresh token (the poisoned one never recovers)
// and retry on a short delay. network-class: keep the token, back off.
if (kind === "transient-auth" && refreshGitToken) {
currentToken = await refreshGitToken(currentToken);
const delay = Math.round(1000 * (0.75 + Math.random() * 0.5));
log.info(
`push attempt ${attempt + 1} failed (auth), re-minted token, retrying in ${delay}ms: ${msg.slice(0, 300)}`
);
await new Promise((r) => setTimeout(r, delay));
continue;
}
// jitter avoids lockstep retries when several agents are hit by the
// same upstream blip simultaneously.
const baseDelay = TRANSIENT_RETRY_DELAYS_MS[attempt] ?? 5000;
const delay = Math.round(baseDelay * (0.75 + Math.random() * 0.5));
log.info(
`push attempt ${attempt + 1} failed (transient), retrying in ${delay}ms: ${msg.slice(0, 300)}`
);
await new Promise((r) => setTimeout(r, delay));
continue;
}
throw err;
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
}
export function PushBranchTool(ctx: ToolContext) {
const primaryDefaultBranch = ctx.repo.defaultBranch;
const pushPermission = ctx.payload.push;
return tool({
name: "push_branch",
mutates: true,
description:
"Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
'Example: `push_branch({})` to push the current branch. Example: `push_branch({ branchName: "pr-1" })` to push a specific local branch. ' +
"If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
"The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
"Requires a clean working tree. Runs the repository prepush hook (if configured) — best-effort. If the hook fails, the tool returns the failure output and every subsequent call this run skips the hook. " +
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode. " +
"If the response reports a timeout, the underlying push may have actually succeeded — verify with `git log origin/<branch>` (or this tool with command 'log') before retrying, otherwise you'll push a duplicate.",
parameters: PushBranch,
execute: execute(async ({ branchName, force, repo }) => {
// permission check
if (pushPermission === "disabled") {
throw new Error(
"Push is disabled for this run: the repository is read-only, or the person who triggered it has no write access."
);
}
assertWritableMode(ctx, "push_branch");
const rc = resolveRepoCtx(ctx, repo);
if (rc.access === "read") {
throw new Error(
`push blocked: ${rc.owner}/${rc.name} is read-only (reference-only) in this run.`
);
}
const cwd = rc.dir;
const repoState = requireRepoState(ctx.toolState, rc.owner, rc.name);
// primary's default branch comes from ctx.repo; secondaries store it at checkout_repo.
const defaultBranch =
rc.access === "primary" ? primaryDefaultBranch : repoState.defaultBranch || "main";
const branch =
branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, log: false });
// check the resolved branch too — rev-parse could surface a weird current
// branch name that would otherwise bypass the user-facing check. use
// rejectSpecialRef so "refs/heads/main" and symbolic refs like HEAD
// can't slip past the default-branch guard below.
rejectSpecialRef(branch, "branch");
// signed-commits mode: same-repo commits are created directly on the
// remote by commit_changes, so there is nothing to push. fork PRs keep
// the git push path (the app can't API-commit to a contributor's fork).
// signedCommits is the primary repo's setting — cross-repo secondaries
// keep the normal push path (commit_changes only targets the primary).
if (ctx.signedCommits && rc.access === "primary" && pushesToBaseRepo(ctx)) {
throw new Error(
"push_branch is not used in signed-commits mode — commits land on the remote via the commit_changes tool. " +
"call commit_changes to commit your working-tree changes as a GitHub-signed commit. " +
"if you already called commit_changes, your work is already on the remote — there is nothing left to push."
);
}
// reject push if working tree is dirty — forces agent to commit or discard before pushing
const status = $("git", ["status", "--porcelain"], { cwd, log: false });
if (status) {
throw new Error(
`push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.\n\n` +
`git status:\n${status}` +
(ctx.toolState.prepushFailureCount > 0
? "\n\nnote: the prepush hook failed earlier this run — once the working tree is clean, push_branch will skip the hook."
: "")
);
}
// validate push destination matches expected URL
const pushDest = validatePushDestination(repoState, branch, cwd);
assertPushTarget(ctx, { branch, pushDest, defaultBranch, access: rc.access });
// use refspec when local and remote branch names differ
const refspec =
branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
const pushArgs = force
? ["--force", "-u", pushDest.remoteName, refspec]
: ["-u", pushDest.remoteName, refspec];
// the prepush hook is a primary-repo setting — secondaries skip it.
const prepushSkipped =
rc.access === "primary" ? await runPrepushHook(ctx, "push_branch") : false;
if (rc.access === "primary" && !prepushSkipped && ctx.prepushScript) {
// re-verify clean working tree after prepush. a hook that writes tracked
// files (formatter, type generator, build artifacts) would leave those
// changes uncommitted — pushing now would silently drop them, and the
// agent would report a "successful push" of code the hook had expected
// to be included.
const postHookStatus = $("git", ["status", "--porcelain"], { cwd, log: false });
if (postHookStatus) {
throw new Error(
`push blocked: the prepush hook modified the working tree. those changes are not included in the push. commit or discard them (or change the hook to not mutate tracked files) before retrying.\n\n` +
`git status:\n${postHookStatus}`
);
}
}
log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
if (force) {
log.warning(`force pushing - this will overwrite remote history`);
}
// push is idempotent, so pushWithRetry rides out transient failures
// (5xx, reset, freshly-minted-token 401). concurrent-push is not
// transient — it surfaces here so we can render the integrate-and-retry
// recovery the agent needs.
try {
await pushWithRetry(pushArgs, rc.gitToken, rc.refreshGitToken, cwd);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (classifyPushError(msg) === "concurrent-push") {
// git rebase is blocked through the MCP tool when shell is disabled
// (rebase --exec can execute arbitrary code). merge always works and
// integrates remote changes cleanly, so suggest it as the default.
const integrateStep =
ctx.payload.shell === "disabled"
? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
: `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
throw new Error(
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally (often a concurrent push to the same branch).\n\n` +
`to resolve this:\n` +
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
`${integrateStep}\n` +
`3. resolve any merge conflicts if needed\n` +
`4. retry push_branch`
);
}
throw err;
}
const pushedSha = $("git", ["rev-parse", "HEAD"], { cwd, log: false }).trim();
log.info(
`» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`
);
const baseMsg = `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`;
const message = prepushSkipped
? `${baseMsg} (prepush hook skipped — failed earlier this run).`
: baseMsg;
return {
success: true,
branch,
remoteBranch: pushDest.remoteBranch,
remote: pushDest.remoteName,
force,
prepushSkipped,
message,
};
}),
});
}
/** agent-facing prepush failure message: script output + bypass guidance,
* with no generic lifecycle retry advice (which would conflict). */
function buildPrepushFailureMessage(params: {
failure: LifecycleHookFailure;
shell: ToolContext["payload"]["shell"];
retryTool: string;
}): string {
const failure = params.failure;
const header =
failure.kind === "exit"
? `prepush hook failed with exit code ${failure.exitCode}.\n\nscript output:\n${failure.output || "(empty)"}`
: failure.kind === "timeout"
? `prepush hook timed out — the script is hung or doing too much work.`
: `prepush hook failed to spawn: ${failure.spawnError}.`;
const ifRealBug =
params.shell === "disabled"
? `fix it before pushing again — shell access is disabled in this run, so you can't re-run the hook command yourself.`
: `run the hook command yourself via the shell tool to iterate (${params.retryTool} will NOT re-run it).`;
return (
`${header}\n\n` +
`this repo's prepush hook is best-effort: the next ${params.retryTool} call will SKIP the hook and proceed. ` +
`if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call ${params.retryTool} again. ` +
`if it could be a real bug in your code, ${ifRealBug}`
);
}
/** distinguish "work already landed" and "stranded local commits" from a
* genuinely clean tree — all three end in a clean worktree, and the wrong
* diagnosis sends agents in circles. */
function buildNothingToCommitMessage(pushDest: PushDestination): string {
const base = "nothing to commit — the working tree matches HEAD.";
try {
const remoteTip = $(
"git",
["rev-parse", `refs/remotes/${pushDest.remoteName}/${pushDest.remoteBranch}`],
{ log: false }
).trim();
const head = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
if (remoteTip === head) {
return `${base} your work is already on ${pushDest.remoteName}/${pushDest.remoteBranch} — there is no push step in signed-commits mode.`;
}
$("git", ["merge-base", "--is-ancestor", remoteTip, "HEAD"], { log: false });
return (
`${base} but your local branch has commits that were never pushed — signed-commits mode can't push local commits. ` +
`run git reset --mixed ${remoteTip} (keeps every change in the working tree), then retry commit_changes.`
);
} catch {
return base;
}
}
const CommitChanges = type({
message: type.string.describe("Commit message (first line = subject)"),
files: type.string
.array()
.describe("Optional subset of changed paths to commit. Defaults to every working-tree change.")
.optional(),
});
export function CommitChangesTool(ctx: ToolContext) {
const pushPermission = ctx.payload.push;
return tool({
name: "commit_changes",
mutates: true,
description:
"Commit working-tree changes directly to the remote branch as a GitHub-signed (Verified) commit — this repository has signed commits enabled, so use this INSTEAD of git commit + push_branch. " +
"Edit files locally, then call this tool: it detects every working-tree change (new, modified, deleted files), or commits a subset via `files`. " +
"The commit lands on the remote immediately — there is no separate push step. The remote branch is created automatically on the first commit to a new local branch. " +
"A merge in progress (git merge --no-commit) is concluded as a signed merge commit — resolve conflicts and git add first. " +
"Runs the repository prepush hook (if configured) before committing — best-effort, same skip-on-failure behavior as push_branch.",
parameters: CommitChanges,
timeoutMs: 600_000,
execute: execute(async (params) => {
if (pushPermission === "disabled") {
throw new Error(
"Push is disabled for this run: the repository is read-only, or the person who triggered it has no write access."
);
}
assertWritableMode(ctx, "commit_changes");
const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
if (branch === "HEAD") {
throw new Error(
"HEAD is detached — create or check out a branch before committing (e.g. git checkout -b pullfrog/<description>)."
);
}
rejectSpecialRef(branch, "branch");
// signed commits are a primary-repo concern: the tool operates on the
// run's working directory and the base repo's branch.
const pushDest = validatePushDestination(
primaryRepoState(ctx.toolState),
branch,
process.cwd()
);
if (!pushesToBaseRepo(ctx)) {
throw new Error(
`'${branch}' pushes to the fork '${pushDest.url}', where the app can't create signed commits. ` +
`commit locally via the git tool and use push_branch instead (those commits will be unsigned).`
);
}
assertPushTarget(ctx, {
branch,
pushDest,
defaultBranch: ctx.repo.defaultBranch,
// commit_changes (signed commits) only ever targets the primary repo.
access: "primary",
});
// run the hook before reading file content so formatter/codegen
// effects land inside the commit instead of being silently dropped
const prepushSkipped = await runPrepushHook(ctx, "commit_changes");
const head = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
// a pending merge contributes MERGE_HEAD as a second parent: the API
// commit becomes a true merge commit, so integrating the base branch
// doesn't flatten its commits into the PR diff.
let mergeHead = "";
try {
mergeHead = $("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { log: false }).trim();
} catch {
// no merge in progress
}
let changes = detectWorkingTreeChanges();
if (params.files) {
if (mergeHead) {
throw new Error(
"can't commit a subset of files while a merge is in progress — the merge commit must include every merged change. omit `files`."
);
}
const requested = new Set(params.files);
const known = new Set(changes.map((c) => c.path));
const unknown = [...requested].filter((p) => !known.has(p));
if (unknown.length > 0) {
throw new Error(
`no detected change at: ${unknown.join(", ")} — run git status to list changed paths.`
);
}
changes = changes.filter((c) => requested.has(c.path));
}
// a merge that resolved to HEAD's tree (both sides made the same
// change) still needs its empty merge commit to conclude
if (changes.length === 0 && !mergeHead) {
throw new Error(buildNothingToCommitMessage(pushDest));
}
await assertApiCommittable(changes);
const parents = mergeHead ? [head, mergeHead] : [head];
const result = await createSignedCommit({
token: ctx.gitToken,
owner: ctx.repo.owner,
repo: ctx.repo.name,
remoteBranch: pushDest.remoteBranch,
message: params.message,
parents,
files: changes,
});
// resync the local clone: fetch the new commit, advance the local
// branch to it, refresh the index. worktree files already match the
// committed content, so leftovers from a `files` subset stay visible
// in git status and nothing is lost.
await $git(
"fetch",
[
"--no-tags",
"origin",
`+refs/heads/${pushDest.remoteBranch}:refs/remotes/origin/${pushDest.remoteBranch}`,
],
{ token: ctx.gitToken }
);
$("git", ["update-ref", `refs/heads/${branch}`, result.sha], { log: false });
if (mergeHead) {
$("git", ["merge", "--quit"], { log: false });
}
$("git", ["reset", "-q"], { log: false });
if (result.createdBranch) {
// mirror what `git push -u` would have configured
$("git", ["config", `branch.${branch}.remote`, "origin"], { log: false });
$("git", ["config", `branch.${branch}.merge`, `refs/heads/${pushDest.remoteBranch}`], {
log: false,
});
}
log.info(
`» created signed commit ${result.sha.slice(0, 7)} (${changes.length} file(s)) on ${pushDest.remoteName}/${pushDest.remoteBranch}`
);
return {
success: true,
sha: result.sha,
branch,
remoteBranch: pushDest.remoteBranch,
files: changes.map((c) => (c.deleted ? `D ${c.path}` : c.path)),
createdBranch: result.createdBranch,
verified: true,
prepushSkipped,
message: `created signed commit ${result.sha.slice(0, 7)} on ${pushDest.remoteName}/${pushDest.remoteBranch}${result.createdBranch ? " (remote branch created)" : ""}`,
};
}),
});
}
// commands that require authentication - redirect to dedicated tools.
// exported so tests can exercise the same table the runtime uses.
//
// note: the `pull` redirect intentionally does not mention `rebase` — under
// shell=disabled rebase is itself blocked by NOSHELL_BLOCKED_SUBCOMMANDS, so
// advertising it here would just send the agent into a second block. agents
// under shell=restricted/enabled who prefer rebase can invoke it directly;
// the redirect's job is to name the canonical alternative (merge), which
// works in all modes.
export const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
push: "use the push_branch tool instead — it handles authentication and permission checks.",
fetch: "use the git_fetch tool instead — it handles authentication.",
pull: "use git_fetch to fetch the remote ref, then call this git tool with command 'merge' locally.",
clone: "the repository is already cloned. use checkout_pr for PR branches.",
};
// SECURITY: subcommands blocked when shell is disabled.
// in disabled mode the agent has no shell access, so these subcommands are the
// primary escape vectors for arbitrary code execution. in restricted mode the
// agent already has shell in a stripped sandbox, so blocking these is redundant.
// exported so tests stay in sync with the runtime table.
export const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
submodule:
"Blocked: git submodule can reference malicious repositories and execute code on update.",
"update-index":
"Blocked: git update-index can modify index entries in ways that bypass file protections.",
"filter-branch": "Blocked: git filter-branch executes arbitrary code on repository history.",
replace: "Blocked: git replace can redirect object lookups.",
// subcommands that accept --exec or similar flags for arbitrary code execution
rebase:
"Blocked: git rebase --exec can execute arbitrary shell commands. Use 'merge' instead to integrate remote changes.",
bisect:
"Blocked: git bisect run can execute arbitrary shell commands. Bisect by hand (bisect start/good/bad/reset) is not available through this tool either — ask the user to run the bisect if needed.",
// difftool/mergetool exist to shell out to external diff/merge programs.
// both accept `--extcmd` / `-x` (difftool) or configured tool commands
// (mergetool) that run arbitrary code. NOSHELL_BLOCKED_ARGS catches the
// long `--extcmd` form, but not the `-x` short form — and globally blocking
// `-x` would false-positive on `git cherry-pick -x`. block the subcommands
// wholesale instead; neither has a meaningful use in an automated agent
// workflow (agents use `git diff` / `git show` for diffs and resolve
// conflicts via file edits, not a TUI merge tool).
difftool:
"Blocked: git difftool runs an external diff program via --extcmd/-x or configured tool and can execute arbitrary shell commands. Use 'diff' (or 'show' for single commits) to inspect changes — those output directly and don't invoke an external tool.",
mergetool:
"Blocked: git mergetool runs an external merge program configured via mergetool.<name>.cmd and can execute arbitrary shell commands. Resolve conflicts by editing the files directly (conflict markers are written into the working tree) and then commit.",
};
// SECURITY: subcommand-specific arg flags that execute code.
// only blocked when shell is disabled — in restricted mode the agent already
// has shell access in a stripped sandbox, so these provide no additional security.
//
// NOTE: global git flags like -c and --config-env are NOT included here
// because they only work before the subcommand. in the MCP tool, the
// subcommand is always first, so -c in args is parsed as a subcommand flag
// (e.g., git log -c = combined diff format), not config injection.
// the subcommand check (rejecting "-" prefix) already blocks that attack.
//
// matched as: arg === flag OR arg starts with flag + "="
// (avoids false positives like --exclude matching --exec).
// exported so tests stay in sync with the runtime flag set.
export const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
const COLLAPSE_THRESHOLD = 200;
/** above this, the full body is spilled to a tmp file and only a short head
* preview is logged + returned inline. mirrors `capOutput` in `mcp/shell.ts`
* (which uses 5000 for shell commands; diff/log outputs need more headroom).
* the operator log gets a single summary line instead of a 1000+ line dump. */
const MAX_GIT_OUTPUT_CHARS = 50_000;
const OVERFLOW_PREVIEW_LINES = 50;
/** absolute char cap on the inline preview, in case the first
* `OVERFLOW_PREVIEW_LINES` lines contain a minified blob / binary diff /
* single very long line that would blow the agent's context anyway. */
const OVERFLOW_PREVIEW_MAX_CHARS = 5_000;
/** detect refs in `git diff` args that would produce a symmetric (two-dot)
* diff including the inverse of commits that landed on `<ref>` since the
* branch forked. returns the offending arg + the ref that's ahead + count of
* unmerged commits, or null if the call is safe. silently ignores args that
* aren't refs (paths, pathspecs), three-dot ranges (those are merge-base
* diffs, the correct shape), and any call passing `--merge-base` (git's own
* shorthand for a merge-base diff, also safe). see [run 26545933188](https://github.com/pullfrog/app/actions/runs/26545933188)
* for the failure mode this guards against. */
function detectSymmetricDiffTrap(
args: string[],
cwd: string
): { arg: string; aheadRef: string; ahead: number } | null {
// git's own `--merge-base` flag (2.30+) produces a safe merge-base diff
// regardless of the positional ref; the GHA runner has git 2.54.x.
if (args.includes("--merge-base")) return null;
// ignore everything after `--` (pathspec separator)
const endIdx = args.indexOf("--");
const positionals = (endIdx === -1 ? args : args.slice(0, endIdx)).filter(
(a) => !a.startsWith("-")
);
for (const p of positionals) {
if (p.includes("...")) continue; // three-dot = merge-base diff, safe
// bare ref `A`: implicit second side is HEAD; agent's intent is
// "what my branch changed vs <ref>". fires when <ref> has commits HEAD
// doesn't (branch behind base). diffs against an ancestor (HEAD ahead
// of <ref>) are the legitimate "what did I add since X" case and must
// not be blocked.
//
// two-dot range `A..B`: degenerate when one side is an ancestor of the
// other (the tree diff equals the merge-base diff — safe). only the
// truly-diverged case (BOTH sides have commits the other lacks) pulls
// unwanted inverse-of-progress into the diff. shorthand expansions:
// `A..` → `A..HEAD`, `..A` → `HEAD..A`.
if (p.includes("..")) {
const parts = p.split("..");
if (parts.length !== 2) continue;
const left = parts[0] || "HEAD";
const right = parts[1] || "HEAD";
const leftAhead = countAhead(right, left, cwd);
const rightAhead = countAhead(left, right, cwd);
if (leftAhead === null || rightAhead === null) continue;
// `countAhead` is a reachability walk that stops at the shallow boundary
// and needs no common ancestor, so on a grafted checkout it happily
// reports divergence across the graft — and then every remedy this trap
// prescribes is merge-base-based and guaranteed to fail on that same
// checkout. the symmetric tree diff is the only meaningful answer left,
// so let it through rather than sending the agent into a dead end (#1139).
if (leftAhead > 0 && rightAhead > 0 && hasMergeBase(left, right, cwd)) {
const aheadRef = leftAhead >= rightAhead ? left : right;
return { arg: p, aheadRef, ahead: Math.max(leftAhead, rightAhead) };
}
continue;
}
const ahead = countAhead("HEAD", p, cwd);
if (ahead === null) continue;
if (ahead > 0 && hasMergeBase("HEAD", p, cwd)) return { arg: p, aheadRef: p, ahead };
}
return null;
}
/**
* do the two endpoints share a common ancestor? on a shallow, grafted checkout
* they may not, and every remedy `detectSymmetricDiffTrap` prescribes is
* merge-base-based. see #1139.
*/
function hasMergeBase(a: string, b: string, cwd: string): boolean {
try {
return $("git", ["merge-base", a, b], { cwd, log: false }).trim().length > 0;
} catch {
return false;
}
}
/** `rev-list --count head..base` = commits on `base` not on `head`. returns
* null if either ref is unresolvable (probably a pathspec). */
function countAhead(head: string, base: string, cwd: string): number | null {
try {
const out = $("git", ["rev-list", "--count", `${head}..${base}`], { cwd, log: false }).trim();
const n = parseInt(out, 10);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
/** persist `output` to a tmp file and return an agent-facing string that
* leads with a head preview and ends with a sentinel pointing at the path.
* keeps the operator log to a single summary line for the overflow case. */
function spillGitOutput(params: {
command: string;
args: string[];
output: string;
lineCount: number;
}): { output: string; outputPath: string } {
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR not set");
const outputPath = join(tempDir, `git-${params.command}-${randomUUID().slice(0, 8)}.txt`);
writeFileSync(outputPath, params.output);
const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
const preview =
previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS
? previewByLines
: `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}…`;
log.info(
`» git ${params.command} ${params.args.join(" ")}: ${params.lineCount} lines / ${params.output.length} chars → ${outputPath}`
);
return {
output: `${preview}\n\n... [output truncated; full ${params.lineCount}-line / ${params.output.length}-char body saved to ${outputPath} — read selectively with \`read({ filePath: "${outputPath}" })\`] ...`,
outputPath,
};
}
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
//
// critical attack: git -c "alias.x=!evil-command" x
// -> sets alias "x" to a shell command via -c config injection, then runs it
// -> achieves arbitrary code execution even with shell=disabled
const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
/**
* Two malformed-but-unambiguous shapes the models keep emitting: `args[0]`
* repeating the subcommand, and the whole cmdline pushed into `args` behind
* `command: "git"`. Both were rejected (or, for the second, run as `git git
* log …`) and the models re-emitted the byte-identical call rather than
* adapting — 368 dead round trips over 190 runs in 24h (#1199). The intent is
* knowable, so normalize rather than reject.
*
* The promotion out of `command: "git"` re-clears `subcommandPattern`: without
* that, `args: ["-c", "alias.x=!evil", "x"]` would move a global git option
* into the subcommand slot that pattern exists to guard.
*/
export function normalizeGitInvocation(input: { command: string; args: string[] }): {
command: string;
args: string[];
} {
let command = input.command;
let args = input.args;
while (command === "git" && args[0] !== undefined && subcommandPattern.test(args[0])) {
command = args[0];
args = args.slice(1);
}
// git would otherwise read the duplicate as a pathspec and silently return
// clean/empty output on a dirty tree. a real pathspec stays reachable via the
// documented `args: ["--", "<path>"]` form.
if (args[0]?.toLowerCase() === command.toLowerCase()) args = args.slice(1);
return { command, args };
}
/**
* git subcommands whose exit 1 is a documented result rather than a failure.
* The status code IS the answer and both streams are empty by design, so
* anything that reads "non-zero means broken" reports a successful negative as
* an error. See #1152; `merge-base --is-ancestor` keeps its own branch because
* it returns a boolean rather than a note.
*/
export function describeEmptyResult(command: string, args: string[]): string | null {
if (command === "grep") return "no matches";
if (command === "check-ignore") return "none of the given paths are ignored";
if (command === "show-ref") return "no matching ref";
if (command === "merge-base" && args.includes("--fork-point")) return "no fork point";
if (
(command === "diff" || command === "diff-index" || command === "diff-tree") &&
(args.includes("--quiet") || args.includes("--exit-code"))
) {
return "differences found";
}
return null;
}
const Git = type({
command: type(subcommandPattern).describe("Git command (e.g., 'status', 'log', 'diff')"),
args: type.string.array().describe("Additional arguments for the git command").optional(),
"repo?": type.string.describe(
"cross-repo runs only: run this git command inside the named secondary repo's checkout (bare name, from list_repos). omit for the primary repo."
),
});
export function GitTool(ctx: ToolContext) {
return tool({
name: "git",
description:
"Run a git subcommand. `command` is the subcommand ONLY — never repeat it inside `args`. " +
"`args` is optional; omit it entirely for no-flag invocations like plain `git status`. " +
'Example: `git({ command: "status" })` for plain `git status`. ' +
'Example: `git({ command: "log", args: ["--oneline", "-n", "20"] })`. ' +
'Example: `git({ command: "diff", args: ["--merge-base", "origin/main"] })` — merge-base diff including uncommitted edits (single MCP call). ' +
'Example: `git({ command: "diff", args: ["origin/main...HEAD"] })` — three-dot, committed-only changes vs merge-base. ' +
"For PR-scope diffs ALWAYS use `--merge-base <base>` or three-dot `<base>...HEAD`. " +
"Bare `<base>` and two-dot `<base>..HEAD` are symmetric (working-tree-or-HEAD vs ref): when your branch is behind `<base>` they include the inverse of every commit on `<base>` you lack — pure noise, and this tool will reject those forms when the divergence is detected. " +
"Output >50K chars is spilled to a tmp file; the tool returns a head preview + path you can `read` selectively. " +
"For push/fetch, use the dedicated MCP tools (push_branch, git_fetch). " +
"git pull is not available — use git_fetch then this tool with command 'merge'.",
parameters: Git,
execute: execute(async (params) => {
const invocation = normalizeGitInvocation({
command: params.command,
args: params.args ?? [],
});
const command = invocation.command;
const args = invocation.args;
const cwd = resolveRepoCtx(ctx, params.repo).dir;
// only reachable when args[0] is not a subcommand (a flag, a path); git's
// own `git: 'git' is not a git command` says nothing about our contract.
if (command === "git") {
throw new Error(
"git: 'command' is the subcommand only — pass e.g. " +
'git({ command: "log", args: ["--oneline"] }), not command: "git".'
);
}
const redirect = AUTH_REQUIRED_REDIRECT[command];
if (redirect) {
if (command === "push" && ctx.signedCommits) {
throw new Error(
"git push is not available through this tool — in signed-commits mode use commit_changes instead: it commits your working-tree changes directly to the remote as a GitHub-signed commit (push_branch only applies to fork PRs)."
);
}
throw new Error(`git ${command} is not available through this tool — ${redirect}`);
}
// signed-commits mode: local commits can never reach the remote (the
// app only accepts API-created signed commits via commit_changes), so
// block commit-creating subcommands for same-repo work up front. merge
// stays available with --no-commit so conflict resolution still works;
// commit_changes concludes the pending merge as a signed merge commit.
// fork-PR branches keep plain git semantics (signing is impossible there).
if (ctx.signedCommits && (command === "commit" || command === "merge")) {
if (pushesToBaseRepo(ctx)) {
if (command === "commit") {
throw new Error(
"git commit is blocked in signed-commits mode — use the commit_changes tool instead. " +
"it commits your working-tree changes directly to the remote as a GitHub-signed (Verified) commit. " +
"if you are concluding a merge, stage the resolutions with git add and call commit_changes — no local commit is needed."
);
}
const noLocalCommit = args.some(
(a) => a === "--no-commit" || a === "--abort" || a === "--quit"
);
if (!noLocalCommit) {
throw new Error(
"bare git merge would create a local commit, which can't be pushed in signed-commits mode. " +
"use git merge --no-commit <ref>, resolve any conflicts, git add the results, then call commit_changes — " +
"it concludes the merge as a signed merge commit."
);
}
}
}
// SECURITY: block dangerous subcommands when shell is disabled.
// in restricted mode the agent has shell in a stripped sandbox, so blocking
// these through the MCP tool is redundant (agent can do it via shell).
if (ctx.payload.shell === "disabled") {
const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[command];
if (blocked) {
throw new Error(blocked);
}
// block subcommand-specific flags that execute arbitrary code
for (const arg of args) {
const isBlocked = NOSHELL_BLOCKED_ARGS.some(
(flag) => arg === flag || arg.startsWith(flag + "=")
);
if (isBlocked) {
throw new Error(
`Blocked: '${arg}' flag can execute arbitrary code and is not allowed.`
);
}
}
}
// reject symmetric (two-dot or bare-ref) diffs whose endpoints have
// commits each other doesn't — those include the *inverse* of every
// commit on the diverged side, ballooning the diff and confusing
// reviewer subagents. three-dot (`A...B`) and `--merge-base` are
// always allowed (both produce merge-base diffs).
if (command === "diff") {
const trap = detectSymmetricDiffTrap(args, cwd);
if (trap) {
throw new Error(
`git diff '${trap.arg}' would include the inverse of ${trap.ahead} commit(s) on '${trap.aheadRef}' that aren't on the other side — that's a symmetric tree diff full of upstream noise, not your branch's own changes.\n\n` +
`use one of:\n` +
` - git diff --merge-base ${trap.aheadRef} (one MCP call; merge-base diff, includes uncommitted edits)\n` +
` - git diff ${trap.aheadRef}...HEAD (three-dot; merge-base diff of committed-only changes)\n\n` +
`if you ALSO need the PR's pre-formatted diff, the orchestrator's checkout_pr response includes a \`diffPath\` you can \`read\` directly without invoking git at all.`
);
}
}
// `git merge-base --is-ancestor` uses exit codes as data: 0 = ancestor,
// 1 = not-an-ancestor, >1 = real error. Surface the binary answer
// instead of throwing on exit 1. see #766.
if (command === "merge-base" && args.includes("--is-ancestor")) {
let isAncestor = true;
$("git", [command, ...args], {
cwd,
log: false,
onError: (r) => {
if (r.status === 1) {
isAncestor = false;
return;
}
const detail = [r.stderr, r.stdout]
.map((s) => s.trim())
.filter(Boolean)
.join("\n");
throw new Error(
`git merge-base --is-ancestor failed (exit ${r.status}): ${detail || "Unknown error"}`
);
},
});
return { success: true, isAncestor };
}
// exit 1 from these carries the ANSWER, and both streams are
// deliberately empty, so `$()`'s `detail || "Unknown error"` fallback
// handed the model the word "error" for a successful negative result.
// that cost a turn every time and, worse, let a review conclude it had
// been unable to check rather than that it checked and found nothing
// (#1152). same idea as the `--is-ancestor` branch above, generalized.
const emptyResult = describeEmptyResult(command, args);
let noResult = false;
const output = $("git", [command, ...args], {
cwd,
log: false,
env: WORKING_TREE_GIT_ENV,
onError: (r) => {
if (r.status === 1 && emptyResult && !r.stderr.trim() && !r.stdout.trim()) {
noResult = true;
return;
}
const detail = [r.stderr, r.stdout]
.map((s) => s.trim())
.filter(Boolean)
.join("\n");
throw new Error(
`Command failed with exit code ${r.status}: ${detail || `no output from \`git ${command} ${args.join(" ")}\``}`
);
},
});
if (noResult) return { success: true, output: "", note: emptyResult };
const lineCount = output.split("\n").length;
if (output.length > MAX_GIT_OUTPUT_CHARS) {
const spilled = spillGitOutput({ command, args, output, lineCount });
return { success: true, output: spilled.output, outputPath: spilled.outputPath };
}
if (lineCount > COLLAPSE_THRESHOLD) {
log.group(`git ${command} output (${lineCount} lines)`, () => {
log.info(output);
});
} else if (output) {
log.info(output);
}
return { success: true, output };
}),
});
}
const GitFetch = type({
ref: type.string.describe("Ref to fetch: branch name, tag, or 'pull/N/head' for PRs"),
depth: type.number.describe("Fetch depth (for shallow clones)").optional(),
"repo?": type.string.describe(
"cross-repo runs only: fetch inside the named secondary repo's checkout (bare name, from list_repos). omit for the primary repo."
),
});
export function GitFetchTool(ctx: ToolContext) {
return tool({
name: "git_fetch",
description:
"Fetch refs from remote repository. Use this instead of git fetch directly. " +
'Example: `git_fetch({ ref: "main" })`. With depth: `git_fetch({ ref: "pull/1234/head", depth: 1 })`.',
parameters: GitFetch,
execute: execute(async (params) => {
rejectIfLeadingDash(params.ref, "ref");
const rc = resolveRepoCtx(ctx, params.repo);
const fetchArgs = ["--no-tags", "origin", params.ref];
if (params.depth !== undefined) {
fetchArgs.push(`--depth=${params.depth}`);
}
await $gitFetchWithDeepen(
fetchArgs,
{ token: rc.gitToken, cwd: rc.dir, refreshGitToken: rc.refreshGitToken },
"git_fetch"
);
return { success: true, ref: params.ref };
}),
});
}
const DeleteBranch = type({
branchName: type.string.describe("Remote branch to delete"),
});
export function DeleteBranchTool(ctx: ToolContext) {
const pushPermission = ctx.payload.push;
const defaultBranch = ctx.repo.defaultBranch;
return tool({
name: "delete_branch",
mutates: true,
description:
"Delete a remote branch. Requires push: enabled permission. " +
"Deletion of the repository's default branch is always blocked regardless of permission mode.",
parameters: DeleteBranch,
execute: execute(async (params) => {
if (pushPermission !== "enabled") {
throw new Error(
"Branch deletion requires push: enabled permission. " +
"Current mode only allows pushing to non-protected branches."
);
}
assertWritableMode(ctx, "delete_branch");
// delete_branch is already gated on push: enabled, but also block the
// refs/heads/... and symbolic-ref forms so this tool can't be tricked
// into deleting a protected ref that wouldn't match a bare-name check.
rejectSpecialRef(params.branchName, "branchName");
// defense-in-depth: deleting the default branch is catastrophic and
// unlike pushing to main it has no easy revert path (GitHub retains
// refs for 30 days but restoring requires the reflog or a direct SHA).
// push: enabled authorizes pushes, not wholesale removal of the
// repository's primary branch. block it locally even if GitHub branch
// protection would also reject — some repos disable protection on
// default branches and we should not rely on that config for safety.
if (params.branchName === defaultBranch) {
throw new Error(
`Blocked: cannot delete the default branch '${defaultBranch}'. ` +
`If you really need to delete or rename it, do it manually via the repository settings.`
);
}
// use refs/heads/<name> explicitly so a same-named tag can't be deleted
// by accident. `push --delete <bare-name>` resolves against both remote
// branches and tags; a tag-only match would silently remove the tag.
// rejectSpecialRef guarantees branchName is a bare name, so the
// branchName construction here can't collide with user-supplied refs.
await pushWithRetry(
["origin", "--delete", `refs/heads/${params.branchName}`],
ctx.gitToken,
ctx.refreshGitToken
);
log.info(`» deleted branch ${params.branchName}`);
return { success: true, deleted: params.branchName };
}),
});
}
const PushTags = type({
tag: type.string.describe("Tag name to push"),
force: type.boolean.describe("Force push the tag").default(false),
});
export function PushTagsTool(ctx: ToolContext) {
const pushPermission = ctx.payload.push;
return tool({
name: "push_tags",
mutates: true,
description: "Push a tag to remote. Requires push: enabled permission.",
parameters: PushTags,
execute: execute(async (params) => {
if (pushPermission !== "enabled") {
throw new Error(
"Tag pushing requires push: enabled permission. " +
"Current mode only allows pushing branches."
);
}
assertWritableMode(ctx, "push_tags");
validateTagName(params.tag);
const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`];
await pushWithRetry(pushArgs, ctx.gitToken, ctx.refreshGitToken);
log.info(`» pushed tag ${params.tag}`);
return { success: true, tag: params.tag };
}),
});
}