Project Files
src / tools / git-grep-tool.ts
import { tool } from "@lmstudio/sdk"
import { simpleGit } from "simple-git"
import { z } from "zod"
import { formatGitError } from "../git/error-formatting"
import { parseGrepOutput } from "../git/grep-parsing"
import { gitSafeString } from "../git/parameter-schemas"
import { resolveAndValidateRepoPath } from "../path/validation"
import type { Tool, ToolsProviderController } from "@lmstudio/sdk"
/**
* Maximum size in bytes of the raw `git grep` output to load into memory.
*
* @const {number}
* @default 10485760
*/
const maxOutputBytes = 10 * 1024 * 1024
/**
* Maximum number of matches a single paginated search can return.
*
* @const {number}
* @default 10000
*/
const maxMatchLimit = 10_000
/**
* Maximum allowed value for the offset parameter.
*
* @const {number}
* @default 10000000
*/
const maxOffset = 10_000_000
/**
* Maximum allowed character length for path strings (repoName and pathspec).
*
* @const {number}
* @default 4096
*/
const maxFilePathLength = 4096
/**
* Maximum allowed character length for the search pattern.
*
* @const {number}
* @default 1024
*/
const maxPatternLength = 1024
/**
* Create the git grep tool.
*
* @param ctl Tools provider controller supplied by the LM Studio SDK.
* @returns The configured git grep tool.
*/
export function createGitGrepTool(ctl: ToolsProviderController): Tool {
return tool({
name: "git_grep",
description:
"Search the contents of tracked files in a cloned git repository for a text or regular-expression pattern. Use this to locate symbols, strings, or other patterns across the repository.",
parameters: {
repoName: gitSafeString("repoName", maxFilePathLength).describe("Directory name of the cloned git repository."),
pattern: gitSafeString("pattern", maxPatternLength).describe(
"The text or regular-expression pattern to search for. Treated as a basic regular expression unless fixedStrings is true."
),
pathspec: gitSafeString("pathspec", maxFilePathLength)
.optional()
.describe(
"Optional git pathspec pattern to narrow the search relative to the repository root. Valid pathspecs include exact filenames, directory paths, or a wildcard pattern. Omit the pathspec to search all tracked files in the repository."
),
caseInsensitive: z.boolean().optional().default(false).describe("If true, perform a case-insensitive match."),
fixedStrings: z
.boolean()
.optional()
.default(false)
.describe("If true, treat the pattern as a literal string instead of a regular expression."),
wordRegexp: z
.boolean()
.optional()
.default(false)
.describe("If true, only match the pattern at word boundaries (whole-word match)."),
offset: z
.number()
.int()
.min(0)
.max(maxOffset)
.optional()
.describe("0-based index of the first match to return for pagination. Omit to start from the first match."),
limit: z
.number()
.int()
.min(1)
.max(maxMatchLimit)
.optional()
.describe(`Maximum number of matches to return (max ${maxMatchLimit}). Omit to return all matching lines.`),
},
/**
* Executes `git grep`, optionally filtered by pathspec, and returns paginated JSON match results.
*
* @param arguments_ Validated tool parameters.
* @param arguments_.repoName Repository directory name relative to the working directory.
* @param arguments_.pattern Pattern to search for.
* @param arguments_.pathspec Optional git pathspec narrowing the search.
* @param arguments_.caseInsensitive When true, perform a case-insensitive match.
* @param arguments_.fixedStrings When true, treat the pattern as a literal string.
* @param arguments_.wordRegexp When true, restrict matches to word boundaries.
* @param arguments_.offset 0-based index to start reading from in the match list.
* @param arguments_.limit Maximum number of matches to return.
* @param context Runtime tool context supplied by the SDK.
* @returns The match listing as JSON, or a clear error string.
*/
implementation: async (arguments_, context) => {
const { repoName, pattern, pathspec, offset, limit } = arguments_
const caseInsensitive = arguments_.caseInsensitive === true
const fixedStrings = arguments_.fixedStrings === true
const wordRegexp = arguments_.wordRegexp === true
const repoPath = resolveAndValidateRepoPath(repoName, ctl.getWorkingDirectory())
const git = simpleGit(repoPath)
const target = typeof pathspec === "string" ? `"${repoName}" matching "${pathspec}"` : `"${repoName}"`
context.status(`Searching for "${pattern}" in ${target}…`)
const grepArguments = ["grep", "--no-color", "-n", "-z", "-I", "--full-name"]
if (caseInsensitive) {
grepArguments.push("-i")
}
if (fixedStrings) {
grepArguments.push("-F")
}
if (wordRegexp) {
grepArguments.push("-w")
}
grepArguments.push("-e", pattern, "--")
if (typeof pathspec === "string") {
grepArguments.push(pathspec)
}
try {
const raw = await git.raw(grepArguments)
const outputBytes = Buffer.byteLength(raw, "utf8")
if (outputBytes > maxOutputBytes) {
return `Error: git_grep output for "${repoName}" exceeds the maximum allowed size of ${maxOutputBytes} bytes (${outputBytes} bytes). Narrow the search with a more specific pathspec or pattern.`
}
const matches = parseGrepOutput(raw)
const totalCount = matches.length
const start = offset === undefined ? 0 : Math.min(offset, totalCount)
const end = limit === undefined ? totalCount : Math.min(start + limit, totalCount)
return JSON.stringify(
{
matches: matches.slice(start, end),
total_count: totalCount,
from: start + 1,
to: end,
},
undefined,
2
)
} catch (error) {
return formatGitError("git_grep", repoName, error)
}
},
})
}