mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
233 lines
6.9 KiB
TypeScript
233 lines
6.9 KiB
TypeScript
import { execFile } from "child_process";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { promisify } from "util";
|
|
import { TEXT_PREVIEW_MAX_BYTES } from "./file-types";
|
|
import type {
|
|
GitFileDiffResponse,
|
|
GitFileStatus,
|
|
GitStatusResponse,
|
|
} from "./git-types";
|
|
import {
|
|
classifyGitStatus,
|
|
parseGitPorcelainV1,
|
|
type GitPorcelainEntry,
|
|
} from "./git-status";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const GIT_TIMEOUT_MS = 10_000;
|
|
const GIT_STATUS_MAX_BUFFER = 8 * 1024 * 1024;
|
|
|
|
async function git(cwd: string, args: string[], maxBuffer = GIT_STATUS_MAX_BUFFER): Promise<string> {
|
|
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
timeout: GIT_TIMEOUT_MS,
|
|
maxBuffer,
|
|
env: { ...process.env, LC_ALL: "C" },
|
|
});
|
|
return stdout;
|
|
}
|
|
|
|
async function findRepositoryRoot(cwd: string): Promise<string | null> {
|
|
try {
|
|
return (await git(cwd, ["rev-parse", "--show-toplevel"])).trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isWithinPath(parent: string, target: string): boolean {
|
|
const relative = path.relative(path.resolve(parent), path.resolve(target));
|
|
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function toGitPath(filePath: string): string {
|
|
return filePath.split(path.sep).join("/");
|
|
}
|
|
|
|
async function readStatusEntries(repositoryRoot: string): Promise<GitPorcelainEntry[]> {
|
|
const output = await git(repositoryRoot, [
|
|
"status",
|
|
"--porcelain=v1",
|
|
"-z",
|
|
"--untracked-files=all",
|
|
]);
|
|
return parseGitPorcelainV1(output);
|
|
}
|
|
|
|
async function readTrackedLineStats(
|
|
repositoryRoot: string,
|
|
cwd: string,
|
|
): Promise<{ additions: number; deletions: number }> {
|
|
const relativeCwd = toGitPath(path.relative(repositoryRoot, cwd));
|
|
const pathspec = relativeCwd || ".";
|
|
try {
|
|
const output = await git(repositoryRoot, [
|
|
"diff",
|
|
"--no-color",
|
|
"--no-ext-diff",
|
|
"--numstat",
|
|
"HEAD",
|
|
"--",
|
|
pathspec,
|
|
]);
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
for (const line of output.split(/\r?\n/)) {
|
|
if (!line) continue;
|
|
const [added, deleted] = line.split("\t", 2);
|
|
const addedCount = Number(added);
|
|
const deletedCount = Number(deleted);
|
|
if (Number.isInteger(addedCount)) additions += addedCount;
|
|
if (Number.isInteger(deletedCount)) deletions += deletedCount;
|
|
}
|
|
return { additions, deletions };
|
|
} catch {
|
|
return { additions: 0, deletions: 0 };
|
|
}
|
|
}
|
|
|
|
function countUntrackedTextLines(filePath: string): number {
|
|
try {
|
|
const stat = fs.lstatSync(filePath);
|
|
if (!stat.isFile() || stat.size > TEXT_PREVIEW_MAX_BYTES) return 0;
|
|
const content = fs.readFileSync(filePath);
|
|
if (hasNullByte(content) || content.length === 0) return 0;
|
|
const text = content.toString("utf8");
|
|
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export async function getGitStatus(cwd: string): Promise<GitStatusResponse> {
|
|
const repositoryRoot = await findRepositoryRoot(cwd);
|
|
if (!repositoryRoot) {
|
|
return {
|
|
isGitRepository: false,
|
|
repositoryRoot: null,
|
|
files: [],
|
|
additions: 0,
|
|
deletions: 0,
|
|
};
|
|
}
|
|
|
|
const [entries, trackedLineStats] = await Promise.all([
|
|
readStatusEntries(repositoryRoot),
|
|
readTrackedLineStats(repositoryRoot, cwd),
|
|
]);
|
|
const files = entries.flatMap((entry): GitFileStatus[] => {
|
|
const filePath = path.resolve(repositoryRoot, entry.path);
|
|
if (!isWithinPath(cwd, filePath)) return [];
|
|
const classified = classifyGitStatus(entry);
|
|
return [{
|
|
filePath,
|
|
...classified,
|
|
indexStatus: entry.indexStatus,
|
|
worktreeStatus: entry.worktreeStatus,
|
|
}];
|
|
});
|
|
const untrackedAdditions = files.reduce(
|
|
(total, file) => total + (file.status === "untracked" ? countUntrackedTextLines(file.filePath) : 0),
|
|
0,
|
|
);
|
|
|
|
return {
|
|
isGitRepository: true,
|
|
repositoryRoot,
|
|
files,
|
|
additions: trackedLineStats.additions + untrackedAdditions,
|
|
deletions: trackedLineStats.deletions,
|
|
};
|
|
}
|
|
|
|
function hasNullByte(content: Buffer): boolean {
|
|
return content.includes(0);
|
|
}
|
|
|
|
function createAddedFilePatch(gitPath: string, content: string): string {
|
|
const hasTrailingNewline = content.endsWith("\n");
|
|
const lines = content.split("\n");
|
|
if (hasTrailingNewline) lines.pop();
|
|
const body = lines.map((line) => `+${line}`).join("\n");
|
|
const noNewlineMarker = !hasTrailingNewline && lines.length > 0
|
|
? "\n\\ No newline at end of file"
|
|
: "";
|
|
return [
|
|
`diff --git a/${gitPath} b/${gitPath}`,
|
|
"new file mode 100644",
|
|
"--- /dev/null",
|
|
`+++ b/${gitPath}`,
|
|
`@@ -0,0 +1,${lines.length} @@`,
|
|
`${body}${noNewlineMarker}`,
|
|
].join("\n");
|
|
}
|
|
|
|
async function createTrackedFilePatch(
|
|
repositoryRoot: string,
|
|
relativePath: string,
|
|
originalPath?: string,
|
|
): Promise<string | null> {
|
|
const paths = originalPath && originalPath !== relativePath
|
|
? [originalPath, relativePath]
|
|
: [relativePath];
|
|
try {
|
|
return await git(repositoryRoot, [
|
|
"diff",
|
|
"--no-color",
|
|
"--no-ext-diff",
|
|
"--unified=3",
|
|
"HEAD",
|
|
"--",
|
|
...paths,
|
|
], TEXT_PREVIEW_MAX_BYTES * 4);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getGitFileDiff(cwd: string, filePath: string): Promise<GitFileDiffResponse> {
|
|
const repositoryRoot = await findRepositoryRoot(cwd);
|
|
if (!repositoryRoot || !isWithinPath(repositoryRoot, filePath)) return { supported: false };
|
|
|
|
const resolvedFilePath = path.resolve(filePath);
|
|
const relativePath = toGitPath(path.relative(repositoryRoot, resolvedFilePath));
|
|
const entries = await readStatusEntries(repositoryRoot);
|
|
const entry = entries.find((candidate) => candidate.path === relativePath);
|
|
if (!entry) return { supported: false };
|
|
|
|
const { status } = classifyGitStatus(entry);
|
|
if (status === "deleted") {
|
|
const patch = await createTrackedFilePatch(repositoryRoot, relativePath, entry.originalPath);
|
|
if (!patch?.includes("\n@@ ")) return { supported: false };
|
|
return { supported: true, status, patch };
|
|
}
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.lstatSync(resolvedFilePath);
|
|
} catch {
|
|
return { supported: false };
|
|
}
|
|
if (!stat.isFile() || stat.size > TEXT_PREVIEW_MAX_BYTES) return { supported: false };
|
|
|
|
const currentBuffer = fs.readFileSync(resolvedFilePath);
|
|
if (hasNullByte(currentBuffer)) return { supported: false };
|
|
const newContent = currentBuffer.toString("utf8");
|
|
|
|
let patch: string;
|
|
if (status === "untracked") {
|
|
patch = createAddedFilePatch(relativePath, newContent);
|
|
} else {
|
|
const trackedPatch = await createTrackedFilePatch(repositoryRoot, relativePath, entry.originalPath);
|
|
if (trackedPatch === null) {
|
|
if (status !== "added") return { supported: false };
|
|
patch = createAddedFilePatch(relativePath, newContent);
|
|
} else {
|
|
patch = trackedPatch;
|
|
}
|
|
}
|
|
|
|
if (!patch.includes("\n@@ ")) return { supported: false };
|
|
return { supported: true, status, patch };
|
|
}
|