Skip to content

Commit 6dcd454

Browse files
mvanhornbenvinegar
authored andcommitted
feat: surface git's color-moved highlights for moved lines
moved code in a diff is highlighted distinctly from added/removed. Loads the relevant git config keys, threads a 'moved' lane through the diff renderer, and renders moved-from / moved-to lines with the configured palette. Fixes #261
1 parent 7104ec8 commit 6dcd454

22 files changed

Lines changed: 472 additions & 29 deletions

src/core/config.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe("config resolution", () => {
5959
'theme = "graphite"',
6060
"line_numbers = false",
6161
"transparentBackground = true",
62+
"color_moved = true",
6263
"",
6364
"[patch]",
6465
'mode = "split"',
@@ -89,6 +90,7 @@ describe("config resolution", () => {
8990
hunkHeaders: false,
9091
agentNotes: true,
9192
transparentBackground: true,
93+
colorMoved: true,
9294
});
9395
});
9496

src/core/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const CUSTOM_THEME_COLOR_KEYS = [
3535
"muted",
3636
"addedBg",
3737
"removedBg",
38+
"movedAddedBg",
39+
"movedRemovedBg",
3840
"contextBg",
3941
"addedContentBg",
4042
"removedContentBg",
@@ -239,6 +241,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
239241
transparentBackground:
240242
normalizeBoolean(source.transparentBackground) ??
241243
normalizeBoolean(source.transparent_background),
244+
colorMoved: normalizeBoolean(source.color_moved),
242245
};
243246
}
244247

@@ -259,6 +262,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
259262
agentNotes: overrides.agentNotes ?? base.agentNotes,
260263
copyDecorations: overrides.copyDecorations ?? base.copyDecorations,
261264
transparentBackground: overrides.transparentBackground ?? base.transparentBackground,
265+
colorMoved: overrides.colorMoved ?? base.colorMoved,
262266
};
263267
}
264268

@@ -353,6 +357,7 @@ export function resolveConfiguredCliInput(
353357
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
354358
copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations,
355359
transparentBackground: resolvedOptions.transparentBackground ?? false,
360+
colorMoved: resolvedOptions.colorMoved,
356361
};
357362

