Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Use smart-diff as a library, or as the `smart-diff` CLI binary that ships with t
```ts
import { summarizeGitDiff } from '@mcarvin/smart-diff';

const markdown = await summarizeGitDiff({
const { summary, usage } = await summarizeGitDiff({
from: 'origin/main',
to: 'HEAD',
cwd: '/path/to/repo', // optional; default process.cwd()
Expand All @@ -150,6 +150,8 @@ const markdown = await summarizeGitDiff({
model: 'claude-3-5-sonnet-latest', // optional
maxDiffChars: 120_000, // optional; also see LLM_MAX_DIFF_CHARS
});
// summary: Markdown string
// usage: LlmUsageReport aggregated across every LLM call — see Token usage reporting below
```

### Use as a CLI
Expand All @@ -161,7 +163,7 @@ npx smart-diff --help
```

- `<from>` (required) and `[to]` (default `HEAD`) can be passed positionally or via `--from`/`--to`.
- Instead of `<from>`/`--from`, pass `--from-merge-base <ref>` to resolve `from` as the merge base of `to` and `<ref>` — e.g. `--to develop --from-merge-base main` is the tsgit-native equivalent of `--to develop --from $(git merge-base develop main)`, with no local git binary required. Mutually exclusive with `<from>`/`--from`.
- Pass `--merge-base` / `-b` alongside `<from>`/`--from` to resolve it as the merge base of `to` and `from` instead of using it directly — e.g. `--to develop --from main --merge-base` is the tsgit-native equivalent of `--to develop --from $(git merge-base develop main)`, with no local git binary required.
- Repeatable options (`--include`, `--exclude`, `--commit-include`, `--commit-exclude`) accept multiple flags.
- The Markdown summary is printed to stdout; errors go to stderr and exit with code 1.
- Run `smart-diff --help` for the full flag reference, or `smart-diff --version` for the installed version.
Expand All @@ -173,7 +175,7 @@ npx smart-diff --help
| Option | CLI flag | Description |
|--------|----------|-------------|
| `from` / `to` | `<from>` `[to]` / `--from` / `--to` | Git refs for the range; `to` defaults to `HEAD`. |
| `fromMergeBase` | `--from-merge-base <ref>` | Resolve `from` as the merge base of `to` and `<ref>`, in-process via tsgit — no local git binary needed. Mutually exclusive with `from`/`--from`. |
| `mergeBase` | `--merge-base`, `-b` | Resolve `from` as the merge base of `to` and `from`, in-process via tsgit — no local git binary needed. |
| `cwd` / `git` | `--cwd <path>` | Working directory path, or inject your own `GitClient` instance (library only; see [Lower-level API](#lower-level-api)). |
| `includeFolders` | `--include <path>` | Limit diff to these paths relative to repo root (omit for full repo minus excludes). |
| `excludeFolders` | `--exclude <path>` | Excluded paths, applied client-side to the changed-path list (directory-prefix match), e.g. `node_modules`. |
Expand Down Expand Up @@ -214,7 +216,7 @@ npx smart-diff --help

| Option | CLI flag | Description |
|--------|----------|-------------|
| use `summarizeGitDiffWithUsage` instead | `--usage` | Print the same `LlmUsageReport` as [Token usage reporting](#token-usage-reporting) to stderr, after the Markdown summary on stdout. |
| — (`summarizeGitDiff` always returns `usage`) | `--usage` | Print the same `LlmUsageReport` as [Token usage reporting](#token-usage-reporting) to stderr, after the Markdown summary on stdout. |
| — | `-h`, `--help` | Show CLI help. |
| — | `-v`, `--version` | Print the installed version. |

Expand Down Expand Up @@ -253,12 +255,12 @@ This costs one extra LLM call per batch plus one reduce call, so it's slower and

### Token usage reporting

`summarizeGitDiffWithUsage` returns the same Markdown summary plus token usage aggregated across every LLM call made to produce it — one call by default, or every map-reduce batch plus the reduce call when `mapReduce` is used:
`summarizeGitDiff` returns the Markdown summary plus token usage aggregated across every LLM call made to produce it — one call by default, or every map-reduce batch plus the reduce call when `mapReduce` is used:

```ts
import { summarizeGitDiffWithUsage } from '@mcarvin/smart-diff';
import { summarizeGitDiff } from '@mcarvin/smart-diff';

