import { type } from "arktype"; import { log } from "../utils/cli.ts"; import { $git } from "../utils/gitAuth.ts"; import { $ } from "../utils/shell.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. * uses git's native @{push} resolution, falls back to origin if unset. * * for branches created via checkout_pr: uses configured pushRemote/merge * for new branches (git checkout -b): falls back to origin/ */ function getPushDestination(branch: string): PushDestination { // try git's @{push} resolution first (works for checkout_pr branches) try { const pushRef = $( "git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{push}`], { log: false } ).trim(); // pushRef is like "origin/main" or "pr-123/feature/foo" // parse carefully to handle branch names with slashes const slashIndex = pushRef.indexOf("/"); if (slashIndex === -1) { throw new Error(`unexpected push ref format: ${pushRef}`); } const remoteName = pushRef.slice(0, slashIndex); const remoteBranch = pushRef.slice(slashIndex + 1); // get the actual URL git would push to (handles remote.X.pushurl) const url = $("git", ["remote", "get-url", "--push", remoteName], { log: false }).trim(); return { remoteName, remoteBranch, url }; } catch { // @{push} not configured - branch was created locally without checkout_pr // fall back to origin with the same branch name log.debug(`no push tracking for ${branch}, falling back to origin/${branch}`); const url = $("git", ["remote", "get-url", "--push", "origin"], { 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(); } type ValidatePushParams = { branch: string; pushUrl: string; }; /** * validate that the push destination matches expected URL. * pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo). */ function validatePushDestination(params: ValidatePushParams): PushDestination { const dest = getPushDestination(params.branch); if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) { throw new Error( `Push blocked: destination does not match expected repository.\n` + `Expected: ${params.pushUrl}\n` + `Actual: ${dest.url}\n` + `Git configuration may have been tampered with.` ); } 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), }); export function PushBranchTool(ctx: ToolContext) { const defaultBranch = ctx.repo.data.default_branch || "main"; const pushPermission = ctx.payload.push; return tool({ name: "push_branch", description: "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.", parameters: PushBranch, execute: execute(async ({ branchName, force }) => { // permission check if (pushPermission === "disabled") { throw new Error("Push is disabled. This repository is configured for read-only access."); } const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); // validate push destination matches expected URL const pushUrl = ctx.toolState.pushUrl; if (!pushUrl) { throw new Error("pushUrl not set - setupGit must run before push_branch"); } const pushDest = validatePushDestination({ branch, pushUrl }); // block pushes to default branch in restricted mode if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) { throw new Error( `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. ` + `Create a feature branch and open a PR instead.` ); } // 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]; log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`); if (force) { log.warning(`force pushing - this will overwrite remote history`); } $git("push", pushArgs, { token: ctx.gitToken, restricted: ctx.payload.bash === "restricted", }); return { success: true, branch, remoteBranch: pushDest.remoteBranch, remote: pushDest.remoteName, force, message: `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`, }; }), }); } // commands that require authentication - redirect to dedicated tools const AUTH_REQUIRED_REDIRECT: Record = { push: "Use push_branch tool instead.", fetch: "Use git_fetch tool instead.", pull: "Use git_fetch + git merge instead.", clone: "Repository already cloned. Use checkout_pr for PR branches.", }; const Git = type({ subcommand: type.string.describe("Git subcommand (e.g., 'status', 'log', 'diff')"), args: type.string.array().describe("Additional arguments for the git command").optional(), }); export function GitTool(_ctx: ToolContext) { return tool({ name: "git", description: "Run git commands. For push/fetch/pull, use the dedicated MCP tools instead (push_branch, git_fetch).", parameters: Git, execute: execute(async (params) => { const subcommand = params.subcommand; const args = params.args ?? []; const redirect = AUTH_REQUIRED_REDIRECT[subcommand]; if (redirect) { throw new Error(`git ${subcommand} requires authentication. ${redirect}`); } const output = $("git", [subcommand, ...args]); 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(), }); export function GitFetchTool(ctx: ToolContext) { return tool({ name: "git_fetch", description: "Fetch refs from remote repository. Use this instead of git fetch directly.", parameters: GitFetch, execute: execute(async (params) => { const fetchArgs = ["--no-tags", "origin", params.ref]; if (params.depth !== undefined) { fetchArgs.push(`--depth=${params.depth}`); } $git("fetch", fetchArgs, { token: ctx.gitToken, restricted: ctx.payload.bash === "restricted", }); 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; return tool({ name: "delete_branch", description: "Delete a remote branch. Requires push: enabled permission.", 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." ); } $git("push", ["origin", "--delete", params.branchName], { token: ctx.gitToken, restricted: ctx.payload.bash === "restricted", }); 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", 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." ); } const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`]; $git("push", pushArgs, { token: ctx.gitToken, restricted: ctx.payload.bash === "restricted", }); return { success: true, tag: params.tag }; }), }); }