diff options
author | Aravind Vasudevan <aravindvasudev@google.com> | 2023-10-06 00:40:25 +0000 |
---|---|---|
committer | LUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2023-10-06 18:21:45 +0000 |
commit | 2844a5f3cc81ffe2b749e574bdeb61809deab5b9 (patch) | |
tree | f57cc7f5e9420b47ed4c8506e4d2c3097ac49af5 | |
parent | 47944bbe2ea69009c0da78573f6536ad2c77f026 (diff) | |
download | git-repo-2844a5f3cc81ffe2b749e574bdeb61809deab5b9.tar.gz |
git_command: Augment underlying git errors with suggestions
This change appends suggestions to the underlying git error to make the
error slightly more actionable.
DD: go/improve-repo-error-reporting & go/tee-repo-stderr
Bug: b/292704435
Change-Id: I2bf8bea5fca42c6a9acd2fadc70f58f22456e027
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/387774
Commit-Queue: Aravind Vasudevan <aravindvasudev@google.com>
Reviewed-by: Jason Chang <jasonnc@google.com>
Tested-by: Aravind Vasudevan <aravindvasudev@google.com>
Reviewed-by: Aravind Vasudevan <aravindvasudev@google.com>
-rw-r--r-- | git_command.py | 166 | ||||
-rw-r--r-- | project.py | 2 | ||||
-rw-r--r-- | tests/test_git_command.py | 133 |
3 files changed, 270 insertions, 31 deletions
diff --git a/git_command.py b/git_command.py index fe1e48d6..ef6e321c 100644 --- a/git_command.py +++ b/git_command.py | |||
@@ -15,6 +15,7 @@ | |||
15 | import functools | 15 | import functools |
16 | import json | 16 | import json |
17 | import os | 17 | import os |
18 | import re | ||
18 | import subprocess | 19 | import subprocess |
19 | import sys | 20 | import sys |
20 | from typing import Any, Optional | 21 | from typing import Any, Optional |
@@ -24,6 +25,7 @@ from error import RepoExitError | |||
24 | from git_refs import HEAD | 25 | from git_refs import HEAD |
25 | from git_trace2_event_log_base import BaseEventLog | 26 | from git_trace2_event_log_base import BaseEventLog |
26 | import platform_utils | 27 | import platform_utils |
28 | from repo_logging import RepoLogger | ||
27 | from repo_trace import IsTrace | 29 | from repo_trace import IsTrace |
28 | from repo_trace import REPO_TRACE | 30 | from repo_trace import REPO_TRACE |
29 | from repo_trace import Trace | 31 | from repo_trace import Trace |
@@ -50,9 +52,11 @@ DEFAULT_GIT_FAIL_MESSAGE = "git command failure" | |||
50 | ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError" | 52 | ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError" |
51 | # Common line length limit | 53 | # Common line length limit |
52 | GIT_ERROR_STDOUT_LINES = 1 | 54 | GIT_ERROR_STDOUT_LINES = 1 |
53 | GIT_ERROR_STDERR_LINES = 1 | 55 | GIT_ERROR_STDERR_LINES = 10 |
54 | INVALID_GIT_EXIT_CODE = 126 | 56 | INVALID_GIT_EXIT_CODE = 126 |
55 | 57 | ||
58 | logger = RepoLogger(__file__) | ||
59 | |||
56 | 60 | ||
57 | class _GitCall(object): | 61 | class _GitCall(object): |
58 | @functools.lru_cache(maxsize=None) | 62 | @functools.lru_cache(maxsize=None) |
@@ -60,7 +64,7 @@ class _GitCall(object): | |||
60 | ret = Wrapper().ParseGitVersion() | 64 | ret = Wrapper().ParseGitVersion() |
61 | if ret is None: | 65 | if ret is None: |
62 | msg = "fatal: unable to detect git version" | 66 | msg = "fatal: unable to detect git version" |
63 | print(msg, file=sys.stderr) | 67 | logger.error(msg) |
64 | raise GitRequireError(msg) | 68 | raise GitRequireError(msg) |
65 | return ret | 69 | return ret |
66 | 70 | ||
@@ -135,10 +139,11 @@ def GetEventTargetPath(): | |||
135 | # `git config --get` is documented to produce an exit status of `1` | 139 | # `git config --get` is documented to produce an exit status of `1` |
136 | # if the requested variable is not present in the configuration. | 140 | # if the requested variable is not present in the configuration. |
137 | # Report any other return value as an error. | 141 | # Report any other return value as an error. |
138 | print( | 142 | logger.error( |
139 | "repo: error: 'git config --get' call failed with return code: " | 143 | "repo: error: 'git config --get' call failed with return code: " |
140 | "%r, stderr: %r" % (retval, p.stderr), | 144 | "%r, stderr: %r", |
141 | file=sys.stderr, | 145 | retval, |
146 | p.stderr, | ||
142 | ) | 147 | ) |
143 | return path | 148 | return path |
144 | 149 | ||
@@ -212,7 +217,7 @@ def git_require(min_version, fail=False, msg=""): | |||
212 | if msg: | 217 | if msg: |
213 | msg = " for " + msg | 218 | msg = " for " + msg |
214 | error_msg = "fatal: git %s or later required%s" % (need, msg) | 219 | error_msg = "fatal: git %s or later required%s" % (need, msg) |
215 | print(error_msg, file=sys.stderr) | 220 | logger.error(error_msg) |
216 | raise GitRequireError(error_msg) | 221 | raise GitRequireError(error_msg) |
217 | return False | 222 | return False |
218 | 223 | ||
@@ -297,6 +302,7 @@ class GitCommand(object): | |||
297 | self.project = project | 302 | self.project = project |
298 | self.cmdv = cmdv | 303 | self.cmdv = cmdv |
299 | self.verify_command = verify_command | 304 | self.verify_command = verify_command |
305 | self.stdout, self.stderr = None, None | ||
300 | 306 | ||
301 | # Git on Windows wants its paths only using / for reliability. | 307 | # Git on Windows wants its paths only using / for reliability. |
302 | if platform_utils.isWindows(): | 308 | if platform_utils.isWindows(): |
@@ -326,14 +332,6 @@ class GitCommand(object): | |||
326 | command.append("--progress") | 332 | command.append("--progress") |
327 | command.extend(cmdv[1:]) | 333 | command.extend(cmdv[1:]) |
328 | 334 | ||
329 | stdin = subprocess.PIPE if input else None | ||
330 | stdout = subprocess.PIPE if capture_stdout else None | ||
331 | stderr = ( | ||
332 | subprocess.STDOUT | ||
333 | if merge_output | ||
334 | else (subprocess.PIPE if capture_stderr else None) | ||
335 | ) | ||
336 | |||
337 | event_log = ( | 335 | event_log = ( |
338 | BaseEventLog(env=env, add_init_count=True) | 336 | BaseEventLog(env=env, add_init_count=True) |
339 | if add_event_log | 337 | if add_event_log |
@@ -344,9 +342,9 @@ class GitCommand(object): | |||
344 | self._RunCommand( | 342 | self._RunCommand( |
345 | command, | 343 | command, |
346 | env, | 344 | env, |
347 | stdin=stdin, | 345 | capture_stdout=capture_stdout, |
348 | stdout=stdout, | 346 | capture_stderr=capture_stderr, |
349 | stderr=stderr, | 347 | merge_output=merge_output, |
350 | ssh_proxy=ssh_proxy, | 348 | ssh_proxy=ssh_proxy, |
351 | cwd=cwd, | 349 | cwd=cwd, |
352 | input=input, | 350 | input=input, |
@@ -377,13 +375,46 @@ class GitCommand(object): | |||
377 | self, | 375 | self, |
378 | command, | 376 | command, |
379 | env, | 377 | env, |
380 | stdin=None, | 378 | capture_stdout=False, |
381 | stdout=None, | 379 | capture_stderr=False, |
382 | stderr=None, | 380 | merge_output=False, |
383 | ssh_proxy=None, | 381 | ssh_proxy=None, |
384 | cwd=None, | 382 | cwd=None, |
385 | input=None, | 383 | input=None, |
386 | ): | 384 | ): |
385 | # Set subprocess.PIPE for streams that need to be captured. | ||
386 | stdin = subprocess.PIPE if input else None | ||
387 | stdout = subprocess.PIPE if capture_stdout else None | ||
388 | stderr = ( | ||
389 | subprocess.STDOUT | ||
390 | if merge_output | ||
391 | else (subprocess.PIPE if capture_stderr else None) | ||
392 | ) | ||
393 | |||
394 | # tee_stderr acts like a tee command for stderr, in that, it captures | ||
395 | # stderr from the subprocess and streams it back to sys.stderr, while | ||
396 | # keeping a copy in-memory. | ||
397 | # This allows us to store stderr logs from the subprocess into | ||
398 | # GitCommandError. | ||
399 | # Certain git operations, such as `git push`, writes diagnostic logs, | ||
400 | # such as, progress bar for pushing, into stderr. To ensure we don't | ||
401 | # break git's UX, we need to write to sys.stderr as we read from the | ||
402 | # subprocess. Setting encoding or errors makes subprocess return | ||
403 | # io.TextIOWrapper, which is line buffered. To avoid line-buffering | ||
404 | # while tee-ing stderr, we unset these kwargs. See GitCommand._Tee | ||
405 | # for tee-ing between the streams. | ||
406 | # We tee stderr iff the caller doesn't want to capture any stream to | ||
407 | # not disrupt the existing flow. | ||
408 | # See go/tee-repo-stderr for more context. | ||
409 | tee_stderr = False | ||
410 | kwargs = {"encoding": "utf-8", "errors": "backslashreplace"} | ||
411 | if not (stdin or stdout or stderr): | ||
412 | tee_stderr = True | ||
413 | # stderr will be written back to sys.stderr even though it is | ||
414 | # piped here. | ||
415 | stderr = subprocess.PIPE | ||
416 | kwargs = {} | ||
417 | |||
387 | dbg = "" | 418 | dbg = "" |
388 | if IsTrace(): | 419 | if IsTrace(): |
389 | global LAST_CWD | 420 | global LAST_CWD |
@@ -430,11 +461,10 @@ class GitCommand(object): | |||
430 | command, | 461 | command, |
431 | cwd=cwd, | 462 | cwd=cwd, |
432 | env=env, | 463 | env=env, |
433 | encoding="utf-8", | ||
434 | errors="backslashreplace", | ||
435 | stdin=stdin, | 464 | stdin=stdin, |
436 | stdout=stdout, | 465 | stdout=stdout, |
437 | stderr=stderr, | 466 | stderr=stderr, |
467 | **kwargs, | ||
438 | ) | 468 | ) |
439 | except Exception as e: | 469 | except Exception as e: |
440 | raise GitPopenCommandError( | 470 | raise GitPopenCommandError( |
@@ -449,13 +479,45 @@ class GitCommand(object): | |||
449 | self.process = p | 479 | self.process = p |
450 | 480 | ||
451 | try: | 481 | try: |
452 | self.stdout, self.stderr = p.communicate(input=input) | 482 | if tee_stderr: |
483 | # tee_stderr streams stderr to sys.stderr while capturing | ||
484 | # a copy within self.stderr. tee_stderr is only enabled | ||
485 | # when the caller wants to pipe no stream. | ||
486 | self.stderr = self._Tee(p.stderr, sys.stderr) | ||
487 | else: | ||
488 | self.stdout, self.stderr = p.communicate(input=input) | ||
453 | finally: | 489 | finally: |
454 | if ssh_proxy: | 490 | if ssh_proxy: |
455 | ssh_proxy.remove_client(p) | 491 | ssh_proxy.remove_client(p) |
456 | self.rc = p.wait() | 492 | self.rc = p.wait() |
457 | 493 | ||
458 | @staticmethod | 494 | @staticmethod |
495 | def _Tee(in_stream, out_stream): | ||
496 | """Writes text from in_stream to out_stream while recording in buffer. | ||
497 | |||
498 | Args: | ||
499 | in_stream: I/O stream to be read from. | ||
500 | out_stream: I/O stream to write to. | ||
501 | |||
502 | Returns: | ||
503 | A str containing everything read from the in_stream. | ||
504 | """ | ||
505 | buffer = "" | ||
506 | chunk = in_stream.read1() | ||
507 | while chunk: | ||
508 | # Convert to str. | ||
509 | if not hasattr(chunk, "encode"): | ||
510 | chunk = chunk.decode("utf-8", "backslashreplace") | ||
511 | |||
512 | buffer += chunk | ||
513 | out_stream.write(chunk) | ||
514 | out_stream.flush() | ||
515 | |||
516 | chunk = in_stream.read1() | ||
517 | |||
518 | return buffer | ||
519 | |||
520 | @staticmethod | ||
459 | def _GetBasicEnv(): | 521 | def _GetBasicEnv(): |
460 | """Return a basic env for running git under. | 522 | """Return a basic env for running git under. |
461 | 523 | ||
@@ -517,6 +579,29 @@ class GitCommandError(GitError): | |||
517 | raised exclusively from non-zero exit codes returned from git commands. | 579 | raised exclusively from non-zero exit codes returned from git commands. |
518 | """ | 580 | """ |
519 | 581 | ||
582 | # Tuples with error formats and suggestions for those errors. | ||
583 | _ERROR_TO_SUGGESTION = [ | ||
584 | ( | ||
585 | re.compile("couldn't find remote ref .*"), | ||
586 | "Check if the provided ref exists in the remote.", | ||
587 | ), | ||
588 | ( | ||
589 | re.compile("unable to access '.*': .*"), | ||
590 | ( | ||
591 | "Please make sure you have the correct access rights and the " | ||
592 | "repository exists." | ||
593 | ), | ||
594 | ), | ||
595 | ( | ||
596 | re.compile("'.*' does not appear to be a git repository"), | ||
597 | "Are you running this repo command outside of a repo workspace?", | ||
598 | ), | ||
599 | ( | ||
600 | re.compile("not a git repository"), | ||
601 | "Are you running this repo command outside of a repo workspace?", | ||
602 | ), | ||
603 | ] | ||
604 | |||
520 | def __init__( | 605 | def __init__( |
521 | self, | 606 | self, |
522 | message: str = DEFAULT_GIT_FAIL_MESSAGE, | 607 | message: str = DEFAULT_GIT_FAIL_MESSAGE, |
@@ -533,16 +618,37 @@ class GitCommandError(GitError): | |||
533 | self.git_stdout = git_stdout | 618 | self.git_stdout = git_stdout |
534 | self.git_stderr = git_stderr | 619 | self.git_stderr = git_stderr |
535 | 620 | ||
621 | @property | ||
622 | @functools.lru_cache | ||
623 | def suggestion(self): | ||
624 | """Returns helpful next steps for the given stderr.""" | ||
625 | if not self.git_stderr: | ||
626 | return self.git_stderr | ||
627 | |||
628 | for err, suggestion in self._ERROR_TO_SUGGESTION: | ||
629 | if err.search(self.git_stderr): | ||
630 | return suggestion | ||
631 | |||
632 | return None | ||
633 | |||
536 | def __str__(self): | 634 | def __str__(self): |
537 | args = "[]" if not self.command_args else " ".join(self.command_args) | 635 | args = "[]" if not self.command_args else " ".join(self.command_args) |
538 | error_type = type(self).__name__ | 636 | error_type = type(self).__name__ |
539 | return f"""{error_type}: {self.message} | 637 | string = f"{error_type}: '{args}' on {self.project} failed" |
540 | Project: {self.project} | 638 | |
541 | Args: {args} | 639 | if self.message != DEFAULT_GIT_FAIL_MESSAGE: |
542 | Stdout: | 640 | string += f": {self.message}" |
543 | {self.git_stdout} | 641 | |
544 | Stderr: | 642 | if self.git_stdout: |
545 | {self.git_stderr}""" | 643 | string += f"\nstdout: {self.git_stdout}" |
644 | |||
645 | if self.git_stderr: | ||
646 | string += f"\nstderr: {self.git_stderr}" | ||
647 | |||
648 | if self.suggestion: | ||
649 | string += f"\nsuggestion: {self.suggestion}" | ||
650 | |||
651 | return string | ||
546 | 652 | ||
547 | 653 | ||
548 | class GitPopenCommandError(GitError): | 654 | class GitPopenCommandError(GitError): |
@@ -1135,7 +1135,7 @@ class Project(object): | |||
1135 | url = branch.remote.ReviewUrl(self.UserEmail, validate_certs) | 1135 | url = branch.remote.ReviewUrl(self.UserEmail, validate_certs) |
1136 | if url is None: | 1136 | if url is None: |
1137 | raise UploadError("review not configured", project=self.name) | 1137 | raise UploadError("review not configured", project=self.name) |
1138 | cmd = ["push"] | 1138 | cmd = ["push", "--progress"] |
1139 | if dryrun: | 1139 | if dryrun: |
1140 | cmd.append("-n") | 1140 | cmd.append("-n") |
1141 | 1141 | ||
diff --git a/tests/test_git_command.py b/tests/test_git_command.py index c803d280..881cccb8 100644 --- a/tests/test_git_command.py +++ b/tests/test_git_command.py | |||
@@ -14,6 +14,7 @@ | |||
14 | 14 | ||
15 | """Unittests for the git_command.py module.""" | 15 | """Unittests for the git_command.py module.""" |
16 | 16 | ||
17 | import io | ||
17 | import os | 18 | import os |
18 | import re | 19 | import re |
19 | import subprocess | 20 | import subprocess |
@@ -74,6 +75,10 @@ class GitCommandWaitTest(unittest.TestCase): | |||
74 | class MockPopen(object): | 75 | class MockPopen(object): |
75 | rc = 0 | 76 | rc = 0 |
76 | 77 | ||
78 | def __init__(self): | ||
79 | self.stdout = io.BufferedReader(io.BytesIO()) | ||
80 | self.stderr = io.BufferedReader(io.BytesIO()) | ||
81 | |||
77 | def communicate( | 82 | def communicate( |
78 | self, input: str = None, timeout: float = None | 83 | self, input: str = None, timeout: float = None |
79 | ) -> [str, str]: | 84 | ) -> [str, str]: |
@@ -117,6 +122,115 @@ class GitCommandWaitTest(unittest.TestCase): | |||
117 | self.assertEqual(1, r.Wait()) | 122 | self.assertEqual(1, r.Wait()) |
118 | 123 | ||
119 | 124 | ||
125 | class GitCommandStreamLogsTest(unittest.TestCase): | ||
126 | """Tests the GitCommand class stderr log streaming cases.""" | ||
127 | |||
128 | def setUp(self): | ||
129 | self.mock_process = mock.MagicMock() | ||
130 | self.mock_process.communicate.return_value = (None, None) | ||
131 | self.mock_process.wait.return_value = 0 | ||
132 | |||
133 | self.mock_popen = mock.MagicMock() | ||
134 | self.mock_popen.return_value = self.mock_process | ||
135 | mock.patch("subprocess.Popen", self.mock_popen).start() | ||
136 | |||
137 | def tearDown(self): | ||
138 | mock.patch.stopall() | ||
139 | |||
140 | def test_does_not_stream_logs_when_input_is_set(self): | ||
141 | git_command.GitCommand(None, ["status"], input="foo") | ||
142 | |||
143 | self.mock_popen.assert_called_once_with( | ||
144 | ["git", "status"], | ||
145 | cwd=None, | ||
146 | env=mock.ANY, | ||
147 | encoding="utf-8", | ||
148 | errors="backslashreplace", | ||
149 | stdin=subprocess.PIPE, | ||
150 | stdout=None, | ||
151 | stderr=None, | ||
152 | ) | ||
153 | self.mock_process.communicate.assert_called_once_with(input="foo") | ||
154 | self.mock_process.stderr.read1.assert_not_called() | ||
155 | |||
156 | def test_does_not_stream_logs_when_stdout_is_set(self): | ||
157 | git_command.GitCommand(None, ["status"], capture_stdout=True) | ||
158 | |||
159 | self.mock_popen.assert_called_once_with( | ||
160 | ["git", "status"], | ||
161 | cwd=None, | ||
162 | env=mock.ANY, | ||
163 | encoding="utf-8", | ||
164 | errors="backslashreplace", | ||
165 | stdin=None, | ||
166 | stdout=subprocess.PIPE, | ||
167 | stderr=None, | ||
168 | ) | ||
169 | self.mock_process.communicate.assert_called_once_with(input=None) | ||
170 | self.mock_process.stderr.read1.assert_not_called() | ||
171 | |||
172 | def test_does_not_stream_logs_when_stderr_is_set(self): | ||
173 | git_command.GitCommand(None, ["status"], capture_stderr=True) | ||
174 | |||
175 | self.mock_popen.assert_called_once_with( | ||
176 | ["git", "status"], | ||
177 | cwd=None, | ||
178 | env=mock.ANY, | ||
179 | encoding="utf-8", | ||
180 | errors="backslashreplace", | ||
181 | stdin=None, | ||
182 | stdout=None, | ||
183 | stderr=subprocess.PIPE, | ||
184 | ) | ||
185 | self.mock_process.communicate.assert_called_once_with(input=None) | ||
186 | self.mock_process.stderr.read1.assert_not_called() | ||
187 | |||
188 | def test_does_not_stream_logs_when_merge_output_is_set(self): | ||
189 | git_command.GitCommand(None, ["status"], merge_output=True) | ||
190 | |||
191 | self.mock_popen.assert_called_once_with( | ||
192 | ["git", "status"], | ||
193 | cwd=None, | ||
194 | env=mock.ANY, | ||
195 | encoding="utf-8", | ||
196 | errors="backslashreplace", | ||
197 | stdin=None, | ||
198 | stdout=None, | ||
199 | stderr=subprocess.STDOUT, | ||
200 | ) | ||
201 | self.mock_process.communicate.assert_called_once_with(input=None) | ||
202 | self.mock_process.stderr.read1.assert_not_called() | ||
203 | |||
204 | @mock.patch("sys.stderr") | ||
205 | def test_streams_stderr_when_no_stream_is_set(self, mock_stderr): | ||
206 | logs = "\n".join( | ||
207 | [ | ||
208 | "Enumerating objects: 5, done.", | ||
209 | "Counting objects: 100% (5/5), done.", | ||
210 | "Writing objects: 100% (3/3), 330 bytes | 330 KiB/s, done.", | ||
211 | "remote: Processing changes: refs: 1, new: 1, done ", | ||
212 | "remote: SUCCESS", | ||
213 | ] | ||
214 | ) | ||
215 | self.mock_process.stderr = io.BufferedReader( | ||
216 | io.BytesIO(bytes(logs, "utf-8")) | ||
217 | ) | ||
218 | |||
219 | cmd = git_command.GitCommand(None, ["push"]) | ||
220 | |||
221 | self.mock_popen.assert_called_once_with( | ||
222 | ["git", "push"], | ||
223 | cwd=None, | ||
224 | env=mock.ANY, | ||
225 | stdin=None, | ||
226 | stdout=None, | ||
227 | stderr=subprocess.PIPE, | ||
228 | ) | ||
229 | self.mock_process.communicate.assert_not_called() | ||
230 | mock_stderr.write.assert_called_once_with(logs) | ||
231 | self.assertEqual(cmd.stderr, logs) | ||
232 | |||
233 | |||
120 | class GitCallUnitTest(unittest.TestCase): | 234 | class GitCallUnitTest(unittest.TestCase): |
121 | """Tests the _GitCall class (via git_command.git).""" | 235 | """Tests the _GitCall class (via git_command.git).""" |
122 | 236 | ||
@@ -214,3 +328,22 @@ class GitRequireTests(unittest.TestCase): | |||
214 | with self.assertRaises(git_command.GitRequireError) as e: | 328 | with self.assertRaises(git_command.GitRequireError) as e: |
215 | git_command.git_require((2,), fail=True, msg="so sad") | 329 | git_command.git_require((2,), fail=True, msg="so sad") |
216 | self.assertNotEqual(0, e.code) | 330 | self.assertNotEqual(0, e.code) |
331 | |||
332 | |||
333 | class GitCommandErrorTest(unittest.TestCase): | ||
334 | """Test for the GitCommandError class.""" | ||
335 | |||
336 | def test_augument_stderr(self): | ||
337 | self.assertEqual( | ||
338 | git_command.GitCommandError( | ||
339 | git_stderr="couldn't find remote ref refs/heads/foo" | ||
340 | ).suggestion, | ||
341 | "Check if the provided ref exists in the remote.", | ||
342 | ) | ||
343 | |||
344 | self.assertEqual( | ||
345 | git_command.GitCommandError( | ||
346 | git_stderr="'foobar' does not appear to be a git repository" | ||
347 | ).suggestion, | ||
348 | "Are you running this repo command outside of a repo workspace?", | ||
349 | ) | ||