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
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@
- Missing provider access, a stale runtime, or unavailable desktop control is a coverage gap. Focused tests do not replace the Composer and Review proof.
- The focused real Git/RPC test covers same-file isolation, settled service recreation, cumulative edits, and legacy fallback. The startup migration test covers an existing database. Neither proves rendered labels or an actual provider turn.
- An unsupported or over-limit patch is rejected whole. Do not expect a partial native patch.
- The rendered diff surface is `@pierre/diffs` CodeView. Lines and headers mount inside `diffs-container` shadow roots; use Playwright locators (they pierce shadow DOM), not `element.textContent` scraping of the light DOM.
- Long patches collapse unchanged context behind "N unmodified lines" bands. Click a band and confirm the hidden lines render.
- Line comments open from the gutter "+" affordance on hover and land in the thread annotation bundle. Exercise both the deletions (left) and additions (right) sides.
Original file line number Diff line number Diff line change
Expand Up @@ -135,18 +135,20 @@ describe("SnapshotService ref and numstat validation", () => {

it("returns destination paths for simple and brace-form numstat renames", async () => {
const executor: GitExecutor = {
async exec() {
return {
stdout: "0\t0\told.txt => new.txt\n1\t2\tarch/{i386 => x86}/Makefile\n",
stderr: "",
};
async exec(args) {
return args.includes("--name-status")
? { stdout: "R100\told.txt\tnew.txt\nM\tarch/x86/Makefile\n", stderr: "" }
: {
stdout: "0\t0\told.txt => new.txt\n1\t2\tarch/{i386 => x86}/Makefile\n",
stderr: "",
};
},
};
const service = new SnapshotService(executor);

await expect(service.getDiffStats("/repo", "before", "after")).resolves.toEqual([
{ filePath: "new.txt", additions: 0, deletions: 0 },
{ filePath: "arch/x86/Makefile", additions: 1, deletions: 2 },
{ filePath: "new.txt", additions: 0, deletions: 0, changeType: "renamed" },
{ filePath: "arch/x86/Makefile", additions: 1, deletions: 2, changeType: "modified" },
]);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { injectable, inject } from "tsyringe";
import * as NodeFSPromises from "node:fs/promises";
import * as NodePath from "node:path";
import * as NodeCrypto from "node:crypto";
import type { DiffStats } from "@mcode/contracts";
import type { GitExecutor } from "../../git/execution/index.js";
import { RealGitExecutor } from "../../git/execution/real-git-executor.js";

Expand Down Expand Up @@ -82,7 +83,7 @@ function selectedPathGroups(
return pathGroups.filter((group) => group.some((path) => connected.has(normalize(path))));
}

function parseNumstat(stdout: string): { filePath: string; additions: number; deletions: number }[] {
function parseNumstat(stdout: string): Omit<DiffStats, "changeType">[] {
return stdout
.trim()
.split("\n")
Expand All @@ -97,6 +98,28 @@ function parseNumstat(stdout: string): { filePath: string; additions: number; de
});
}

/** Map a --name-status letter (A/M/D/R/C/T/U…) to the shared change type. */
function nameStatusChangeType(letter: string): DiffStats["changeType"] {
if (letter === "A") return "added";
if (letter === "D") return "deleted";
if (letter === "R") return "renamed";
if (letter === "C") return "copied";
return "modified";
}

function parseNameStatus(stdout: string): Map<string, DiffStats["changeType"]> {
const types = new Map<string, DiffStats["changeType"]>();
for (const line of stdout.trim().split("\n")) {
if (!line.includes("\t")) continue;
const [status, ...paths] = line.split("\t");
// Rename/copy rows carry "old\tnew"; the destination is the diff file path.
const filePath = paths[paths.length - 1];
if (!filePath) continue;
types.set(filePath, nameStatusChangeType(status?.[0] ?? "M"));
}
return types;
}

function numstatDestinationPath(path: string): string {
const braceRename = path.match(/^(.*)\{([^{}]*) => ([^{}]*)\}(.*)$/);
if (braceRename) {
Expand Down Expand Up @@ -124,12 +147,12 @@ function getDiffPathspecBatches(

function gitDiffArgs(
cwd: string,
format: "unified" | "numstat",
format: "unified" | "numstat" | "name-status",
refBefore: string,
refAfter: string,
pathspecs: string[] | undefined,
): string[] {
const formatArgs = format === "unified" ? [] : ["--numstat"];
const formatArgs = format === "unified" ? [] : [`--${format}`];
const args = ["-C", cwd, "diff", ...formatArgs, "--find-renames", refBefore, refAfter];
if (pathspecs) args.push("--", ...pathspecs);
return args;
Expand All @@ -138,7 +161,7 @@ function gitDiffArgs(
async function executeDiffBatches(
gitExecutor: GitExecutor,
cwd: string,
format: "unified" | "numstat",
format: "unified" | "numstat" | "name-status",
refBefore: string,
refAfter: string,
pathspecBatches: (string[] | undefined)[],
Expand All @@ -159,10 +182,24 @@ function limitDiffLines(diff: string, maxLines: number | undefined): string {
return maxLines ? diff.split("\n").slice(0, maxLines).join("\n") : diff;
}

function collectDiffStats(outputs: readonly string[]): { filePath: string; additions: number; deletions: number }[] {
const stats = new Map<string, { filePath: string; additions: number; deletions: number }>();
function collectDiffStats(
outputs: readonly string[],
statusOutputs: readonly string[],
): DiffStats[] {
const changeTypes = new Map<string, DiffStats["changeType"]>();
for (const output of statusOutputs) {
for (const [filePath, changeType] of parseNameStatus(output)) {
changeTypes.set(filePath, changeType);
}
}
const stats = new Map<string, DiffStats>();
for (const output of outputs) {
for (const entry of parseNumstat(output)) stats.set(entry.filePath, entry);
for (const entry of parseNumstat(output)) {
stats.set(entry.filePath, {
...entry,
changeType: changeTypes.get(entry.filePath) ?? "modified",
});
}
}
return [...stats.values()];
}
Expand Down Expand Up @@ -320,28 +357,24 @@ export class SnapshotService {
}
}

/** Get per-file line addition/deletion counts between two refs (tree or commit SHAs). */
/** Get per-file change classification and line counts between two refs (tree or commit SHAs). */
async getDiffStats(
cwd: string,
refBefore: string,
refAfter: string,
allowedPaths?: readonly string[],
allowedPathGroups?: readonly (readonly string[])[],
): Promise<{ filePath: string; additions: number; deletions: number }[]> {
): Promise<DiffStats[]> {
if (hasUnsafeDiffRefs(refBefore, refAfter) || refBefore === refAfter) return [];
const pathspecBatches = getDiffPathspecBatches(undefined, allowedPaths, allowedPathGroups);
if (pathspecBatches.length === 0) return [];

try {
const outputs = await executeDiffBatches(
this.gitExecutor,
cwd,
"numstat",
refBefore,
refAfter,
pathspecBatches,
);
return collectDiffStats(outputs);
const [outputs, statusOutputs] = await Promise.all([
executeDiffBatches(this.gitExecutor, cwd, "numstat", refBefore, refAfter, pathspecBatches),
executeDiffBatches(this.gitExecutor, cwd, "name-status", refBefore, refAfter, pathspecBatches),
]);
return collectDiffStats(outputs, statusOutputs);
} catch {
return [];
}
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/features/projects/git/git-comparison-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,27 @@ export class GitComparisonService {
}
}

/**
* Read a file's contents at a revision. `ref` follows `git show` revision
* rules with two extensions: "" reads the staged index blob (`:path`), and
* "A...B" resolves to the merge base of A and B, matching the old side of a
* three-dot comparison diff.
*/
async readFileAtRef(
workspaceId: string,
ref: string,
filePath: string,
repoPath?: string,
): Promise<string> {
const cwd = repoPath ?? this.requireWorkspace(workspaceId).path;
const resolved = await this.resolveShowRef(cwd, ref);
const { stdout } = await this.gitExecutor.exec(
["-C", cwd, "show", `${resolved}:${filePath}`],
{ timeout: 10_000 },
);
return stdout;
}

/** List files changed on the target side of a branch comparison. */
async listBranchComparisonChangedFiles(workspaceId: string, base?: string, target?: string, repoPath?: string): Promise<string[]> {
const cwd = repoPath ?? this.requireWorkspace(workspaceId).path;
Expand Down Expand Up @@ -449,6 +470,25 @@ export class GitComparisonService {
return this.gitRepository.getDefaultBranchAt(repoPath);
}

/** Map a `readFileAtRef` revision onto the commit git show should read. */
private async resolveShowRef(cwd: string, ref: string): Promise<string> {
if (ref === "") return "";
const mergeBaseIndex = ref.indexOf("...");
if (mergeBaseIndex >= 0) {
const base = ref.slice(0, mergeBaseIndex);
const target = ref.slice(mergeBaseIndex + 3);
assertSafeRef(base);
assertSafeRef(target || "HEAD");
const { stdout } = await this.gitExecutor.exec(
["-C", cwd, "merge-base", base, target || "HEAD"],
{ timeout: 10_000 },
);
return stdout.trim();
}
assertShowRef(ref);
return ref;
}

private requireWorkspace(workspaceId: string) {
const workspace = this.workspaceRepo.findById(workspaceId);
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
Expand Down Expand Up @@ -734,6 +774,11 @@ function assertSafeRef(ref: string): void {
if (!/^(?!-)[A-Za-z0-9._/-]+$/.test(ref)) throw new Error(`Unsafe git ref: ${ref}`);
}

/** Refs passed to `git show` may carry revision suffixes like `sha~1`. */
function assertShowRef(ref: string): void {
if (!/^(?!-)[A-Za-z0-9._/-]+(?:[~^][0-9]*)*$/.test(ref)) throw new Error(`Unsafe git ref: ${ref}`);
}

function assertSafeSha(sha: string | undefined): asserts sha is string {
if (!sha || !/^[0-9a-fA-F]{4,40}$/.test(sha)) throw new Error(`Invalid or missing git SHA for commit view: ${sha}`);
}
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/features/projects/git/transport/git-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface GitRouterDeps {
| "listCommitChangedFiles"
| "listWorkingTreeChangedFiles"
| "readWorkingTreeDiff"
| "readFileAtRef"
| "listBranchComparisonChangedFiles"
| "readBranchComparisonDiff"
| "resolveBranchComparison"
Expand Down Expand Up @@ -136,6 +137,19 @@ const gitHandlers: GitHandlerMap = {
resolveThreadRepoPath(deps, params.threadId),
)
: "",
// Hydration needs the real old/new contents; a soft "" here would let the
// diff renderer merge inconsistent metadata, so failures must propagate.
"git.fileAtRef": (deps, params) => {
if (!isGitWorkspace(deps, params.workspaceId)) {
throw new Error(`Workspace ${params.workspaceId} is not a git repository`);
}
return deps.gitComparison.readFileAtRef(
params.workspaceId,
params.ref,
params.filePath,
resolveThreadRepoPath(deps, params.threadId),
);
},
"git.branchFiles": (deps, params) =>
isGitWorkspace(deps, params.workspaceId)
? deps.gitComparison.listBranchComparisonChangedFiles(
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@mcode/contracts": "workspace:*",
"@mcode/shared": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "1.4.1",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
Expand Down
13 changes: 5 additions & 8 deletions apps/web/src/__tests__/TurnTimeline.expand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { TurnSnapshot } from "@mcode/contracts";
import { TurnTimeline } from "../components/diff/TurnTimeline";
import { useDiffStore } from "../stores/diffStore";

// Mock the transport so FileEntry's lazy diff load resolves immediately.
// Mock the transport so lazy diff loads resolve immediately.
vi.mock("@/transport", () => ({
getTransport: () => ({
getSnapshotDiff: vi.fn().mockResolvedValue("@@ -0,0 +1 @@\n+hello\n"),
Expand All @@ -14,15 +14,12 @@ vi.mock("@/transport", () => ({
}),
}));

// Mock heavy diff renderers to keep tests fast and avoid unrelated failures.
vi.mock("../components/diff/UnifiedDiff", () => ({
UnifiedDiff: ({ lines }: { lines: unknown[] }) => (
<div data-testid="unified-diff">{lines.length} lines</div>
// Mock the pierre-backed diff surface to keep tests fast and avoid unrelated failures.
vi.mock("../components/diff/ReviewDiffView", () => ({
ReviewDiffView: ({ files }: { files: { path: string }[] }) => (
<div data-testid="unified-diff">{files.length} files</div>
),
}));
vi.mock("../components/diff/SideBySideDiff", () => ({
SideBySideDiff: () => <div data-testid="side-by-side-diff" />,
}));
vi.mock("../components/diff/DiffPreview", () => ({
DiffPreview: () => <div data-testid="diff-preview" />,
}));
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/__tests__/mocks/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export const mockTransport: McodeTransport = {
refreshWorkspaceFiles: vi.fn().mockResolvedValue(undefined),
getReviewComparison: vi.fn().mockResolvedValue({ files: [], additions: 0, deletions: 0 }),
readFileContent: vi.fn().mockResolvedValue(""),
readFileAtRef: vi.fn().mockResolvedValue(""),
listOpenInApps: vi.fn().mockResolvedValue([]),
openIn: vi.fn().mockResolvedValue(undefined),
getBranchPr: vi.fn().mockResolvedValue(null),
Expand Down
Loading
Loading