9
\$\begingroup\$

I have recently been in a situation where multiple developers worked on a shared local git repository under the same Linux user¹. As one can imagine, it can easily become a bit annoying not to commit changes as someone else if you don't check the values of git config user.name and git config user.email carefully and then change them accordingly. The same problem might also arise if you happen to work on several projects on your local machine side-by-side where you have to use different "identities", e.g. some work-related and private projects. I decided to tackle this with a little "git extension" that allows you to view and change the committer identity with less of a hassle.

Enter git user. To get an idea of its intended use, have a look at the output of git user -h:

git-user - Helps you to manage multiple users in a shared local repository

Subcommands
-----------
add, update, show, delete

Use git user <subcommand> --help to get more help

Examples
--------
# Add two test users
> git user add itsme "My Last" my.last@domain.tld --credential-username itsme
> git user add itsyou "Your Last" your.last@domain.tld

# activate the first user
> git user itsme
My Last <my.last@domain.tld>
# you can check this yourself with git config.name and git config user.email

# activate the second user
> git user itsyou
Your Last <your.last@domain.tld>

# use git user with no arguments to check which values are currently set
> git user
Your Last <your.last@domain.tld>

> git user show
    itsme: My Last <my.last@domain.tld> (push as 'itsme')
    itsyou: Your Last <your.last@domain.tld>
> git user delete itsyou --force
> git user show
    itsme: My Last <my.last@domain.tld>

The code that makes this possible is as follows:

#!/usr/bin/env python3
"""
... omitted for brevity, see help text in question ...
"""
import argparse
import json
import os
import sys
from collections import defaultdict
from subprocess import check_output, CalledProcessError, DEVNULL

DESCRIPTION = sys.modules[__name__].__doc__

__version__ = "0.2.0"


class UserbaseError(Exception):
    """Base class for userbase related exceptions"""


class UserDoesNotExist(UserbaseError):
    """Custom exception to indicate that a user does not exist"""


class UserDoesAlreadyExist(UserbaseError):
    """Custom exception to indicate that a user does already exist"""


class Userbase:
    """Abstraction of the underlying user storage file"""

    DATA_KEYS = ("name", "email", "credential_username")

    def __init__(self, users_file):
        self._users_file = users_file
        self._users = defaultdict(lambda: defaultdict(str))
        self._load()

    def _load(self):
        if not os.path.isfile(self._users_file):
            print("Creating default at '{}'.".format(self._users_file))
            os.makedirs(os.path.dirname(self._users_file))
            self.save()

        with open(self._users_file, "r") as json_file:
            self._users.update(json.load(json_file))

    def save(self):
        """Dump the current userbase to file"""
        with open(self._users_file, "w") as json_file:
            json.dump(self._users, json_file)

    def is_known(self, alias):
        """Check if an alias is part of the userbase"""
        return alias in self._users

    def get(self, alias):
        """Try to get user data for an alias

        Parameters
        ----------
        alias: str
            access the data stored under this alias

        Returns
        -------
        dict
            the data associated with the alias. Keys are listed in
            Userbase.DATA_KEYS

        Raises
        ------
        UserDoesNotExist
            If the user is not part of the userbase
        """
        if self.is_known(alias):
            return self._users[alias]

        raise UserDoesNotExist(
            "User with alias '{}' unknown".format(alias)
        )

    def as_dict(self):
        """Access the userbase as a dict"""
        return dict(self._users)

    def update(self, alias, **kwargs):
        """Update the data stored under an alias

        This function does not check whether the user exists or not. If it
        exists, its data will simply be overwritten.

        Parameters
        ----------
        alias: str
            update the data found under this alias
        kwargs: dict
            the script will look for the keys from Userbase.DATA_KEYS in the
            kwargs in order to update the internal database
        """
        for key in Userbase.DATA_KEYS:
            new_value = kwargs[key]
            if new_value is not None:
                self._users[alias][key] = new_value

    def delete(self, alias):
        """Delete a user from the userbase given its alias

        Parameters
        ----------
        alias: str
            the alias to look for

        Raises
        ------
        UserDoesNotExist
            you can probably guess when this is raised
        """
        try:
            del self._users[alias]
        except KeyError:
            raise UserDoesNotExist(
                "User with alias '{}' unknown".format(alias)
            )


try:
    _USERS_FILE = os.environ["GITUSER_CONFIG"]
except KeyError:
    _USERS_FILE = os.path.join(
        os.path.expanduser("~"), ".config", "git-user", "users.json"
    )
_USERS_FILE = os.path.abspath(_USERS_FILE)
_USERS = Userbase(_USERS_FILE)


