Files

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 };
}