const { summary, usage } = await summarizeGitDiffWithUsage({ from: 'origin/main' });
const { summary, usage } = await summarizeGitDiff({ from: 'origin/main' });
// usage: { requestCount, inputTokens, outputTokens, totalTokens, cachedInputTokens }
```

Expand All @@ -272,7 +274,7 @@ If you want full control — for example, to configure retries, middlewares, or
import { summarizeGitDiff } from '@mcarvin/smart-diff';
import { createAnthropic } from '@ai-sdk/anthropic';

const md = await summarizeGitDiff({
const { summary } = await summarizeGitDiff({
from: 'origin/main',
llmModelProvider: async () =>
createAnthropic({ apiKey: process.env.MY_ANTHROPIC_KEY })(
Expand Down Expand Up @@ -309,7 +311,7 @@ Only the [lower-level API](#lower-level-api) is affected:
- `buildDiffShapingGitArgs` is removed — `contextLines` and `ignoreWhitespace` are now consumed directly by the diff renderer instead of being turned into `git diff` flags.
- `parseDiffSummary` is removed; build `DiffFileSummary` entries from a `DiffChange` + rendered diff via `buildFileSummary`, and aggregate with `mergeFileSummariesByPath` / `summarizeFiles`.

`summarizeGitDiff`, `summarizeGitDiffWithUsage`, and every CLI flag are unchanged.
Every CLI flag is unchanged. (`summarizeGitDiff`'s return shape changed separately — see [Token usage reporting](#token-usage-reporting).)

## Used By

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"vitest": "4.1.5"
},
"dependencies": {
"@scolladon/tsgit": "3.2.1",
"@scolladon/tsgit": "3.4.0",
"ai": "7.0.58",
"diff": "9.0.0"
},
Expand Down
10 changes: 3 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

import { CliUsageError, HELP_TEXT, parseCliArgs } from "./cliOptions.js";
import { summarizeGitDiff, summarizeGitDiffWithUsage } from "./index.js";
import { summarizeGitDiff } from "./index.js";

function readPackageVersion(): string {
const packageJsonUrl = new URL("../package.json", import.meta.url);
Expand All @@ -27,15 +27,11 @@ async function main(): Promise<void> {
return;
}

const { summary, usage } = await summarizeGitDiff(parsed.options);
process.stdout.write(`${summary}\n`);
if (parsed.reportUsage) {
const { summary, usage } = await summarizeGitDiffWithUsage(parsed.options);
process.stdout.write(`${summary}\n`);
process.stderr.write(`\n[usage] ${JSON.stringify(usage)}\n`);
return;
}

const summary = await summarizeGitDiff(parsed.options);
process.stdout.write(`${summary}\n`);
}

main().catch((err: unknown) => {
Expand Down
24 changes: 9 additions & 15 deletions src/cliOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ export const HELP_TEXT = `smart-diff <from> [to] [options]
Summarizes a git diff between two refs using an LLM, printed as Markdown to stdout.

Arguments:
<from> Start ref (required unless --from-merge-base is set; also settable via --from)
<from> Start ref (required; also settable via --from)
[to] End ref (default: HEAD; also settable via --to)