def add(args):
    """Add a user to the userbase"""
    if _USERS.is_known(args.alias):
        raise UserDoesAlreadyExist(
            "User with alias '{}' already exist. ".format(args.alias)
            + "Delete first or use 'update'"
        )
    kwargs = {name: getattr(args, name, "") for name in Userbase.DATA_KEYS}
    _USERS.update(args.alias, **kwargs)


def update(args):
    """Interactive wrapper around Userbase.update"""
    if not _USERS.is_known(args.alias):
        raise UserDoesAlreadyExist(
            "User with alias '{}' does not exist. ".format(args.alias)
            + "Add first using 'add'"
        )
    kwargs = {name: getattr(args, name, "") for name in Userbase.DATA_KEYS}
    _USERS.update(args.alias, **kwargs)


def delete(args):
    """Interactivate wrapper around Userbase.delete"""
    if _USERS.is_known(args.alias) and not args.force:
        while True:
            answer = input(
                "Really delete user '{}'? [N/y] ".format(args.alias)
            )
            answer = answer.lower().strip()
            if answer in ("yes", "y"):
                break
            if answer in ("no", "n", ""):
                return
    _USERS.delete(args.alias)


def show(args):
    """Show the data of one or all the users in the userbase"""
    to_show = tuple(sorted(_USERS.as_dict().keys()))
    if args.alias is not None:
        to_show = (args.alias, )

    if to_show:
        for alias in to_show:
            cfg = _USERS.get(alias)
            msg = "    {}: {name} <{email}>".format(alias, **cfg)
            if "credential_username" in cfg.keys():
                msg += " (push as '{credential_username}')".format(**cfg)
            print(msg)
    else:
        print("No known aliases.")


def switch(args):
    """Switch to an other user from the userbase and/or show current config

    Set args.quiet to True to avoid seeing the current config as console output
    """
    if args.alias is not None:
        cfg = _USERS.get(args.alias)
        _git_config_name(cfg["name"])
        _git_config_email(cfg["email"])
        credential_username = cfg.get("credential_username", "")
        try:
            _git_config_credential_username(credential_username)
        except CalledProcessError as ex:
            if credential_username not in ("", None):
                raise ex
    if not args.quiet:
        _show_git_config()


def _show_git_config():
    try:
        git_name = _git_config_name().strip().decode("utf8")
        git_email = _git_config_email().strip().decode("utf8")
    except CalledProcessError:
        print(
            "Currently there is no (default) user for this repository.\n"
            "Select one using git user <alias> or manually with git config"
        )
        return
    try:
        git_cred_username = _git_config_credential_username().strip().decode("utf8")
        print("{} <{}> (push as '{}')".format(git_name, git_email, git_cred_username))
        return
    except CalledProcessError:
        # git config has a non-zero exit status if no value is set
        pass

    print("{} <{}>".format(git_name, git_email))


def _git_config_name(name=None):
    args = ["git", "config", "user.name"]
    if name is not None:
        args.append(str(name))
    return check_output(args)


def _git_config_email(email=None):
    args = ["git", "config", "user.email"]
    if email is not None:
        args.append(str(email))
    return check_output(args)


def _git_config_credential_username(username=None):
    # remote_url = _git_get_remote_url(remote).strip().decode("utf8")
    args = ["git", "config"]
    if username == "":
        args.extend(["--remove-section", "credential"])
    else:
        args.append("credential.username")
        if username is not None:
            args.append(username)
    return check_output(args, stderr=DEVNULL)


