Repository: xonsh/xonsh

Issue:
{gitstatus: {}} is no longer autohiding gitstatus
`{gitstatus: {}}` in my right prompt no longer hides itself when I'm not in a git repository.

## xonfig

<details>

```
$ xonfig
+------------------+---------------------+
| xonsh            | 0.13.0              |
| Python           | 3.10.5              |
| PLY              | 3.11                |
| have readline    | True                |
| prompt toolkit   | 3.0.30              |
| shell type       | prompt_toolkit      |
| history backend  | sqlite              |
| pygments         | 2.12.0              |
| on posix         | True                |
| on linux         | True                |
| distro           | unknown             |
| on wsl           | False               |
| on darwin        | False               |
| on windows       | False               |
| on cygwin        | False               |
| on msys2         | False               |
| is superuser     | False               |
| default encoding | utf-8               |
| xonsh encoding   | utf-8               |
| encoding errors  | surrogateescape     |
| xontrib          | []                  |
| RC file 1        | /home/monk/.xonshrc |
+------------------+---------------------+
```
</details>

## Expected Behavior
```
$ xonsh --no-rc
monk@lychee ~ $ $RIGHT_PROMPT='<{gitstatus: {}}>'
monk@lychee ~ $ $PROMPT_FIELDS['gitstatus'].fragments = ()                    <>
monk@lychee ~ $                                                               <>
```

## Current Behavior
```
$ xonsh --no-rc
monk@lychee ~ $ $RIGHT_PROMPT='<{gitstatus: {}}>'
monk@lychee ~ $ $PROMPT_FIELDS['gitstatus'].fragments = ()                 < |✓>
monk@lychee ~ $                                                              < >
```

