diff --git a/invoke/context.py b/invoke/context.py index 00fb35178..b85832e49 100644 --- a/invoke/context.py +++ b/invoke/context.py @@ -16,6 +16,7 @@ from .config import Config, DataProxy from .exceptions import AuthFailure, Failure, ResponseNotAccepted from .runners import Result, Runner +from .terminals import WINDOWS from .watchers import FailingResponder @@ -273,10 +274,22 @@ def _prefix_commands(self, command: str) -> str: prefixes = list(self.command_prefixes) current_directory = self.cwd if current_directory: - prefixes.insert(0, "cd {}".format(current_directory)) + prefixes.insert(0, self._cd_prefix(current_directory)) return " && ".join(prefixes + [command]) + def _cd_prefix(self, directory: str) -> str: + """ + Build a ``cd `` prefix for the active shell. + + POSIX shells accept a backslash-escaped space (applied in `cwd`). + ``cmd.exe`` does not, so Windows paths with spaces are quoted. + """ + if WINDOWS and (" " in directory or '"' in directory): + quoted = directory.replace('"', '""') + return 'cd "{}"'.format(quoted) + return "cd {}".format(directory) + @contextmanager def prefix(self, command: str) -> Generator[None, None, None]: """ @@ -351,9 +364,11 @@ def cwd(self) -> str: if path.startswith("~") or path.startswith("/"): break - # TODO: see if there's a stronger "escape this path" function somewhere - # we can reuse. e.g., escaping tildes or slashes in filenames. - paths = [path.replace(" ", r"\ ") for path in self.command_cwds[i:]] + # POSIX `cd` needs spaces backslash-escaped. Windows quoting is done + # in `_cd_prefix` (`cmd.exe` does not treat `\ ` as a space escape). + paths = list(self.command_cwds[i:]) + if not WINDOWS: + paths = [path.replace(" ", r"\ ") for path in paths] return str(os.path.join(*paths)) @contextmanager @@ -393,8 +408,8 @@ def cd(self, path: Union[PathLike, str]) -> Generator[None, None, None]: c.run('ls') # cd /var/www/website1 && ls .. note:: - Space characters will be escaped automatically to make dealing with - such directory names easier. + Space characters are escaped automatically: POSIX shells get a + backslash escape, Windows ``cmd.exe`` gets a quoted path. .. versionadded:: 1.0 .. versionchanged:: 1.5 diff --git a/tests/context.py b/tests/context.py index e03c20954..eb6b0c539 100644 --- a/tests/context.py +++ b/tests/context.py @@ -154,6 +154,16 @@ def home(self): self.c.command_cwds = ["a", "~b", "c"] assert self.c.cwd == os.path.join("~b", "c") + def posix_backslash_escapes_spaces(self): + with patch("invoke.context.WINDOWS", False): + self.c.command_cwds = ["foo bar"] + assert self.c.cwd == r"foo\ bar" + + def windows_keeps_literal_spaces(self): + with patch("invoke.context.WINDOWS", True): + self.c.command_cwds = ["foo bar"] + assert self.c.cwd == "foo bar" + class cd: _escaped_prompt = re.escape(Config().sudo.prompt) @@ -227,6 +237,27 @@ def __str__(self): cmd = "cd foo && whoami" assert runner.run.call_args[0][0] == cmd + @patch(local_path) + def posix_backslash_escapes_spaces(self, Local): + runner = Local.return_value + c = Context() + with patch("invoke.context.WINDOWS", False): + with c.cd("foo bar"): + c.run("whoami") + assert runner.run.call_args[0][0] == r"cd foo\ bar && whoami" + + @patch(local_path) + def windows_quotes_paths_with_spaces(self, Local): + runner = Local.return_value + c = Context() + with patch("invoke.context.WINDOWS", True): + with c.cd(r"C:\Users\with space"): + c.run("whoami") + assert ( + runner.run.call_args[0][0] + == r'cd "C:\Users\with space" && whoami' + ) + class prefix: @patch(local_path) def prefixes_should_apply_to_run(self, Local):