Repository: pantsbuild/pants

Issue:
run_shell_command workdir='.' appears ignored
**Describe the bug**
I would expect that if I set 
```
run_shell_command(
    name='where-am-i-workdir',
    command="pwd",
    workdir='.'
)
```
Then the command to run relative to the BUILD file ("`.` specifies the location of the `BUILD` file.")  However,
```
$ cat src/py/example/BUILD  | head -18
run_shell_command(
    name='where-am-i',
    command="pwd",
)
    
run_shell_command(
    name='where-am-i-workdir',
    command="pwd",
    workdir='.'
)

run_shell_command(
    name='where-am-i-workdir-2',
    command="cd {chroot} && pwd",
    workdir='.'
)
```

```
$ ./pants run src/py/example:where-am-i-workdir
/home/ecsb/src/o/example-python

$ ./pants run src/py/example:where-am-i-workdir-2
/tmp/pants-sandbox-7zAQyl
```

Neither of which are the  "same" (in or out of the sandbox) directory as the BUILD file. 

**Pants version**
 2.16.0.dev7

**OS**
Linux

**Additional info**
https://github.com/cburroughs/example-python/commit/e27998aa8525a6e6755e322d45684056634c9024

A less contrived pseudo-code example of what I'm trying to do:
```
run_shell_command(
    command="./src.py.example/foo-cli.pex --do-stuff-to-files=my-file.txt",
    execution_dependencies=['src/py/example/foo-cli.pex', ':my-text-files'],
    workdir='.'
```



Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/shell/target_types.py b/src/python/pants/backend/shell/target_types.py
--- a/src/python/pants/backend/shell/target_types.py
+++ b/src/python/pants/backend/shell/target_types.py
@@ -326,14 +326,8 @@ class ShellCommandWorkdirField(AdhocToolWorkdirField):
     pass
 
 
-class RunShellCommandWorkdirField(StringField):
-    alias = "workdir"
-    default = "."
-    help = help_text(
-        "Sets the current working directory of the command that is `run`. Values that begin with "
-        "`.` are relative to the directory you are running Pants from. Values that begin with `/` "
-        "are from your project root."
-    )
+class RunShellCommandWorkdirField(AdhocToolWorkdirField):
+    pass
 
 
 class ShellCommandOutputRootDirField(AdhocToolOutputRootDirField):
diff --git a/src/python/pants/backend/shell/util_rules/shell_command.py b/src/python/pants/backend/shell/util_rules/shell_command.py
--- a/src/python/pants/backend/shell/util_rules/shell_command.py
+++ b/src/python/pants/backend/shell/util_rules/shell_command.py
@@ -4,7 +4,6 @@
 from __future__ import annotations
 
 import logging
-import os
 import shlex
 from dataclasses import dataclass
 
@@ -35,6 +34,7 @@
     MergeExtraSandboxContents,
     ResolvedExecutionDependencies,
     ResolveExecutionDependenciesRequest,
+    parse_relative_directory,
 )
 from pants.core.util_rules.adhoc_process_support import rules as adhoc_process_support_rules
 from pants.core.util_rules.environments import EnvironmentNameRequest
@@ -241,17 +241,14 @@ async def _interactive_shell_command(
     )
     dependencies_digest = execution_environment.digest
 
-    relpath = os.path.relpath(
-        working_directory, start="/" if os.path.isabs(working_directory) else "."
-    )
-    boot_script = f"cd {shlex.quote(relpath)}; " if relpath != "." else ""
+    relpath = parse_relative_directory(working_directory, shell_command.address)
+    boot_script = f"cd {shlex.quote(relpath)}; " if relpath != "" else ""
 
     return Process(
         argv=(bash.path, "-c", boot_script + command, shell_name),
         description=f"Running {description}",
         env=command_env,
         input_digest=dependencies_digest,
-        working_directory=working_directory,
     )
 
 
