Repository: jwodder/ps1.py (language: python)

Issue:
Make this into a Python package on PyPI
Package name: `jwodder-ps1`?


Discussion / hints:

Fix patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..eaba9b8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,21 @@
+version: 2
+updates:
+  - package-ecosystem: pip
+    directory: /
+    schedule:
+      interval: weekly
+    commit-message:
+      prefix: "[python]"
+    labels:
+      - dependencies
+      - d:python
+
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: weekly
+    commit-message:
+      prefix: "[gh-actions]"
+    labels:
+      - dependencies
+      - d:github-actions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..350c421
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.coverage*
+.mypy_cache/
+.nox/
+.tox/
+__pycache__/
+dist/
+docs/_build/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8f28406..b26a9e9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.6.0
     hooks:
       - id: check-added-large-files
       - id: check-json
@@ -10,17 +10,17 @@ repos:
       - id: trailing-whitespace
 
   - repo: https://github.com/psf/black
-    rev: 23.9.1
+    rev: 24.4.2
     hooks:
       - id: black
 
   - repo: https://github.com/PyCQA/isort
-    rev: 5.12.0
+    rev: 5.13.2
     hooks:
       - id: isort
 
   - repo: https://github.com/PyCQA/flake8
-    rev: 6.1.0
+    rev: 7.0.0
     hooks:
       - id: flake8
         additional_dependencies:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 542be69..e17317c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
 v0.6.0 (in development)
 -----------------------
 - Always open text files in UTF-8
+- If Git is not installed, the Git integration will now be automatically
+  disabled instead of raising an error
+- Require Python 3.8+
+- Now available as a package on PyPI
 
 v0.5.0 (2022-07-03)
 -------------------
diff --git a/LICENSE b/LICENSE
index edf4d37..29d1ae0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2017-2018, 2020-2022 John Thorvald Wodder II
+Copyright (c) 2017-2018, 2020-2024 John Thorvald Wodder II
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.rst b/README.rst
index 9590d85..ea2641f 100644
--- a/README.rst
+++ b/README.rst
@@ -1,65 +1,81 @@
-Here we have yet another script for Git-aware customization of the command
-prompt in Bash and zsh.  Unlike all the other scripts, I wrote this one, so
+|repostatus| |ci-status| |coverage| |license|
+
+.. |repostatus| image:: https://www.repostatus.org/badges/latest/wip.svg
+    :target: https://www.repostatus.org/#wip
+    :alt: Project Status: WIP — Initial development is in progress, but there
+          has not yet been a stable, usable release suitable for the public.
+
+.. |ci-status| image:: https://github.com/jwodder/ps1.py/actions/workflows/test.yml/badge.svg
+    :target: https://github.com/jwodder/ps1.py/actions/workflows/test.yml
+    :alt: CI Status
+
+.. |coverage| image:: https://codecov.io/gh/jwodder/ps1.py/branch/master/graph/badge.svg
+    :target: https://codecov.io/gh/jwodder/ps1.py
+
+.. |license| image:: https://img.shields.io/github/license/jwodder/ps1.py.svg
+    :target: https://opensource.org/licenses/MIT
+    :alt: MIT License
+
+`GitHub <https://github.com/jwodder/ps1.py>`_
+| `Issues <https://github.com/jwodder/ps1.py/issues>`_
+
+``jwodder-ps1`` is yet another program for Git-aware customization of the
+command prompt in Bash and zsh.  Unlike all the others, I wrote this one, so
 it's better.
 
-.. image:: screenshot.png
+.. image:: https://github.com/jwodder/ps1.py/raw/master/screenshot.png
 
 Features:
 
-- lets you know if you have mail in ``$MAIL``
-- shows chroot, `virtualenv <https://virtualenv.pypa.io>`_, and `Conda
+- Lets you know if you have mail in ``$MAIL``
+- Shows chroot, `virtualenv <https://virtualenv.pypa.io>`_, and `Conda
   <https://conda.io>`_ environment prompt prefixes
-- automatically truncates the current directory path if it gets too long
-- shows the status of the current Git repository (see below)
-- thoroughly documented and easily customizable
-- supports both Bash and zsh
-- can optionally output just the Git status, in case you want to combine it
+- Automatically truncates the current directory path if it gets too long
+- Shows the status of the current Git repository (see below)
+- Supports both Bash and zsh
+- Can optionally output just the Git status, in case you want to combine it
   with your own prompt string
 
 
-Requirements
-============
-
-- Python 3, version 3.5 or higher
-- Git, version 1.7.10 or higher
-- bash or zsh
-
-
 Installation & Setup
 ====================
 
-1. Save `ps1.py <ps1.py>`_ to your computer somewhere (I put my copy at
-   ``~/share/ps1.py``)
+``jwodder-ps1`` requires Python 3.8 or higher.  You'll also need a Bash or zsh
+shell to set the program up in, and you'll need ``git`` v1.7.10+ installed in
+order to get status information about Git repositories.
 
-2. If using Bash, add the following line to the end of your ``~/.bashrc``:
+Install the ``jwodder-ps1`` command by using `pip <https://pip.pypa.io>`_ or
+similar (using `pipx <https://pipx.pypa.io>`_ is recommended) to install the
+package of the same name from PyPI.
 
-   .. code:: shell
+If you use Bash, configure it to use ``jwodder-ps1`` for the prompt by adding
+the following line to the end of your ``~/.bashrc``:
 
-        PROMPT_COMMAND="$PROMPT_COMMAND"'; PS1="$(/usr/bin/python3 ~/share/ps1.py "${PS1_GIT:-}")"'
+.. code:: shell
 
-   If using zsh, add the following to the end of your ``~/.zshrc``:
+    PROMPT_COMMAND="$PROMPT_COMMAND"'; PS1="$(jwodder-ps1 "${PS1_GIT:-}")"'
 
-   .. code:: shell
+If you use zsh instead, add the following to the end of your ``~/.zshrc``:
 
-        precmd_ps1_py() { PS1="$(/usr/bin/python3 ~/share/ps1.py --zsh "${PS1_GIT:-}")" }
-        precmd_functions+=( precmd_ps1_py )
+.. code:: shell
 
-   If you want to use just the Git status portion of the script's output and
-   combine it with your own prompt string, replace the ``PS1`` assignment with
-   your desired prompt, with ``$(/usr/bin/python3 ~/share/ps1.py --git-only
-   "${PS1_GIT:-}")`` inserted where you want the Git status string.
+    precmd_jwodder_ps1() { PS1="$(jwodder-ps1 --zsh "${PS1_GIT:-}")" }
+    precmd_functions+=( precmd_jwodder_ps1 )
 
-   Replace ``/usr/bin/python3`` with the path to your Python 3 interpreter, and
-   replace ``~/share/ps1.py`` with the location you saved ``ps1.py`` at as
-   appropriate.
+If you want to use just the Git status portion of the script's output and
+combine it with your own prompt string, replace the ``PS1`` assignment with
+your desired prompt, with ``$(jwodder-ps1 --git-only "${PS1_GIT:-}")`` inserted
+where you want the Git status string.
 
-3. Open a new shell
+Depending on how ``jwodder-ps1`` was installed and what the value of your
+``PATH`` is, you may have to use the full path to the ``jwodder-ps1``
+executable instead.
 
-4. Enjoy!
+Once ``jwodder-ps1`` is configured, open a new shell and enjoy!
 
