diff --git a/.changeset/summary.md b/.changeset/summary.md new file mode 100644 index 00000000..21894c3b --- /dev/null +++ b/.changeset/summary.md @@ -0,0 +1,7 @@ +--- +"just-git": patch +--- + +Support `--summary` in `git show`, `git log` and `git diff`. It prints git's create, delete, rename and mode-change lines, after `--stat`, `--shortstat` or `--numstat` output and before a `-p` patch. On its own it replaces the patch. The summary that `commit` and `merge` print now uses the same lines, so a mode change prints `mode change 100644 => 100755 ` instead of a delete and a create, a rename that changes the mode prints the extra mode line, and unusual paths are C-quoted as in git. + +`git show` on a merge commit now prints `--stat`, `--shortstat`, `--numstat` and `--summary` against the first parent, as git does, instead of printing nothing. `git log --graph --format=` now prints the blank line between each commit and its diff output. diff --git a/docs/CLI.md b/docs/CLI.md index e52b0db0..b3a086c1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -465,6 +465,7 @@ Options: --name-status Show names and status of changed files --shortstat Show only the shortstat summary line --numstat Machine-readable insertions/deletions per file + --summary Show creations, deletions, renames and mode changes -U, --unified Generate diffs with lines of context -M, --find-renames Detect renames (enabled by default) -C, --find-copies Detect copies (accepted for compatibility) @@ -609,6 +610,7 @@ Options: --name-only Show only names of changed files --shortstat Show only the shortstat summary line --numstat Machine-readable insertions/deletions per file + --summary Show creations, deletions, renames and mode changes -q, --quiet suppress diff output --graph Draw text-based graph of the commit history --first-parent Follow only the first parent of merge commits @@ -1015,6 +1017,7 @@ Options: --name-status Show names and status of changed files --shortstat Show only the shortstat summary line --numstat Machine-readable insertions/deletions per file + --summary Show creations, deletions, renames and mode changes --format Pretty-print format string --pretty Pretty-print format or preset name ``` diff --git a/docs/FILE_REFERENCE.md b/docs/FILE_REFERENCE.md index 4309753b..07f4969b 100644 --- a/docs/FILE_REFERENCE.md +++ b/docs/FILE_REFERENCE.md @@ -28,8 +28,9 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/commit-summary.ts -- `formatShortstatParts`, `computeDiffStats`, `formatCommitSummary`, `renderStatLines`, `formatDiffStat` +- `formatShortstatParts`, `computeDiffStats`, `formatSummaryLines`, `withSummary`, `formatCommitSummary`, `renderStatLines`, `formatDiffStat` - **interfaces**: `FileStat` +- **types**: `SummaryRename` ### lib/commit-walk.ts diff --git a/src/commands/diff.ts b/src/commands/diff.ts index f2e7ffce..f406f823 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -11,7 +11,13 @@ import { requireGitContext, requireWorkTree, } from "../lib/command-utils.ts"; -import { type FileStat, formatShortstatParts, renderStatLines } from "../lib/commit-summary.ts"; +import { + type FileStat, + formatShortstatParts, + formatSummaryLines, + renderStatLines, + type SummaryRename, +} from "../lib/commit-summary.ts"; import { formatUnifiedDiff, myersDiff, splitLinesWithNL } from "../lib/diff-algorithm.ts"; import { getStage0Entries, readIndex } from "../lib/index.ts"; import { findAllMergeBases } from "../lib/merge.ts"; @@ -119,6 +125,7 @@ export function registerDiffCommand(parent: Command, ext?: GitExtensions) { nameStatus: f().describe("Show names and status of changed files"), shortstat: f().describe("Show only the shortstat summary line"), numstat: f().describe("Machine-readable insertions/deletions per file"), + summary: f().describe("Show creations, deletions, renames and mode changes"), unified: o.number().alias("U").describe("Generate diffs with lines of context"), findRenames: f().alias("M").describe("Detect renames (enabled by default)"), findCopies: f().alias("C").describe("Detect copies (accepted for compatibility)"), @@ -185,7 +192,14 @@ export function registerDiffCommand(parent: Command, ext?: GitExtensions) { if (isError(result)) return result; const contextLines = args.unified; - const output = await formatOutput(gitCtx, result.items, format, contextLines); + // Git drops --summary under the name formats, and --summary on its + // own replaces the patch. + const summary = args.summary && format !== "name-only" && format !== "name-status"; + const output = + summary && format === "unified" + ? { stdout: "", stderr: "", exitCode: 0 } + : await formatOutput(gitCtx, result.items, format, contextLines); + if (summary) output.stdout += formatAsSummary(result.items); if (result.stderr) output.stderr = result.stderr; return output; }, @@ -864,6 +878,20 @@ async function formatAsNumstat(gitCtx: GitContext, items: DiffFileResult[]): Pro return output; } +function formatAsSummary(items: DiffFileResult[]): string { + const diffs: TreeDiffEntry[] = []; + const renames: SummaryRename[] = []; + for (const { path, status, oldPath, oldMode, newMode, similarity } of items) { + if (status === "R" && oldPath) { + renames.push({ oldPath, newPath: path, similarity: similarity ?? 100, oldMode, newMode }); + } else if (status === "A" || status === "D" || status === "M") { + const treeStatus = status === "A" ? "added" : status === "D" ? "deleted" : "modified"; + diffs.push({ path, status: treeStatus, oldMode, newMode }); + } + } + return formatSummaryLines(diffs, renames); +} + async function formatAsStat(gitCtx: GitContext, items: DiffFileResult[]): Promise { const fileStats = await buildFileStats(gitCtx, items); return renderStatLines(fileStats); diff --git a/src/commands/log.ts b/src/commands/log.ts index b96b59a7..16fbdcb1 100644 --- a/src/commands/log.ts +++ b/src/commands/log.ts @@ -8,7 +8,13 @@ import { requireGitContext, requireHead, } from "../lib/command-utils.ts"; -import { computeDiffStats, formatShortstatParts, renderStatLines } from "../lib/commit-summary.ts"; +import { + computeDiffStats, + formatShortstatParts, + formatSummaryLines, + renderStatLines, + withSummary, +} from "../lib/commit-summary.ts"; import { CommitHeap, walkCommits } from "../lib/commit-walk.ts"; import { parseDate } from "../lib/date.ts"; import { formatUnifiedDiff, myersDiff, splitLinesWithNL } from "../lib/diff-algorithm.ts"; @@ -39,6 +45,12 @@ import type { Commit, GitRepo, ObjectId, TreeDiffEntry } from "../lib/types.ts"; import { worktreeHeadCommits } from "../lib/worktree-admin.ts"; import { a, type Command, f, o } from "../parse/index.ts"; +/** The diff each commit is shown with: a main format and/or the --summary lines. */ +interface LogDiffOutput { + format: LogDiffFormat; + summary: boolean; +} + type LogDiffFormat = | "name-status" | "name-only" @@ -73,6 +85,7 @@ export function registerLogCommand(parent: Command, ext?: GitExtensions) { nameOnly: f().describe("Show only names of changed files"), shortstat: f().describe("Show only the shortstat summary line"), numstat: f().describe("Machine-readable insertions/deletions per file"), + summary: f().describe("Show creations, deletions, renames and mode changes"), quiet: quietFlag("suppress diff output"), graph: f().describe("Draw text-based graph of the commit history"), firstParent: f().describe("Follow only the first parent of merge commits"), @@ -241,6 +254,10 @@ export function registerLogCommand(parent: Command, ext?: GitExtensions) { : args.numstat ? "numstat" : null; + // Git drops --summary when a name format is in effect. + const summary = args.summary && diffFormat !== "name-only" && diffFormat !== "name-status"; + const diffOutput: LogDiffOutput | null = + diffFormat || summary ? { format: diffFormat, summary } : null; // ── Graph / reverse conflict ──────────────────────────── const useGraph = args.graph; @@ -326,7 +343,7 @@ export function registerLogCommand(parent: Command, ext?: GitExtensions) { customFormat, presetName, abbrevCommit, - diffFormat, + diffOutput, decoFn, decoRawFn, renameState, @@ -350,9 +367,9 @@ export function registerLogCommand(parent: Command, ext?: GitExtensions) { ]), }; let line = expandFormat(customFormat, fctx); - const diffText = await formatCommitDiff(gitCtx, entry.commit, diffFormat, renameState); - if (diffText) { - line += `\n\n${diffText.replace(/\n$/, "")}`; + const diffText = await formatCommitDiff(gitCtx, entry.commit, diffOutput, renameState); + if (diffText !== null) { + line += `\n\n${diffText}`.replace(/\n$/, ""); } lines.push(line); } @@ -381,10 +398,10 @@ export function registerLogCommand(parent: Command, ext?: GitExtensions) { ]), }; let line = formatPreset(effectivePreset, fctx, idx === 0, abbrevCommit); - const diffText = await formatCommitDiff(gitCtx, entry.commit, diffFormat, renameState); - if (diffText) { + const diffText = await formatCommitDiff(gitCtx, entry.commit, diffOutput, renameState); + if (diffText !== null) { const sep = isOneline ? "\n" : "\n\n"; - line += `${sep}${diffText.replace(/\n$/, "")}`; + line += `${sep}${diffText}`.replace(/\n$/, ""); } lines.push(line); } @@ -606,7 +623,7 @@ async function formatWithGraph( customFormat: string | null, presetName: string | null, abbrevCommit: boolean, - diffFormat: LogDiffFormat, + diffOutput: LogDiffOutput | null, decoFn: ((h: ObjectId) => string) | undefined, decoRawFn: ((h: ObjectId) => string) | undefined, renameState: RenameWarningState, @@ -679,20 +696,20 @@ async function formatWithGraph( const diffText = await formatCommitDiff( gitCtx, entry.commit, - diffFormat, + diffOutput, renameState, statWidth, ); // Diff/stat output gets padding prefixes (columns already settled) - if (diffText) { - const diffLines = diffText.replace(/\n$/, "").split("\n"); - if (isOneline || customFormat !== null) { + if (diffText !== null) { + const diffLines = diffText ? diffText.replace(/\n$/, "").split("\n") : []; + if (isOneline) { for (const dl of diffLines) { output.push(graph.paddingPrefix() + dl); } } else { - // Medium/full format: blank separator before diff + // Blank separator before the diff, as for non-graph output output.push(graph.paddingPrefix()); for (const dl of diffLines) { output.push(graph.paddingPrefix() + dl); @@ -710,15 +727,22 @@ async function formatWithGraph( // ── Diff output for log ───────────────────────────────────────────── +/** + * The diff text shown under a commit, or null when nothing is shown at + * all: no diff output requested, a merge commit, or an empty diff. An + * empty string means the commit has changes but the requested output is + * empty (e.g. --summary on a content-only change); git still prints the + * separator line for it. + */ async function formatCommitDiff( ctx: GitRepo, commit: Commit, - format: LogDiffFormat, + output: LogDiffOutput | null, renameState: RenameWarningState, statWidth?: number, -): Promise { - if (!format) return ""; - if (commit.parents.length >= 2) return ""; +): Promise { + if (!output) return null; + if (commit.parents.length >= 2) return null; const parentTree = commit.parents.length === 1 @@ -730,7 +754,20 @@ async function formatCommitDiff( limit: renameState.limit, }); renameState.needed = Math.max(renameState.needed ?? 0, neededRenameLimit ?? 0) || undefined; + if (remaining.length === 0 && renames.length === 0) return null; + const main = await formatLogDiffFormat(ctx, remaining, renames, output.format, statWidth); + if (!output.summary) return main; + return withSummary(main, formatSummaryLines(remaining, renames), output.format === "patch"); +} + +async function formatLogDiffFormat( + ctx: GitRepo, + remaining: TreeDiffEntry[], + renames: RenamePair[], + format: LogDiffFormat, + statWidth?: number, +): Promise { switch (format) { case "name-only": return logNameOnly(remaining, renames); @@ -744,6 +781,8 @@ async function formatCommitDiff( return logNumstat(ctx, remaining, renames); case "patch": return logPatch(ctx, remaining, renames); + case null: + return ""; } } @@ -775,7 +814,7 @@ async function logStat( renames: RenamePair[], statWidth?: number, ): Promise { - const { fileStats } = await computeDiffStats(ctx, remaining, renames); + const fileStats = await computeDiffStats(ctx, remaining, renames); fileStats.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); return renderStatLines(fileStats, statWidth); } @@ -785,7 +824,7 @@ async function logShortstat( remaining: TreeDiffEntry[], renames: RenamePair[], ): Promise { - const { fileStats } = await computeDiffStats(ctx, remaining, renames); + const fileStats = await computeDiffStats(ctx, remaining, renames); let totalIns = 0; let totalDel = 0; for (const s of fileStats) { diff --git a/src/commands/show.ts b/src/commands/show.ts index 8b514324..91bb856f 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -10,7 +10,13 @@ import { requireHead, requireRevision, } from "../lib/command-utils.ts"; -import { computeDiffStats, formatShortstatParts, renderStatLines } from "../lib/commit-summary.ts"; +import { + computeDiffStats, + formatShortstatParts, + formatSummaryLines, + renderStatLines, + withSummary, +} from "../lib/commit-summary.ts"; import { formatDate } from "../lib/date.ts"; import { formatUnifiedDiff, myersDiff, splitLinesWithNL } from "../lib/diff-algorithm.ts"; import { @@ -47,6 +53,12 @@ const decoder = new TextDecoder(); const QUIET_NAME_CONFLICT = "options '--name-only', '--name-status', '--check', and '-s' cannot be used together"; +/** The diff a commit is shown with: a main format and/or the --summary lines. */ +interface ShowDiffOutput { + format: ShowDiffFormat; + summary: boolean; +} + type ShowDiffFormat = | "patch" | "stat" @@ -69,6 +81,7 @@ export function registerShowCommand(parent: Command, ext?: GitExtensions) { nameStatus: f().describe("Show names and status of changed files"), shortstat: f().describe("Show only the shortstat summary line"), numstat: f().describe("Machine-readable insertions/deletions per file"), + summary: f().describe("Show creations, deletions, renames and mode changes"), format: o.string().describe("Pretty-print format string"), pretty: o.string().describe("Pretty-print format or preset name"), }, @@ -118,11 +131,16 @@ export function registerShowCommand(parent: Command, ext?: GitExtensions) { diffFormat = "shortstat"; } else if (args.numstat) { diffFormat = "numstat"; - } else if (args.quiet && !args.patch) { + } else if ((args.quiet || args.summary) && !args.patch) { diffFormat = null; } else { diffFormat = "patch"; } + // Git drops --summary when a name format or --no-patch is in effect. + const summary = + args.summary && !args.noPatch && diffFormat !== "name-only" && diffFormat !== "name-status"; + const diffOutput: ShowDiffOutput | null = + diffFormat || summary ? { format: diffFormat, summary } : null; // Determine header format const formatRaw = args.format ?? args.pretty; @@ -142,7 +160,7 @@ export function registerShowCommand(parent: Command, ext?: GitExtensions) { hash, commit, renameState, - diffFormat, + diffOutput, customFormat, presetName, ); @@ -158,7 +176,7 @@ export function registerShowCommand(parent: Command, ext?: GitExtensions) { gitCtx, tag, renameState, - diffFormat, + diffOutput, customFormat, presetName, ); @@ -235,7 +253,7 @@ async function formatCommitShow( hash: ObjectId, commit: Commit, renameState: RenameWarningState, - diffFormat: ShowDiffFormat = "patch", + diffOutput: ShowDiffOutput | null, customFormat: string | null = null, presetName: string | null = null, ): Promise { @@ -265,51 +283,85 @@ async function formatCommitShow( header = lines.join("\n"); } - if (!diffFormat) { + if (!diffOutput) { return `${header}\n`; } // ── Diff ──────────────────────────────────────────────────── - let diffOutput = ""; + let body = ""; + let hasChanges = false; if (commit.parents.length <= 1) { const parentTree = commit.parents.length === 1 ? (await readCommit(ctx, commit.parents[0] as ObjectId)).tree : null; - - const rawDiffs = await diffTrees(ctx, parentTree, commit.tree); - const { - remaining: diffs, - renames, - neededRenameLimit, - } = await detectRenames(ctx, rawDiffs, { - limit: renameState.limit, - }); - renameState.needed = Math.max(renameState.needed ?? 0, neededRenameLimit ?? 0) || undefined; - diffOutput = await formatShowDiff(ctx, diffs, renames, diffFormat); + const { diffs, renames } = await diffWithRenames(ctx, parentTree, commit.tree, renameState); + hasChanges = diffs.length > 0 || renames.length > 0; + body = await formatShowDiff(ctx, diffs, renames, diffOutput); } else { - if (diffFormat === "patch") { - diffOutput = (await formatCombinedDiff(ctx, commit)) ?? ""; - } - // For non-patch formats on merge commits, no output (matches real git) + body = await formatMergeShowDiff(ctx, commit, renameState, diffOutput); } - if (diffOutput) { - return `${header}\n\n${diffOutput.replace(/\n$/, "")}\n`; + if (body) { + return `${header}\n\n${body.replace(/\n$/, "")}\n`; } - if (commit.parents.length >= 2) { + // Git still prints the separator line when a commit has changes but the + // requested output is empty, e.g. --summary on a content-only change. + if (hasChanges || commit.parents.length >= 2) { return `${header}\n\n`; } return `${header}\n`; } +async function diffWithRenames( + ctx: GitRepo, + parentTree: ObjectId | null, + tree: ObjectId, + renameState: RenameWarningState, +): Promise<{ diffs: TreeDiffEntry[]; renames: RenamePair[] }> { + const rawDiffs = await diffTrees(ctx, parentTree, tree); + const { remaining, renames, neededRenameLimit } = await detectRenames(ctx, rawDiffs, { + limit: renameState.limit, + }); + renameState.needed = Math.max(renameState.needed ?? 0, neededRenameLimit ?? 0) || undefined; + return { diffs: remaining, renames }; +} + +/** + * Git shows a merge's patch as a combined diff, but its stat formats and + * --summary compare the merge with its first parent. The name formats + * print nothing here. + */ +async function formatMergeShowDiff( + ctx: GitRepo, + commit: Commit, + renameState: RenameWarningState, + output: ShowDiffOutput, +): Promise { + const statFormat = + output.format === "stat" || output.format === "shortstat" || output.format === "numstat" + ? output.format + : null; + let firstParent = ""; + if (statFormat || output.summary) { + const parentTree = (await readCommit(ctx, commit.parents[0] as ObjectId)).tree; + const { diffs, renames } = await diffWithRenames(ctx, parentTree, commit.tree, renameState); + firstParent = await formatShowDiff(ctx, diffs, renames, { + format: statFormat, + summary: output.summary, + }); + } + const combined = output.format === "patch" ? await formatCombinedDiff(ctx, commit) : ""; + return withSummary(combined, firstParent, combined !== ""); +} + // ── Tag display ───────────────────────────────────────────────────── async function formatTagShow( ctx: GitRepo, tag: Tag, renameState: RenameWarningState, - diffFormat: ShowDiffFormat = "patch", + diffOutput: ShowDiffOutput | null, customFormat: string | null = null, presetName: string | null = null, ): Promise { @@ -331,7 +383,7 @@ async function formatTagShow( tag.object, commit, renameState, - diffFormat, + diffOutput, customFormat, presetName, ); @@ -356,6 +408,17 @@ function formatTreeShow(tree: Tree): string { // ── Diff format dispatch ──────────────────────────────────────────── async function formatShowDiff( + ctx: GitRepo, + diffs: TreeDiffEntry[], + renames: RenamePair[], + output: ShowDiffOutput, +): Promise { + const main = await formatShowDiffFormat(ctx, diffs, renames, output.format); + if (!output.summary) return main; + return withSummary(main, formatSummaryLines(diffs, renames), output.format === "patch"); +} + +async function formatShowDiffFormat( ctx: GitRepo, diffs: TreeDiffEntry[], renames: RenamePair[], @@ -406,7 +469,7 @@ async function showStat( diffs: TreeDiffEntry[], renames: RenamePair[], ): Promise { - const { fileStats } = await computeDiffStats(ctx, diffs, renames); + const fileStats = await computeDiffStats(ctx, diffs, renames); fileStats.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); return renderStatLines(fileStats); } @@ -416,7 +479,7 @@ async function showShortstat( diffs: TreeDiffEntry[], renames: RenamePair[], ): Promise { - const { fileStats } = await computeDiffStats(ctx, diffs, renames); + const fileStats = await computeDiffStats(ctx, diffs, renames); let totalIns = 0; let totalDel = 0; for (const s of fileStats) { diff --git a/src/lib/commit-summary.ts b/src/lib/commit-summary.ts index a3dfa6a7..4ed30b71 100644 --- a/src/lib/commit-summary.ts +++ b/src/lib/commit-summary.ts @@ -1,6 +1,7 @@ /** - * Shared commit summary formatting (shortstat + mode lines). - * Used by git commit, git cherry-pick, and git merge. + * Shared commit summary formatting (shortstat, diffstat and --summary + * lines). Used by git commit, cherry-pick and merge, and by the --stat + * and --summary output of show, log and diff. */ import { formatDate } from "./date.ts"; import { myersDiff, splitLinesWithNL } from "./diff-algorithm.ts"; @@ -52,18 +53,15 @@ export function formatShortstatParts( } /** - * Compute per-file diff stats (insertions/deletions/binary info) and - * mode lines for a set of tree diffs and renames. Shared by - * formatCommitSummary and formatDiffStat. + * Compute per-file diff stats (insertions/deletions/binary info) for a + * set of tree diffs and renames. */ export async function computeDiffStats( ctx: GitRepo, diffs: TreeDiffEntry[], renames: RenamePair[], -): Promise<{ fileStats: FileStat[]; modeLines: string[] }> { +): Promise { const fileStats: FileStat[] = []; - const createModes: { path: string; mode: string }[] = []; - const deleteModes: { path: string; mode: string }[] = []; for (const diff of diffs) { if (diff.status === "added" && diff.newHash && diff.newMode) { @@ -86,7 +84,6 @@ export async function computeDiffStats( deletions: 0, }); } - createModes.push({ path: diff.path, mode: diff.newMode }); } else if (diff.status === "deleted" && diff.oldHash && diff.oldMode) { const bytes = await readBlobBytes(ctx, diff.oldHash); if (isBinaryBytes(bytes)) { @@ -107,7 +104,6 @@ export async function computeDiffStats( deletions: countLines(textDecoder.decode(bytes)), }); } - deleteModes.push({ path: diff.path, mode: diff.oldMode }); } else if (diff.status === "modified" && diff.oldHash && diff.newHash) { const oldBytes = await readBlobBytes(ctx, diff.oldHash); const newBytes = await readBlobBytes(ctx, diff.newHash); @@ -138,10 +134,6 @@ export async function computeDiffStats( deletions: del, }); } - if (diff.oldMode && diff.newMode && diff.oldMode !== diff.newMode) { - deleteModes.push({ path: diff.path, mode: diff.oldMode }); - createModes.push({ path: diff.path, mode: diff.newMode }); - } } } @@ -170,23 +162,99 @@ export async function computeDiffStats( }); } - const modeLines: { sortKey: string; text: string }[] = []; - for (const { path, mode } of createModes) { - modeLines.push({ sortKey: path, text: ` create mode ${mode} ${path}` }); + return fileStats; +} + +const C_QUOTE_ESCAPES: Record = { + 0x07: "\\a", + 0x08: "\\b", + 0x09: "\\t", + 0x0a: "\\n", + 0x0b: "\\v", + 0x0c: "\\f", + 0x0d: "\\r", + 0x22: '\\"', + 0x5c: "\\\\", +}; + +/** Quote a path the way git's `quote_c_style` does with `core.quotePath` on. */ +function quoteCPath(path: string): string { + let out = ""; + let quoted = false; + for (const byte of new TextEncoder().encode(path)) { + const escape = C_QUOTE_ESCAPES[byte]; + if (escape) { + out += escape; + quoted = true; + } else if (byte < 0x20 || byte >= 0x7f) { + out += `\\${byte.toString(8).padStart(3, "0")}`; + quoted = true; + } else { + out += String.fromCharCode(byte); + } } - for (const { path, mode } of deleteModes) { - modeLines.push({ sortKey: path, text: ` delete mode ${mode} ${path}` }); + return quoted ? `"${out}"` : path; +} + +/** The parts of a rename that `--summary` prints. */ +export type SummaryRename = Pick< + RenamePair, + "oldPath" | "newPath" | "similarity" | "oldMode" | "newMode" +>; + +/** + * Format git's `--summary` lines: one line per created, deleted or + * mode-changed path, and a rename line (followed by a pathless mode + * line when the mode changed too) per rename. Sorted by path, with + * renames at their new path. Shared by `show`, `log`, `diff` and the + * summaries printed after `commit` and `merge`. + */ +export function formatSummaryLines( + diffs: readonly TreeDiffEntry[], + renames: readonly SummaryRename[], +): string { + const modeChange = (oldMode?: string, newMode?: string) => + oldMode && newMode && oldMode !== newMode ? ` mode change ${oldMode} => ${newMode}` : null; + + const entries: { sortKey: string; text: string }[] = []; + for (const diff of diffs) { + const path = quoteCPath(diff.path); + let text: string | null = null; + if (diff.status === "added") { + text = ` create mode ${diff.newMode} ${path}\n`; + } else if (diff.status === "deleted") { + text = ` delete mode ${diff.oldMode} ${path}\n`; + } else { + const line = modeChange(diff.oldMode, diff.newMode); + if (line) text = `${line} ${path}\n`; + } + if (text) entries.push({ sortKey: diff.path, text }); } for (const rename of renames) { - const d = formatRenamePath(rename.oldPath, rename.newPath); - modeLines.push({ + const oldQuoted = quoteCPath(rename.oldPath); + const newQuoted = quoteCPath(rename.newPath); + const display = + oldQuoted !== rename.oldPath || newQuoted !== rename.newPath + ? `${oldQuoted} => ${newQuoted}` + : formatRenamePath(rename.oldPath, rename.newPath); + const line = modeChange(rename.oldMode, rename.newMode); + entries.push({ sortKey: rename.newPath, - text: ` rename ${d} (${rename.similarity}%)`, + text: ` rename ${display} (${rename.similarity}%)\n${line ? `${line}\n` : ""}`, }); } - modeLines.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); + entries.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); + return entries.map((e) => e.text).join(""); +} - return { fileStats, modeLines: modeLines.map((m) => m.text) }; +/** + * Combine a diff's main output with its `--summary` lines in git's + * order: the summary follows the stat formats, and comes before a patch + * with a blank line between them. + */ +export function withSummary(main: string, summary: string, mainIsPatch: boolean): string { + if (mainIsPatch && summary) return `${summary}\n${main}`; + return main + summary; } /** @@ -223,7 +291,7 @@ export async function formatCommitSummary( const { remaining: diffs, renames } = await detectRenames(ctx, rawDiffs, { limit: renameLimit, }); - const { fileStats, modeLines } = await computeDiffStats(ctx, diffs, renames); + const fileStats = await computeDiffStats(ctx, diffs, renames); let totalInsertions = 0; let totalDeletions = 0; @@ -234,9 +302,9 @@ export async function formatCommitSummary( const shortstat = formatShortstatParts(fileStats.length, totalInsertions, totalDeletions); if (shortstat) lines.push(shortstat); - for (const ml of modeLines) lines.push(ml); - return lines.join("\n") + (lines.length > 0 ? "\n" : ""); + const head = lines.join("\n") + (lines.length > 0 ? "\n" : ""); + return head + formatSummaryLines(diffs, renames); } // ── Diffstat formatting (for merge/FF output) ─────────────────── @@ -407,14 +475,9 @@ export async function formatDiffStat( }); if (diffs.length === 0 && renames.length === 0) return ""; - const { fileStats, modeLines } = await computeDiffStats(ctx, diffs, renames); + const fileStats = await computeDiffStats(ctx, diffs, renames); fileStats.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); - let output = renderStatLines(fileStats); - for (const ml of modeLines) { - output += `${ml}\n`; - } - - return output; + return renderStatLines(fileStats) + formatSummaryLines(diffs, renames); } diff --git a/test/commands/commit.test.ts b/test/commands/commit.test.ts index 9dcf482d..e778c179 100644 --- a/test/commands/commit.test.ts +++ b/test/commands/commit.test.ts @@ -176,6 +176,39 @@ describe("git commit", () => { }); }); + describe("summary lines", () => { + test("print mode changes and renames that change mode like git", async () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}\n`).join(""); + const bash = createTestBash({ + files: { "/repo/a": "a\n", "/repo/big": lines(20) }, + env: TEST_ENV, + }); + await bash.exec("git init"); + await bash.exec("git add ."); + await bash.exec('git commit -m "first"'); + await bash.fs.writeFile("/repo/big", lines(15)); + await bash.exec("git mv big big2"); + await bash.exec("chmod +x a big2"); + await bash.fs.writeFile('/repo/q"d', "q\n"); + await bash.exec("git add -A"); + + const result = await bash.exec('git commit -m "modes"'); + const short = (await bash.exec("git rev-parse --short HEAD")).stdout.trim(); + expect(result).toMatchObject({ + stdout: + `[main ${short}] modes\n` + + " Author: Test Author \n" + + " 3 files changed, 1 insertion(+), 5 deletions(-)\n" + + " mode change 100644 => 100755 a\n" + + " rename big => big2 (70%)\n" + + " mode change 100644 => 100755\n" + + ' create mode 100644 "q\\"d"\n', + stderr: "", + exitCode: 0, + }); + }); + }); + describe("--allow-empty", () => { test("creates a commit with no staged changes", async () => { const { results } = await runScenario( diff --git a/test/commands/diff.test.ts b/test/commands/diff.test.ts index fdcdaeea..5ac993e2 100644 --- a/test/commands/diff.test.ts +++ b/test/commands/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, observeFsCalls, quickExec } from "../util"; +import { createTestBash, observeFsCalls, quickExec, setupSummaryHistory } from "../util"; describe("git diff", () => { describe("outside a git repo", () => { @@ -1194,4 +1194,92 @@ describe("git diff", () => { expect(result.stderr).toContain("too many arguments"); }); }); + describe("--summary", () => { + const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 }); + + async function setupStaged() { + const bash = await setupSummaryHistory(); + await bash.exec("git mv keep keep2"); + await bash.exec("echo new > newf"); + await bash.exec("git add newf"); + await bash.exec("rm x/y/b"); + return bash; + } + + test("prints only the summary lines between two commits", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git diff --summary HEAD~5 HEAD~3")).toMatchObject( + ok( + " rename big => big2 (70%)\n" + + " mode change 100644 => 100755\n" + + " rename dir/sub/{a => b} (100%)\n" + + " create mode 120000 link\n" + + " mode change 100644 => 100755 n\n" + + ' create mode 100644 "q\\"uote"\n' + + " create mode 100644 sp ace\n" + + ' create mode 100644 "ta\\tb"\n' + + ' create mode 100644 "\\303\\251.txt"\n', + ), + ); + }); + + test("follows --stat", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git diff --stat --summary HEAD~5 HEAD~4")).toMatchObject( + ok( + " big => big2 | 5 -----\n" + + " n | 0\n" + + " 2 files changed, 5 deletions(-)\n" + + " rename big => big2 (70%)\n" + + " mode change 100644 => 100755\n" + + " mode change 100644 => 100755 n\n", + ), + ); + }); + + test("--cached summarizes staged renames and additions", async () => { + const bash = await setupStaged(); + const summary = " rename keep => keep2 (100%)\n create mode 100644 newf\n"; + expect(await bash.exec("git diff --cached --summary")).toMatchObject(ok(summary)); + expect(await bash.exec("git diff --cached --stat --summary")).toMatchObject( + ok( + " keep => keep2 | 0\n" + + " newf | 1 +\n" + + " 2 files changed, 1 insertion(+)\n" + + summary, + ), + ); + expect(await bash.exec("git diff --cached --shortstat --summary")).toMatchObject( + ok(` 2 files changed, 1 insertion(+)\n${summary}`), + ); + expect(await bash.exec("git diff --cached --numstat --summary")).toMatchObject( + ok(`0\t0\tkeep => keep2\n1\t0\tnewf\n${summary}`), + ); + }); + + test("summarizes unstaged deletions", async () => { + const bash = await setupStaged(); + expect(await bash.exec("git diff --summary")).toMatchObject( + ok(" delete mode 100644 x/y/b\n"), + ); + expect(await bash.exec("git diff --stat --summary")).toMatchObject( + ok(" x/y/b | 1 -\n 1 file changed, 1 deletion(-)\n delete mode 100644 x/y/b\n"), + ); + }); + + test("prints nothing for content-only changes", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git diff --summary HEAD~2 HEAD~1")).toMatchObject(ok("")); + }); + + test("is dropped by --name-only and --name-status", async () => { + const bash = await setupStaged(); + for (const opts of ["--name-only", "--name-status"]) { + const plain = await bash.exec(`git diff --cached ${opts}`); + expect(await bash.exec(`git diff --cached ${opts} --summary`)).toMatchObject( + ok(plain.stdout), + ); + } + }); + }); }); diff --git a/test/commands/log.test.ts b/test/commands/log.test.ts index 9b0fd925..763df4d9 100644 --- a/test/commands/log.test.ts +++ b/test/commands/log.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, runScenario } from "../util"; +import { createTestBash, quickExec, runScenario, setupSummaryHistory } from "../util"; describe("git log", () => { describe("outside a git repo", () => { @@ -1514,4 +1514,123 @@ describe("git log", () => { expect(result.stderr).toContain("fatal: ambiguous argument 'nope'"); }); }); + describe("--summary", () => { + const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 }); + + test("prints the summary under each commit and nothing for merges", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git log --summary --format=%s -6")).toMatchObject( + ok( + "merge side\n" + + "content only\n" + + "\n" + + "side\n" + + "\n" + + " create mode 100644 s\n" + + "typechange\n" + + "\n" + + " mode change 100755 => 120000 n\n" + + " rename {dir/sub => x/y}/b (100%)\n" + + "more\n" + + "\n" + + " rename dir/sub/{a => b} (100%)\n" + + " create mode 120000 link\n" + + ' create mode 100644 "q\\"uote"\n' + + " create mode 100644 sp ace\n" + + ' create mode 100644 "ta\\tb"\n' + + ' create mode 100644 "\\303\\251.txt"\n' + + "modes\n" + + "\n" + + " rename big => big2 (70%)\n" + + " mode change 100644 => 100755\n" + + " mode change 100644 => 100755 n\n", + ), + ); + }); + + test("keeps the blank line for a commit with no summary lines", async () => { + const bash = await setupSummaryHistory(); + const header = async (rev: string) => (await bash.exec(`git show --no-patch ${rev}`)).stdout; + expect(await bash.exec("git log --summary -3")).toMatchObject( + ok( + `${await header("HEAD")}\n` + + `${await header("HEAD~1")}\n\n` + + `${await header("side")}\n` + + " create mode 100644 s\n", + ), + ); + }); + + test("--oneline puts the summary right under each line", async () => { + const bash = await setupSummaryHistory(); + const oneline = (await bash.exec("git log --oneline -5")).stdout.split("\n"); + expect(await bash.exec("git log --oneline --summary -5")).toMatchObject( + ok( + `${oneline[0]}\n` + + `${oneline[1]}\n` + + `${oneline[2]}\n` + + " create mode 100644 s\n" + + `${oneline[3]}\n` + + " mode change 100755 => 120000 n\n" + + " rename {dir/sub => x/y}/b (100%)\n" + + `${oneline[4]}\n` + + " rename dir/sub/{a => b} (100%)\n" + + " create mode 120000 link\n" + + ' create mode 100644 "q\\"uote"\n' + + " create mode 100644 sp ace\n" + + ' create mode 100644 "ta\\tb"\n' + + ' create mode 100644 "\\303\\251.txt"\n', + ), + ); + }); + + test("follows --stat", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git log --stat --summary --format=%s -2 HEAD~4")).toMatchObject( + ok( + "modes\n\n" + + " big => big2 | 5 -----\n" + + " n | 0\n" + + " 2 files changed, 5 deletions(-)\n" + + " rename big => big2 (70%)\n" + + " mode change 100644 => 100755\n" + + " mode change 100644 => 100755 n\n" + + "sum\n\n" + + " m | 1 -\n" + + " n | 1 +\n" + + " z => z2 | 0\n" + + " 3 files changed, 1 insertion(+), 1 deletion(-)\n" + + " delete mode 100755 m\n" + + " create mode 100644 n\n" + + " rename z => z2 (100%)\n", + ), + ); + }); + + test("comes before the patch with -p, separated by a blank line", async () => { + const bash = await setupSummaryHistory(); + const patch = (await bash.exec("git log -p --format=%s -1 HEAD~5")).stdout; + expect(patch).toStartWith("sum\n\ndiff --git a/m b/m\n"); + expect(await bash.exec("git log -p --summary --format=%s -1 HEAD~5")).toMatchObject( + ok( + "sum\n\n" + + " delete mode 100755 m\n" + + " create mode 100644 n\n" + + " rename z => z2 (100%)\n" + + "\n" + + patch.slice("sum\n\n".length), + ), + ); + }); + + test("is dropped by --name-only and --name-status", async () => { + const bash = await setupSummaryHistory(); + for (const opts of ["--name-only", "--name-status"]) { + const plain = await bash.exec(`git log ${opts} -3 HEAD~3`); + expect(await bash.exec(`git log ${opts} --summary -3 HEAD~3`)).toMatchObject( + ok(plain.stdout), + ); + } + }); + }); }); diff --git a/test/commands/show.test.ts b/test/commands/show.test.ts index ad268834..6933e178 100644 --- a/test/commands/show.test.ts +++ b/test/commands/show.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, runScenario } from "../util"; +import { createTestBash, quickExec, runScenario, setupSummaryHistory } from "../util"; describe("git show", () => { describe("outside a git repo", () => { @@ -121,6 +121,22 @@ describe("git show", () => { // Merge commits should NOT include diff output expect(show.stdout).not.toContain("diff --git"); }); + + test("stat formats compare a merge with its first parent", async () => { + const bash = await setupSummaryHistory(); + const cases: [string, string][] = [ + ["--stat", " s | 1 +\n 1 file changed, 1 insertion(+)\n"], + ["--shortstat", " 1 file changed, 1 insertion(+)\n"], + ["--numstat", "1\t0\ts\n"], + ]; + for (const [opts, diff] of cases) { + expect(await bash.exec(`git show ${opts} --format=%s HEAD`)).toMatchObject({ + stdout: `merge side\n\n${diff}`, + stderr: "", + exitCode: 0, + }); + } + }); }); describe("show annotated tag", () => { @@ -478,6 +494,132 @@ describe("git show", () => { expect(tag.stdout).not.toContain("diff --git"); }); }); + + describe("--summary", () => { + const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 }); + + test("replaces the patch with the summary lines", async () => { + const bash = await setupSummaryHistory(); + const header = (await bash.exec("git show --no-patch HEAD~5")).stdout; + expect(await bash.exec("git show --summary HEAD~5")).toMatchObject( + ok( + `${header}\n` + + " delete mode 100755 m\n" + + " create mode 100644 n\n" + + " rename z => z2 (100%)\n", + ), + ); + }); + + test("follows --stat", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git show --stat --summary --format=%s HEAD~4")).toMatchObject( + ok( + "modes\n\n" + + " big => big2 | 5 -----\n" + + " n | 0\n" + + " 2 files changed, 5 deletions(-)\n" + + " rename big => big2 (70%)\n" + + " mode change 100644 => 100755\n" + + " mode change 100644 => 100755 n\n", + ), + ); + }); + + test("follows --shortstat and --numstat", async () => { + const bash = await setupSummaryHistory(); + const summary = " delete mode 100755 m\n create mode 100644 n\n rename z => z2 (100%)\n"; + expect(await bash.exec("git show --shortstat --summary --format=%s HEAD~5")).toMatchObject( + ok(`sum\n\n 3 files changed, 1 insertion(+), 1 deletion(-)\n${summary}`), + ); + expect(await bash.exec("git show --numstat --summary --format=%s HEAD~5")).toMatchObject( + ok(`sum\n\n0\t1\tm\n1\t0\tn\n0\t0\tz => z2\n${summary}`), + ); + }); + + test("comes before the patch with -p, separated by a blank line", async () => { + const bash = await setupSummaryHistory(); + const patch = (await bash.exec("git show -p --format=%s HEAD~5")).stdout; + expect(patch).toStartWith("sum\n\ndiff --git a/m b/m\n"); + expect(await bash.exec("git show -p --summary --format=%s HEAD~5")).toMatchObject( + ok( + "sum\n\n" + + " delete mode 100755 m\n" + + " create mode 100644 n\n" + + " rename z => z2 (100%)\n" + + "\n" + + patch.slice("sum\n\n".length), + ), + ); + }); + + test("prints symlinks, quoted names and renames with a common prefix", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git show --summary --format=%s HEAD~3")).toMatchObject( + ok( + "more\n\n" + + " rename dir/sub/{a => b} (100%)\n" + + " create mode 120000 link\n" + + ' create mode 100644 "q\\"uote"\n' + + " create mode 100644 sp ace\n" + + ' create mode 100644 "ta\\tb"\n' + + ' create mode 100644 "\\303\\251.txt"\n', + ), + ); + }); + + test("prints a type change as a mode change", async () => { + const bash = await setupSummaryHistory(); + expect(await bash.exec("git show --summary --format=%s HEAD~2")).toMatchObject( + ok( + "typechange\n\n" + + " mode change 100755 => 120000 n\n" + + " rename {dir/sub => x/y}/b (100%)\n", + ), + ); + }); + + test("keeps the blank line when a commit has no summary lines", async () => { + const bash = await setupSummaryHistory(); + const header = (await bash.exec("git show --no-patch HEAD~1")).stdout; + expect(await bash.exec("git show --summary HEAD~1")).toMatchObject(ok(`${header}\n`)); + expect(await bash.exec("git show --summary --format=%s HEAD~1")).toMatchObject( + ok("content only\n\n"), + ); + }); + + test("is dropped by --name-only, --name-status and --no-patch", async () => { + const bash = await setupSummaryHistory(); + for (const opts of ["--name-only", "--name-status", "--no-patch"]) { + const plain = await bash.exec(`git show ${opts} HEAD~5`); + expect(await bash.exec(`git show ${opts} --summary HEAD~5`)).toMatchObject( + ok(plain.stdout), + ); + } + }); + + test("compares a merge with its first parent", async () => { + const bash = await setupSummaryHistory(); + const header = (await bash.exec("git show --no-patch HEAD")).stdout; + expect(await bash.exec("git show --summary HEAD")).toMatchObject( + ok(`${header}\n create mode 100644 s\n`), + ); + expect(await bash.exec("git show --stat --summary --format=%s HEAD")).toMatchObject( + ok("merge side\n\n s | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 s\n"), + ); + }); + + test("applies to the commit an annotated tag points at", async () => { + const bash = await setupSummaryHistory(); + await bash.exec("git tag -a v1 -m release HEAD~5"); + const result = await bash.exec("git show --summary v1"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toStartWith("tag v1\n"); + expect(result.stdout).toEndWith( + " sum\n\n delete mode 100755 m\n create mode 100644 n\n rename z => z2 (100%)\n", + ); + }); + }); }); // ── Helpers ───────────────────────────────────────────────────────── diff --git a/test/graph/scenarios/09-graph-with-diffs.ts b/test/graph/scenarios/09-graph-with-diffs.ts index e8041805..a3803096 100644 --- a/test/graph/scenarios/09-graph-with-diffs.ts +++ b/test/graph/scenarios/09-graph-with-diffs.ts @@ -20,5 +20,7 @@ export default { "git log --graph --oneline --stat", "git log --graph -p -n2", "git log --graph --stat", + "git log --graph --format=%s --stat", + 'git log --graph --format="%h %s" --name-status', ], } satisfies GraphScenario; diff --git a/test/graph/scenarios/16-graph-with-summary.ts b/test/graph/scenarios/16-graph-with-summary.ts new file mode 100644 index 00000000..5cb326e9 --- /dev/null +++ b/test/graph/scenarios/16-graph-with-summary.ts @@ -0,0 +1,37 @@ +import type { GraphScenario } from "../types"; + +const lines = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}\n`).join(""); + +export default { + description: "Graph with --summary, including a commit with no summary lines", + steps: [ + "git init", + "git add .", + 'git commit -m "initial"', + { write: "big", content: lines(20) }, + { write: "m", content: "m\n" }, + "chmod +x m", + "git add .", + 'git commit -m "add files"', + "git checkout -b side", + { write: "s", content: "s\n" }, + "git add s", + 'git commit -m "side"', + "git checkout main", + { write: "big", content: lines(15) }, + "git rm -q m", + "git mv big big2", + "chmod +x big2", + "git add -A", + 'git commit -m "rename and modes"', + { write: "README.md", content: "changed\n" }, + 'git commit -am "content only"', + 'git merge side -m "merge"', + ], + logCommands: [ + "git log --graph --summary", + "git log --graph --oneline --summary", + "git log --graph --format=%s --summary", + "git log --graph --stat --summary", + ], +} satisfies GraphScenario; diff --git a/test/interop/summary.test.ts b/test/interop/summary.test.ts new file mode 100644 index 00000000..b906065f --- /dev/null +++ b/test/interop/summary.test.ts @@ -0,0 +1,130 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, rmSync, symlinkSync } from "fs"; +import { join } from "path"; +import { createSandbox, jg, justBash, realGit, removeSandbox, writeToSandbox } from "./util"; + +const GITLINK = "1111111111111111111111111111111111111111"; + +const lines = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}\n`).join(""); + +// Newest first: content, typechange, gitlink, more, modes, sum, init. The +// "merged" branch merges "side", which branches from typechange, into +// content. +const CASES = [ + "show --summary HEAD~5", + "show --summary --format=%s HEAD~5", + "show --stat --summary HEAD~5", + "show --shortstat --summary HEAD~5", + "show --numstat --summary HEAD~5", + "show -p --summary HEAD~5", + "show --name-status --summary HEAD~5", + "show --name-only --summary HEAD~5", + "show --summary HEAD~4", + "show --stat --summary HEAD~4", + "show -p --summary --format=%s HEAD~6", + "show --summary HEAD~3", + "show --summary HEAD~2", + "show --summary HEAD~1", + "show --summary HEAD", + "show --summary --format=%s HEAD", + "show --summary merged", + "show --stat --summary merged", + "show -p --summary --format=%s merged", + "show --stat merged", + "show --shortstat merged", + "show --numstat merged", + "log --summary", + "log --summary -3 merged", + "log --oneline --summary", + "log --format=%s --summary", + "log --summary --reverse", + "log --stat --summary -2 HEAD~4", + "log -p --summary -1 HEAD~5", + "log --graph --summary", + "log --graph --oneline --summary", + "log --name-status --summary -2 HEAD~4", + "diff --summary HEAD~6 HEAD", + "diff --summary HEAD~5 HEAD~3", + "diff --stat --summary HEAD~5 HEAD~4", + "diff --shortstat --summary HEAD~5 HEAD~4", + "diff --numstat --summary HEAD~5 HEAD~4", + "diff --name-status --summary HEAD~5 HEAD~4", + "diff --summary HEAD~1 HEAD", +]; + +describe("interop: --summary matches real git", () => { + let sandbox: string; + const git = async (cmd: string) => { + const r = await realGit(sandbox, cmd); + expect(r.exitCode).toBe(0); + }; + + beforeAll(async () => { + sandbox = createSandbox(); + writeToSandbox(sandbox, "m", "m\n"); + chmodSync(join(sandbox, "m"), 0o755); + writeToSandbox(sandbox, "z", "z\n"); + writeToSandbox(sandbox, "big", lines(20)); + writeToSandbox(sandbox, "keep", "keep\n"); + writeToSandbox(sandbox, "dir/sub/a", "a\n"); + await git("init"); + await git("add -A"); + await git("commit -m init"); + + writeToSandbox(sandbox, "n", "n\n"); + await git("rm -q m"); + await git("mv z z2"); + await git("add n"); + await git("commit -m sum"); + + writeToSandbox(sandbox, "big", lines(15)); + await git("mv big big2"); + chmodSync(join(sandbox, "big2"), 0o755); + chmodSync(join(sandbox, "n"), 0o755); + await git("add -A"); + await git("commit -m modes"); + + await git("mv dir/sub/a dir/sub/b"); + symlinkSync("n", join(sandbox, "link")); + writeToSandbox(sandbox, "sp ace", "1\n"); + writeToSandbox(sandbox, 'q"uote', "2\n"); + writeToSandbox(sandbox, "ta\tb", "3\n"); + writeToSandbox(sandbox, "é.txt", "4\n"); + await git("add -A"); + await git("commit -m more"); + + await git(`update-index --add --cacheinfo 160000,${GITLINK},gm`); + await git("commit -m gitlink"); + + rmSync(join(sandbox, "n")); + symlinkSync("keep", join(sandbox, "n")); + mkdirSync(join(sandbox, "x/y"), { recursive: true }); + await git("mv dir/sub/b x/y/b"); + await git("add n"); + await git("commit -m typechange"); + + writeToSandbox(sandbox, "keep", "keep\nmore\n"); + await git("commit -am content"); + + // A merge on its own branch, so HEAD stays at "content". + await git("checkout -q -b side HEAD~1"); + writeToSandbox(sandbox, "s", "s\n"); + await git("add s"); + await git("commit -m side"); + await git("checkout -q -"); + await git("checkout -q -b merged"); + await git("merge -q --no-ff -m merge side"); + await git("checkout -q -"); + }); + afterAll(() => removeSandbox(sandbox)); + + for (const args of CASES) { + test(`git ${args}`, async () => { + const expected = await realGit(sandbox, args); + const actual = await jg(justBash(sandbox), `git ${args}`); + expect({ stdout: actual.stdout, stderr: actual.stderr, exitCode: actual.exitCode }).toEqual( + expected, + ); + }); + } +}); diff --git a/test/util.ts b/test/util.ts index b28ce270..b35e246f 100644 --- a/test/util.ts +++ b/test/util.ts @@ -75,6 +75,62 @@ export async function setupClonePair(): Promise { return bash; } +/** + * Build a history whose commits exercise every `--summary` line, newest + * first: + * + * - `merge side`: a merge that brings in `s` + * - `content only`: modifies `keep`, so it has no summary lines + * - `typechange`: `n` becomes a symlink, `dir/sub/b` moves to `x/y/b` + * - `more`: renames `dir/sub/a`, adds a symlink and oddly named files + * - `modes`: renames and shrinks `big` while making it executable, and + * makes `n` executable + * - `sum`: deletes executable `m`, adds `n`, renames `z` to `z2` + * - `init` + * + * `side` branches from `typechange` and adds `s`. + */ +export async function setupSummaryHistory(): Promise { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}\n`).join(""); + const bash = createTestBash({ + files: { + "/repo/m": "m\n", + "/repo/z": "z\n", + "/repo/big": lines(20), + "/repo/keep": "keep\n", + "/repo/dir/sub/a": "a\n", + }, + env: TEST_ENV, + }); + const run = async (...commands: string[]) => { + for (const command of commands) { + const result = await bash.exec(command); + if (result.exitCode !== 0) throw new Error(`${command}: ${result.stderr}`); + } + }; + await run("git init -q", "chmod +x m", "git add .", "git commit -qm init"); + await bash.fs.writeFile("/repo/n", "n\n"); + await run("git rm -q m", "git add n", "git mv z z2", "git commit -qm sum"); + await bash.fs.writeFile("/repo/big", lines(15)); + await run("git mv big big2", "chmod +x big2 n", "git add -A", "git commit -qm modes"); + for (const name of ["sp ace", 'q"uote', "ta\tb", "é.txt"]) { + await bash.fs.writeFile(`/repo/${name}`, "x\n"); + } + await run("git mv dir/sub/a dir/sub/b", "ln -s n link", "git add -A", "git commit -qm more"); + await run( + "rm n", + "ln -s keep n", + "mkdir -p x/y", + "git mv dir/sub/b x/y/b", + "git add -A", + "git commit -qm typechange", + ); + await run("git checkout -qb side", "echo s > s", "git add s", "git commit -qm side"); + await run("git checkout -q main", "echo more >> keep", "git commit -qam 'content only'"); + await run("git merge -q --no-ff -m 'merge side' side"); + return bash; +} + // ── Filesystem query helpers (for use with expect()) ───────────────── /** Check if a path exists in the virtual filesystem. */