358363
if (resolvedOptions.theme === "custom" && !resolvedCustomTheme) {

src/core/diffFile.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { findAgentFileContext } from "./agent";
33
import { patchLooksBinary } from "./binary";
44
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
55
import type { FileSourceFetcher } from "./fileSource";
6-
import type { AgentContext, DiffFile } from "./types";
6+
import type { AgentContext, DiffFile, DiffLineMoveKinds } from "./types";
77

88
/** Count visible additions and deletions from parsed diff metadata. */
99
export function countDiffStats(metadata: FileDiffMetadata) {
@@ -38,6 +38,7 @@ export interface BuildDiffFileOptions {
3838
isTooLarge?: boolean;
3939
stats?: DiffFile["stats"];
4040
statsTruncated?: boolean;
41+
lineMoveKinds?: DiffLineMoveKinds;
4142
}
4243

4344
/** Build the normalized per-file model used by the UI regardless of input mode. */
@@ -55,6 +56,7 @@ export function buildDiffFile(
5556
isTooLarge,
5657
stats,
5758
statsTruncated,
59+
lineMoveKinds,
5860
}: BuildDiffFileOptions = {},
5961
): DiffFile {
6062
const normalizedMetadata = normalizeDiffMetadataPaths(metadata);
@@ -77,6 +79,7 @@ export function buildDiffFile(
7779
language: getFiletypeFromFileName(path) ?? undefined,
7880
stats: stats ?? countDiffStats(normalizedMetadata),
7981
metadata: normalizedMetadata,
82+
lineMoveKinds,
8083
agent: findAgentFileContext(agentContext, path, resolvedPreviousPath),
8184
isUntracked,
8285
isBinary: resolvedIsBinary,

src/core/git.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
22
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { buildGitStashShowArgs, resolveGitDiffEndpoints, runGitText } from "./git";
5+
import {
6+
buildGitDiffArgs,
7+
buildGitStashShowArgs,
8+
resolveGitDiffEndpoints,
9+
runGitText,
10+
} from "./git";
611
import type { VcsCommandInput } from "./types";
712

813
const tempDirs: string[] = [];
@@ -51,6 +56,25 @@ afterEach(() => {
5156
}
5257
});
5358
describe("git command helpers", () => {
59+
test("enables deterministic color-moved output for patch parsing", () => {
60+
const args = buildGitDiffArgs(
61+
{
62+
kind: "vcs",
63+
staged: false,
64+
options: { mode: "auto" },
65+
},
66+
[],
67+
{ mode: "zebra", whitespaceMode: "allow-indentation-change" },
68+
);
69+
70+
expect(args).toContain("--color=always");
71+
expect(args).toContain("--color-moved=zebra");
72+
expect(args).toContain("--color-moved-ws=allow-indentation-change");
73+
expect(args).not.toContain("--no-color");
74+
expect(args).toContain("color.diff.oldMoved=magenta bold");
75+
expect(args).toContain("color.diff.newMoved=cyan bold");
76+
});
77+
5478
test("disables external diff tools for stash patches", () => {
5579
const args = buildGitStashShowArgs({
5680
kind: "stash-show",

src/core/git.ts

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ interface RunGitCommandOptions extends RunGitTextOptions {
2323
acceptedExitCodes?: number[];
2424
}
2525

26+
export interface GitColorMovedOptions {
27+
mode: string;
28+
whitespaceMode?: string;
29+
}
30+
2631
/** Append Git pathspec arguments only when the caller requested them. */
2732
export function appendGitPathspecs(args: string[], pathspecs?: string[]) {
2833
if (!pathspecs || pathspecs.length === 0) {
@@ -46,13 +51,58 @@ const DIFF_PREFIX_NORMALIZATION_ARGS = [
4651
"diff.dstPrefix=b/",
4752
];
4853

54+
const GIT_MOVED_LINE_COLOR_CONFIG = [
55+
"-c",
56+
"color.diff.oldMoved=magenta bold",
57+
"-c",
58+
"color.diff.oldMovedAlternative=magenta bold",
59+
"-c",
60+
"color.diff.oldMovedDimmed=magenta dim",
61+
"-c",
62+
"color.diff.oldMovedAlternativeDimmed=magenta dim",
63+
"-c",
64+
"color.diff.newMoved=cyan bold",
65+
"-c",
66+
"color.diff.newMovedAlternative=cyan bold",
67+
"-c",
68+
"color.diff.newMovedDimmed=cyan dim",
69+
"-c",
70+
"color.diff.newMovedAlternativeDimmed=cyan dim",
71+
];
72+
4973
function withNormalizedDiffPrefixes(args: string[]) {
5074
return [...DIFF_PREFIX_NORMALIZATION_ARGS, ...args];
5175
}
5276

77+
/** Return Git color flags for patch commands, enabling ANSI only when Hunk needs move classes. */
78+
function gitPatchColorArgs(colorMoved: GitColorMovedOptions | null) {
79+
if (!colorMoved) {
80+
return ["--no-color"];
81+
}
82+
83+
return [
84+
"--color=always",
85+
`--color-moved=${colorMoved.mode}`,
86+
...(colorMoved.whitespaceMode ? [`--color-moved-ws=${colorMoved.whitespaceMode}`] : []),
87+
];
88+
}
89+
90+
/** Add deterministic moved-line colors so the parser can classify Git's ANSI output reliably. */
91+
function withGitMovedLineColorConfig(args: string[], colorMoved: GitColorMovedOptions | null) {
92+
if (!colorMoved) {
93+
return args;
94+
}
95+
96+
return [...GIT_MOVED_LINE_COLOR_CONFIG, ...args];
97+
}
98+
5399
/** Build the exact `git diff` arguments used for the shared working-tree and range review path. */
54-
export function buildGitDiffArgs(input: VcsCommandInput, excludedPathspecs: string[] = []) {
55-
const args = ["diff", "--no-ext-diff", "--find-renames", "--no-color"];
100+
export function buildGitDiffArgs(
101+
input: VcsCommandInput,
102+
excludedPathspecs: string[] = [],
103+
colorMoved: GitColorMovedOptions | null = null,
104+
) {
105+
const args = ["diff", "--no-ext-diff", "--find-renames", ...gitPatchColorArgs(colorMoved)];
56106

57107
if (input.staged) {
58108
args.push("--staged");
@@ -72,7 +122,7 @@ export function buildGitDiffArgs(input: VcsCommandInput, excludedPathspecs: stri
72122
appendGitPathspecs(args, input.pathspecs);
73123
}
74124

75-
return withNormalizedDiffPrefixes(args);
125+
return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved));
76126
}
77127

78128
/** Build the cheap tracked-file stats query used to skip huge file diffs before patch output. */
@@ -115,26 +165,45 @@ function buildGitNewFileDiffArgs(filePath: string) {
115165
}
116166

117167
/** Build the exact `git show` arguments used for commit review. */
118-
export function buildGitShowArgs(input: ShowCommandInput) {
119-
const args = ["show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"];
168+
export function buildGitShowArgs(
169+
input: ShowCommandInput,
170+
colorMoved: GitColorMovedOptions | null = null,
171+
) {
172+
const args = [
173+
"show",
174+
"--format=",
175+
"--no-ext-diff",
176+
"--find-renames",
177+
...gitPatchColorArgs(colorMoved),
178+
];
120179

121180
if (input.ref) {
122181
args.push(input.ref);
123182
}
124183

125184
appendGitPathspecs(args, input.pathspecs);
126-
return withNormalizedDiffPrefixes(args);
185+
return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved));
127186
}
128187

129188
/** Build the exact `git stash show -p` arguments used for stash review. */
130-
export function buildGitStashShowArgs(input: StashShowCommandInput) {
131-
const args = ["stash", "show", "-p", "--no-ext-diff", "--find-renames", "--no-color"];
189+
export function buildGitStashShowArgs(
190+
input: StashShowCommandInput,
191+
colorMoved: GitColorMovedOptions | null = null,
192+
) {
193+
const args = [
194+
"stash",
195+
"show",
196+
"-p",
197+
"--no-ext-diff",
198+
"--find-renames",
199+
...gitPatchColorArgs(colorMoved),
200+
];
132201

133202
if (input.ref) {
134203
args.push(input.ref);
135204
}
136205

137-
return withNormalizedDiffPrefixes(args);
206+
return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved));
138207
}
139208

140209
export function formatGitCommandLabel(input: GitBackedInput) {
@@ -330,6 +399,72 @@ export function runGitText(options: RunGitTextOptions) {
330399
return runGitCommand(options).stdout;
331400
}
332401

402+
const GIT_BOOLEAN_TRUE_VALUES = new Set(["true", "yes", "on", "1", "always"]);
403+
const GIT_BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "never"]);
404+
405+
/** Read an optional Git config value without treating an unset key as an error. */
406+
function readOptionalGitConfig(
407+
input: GitBackedInput,
408+
key: string,
409+
options: Omit<RunGitTextOptions, "input" | "args"> = {},
410+
) {
411+
const result = runGitCommand({
412+
input,
413+
args: ["config", "--get", key],
414+
...options,
415+
acceptedExitCodes: [0, 1],
416+
});
417+
418+
if (result.exitCode !== 0) {
419+
return undefined;
420+
}
421+
422+
return result.stdout.trim() || undefined;
423+
}
424+
425+
/** Normalize Git's diff.colorMoved config into the mode Hunk should request from Git. */
426+
function normalizeGitColorMovedMode(value: string | undefined) {
427+
if (!value) {
428+
return undefined;
429+
}
430+
431+
const normalized = value.toLowerCase();
432+
if (GIT_BOOLEAN_FALSE_VALUES.has(normalized) || normalized === "no") {
433+
return null;
434+
}
435+
436+
if (GIT_BOOLEAN_TRUE_VALUES.has(normalized)) {
437+
return "zebra";
438+
}
439+
440+
return value;
441+
}
442+
443+
/** Resolve whether Hunk should ask Git to color moved lines for this patch command. */
444+
export function resolveGitColorMovedOptions(
445+
input: GitBackedInput,
446+
options: Omit<RunGitTextOptions, "input" | "args"> = {},
447+
): GitColorMovedOptions | null {
448+
const gitMode = normalizeGitColorMovedMode(
449+
readOptionalGitConfig(input, "diff.colorMoved", options),
450+
);
451+
452+
if (gitMode === null) {
453+
return null;
454+
}
455+
456+
const mode = gitMode ?? (input.options.colorMoved ? "zebra" : undefined);
457+
if (!mode) {
458+
return null;
459+
}
460+
461+
const whitespaceMode = readOptionalGitConfig(input, "diff.colorMovedWS", options);
462+
return {
463+
mode,
464+
whitespaceMode,
465+
};
466+
}
467+
333468
/**
334469
* Return whether one `hunk diff` input still compares against the live working tree.
335470
*

src/core/loaders.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,62 @@ describe("loadAppBootstrap", () => {
706706
]);
707707
});
708708

709+
test("tags moved lines from git diff.colorMoved output", async () => {
710+
const dir = createTempRepo("hunk-git-color-moved-");
711+
712+
writeFileSync(
713+
join(dir, "example.txt"),
714+
[
715+
"start anchor",
716+
"relocated block first line has many chars",
717+
"relocated block second line has many chars",
718+
"relocated block third line has many chars",
719+
"middle unchanged one has many chars",
720+
"middle unchanged two has many chars",
721+
"end anchor",
722+
"",
723+
].join("\n"),
724+
);
725+
git(dir, "add", "example.txt");
726+
git(dir, "commit", "-m", "initial");
727+
git(dir, "config", "--local", "diff.colorMoved", "zebra");
728+
729+
writeFileSync(
730+
join(dir, "example.txt"),
731+
[
732+
"start anchor",
733+
"middle unchanged one has many chars",
734+
"middle unchanged two has many chars",
735+
"relocated block first line has many chars",
736+
"relocated block second line has many chars",
737+
"relocated block third line has many chars",
738+
"end anchor",
739+
"",
740+
].join("\n"),
741+
);
742+
743+
const bootstrap = await loadFromRepo(dir, {
744+
kind: "vcs",
745+
staged: false,
746+
options: { mode: "auto" },
747+
});
748+
const file = bootstrap.changeset.files[0];
749+
750+
expect(file?.path).toBe("example.txt");
751+
expect(file?.lineMoveKinds?.additionLines.some(Boolean)).toBe(true);
752+
expect(file?.lineMoveKinds?.deletionLines.some(Boolean)).toBe(true);
753+
754+
const movedAdditions = file?.metadata.additionLines.filter(
755+
(_line, index) => file.lineMoveKinds?.additionLines[index] === "moved",
756+
);
757+
const movedDeletions = file?.metadata.deletionLines.filter(
758+
(_line, index) => file.lineMoveKinds?.deletionLines[index] === "moved",
759+
);
760+
761+
expect(movedAdditions).toContain("middle unchanged one has many chars\n");
762+
expect(movedDeletions).toContain("middle unchanged one has many chars\n");
763+
});
764+
709765
test("reports a friendly error when git review runs outside a repository", async () => {
710766
const dir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-"));
711767
tempDirs.push(dir);

0 commit comments

Comments
 (0)