Repository: xonsh/xonsh

Issue:
Don't resolve symlinks for CWD in prompt
I am not entirely sure if this is a subjective matter, however I think that the path returned by using the `{cwd}` variable in `$PROMPT` shouldn't resolve symbolic links to their real paths. I use symbolic links to abstract away complexities and simplify folder hierarchies and would like this abstraction to be preserved in the prompt.

For example I created symbolic links for each of the common XDG desktop directories in my home-directory (Desktop, Documents, Downloads etc.) so that they point to folders on my HDD instead of my SSD to reduce writes. Currently when changing the directory to such a symbolic link, I would prefer for my prompt to display either: `/home/cryzed/Downloads` or `~/Downloads` instead of fully resolving the path.

I am aware that I can customize the `$FORMATTER_DICT` with my own additions, but I am not entirely sure how to go about this: the code handling the tracking of the CWD and PWD seems rather hard to make sense of to me. It would be awesome if there was a prompt-variable that preserved the symbolic links on the path and maybe optionally one that resolves all symbolic links (the current behaviour).



Discussion:
For reference, how another shell handles this:
http://www.tcsh.org/tcsh.html/Special_shell_variables.html#symlinks

Hey @cryzed, thanks for reporting!  I think this should be an option for users and a non-default entry in `$FORMATTER_DICT` seems like a good way to go about it.

I agree with @cryzed that this would be a nice feature to have (it bothers me too sometimes) and with @gforsyth on the implementation. Pull requests most welcome!  I think this would be easy to do.

It is not as simple as it looks like. The problem is not only what is displayed, it's also what the current directory is. I created a folder, and a symlink to this folder:

    /tmp/to/dst -> /tmp/from/src

Whenever someone `cd` in the `/tmp/to/dst` folder, the `os.chdir()` function follows the symlink, so the shell goes literally to `/tmp/from/src`.

I added two `print()` statement in the `_change_working_directory` function:

```diff
 def _change_working_directory(newdir):
     env = builtins.__xonsh_env__
     old = env['PWD']
     new = os.path.join(old, newdir)
     absnew = os.path.abspath(new)
+    print(absnew)
     try:
         os.chdir(absnew)
+        print(os.getcwd())
     except (OSError, FileNotFoundError):
         if new.endswith(get_sep()):
             new = new[:-1]
         if os.path.basename(new) == '..':
             env['PWD'] = new
     else:
         if old is not None:
             env['OLDPWD'] = old
         if new is not None:
             env['PWD'] = absnew
 
     # Fire event if the path actually changed
     if old != env['PWD']:
         events.on_chdir.fire(olddir=old, newdir=env['PWD'])
```

And here is an example session:

```shell
fabien@asus ~ $ cd /tmp
/tmp
/tmp
fabien@asus /tmp $ mkdir from from/src to
mkdir: created directory 'from'
mkdir: created directory 'from/src'
mkdir: created directory 'to'
fabien@asus /tmp $ ln -s ../from/src/ to/dst
fabien@asus /tmp $ cd from/src/
/tmp/from/src
/tmp/from/src
fabien@asus /tmp/from/src $ cd ..
/tmp/from
/tmp/from
fabien@asus /tmp/from $ cd /tmp/to/dst
/tmp/to/dst
/tmp/from/src #   <-- Notice here that after the `os.chdir`, the notion of symlink is lost.
fabien@asus /tmp/from/src $ cd .. # <-- So there is no way to show the symlink path here
/tmp/from # <-- And we go back up to the original directory as expected
/tmp/from
fabien@asus /tmp/from $ 
```

I haven't found  a way in [`os`](https://docs.python.org/3/library/os.html) to change the directory by keeping track of the symlinked path. So up to my knowledge, the only way to do this would be to manually keep track of the current path in a variable, and to translate `cd` calls (like `cd ..`, `cd /etc`, cd `test/dir`) on the variable instead of on the current directory.
Ok, POC:

```diff
 def _change_working_directory(newdir):
     env = builtins.__xonsh_env__
-    old = env['PWD']
+    old = env['MYOWNPWD'] if 'MYOWNPWD' in env else env['PWD']
     new = os.path.join(old, newdir)
     absnew = os.path.abspath(new)
     try:
         os.chdir(absnew)
+        env['MYOWNPWD'] = absnew
     except (OSError, FileNotFoundError):
         if new.endswith(get_sep()):
             new = new[:-1]
```

```diff
 def _replace_home_cwd():
-    return _replace_home(builtins.__xonsh_env__['PWD'])
+    env = builtins.__xonsh_env__
+    return _replace_home(env['MYOWNPWD'] if 'MYOWNPWD' in env else env['PWD'])
```

And now it's working as desired, for changing dir but also for displaying cwd:

