feat: add shared diff-source helper (collectDiff)

Unify duplicated diff-collection logic from explain.ts, review.ts,
suggest.ts into a single collectDiff() that handles staged, unstaged,
and piped stdin sources, interactive file selection, diff truncation,
and project context collection.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 01:55:25 +08:00
parent 5959d78e6c
commit 10c7b9a688
+98
View File
@@ -0,0 +1,98 @@
// Shared diff-source helper used by explain, review, suggest, and commit commands.
// Handles the common pattern of: detecting where to get a diff from
// (staged, unstaged, or piped stdin), optionally presenting an interactive
// file selector, applying truncation, and collecting project context.
import { isGitRepo, getStagedFiles, getStagedDiff, getUnstagedFiles, getRepoRoot, applyFileSelection } from "./git";
import { selectFiles } from "./selector";
import { BACK } from "./menu";
import { collectProjectContext } from "./context";
import { isStdinTTY } from "./tty";
export interface DiffSourceResult {
diff: string;
sourceLabel: string;
contextPrefix: string;
back: boolean;
}
const MAX_DIFF_SIZE = 15000;
/**
* Collect a diff from the appropriate source based on flags and TTY state.
*
* - If `unstaged` is true: uses `git diff` (unstaged changes)
* - If TTY (interactive): shows file selector for staged/unstaged, then uses staged diff
* - If piped (non-TTY): reads diff from stdin
*
* Returns the diff, a human-readable source label, project context prefix,
* and whether the user pressed back.
*/
export async function collectDiff(opts: {
unstaged?: boolean;
includeProjectContext?: boolean;
} = {}): Promise<DiffSourceResult> {
const { unstaged = false, includeProjectContext = true } = opts;
let diff: string;
let sourceLabel: string;
let contextPrefix = "";
if (unstaged) {
if (!(await isGitRepo())) {
throw new Error("Not a git repository.");
}
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else if (isStdinTTY()) {
if (!(await isGitRepo())) {
throw new Error("Not a git repository.");
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) {
return { diff: "", sourceLabel, contextPrefix, back: true };
}
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
} else {
// Piped input (non-TTY)
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
}
// Truncate large diffs
if (diff.length > MAX_DIFF_SIZE) {
diff = diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)";
}
// Collect project context for better AI responses
if (includeProjectContext && diff) {
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {
// Context collection is best-effort
}
}
return { diff, sourceLabel, contextPrefix, back: false };
}