diff --git a/news/vc.rst b/news/vc.rst new file mode 100644 index 000000000..c27bc1d7e --- /dev/null +++ b/news/vc.rst @@ -0,0 +1,24 @@ +**Added:** + +* Display the current branch of Fossil VCS checkouts in the prompt, + similar to git and hg. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/tests/prompt/test_vc.py b/tests/prompt/test_vc.py index b32091044..f0ac60b96 100644 --- a/tests/prompt/test_vc.py +++ b/tests/prompt/test_vc.py @@ -1,5 +1,7 @@ import os import subprocess as sp +import textwrap +from typing import Dict, List from pathlib import Path from unittest.mock import Mock @@ -8,7 +10,19 @@ import pytest from xonsh.prompt import vc # Xonsh interaction with version control systems. -VC_BRANCH = {"git": {"master", "main"}, "hg": {"default"}} +VC_BRANCH = { + "git": {"master", "main"}, + "hg": {"default"}, + "fossil": {"trunk"}, +} +VC_INIT: Dict[str, List[List[str]]] = { + # A sequence of commands required to initialize a repository + "git": [["init"]], + "hg": [["init"]], + # Fossil "init" creates a central repository file with the given name, + # "open" creates the working directory at another, arbitrary location. + "fossil": [["init", "test.fossil"], ["open", "test.fossil"]] +} @pytest.fixture(params=VC_BRANCH.keys()) @@ -20,28 +34,32 @@ def repo(request, tmpdir_factory): temp_dir = Path(tmpdir_factory.mktemp("dir")) os.chdir(temp_dir) try: - sp.call([vc, "init"]) + for init_command in VC_INIT[vc]: + sp.call([vc] + init_command) except FileNotFoundError: pytest.skip(f"cannot find {vc} executable") if vc == "git": - git_config = temp_dir / ".git/config" - git_config.write_text( - """ -[user] -name = me -email = my@email.address -[init] -defaultBranch = main -""" - ) - - # git needs at least one commit - Path("test-file").touch() - sp.call(["git", "add", "test-file"]) - sp.call(["git", "commit", "-m", "test commit"]) + _init_git_repository(temp_dir) return {"vc": vc, "dir": temp_dir} +def _init_git_repository(temp_dir): + git_config = temp_dir / ".git/config" + git_config.write_text(textwrap.dedent( + """\ + [user] + name = me + email = my@email.address + [init] + defaultBranch = main + """ + )) + # git needs at least one commit + Path("test-file").touch() + sp.call(["git", "add", "test-file"]) + sp.call(["git", "commit", "-m", "test commit"]) + + @pytest.fixture def set_xenv(xession, monkeypatch): def _wrapper(path): @@ -52,8 +70,15 @@ def set_xenv(xession, monkeypatch): def test_test_repo(repo): - test_vc_dir = repo["dir"] / ".{}".format(repo["vc"]) - assert test_vc_dir.is_dir() + if repo["vc"] == "fossil": + # Fossil stores the check-out meta-data in a special file within the open check-out. + # At least one of these below must exist + metadata_file_names = {".fslckout", "_FOSSIL_"} # .fslckout on Unix, _FOSSIL_ on Windows + existing_files = set(file.name for file in repo["dir"].iterdir()) + assert existing_files.intersection(metadata_file_names) + else: + test_vc_dir = repo["dir"] / ".{}".format(repo["vc"]) + assert test_vc_dir.is_dir() if repo["vc"] == "git": test_file = repo["dir"] / "test-file" assert test_file.exists() diff --git a/xonsh/prompt/vc.py b/xonsh/prompt/vc.py index a0264d845..0f5dedcc4 100644 --- a/xonsh/prompt/vc.py +++ b/xonsh/prompt/vc.py @@ -124,6 +124,27 @@ def get_hg_branch(root=None): return branch +def _run_fossil_cmd(cmd): + timeout = XSH.env.get("VC_BRANCH_TIMEOUT") + result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=timeout) + return result + + +def get_fossil_branch(): + """Attempts to find the current fossil branch. If this could not + be determined (timeout, not in a fossil checkout, etc.) then this returns None. + """ + # from fossil branch --help: "fossil branch current: Print the name of the branch for the current check-out" + cmd = "fossil branch current".split() + try: + branch = xt.decode_bytes(_run_fossil_cmd(cmd)) + except (subprocess.CalledProcessError, OSError): + branch = None + else: + branch = RE_REMOVE_ANSI.sub("", branch.splitlines()[0]) + return branch + + _FIRST_BRANCH_TIMEOUT = True @@ -156,7 +177,7 @@ def _vc_has(binary): def current_branch(): """Gets the branch for a current working directory. Returns an empty string - if the cwd is not a repository. This currently only works for git and hg + if the cwd is not a repository. This currently only works for git, hg, and fossil and should be extended in the future. If a timeout occurred, the string '' is returned. """ @@ -165,6 +186,8 @@ def current_branch(): branch = get_git_branch() if not branch and _vc_has("hg"): branch = get_hg_branch() + if not branch and _vc_has("fossil"): + branch = get_fossil_branch() if isinstance(branch, subprocess.TimeoutExpired): branch = "" _first_branch_timeout_message() @@ -234,6 +257,20 @@ def hg_dirty_working_directory(): return None +def fossil_dirty_working_directory(): + """Returns whether the fossil checkout is dirty. If this could not + be determined (timeout, file not found, etc.) then this returns None. + """ + cmd = ["fossil", "changes"] + try: + status = _run_fossil_cmd(cmd) + except (subprocess.CalledProcessError, OSError): + status = None + else: + status = bool(status) + return status + + def dirty_working_directory(): """Returns a boolean as to whether there are uncommitted files in version control repository we are inside. If this cannot be determined, returns @@ -244,6 +281,8 @@ def dirty_working_directory(): dwd = git_dirty_working_directory() if dwd is None and _vc_has("hg"): dwd = hg_dirty_working_directory() + if dwd is None and _vc_has("fossil"): + dwd = fossil_dirty_working_directory() return dwd