CLI discards logical symlink path, uses realpath — degrades usability in deep directory trees

Resolved 💬 5 comments Opened Apr 7, 2026 by in4mer Closed Jul 10, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Claude Code resolves the working directory to its physical path at startup via realpath() or process.cwd(), discarding the logical path from $PWD that the user navigated through. Every path in tool output (Read, Glob, Grep, Edit, etc.) uses the fully dereferenced physical path.

This is a re-filing of #39594, which was auto-closed as a duplicate of #28201, then auto-locked. Neither issue was resolved. They are not duplicates — this report explains why below and expands on the real-world impact that makes this more than a cosmetic issue.

Why #39594 and #28201 are not the same bug

#28201 is a Windows/VS Code issue where junction points are resolved instead of using the VS Code workspace API (vscode.workspace.workspaceFolders). It is labeled platform:windows and platform:vscode. The fix surface is the VS Code extension reading from the workspace API instead of an OS-level call.

#39594 (and this issue) is a Linux CLI issue where POSIX symlinks are resolved instead of preserving the shell's $PWD. The fix surface is the CLI's startup path resolution, where process.env.PWD (the logical path maintained by the shell) should be preferred over process.cwd() (which returns the physical path on Linux), after validating that both point to the same inode.

| | #28201 (Windows/VS Code) | This issue (Linux CLI) |
|---|---|---|
| Platform | Windows | Linux |
| Entry point | VS Code extension | CLI (claude binary) |
| Path type | NTFS junction points | POSIX symlinks |
| Fix location | VS Code workspace API | Node.js process.env.PWD |
| Shell involvement | None (VS Code opens the folder) | Shell maintains $PWD as logical path |

The shared root cause is that Claude Code calls realpath() (or equivalent) instead of preserving the path the user actually used. But the platforms, the fix surfaces, the APIs involved, and the reproduction steps are entirely different. Closing one as a duplicate of the other means neither gets fixed.

Both #39594 and #28201 are now closed without resolution. #39594 was auto-closed after 3 days with no human review, then auto-locked after 7 days. #28201 was closed as stale. The Linux symlink variant has zero open upstream tracking.

Why this matters: real-world impact in non-trivial directory trees

This isn't a cosmetic "the path looks different" issue. In any project with a non-trivially deep directory tree, discarding the logical path actively degrades the user's ability to work with Claude Code's output. Here's why:

Symlinks exist to flatten deep hierarchies. Many developers create symlinks to provide short, memorable paths to deeply nested project directories. For example:

Physical:  /home/user/Documents/cloud-sync/projects/work/myproject  (7 levels)
Symlink:   /home/user/w/myproject                                   (3 levels)

The symlink isn't decoration — it's the path used in shell aliases, scripts, $CDPATH, bookmarks, terminal history, and muscle memory. It's the path that appears in the user's prompt. It's the path that other tools (the shell, git, $EDITOR, language servers) all use because they all respect $PWD.

When Claude discards the symlink, three things break:

  1. Absolute paths become long and unfamiliar. Every file reference in tool output uses the physical path. The user has to mentally translate these back to the symlink-based paths they actually use. In a session with dozens of file references, this is a persistent cognitive tax.
  1. Relative paths become absurdly deep. When computing relative paths between modules, Claude starts from the physical directory depth. A cross-module reference that would be ../../other-module/src/File.java through the symlink becomes ../../../../../../other-module/src/File.java through the physical path. These chains are unreadable, unpasteable, and error-prone. In a monorepo with 5+ modules, this makes cross-module references in Claude's output essentially useless — the user can't visually parse them, can't paste them into their shell, and can't verify them at a glance.
  1. Copy-paste workflow breaks. When the user copies a path from Claude's output to use in their terminal, it doesn't match their $PWD. Tab completion doesn't work from the physical path prefix because the user's shell is rooted at the symlink. Pasting a long physical path into a commit message, PR description, or Slack message exposes internal directory structure that the symlink was specifically designed to abstract away.

Scale amplifies the problem. In a small project with a flat directory tree, the difference between physical and logical paths might be one extra directory level — annoying but manageable. But symlinks are most commonly used precisely in the cases where the physical path is deep: cloud-synced directories, NFS mounts, Nix/Guix profiles, stow-managed dotfiles, Docker volume mounts, and monorepos with deeply nested module hierarchies. These are the cases where the path difference is 3-5+ levels, where relative paths become illegible, and where the user chose to create a symlink because the physical path was already too long to work with.

The irony is that Claude Code's realpath behavior hurts most in exactly the environments where developers have already invested effort in making their paths manageable — and undoes that effort silently on every session start.

What Should Happen?

Claude Code should use process.env.PWD (the logical path from the shell environment) as the working directory rather than resolving it via realpath() or process.cwd(). This is what the user's shell shows, what their prompt displays, and what all their other tools use.

The fix is straightforward and safe:

const logical = process.env.PWD;
const physical = process.cwd();

// Use the logical path if it points to the same directory
if (logical && fs.statSync(logical).ino === fs.statSync(physical).ino) {
  workingDirectory = logical;
} else {
  workingDirectory = physical;  // fallback: env not set or inode mismatch
}

The inode check ensures the logical path is valid and actually points to the same directory. If $PWD is stale, missing, or points somewhere else, the fallback is the current realpath behavior. No existing behavior changes for users who don't use symlinks.

Steps to Reproduce

  1. Create a symlink to a project directory:

``bash
ln -s /long/physical/path/to/project ~/short/project
``

  1. cd ~/short/project
  1. Confirm the shell preserves the logical path:

``bash
echo $PWD # /home/user/short/project
pwd # /home/user/short/project
pwd -P # /long/physical/path/to/project
``

  1. Launch claude
  1. Ask Claude to read or reference any file. All paths in tool output use the physical path, not the symlink path.

Environment

  • Platform: Linux (Ubuntu 22.04)
  • Terminal: CLI
  • Version: 1.0.41 (also confirmed in 2.1.39)

Related Issues

  • #39594 — Previous filing of this bug. Auto-closed as duplicate of #28201, auto-locked. No human review, no resolution.
  • #28201 — Same root cause on Windows (junction points), but different platform, different fix surface (VS Code workspace API). Closed as stale, no resolution.
  • #764 — ~/.claude symlinked via stow not traversed. Same realpath root cause, scoped to config directory.
  • #16507 — Glob tool does not follow symlinks into symlinked directories.
  • #34125, #36610, #33140, #38186 — Windows cluster: fs.realpath() resolving mapped drives to UNC paths, breaking session matching.

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