Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/strata/git.ts

Raw
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { devNull } from "node:os";
import type { DiffLine, Hunk, Snapshot, Source } from "./types";

export const MAX_PATCH_BYTES = 256 * 1024;
const MAX_METADATA_BYTES = 1024 * 1024;
const MAX_STDERR_BYTES = 64 * 1024;
const MAX_CHANGES = 2_048;
const MAX_PATHSPEC_CHARACTERS = 12_000;
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*$/;

type Change = {
	status: string;
	path: string;
	oldPath?: string;
};

type ParsedBlock = {
	hunks: Array<Omit<Hunk, "id">>;
	binary: boolean;
};

export type StrataArgs =
	| Source
	| "cancel"
	| { kind: "pr"; identifier?: string };

export function parseArgs(raw: string): StrataArgs {
	const args = raw.trim().split(/\s+/u).filter(Boolean);
	if (args.length === 0) return { kind: "working" };
	if (args.length === 1 && args[0] === "cancel") return "cancel";
	if (args.length === 1 && args[0] === "--staged") return { kind: "staged" };
	if (args[0] === "--pr" && args.length <= 2) {
		const identifier = args[1];
		if (identifier?.includes("\0") || (identifier?.length ?? 0) > 2_048)
			throw new Error("--pr identifier is invalid");
		return { kind: "pr", ...(identifier ? { identifier } : {}) };
	}
	if (args.length === 2 && args[0] === "--base") {
		const ref = args[1];
		if (!ref || ref.startsWith("-") || ref.includes("\0"))
			throw new Error("--base requires a Git revision that is not an option");
		if (ref.length > 1_024) throw new Error("--base revision is too long");
		return { kind: "base", ref };
	}
	throw new Error(
		"usage: /strata [--staged | --base REF | --pr [number|URL] | cancel]",
	);
}

export function isProtectedPath(path: string, nativePath = false): boolean {
	const normalized =
		nativePath && process.platform !== "win32"
			? path
			: path.replaceAll("\\", "/");
	const lowerParts = normalized.split("/").map((part) => part.toLowerCase());
	for (let length = 1; length <= lowerParts.length; length++) {
		if (isProtectedPrefix(lowerParts.slice(0, length))) return true;
	}
	return false;
}

function isProtectedPrefix(lowerParts: string[]): boolean {
	const name = lowerParts.at(-1) ?? "";
	const joined = lowerParts.join("/");

	if (lowerParts.includes(".git")) return true;
	if (
		(name === ".env" || name.startsWith(".env.")) &&
		![".env.example", ".env.sample", ".env.template"].includes(name)
	)
		return true;
	if ([".npmrc", ".pypirc", ".netrc"].includes(name)) return true;
	if (name.endsWith(".p12") || name.endsWith(".pfx")) return true;
	if (name === "secrets.auto.tfvars") return true;
	if (name === "secrets.nu" && lowerParts.at(-2) === "nushell") return true;
	if (lowerParts.includes(".ssh")) return true;
	if (name.endsWith(".key") || name.endsWith(".pem")) return true;
	if (lowerParts.includes("private-keys-v1.d") && lowerParts.includes(".gnupg"))
		return true;
	if (name === "secring.gpg" && lowerParts.includes(".gnupg")) return true;
	if (
		["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"].some(
			(prefix) => name === prefix || name.startsWith(`${prefix}.`),
		)
	)
		return true;
	if (
		name === "auth.json" &&
		(lowerParts.includes(".pi") ||
			joined.includes("pi/agent/") ||
			lowerParts.includes("containers") ||
			lowerParts.includes(".codex"))
	)
		return true;
	if (name === ".credentials.json" && lowerParts.includes(".claude"))
		return true;
	if (
		[
			"application_default_credentials.json",
			"credentials.db",
			"access_tokens.db",
		].includes(name) &&
		lowerParts.includes("gcloud")
	)
		return true;
	if (
		lowerParts.includes("gcloud") &&
		lowerParts.includes("legacy_credentials")
	)
		return true;
	if (name === "accounts.conf" && lowerParts.includes("aerc")) return true;
	if (name === "credentials" && lowerParts.includes(".aws")) return true;
	if (
		(name === "credentials" || name === "credentials.toml") &&
		lowerParts.includes(".cargo")
	)
		return true;
	if (name === ".git-credentials") return true;
	if (name === "credentials" && joined.includes(".config/git/")) return true;
	if (name === "config.json" && lowerParts.includes(".docker")) return true;
	if (
		name === "hosts.yml" &&
		(joined.includes(".config/gh/") || joined.includes("gh/"))
	)
		return true;
	if (name === "config" && lowerParts.includes(".kube")) return true;
	if (name === "auth.json" && lowerParts.includes("containers")) return true;
	if (lowerParts.includes(".subversion") && lowerParts.includes("auth"))
		return true;
	if (
		["keys.json", "ui-creds.json"].includes(name) &&
		joined.includes("cyborus/forgejo-cli/data/")
	)
		return true;
	if (name.endsWith(".json") && joined.includes(".pi/google-workspace/tokens/"))
		return true;
	if (
		name === "config.xml" &&
		(lowerParts.includes("syncthing") || joined.includes(".config/syncthing/"))
	)
		return true;
	if (name === "picard.ini" && lowerParts.includes("musicbrainz")) return true;
	return false;
}

