From 10c7b9a68821eec472513509f5c7225fac40b127 Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 18 Jun 2026 01:55:25 +0800 Subject: [PATCH] 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 --- src/diff-source.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/diff-source.ts diff --git a/src/diff-source.ts b/src/diff-source.ts new file mode 100644 index 0000000..b64dfd8 --- /dev/null +++ b/src/diff-source.ts @@ -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 { + 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 }; +}