9
\$\begingroup\$

I have recently been in a situation where multiple developers worked on a shared local git repository under the same Linux user1. 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" [email protected] --credential-username itsme
> git user add itsyou "Your Last" [email protected]
# activate the first user
> git user itsme
My Last <[email protected]>
# you can check this yourself with git config.name and git config user.email
# activate the second user
> git user itsyou
Your Last <[email protected]>
# use git user with no arguments to check which values are currently set
> git user
Your Last <[email protected]>
> git user show
 itsme: My Last <[email protected]> (push as 'itsme')
 itsyou: Your Last <[email protected]>
> git user delete itsyou --force
> git user show
 itsme: My Last <[email protected]>

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.

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

Toby Speight
87.9k14 gold badges104 silver badges325 bronze badges
asked Mar 1, 2021 at 21:41
\$\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\$ Commented 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\$ Commented 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.

answered Mar 12, 2021 at 12:45
\$\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\$ Commented Mar 12, 2021 at 13:46

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.