Skip to content

Commit 1c2686e

Browse files
authored
fix: normalize rename-only diff paths (#194)
1 parent 3ab9663 commit 1c2686e

File tree

6 files changed

+103
-22
lines changed

6 files changed

+103
-22
lines changed

src/core/diffPaths.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { FileDiffMetadata } from "@pierre/diffs";
2+
3+
/** Remove parser-added CR/LF suffixes from diff paths without touching meaningful spaces. */
4+
export function normalizeDiffPath(path: string | undefined) {
5+
return path?.replace(/[\r\n]+$/u, "");
6+
}
7+
8+
/** Sanitize parsed diff metadata path fields before the UI or loaders consume them. */
9+
export function normalizeDiffMetadataPaths(metadata: FileDiffMetadata): FileDiffMetadata {
10+
const name = normalizeDiffPath(metadata.name) ?? metadata.name;
11+
const prevName = normalizeDiffPath(metadata.prevName);
12+
13+
if (name === metadata.name && prevName === metadata.prevName) {
14+
return metadata;
15+
}
16+
17+
return {
18+
...metadata,
19+
name,
20+
prevName,
21+
};
22+
}

src/core/loaders.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,30 @@ describe("loadAppBootstrap", () => {
720720
).rejects.toThrow("`hunk stash show stash@{99}` could not resolve stash entry `stash@{99}`.");
721721
});
722722