-5. If the Git integration causes you trouble (either because something breaks
-   or just because it's taking too long to run), it can be temporarily disabled
-   by running ``PS1_GIT=off`` on the command line.
+If the Git integration causes you trouble (either because something breaks or
+just because it's taking too long to run), it can be temporarily disabled by
+running ``PS1_GIT=off`` on the command line.
 
 
 Usage
@@ -67,16 +83,16 @@ Usage
 
 ::
 
-    python3 ps1.py [<options>] [<git flag>]
+    jwodder-ps1 [<options>] [<git flag>]
 
-``ps1.py`` outputs a single line containing a stylized prompt string for the
-current directory.  By default, the stylization is in a format usable in Bash's
-``PS1`` variable, though the ``--ansi`` and ``--zsh`` option can be supplied to
-change the format.
+The ``jwodder-ps1`` command outputs a single line containing a stylized prompt
+string for the current directory.  By default, the stylization is in a format
+usable in Bash's ``PS1`` variable, though the ``--ansi`` and ``--zsh`` option
+can be supplied to change the format.
 
-``ps1.py`` takes a single optional argument.  If this argument is "off", then
-the Git integration is disabled.  If it is any other value or not specified,
-the Git integration is enabled.
+``jwodder-ps1`` takes a single optional argument.  If this argument is "off",
+then the Git integration is disabled.  If it is any other value or not
+specified, the Git integration is enabled.
 
 Options
 -------
diff --git a/ps1.py b/ps1.py
deleted file mode 100644
index 16e5b4e..0000000
--- a/ps1.py
+++ /dev/null
@@ -1,680 +0,0 @@
-#!/usr/bin/python3
-"""
-Yet another bash/zsh prompt script
-
-Here we have yet another script for Git-aware customization of the command
-prompt in Bash and zsh.  Unlike all the other scripts, I wrote this one, so
-it's better.
-
-Features:
-
-- lets you know if you have mail in ``$MAIL``
-- shows chroot, `virtualenv <https://virtualenv.pypa.io>`_, and `Conda
-  <https://conda.io>`_ environment prompt prefixes
-- automatically truncates the current directory path if it gets too long
-- shows the status of the current Git repository
-- thoroughly documented and easily customizable
-- supports both Bash and zsh
-- can optionally output just the Git status, in case you want to combine it
-  with your own prompt string
-
-Visit <https://github.com/jwodder/ps1.py> for more information.
-
-
-Installation & Setup
-====================
-
-1. Save this script to your computer somewhere (I put my copy at
-   ``~/share/ps1.py``)
-
-2. If using Bash, add the following line to the end of your ``~/.bashrc``:
-
-   .. code:: shell
-
-        PROMPT_COMMAND="$PROMPT_COMMAND"'; PS1="$(/usr/bin/python3 ~/share/ps1.py "${PS1_GIT:-}")"'
-
-   If using zsh, add the following to the end of your ``~/.zshrc``:
-
-   .. code:: shell
-
-        precmd_ps1_py() { PS1="$(/usr/bin/python3 ~/share/ps1.py --zsh "${PS1_GIT:-}")" }
-        precmd_functions+=( precmd_ps1_py )
-
-   If you want to use just the Git status portion of the script's output and
-   combine it with your own prompt string, replace the ``PS1`` assignment with
-   your desired prompt, with ``$(/usr/bin/python3 ~/share/ps1.py --git-only
-   "${PS1_GIT:-}")`` inserted where you want the Git status string.
-
-   Replace ``/usr/bin/python3`` with the path to your Python 3 interpreter, and
-   replace ``~/share/ps1.py`` with the location you saved ``ps1.py`` at as
-   appropriate.
-
-3. Open a new shell
-
-4. Enjoy!
-
-5. If the Git integration causes you trouble (either because something breaks
-   or just because it's taking too long to run), it can be temporarily disabled
-   by running ``PS1_GIT=off`` on the command line.
-"""
-
-__version__ = "0.5.0"
-__author__ = "John T. Wodder II"
-__author_email__ = "ps1@varonathe.org"
-__license__ = "MIT"
-__url__ = "https://github.com/jwodder/ps1.py"
-
-import argparse
-from ast import literal_eval
-from enum import Enum
-import os
-from pathlib import Path, PurePath
-import re
-import socket
-from subprocess import (
-    DEVNULL,
-    PIPE,
-    CalledProcessError,
-    TimeoutExpired,
-    check_output,
-    run,
-)
-from types import SimpleNamespace
-
-#: Default maximum display length of the path to the current working directory
-MAX_CWD_LEN = 30
-
-
-class Color(Enum):
-    """
-    An enum of the supported foreground colors.  Each color's value equals its
-    xterm number.
-    """
-
-    RED = 1
-    GREEN = 2
-    YELLOW = 3
-    BLUE = 4
-    MAGENTA = 5
-    CYAN = 6
-    LIGHT_RED = 9
-    LIGHT_GREEN = 10
-    LIGHT_YELLOW = 11
-    LIGHT_BLUE = 12
-    LIGHT_MAGENTA = 13
-    LIGHT_CYAN = 14
-
-    def asfg(self):
-        """
-        Return the ANSI SGR parameter for setting the color as the foreground
-        color
-        """
-        c = self.value
-        return c + 30 if c < 8 else c + 82
-
-
-class BashStyler:
-    """Class for escaping & styling strings for use in Bash's PS1 variable"""
-
-    #: The actual prompt symbol to add at the end of the output, just before a
-    #: final space character
-    prompt_suffix = r"\$"
-
-    def __call__(self, s, fg=None, bold=False):
-        r"""
-        Return the string ``s`` escaped for use in a PS1 variable.  If ``fg``
-        is non-`None`, the string will be wrapped in the proper escape
-        sequences to display it as the given foreground color.  If ``bold`` is
-        true, the string will be wrapped in the proper escape sequences to
-        display it bold.  All escape sequences are wrapped in ``\[ ... \]`` so
-        that they may be used in a PS1 variable.
-
-        :param str s: the string to stylize
-        :param Color fg: the foreground color to stylize the string with
-        :param bool bold: whether to stylize the string as bold
-        """
-        s = self.escape(s)
-        if fg is not None:
-            s = r"\[\e[{}{}m\]{}\[\e[0m\]".format(
-                fg.asfg(),
-                ";1" if bold else "",
-                s,
-            )
-        elif bold:
-            s = r"\[\e[1m\]{}\[\e[0m\]".format(s)
-        return s
-
-    def escape(self, s):
-        """
-        Escape characters in the string ``s`` that have special meaning in a
-        PS1 variable
-        """
-        return s.replace("\\", r"\\")
-
-
-class ANSIStyler:
-    """Class for styling strings for display immediately in the terminal"""
-
-    #: The actual prompt symbol to add at the end of the output, just before a
-    #: final space character
-    prompt_suffix = "$"
-
-    def __call__(self, s, fg=None, bold=False):
-        r"""
-        Stylize the string ``s`` with ANSI escape sequences.  If ``fg`` is
-        non-`None`, the string will be stylized with the given foreground
-        color.  If ``bold`` is true, the string will be stylized bold.
-
-        :param str s: the string to stylize
-        :param Color fg: the foreground color to stylize the string with
-        :param bool bold: whether to stylize the string as bold
-        """
-        if fg is not None:
-            s = "\033[{}{}m{}\033[0m".format(fg.asfg(), ";1" if bold else "", s)
-        elif bold:
-            s = "\033[1m{}\033[0m".format(s)
-        return s
-
-
-class ZshStyler:
-    """Class for escaping & styling strings for use in zsh's PS1 variable"""
-
-    #: The actual prompt symbol to add at the end of the output, just before a
-    #: final space character
-    prompt_suffix = "%#"
-
-    def __call__(self, s, fg=None, bold=False):
-        """
-        Return the string ``s`` escaped for use in a zsh PS1 variable.  If
-        ``fg`` is non-`None`, the string will be wrapped in the proper escape
-        sequences to display it as the given foreground color.  If ``bold`` is
-        true, the string will be wrapped in the proper escape sequences to
-        display it bold.
-
-        :param str s: the string to stylize
-        :param Color fg: the foreground color to stylize the string with
-        :param bool bold: whether to stylize the string as bold
-        """
-        s = self.escape(s)
-        if bold:
-            s = "%B{}%b".format(s)
-        if fg is not None:
-            s = "%F{{{}}}{}%f".format(fg.value, s)
-        return s
-
-    def escape(self, s):
-        return s.replace("%", "%%")
-
-
-def main():
-    parser = argparse.ArgumentParser(
-        description=(
-            "Yet another bash/zsh prompt script."
-            "  Visit <{}> for more information.".format(__url__)
-        )
-    )
-    parser.add_argument(
-        "--ansi",
-        action="store_const",
-        dest="stylecls",
-        const=ANSIStyler,
-        help="Format prompt for direct display",
-    )
-    parser.add_argument(
-        "--bash",
-        action="store_const",
-        dest="stylecls",
-        const=BashStyler,
-        help="Format prompt for Bash's PS1 (default)",
-    )
-    parser.add_argument(
-        "-G",
-        "--git-only",
-        action="store_true",
-        help="Only output the Git portion of the prompt",
-    )
-    parser.add_argument(
-        "--git-timeout",
-        type=float,
-        metavar="SECONDS",
-        default=3,
-        help=(
-            "Disable Git integration if `git status` runtime exceeds timeout"
-            "  [default: 3]"
-        ),
-    )
-    parser.add_argument(
-        "--zsh",
-        action="store_const",
-        dest="stylecls",
-        const=ZshStyler,
-        help="Format prompt for zsh's PS1",
-    )
-    parser.add_argument(
-        "-V",
-        "--version",
-        action="version",
-        version="%(prog)s {}".format(__version__),
-    )
-    parser.add_argument(
-        "git_flag", nargs="?", help='Set to "off" to disable Git integration'
-    )
-    args = parser.parse_args()
-    show_git = args.git_flag != "off"
-    # Stylizing & escaping callable:
-    style = (args.stylecls or BashStyler)()
-    if args.git_only:
-        if show_git:
-            s = show_git_status(style, git_timeout=args.git_timeout)
-        else:
-            s = ""
-    else:
-        s = show_prompt_string(style, show_git=show_git, git_timeout=args.git_timeout)
-    print(s)
-
-
-def show_prompt_string(style, show_git=True, git_timeout=3):
-    """
-    Construct & return a complete prompt string for the current environment
-    """
-
-    # The beginning of the prompt string:
-    PS1 = ""
-
-    # If the $MAIL file is nonempty, show the string "[MAIL]":
-    try:
-        if Path(os.environ["MAIL"]).stat().st_size > 0:
-            PS1 += style("[MAIL] ", fg=Color.CYAN, bold=True)
-    except (KeyError, FileNotFoundError):
-        pass
-
-    # Show the chroot we're working in (if any):
-    debian_chroot = cat(Path("/etc/debian_chroot"))
-    if debian_chroot:
-        PS1 += style("[{}] ".format(debian_chroot), fg=Color.BLUE, bold=True)
-
-    # If a Conda environment is active, show its prompt prefix (which already
-    # includes the parentheses and trailing space).
-    if "CONDA_PROMPT_MODIFIER" in os.environ:
-        # Green like a snake!
-        PS1 += style(os.environ["CONDA_PROMPT_MODIFIER"], fg=Color.LIGHT_GREEN)
-
-    # If we're inside a Python virtualenv, show the basename of the virtualenv
-    # directory (or the custom prompt prefix, if set).
-    if "VIRTUAL_ENV" in os.environ:
-        venv = Path(os.environ["VIRTUAL_ENV"])
-        prompt = venv.name
-        try:
-            with (venv / "pyvenv.cfg").open(encoding="utf-8") as fp:
-                for line in fp:
-                    line = line.strip()
-                    m = re.match(r"^prompt\s*=\s*", line)
-                    if m:
-                        prompt = line[m.end() :]
-                        if re.fullmatch(r'([\x27"]).*\1', prompt):
-                            # repr-ized prompt produced by venv
-                            try:
-                                prompt = literal_eval(prompt)
-                            except Exception:
-                                pass
-                        break
-        except FileNotFoundError:
-            pass
-        PS1 += style("({}) ".format(prompt))
-
-    # Show the username of the current user.  I know who I am, so I don't need
-    # to see this, but the code is left in here as an example in case you want
-    # to enable it.
-    # PS1 += style(os.getlogin(), fg=Color.LIGHT_GREEN)
-
-    # Separator:
-    # PS1 += style('@')
-
-    # Show the current hostname:
-    PS1 += style(socket.gethostname(), fg=Color.LIGHT_RED)
-
-    # Separator:
-    PS1 += style(":")
-
-    # Show the path to the current working directory:
-    PS1 += style(cwdstr(), fg=Color.LIGHT_CYAN)
-
-    # If we're in a Git repository, show its status.  This can be disabled
-    # (e.g., in case of breakage or slowness) by passing "off" as the script's
-    # first argument.
-    if show_git:
-        PS1 += show_git_status(style, git_timeout=git_timeout)
-
-    # The actual prompt symbol at the end of the prompt:
-    PS1 += style.prompt_suffix + " "
-
-    # If your terminal emulator supports it, it's also possible to set the
-    # title of the terminal window by emitting "\[\e]0;$TITLE\a\]" somewhere in
-    # the prompt.  Here's an example that sets the title to `username@host`:
-    # PS1 += r'\[\e]0;{}@{}\a\]'.format(os.getlogin(), socket.gethostname())
-
-    return PS1
-
-
-def show_git_status(style, git_timeout=3):
-    """
-    Returns the portion of the prompt string (including the leading separator)
-    dedicated to showing the status of the current Git repository.  If we are
-    not in a Git repository, or if ``git status`` times out, returns the empty
-    string.
-    """
-    gs = git_status(timeout=git_timeout)
-    if gs is None:
-        return ""
-    # Start building the status string with the separator:
-    p = style("@")
-    if not gs.bare and gs.stashed:
-        # We have stashed changes:
-        p += style("+", fg=Color.LIGHT_YELLOW, bold=True)
-    # Show HEAD; color changes depending on whether it's detached:
-    head_color = Color.LIGHT_BLUE if gs.detached else Color.LIGHT_GREEN
-    p += style(gs.head, fg=head_color)
-    if gs.ahead:
-        # Show commits ahead of upstream:
-        p += style("+{}".format(gs.ahead), fg=Color.GREEN)
-        if gs.behind:
-            # Ahead/behind separator:
-            p += style(",")
-    if gs.behind:
-        # Show commits behind upstream:
-        p += style("-{}".format(gs.behind), fg=Color.RED)
-    if not gs.bare:
-        # Show staged/unstaged status:
-        if gs.staged and gs.unstaged:
-            p += style("*", fg=Color.LIGHT_YELLOW, bold=True)
-        elif gs.staged:
-            p += style("*", fg=Color.GREEN)
-        elif gs.unstaged:
-            p += style("*", fg=Color.RED)
-        # else: Show nothing
-        if gs.untracked:
-            # There are untracked files:
-            p += style("+", fg=Color.RED, bold=True)
-        if gs.state is not None:
-            # The repository is in the middle of something special:
-            p += style("[" + gs.state.value + "]", fg=Color.MAGENTA)
-        if gs.conflict:
-            # There are conflicted files:
-            p += style("!", fg=Color.RED, bold=True)
-    return p
-
-
-def cwdstr():
-    """
-    Show the path to the current working directory.  If the directory is at or
-    under :envvar:`HOME`, the path will start with ``~/``.  The path will also
-    be truncated to be no more than `MAX_CWD_LEN` characters long.
-    """
-    # Prefer $PWD to os.getcwd() as the former does not resolve symlinks
-    cwd = Path(os.environ.get("PWD") or os.getcwd())
-    try:
-        cwd = "~" / cwd.relative_to(Path.home())
-    except ValueError:
-        pass
-    return shortpath(cwd)
-
-
-def shortpath(p: PurePath, max_len=MAX_CWD_LEN):
-    """
-    If the filepath ``p`` is too long (longer than ``max_len``), cut off
-    leading components to make it fit; if that's not enough, also truncate the
-    final component.  Deleted bits are replaced with ellipses.
-
-    >>> shortpath(PurePath('/'))
-    '/'
-    >>> shortpath(PurePath('~'))
-    '~'
-    >>> shortpath(PurePath('/var/lib/data'))
-    '/var/lib/data'
-    >>> shortpath(PurePath('~/.local/lib/data'))
-    '~/.local/lib/data'
-    >>> shortpath(PurePath('/var/atlassian/applicationdata'))
-    '/var/atlassian/applicationdata'
-    >>> shortpath(PurePath('/var/atlassian/application-data'))
-    '…/atlassian/application-data'
-    >>> shortpath(PurePath('/var/atlassian/application-data/jira'))
-    '…/application-data/jira'
-    >>> shortpath(PurePath('~/var/atlassian/applicationdata'))
-    '…/atlassian/applicationdata'
-    >>> shortpath(PurePath('~/Photos/Vacation_2000_summer_part_1_funny'))
-    '…/Vacation_2000_summer_part_1…'
-    """
-    assert len(p.parts) > 0
-    if len(str(p)) > max_len:
-        p = PurePath("…", *p.parts[1 + (p.parts[0] == "/") :])
-        while len(str(p)) > max_len:
-            if len(p.parts) > 2:
-                p = PurePath("…", *p.parts[2:])
-            else:
-                p = PurePath("…", p.parts[1][: max_len - 3] + "…")
-                assert len(str(p)) <= max_len
-    return str(p)
-
-
-class GitState(Enum):
-    """
-    Represents the various "in progress" states that a Git repository can be
-    in.  The value of each enumeration is a short string for displaying in a
-    command prompt.
-    """
-
-    REBASE_MERGING = "REBAS"
-    REBASE_APPLYING = "REBAS"
-    MERGING = "MERGE"
-    CHERRY_PICKING = "CHYPK"
-    REVERTING = "REVRT"
-    BISECTING = "BSECT"
-
-
-def git_status(timeout=3):
-    """
-    If the current directory is in a Git repository, ``git_status()`` returns
-    an object with the following attributes:
-
-    :var head: a description of the repository's ``HEAD``: either the name of
-        the current branch (if any), or the name of the currently checked-out
-        tag (if any), or the short form of the current commit hash
-    :vartype head: str
-
-    :var detached: `True` iff the repository is in detached ``HEAD`` state
-    :vartype detached: bool
-
-    :var ahead: the number of commits by which ``HEAD`` is ahead of
-        ``@{upstream}``, or `None` if there is no upstream
-    :vartype ahead: int or None
-
-    :var behind: the number of commits by which ``HEAD`` is behind
-        ``@{upstream}``, or `None` if there is no upstream
-    :vartype behind: int or None
-
-    :var bare: `True` iff the repository is a bare repository
-    :vartype bare: bool
-
-    The following attributes are only present when ``bare`` is `False`:
-
-    :var stashed: `True` iff there are any stashed changes
-    :vartype stashed: bool
-
-    :var staged: `True` iff there are changes staged to be committed
-    :vartype staged: bool
-
-    :var unstaged: `True` iff there are unstaged changes in the working tree
-    :vartype unstaged: bool
-
-    :var untracked: `True` iff there are untracked files in the working tree
-    :vartype untracked: bool
-
-    :var conflict: `True` iff there are any paths in the working tree with
-        merge conflicts
-    :vartype conflict: bool
-
-    :var state: the current state of the working tree, or `None` if there are
-        no rebases/bisections/etc. currently in progress
-    :vartype state: GitState or None
-
-    :var rebase_head: the name of the original branch when rebasing?
-    :vartype rebase_head: str or None
-
-    :var progress: when rebasing, the current progress as a ``(current, total)``
-        pair
-    :vartype progress: tuple(int, int) or None
-
-    If the current directory is *not* in a Git repository, or if the runtime of
-    the ``git status`` command exceeds ``timeout``, ``git_status()`` returns
-    `None`.
-
-    This function is based on a combination of Git's `git-prompt.sh`__ and
-    magicmonty's bash-git-prompt__.
-
-    __ https://github.com/git/git/blob/master/contrib/completion/git-prompt.sh
-    __ https://github.com/magicmonty/bash-git-prompt/blob/master/gitstatus.py
-    """
-
-    git_dir = git("rev-parse", "--git-dir")
-    if git_dir is None:
-        return None
-    git_dir = Path(git_dir)
-
-    gs = SimpleNamespace()
-    gs.head = cat(git_dir / "HEAD")
-    if gs.head is None:
-        gs.detached = False
-    elif gs.head.startswith("ref: "):
-        gs.head = re.sub(r"^(ref: )?(refs/heads/)?", "", gs.head)
-        gs.detached = False
-    else:
-        gs.head = git("describe", "--tags", "--exact-match", "HEAD") or git(
-            "rev-parse", "--short", "HEAD"
-        )
-        gs.detached = True
-
-    gs.ahead = gs.behind = None
-    gs.bare = (
-        git("rev-parse", "--is-bare-repository") == "true"
-        or git("rev-parse", "--is-inside-work-tree") == "false"
-    )
-    # Note: The latter condition above actually means that we're inside a .git
-    # directory, but that's similar enough to a bare repo that no one will
-    # care.
-    if gs.bare:
-        delta = git("rev-list", "--count", "--left-right", "@{upstream}...HEAD")
-        if delta is not None:
-            gs.behind, gs.ahead = map(int, delta.split())
-        return gs
-
-    gs.stashed = git("rev-parse", "--verify", "--quiet", "refs/stash") is not None
-    gs.staged = False
-    gs.unstaged = False
-    gs.untracked = False
-    gs.conflict = False
-
-    try:
-        r = run(
-            ["git", "status", "--porcelain", "--branch"],
-            stdout=PIPE,
-            stderr=DEVNULL,
-            check=True,
-            universal_newlines=True,
-            timeout=timeout,
-        )
-    except TimeoutExpired:
-        return None
-
-    for line in r.stdout.strip().splitlines():
-        if line.startswith("##"):
-            m = re.fullmatch(
-                r"""
-                \#\#\s*(?:(?:Initial commit|No commits yet) on )?
-                (?P<branch>(?:[^\s.]|\.(?!\.))+)
-                (?:\.\.\.\S+
-                    (?:
-                        \s*\[
-                            (?:ahead\s*(?P<ahead>\d+))?
-                            (?:(?(ahead)[,\s]+)behind\s*(?P<behind>\d+))?
-                        \]
-                    )?
-                )?\s*
-            """,
-                line,
-                flags=re.X,
-            )
-            if m:
-                if m.group("ahead") is not None:
-                    gs.ahead = int(m.group("ahead"))
-                if m.group("behind") is not None:
-                    gs.behind = int(m.group("behind"))
-        elif line.startswith("??"):
-            gs.untracked = True
-        elif not line.startswith("!!"):
-            if "U" in line[:2]:
-                gs.conflict = True
-            else:
-                if line[0] != " ":
-                    gs.staged = True
-                if line[1] in "DM":
-                    gs.unstaged = True
-
-    gs.rebase_head = None
-    gs.progress = None
-    if (git_dir / "rebase-merge").is_dir():
-        gs.state = GitState.REBASE_MERGING
-        gs.rebase_head = cat(git_dir / "rebase-merge" / "head-name")
-        gs.progress = (
-            int(cat(git_dir / "rebase-merge" / "msgnum")),
-            int(cat(git_dir / "rebase-merge" / "end")),
-        )
-    elif (git_dir / "rebase-apply").is_dir():
-        gs.state = GitState.REBASE_APPLYING
-        if (git_dir / "rebase-apply" / "rebasing").is_file():
-            gs.rebase_head = cat(git_dir / "rebase-apply" / "head-name")
-        gs.progress = (
-            int(cat(git_dir / "rebase-apply" / "next")),
-            int(cat(git_dir / "rebase-apply" / "last")),
-        )
-    elif (git_dir / "MERGE_HEAD").is_file():
-        gs.state = GitState.MERGING
-    elif (git_dir / "CHERRY_PICK_HEAD").is_file():
-        gs.state = GitState.CHERRY_PICKING
-    elif (git_dir / "REVERT_HEAD").is_file():
-        gs.state = GitState.REVERTING
-    elif (git_dir / "BISECT_LOG").is_file():
-        gs.state = GitState.BISECTING
-    else:
-        gs.state = None
-
-    return gs
-
-
-def git(*args):
-    """
-    Run a Git command (suppressing stderr) and return its stdout with leading &
-    trailing whitespace stripped.  If the command fails, return `None`.
-    """
-    try:
-        return check_output(
-            ("git",) + args,
-            universal_newlines=True,
-            stderr=DEVNULL,
-        ).strip()
-    except CalledProcessError:
-        return None
-
-
-def cat(path: Path):
-    """
-    Return the contents of the given file with leading & trailing whitespace
-    stripped.  If the file does not exist, return `None`.
-    """
-    try:
-        return path.read_text(encoding="utf-8").strip()
-    except FileNotFoundError:
-        return None
-
-
-if __name__ == "__main__":
-    main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..abad16d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,85 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "jwodder-ps1"
+dynamic = ["version"]
+description = "Yet another bash/zsh custom prompt script"
+readme = "README.rst"
+requires-python = ">=3.8"
+license = "MIT"
+license-files = { paths = ["LICENSE"] }
+authors = [
+    { name = "John Thorvald Wodder II", email = "ps1@varonathe.org" }
+]
+
+keywords = [
+    "bash",
+    "git",
+    "prompt",
+    "ps1",
+    "zsh",
+]
+
+classifiers = [
+    "Programming Language :: Python :: 3 :: Only",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: Implementation :: CPython",
+    "Programming Language :: Python :: Implementation :: PyPy",
+    "License :: OSI Approved :: MIT License",
+    "Environment :: Console",
+    "Intended Audience :: Developers",
+    "Intended Audience :: End Users/Desktop",
+    "Intended Audience :: Information Technology",
+    "Operating System :: POSIX",
+    "Topic :: Software Development :: Version Control :: Git",
+    "Topic :: System :: Shells",
+    "Typing :: Typed",
+]
+
+dependencies = []
+
+[project.scripts]
+jwodder-ps1 = "jwodder_ps1.__main__:main"
+
+[project.urls]
+"Source Code" = "https://github.com/jwodder/ps1.py"
+"Bug Tracker" = "https://github.com/jwodder/ps1.py/issues"
+
+[tool.hatch.version]
+path = "src/jwodder_ps1/__init__.py"
+
+[tool.hatch.build.targets.sdist]
+include = [
+    "/docs",
+    "/src",
+    "/test",
+    "CHANGELOG.*",
+    "CONTRIBUTORS.*",
+    "tox.ini",
+]
+
+[tool.hatch.envs.default]
+python = "3"
+
+[tool.mypy]
+allow_incomplete_defs = false
+allow_untyped_defs = false
+ignore_missing_imports = false
+# <https://github.com/python/mypy/issues/7773>:
+no_implicit_optional = true
+implicit_reexport = false
+local_partial_types = true
+pretty = true
+show_error_codes = true
+show_traceback = true
+strict_equality = true
+warn_redundant_casts = true
+warn_return_any = true
+warn_unreachable = true
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index ea61ea8..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,17 +0,0 @@
-[flake8]
-doctests = True
-extend-exclude = build/,dist/,test/data,venv/
-max-doc-length = 100
-max-line-length = 100
-unused-arguments-ignore-stub-functions = True
-extend-select = B901,B902,B950
-ignore = A003,B005,E203,E262,E266,E501,U101,W503
-
-[isort]
-atomic = True
-force_sort_within_sections = True
-honor_noqa = True
-lines_between_sections = 0
-profile = black
-reverse_relative = True
-sort_relative_in_force_sorted_sections = True
diff --git a/src/jwodder_ps1/__init__.py b/src/jwodder_ps1/__init__.py
new file mode 100644
index 0000000..b30ea43
--- /dev/null
+++ b/src/jwodder_ps1/__init__.py
@@ -0,0 +1,27 @@
+"""
+Yet another bash/zsh custom prompt script
+
+``jwodder-ps1`` is yet another program for Git-aware customization of the
+command prompt in Bash and zsh.  Unlike all the others, I wrote this one, so
+it's better.
+
+Features:
+
+- Lets you know if you have mail in ``$MAIL``
+- Shows chroot, `virtualenv <https://virtualenv.pypa.io>`_, and `Conda
+  <https://conda.io>`_ environment prompt prefixes
+- Automatically truncates the current directory path if it gets too long
+- Shows the status of the current Git repository
+- Thoroughly documented and easily customizable
+- Supports both Bash and zsh
+- Can optionally output just the Git status, in case you want to combine it
+  with your own prompt string
+
+Visit <https://github.com/jwodder/ps1.py> for more information.
+"""
+
+__version__ = "0.6.0.dev1"
+__author__ = "John Thorvald Wodder II"
+__author_email__ = "ps1@varonathe.org"
+__license__ = "MIT"
+__url__ = "https://github.com/jwodder/ps1.py"
diff --git a/src/jwodder_ps1/__main__.py b/src/jwodder_ps1/__main__.py
new file mode 100644
index 0000000..dddcebf
--- /dev/null
+++ b/src/jwodder_ps1/__main__.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+import argparse
+from . import __url__, __version__
+from .git import git_status
+from .info import PromptInfo
+from .style import ANSIStyler, BashStyler, ZshStyler
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description=(
+            "Yet another bash/zsh prompt script."
+            f"  Visit <{__url__}> for more information."
+        )
+    )
+    parser.add_argument(
+        "--ansi",
+        action="store_const",
+        dest="stylecls",
+        const=ANSIStyler,
+        help="Format prompt for direct display",
+    )
+    parser.add_argument(
+        "--bash",
+        action="store_const",
+        dest="stylecls",
+        const=BashStyler,
+        help="Format prompt for Bash's PS1 (default)",
+    )
+    parser.add_argument(
+        "-G",
+        "--git-only",
+        action="store_true",
+        help="Only output the Git portion of the prompt",
+    )
+    parser.add_argument(
+        "--git-timeout",
+        type=float,
+        metavar="SECONDS",
+        default=3,
+        help=(
+            "Disable Git integration if `git status` runtime exceeds timeout"
+            "  [default: 3]"
+        ),
+    )
+    parser.add_argument(
+        "--zsh",
+        action="store_const",
+        dest="stylecls",
+        const=ZshStyler,
+        help="Format prompt for zsh's PS1",
+    )
+    parser.add_argument(
+        "-V",
+        "--version",
+        action="version",
+        version=f"%(prog)s {__version__}",
+    )
+    parser.add_argument(
+        "git_flag", nargs="?", help='Set to "off" to disable Git integration'
+    )
+    args = parser.parse_args()
+    show_git = args.git_flag != "off"
+    # Stylizing & escaping callable:
+    style = (args.stylecls or BashStyler)()
+    if args.git_only:
+        if show_git and (gs := git_status(timeout=args.git_timeout)):
+            s = gs.display(style)
+        else:
+            s = ""
+    else:
+        s = PromptInfo.get(git=show_git, git_timeout=args.git_timeout).display(style)
+    print(s)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/jwodder_ps1/git.py b/src/jwodder_ps1/git.py
new file mode 100644
index 0000000..23a4a26
--- /dev/null
+++ b/src/jwodder_ps1/git.py
@@ -0,0 +1,284 @@
+from __future__ import annotations
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+import re
+import subprocess
+from .style import Color, Styler
+
+
+@dataclass
+class GitStatus:
+    #: A description of the repository's ``HEAD``: either the name of the
+    #: current branch (if any), or the name of the currently checked-out tag
+    #: (if any), or the short form of the current commit hash
+    head: str
+
+    #: `True` iff the repository is in a detached ``HEAD`` state
+    detached: bool
+
+    #: The number of commits by which ``HEAD`` is ahead of ``@{upstream}``, or
+    #: `None` if there is no upstream
+    ahead: int | None
+
+    #: The number of commits by which ``HEAD`` is behind ``@{upstream}``, or
+    #: `None` if there is no upstream
+    behind: int | None
+
+    #: Status of the repository's worktree; this is non-`None` iff the
+    #: repository is not a bare repository
+    wkt: WorkTreeStatus | None
+
+    def display(self, style: Styler) -> str:
+        # Start building the status string with the separator:
+        p = style("@")
+        if self.wkt is not None and self.wkt.stashed:
+            # We have stashed changes:
+            p += style("+", fg=Color.LIGHT_YELLOW, bold=True)
+        # Show HEAD; color changes depending on whether it's detached:
+        head_color = Color.LIGHT_BLUE if self.detached else Color.LIGHT_GREEN
+        p += style(self.head, fg=head_color)
+        if self.ahead:
+            # Show commits ahead of upstream:
+            p += style(f"+{self.ahead}", fg=Color.GREEN)
+            if self.behind:
+                # Ahead/behind separator:
+                p += style(",")
+        if self.behind:
+            # Show commits behind upstream:
+            p += style(f"-{self.behind}", fg=Color.RED)
+        if (wkt := self.wkt) is not None:
+            # Show staged/unstaged status:
+            if wkt.staged and wkt.unstaged:
+                p += style("*", fg=Color.LIGHT_YELLOW, bold=True)
+            elif wkt.staged:
+                p += style("*", fg=Color.GREEN)
+            elif wkt.unstaged:
+                p += style("*", fg=Color.RED)
+            # else: Show nothing
+            if wkt.untracked:
+                # There are untracked files:
+                p += style("+", fg=Color.RED, bold=True)
+            if wkt.state is not None:
+                # The repository is in the middle of something special:
+                p += style("[" + wkt.state.value + "]", fg=Color.MAGENTA)
+            if wkt.conflict:
+                # There are conflicted files:
+                p += style("!", fg=Color.RED, bold=True)
+        return p
+
+
+@dataclass
+class WorkTreeStatus:
+    #: `True` iff there are any stashed changes
+    stashed: bool
+
+    #: `True` iff there are changes staged to be committed
+    staged: bool
+
+    #: `True` iff there are unstaged changes in the working tree
+    unstaged: bool
+
+    #: `True` iff there are untracked files in the working tree
+    untracked: bool
+
+    #: `True` iff there are any paths in the working tree with merge conflicts
+    conflict: bool
+
+    #: The current state of the working tree, or `None` if there are no
+    #: rebases/bisections/etc. currently in progress
+    state: GitState | None
+
+    #: The name of the original branch when rebasing?
+    # rebase_head: str | None
+
+    #: When rebasing, the current progress as a ``(current, total)`` pair
+    # progress: tuple[int, int] | None
+
+
+class GitState(Enum):
+    """
+    Represents the various "in progress" states that a Git repository can be
+    in.  The value of each enumeration is a short string for displaying in a
+    command prompt.
+    """
+
+    REBASE_MERGING = "REBAS"
+    REBASE_APPLYING = "REBAS"
+    MERGING = "MERGE"
+    CHERRY_PICKING = "CHYPK"
+    REVERTING = "REVRT"
+    BISECTING = "BSECT"
+
+
+def git_status(timeout: float = 3) -> GitStatus | None:
+    """
+    If the current directory is in a Git repository, ``git_status()`` returns
+    a `GitStatus` instance describing the repository's current state.
+
+    If the current directory is not in a Git repository, or if Git is not
+    installed, or or if the runtime of the ``git status`` command exceeds
+    ``timeout``, ``git_status()`` returns `None`.
+
+    This function is based on a combination of Git's `git-prompt.sh`__ and
+    magicmonty's bash-git-prompt__.
+
+    __ https://github.com/git/git/blob/master/contrib/completion/git-prompt.sh
+    __ https://github.com/magicmonty/bash-git-prompt/blob/master/gitstatus.py
+    """
+
+    try:
+        git_dir_str = git("rev-parse", "--git-dir")
+    except FileNotFoundError:
+        # Git is not installed
+        return None
+    if git_dir_str is None:
+        return None
+    git_dir = Path(git_dir_str)
+
+    head = (git_dir / "HEAD").read_text(encoding="utf-8").strip()
+    if head.startswith("ref: "):
+        head = re.sub(r"^(ref: )?(refs/heads/)?", "", head)
+        detached = False
+    else:
+        head2 = git("describe", "--tags", "--exact-match", "HEAD") or git(
+            "rev-parse", "--short", "HEAD"
+        )
+        assert head2 is not None
+        head = head2
+        detached = True
+
+    ahead: int | None = None
+    behind: int | None = None
+    bare = (
+        git("rev-parse", "--is-bare-repository") == "true"
+        or git("rev-parse", "--is-inside-work-tree") == "false"
+    )
+    # Note: The latter condition above actually means that we're inside a .git
+    # directory, but that's similar enough to a bare repo that no one will
+    # care.
+    if bare:
+        delta = git("rev-list", "--count", "--left-right", "@{upstream}...HEAD")
+        if delta is not None:
+            behind, ahead = map(int, delta.split())
+        return GitStatus(
+            head=head,
+            detached=detached,
+            ahead=ahead,
+            behind=behind,
+            wkt=None,
+        )
+
+    stashed = git("rev-parse", "--verify", "--quiet", "refs/stash") is not None
+    staged = False
+    unstaged = False
+    untracked = False
+    conflict = False
+
+    try:
+        r = subprocess.run(
+            ["git", "status", "--porcelain", "--branch"],
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+            check=True,
+            text=True,
+            timeout=timeout,
+        )
+    except subprocess.TimeoutExpired:
+        return None
+
+    for line in r.stdout.strip().splitlines():
+        if line.startswith("##"):
+            m = re.fullmatch(
+                r"""
+                \#\#\s*(?:(?:Initial commit|No commits yet) on )?
+                (?P<branch>(?:[^\s.]|\.(?!\.))+)
+                (?:\.\.\.\S+
+                    (?:
+                        \s*\[
+                            (?:ahead\s*(?P<ahead>\d+))?
+                            (?:(?(ahead)[,\s]+)behind\s*(?P<behind>\d+))?
+                        \]
+                    )?
+                )?\s*
+            """,
+                line,
+                flags=re.X,
+            )
+            if m:
+                if m.group("ahead") is not None:
+                    ahead = int(m.group("ahead"))
+                if m.group("behind") is not None:
+                    behind = int(m.group("behind"))
+        elif line.startswith("??"):
+            untracked = True
+        elif not line.startswith("!!"):
+            if "U" in line[:2]:
+                conflict = True
+            else:
+                if line[0] != " ":
+                    staged = True
+                if line[1] in "DM":
+                    unstaged = True
+
+    # rebase_head: str | None = None
+    # progress: tuple[int, int] | None = None
+    if (git_dir / "rebase-merge").is_dir():
+        state = GitState.REBASE_MERGING
+        # rbdir = git_dir / "rebase-merge"
+        # rebase_head = (rbdir / "head-name").read_text(encoding="utf-8").strip()
+        # rebase_msgnum = (rbdir / "msgnum").read_text(encoding="utf-8").strip()
+        # rebase_end = (rbdir / "end").read_text(encoding="utf-8").strip()
+        # progress = (int(rebase_msgnum), int(rebase_end))
+    elif (git_dir / "rebase-apply").is_dir():
+        state = GitState.REBASE_APPLYING
+        # rbdir = git_dir / "rebase-apply"
+        # if (rbdir / "rebasing").is_file():
+        #     rebase_head = (rbdir / "head-name").read_text(encoding="utf-8").strip()
+        # rebase_next = (rbdir / "next").read_text(encoding="utf-8").strip()
+        # rebase_last = (rbdir / "last").read_text(encoding="utf-8").strip()
+        # progress = (int(rebase_next), int(rebase_last))
+    elif (git_dir / "MERGE_HEAD").is_file():
+        state = GitState.MERGING
+    elif (git_dir / "CHERRY_PICK_HEAD").is_file():
+        state = GitState.CHERRY_PICKING
+    elif (git_dir / "REVERT_HEAD").is_file():
+        state = GitState.REVERTING
+    elif (git_dir / "BISECT_LOG").is_file():
+        state = GitState.BISECTING
+    else:
+        state = None
+
+    return GitStatus(
+        head=head,
+        detached=detached,
+        ahead=ahead,
+        behind=behind,
+        wkt=WorkTreeStatus(
+            stashed=stashed,
+            staged=staged,
+            unstaged=unstaged,
+            untracked=untracked,
+            conflict=conflict,
+            state=state,
+            # rebase_head=rebase_head,
+            # progress=progress,
+        ),
+    )
+
+
+def git(*args: str) -> str | None:
+    """
+    Run a Git command (suppressing stderr) and return its stdout with leading &
+    trailing whitespace stripped.  If the command fails, return `None`.
+    """
+    try:
+        return subprocess.run(
+            ["git", *args],
+            check=True,
+            text=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+        ).stdout.strip()
+    except subprocess.CalledProcessError:
+        return None
diff --git a/src/jwodder_ps1/info.py b/src/jwodder_ps1/info.py
new file mode 100644
index 0000000..3d8adc5
--- /dev/null
+++ b/src/jwodder_ps1/info.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+from ast import literal_eval
+from dataclasses import dataclass
+import os
+from pathlib import Path, PurePath
+import re
+import socket
+from .git import GitStatus, git_status
+from .style import Color, Styler
+
+#: Default maximum display length of the path to the current working directory
+MAX_CWD_LEN = 30
+
+
+@dataclass
+class PromptInfo:
+    #: `True` iff the :envvar:`MAIL` file is nonempty
+    mail: bool
+
+    #: The chroot we're working in (if any)
+    debian_chroot: str | None
+
+    #: If a Conda environment is active, this is its prompt prefix (including
+    #: the parentheses and trailing space)
+    conda_prompt_modifier: str | None
+
+    #: If we're inside a Python virtualenv, this is its custom prompt prefix,
+    #: if set, (without parentheses) or else the basename of the virtualenv
+    #: directory
+    venv_prompt: str | None
+
+    hostname: str
+
+    #: The path to the current working directory.  If the directory is at or
+    #: under :envvar:`HOME`, the path will start with ``~/``.  The path will
+    #: also be truncated to be no more than `MAX_CWD_LEN` characters long.
+    cwdstr: str
+
+    git: GitStatus | None
+
+    @classmethod
+    def get(cls, git: bool = True, git_timeout: float = 3) -> PromptInfo:
+        try:
+            if os.stat(os.environ["MAIL"]).st_size > 0:
+                mail = True
+        except (KeyError, FileNotFoundError):
+            mail = False
+
+        try:
+            debian_chroot = (
+                Path("/etc/debian_chroot").read_text(encoding="utf-8").strip()
+            )
+        except FileNotFoundError:
+            debian_chroot = None
+
+        conda_prompt_modifier = os.environ.get("CONDA_PROMPT_MODIFIER")
+
+        venv_prompt: str | None
+        if (venv_str := os.environ.get("VIRTUAL_ENV")) is not None:
+            venv = Path(venv_str)
+            venv_prompt = venv.name
+            try:
+                with (venv / "pyvenv.cfg").open(encoding="utf-8") as fp:
+                    for line in fp:
+                        line = line.strip()
+                        if m := re.match(r"^prompt\s*=\s*", line):
+                            prompt = line[m.end() :]
+                            if re.fullmatch(r'([\x27"]).*\1', prompt):
+                                # repr-ized prompt produced by venv
+                                try:
+                                    venv_prompt = literal_eval(prompt)
+                                except Exception:
+                                    pass
+                            break
+            except FileNotFoundError:
+                pass
+        else:
+            venv_prompt = None
+
+        hostname = socket.gethostname()
+
+        if git:
+            gs = git_status(timeout=git_timeout)
+        else:
+            gs = None
+
+        return cls(
+            mail=mail,
+            debian_chroot=debian_chroot,
+            conda_prompt_modifier=conda_prompt_modifier,
+            venv_prompt=venv_prompt,
+            hostname=hostname,
+            cwdstr=cwdstr(),
+            git=gs,
+        )
+
+    def display(self, style: Styler) -> str:
+        """
+        Construct & return a complete prompt string for the current environment
+        """
+
+        # The beginning of the prompt string:
+        ps1 = ""
+
+        # If the $MAIL file is nonempty, show the string "[MAIL]":
+        if self.mail:
+            ps1 += style("[MAIL] ", fg=Color.CYAN, bold=True)
+
+        # Show the chroot we're working in (if any):
+        if self.debian_chroot is not None:
+            ps1 += style(f"[{self.debian_chroot}] ", fg=Color.BLUE, bold=True)
+
+        # If a Conda environment is active, show its prompt prefix:
+        if self.conda_prompt_modifier is not None:
+            # Green like a snake!
+            ps1 += style(self.conda_prompt_modifier, fg=Color.LIGHT_GREEN)
+
+        # If we're inside a Python virtualenv, show the basename of the
+        # virtualenv directory (or the custom prompt prefix, if set).
+        if self.venv_prompt is not None:
+            ps1 += style(f"({self.venv_prompt}) ")
+
+        # Show the current hostname:
+        ps1 += style(self.hostname, fg=Color.LIGHT_RED)
+
+        # Separator:
+        ps1 += style(":")
+
+        # Show the path to the current working directory:
+        ps1 += style(self.cwdstr, fg=Color.LIGHT_CYAN)
+
+        # Show Git status information, if any:
+        if self.git is not None:
+            ps1 += self.git.display(style)
+
+        # The actual prompt symbol at the end of the prompt:
+        ps1 += style.prompt_suffix + " "
+
+        return ps1
+
+
+def cwdstr() -> str:
+    """
+    Show the path to the current working directory.  If the directory is at or
+    under :envvar:`HOME`, the path will start with ``~/``.  The path will also
+    be truncated to be no more than `MAX_CWD_LEN` characters long.
+    """
+    # Prefer $PWD to os.getcwd() as the former does not resolve symlinks
+    cwd = Path(os.environ.get("PWD") or os.getcwd())
+    try:
+        cwd = "~" / cwd.relative_to(Path.home())
+    except ValueError:
+        pass
+    return shortpath(cwd)
+
+
+def shortpath(p: PurePath, max_len: int = MAX_CWD_LEN) -> str:
+    """
+    If the filepath ``p`` is too long (longer than ``max_len``), cut off
+    leading components to make it fit; if that's not enough, also truncate the
+    final component.  Deleted bits are replaced with ellipses.
+    """
+    assert len(p.parts) > 0
+    if len(str(p)) > max_len:
+        p = PurePath("…", *p.parts[1 + (p.parts[0] == "/") :])
+        while len(str(p)) > max_len:
+            if len(p.parts) > 2:
+                p = PurePath("…", *p.parts[2:])
+            else:
+                p = PurePath("…", p.parts[1][: max_len - 3] + "…")
+                assert len(str(p)) <= max_len
+    return str(p)
diff --git a/src/jwodder_ps1/py.typed b/src/jwodder_ps1/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/jwodder_ps1/style.py b/src/jwodder_ps1/style.py
new file mode 100644
index 0000000..a508f5a
--- /dev/null
+++ b/src/jwodder_ps1/style.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+from enum import Enum
+from typing import ClassVar, Protocol
+
+
+class Color(Enum):
+    """
+    An enum of the supported foreground colors.  Each color's value equals its
+    xterm number.
+    """
+
+    RED = 1
+    GREEN = 2
+    YELLOW = 3
+    BLUE = 4
+    MAGENTA = 5
+    CYAN = 6
+    LIGHT_RED = 9
+    LIGHT_GREEN = 10
+    LIGHT_YELLOW = 11
+    LIGHT_BLUE = 12
+    LIGHT_MAGENTA = 13
+    LIGHT_CYAN = 14
+
+    def asfg(self) -> int:
+        """
+        Return the ANSI SGR parameter for setting the color as the foreground
+        color
+        """
+        c = self.value
+        return c + 30 if c < 8 else c + 82
+
+
+class Styler(Protocol):
+    prompt_suffix: ClassVar[str]
+
+    def __call__(self, s: str, fg: Color | None = None, bold: bool = False) -> str: ...
+
+
+class BashStyler:
+    """Class for escaping & styling strings for use in Bash's PS1 variable"""
+
+    #: The actual prompt symbol to add at the end of the output, just before a
+    #: final space character
+    prompt_suffix: ClassVar[str] = r"\$"
+
+    def __call__(self, s: str, fg: Color | None = None, bold: bool = False) -> str:
+        r"""
+        Return the string ``s`` escaped for use in a PS1 variable.  If ``fg``
+        is non-`None`, the string will be wrapped in the proper escape
+        sequences to display it as the given foreground color.  If ``bold`` is
+        true, the string will be wrapped in the proper escape sequences to
+        display it bold.  All escape sequences are wrapped in ``\[ ... \]`` so
+        that they may be used in a PS1 variable.
+
+        :param str s: the string to stylize
+        :param Color fg: the foreground color to stylize the string with
+        :param bool bold: whether to stylize the string as bold
+        """
+        s = self.escape(s)
+        if fg is not None:
+            s = rf"\[\e[{fg.asfg()}{'1' if bold else ''}m\]{s}\[\e[m\]"
+        elif bold:
+            s = rf"\[\e[1m\]{s}\[\e[m\]"
+        return s
+
+    def escape(self, s: str) -> str:
+        """
+        Escape characters in the string ``s`` that have special meaning in a
+        PS1 variable
+        """
+        return s.replace("\\", r"\\")
+
+
+class ANSIStyler:
+    """Class for styling strings for display immediately in the terminal"""
+
+    #: The actual prompt symbol to add at the end of the output, just before a
+    #: final space character
+    prompt_suffix: ClassVar[str] = "$"
+
+    def __call__(self, s: str, fg: Color | None = None, bold: bool = False) -> str:
+        r"""
+        Stylize the string ``s`` with ANSI escape sequences.  If ``fg`` is
+        non-`None`, the string will be stylized with the given foreground
+        color.  If ``bold`` is true, the string will be stylized bold.
+
+        :param str s: the string to stylize
+        :param Color fg: the foreground color to stylize the string with
+        :param bool bold: whether to stylize the string as bold
+        """
+        if fg is not None:
+            s = f"\x1B[{fg.asfg()}{';1' if bold else ''}m{s}\x1B[m"
+        elif bold:
+            s = f"\x1B[1m{s}\x1B[m"
+        return s
+
+
+class ZshStyler:
+    """Class for escaping & styling strings for use in zsh's PS1 variable"""
+
+    #: The actual prompt symbol to add at the end of the output, just before a
+    #: final space character
+    prompt_suffix: ClassVar[str] = "%#"
+
+    def __call__(self, s: str, fg: Color | None = None, bold: bool = False) -> str:
+        """
+        Return the string ``s`` escaped for use in a zsh PS1 variable.  If
+        ``fg`` is non-`None`, the string will be wrapped in the proper escape
+        sequences to display it as the given foreground color.  If ``bold`` is
+        true, the string will be wrapped in the proper escape sequences to
+        display it bold.
+
+        :param str s: the string to stylize
+        :param Color fg: the foreground color to stylize the string with
+        :param bool bold: whether to stylize the string as bold
+        """
+        s = self.escape(s)
+        if bold:
+            s = f"%B{s}%b"
+        if fg is not None:
+            s = f"%F{{{fg.value}}}{s}%f"
+        return s
+
+    def escape(self, s: str) -> str:
+        return s.replace("%", "%%")
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..df7688c
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,73 @@
+[tox]
+envlist = lint,typing,py38,py39,py310,py311,py312,pypy3
+skip_missing_interpreters = True
+isolated_build = True
+minversion = 3.3.0
+
+[testenv]
+deps =
+    pytest
+    pytest-cov
+commands =
+    pytest {posargs} test
+
+[testenv:lint]
+skip_install = True
+deps =
+    flake8
+    flake8-bugbear
+    flake8-builtins
+    flake8-unused-arguments
+commands =
+    flake8 src test
+
+[testenv:typing]
+deps =
+    mypy
+    {[testenv]deps}
+commands =
+    mypy src test
+
+[pytest]
+addopts = --cov=jwodder_ps1 --no-cov-on-fail
+filterwarnings = error
+norecursedirs = test/data
+
+[coverage:run]
+branch = True
+parallel = True
+
+[coverage:paths]
+source =
+    src
+    .tox/**/site-packages
+
+[coverage:report]
+precision = 2
+show_missing = True
+exclude_lines =
+    pragma: no cover
+    if TYPE_CHECKING:
+    \.\.\.
+
+[flake8]
+doctests = True
+extend-exclude = build/,dist/,test/data,venv/
+max-doc-length = 100
+max-line-length = 80
+unused-arguments-ignore-stub-functions = True
+extend-select = B901,B902,B950
+ignore = A003,A005,B005,E203,E262,E266,E501,E704,U101,W503
+
+per-file-ignores =
+    src/jwodder_ps1/__init__.py:B950
+
+[isort]
+atomic = True
+force_sort_within_sections = True
+honor_noqa = True
+lines_between_sections = 0
+profile = black
+reverse_relative = True
+sort_relative_in_force_sorted_sections = True
+src_paths = src


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..df98e3c
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,66 @@
+name: Test
+
+on:
+  pull_request:
+  push:
+    branches-ignore:
+      - 'dependabot/**'
+  schedule:
+    - cron: '0 6 * * *'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name }}
+  cancel-in-progress: true
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version:
+          - '3.8'
+          - '3.9'
+          - '3.10'
+          - '3.11'
+          - '3.12'
+          - 'pypy-3.8'
+          - 'pypy-3.9'
+          - 'pypy-3.10'
+        toxenv: [py]
+        include:
+          - python-version: '3.8'
+            toxenv: lint
+          - python-version: '3.8'
+            toxenv: typing
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip wheel
+          python -m pip install --upgrade --upgrade-strategy=eager tox
+
+      - name: Run tests with coverage
+        if: matrix.toxenv == 'py'
+        run: tox -e py -- --cov-report=xml
+
+      - name: Run generic tests
+        if: matrix.toxenv != 'py'
+        run: tox -e ${{ matrix.toxenv }}
+
+      - name: Upload coverage to Codecov
+        if: matrix.toxenv == 'py'
+        uses: codecov/codecov-action@v4
+        with:
+          fail_ci_if_error: false
+          token: ${{ secrets.CODECOV_TOKEN }}
+          name: ${{ matrix.python-version }}
+
+# vim:set et sts=2:
diff --git a/test/test_git.py b/test/test_git.py
new file mode 100644
index 0000000..831b092
--- /dev/null
+++ b/test/test_git.py
@@ -0,0 +1,280 @@
+from __future__ import annotations
+import pytest
+from jwodder_ps1.git import GitState, GitStatus, WorkTreeStatus
+from jwodder_ps1.style import ANSIStyler
+
+
+@pytest.mark.parametrize(
+    "gs,rendered",
+    [
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=None,
+            ),
+            "@\x1B[92mmain\x1B[m",
+            id="simple-bare",
+        ),
+        pytest.param(
+            GitStatus(
+                head="v0.1.0",
+                detached=True,
+                ahead=None,
+                behind=None,
+                wkt=None,
+            ),
+            "@\x1B[94mv0.1.0\x1B[m",
+            id="detached-bare",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=1,
+                behind=None,
+                wkt=None,
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[32m+1\x1B[m",
+            id="ahead-bare",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=2,
+                wkt=None,
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[31m-2\x1B[m",
+            id="behind-bare",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=1,
+                behind=2,
+                wkt=None,
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[32m+1\x1B[m,\x1B[31m-2\x1B[m",
+            id="ahead-behind-bare",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=False,
+                    unstaged=False,
+                    untracked=False,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m",
+            id="simple",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=True,
+                    unstaged=False,
+                    untracked=False,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[32m*\x1B[m",
+            id="staged",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=False,
+                    unstaged=True,
+                    untracked=False,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[31m*\x1B[m",
+            id="unstaged",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=True,
+                    unstaged=True,
+                    untracked=False,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[93;1m*\x1B[m",
+            id="staged-unstaged",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=True,
+                    staged=False,
+                    unstaged=False,
+                    untracked=False,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[93;1m+\x1B[m\x1B[92mmain\x1B[m",
+            id="stashed",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=False,
+                    unstaged=False,
+                    untracked=True,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[31;1m+\x1B[m",
+            id="untracked",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=True,
+                    staged=False,
+                    unstaged=True,
+                    untracked=True,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            "@\x1B[93;1m+\x1B[m\x1B[92mmain\x1B[m\x1B[31m*\x1B[m\x1B[31;1m+\x1B[m",
+            id="stashed-unstaged-untracked",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=3,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=True,
+                    staged=True,
+                    unstaged=False,
+                    untracked=False,
+                    conflict=False,
+                    state=None,
+                ),
+            ),
+            (
+                "@\x1B[93;1m+\x1B[m"
+                "\x1B[92mmain\x1B[m"
+                "\x1B[32m+3\x1B[m"
+                "\x1B[32m*\x1B[m"
+            ),
+            id="ahead-stashed-staged",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=False,
+                    unstaged=False,
+                    untracked=False,
+                    conflict=True,
+                    state=None,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[31;1m!\x1B[m",
+            id="conflict",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=None,
+                behind=None,
+                wkt=WorkTreeStatus(
+                    stashed=False,
+                    staged=False,
+                    unstaged=False,
+                    untracked=False,
+                    conflict=False,
+                    state=GitState.REBASE_MERGING,
+                ),
+            ),
+            "@\x1B[92mmain\x1B[m\x1B[35m[REBAS]\x1B[m",
+            id="rebasing",
+        ),
+        pytest.param(
+            GitStatus(
+                head="main",
+                detached=False,
+                ahead=3,
+                behind=1,
+                wkt=WorkTreeStatus(
+                    stashed=True,
+                    staged=True,
+                    unstaged=True,
+                    untracked=True,
+                    conflict=True,
+                    state=GitState.CHERRY_PICKING,
+                ),
+            ),
+            (
+                "@\x1B[93;1m+\x1B[m"
+                "\x1B[92mmain\x1B[m"
+                "\x1B[32m+3\x1B[m,"
+                "\x1B[31m-1\x1B[m"
+                "\x1B[93;1m*\x1B[m"
+                "\x1B[31;1m+\x1B[m"
+                "\x1B[35m[CHYPK]\x1B[m"
+                "\x1B[31;1m!\x1B[m"
+            ),
+            id="everything",
+        ),
+    ],
+)
+def test_display_git_status_ansi(gs: GitStatus, rendered: str) -> None:
+    assert gs.display(ANSIStyler()) == rendered
diff --git a/test/test_info.py b/test/test_info.py
new file mode 100644
index 0000000..770cfd4
--- /dev/null
+++ b/test/test_info.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+from jwodder_ps1.git import GitStatus, WorkTreeStatus
+from jwodder_ps1.info import PromptInfo
+from jwodder_ps1.style import ANSIStyler
+
+
+def test_display_simple_prompt_info_ansi() -> None:
+    info = PromptInfo(
+        mail=False,
+        debian_chroot=None,
+        conda_prompt_modifier=None,
+        venv_prompt=None,
+        hostname="firefly",
+        cwdstr="~/work",
+        git=None,
+    )
+    assert info.display(ANSIStyler()) == "\x1B[91mfirefly\x1B[m:\x1B[96m~/work\x1B[m$ "
+
+
+def test_display_full_prompt_info_ansi() -> None:
+    info = PromptInfo(
+        mail=True,
+        debian_chroot="/chroot/jail",
+        conda_prompt_modifier="(base) ",
+        venv_prompt="venv",
+        hostname="firefly",
+        cwdstr="~/work",
+        git=None,
+    )
+    assert info.display(ANSIStyler()) == (
+        "\x1B[36;1m[MAIL] \x1B[m"
+        "\x1B[34;1m[/chroot/jail] \x1B[m"
+        "\x1B[92m(base) \x1B[m"
+        "(venv) "
+        "\x1B[91mfirefly\x1B[m:"
+        "\x1B[96m~/work\x1B[m$ "
+    )
+
+
+def test_display_simple_prompt_info_with_git_ansi() -> None:
+    info = PromptInfo(
+        mail=False,
+        debian_chroot=None,
+        conda_prompt_modifier=None,
+        venv_prompt=None,
+        hostname="firefly",
+        cwdstr="~/work",
+        git=GitStatus(
+            head="main",
+            detached=False,
+            ahead=None,
+            behind=None,
+            wkt=WorkTreeStatus(
+                stashed=False,
+                staged=False,
+                unstaged=False,
+                untracked=False,
+                conflict=False,
+                state=None,
+            ),
+        ),
+    )
+    assert (
+        info.display(ANSIStyler())
+        == "\x1B[91mfirefly\x1B[m:\x1B[96m~/work\x1B[m@\x1B[92mmain\x1B[m$ "
+    )
diff --git a/test/test_util.py b/test/test_util.py
new file mode 100644
index 0000000..04f4d6e
--- /dev/null
+++ b/test/test_util.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+from pathlib import PurePosixPath
+import pytest
+from jwodder_ps1.info import shortpath
+
+
+@pytest.mark.parametrize(
+    "path,short",
+    [
+        ("/", "/"),
+        ("~", "~"),
+        ("/var/lib/data", "/var/lib/data"),
+        ("~/.local/lib/data", "~/.local/lib/data"),
+        ("/var/atlassian/applicationdata", "/var/atlassian/applicationdata"),
+        ("/var/atlassian/application-data", "…/atlassian/application-data"),
+        ("/var/atlassian/application-data/jira", "…/application-data/jira"),
+        ("~/var/atlassian/applicationdata", "…/atlassian/applicationdata"),
+        (
+            "~/Photos/Vacation_2000_summer_part_1_funny",
+            "…/Vacation_2000_summer_part_1…",
+        ),
+    ],
+)
+def test_shortpath(path: str, short: str) -> None:
+    assert shortpath(PurePosixPath(path)) == short