/**
* git-read — read-only git inspection tools for pi.
*
* Registers git_show, git_diff, and git_log: the minimal surface a review or
* exploration agent needs from git history without any write path. All three
* share the file-search output discipline (bounded preview, complete output
* persisted up to 10 MiB) and every user-controlled argument is validated
* before it reaches a child process, so a revision or path can never inject
* flags or escape the repository. The tools are child-safe by design and are
* classified in CHILD_SAFE_PACKAGE_TOOL_NAMES so review/advisor subagents can
* read diffs their tool boundary otherwise excludes (issue #61).
*/
import * as fs from "node:fs";
import * as path from "node:path";
import type {
AgentToolResult,
ExtensionAPI,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Cause, Effect, Exit } from "effect";
import { Type } from "typebox";
import { formatCapturedOutput } from "../file-search/src/output.ts";
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
import {
OPENPI_TOOL_SURFACE,
patchOwnedTools,
} from "../shared/tool-surface.ts";
import {
buildDiffArgs,
buildLogArgs,
buildShowArgs,
type GitDiffParams,
type GitLogParams,
type GitShowParams,
InvalidPathError,
InvalidRevisionError,
} from "./src/args.ts";
import { type GitOutcome, runGit } from "./src/process.ts";
import {
GIT_DIFF_PARAMETER_DESCRIPTIONS,
GIT_DIFF_PROMPT_GUIDELINES,
GIT_DIFF_PROMPT_SNIPPET,
GIT_DIFF_TOOL_DESCRIPTION,
GIT_LOG_PARAMETER_DESCRIPTIONS,
GIT_LOG_PROMPT_GUIDELINES,
GIT_LOG_PROMPT_SNIPPET,
GIT_LOG_TOOL_DESCRIPTION,
GIT_SHOW_PARAMETER_DESCRIPTIONS,
GIT_SHOW_PROMPT_GUIDELINES,
GIT_SHOW_PROMPT_SNIPPET,
GIT_SHOW_TOOL_DESCRIPTION,
} from "./src/prompt.ts";
interface GitToolDetails {
readonly command: string;
readonly truncated: boolean;
readonly lineCount?: number;
readonly fullOutputPath?: string;
}
function causeMessage(cause: unknown): string {
if (
cause instanceof InvalidRevisionError ||
cause instanceof InvalidPathError
) {
return cause.message;
}
if (cause instanceof Error) return cause.message;
return String(cause);
}
function unwrapToolExit(exit: Exit.Exit, tool: string) {
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error(`${tool} was cancelled.`);
}
throw new Error(causeMessage(Cause.squash(exit.cause)));
}
function gitResult(
outcome: GitOutcome,
command: string,
): AgentToolResult {
const formatted = formatCapturedOutput(outcome.output);
return {
content: [
{
type: "text",
text: sanitizeTerminalText(formatted.text) || "(no output)",
},
],
details: {
command,
truncated: formatted.truncated,
lineCount: formatted.lineCount,
fullOutputPath: formatted.fullOutputPath,
},
};
}
function showParameters() {
return Type.Object({
revision: Type.String({
description: GIT_SHOW_PARAMETER_DESCRIPTIONS.revision,
}),
path: Type.Optional(
Type.String({ description: GIT_SHOW_PARAMETER_DESCRIPTIONS.path }),
),
});
}
function diffParameters() {
return Type.Object({
from: Type.Optional(
Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.from }),
),
to: Type.Optional(
Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.to }),
),
staged: Type.Optional(
Type.Boolean({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.staged }),
),
stat: Type.Optional(
Type.Boolean({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.stat }),
),
path: Type.Optional(
Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.path }),
),
});
}
function logParameters() {
return Type.Object({
revision: Type.Optional(
Type.String({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.revision }),
),
file: Type.Optional(
Type.String({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.file }),
),
limit: Type.Optional(
Type.Integer({
description: GIT_LOG_PARAMETER_DESCRIPTIONS.limit,
minimum: 1,
maximum: 1000,
}),
),
oneline: Type.Optional(
Type.Boolean({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.oneline }),
),
});
}
function displayRevision(value: string | undefined, fallback = "HEAD") {
return value === undefined || value.trim() === "" ? fallback : value.trim();
}
export default function gitReadTools(pi: ExtensionAPI) {
const resultDirectories = new Set();
const rememberOutput = (outcome: GitOutcome) => {
if (outcome.output.fullOutputPath) {
resultDirectories.add(path.dirname(outcome.output.fullOutputPath));
}
};
pi.on("session_start", () => {
patchOwnedTools(pi, "gitRead", {
enable: OPENPI_TOOL_SURFACE.gitRead.entry,
});
});
pi.on("session_shutdown", () => {
for (const directory of resultDirectories) {
try {
fs.rmSync(directory, { recursive: true, force: true });
} catch {
// Temporary git artifacts are best-effort cleanup.
}
}
resultDirectories.clear();
});
pi.registerTool, GitToolDetails>({
name: "git_show",
label: "Git Show",
description: GIT_SHOW_TOOL_DESCRIPTION,
promptSnippet: GIT_SHOW_PROMPT_SNIPPET,
promptGuidelines: GIT_SHOW_PROMPT_GUIDELINES,
parameters: showParameters(),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
const args = buildShowArgs(params as GitShowParams);
const outcome = yield* runGit(args, ctx.cwd);
rememberOutput(outcome);
return gitResult(outcome, args.join(" "));
}),
signal ? { signal } : undefined,
);
return unwrapToolExit(exit, "git_show");
},
renderCall(args) {
return new Text(`git show ${displayRevision(args.revision)}`, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details;
let text = details?.command
? `showed ${details.lineCount ?? 0} lines`
: "shown";
if (details?.truncated) text += " (truncated)";
if (expanded)
text += expandedResultPreview(result, details?.fullOutputPath, theme);
return new Text(text, 0, 0);
},
});
pi.registerTool, GitToolDetails>({
name: "git_diff",
label: "Git Diff",
description: GIT_DIFF_TOOL_DESCRIPTION,
promptSnippet: GIT_DIFF_PROMPT_SNIPPET,
promptGuidelines: GIT_DIFF_PROMPT_GUIDELINES,
parameters: diffParameters(),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
const args = buildDiffArgs(params as GitDiffParams);
const outcome = yield* runGit(args, ctx.cwd);
rememberOutput(outcome);
return gitResult(outcome, args.join(" "));
}),
signal ? { signal } : undefined,
);
return unwrapToolExit(exit, "git_diff");
},
renderCall(args) {
const from = displayRevision(
args.from,
args.staged ? "HEAD (staged)" : "index",
);
const to = args.to ? displayRevision(args.to) : "worktree";
let text = `git diff ${from} → ${to}`;
if (args.stat) text += " (stat)";
if (args.path) text += ` ${args.path}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details;
let text = details?.command
? `${details.lineCount ?? 0} diff lines`
: "diffed";
if (details?.truncated) text += " (truncated)";
if (expanded)
text += expandedResultPreview(result, details?.fullOutputPath, theme);
return new Text(text, 0, 0);
},
});
pi.registerTool, GitToolDetails>({
name: "git_log",
label: "Git Log",
description: GIT_LOG_TOOL_DESCRIPTION,
promptSnippet: GIT_LOG_PROMPT_SNIPPET,
promptGuidelines: GIT_LOG_PROMPT_GUIDELINES,
parameters: logParameters(),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
const args = buildLogArgs(params as GitLogParams);
const outcome = yield* runGit(args, ctx.cwd);
rememberOutput(outcome);
return gitResult(outcome, args.join(" "));
}),
signal ? { signal } : undefined,
);
return unwrapToolExit(exit, "git_log");
},
renderCall(args) {
let text = `git log ${displayRevision(args.revision)}`;
if (args.file) text += ` -- ${args.file}`;
if (args.limit !== undefined) text += ` -n ${args.limit}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details;
let text = details?.command
? `${details.lineCount ?? 0} output lines`
: "logged";
if (details?.truncated) text += " (truncated)";
if (expanded)
text += expandedResultPreview(result, details?.fullOutputPath, theme);
return new Text(text, 0, 0);
},
});
}
const EXPANDED_PREVIEW_LINES = 20;
export function expandedResultPreview(
result: { content: { type: string; text?: string }[] },
fullOutputPath: string | undefined,
theme: { fg(color: string, text: string): string },
) {
let text = "";
const content = result.content[0];
if (content?.type === "text" && content.text) {
const lines = sanitizeTerminalText(content.text).split("\n");
for (const line of lines.slice(0, EXPANDED_PREVIEW_LINES)) {
text += `\n${theme.fg("dim", line)}`;
}
if (lines.length > EXPANDED_PREVIEW_LINES) {
text += `\n${theme.fg("muted", `... ${lines.length - EXPANDED_PREVIEW_LINES} more lines`)}`;
}
}
if (fullOutputPath) {
text += `\n${theme.fg("dim", `Full output: ${sanitizeTerminalText(fullOutputPath)}`)}`;
}
return text;
}