723+
test("strips parser-added line endings from rename-only paths", async () => {
724+
const bootstrap = await loadAppBootstrap({
725+
kind: "patch",
726+
text: [
727+
"diff --git a/pi/extensions/loop.ts b/agents/pi/extensions/notify.ts",
728+
"similarity index 100%",
729+
"rename from pi/extensions/loop.ts",
730+
"rename to agents/pi/extensions/notify.ts",
731+
].join("\n"),
732+
options: { mode: "auto" },
733+
});
734+
735+
expect(bootstrap.changeset.files).toHaveLength(1);
736+
expect(bootstrap.changeset.files[0]).toMatchObject({
737+
path: "agents/pi/extensions/notify.ts",
738+
previousPath: "pi/extensions/loop.ts",
739+
metadata: {
740+
name: "agents/pi/extensions/notify.ts",
741+
prevName: "pi/extensions/loop.ts",
742+
type: "rename-pure",
743+
},
744+
});
745+
});
746+
723747
test("treats malformed inline patch text as an empty review instead of throwing", async () => {
724748
const bootstrap = await loadAppBootstrap({
725749
kind: "patch",

src/core/loaders.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createTwoFilesPatch } from "diff";
99
import { resolve as resolvePath } from "node:path";
1010
import { findAgentFileContext, loadAgentContext } from "./agent";
1111
import { createSkippedBinaryMetadata, isProbablyBinaryFile, patchLooksBinary } from "./binary";
12+
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
1213
import {
1314
buildGitDiffArgs,
1415
buildGitShowArgs,
@@ -123,6 +124,7 @@ function findPatchChunk(metadata: FileDiffMetadata, chunks: string[], index: num
123124
return (
124125
chunks.find((chunk) =>
125126
[metadata.name, metadata.prevName]
127+
.map(normalizeDiffPath)
126128
.filter((value): value is string => Boolean(value))
127129
.map(stripPrefixes)
128130
.some(
@@ -148,15 +150,19 @@ function buildDiffFile(
148150
agentContext: AgentContext | null,
149151
{ isUntracked, previousPath, isBinary }: BuildDiffFileOptions = {},
150152
): DiffFile {
153+
const normalizedMetadata = normalizeDiffMetadataPaths(metadata);
154+
const path = normalizedMetadata.name;
155+
const resolvedPreviousPath = normalizeDiffPath(previousPath) ?? normalizedMetadata.prevName;
156+
151157
return {
152-
id: `${sourcePrefix}:${index}:${metadata.name}`,
153-
path: metadata.name,
154-
previousPath: previousPath ?? metadata.prevName,
158+
id: `${sourcePrefix}:${index}:${path}`,
159+
path,
160+
previousPath: resolvedPreviousPath,
155161
patch,
156-
language: getFiletypeFromFileName(metadata.name) ?? undefined,
157-
stats: countDiffStats(metadata),
158-
metadata,
159-
agent: findAgentFileContext(agentContext, metadata.name, metadata.prevName),
162+
language: getFiletypeFromFileName(path) ?? undefined,
163+
stats: countDiffStats(normalizedMetadata),
164+
metadata: normalizedMetadata,
165+
agent: findAgentFileContext(agentContext, path, resolvedPreviousPath),
160166
isUntracked,
161167
isBinary: isBinary ?? patchLooksBinary(patch),
162168
};

src/opentui/HunkDiffView.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMemo } from "react";
22
import { patchLooksBinary } from "../core/binary";
3+
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/diffPaths";
34
import type { DiffFile } from "../core/types";
45
import { findMaxLineNumber } from "../ui/diff/codeColumns";
56
import { buildSplitRows, buildStackRows } from "../ui/diff/pierre";
@@ -28,17 +29,19 @@ function countDiffStats(metadata: HunkDiffFile["metadata"]) {
2829
/** Adapt the public diff shape into Hunk's internal file model without exposing app-only fields. */
2930
function toInternalDiffFile(diff: HunkDiffFile): DiffFile {
3031
const patch = diff.patch ?? "";
32+
const metadata = normalizeDiffMetadataPaths(diff.metadata);
33+
const path = normalizeDiffPath(diff.path) ?? metadata.name;
3134

3235
return {
3336
agent: null,
3437
id: diff.id,
3538
isBinary: patchLooksBinary(patch),
3639
language: diff.language,
37-
metadata: diff.metadata,
40+
metadata,
3841
patch,
39-
path: diff.path ?? diff.metadata.name,
40-
previousPath: diff.metadata.prevName,
41-
stats: countDiffStats(diff.metadata),
42+
path,
43+
previousPath: metadata.prevName,
44+
stats: countDiffStats(metadata),
4245
};
4346
}
4447

src/ui/lib/files.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers";
3-
import { buildSidebarEntries } from "./files";
3+
import { buildSidebarEntries, fileLabelParts } from "./files";
44

55
describe("files helpers", () => {
66
test("buildSidebarEntries hides zero-value sidebar stats", () => {
@@ -60,4 +60,22 @@ describe("files helpers", () => {
6060
deletionsText: null,
6161
});
6262
});
63+
64+
test("fileLabelParts strips parser-added line endings from rename labels", () => {
65+
const renamedAcrossDirectories = {
66+
...createTestDiffFile({
67+
id: "rename-across-dirs",
68+
path: "agents/pi/extensions/notify.ts",
69+
previousPath: "pi/extensions/loop.ts\n",
70+
before: lines("export const stable = true;"),
71+
after: lines("export const stable = true;"),
72+
}),
73+
stats: { additions: 0, deletions: 0 },
74+
};
75+
76+
expect(fileLabelParts(renamedAcrossDirectories)).toEqual({
77+
filename: "pi/extensions/loop.ts -> agents/pi/extensions/notify.ts",
78+
stateLabel: null,
79+
});
80+
});
6381
});

src/ui/lib/files.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { basename, dirname } from "node:path/posix";
22
import type { FileDiffMetadata } from "@pierre/diffs";
3+
import { normalizeDiffPath } from "../../core/diffPaths";
34
import type { AgentAnnotation, DiffFile } from "../../core/types";
45

56
export interface FileListEntry {
@@ -22,12 +23,15 @@ export type SidebarEntry = FileListEntry | FileGroupEntry;
2223

2324
/** Build the filename-first label shown inside one sidebar row. */
2425
function sidebarFileName(file: DiffFile) {
25-
if (!file.previousPath || file.previousPath === file.path) {
26-
return basename(file.path);
26+
const path = normalizeDiffPath(file.path) ?? file.path;
27+
const previousPath = normalizeDiffPath(file.previousPath);
28+
29+
if (!previousPath || previousPath === path) {
30+
return basename(path);
2731
}
2832

29-
const previousName = basename(file.previousPath);
30-
const nextName = basename(file.path);
33+
const previousName = basename(previousPath);
34+
const nextName = basename(path);
3135
return previousName === nextName ? nextName : `${previousName} -> ${nextName}`;
3236
}
3337

@@ -91,7 +95,11 @@ export function filterReviewFiles(files: DiffFile[], query: string): DiffFile[]
9195
}
9296

9397
return files.filter((file) => {
94-
const haystack = [file.path, file.previousPath, file.agent?.summary]
98+
const haystack = [
99+
normalizeDiffPath(file.path),
100+
normalizeDiffPath(file.previousPath),
101+
file.agent?.summary,
102+
]
95103
.filter(Boolean)
96104
.join(" ")
97105
.toLowerCase();
@@ -105,7 +113,8 @@ export function buildSidebarEntries(files: DiffFile[]): SidebarEntry[] {
105113
let activeGroup: string | null = null;
106114

107115
files.forEach((file, index) => {
108-
const group = dirname(file.path);
116+
const path = normalizeDiffPath(file.path) ?? file.path;
117+
const group = dirname(path);
109118
const nextGroup = group === "." ? null : group;
110119

111120
if (nextGroup !== activeGroup) {
@@ -148,10 +157,9 @@ export function fileLabelParts(file: DiffFile | undefined): {
148157
return { filename: "No file selected", stateLabel: null };
149158
}
150159

151-
const baseLabel =
152-
file.previousPath && file.previousPath !== file.path
153-
? `${file.previousPath} -> ${file.path}`
154-
: file.path;
160+
const path = normalizeDiffPath(file.path) ?? file.path;
161+
const previousPath = normalizeDiffPath(file.previousPath);
162+
const baseLabel = previousPath && previousPath !== path ? `${previousPath} -> ${path}` : path;
155163

156164
// Determine state label for special cases
157165
let stateLabel: string | null = null;

0 commit comments

Comments
 (0)