diff --git a/src/python/pants/core/util_rules/adhoc_process_support.py b/src/python/pants/core/util_rules/adhoc_process_support.py
--- a/src/python/pants/core/util_rules/adhoc_process_support.py
+++ b/src/python/pants/core/util_rules/adhoc_process_support.py
@@ -387,8 +387,8 @@ async def run_adhoc_process(
         if result.stderr:
             logger.warning(result.stderr.decode())
 
-    working_directory = _parse_relative_directory(request.working_directory, request.address)
-    root_output_directory = _parse_relative_directory(
+    working_directory = parse_relative_directory(request.working_directory, request.address)
+    root_output_directory = parse_relative_directory(
         request.root_output_directory, working_directory
     )
 
@@ -424,7 +424,7 @@ async def prepare_adhoc_process(
 
     description = request.description
     address = request.address
-    working_directory = _parse_relative_directory(request.working_directory or "", address)
+    working_directory = parse_relative_directory(request.working_directory or "", address)
     argv = request.argv
     timeout: int | None = request.timeout
     output_files = request.output_files
@@ -490,7 +490,7 @@ def _output_at_build_root(process: Process, bash: BashBinary) -> Process:
     )
 
 
-def _parse_relative_directory(workdir_in: str, relative_to: Union[Address, str]) -> str:
+def parse_relative_directory(workdir_in: str, relative_to: Union[Address, str]) -> str:
     """Convert the `workdir` field into something that can be understood by `Process`."""
 
     if isinstance(relative_to, Address):


Test patch:
diff --git a/src/python/pants/backend/shell/util_rules/shell_command_test.py b/src/python/pants/backend/shell/util_rules/shell_command_test.py
--- a/src/python/pants/backend/shell/util_rules/shell_command_test.py
+++ b/src/python/pants/backend/shell/util_rules/shell_command_test.py
@@ -502,39 +502,43 @@ def test_execution_dependencies(caplog, rule_runner: RuleRunner) -> None:
     )
 
 
-def test_run_shell_command_request(rule_runner: RuleRunner) -> None:
+@pytest.mark.parametrize(
+    ("workdir", "expected_boot"),
+    [
+        (None, "cd src; "),
+        (".", "cd src; "),
+        ("/", ""),
+        ("src/with space'n quote", """cd 'src/with space'\"'\"'n quote'; """),
+        ("./with space'n quote", """cd 'src/with space'\"'\"'n quote'; """),
+    ],
+)
+def test_run_shell_command_request(
+    rule_runner: RuleRunner, workdir: None | str, expected_boot: str
+) -> None:
     rule_runner.write_files(
         {
             "src/BUILD": dedent(
-                """\
+                f"""\
                 run_shell_command(
                   name="test",
                   command="some cmd string",
-                )
-
-                run_shell_command(
-                  name="cd-test",
-                  command="some cmd string",
-                  workdir="src/with space'n quote",
+                  workdir={workdir!r},
                 )
                 """
             ),
         }
     )
 
-    def assert_run_args(target: str, args: tuple[str, ...]) -> None:
-        tgt = rule_runner.get_target(Address("src", target_name=target))
-        run = RunShellCommand.create(tgt)
-        request = rule_runner.request(RunRequest, [run])
-        assert len(args) == len(request.args)
-        for arg, request_arg in zip(args, request.args):
-            arg in request_arg
+    args = ("bash", "-c", expected_boot + "some cmd string", "src:test")
 
-    assert_run_args("test", ("bash", "-c", "some cmd string", "src:test"))
-    assert_run_args(
-        "cd-test",
-        ("bash", "-c", "cd 'src/with space'\"'\"'n quote'; some cmd string", "src:cd-test"),
-    )
+    tgt = rule_runner.get_target(Address("src", target_name="test"))
+    run = RunShellCommand.create(tgt)
+    request = rule_runner.request(RunRequest, [run])
+    assert len(args) == len(request.args)
+    # handle the binary name specially, because the path may differ
+    assert args[0] in request.args[0]
+    for arg, request_arg in zip(args[1:], request.args[1:]):
+        assert arg == request_arg
 
 
 @pytest.mark.parametrize(