singularity-forge/src/web/git-summary-service.ts
Mikael Hugo b24f426f2b batch: snapshot of in-flight v2 work
This commit captures uncommitted modifications that accumulated in the
working tree across multiple in-progress workstreams. It is a snapshot
to clear the deck before sf v3 work begins; individual workstreams
should land separately on top of this.

Notable additions:
- trace-collector.ts, traces.ts, src/tests/trace-export.test.ts —
  trace export plumbing
- biome.json — Biome linter configuration
- .gitignore — exclude native/npm/**/*.node compiled binaries

The bulk of the diff is across src/resources/extensions/sf/ (301 files)
and src/resources/extensions/sf/tests/ (277 files), reflecting the
ongoing sf extension work. Specific feature commits should follow this
snapshot rather than being archaeology'd out of it.

The 76MB native/npm/linux-x64-gnu/forge_engine.node compiled binary
was left out of the commit — it's now gitignored and built locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:42:31 +02:00

251 lines
6 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { relative, resolve, sep } from "node:path";
import {
GIT_SUMMARY_SCOPE,
type GitSummaryCounts,
type GitSummaryFile,
type GitSummaryResponse,
} from "../../web/lib/git-summary-contract.ts";
import {
nativeDetectMainBranch,
nativeGetCurrentBranch,
nativeHasChanges,
nativeHasMergeConflicts,
} from "../resources/extensions/sf/native-git-bridge.ts";
import { resolveBridgeRuntimeConfig } from "./bridge-service.ts";
const MAX_CHANGED_FILES = 25;
const CONFLICT_STATUS_CODES = new Set([
"DD",
"AU",
"UD",
"UA",
"DU",
"AA",
"UU",
]);
function sanitizeGitError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return raw.replace(/\s+/g, " ").trim();
}
function gitExecTrim(
basePath: string,
args: string[],
allowFailure = false,
): string {
try {
return execFileSync("git", args, {
cwd: basePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
GIT_SVN_ID: "",
},
}).trim();
} catch {
if (allowFailure) return "";
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
}
}
function readGitStatusPorcelain(basePath: string): string {
try {
return execFileSync(
"git",
["status", "--porcelain", "--untracked-files=all"],
{
cwd: basePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
GIT_SVN_ID: "",
},
},
);
} catch {
return "";
}
}
function toGitPath(value: string): string {
return value.split(sep).join("/");
}
function repoRelativeProjectPath(
projectCwd: string,
repoRoot: string,
): string | null {
const gitPrefix = gitExecTrim(
projectCwd,
["rev-parse", "--show-prefix"],
true,
).replace(/\/$/, "");
if (gitPrefix) {
return gitPrefix;
}
const relativePath = toGitPath(relative(repoRoot, projectCwd));
if (!relativePath || relativePath === ".") return "";
if (relativePath === ".." || relativePath.startsWith("../")) return null;
return relativePath;
}
function pathInsideProject(
repoPath: string,
projectPath: string | null,
): boolean {
if (projectPath === null || projectPath === "") return true;
return repoPath === projectPath || repoPath.startsWith(`${projectPath}/`);
}
function toProjectPath(repoPath: string, projectPath: string | null): string {
if (projectPath === null || projectPath === "") return repoPath;
if (repoPath === projectPath) return ".";
return repoPath.startsWith(`${projectPath}/`)
? repoPath.slice(projectPath.length + 1)
: repoPath;
}
function parsePorcelainPath(rawPath: string): string {
const renameArrow = " -> ";
const arrowIndex = rawPath.lastIndexOf(renameArrow);
const value =
arrowIndex >= 0 ? rawPath.slice(arrowIndex + renameArrow.length) : rawPath;
return value.trim();
}
function parseStatusLine(
line: string,
projectPath: string | null,
): GitSummaryFile | null {
if (line.length < 3) return null;
const status = line.slice(0, 2);
const repoPath = parsePorcelainPath(line.slice(3));
if (!repoPath || !pathInsideProject(repoPath, projectPath)) return null;
const untracked = status === "??";
const conflict = CONFLICT_STATUS_CODES.has(status);
const staged = !untracked && !conflict && status[0] !== " ";
const dirty = !untracked && !conflict && status[1] !== " ";
return {
path: toProjectPath(repoPath, projectPath),
repoPath,
status,
staged,
dirty,
untracked,
conflict,
};
}
function summarizeChangedFiles(
changedFiles: GitSummaryFile[],
): GitSummaryCounts {
return changedFiles.reduce<GitSummaryCounts>(
(counts, file) => ({
changed: counts.changed + 1,
staged: counts.staged + Number(file.staged),
dirty: counts.dirty + Number(file.dirty),
untracked: counts.untracked + Number(file.untracked),
conflicts: counts.conflicts + Number(file.conflict),
}),
{
changed: 0,
staged: 0,
dirty: 0,
untracked: 0,
conflicts: 0,
},
);
}
function collectChangedFiles(
repoRoot: string,
projectPath: string | null,
): GitSummaryFile[] {
const porcelain = readGitStatusPorcelain(repoRoot);
if (!porcelain.trim()) return [];
return porcelain
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => parseStatusLine(line, projectPath))
.filter((file): file is GitSummaryFile => file !== null);
}
export async function collectCurrentProjectGitSummary(
projectCwdOverride?: string,
): Promise<GitSummaryResponse> {
const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride);
const projectCwd = resolve(config.projectCwd);
const repoRoot = gitExecTrim(
projectCwd,
["rev-parse", "--show-toplevel"],
true,
);
if (!repoRoot) {
return {
kind: "not_repo",
project: {
scope: GIT_SUMMARY_SCOPE,
cwd: projectCwd,
repoRoot: null,
repoRelativePath: null,
},
message: "Current project is not inside a Git repository.",
};
}
try {
const resolvedRepoRoot = resolve(repoRoot);
const projectPath = repoRelativeProjectPath(projectCwd, resolvedRepoRoot);
const allChangedFiles = collectChangedFiles(resolvedRepoRoot, projectPath);
const counts = summarizeChangedFiles(allChangedFiles);
const branch = nativeGetCurrentBranch(resolvedRepoRoot) || null;
const mainBranch = nativeDetectMainBranch(resolvedRepoRoot) || null;
const hasChanges =
projectPath === ""
? nativeHasChanges(resolvedRepoRoot)
: counts.changed > 0;
const hasConflicts =
projectPath === ""
? nativeHasMergeConflicts(resolvedRepoRoot)
: counts.conflicts > 0;
return {
kind: "repo",
project: {
scope: GIT_SUMMARY_SCOPE,
cwd: projectCwd,
repoRoot: resolvedRepoRoot,
repoRelativePath: projectPath,
},
branch,
mainBranch,
hasChanges,
hasConflicts,
counts,
changedFiles: allChangedFiles.slice(0, MAX_CHANGED_FILES),
truncatedFileCount: Math.max(
0,
allChangedFiles.length - MAX_CHANGED_FILES,
),
};
} catch (error) {
throw new Error(
`Current-project git summary failed: ${sanitizeGitError(error)}`,
);
}
}