export async function collectSnapshot(
	cwd: string,
	source: Source,
	signal?: AbortSignal,
): Promise<Snapshot> {
	signal?.throwIfAborted();
	const repoRoot = stripFinalNewline(
		await gitText(
			cwd,
			["rev-parse", "--path-format=absolute", "--show-toplevel"],
			16 * 1024,
			signal,
		),
	);
	if (!repoRoot) throw new Error("Git returned an empty repository root");

	const headCommit = await resolveCommit(repoRoot, "HEAD", signal);
	let base = headCommit;
	let head = headCommit;
	let revisions: string[];

	if (source.kind === "working") {
		revisions = [headCommit];
	} else if (source.kind === "staged") {
		head = await objectId(repoRoot, ["write-tree"], signal);
		revisions = [headCommit, head];
	} else {
		if (!source.ref) throw new Error("base source requires a revision");
		const requested = await resolveCommit(repoRoot, source.ref, signal);
		base = await objectId(
			repoRoot,
			["merge-base", requested, headCommit],
			signal,
		);
		revisions = [base, headCommit];
	}

	const trackedPaths = await trackedManifest(repoRoot, revisions, signal);
	const protectedPaths = trackedPaths.filter((path) => isProtectedPath(path));
	const exclusions = exclusionPathspecs(protectedPaths);
	if (
		exclusions.reduce((sum, pathspec) => sum + pathspec.length + 1, 0) >
		MAX_PATHSPEC_CHARACTERS
	)
		throw new Error(
			"Git capture has too many protected path characters for one safe diff invocation",
		);

	const metadata = await changedManifest(
		repoRoot,
		revisions,
		exclusions,
		signal,
	);
	const changes = parseNameStatus(metadata);

	const protectedChanges = await protectedManifest(
		repoRoot,
		revisions,
		protectedPaths,
		signal,
	);
	let untracked: string[] = [];
	if (source.kind === "working")
		untracked = await untrackedManifest(repoRoot, signal);
	if (changes.length + protectedChanges.length + untracked.length > MAX_CHANGES)
		throw new Error("Git capture has too many changed and untracked files");

	const skipped: Snapshot["skipped"] = protectedChanges.map((path) => ({
		path,
		reason: "protected path",
	}));
	for (const path of untracked)
		skipped.push({
			path,
			reason: isProtectedPath(path) ? "protected path" : "untracked",
		});

	const patch =
		changes.length === 0
			? ""
			: await gitText(
					repoRoot,
					diffArgs(revisions, ["--patch"], exclusions),
					MAX_PATCH_BYTES,
					signal,
				);
	if (source.kind === "working") {
		const afterMetadata = await changedManifest(
			repoRoot,
			revisions,
			exclusions,
			signal,
		);
		const afterUntracked = await untrackedManifest(repoRoot, signal);
		if (afterMetadata !== metadata || !samePaths(afterUntracked, untracked))
			throw new Error("Git working tree changed during snapshot capture");
	}
	const parsed = parsePatch(patch, changes);
	for (const [index, block] of parsed.entries()) {
		if (block.binary)
			skipped.push({
				path: (changes[index] as Change).path,
				reason: "binary",
			});
	}

	const hunks = parsed
		.flatMap((block) => block.hunks)
		.map((hunk) => ({ ...hunk, id: hunkId(hunk) }));
	const ids = new Set(hunks.map((hunk) => hunk.id));
	if (ids.size !== hunks.length)
		throw new Error("Git capture produced duplicate hunk IDs");

	skipped.sort((left, right) =>
		left.path === right.path
			? left.reason.localeCompare(right.reason)
			: left.path.localeCompare(right.path),
	);
	const id = sha256(JSON.stringify({ source, base, head, patch, skipped }));
	return { id, repoRoot, source, base, head, hunks, skipped };
}

