@ton-ai-core/vibecode-linter
Version:
Advanced TypeScript linter with Git integration, dependency analysis, and comprehensive error reporting
246 lines • 8.61 kB
JavaScript
// CHANGE: Git change collector for tree rendering
// WHY: Need per-file status (+/- stats) to build aggregated directory summaries
// QUOTE(USER): "Хочу сделать вот такаое отображение дерева..."
// REF: user-request-project-info-tree
// FORMAT THEOREM: collectGitChangeInfoEffect(target) returns deterministic map filtered to target subtree
// PURITY: SHELL
// EFFECT: Effect<ReadonlyMap<string, FileChangeInfo>, never>
// INVARIANT: Fallback to empty map when git unavailable
// COMPLEXITY: O(n) where n = count of git status entries in subtree
import { Effect } from "effect";
import { execGitStdoutOrNull } from "../git/utils.js";
import { fs, path } from "../utils/node-mods.js";
const fsPromises = fs.promises;
const STATUS_HEADER_PREFIX = "##";
const RENAME_SEPARATOR = "->";
function normalizePath(input) {
return input.replace(/\\/g, "/").replace(/^\.\/+/u, "");
}
function splitNonEmptyLines(raw) {
return raw
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function trimQuotes(value) {
return value.replace(/^"+|"+$/gu, "");
}
async function resolveTargetInfo(targetPath) {
try {
const absolute = path.resolve(process.cwd(), targetPath);
const stats = await fsPromises.stat(absolute);
const relative = path.relative(process.cwd(), absolute);
return {
normalizedTarget: normalizePath(relative),
isDirectory: stats.isDirectory(),
};
}
catch {
return {
normalizedTarget: normalizePath(targetPath),
isDirectory: true,
};
}
}
function createRelativeResolver(target) {
const normalizedTarget = target.normalizedTarget;
if (!target.isDirectory) {
const targetFile = normalizedTarget;
return (gitPath) => {
const normalized = normalizePath(gitPath);
if (normalized === targetFile) {
const baseName = path.posix.basename(normalized);
return baseName;
}
return null;
};
}
if (normalizedTarget.length === 0) {
return (gitPath) => normalizePath(gitPath);
}
const prefix = `${normalizedTarget}/`;
return (gitPath) => {
const normalized = normalizePath(gitPath);
if (normalized === normalizedTarget) {
return "";
}
if (normalized.startsWith(prefix)) {
return normalized.slice(prefix.length);
}
return null;
};
}
function deriveStatusLabel(code) {
if (code.length >= 2) {
const staged = code[0] ?? " ";
const worktree = code[1] ?? " ";
if (staged !== " ")
return staged;
if (worktree !== " ")
return worktree;
}
return code.trim() || "M";
}
function deriveCategory(label) {
return label === "??" ? "untracked" : "modified";
}
function cleanGitPath(pathValue) {
const trimmed = pathValue.trim();
const arrowIndex = trimmed.lastIndexOf(RENAME_SEPARATOR);
if (arrowIndex >= 0) {
return trimmed.slice(arrowIndex + RENAME_SEPARATOR.length).trim();
}
return trimmed;
}
function extractPathInfo(pathValue) {
const cleaned = cleanGitPath(pathValue);
const isDirectory = cleaned.endsWith("/");
const normalized = isDirectory ? cleaned.replace(/\/+$/u, "") : cleaned;
return {
normalized,
isDirectory,
};
}
function insertTrackedStatus(line, resolveRelative, map) {
const match = line.match(/^(..)\s+(.*)$/u);
if (match === null)
return;
const [, code, rawPath] = match;
const safeCode = code ?? "";
const safePath = rawPath ?? "";
const pathInfo = extractPathInfo(safePath);
const statusLabel = deriveStatusLabel(safeCode);
const relative = resolveRelative(pathInfo.normalized);
if (relative === null || relative.length === 0)
return;
map.set(relative, {
statusLabel,
category: deriveCategory(statusLabel),
additions: 0,
deletions: 0,
isDirectory: pathInfo.isDirectory,
});
}
function parseStatusLines(raw, resolveRelative) {
const map = new Map();
const lines = splitNonEmptyLines(raw);
for (const line of lines) {
if (line.startsWith(STATUS_HEADER_PREFIX)) {
continue;
}
if (line.startsWith("??")) {
const pathInfo = extractPathInfo(line.slice(2));
const relative = resolveRelative(pathInfo.normalized);
if (relative !== null && relative.length > 0) {
map.set(relative, {
statusLabel: "??",
category: "untracked",
additions: 0,
deletions: 0,
isDirectory: pathInfo.isDirectory,
});
}
continue;
}
insertTrackedStatus(line, resolveRelative, map);
}
return map;
}
function parseNumericStat(value) {
if (value === undefined || value === "-")
return 0;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
function appendNumstatEntry(map, additionsRaw, deletionsRaw, pathRaw, resolveRelative) {
const safePath = trimQuotes(pathRaw ?? "");
const pathInfo = extractPathInfo(safePath);
if (pathInfo.isDirectory)
return;
const relative = resolveRelative(pathInfo.normalized);
if (relative === null || relative.length === 0)
return;
const additions = parseNumericStat(additionsRaw);
const deletions = parseNumericStat(deletionsRaw);
const previous = map.get(relative);
if (previous === undefined) {
map.set(relative, { additions, deletions });
}
else {
map.set(relative, {
additions: previous.additions + additions,
deletions: previous.deletions + deletions,
});
}
}
function parseNumstat(raw, resolveRelative) {
const map = new Map();
const lines = splitNonEmptyLines(raw);
for (const line of lines) {
const parts = line.split(/\t/u);
if (parts.length < 3)
continue;
const additionsRaw = parts[0];
const deletionsRaw = parts[1];
const pathPart = parts[parts.length - 1];
appendNumstatEntry(map, additionsRaw, deletionsRaw, pathPart, resolveRelative);
}
return map;
}
async function collectNumstatForModes(resolveRelative) {
const combined = new Map();
const commands = ["git diff --numstat", "git diff --cached --numstat"];
for (const command of commands) {
const raw = (await execGitStdoutOrNull(command)) ?? "";
const parsed = parseNumstat(raw, resolveRelative);
for (const [relative, entry] of parsed) {
const existing = combined.get(relative);
if (existing === undefined) {
combined.set(relative, entry);
}
else {
combined.set(relative, {
additions: existing.additions + entry.additions,
deletions: existing.deletions + entry.deletions,
});
}
}
}
return combined;
}
async function collectStatusMap(resolveRelative) {
const raw = (await execGitStdoutOrNull("git status -sb")) ?? "";
return parseStatusLines(raw, resolveRelative);
}
function mergeNumstatIntoStatus(statusMap, numstatMap) {
for (const [relative, entry] of numstatMap) {
const status = statusMap.get(relative);
if (status === undefined ||
status.category === "untracked" ||
status.isDirectory) {
continue;
}
statusMap.set(relative, {
...status,
additions: entry.additions,
deletions: entry.deletions,
});
}
}
/**
* Собирает карту изменений git для выбранного таргета.
*/
export function collectGitChangeInfoEffect(targetPath) {
return Effect.tryPromise(async () => {
const targetInfo = await resolveTargetInfo(targetPath);
const resolveRelative = createRelativeResolver(targetInfo);
const statusMap = await collectStatusMap(resolveRelative);
if (statusMap.size === 0) {
return new Map();
}
const numstatMap = await collectNumstatForModes(resolveRelative);
mergeNumstatIntoStatus(statusMap, numstatMap);
return statusMap;
}).pipe(Effect.catchAll(() => Effect.succeed(new Map())));
}
//# sourceMappingURL=git-changes.js.map