Core:
--from-merge-base <ref> Resolve <from> as the merge base of --to and <ref>, e.g.
--to develop --from-merge-base main (no local git binary
needed — resolved in-process via tsgit). Mutually
exclusive with <from>/--from.
-b, --merge-base Resolve <from> as the merge base of --to and <from>, e.g.
--to develop --from main --merge-base is the tsgit-native
equivalent of --to develop --from $(git merge-base develop main)
(no local git binary needed — resolved in-process via tsgit).
--cwd <path> Repo working directory (default: process.cwd())
--include <path> Only include this path (repeatable)
--exclude <path> Exclude this path (repeatable)
Expand Down Expand Up @@ -78,7 +78,7 @@ export function parseCliArgs(argv: string[]): ParsedCli {
allowPositionals: true,
options: {
from: { type: "string" },
"from-merge-base": { type: "string" },
"merge-base": { type: "boolean", short: "b" },
to: { type: "string" },
cwd: { type: "string" },
include: { type: "string", multiple: true },
Expand Down Expand Up @@ -108,22 +108,16 @@ export function parseCliArgs(argv: string[]): ParsedCli {
if (values.version) return { kind: "version" };

const from = values.from ?? positionals[0];
const fromMergeBase = values["from-merge-base"];
if (from && fromMergeBase) {
if (!from) {
throw new CliUsageError(
"<from>/--from and --from-merge-base are mutually exclusive.",
);
}
if (!from && !fromMergeBase) {
throw new CliUsageError(
"Missing required <from> ref. Usage: smart-diff <from> [to] [options]\nRun with --help for details.\nAlternatively, pass --from-merge-base <ref> to resolve <from> as the merge base of --to and <ref>.",
"Missing required <from> ref. Usage: smart-diff <from> [to] [options]\nRun with --help for details.",
);
}
const to = values.to ?? positionals[1];

const options: GitDiffAiSummaryOptions = {
from,
fromMergeBase,
mergeBase: values["merge-base"],
to,
cwd: values.cwd,
includeFolders: values.include,
Expand Down
22 changes: 21 additions & 1 deletion src/git/gitDiffOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ export async function getCommits(
}));
}

/**
* Peel an object id down to the commit it (transitively) points at.
* `revParse` on an annotated tag returns the tag object's own oid, not the
* commit it targets, and tsgit's `mergeBase` primitive doesn't peel — so
* feeding a tag oid straight in would report no common ancestor even for a
* perfectly valid ref.
*/
async function peelToCommit(git: GitClient, id: ObjectId): Promise<ObjectId> {
let current = id;
for (;;) {
const object = await git.primitives.readObject(current);
if (object.type !== "tag") return current;
current = object.data.object;
}
}

/**
* Resolve the best common ancestor of `to` and `other`, in-process via tsgit —
* the equivalent of `git merge-base <to> <other>` without a local git binary.
Expand All @@ -66,7 +82,11 @@ export async function getMergeBase(
git.revParse(to),
git.revParse(other),
]);
const [base] = await git.primitives.mergeBase([toId, otherId]);
const [peeledTo, peeledOther] = await Promise.all([
peelToCommit(git, toId),
peelToCommit(git, otherId),
]);
const [base] = await git.primitives.mergeBase([peeledTo, peeledOther]);
if (!base) {
throw new Error(`No merge base found between '${to}' and '${other}'`);
}
Expand Down
44 changes: 14 additions & 30 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
type GenerateSummaryInput,
generateSummary,
generateSummaryWithUsage,
type LlmModelProvider,
type LlmProviderId,
Expand All @@ -24,17 +23,14 @@ import {
} from "./git/index.js";

export type GitDiffAiSummaryOptions = {
/** Start ref (older side of the range). Required. */
from: string;
/**
* Start ref (older side of the range). Required unless `fromMergeBase` is set.
*/
from?: string;
/**
* Resolve `from` as the merge base of `to` and this ref, instead of passing
* an explicit `from`. Equivalent to `--from $(git merge-base <to> <ref>)`,
* resolved in-process via tsgit — no local git binary required. Mutually
* exclusive with `from`.
* Resolve the effective `from` as the merge base of `to` and `from`, instead
* of using `from` directly. Equivalent to `--from $(git merge-base <to>
* <from>)`, resolved in-process via tsgit — no local git binary required.
*/
fromMergeBase?: string;
mergeBase?: boolean;
/** End ref; defaults to `HEAD`. */
to?: string;
/** Working directory of the git repository; defaults to `process.cwd()`. */
Expand Down Expand Up @@ -185,16 +181,11 @@ async function resolveFromRef(
options: GitDiffAiSummaryOptions,
to: string,
): Promise<string> {
if (options.from && options.fromMergeBase) {
throw new Error(
"`from` and `fromMergeBase` are mutually exclusive — pass one or the other.",
);
}
if (options.fromMergeBase) {
return getMergeBase(git, to, options.fromMergeBase);
}
if (!options.from) {
throw new Error("Either `from` or `fromMergeBase` must be provided.");
throw new Error("`from` must be provided.");
}
if (options.mergeBase) {
return getMergeBase(git, to, options.from);
}
return options.from;
}
Expand Down Expand Up @@ -270,20 +261,13 @@ async function prepareSummaryInput(

/**
* Produce an AI-assisted Markdown summary of the git changes between `from` and `to`,
* honoring path filters, commit message include/exclude regexes, optional team label, and optional system prompt.
* honoring path filters, commit message include/exclude regexes, optional team label, and
* optional system prompt. Also returns token usage aggregated across every LLM call made
* to produce the summary (one call, or every map-reduce batch plus the reduce call). See
* {@link LlmUsageReport}.
*/
export async function summarizeGitDiff(
options: GitDiffAiSummaryOptions,
): Promise<string> {
return generateSummary(await prepareSummaryInput(options));
}

/**
* Same as `summarizeGitDiff`, but also returns token usage aggregated across
* every LLM call made to produce the summary. See {@link LlmUsageReport}.
*/
export async function summarizeGitDiffWithUsage(
options: GitDiffAiSummaryOptions,
): Promise<{ summary: string; usage: LlmUsageReport }> {
return generateSummaryWithUsage(await prepareSummaryInput(options));
}
Expand Down
15 changes: 12 additions & 3 deletions test/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,16 @@ describe("cli entrypoint", () => {
(mod) => {
summarizeGitDiffSpy = vi
.spyOn(mod, "summarizeGitDiff")
.mockResolvedValue("## Summary\n\nchanges");
.mockResolvedValue({
summary: "## Summary\n\nchanges",
usage: {
requestCount: 1,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
cachedInputTokens: 0,
},
});
},
);

Expand All @@ -94,11 +103,11 @@ describe("cli entrypoint", () => {
expect(exitCode).toBeUndefined();
});

it("uses summarizeGitDiffWithUsage and reports usage on stderr when --usage is set", async () => {
it("reports usage on stderr when --usage is set", async () => {
const { stdout, stderr, exitCode } = await runCli(
["origin/main", "--usage"],
(mod) => {
vi.spyOn(mod, "summarizeGitDiffWithUsage").mockResolvedValue({
vi.spyOn(mod, "summarizeGitDiff").mockResolvedValue({
summary: "## Summary",
usage: {
requestCount: 1,
Expand Down
32 changes: 13 additions & 19 deletions test/cliOptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,31 +56,25 @@ describe("parseCliArgs", () => {
expect(parsed.options.to).toBe("flag-to");
});

it("accepts --from-merge-base in place of <from>", () => {
const parsed = parseCliArgs([
"--to",
"develop",
"--from-merge-base",
"main",
]);
it("accepts --merge-base alongside <from> to resolve it as a merge base", () => {
const parsed = parseCliArgs(["main", "--to", "develop", "--merge-base"]);
if (parsed.kind !== "run") throw new Error("expected run");
expect(parsed.options.from).toBeUndefined();
expect(parsed.options.fromMergeBase).toBe("main");
expect(parsed.options.from).toBe("main");
expect(parsed.options.mergeBase).toBe(true);
expect(parsed.options.to).toBe("develop");
});

it("throws CliUsageError when both <from> and --from-merge-base are passed", () => {
expect(() =>
parseCliArgs(["origin/main", "--from-merge-base", "main"]),
).toThrow(CliUsageError);
expect(() =>
parseCliArgs(["origin/main", "--from-merge-base", "main"]),
).toThrow(/mutually exclusive/);
it("accepts -b as a shorthand for --merge-base", () => {
const parsed = parseCliArgs(["main", "-b"]);
if (parsed.kind !== "run") throw new Error("expected run");
expect(parsed.options.mergeBase).toBe(true);
});

it("throws CliUsageError when neither <from> nor --from-merge-base are passed", () => {
expect(() => parseCliArgs(["--to", "develop"])).toThrow(CliUsageError);
expect(() => parseCliArgs(["--to", "develop"])).toThrow(
it("throws CliUsageError when <from> is missing even if --merge-base is passed", () => {
expect(() => parseCliArgs(["--to", "develop", "--merge-base"])).toThrow(
CliUsageError,
);
expect(() => parseCliArgs(["--to", "develop", "--merge-base"])).toThrow(
/Missing required <from> ref/,
);
});
Expand Down
Loading