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
+2 -2
View File
@@ -25,10 +25,10 @@ The following functions are exported and used by the web server:
### Status and Diff Operations
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree.
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs.
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs).
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
- `collectDiffs(directory, files)`: Collect diff output for multiple files.
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
- `stageFile(directory, filePath)`: Add one file path to the index.
+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);
@@ -22,6 +22,7 @@ import {
unstageFiles,
applyHunk,
getDiff,
getFileDiff,
} from './service.js';
// ---------------------------------------------------------------------------
@@ -264,6 +265,26 @@ describe('applyHunk', () => {
});
});
describe('symlink diffs', () => {
it('treats an untracked directory symlink as a link in patch and split diffs', async () => {
if (!canRunGit() || process.platform === 'win32') return;
const { tmpDir } = await createTempRepo();
fs.mkdirSync(path.join(tmpDir, 'source'));
fs.symlinkSync('source', path.join(tmpDir, 'linked-source'));
const patch = await getDiff(tmpDir, { path: 'linked-source' });
const split = await getFileDiff(tmpDir, { path: 'linked-source' });
expect(patch).toContain('new file mode 120000');
expect(patch).toContain('+source');
expect(split).toMatchObject({
original: '',
modified: 'source',
isBinary: false,
});
});
});
// ---------------------------------------------------------------------------
// getStatus
// ---------------------------------------------------------------------------