function diffArgs(
	revisions: string[],
	options: string[],
	exclusions: string[] = [],
	inclusions: string[] = ["."],
): string[] {
	return [
		"-c",
		"core.quotePath=false",
		"diff",
		"--no-ext-diff",
		"--no-textconv",
		"--no-color",
		"--find-renames=50%",
		"--submodule=short",
		"--unified=3",
		"--src-prefix=a/",
		"--dst-prefix=b/",
		...revisions,
		...options,
		"--",
		...inclusions,
		...exclusions,
	];
}

async function trackedManifest(
	cwd: string,
	revisions: string[],
	signal?: AbortSignal,
): Promise<string[]> {
	const manifests = await Promise.allSettled([
		gitText(cwd, ["ls-files", "-z"], MAX_METADATA_BYTES, signal),
		...revisions.map((revision) =>
			gitText(
				cwd,
				["ls-tree", "-r", "--name-only", "-z", revision],
				MAX_METADATA_BYTES,
				signal,
			),
		),
	]);
	return unique(
		manifests.flatMap((result) => {
			if (result.status === "rejected") throw result.reason;
			return parseNulPaths(result.value);
		}),
	);
}

function changedManifest(
	cwd: string,
	revisions: string[],
	exclusions: string[],
	signal?: AbortSignal,
): Promise<string> {
	return gitText(
		cwd,
		diffArgs(revisions, ["--name-status", "-z"], exclusions),
		MAX_METADATA_BYTES,
		signal,
	);
}

async function protectedManifest(
	cwd: string,
	revisions: string[],
	paths: string[],
	signal?: AbortSignal,
): Promise<string[]> {
	if (paths.length === 0) return [];
	return parseNulPaths(
		await gitText(
			cwd,
			diffArgs(
				revisions,
				["--name-only", "--no-renames", "-z"],
				[],
				paths.map((path) => `:(literal)${path}`),
			),
			MAX_METADATA_BYTES,
			signal,
		),
	);
}

async function untrackedManifest(
	cwd: string,
	signal?: AbortSignal,
): Promise<string[]> {
	return parseNulPaths(
		await gitText(
			cwd,
			["ls-files", "--others", "--exclude-standard", "-z"],
			MAX_METADATA_BYTES,
			signal,
		),
	);
}

function exclusionPathspecs(paths: string[]): string[] {
	return paths.map((path) => `:(exclude,literal)${path}`);
}

function parseNameStatus(text: string): Change[] {
	const fields = nulFields(text);
	const changes: Change[] = [];
	for (let index = 0; index < fields.length; ) {
		const status = fields[index++];
		if (!status || !/^[ACDMRTUXB][0-9]*$/u.test(status))
			throw new Error("malformed Git name-status capture");
		if (status.startsWith("R") || status.startsWith("C")) {
			const oldPath = fields[index++];
			const path = fields[index++];
			if (!oldPath || !path)
				throw new Error("malformed Git rename/copy capture");
			changes.push({ status, oldPath, path });
		} else {
			const path = fields[index++];
			if (!path) throw new Error("malformed Git path capture");
			changes.push({ status, path });
		}
	}
	return changes;
}

