/*
Facilitates Git operations for tracking file integrity and repository metadata.
- Do not handle raw Git content parsing outside defined methods.
- Do not manage Git configuration or transport orchestration.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
*/
/**
* Git operations for integrity tracking.
* Provides file history, change detection, and repository metadata queries.
*/
import type { ChangedPaths, GitFileHistory } from "./types.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
const DEFAULT_GIT_TIMEOUT_MS = 15_000;
interface RunGitOptions {
timeoutMs?: number;
}
async function runGit(
cwd: string,
args: string[],
io: WorkspaceIO,
options: RunGitOptions = {},
): Promise {
const { timeoutMs = DEFAULT_GIT_TIMEOUT_MS } = options;
const result = await io.exec("git", args, { cwd, timeoutMs });
if (result.exitCode !== 0) {
throw new Error(
`git ${args[0] ?? ""} failed (exit ${result.exitCode}): ${result.stderr.trim()}`,
);
}
return result.stdout.trimEnd();
}
function uniqueSorted(values: Iterable): string[] {
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
}
export async function getTrackedFiles(
cwd: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const output = await runGit(cwd, ["ls-files"], io);
return output ? output.split("\n").filter(Boolean) : [];
}
export async function getFileHistory(
cwd: string,
repoPath: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
const latestOutput = await runGit(
cwd,
["log", "--follow", "-1", "--format=%aI%x09%H", "--", repoPath],
io,
{ timeoutMs: 10_000 },
).catch(() => "");
const [updatedAtRaw, lastCommitShaRaw] = latestOutput.split("\t");
const createdOutput = await runGit(
cwd,
["log", "--follow", "--diff-filter=A", "--format=%aI", "--", repoPath],
io,
{ timeoutMs: 5_000 },
).catch(() => "");
const createdLines = createdOutput ? createdOutput.split("\n").filter(Boolean) : [];
return {
createdAt: createdLines.length ? createdLines[createdLines.length - 1] : null,
updatedAt: updatedAtRaw || null,
lastCommitSha: lastCommitShaRaw || null,
};
} catch {
return { createdAt: null, updatedAt: null, lastCommitSha: null };
}
}
export async function getFileRevisionFromHistory(
cwd: string,
repoPath: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
const output = await runGit(
cwd,
["log", "--follow", "--diff-filter=AMT", "--format=%H", "--", repoPath],
io,
{ timeoutMs: 15_000 },
).catch(() => "");
if (!output) {
return 1;
}
const revisions = output.split("\n").filter(Boolean).length;
return Math.max(1, revisions);
} catch {
return 1;
}
}
function parseNameStatus(output: string): ChangedPaths {
const added = new Set();
const modified = new Set();
const deleted = new Set();
const renamed: Array<{ from: string; to: string }> = [];
for (const line of output.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const status = parts[0] ?? "";
if (status.startsWith("R") && parts[1] && parts[2]) {
renamed.push({ from: parts[1], to: parts[2] });
continue;
}
if (status === "A" && parts[1]) added.add(parts[1]);
if ((status === "M" || status === "T") && parts[1]) modified.add(parts[1]);
if (status === "D" && parts[1]) deleted.add(parts[1]);
}
return {
added: uniqueSorted(added),
modified: uniqueSorted(modified),
deleted: uniqueSorted(deleted),
renamed,
};
}
function parsePorcelainStatus(output: string): ChangedPaths {
const added = new Set();
const modified = new Set();
const deleted = new Set();
const renamed: Array<{ from: string; to: string }> = [];
for (const line of output.split("\n").filter(Boolean)) {
if (line.startsWith("?? ")) {
added.add(line.slice(3).trim());
continue;
}
const status = line.slice(0, 2);
const body = line.slice(3).trim();
if (body.includes(" -> ") && (status.includes("R") || status.includes("C"))) {
const [from, to] = body.split(" -> ");
if (from && to) {
renamed.push({ from, to });
}
continue;
}
if (status.includes("D")) {
deleted.add(body);
continue;
}
if (status.includes("A")) {
added.add(body);
continue;
}
if (status.includes("M") || status.includes("T")) {
modified.add(body);
}
}
return {
added: uniqueSorted(added),
modified: uniqueSorted(modified),
deleted: uniqueSorted(deleted),
renamed,
};
}
function hasAnyChanges(changes: ChangedPaths): boolean {
return Boolean(
changes.added.length ||
changes.modified.length ||
changes.deleted.length ||
changes.renamed.length,
);
}
export async function getChangedPaths(
cwd: string,
baseRef?: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
if (baseRef) {
const output = await runGit(
cwd,
["diff", "--name-status", "--find-renames", `${baseRef}...HEAD`],
io,
{ timeoutMs: 10_000 },
);
return parseNameStatus(output);
}
const output = await runGit(cwd, ["status", "--porcelain=1", "--untracked-files=normal"], io, {
timeoutMs: 10_000,
});
const workingTreeChanges = parsePorcelainStatus(output);
if (hasAnyChanges(workingTreeChanges)) {
return workingTreeChanges;
}
const headDiff = await runGit(
cwd,
["diff-tree", "--no-commit-id", "--name-status", "--find-renames", "-r", "HEAD"],
io,
{ timeoutMs: 10_000 },
).catch(() => "");
if (!headDiff) {
return workingTreeChanges;
}
return parseNameStatus(headDiff);
} catch {
return { added: [], modified: [], deleted: [], renamed: [] };
}
}
export async function getRepoUrl(
cwd: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
const output = await runGit(cwd, ["config", "--get", "remote.origin.url"], io, {
timeoutMs: 5_000,
});
return output || null;
} catch {
return null;
}
}
export async function getHeadSha(
cwd: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
const output = await runGit(cwd, ["rev-parse", "HEAD"], io, { timeoutMs: 5_000 });
return output || null;
} catch {
return null;
}
}