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>
251 lines
6 KiB
TypeScript
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)}`,
|
|
);
|
|
}
|
|
}
|