## Steps to Reproduce
see above

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**


Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/xonsh/xonsh/pull/4920?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) Report
> Merging [#4920](https://codecov.io/gh/xonsh/xonsh/pull/4920?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) (440c1d4) into [main](https://codecov.io/gh/xonsh/xonsh/commit/6fa83f830579d2bad39b1842911951f55a9da22c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) (6fa83f8) will **decrease** coverage by `2.42%`.
> The diff coverage is `97.05%`.

```diff
@@            Coverage Diff             @@
##             main    #4920      +/-   ##
==========================================
- Coverage   66.80%   64.38%   -2.43%     
==========================================
  Files         129      129              
  Lines       23729    23738       +9     
  Branches     4706     4710       +4     
==========================================
- Hits        15853    15284     -569     
- Misses       6656     7201     +545     
- Partials     1220     1253      +33     
```

| Flag | Coverage Δ | |
|---|---|---|
| macOS-latest | `?` | |
| ubuntu-latest | `64.38% <97.05%> (+0.03%)` | :arrow_up: |
| windows-latest | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/xonsh/xonsh/pull/4920?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | Coverage Δ | |
|---|---|---|
| [xonsh/prompt/gitstatus.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvcHJvbXB0L2dpdHN0YXR1cy5weQ==) | `78.20% <97.05%> (-0.03%)` | :arrow_down: |
| [xonsh/macutils.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvbWFjdXRpbHMucHk=) | `0.00% <0.00%> (-80.00%)` | :arrow_down: |
| [xonsh/winutils.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvd2ludXRpbHMucHk=) | `0.00% <0.00%> (-47.55%)` | :arrow_down: |
| [xonsh/platform.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvcGxhdGZvcm0ucHk=) | `46.62% <0.00%> (-19.65%)` | :arrow_down: |
| [xonsh/completers/path.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvY29tcGxldGVycy9wYXRoLnB5) | `56.41% <0.00%> (-15.39%)` | :arrow_down: |
| [xonsh/lazyimps.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvbGF6eWltcHMucHk=) | `57.37% <0.00%> (-14.76%)` | :arrow_down: |
| [xonsh/procs/readers.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvcHJvY3MvcmVhZGVycy5weQ==) | `47.00% <0.00%> (-10.60%)` | :arrow_down: |
| [xonsh/xoreutils/\_which.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gveG9yZXV0aWxzL193aGljaC5weQ==) | `27.56% <0.00%> (-10.26%)` | :arrow_down: |
| [xonsh/xoreutils/uptime.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gveG9yZXV0aWxzL3VwdGltZS5weQ==) | `21.51% <0.00%> (-8.87%)` | :arrow_down: |
| [xonsh/commands\_cache.py](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#diff-eG9uc2gvY29tbWFuZHNfY2FjaGUucHk=) | `75.98% <0.00%> (-7.90%)` | :arrow_down: |
| ... and [34 more](https://codecov.io/gh/xonsh/xonsh/pull/4920/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh)


Patch:
diff --git a/news/fix-gitstatus-no-git.rst b/news/fix-gitstatus-no-git.rst
new file mode 100644
index 00000000..d8487e0e
--- /dev/null
+++ b/news/fix-gitstatus-no-git.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* <news item>
+
+**Changed:**
+
+* When there is no git repository, the values of all ``gitstatus`` prompt fields are now ``None``.
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* When there is no git repository, ``$PROMPT`` format strings like ``{gitstatus: hello {}}`` now work as expected.
+
+**Security:**
+
+* <news item>
diff --git a/xonsh/prompt/gitstatus.py b/xonsh/prompt/gitstatus.py
index e3a41ca8..e6cc0a76 100644
--- a/xonsh/prompt/gitstatus.py
+++ b/xonsh/prompt/gitstatus.py
@@ -92,12 +92,48 @@ def _get_sp_output(xsh, *args: str, **kwargs) -> str:
     return out
 
 
-class _GSField(PromptField):
+class _GitDir(PromptField):
+    _cwd = ""
+
+    def update(self, ctx):
+        # call the subprocess only if cwd changed
+        # or if value is None (in case `git init` was run)
+        from xonsh.dirstack import _get_cwd
+
+        cwd = _get_cwd()
+        if cwd != self._cwd or self.value is None:
+            self._cwd = cwd
+            self.value = _get_sp_output(
+                ctx.xsh, "git", "rev-parse", "--git-dir"
+            ).strip()
+            if self.value == "":
+                self.value = None
+
+
+repo_path = _GitDir()
+
+
+def inside_repo(ctx):
+    return ctx.pick_val(repo_path) is not None
+
+
+class GitStatusPromptField(PromptField):
+    """Only calls the updator if we are inside a git repository"""
+
+    def update(self, ctx):
+        if inside_repo(ctx):
+            if self.updator:
+                self.updator(self, ctx)
+        else:
+            self.value = None
+
+
+class _GSField(GitStatusPromptField):
     """wrap output from git command to value"""
 
     _args: "tuple[str, ...]" = ()
 
-    def update(self, ctx):
+    def updator(self, ctx):
         self.value = _get_sp_output(ctx.xsh, *self._args).strip()
 
 
@@ -105,7 +141,7 @@ short_head = _GSField(prefix=":", _args=("git", "rev-parse", "--short", "HEAD"))
 tag = _GSField(_args=("git", "describe", "--always"))
 
 
-@PromptField.wrap()
+@GitStatusPromptField.wrap()
 def tag_or_hash(fld: PromptField, ctx):
     fld.value = ctx.pick(tag) or ctx.pick(short_head)
 
@@ -116,23 +152,6 @@ def _parse_int(val: str, default=0):
     return default
 
 
-class _GitDir(_GSField):
-    _args = ("git", "rev-parse", "--git-dir")
-    _cwd = ""
-
-    def update(self, ctx):
-        # call the subprocess only if cwd changed
-        from xonsh.dirstack import _get_cwd
-
-        cwd = _get_cwd()
-        if cwd != self._cwd:
-            self._cwd = cwd
-            super().update(ctx)
-
-
-repo_path = _GitDir()
-
-
 def get_stash_count(gitdir: str):
     """Get git-stash count"""
     with contextlib.suppress(OSError):
@@ -141,7 +160,7 @@ def get_stash_count(gitdir: str):
     return 0
 
 
-@PromptField.wrap(prefix="⚑")
+@GitStatusPromptField.wrap(prefix="⚑")
 def stash_count(fld: PromptField, ctx: PromptFields):
     fld.value = get_stash_count(ctx.pick_val(repo_path))
 
@@ -160,7 +179,7 @@ def get_operations(gitdir: str):
             yield name
 
 
-@PromptField.wrap(prefix="{CYAN}", separator="|")
+@GitStatusPromptField.wrap(prefix="{CYAN}", separator="|")
 def operations(fld, ctx: PromptFields) -> None:
     gitdir = ctx.pick_val(repo_path)
     op = fld.separator.join(get_operations(gitdir))
@@ -170,7 +189,7 @@ def operations(fld, ctx: PromptFields) -> None:
         fld.value = ""
 
 
-@PromptField.wrap()
+@GitStatusPromptField.wrap()
 def porcelain(fld, ctx: PromptFields):
     """Return parsed values from ``git status --porcelain``"""
 
@@ -228,7 +247,7 @@ def get_gitstatus_info(fld: "_GSInfo", ctx: PromptFields) -> None:
     fld.value = info[fld.info]
 
 
-class _GSInfo(PromptField):
+class _GSInfo(GitStatusPromptField):
     info: str
 
     def __init__(self, **kwargs):
@@ -246,7 +265,7 @@ conflicts = _GSInfo(prefix="{RED}×", suffix="{RESET}", info="conflicts")
 staged = _GSInfo(prefix="{RED}●", suffix="{RESET}", info="staged")
 
 
-@PromptField.wrap()
+@GitStatusPromptField.wrap()
 def numstat(fld, ctx):
     changed = _get_sp_output(ctx.xsh, "git", "diff", "--numstat")
 
@@ -262,17 +281,17 @@ def numstat(fld, ctx):
     fld.value = (insert, delete)
 
 
-@PromptField.wrap(prefix="{BLUE}+", suffix="{RESET}")
+@GitStatusPromptField.wrap(prefix="{BLUE}+", suffix="{RESET}")
 def lines_added(fld: PromptField, ctx: PromptFields):
     fld.value = ctx.pick_val(numstat)[0]
 
 
-@PromptField.wrap(prefix="{RED}-", suffix="{RESET}")
+@GitStatusPromptField.wrap(prefix="{RED}-", suffix="{RESET}")
 def lines_removed(fld: PromptField, ctx):
     fld.value = ctx.pick_val(numstat)[-1]
 
 
-@PromptField.wrap(prefix="{BOLD_GREEN}", suffix="{RESET}", symbol="✓")
+@GitStatusPromptField.wrap(prefix="{BOLD_GREEN}", suffix="{RESET}", symbol="✓")
 def clean(fld, ctx):
     changes = sum(
         ctx.pick_val(f)
@@ -319,11 +338,11 @@ class GitStatus(MultiPromptField):
                 continue
             yield frag
 
-    def _collect(self, ctx):
-        if not ctx.pick_val(repo_path):
-            # no need to display any other fragments
-            return
-        yield from super()._collect(ctx)
+    def update(self, ctx):
+        if inside_repo(ctx):
+            super().update(ctx)
+        else:
+            self.value = None
 
 
 gitstatus = GitStatus()


Test patch:
diff --git a/tests/prompt/test_gitstatus.py b/tests/prompt/test_gitstatus.py
index ce20044e..72e780df 100644
--- a/tests/prompt/test_gitstatus.py
+++ b/tests/prompt/test_gitstatus.py
@@ -79,19 +79,14 @@ def test_gitstatus_clean(prompts, fake_proc):
 def test_no_git(prompts, fake_process, tmp_path):
     os.chdir(tmp_path)
     err = b"fatal: not a git repository (or any of the parent directories): .git"
-    for cmd in (
-        "git status --porcelain --branch",
-        "git rev-parse --git-dir",
-        "git diff --numstat",
-    ):
-        fake_process.register_subprocess(
-            command=cmd,
-            stderr=err,
-            returncode=128,
-        )
-
-    exp = ""
-    assert prompts.pick_val("gitstatus.repo_path") == ""
-    assert format(prompts.pick("gitstatus")) == exp
-    assert _format_value(prompts.pick("gitstatus"), None, None) == exp
-    assert _format_value(prompts.pick("gitstatus"), "{}", None) == exp
+    fake_process.register_subprocess(
+        command="git rev-parse --git-dir", stderr=err, returncode=128
+    )
+
+    # test that all gitstatus fields (gitstatus, gitstatus.branch,
+    # gitstatus.porceclain, etc) are None and are formatted correctly in a
+    # format string like {gitstatus: hello {}}
+    for field in prompts.get_fields(gitstatus):
+        assert prompts.pick_val(field) is None
+        assert _format_value(prompts.pick(field), None, None) == ""
+        assert _format_value(prompts.pick(field), "hello {}", None) == ""