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 // ---------------------------------------------------------------------------