function parseNulPaths(text: string): string[] {
	const paths = nulFields(text);
	if (paths.some((path) => path.length === 0))
		throw new Error("malformed Git untracked path capture");
	return paths;
}

function nulFields(text: string): string[] {
	if (!text) return [];
	if (!text.endsWith("\0"))
		throw new Error("malformed unterminated Git metadata capture");
	return text.slice(0, -1).split("\0");
}

function parsePatch(patch: string, changes: Change[]): ParsedBlock[] {
	if (changes.length === 0) {
		if (patch !== "") throw new Error("Git produced a patch without metadata");
		return [];
	}
	if (!patch) throw new Error("Git metadata and patch capture disagree");
	const lines = patch.endsWith("\n")
		? patch.slice(0, -1).split("\n")
		: patch.split("\n");
	const blocks: string[][] = [];
	for (const line of lines) {
		if (line.startsWith("diff --git ")) blocks.push([line]);
		else {
			const block = blocks.at(-1);
			if (!block)
				throw new Error("malformed Git patch before first file header");
			block.push(line);
		}
	}
	if (blocks.length !== changes.length)
		throw new Error(
			`Git metadata and patch disagree (${changes.length} files, ${blocks.length} patches)`,
		);
	return blocks.map((block, index) =>
		parseBlock(block, changes[index] as Change),
	);
}

function parseBlock(lines: string[], change: Change): ParsedBlock {
	if (!lines[0]?.startsWith("diff --git "))
		throw new Error("malformed Git file header");
	if (lines[0] !== diffHeader(change))
		throw new Error(
			`Git patch header does not match metadata for ${change.path}`,
		);
	const hunks: Array<Omit<Hunk, "id">> = [];
	const metadata: string[] = [];
	let binary = false;
	let index = 1;

	while (index < lines.length) {
		const line = lines[index] as string;
		if (line.startsWith("@@ ")) {
			const parsed = parseHunk(lines, index, change);
			hunks.push(parsed.hunk);
			index = parsed.next;
			continue;
		}
		if (line.startsWith("Binary files ")) binary = true;
		if (!isMetadataLine(line))
			throw new Error(`malformed Git patch metadata for ${change.path}`);
		metadata.push(line);
		index++;
	}

	validateStatus(change, metadata);
	const meaningful = metadata.filter(isMeaningfulMetadata);
	if (hunks.length === 0) {
		if (meaningful.length === 0)
			throw new Error(
				`Git patch for ${change.path} contains no hunk or metadata change`,
			);
		hunks.push(metadataHunk(change, meaningful));
	} else if (meaningful.length > 0) {
		hunks[0]?.lines.unshift(
			...meaningful.map((text) => ({ kind: "meta" as const, text })),
		);
	}
	return { hunks, binary };
}

function metadataHunk(change: Change, metadata: string[]): Omit<Hunk, "id"> {
	return {
		path: change.path,
		...(change.oldPath && change.oldPath !== change.path
			? { oldPath: change.oldPath }
			: {}),
		header: "metadata",
		lines: metadata.map((text) => ({ kind: "meta" as const, text })),
	};
}

function diffHeader(change: Change): string {
	return `diff --git ${gitQuotePath("a", change.oldPath ?? change.path)} ${gitQuotePath("b", change.path)}`;
}

function gitQuotePath(prefix: string, path: string): string {
	const value = `${prefix}/${path}`;
	let quoted = false;
	let escaped = "";
	for (const character of value) {
		switch (character) {
			case "\\":
				escaped += "\\\\";
				quoted = true;
				break;
			case '"':
				escaped += '\\"';
				quoted = true;
				break;
			case "\t":
				escaped += "\\t";
				quoted = true;
				break;
			case "\n":
				escaped += "\\n";
				quoted = true;
				break;
			case "\r":
				escaped += "\\r";
				quoted = true;
				break;
			default:
				if (character.charCodeAt(0) < 0x20) quoted = true;
				escaped += character;
		}
	}
	return quoted ? `"${escaped}"` : escaped;
}