```shell
fabien@asus ~ $ cd /tmp/to/dst
/tmp/to/dst
/tmp/from/src
fabien@asus /tmp/to/dst $ cd .. # <-- CWD displayed correctly here
/tmp/to
/tmp/to
fabien@asus /tmp/to $ cd .. # <-- Going up to the symlink parent folder
/tmp
/tmp
fabien@asus /tmp $ cd from/src/
/tmp/from/src
/tmp/from/src
fabien@asus /tmp/from/src $ cd ..
/tmp/from
/tmp/from
fabien@asus /tmp/from $ 
```
Pull requests welcome :)
Note: Looking at the POSIX function `chdir()`, it seems that Python isn't doing anything special with regards to symlinks. I couldn't find anything explicitly stating it, but `chdir()` can fail with `ELOOP` if there's too many symlinks.
> Pull requests welcome :)

@scopatz I know, but I'm not sure it's the right way to solve it, because:

1. I'm sure other people will ask for the old behaviour once implemented; and
2. Keeping an alternate variable to `PWD` seems risky, it may break on some weird situations
Well, I the right think to do is to offer an additional new prompt variable that keeps the symlink while retaining the old prompt variable that derefences them. That way we have both available.
Awesome, thank you guys for looking into this :+1: .
It is still in discussion in #2235, especially how to do it properly, but we now understand what is the problem and what the solution should be!

Special thanks to @scopatz for the `newcomer` and `sprint-friendly` tag on this one :joy: :wink: 

Resolving patch:
diff --git a/xonsh/base_shell.py b/xonsh/base_shell.py
--- a/xonsh/base_shell.py
+++ b/xonsh/base_shell.py
@@ -372,14 +372,15 @@ def _append_history(self, tee_out=None, **info):
             hist.last_cmd_rtn = hist.last_cmd_out = None
 
     def _fix_cwd(self):
-        """Check if the cwd changed out from under us"""
+        """Check if the cwd changed out from under us."""
+        env = builtins.__xonsh_env__
         cwd = os.getcwd()
-        if cwd != builtins.__xonsh_env__.get('PWD'):
-            old = builtins.__xonsh_env__.get('PWD')  # working directory changed without updating $PWD
-            builtins.__xonsh_env__['PWD'] = cwd      # track it now
+        if 'PWD' in env and os.path.realpath(cwd) != os.path.realpath(env['PWD']):
+            old = env['PWD']  # working directory changed without updating $PWD
+            env['PWD'] = cwd  # track it now
             if old is not None:
-                builtins.__xonsh_env__['OLDPWD'] = old  # and update $OLDPWD like dirstack.
-            events.on_chdir.fire(olddir=old, newdir=cwd)              # fire event after cwd actually changed.
+                env['OLDPWD'] = old  # and update $OLDPWD like dirstack.
+            events.on_chdir.fire(olddir=old, newdir=cwd)  # fire event after cwd actually changed.
 
     def push(self, line):
         """Pushes a line onto the buffer and compiles the code in a way that
diff --git a/xonsh/dirstack.py b/xonsh/dirstack.py
--- a/xonsh/dirstack.py
+++ b/xonsh/dirstack.py
@@ -129,11 +129,15 @@ def _get_cwd():
         return None
 
 
-def _change_working_directory(newdir):
+def _change_working_directory(newdir, follow_symlinks=False):
     env = builtins.__xonsh_env__
     old = env['PWD']
     new = os.path.join(old, newdir)
     absnew = os.path.abspath(new)
+
+    if follow_symlinks:
+        absnew = os.path.realpath(absnew)
+
     try:
         os.chdir(absnew)
     except (OSError, FileNotFoundError):
@@ -180,6 +184,11 @@ def cd(args, stdin=None):
     oldpwd = env.get('OLDPWD', None)
     cwd = env['PWD']
 
+    follow_symlinks = False
+    if len(args) > 0 and args[0] == '-P':
+        follow_symlinks = True
+        del args[0]
+
     if len(args) == 0:
         d = os.path.expanduser('~')
     elif len(args) == 1:
@@ -207,7 +216,9 @@ def cd(args, stdin=None):
             else:
                 d = _try_cdpath(d)
     else:
-        return '', 'cd takes 0 or 1 arguments, not {0}\n'.format(len(args)), 1
+        return '', ('cd takes 0 or 1 arguments, not {0}. An additional `-P` '
+                    'flag can be passed in first position to follow symlinks.'
+                    '\n'.format(len(args))), 1
     if not os.path.exists(d):
         return '', 'cd: no such file or directory: {0}\n'.format(d), 1
     if not os.path.isdir(d):
@@ -223,7 +234,7 @@ def cd(args, stdin=None):
         pushd(['-n', '-q', cwd])
         if ON_WINDOWS and _is_unc_path(d):
             d = _unc_map_temp_drive(d)
-    _change_working_directory(d)
+    _change_working_directory(d, follow_symlinks)
     return None, None, 0
 
 


Test patch: