feat: represent symlink diffs as link targets

Untracked symlinks now show as link entries in diff output.
File diffs display symlink targets instead of following them.
Added tests for patch and split diff behavior.
This commit is contained in:
Bohdan Triapitsyn
2026-08-01 10:44:45 +03:00
parent f1e8e03c31
commit ea8cc5d7b0
3 changed files with 55 additions and 7 deletions
+32 -5
View File
@@ -493,7 +493,9 @@ const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverr
}
const repoPath = toGitPath(path.relative(repoRoot, absolutePath));
const existsInWorktree = await fsp.stat(absolutePath).then((stat) => stat.isFile()).catch(() => false);
const worktreeEntry = await fsp.lstat(absolutePath).catch(() => null);
const isSymbolicLink = worktreeEntry?.isSymbolicLink() ?? false;
const existsInWorktree = worktreeEntry?.isFile() || isSymbolicLink;
const existsInIndex = await git.raw(['cat-file', '-e', `:${repoPath}`]).then(() => true).catch(() => false);
const existsInHead = await git.raw(['cat-file', '-e', `HEAD:${repoPath}`]).then(() => true).catch(() => false);
@@ -502,6 +504,7 @@ const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverr
absolutePath,
repoPath,
repoRoot,
isSymbolicLink,
};
}
}
@@ -2358,6 +2361,20 @@ export async function getDiff(directory, { path: filePath, staged = false, conte
await git.raw(['ls-files', '--error-unmatch', '--', fileContext.repoPath]);
return diff;
} catch {
if (fileContext.isSymbolicLink) {
const target = await fsp.readlink(fileContext.absolutePath);
return [
`diff --git a/${fileContext.repoPath} b/${fileContext.repoPath}`,
'new file mode 120000',
'--- /dev/null',
`+++ b/${fileContext.repoPath}`,
'@@ -0,0 +1 @@',
`+${target}`,
'\\ No newline at end of file',
'',
].join('\n');
}
const noIndexArgs = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
@@ -2555,9 +2572,9 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const isImage = isImageFile(filePath);
const mimeType = isImage ? getImageMimeType(filePath) : null;
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
const { absolutePath, repoPath, isSymbolicLink } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
if (!isImage) {
if (!isImage && !isSymbolicLink) {
const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
const isBinary = isBinaryBySniff || (await isBinaryDiff(repoRoot, repoPath, staged));
if (isBinary) {
@@ -2611,8 +2628,18 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
modified = await git.show([`:${repoPath}`]);
}
} else {
const stat = await fsp.stat(absolutePath);
if (stat.isFile()) {
if (isSymbolicLink) {
modified = await fsp.readlink(absolutePath);
} else {
const stat = await fsp.stat(absolutePath);
if (!stat.isFile()) {
return {
original: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original,
modified: '',
path: filePath,
isBinary: false,
};
}
if (isImage) {
// For images, read as binary and convert to data URL
const buffer = await fsp.readFile(absolutePath);