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
andpycodestyle
, 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.