mirror of
https://github.com/codeflash-ai/codeflash-agent.git
synced 2026-05-04 18:25:19 +00:00
Notable upstream fixes: - Fix working-tree review crash on untracked directories - Avoid embedding large adversarial review diffs - Inherit process.env in app-server spawn - Scope implicit resume-last and cancel to current session - Gracefully handle unsupported thread/name/set on older CLI - Use app-server auth status for Codex readiness
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { isProbablyText } from "./fs.mjs";
|
|
import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
|
|
|
|
const MAX_UNTRACKED_BYTES = 24 * 1024;
|
|
const DEFAULT_INLINE_DIFF_MAX_FILES = 2;
|
|
const DEFAULT_INLINE_DIFF_MAX_BYTES = 256 * 1024;
|
|
|
|
function git(cwd, args, options = {}) {
|
|
return runCommand("git", args, { cwd, ...options });
|
|
}
|
|
|
|
function gitChecked(cwd, args, options = {}) {
|
|
return runCommandChecked("git", args, { cwd, ...options });
|
|
}
|
|
|
|
function listUniqueFiles(...groups) {
|
|
return [...new Set(groups.flat().filter(Boolean))].sort();
|
|
}
|
|
|
|
function normalizeMaxInlineFiles(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return DEFAULT_INLINE_DIFF_MAX_FILES;
|
|
}
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function normalizeMaxInlineDiffBytes(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return DEFAULT_INLINE_DIFF_MAX_BYTES;
|
|
}
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function measureGitOutputBytes(cwd, args, maxBytes) {
|
|
const result = git(cwd, args, { maxBuffer: maxBytes + 1 });
|
|
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOBUFS") {
|
|
return maxBytes + 1;
|
|
}
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(formatCommandFailure(result));
|
|
}
|
|
return Buffer.byteLength(result.stdout, "utf8");
|
|
}
|
|
|
|
function measureCombinedGitOutputBytes(cwd, argSets, maxBytes) {
|
|
let totalBytes = 0;
|
|
for (const args of argSets) {
|
|
const remainingBytes = maxBytes - totalBytes;
|
|
if (remainingBytes < 0) {
|
|
return maxBytes + 1;
|
|
}
|
|
totalBytes += measureGitOutputBytes(cwd, args, remainingBytes);
|
|
if (totalBytes > maxBytes) {
|
|
return totalBytes;
|
|
}
|
|
}
|
|
return totalBytes;
|
|
}
|
|
|
|
function buildBranchComparison(cwd, baseRef) {
|
|
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
|
|
return {
|
|
mergeBase,
|
|
commitRange: `${mergeBase}..HEAD`,
|
|
reviewRange: `${baseRef}...HEAD`
|
|
};
|
|
}
|
|
|
|
export function ensureGitRepository(cwd) {
|
|
const result = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
const errorCode = result.error && "code" in result.error ? result.error.code : null;
|
|
if (errorCode === "ENOENT") {
|
|
throw new Error("git is not installed. Install Git and retry.");
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error("This command must run inside a Git repository.");
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
export function getRepoRoot(cwd) {
|
|
return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
|
|
}
|
|
|
|
export function detectDefaultBranch(cwd) {
|
|
const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
if (symbolic.status === 0) {
|
|
const remoteHead = symbolic.stdout.trim();
|
|
if (remoteHead.startsWith("refs/remotes/origin/")) {
|
|
return remoteHead.replace("refs/remotes/origin/", "");
|
|
}
|
|
}
|
|
|
|
const candidates = ["main", "master", "trunk"];
|
|
for (const candidate of candidates) {
|
|
const local = git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`]);
|
|
if (local.status === 0) {
|
|
return candidate;
|
|
}
|
|
const remote = git(cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`]);
|
|
if (remote.status === 0) {
|
|
return `origin/${candidate}`;
|
|
}
|
|
}
|
|
|
|
throw new Error("Unable to detect the repository default branch. Pass --base <ref> or use --scope working-tree.");
|
|
}
|
|
|
|
export function getCurrentBranch(cwd) {
|
|
return gitChecked(cwd, ["branch", "--show-current"]).stdout.trim() || "HEAD";
|
|
}
|
|
|
|
export function getWorkingTreeState(cwd) {
|
|
const staged = gitChecked(cwd, ["diff", "--cached", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
|
|
const unstaged = gitChecked(cwd, ["diff", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
|
|
const untracked = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean);
|
|
|
|
return {
|
|
staged,
|
|
unstaged,
|
|
untracked,
|
|
isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0
|
|
};
|
|
}
|
|
|
|
export function resolveReviewTarget(cwd, options = {}) {
|
|
ensureGitRepository(cwd);
|
|
|
|
const requestedScope = options.scope ?? "auto";
|
|
const baseRef = options.base ?? null;
|
|
const state = getWorkingTreeState(cwd);
|
|
const supportedScopes = new Set(["auto", "working-tree", "branch"]);
|
|
|
|
if (baseRef) {
|
|
return {
|
|
mode: "branch",
|
|
label: `branch diff against ${baseRef}`,
|
|
baseRef,
|
|
explicit: true
|
|
};
|
|
}
|
|
|
|
if (requestedScope === "working-tree") {
|
|
return {
|
|
mode: "working-tree",
|
|
label: "working tree diff",
|
|
explicit: true
|
|
};
|
|
}
|
|
|
|
if (!supportedScopes.has(requestedScope)) {
|
|
throw new Error(
|
|
`Unsupported review scope "${requestedScope}". Use one of: auto, working-tree, branch, or pass --base <ref>.`
|
|
);
|
|
}
|
|
|
|
if (requestedScope === "branch") {
|
|
const detectedBase = detectDefaultBranch(cwd);
|
|
return {
|
|
mode: "branch",
|
|
label: `branch diff against ${detectedBase}`,
|
|
baseRef: detectedBase,
|
|
explicit: true
|
|
};
|
|
}
|
|
|
|
if (state.isDirty) {
|
|
return {
|
|
mode: "working-tree",
|
|
label: "working tree diff",
|
|
explicit: false
|
|
};
|
|
}
|
|
|
|
const detectedBase = detectDefaultBranch(cwd);
|
|
return {
|
|
mode: "branch",
|
|
label: `branch diff against ${detectedBase}`,
|
|
baseRef: detectedBase,
|
|
explicit: false
|
|
};
|
|
}
|
|
|
|
function formatSection(title, body) {
|
|
return [`## ${title}`, "", body.trim() ? body.trim() : "(none)", ""].join("\n");
|
|
}
|
|
|
|
function formatUntrackedFile(cwd, relativePath) {
|
|
const absolutePath = path.join(cwd, relativePath);
|
|
let stat;
|
|
try {
|
|
stat = fs.statSync(absolutePath);
|
|
} catch {
|
|
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
|
}
|
|
if (stat.isDirectory()) {
|
|
return `### ${relativePath}\n(skipped: directory)`;
|
|
}
|
|
if (stat.size > MAX_UNTRACKED_BYTES) {
|
|
return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`;
|
|
}
|
|
|
|
let buffer;
|
|
try {
|
|
buffer = fs.readFileSync(absolutePath);
|
|
} catch {
|
|
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
|
}
|
|
if (!isProbablyText(buffer)) {
|
|
return `### ${relativePath}\n(skipped: binary file)`;
|
|
}
|
|
|
|
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
|
|
}
|
|
|
|
function collectWorkingTreeContext(cwd, state, options = {}) {
|
|
const includeDiff = options.includeDiff !== false;
|
|
const status = gitChecked(cwd, ["status", "--short", "--untracked-files=all"]).stdout.trim();
|
|
const changedFiles = listUniqueFiles(state.staged, state.unstaged, state.untracked);
|
|
|
|
let parts;
|
|
if (includeDiff) {
|
|
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
|
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
|
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
|
parts = [
|
|
formatSection("Git Status", status),
|
|
formatSection("Staged Diff", stagedDiff),
|
|
formatSection("Unstaged Diff", unstagedDiff),
|
|
formatSection("Untracked Files", untrackedBody)
|
|
];
|
|
} else {
|
|
const stagedStat = gitChecked(cwd, ["diff", "--shortstat", "--cached"]).stdout.trim();
|
|
const unstagedStat = gitChecked(cwd, ["diff", "--shortstat"]).stdout.trim();
|
|
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
|
parts = [
|
|
formatSection("Git Status", status),
|
|
formatSection("Staged Diff Stat", stagedStat),
|
|
formatSection("Unstaged Diff Stat", unstagedStat),
|
|
formatSection("Changed Files", changedFiles.join("\n")),
|
|
formatSection("Untracked Files", untrackedBody)
|
|
];
|
|
}
|
|
|
|
return {
|
|
mode: "working-tree",
|
|
summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
|
|
content: parts.join("\n"),
|
|
changedFiles
|
|
};
|
|
}
|
|
|
|
function collectBranchContext(cwd, baseRef, options = {}) {
|
|
const includeDiff = options.includeDiff !== false;
|
|
const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
|
|
const currentBranch = getCurrentBranch(cwd);
|
|
const changedFiles = gitChecked(cwd, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean);
|
|
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", comparison.commitRange]).stdout.trim();
|
|
const diffStat = gitChecked(cwd, ["diff", "--stat", comparison.commitRange]).stdout.trim();
|
|
|
|
return {
|
|
mode: "branch",
|
|
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${comparison.mergeBase}.`,
|
|
content: includeDiff
|
|
? [
|
|
formatSection("Commit Log", logOutput),
|
|
formatSection("Diff Stat", diffStat),
|
|
formatSection(
|
|
"Branch Diff",
|
|
gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange]).stdout
|
|
)
|
|
].join("\n")
|
|
: [
|
|
formatSection("Commit Log", logOutput),
|
|
formatSection("Diff Stat", diffStat),
|
|
formatSection("Changed Files", changedFiles.join("\n"))
|
|
].join("\n"),
|
|
changedFiles,
|
|
comparison
|
|
};
|
|
}
|
|
|
|
function buildAdversarialCollectionGuidance(options = {}) {
|
|
if (options.includeDiff !== false) {
|
|
return "Use the repository context below as primary evidence.";
|
|
}
|
|
|
|
return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings.";
|
|
}
|
|
|
|
export function collectReviewContext(cwd, target, options = {}) {
|
|
const repoRoot = getRepoRoot(cwd);
|
|
const currentBranch = getCurrentBranch(repoRoot);
|
|
const maxInlineFiles = normalizeMaxInlineFiles(options.maxInlineFiles);
|
|
const maxInlineDiffBytes = normalizeMaxInlineDiffBytes(options.maxInlineDiffBytes);
|
|
let details;
|
|
let includeDiff;
|
|
let diffBytes;
|
|
|
|
if (target.mode === "working-tree") {
|
|
const state = getWorkingTreeState(repoRoot);
|
|
diffBytes = measureCombinedGitOutputBytes(
|
|
repoRoot,
|
|
[
|
|
["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"],
|
|
["diff", "--binary", "--no-ext-diff", "--submodule=diff"]
|
|
],
|
|
maxInlineDiffBytes
|
|
);
|
|
includeDiff =
|
|
options.includeDiff ??
|
|
(listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles &&
|
|
diffBytes <= maxInlineDiffBytes);
|
|
details = collectWorkingTreeContext(repoRoot, state, { includeDiff });
|
|
} else {
|
|
const comparison = buildBranchComparison(repoRoot, target.baseRef);
|
|
const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length;
|
|
diffBytes = measureGitOutputBytes(
|
|
repoRoot,
|
|
["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange],
|
|
maxInlineDiffBytes
|
|
);
|
|
includeDiff = options.includeDiff ?? (fileCount <= maxInlineFiles && diffBytes <= maxInlineDiffBytes);
|
|
details = collectBranchContext(repoRoot, target.baseRef, { includeDiff, comparison });
|
|
}
|
|
|
|
return {
|
|
cwd: repoRoot,
|
|
repoRoot,
|
|
branch: currentBranch,
|
|
target,
|
|
fileCount: details.changedFiles.length,
|
|
diffBytes,
|
|
inputMode: includeDiff ? "inline-diff" : "self-collect",
|
|
collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
|
|
...details
|
|
};
|
|
}
|