function parseHunk(
	lines: string[],
	start: number,
	change: Change,
): { hunk: Omit<Hunk, "id">; next: number } {
	const header = lines[start] as string;
	const match = HUNK_HEADER.exec(header);
	if (!match) throw new Error(`malformed Git hunk header for ${change.path}`);
	let oldLine = Number(match[1]);
	let newLine = Number(match[3]);
	const expectedOld = match[2] === undefined ? 1 : Number(match[2]);
	const expectedNew = match[4] === undefined ? 1 : Number(match[4]);
	let consumedOld = 0;
	let consumedNew = 0;
	const hunkLines: DiffLine[] = [];
	let index = start + 1;

	for (; index < lines.length; index++) {
		const line = lines[index] as string;
		if (line.startsWith("@@ ")) break;
		if (line.startsWith("diff --git ")) break;
		if (line.startsWith("\\")) {
			hunkLines.push({ kind: "meta", text: line });
			continue;
		}
		const marker = line[0];
		const text = line.slice(1);
		if (marker === " ") {
			hunkLines.push({ kind: "context", text, oldLine, newLine });
			oldLine++;
			newLine++;
			consumedOld++;
			consumedNew++;
		} else if (marker === "+") {
			hunkLines.push({ kind: "add", text, newLine });
			newLine++;
			consumedNew++;
		} else if (marker === "-") {
			hunkLines.push({ kind: "delete", text, oldLine });
			oldLine++;
			consumedOld++;
		} else {
			throw new Error(`malformed Git hunk line for ${change.path}`);
		}
	}
	if (consumedOld !== expectedOld || consumedNew !== expectedNew)
		throw new Error(
			`Git hunk counts disagree for ${change.path}: expected ${expectedOld}/${expectedNew}, got ${consumedOld}/${consumedNew}`,
		);
	return {
		hunk: {
			path: change.path,
			...(change.oldPath && change.oldPath !== change.path
				? { oldPath: change.oldPath }
				: {}),
			header,
			lines: hunkLines,
		},
		next: index,
	};
}

function isMetadataLine(line: string): boolean {
	return [
		"index ",
		"old mode ",
		"new mode ",
		"new file mode ",
		"deleted file mode ",
		"similarity index ",
		"dissimilarity index ",
		"rename from ",
		"rename to ",
		"copy from ",
		"copy to ",
		"--- ",
		"+++ ",
		"Binary files ",
	].some((prefix) => line.startsWith(prefix));
}

function isMeaningfulMetadata(line: string): boolean {
	return (
		!line.startsWith("index ") &&
		!line.startsWith("--- ") &&
		!line.startsWith("+++ ")
	);
}

function validateStatus(change: Change, metadata: string[]): void {
	const kind = change.status[0];
	if (
		kind === "R" &&
		(!metadata.some((line) => line.startsWith("rename from ")) ||
			!metadata.some((line) => line.startsWith("rename to ")))
	)
		throw new Error(`Git rename metadata is incomplete for ${change.path}`);
	if (
		kind === "C" &&
		(!metadata.some((line) => line.startsWith("copy from ")) ||
			!metadata.some((line) => line.startsWith("copy to ")))
	)
		throw new Error(`Git copy metadata is incomplete for ${change.path}`);
	if (
		kind === "A" &&
		!metadata.some(
			(line) => line.startsWith("new file mode ") || line === "--- /dev/null",
		)
	)
		throw new Error(`Git addition metadata is incomplete for ${change.path}`);
	if (
		kind === "D" &&
		!metadata.some(
			(line) =>
				line.startsWith("deleted file mode ") || line === "+++ /dev/null",
		)
	)
		throw new Error(`Git deletion metadata is incomplete for ${change.path}`);
}

