From ea8cc5d7b04f77573f0242d4ebdd151738b828c9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 1 Aug 2026 10:44:45 +0300 Subject: [PATCH] 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. --- packages/web/server/lib/git/DOCUMENTATION.md | 4 +-- packages/web/server/lib/git/service.js | 37 +++++++++++++++++--- packages/web/server/lib/git/service.test.js | 21 +++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 2af4f877..746ecb88 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index a23f0cea..a70b7a47 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 0e480b8e..9afe758f 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -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 // ---------------------------------------------------------------------------