def main():
    """CLI of the git user helper"""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter)

    # click might be an alternative here, but I want this to be as lightweight
    # as possible
    try:
        command = sys.argv[1]
    except IndexError:
        command = "switch"

    if command in ("add", "update", "delete", "show"):
        subparsers = parser.add_subparsers()

        add_subparser = subparsers.add_parser(
            "add", description="Add name and email for an alias")
        add_subparser.add_argument(
            "alias", help="Alias used to access this user's name and email")
        add_subparser.add_argument(
            "name", help="This value gets passed to git config user.name",
            default="")
        add_subparser.add_argument(
            "email", help="This value gets passed to git config user.email",
            default="")
        add_subparser.add_argument(
            "--credential-username",
            help="optional: push credential username",
            default="")
        add_subparser.set_defaults(command=add)

        update_subparser = subparsers.add_parser(
            "update", description="Update an alias")
        update_subparser.add_argument(
            "alias", help="Alias used to access this user's name and email")
        update_subparser.add_argument(
            "--name", help="This value gets passed to git config user.name",
            default=None)
        update_subparser.add_argument(
            "--email", help="This value gets passed to git config user.email",
            default=None)
        update_subparser.add_argument(
            "--credential-username",
            help="optional: push credential username",
            default=None)
        update_subparser.set_defaults(command=update)

        delete_subparser = subparsers.add_parser(
            "delete", description="Delete name and email stored for an alias")
        delete_subparser.add_argument(
            "alias", help="Delete name, email and possibly credentials for this alias")
        delete_subparser.add_argument(
            "--force", action="store_true", help="Delete without interaction"
        )
        delete_subparser.set_defaults(command=delete)

        show_subparser = subparsers.add_parser(
            "show",
            description="Show name and email associated with this alias")
        show_subparser.add_argument("alias", nargs="?", default=None)
        show_subparser.set_defaults(command=show)
    else:
        parser.add_argument(
            "alias",
            nargs="?",
            default=None,
            help="Use git config with name and email that belong to this alias")
        parser.add_argument(
            "--quiet", action="store_true",
            help="Suppress confirmation output after setting the config"
        )
        parser.add_argument(
            "--version", action="store_true",
            help="show git and git-user version and exit"
        )
        parser.set_defaults(command=switch)

    args = parser.parse_args()
    if getattr(args, "version", False):
        print("git-user version {}".format(__version__))
        return

    try:
        args.command(args)
    except (UserbaseError, CalledProcessError) as err:
        print(err)
        sys.exit(1)

    # only save on proper exit
    _USERS.save()


if __name__ == "__main__":
    main()

To test it, copy and paste the file somewhere in your $PATH where git can pick it up, name it git-user and make it executable. A symlink with this name is also possible if you prefer to have the file with a .py extension.

Notes to kind reviewers

  • Every kind of feedback is welcome. I'm also especially interested, how the script worked for you regarding usability. Was the help text actually, well, helpful?
  • As one of the comments tells, I knowingly ignored possibly useful packages like click in order to allow this to run on systems with no additional python packages.
  • Support for signature keys that would make it easier to sign your commits as well is on the TODO list, but not implemented yet.
  • The code was checked with pylint and pycodestyle, so it should be in a reasonable shape regarding code style.

¹ Whether or not this is a good idea might be arguable, but that's not the point here.

\$\endgroup\$
2
  • 1
    \$\begingroup\$ I'm wondering why you didn't avoid the whole problem by adding the shared repository as a remote for the different developers, but I'm not gonna judge :D \$\endgroup\$
    – Vogel612
    Mar 7, 2021 at 12:31
  • \$\begingroup\$ @Vogel612 Yeah, that would be the most sensible choice. But unfortunately a few ideas/concepts from the SVN world are deeply stuck in the habits of some people ;-) And as of now, I have not been able to drive them off of them. \$\endgroup\$
    – AlexV
    Mar 8, 2021 at 8:36

1 Answer 1

2
+50
\$\begingroup\$

It's great I'd say, nothing really to complain except the suggestion to handle missing files more gracefully:

  • Run the script, /home/ferada/.config/git-user/users.json gets created successfully.
  • Delete that file.
  • Run the script again, get a crash.
0 codementor % python3 git-user.py
Creating default at '/home/ferada/.config/git-user/users.json'.
Traceback (most recent call last):
  File "git-user.py", line 134, in <module>
    _USERS = Userbase(_USERS_FILE)
  File "git-user.py", line 37, in __init__
    self._load()
  File "git-user.py", line 42, in _load
    os.makedirs(os.path.dirname(self._users_file))
  File "/usr/lib/python3.8/os.py", line 223, in makedirs
    mkdir(name, mode)
FileExistsError: [Errno 17] File exists: '/home/ferada/.config/git-user'

The os.makedirs call needs an exist_ok=True parameter, then that gets handled.


Maybe the argument parser construction should really be separated from the main function, it's a distinct functionality and could very well be tested separately.

_git_config_name and _git_config_email could always to strip and decode, that seems better than doing it on all but one call site anyway.

The --version flag also has special handling in argparse which you could use, c.f. version= for add_argument.

On that note, I don't like that --help doesn't give me a full list of the subcommands because it's construction is split in two parts. While reading about that topic of default subparsers (which I thought is how this might be handled) it looks like that is fraught with peril.

So, might I simply suggest that the possible subcommands are mentioned in the top-level --help output? Or you did already do that and omitted it as said in the help text. Well, at least you know your users definitely will want that help information.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Thanks very much. Great find on that os.makedirs error! Re: your comment on the help text: the full help text is shown in the question. If what you are looking for is not in there, well, ... my fault I guess ;-) The "two stage help text" with git user -h, where the subcommands are shown (maybe I've missed some), and e.g. git user add -h is similar to how git handles this, see e.g. git remote add -h. \$\endgroup\$
    – AlexV
    Mar 12, 2021 at 13:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.