async function resolveCommit(
	cwd: string,
	ref: string,
	signal?: AbortSignal,
): Promise<string> {
	return objectId(
		cwd,
		["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
		signal,
	);
}

async function objectId(
	cwd: string,
	args: string[],
	signal?: AbortSignal,
): Promise<string> {
	const id = stripFinalNewline(await gitText(cwd, args, 4 * 1024, signal));
	if (!/^[0-9a-f]{40,64}$/u.test(id))
		throw new Error("Git returned an invalid object ID");
	return id;
}

export async function gitText(
	cwd: string,
	args: string[],
	maxBytes: number,
	signal?: AbortSignal,
): Promise<string> {
	const buffer = await runGit(cwd, args, maxBytes, signal);
	try {
		return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
	} catch {
		throw new Error("Git output is not valid UTF-8");
	}
}

export async function gitSucceeds(
	cwd: string,
	args: string[],
	signal?: AbortSignal,
): Promise<boolean> {
	try {
		await runGit(cwd, args, 4 * 1024, signal);
		return true;
	} catch (error) {
		if (signal?.aborted) signal.throwIfAborted();
		if (error instanceof Error && error.message.includes("ENOENT")) throw error;
		return false;
	}
}

function runGit(
	cwd: string,
	args: string[],
	maxBytes: number,
	signal?: AbortSignal,
): Promise<Buffer> {
	return new Promise((resolve, reject) => {
		signal?.throwIfAborted();
		const child = spawn("git", ["-c", "core.fsmonitor=false", ...args], {
			cwd,
			env: cleanGitEnvironment(),
			stdio: ["ignore", "pipe", "pipe"],
			windowsHide: true,
		});
		const stdout: Buffer[] = [];
		const stderr: Buffer[] = [];
		let stdoutBytes = 0;
		let stderrBytes = 0;
		let failure: Error | undefined;

		const finish = (error?: Error, output?: Buffer) => {
			signal?.removeEventListener("abort", abort);
			if (error) reject(error);
			else resolve(output ?? Buffer.alloc(0));
		};
		const abort = () => {
			failure ??=
				signal?.reason instanceof Error
					? signal.reason
					: new Error("Git capture aborted");
			child.kill();
		};
		signal?.addEventListener("abort", abort, { once: true });
		child.stdout.on("data", (chunk: Buffer) => {
			stdoutBytes += chunk.length;
			if (stdoutBytes > maxBytes) {
				failure ??= new Error(`Git output exceeds ${maxBytes} byte limit`);
				child.kill();
				return;
			}
			stdout.push(chunk);
		});
		child.stderr.on("data", (chunk: Buffer) => {
			if (stderrBytes >= MAX_STDERR_BYTES) return;
			const remaining = MAX_STDERR_BYTES - stderrBytes;
			stderr.push(chunk.subarray(0, remaining));
			stderrBytes += Math.min(chunk.length, remaining);
		});
		child.once("error", (error) => {
			failure ??= error;
		});
		child.once("close", (code) => {
			if (failure) return finish(failure);
			if (code === 0) finish(undefined, Buffer.concat(stdout));
			else {
				const detail = Buffer.concat(stderr).toString("utf8").trim();
				finish(
					new Error(detail || `git exited with code ${code ?? "unknown"}`),
				);
			}
		});
	});
}

export function cleanGitEnvironment(): NodeJS.ProcessEnv {
	const env: NodeJS.ProcessEnv = {};
	for (const [key, value] of Object.entries(process.env)) {
		if (!key.toUpperCase().startsWith("GIT_") && value !== undefined)
			env[key] = value;
	}
	env.GIT_CONFIG_NOSYSTEM = "1";
	const gitDevNull = process.platform === "win32" ? "NUL" : devNull;
	env.GIT_CONFIG_SYSTEM = gitDevNull;
	env.GIT_CONFIG_GLOBAL = gitDevNull;
	env.GIT_TERMINAL_PROMPT = "0";
	return env;
}

function stripFinalNewline(value: string): string {
	return value.endsWith("\r\n")
		? value.slice(0, -2)
		: value.endsWith("\n")
			? value.slice(0, -1)
			: value;
}

function unique(values: string[]): string[] {
	return [...new Set(values)];
}

function samePaths(left: string[], right: string[]): boolean {
	return (
		left.length === right.length &&
		left.every((path, index) => path === right[index])
	);
}

function hunkId(hunk: Omit<Hunk, "id">): string {
	return `h-${sha256(JSON.stringify(hunk)).slice(0, 20)}`;
}

function sha256(value: string): string {
	return createHash("sha256").update(value, "utf8").digest("hex");
}