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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user