[PATCH 4 of 4] keyring: add new extension keyring
Markus Zapke-Gründemann
markuszapke at gmx.net
Fri Sep 14 09:46:51 CDT 2012
# HG changeset patch
# User Markus Zapke-Gründemann <markus at keimlink.de>
# Date 1347621853 -7200
# Node ID b76790118dbbc94eeb6527a5fc6b5cc7f58ef9c9
# Parent c24677b7153da58a0cfac8b062cc1a68536b9be9
keyring: add new extension keyring
keyring is an improved version of the mercurial_keyting extension. It
can be used to securely save HTTP authentication details.
diff --git a/hgext/keyring.py b/hgext/keyring.py
--- a/hgext/keyring.py
+++ b/hgext/keyring.py
@@ -1,452 +1,434 @@
-# -*- coding: utf-8 -*-
+# keyring.py - secure password databases for mercurial
#
-# mercurial_keyring: save passwords in password database
+# Based on the mercurial_keyring extension.
#
# Copyright 2009 Marcin Kasperski <Marcin.Kasperski at mekk.waw.pl>
-#
-# This software may be used and distributed according to the terms
-# of the GNU General Public License, incorporated herein by reference.
-#
-# See README.txt for more details.
+# Copyright 2012 Markus Zapke-Gruendemann <markus at keimlink.de>
+"""securely save HTTP authentication details
-''' securely save HTTP and SMTP authentication details
-mercurial_keyring is a Mercurial extension used to securely save
-HTTP and SMTP authentication passwords in password databases (Gnome
-Keyring, KDE KWallet, OSXKeyChain, specific solutions for Win32 and
-command line). This extension uses and wraps services of the keyring
-library.
-'''
+keyring is a Mercurial extension used to securely save HTTP
+authentication passwords in password databases (GNOME Keyring, KDE
+KWallet, OS X Keychain, specific solutions for Win32 and command line).
+This extension uses and wraps services of the python keyring library.
-from mercurial import hg, repo, util
+How it works
+------------
+
+The extension prompts for the password on the first pull/push, just like
+it is done by default, but saves the password. On successive runs it
+checks for the username in the configuration, then for the suitable
+password in the password database, and uses those credentials (if
+found).
+
+In case password turns out to be incorrect (either because it was
+invalid, or because it was changed on the server) or missing it just
+prompts the user again.
+
+Passwords are identified by the combination of username and remote
+address, so they can be reused between repositories if they access the
+same remote repository.
+
+Installation
+------------
+
+Prerequisites
+.............
+
+Install the python keyring library using ``pip``::
+
+ pip install keyring
+
+or ``easy_install``::
+
+ easy_install keyring
+
+On Debian the library can be also installed from the official archive
+(packages ``python-keyring`` and either ``python-keyring-gnome`` or
+``python-keyring-kwallet``).
+
+Note: keyring >= 0.3 is strongly recommended, especially in case text
+backend is to be used.
+
+Extension installation
+......................
+
+Enable the extension in the configuration::
+
+ [extensions]
+ keyring =
+
+Password backend configuration
+------------------------------
+
+The library should usually pick the most appropriate password backend
+without configuration. Still, if necessary, it can be configured using
+the ``~/keyringrc.cfg`` file (``keyringrc.cfg`` in the home directory of
+the current user). Refer to keyring documentation for more details.
+
+Repository configuration
+------------------------
+
+Edit repository-local ``hgrc`` and save there the remote repository path
+and the username, but do not save the password. For example::
+
+ [paths]
+ default = https://hg.example.com/repo/someproject
+
+ [auth]
+ myremote.schemes = http https
+ myremote.prefix = hg.example.com/repo
+ myremote.username = alice
+
+Simpler form with url-embedded name can also be used::
+
+ [paths]
+ bitbucket = https://bob@bitbucket.org/bob/project_name/
+
+If prefix is specified, it is used to identify the password (so all
+repositories with the same prefix and the same username will share the
+same password). Otherwise the full repository URL is used for this
+purpose.
+
+Note: if both username and password are given in the configuration, the
+extension will use them without using the password database. If username
+is not given, extension will prompt for credentials every time, also
+without saving the password.
+
+Finally, if you are consistent about remote repository nicknames, you
+can configure the username in your global configuration. For example,
+write there::
+
+ [auth]
+ acme.schemes = http https
+ acme.prefix = hg.acme.com/repositories
+ acme.username = clara
+
+As long as you will be using alias `acme` for repositories like
+`https://hg.acme.com/repositories/my_beautiful_app`, username `clara`
+will be used, and the same password reused.
+
+The advantage of this method is that it works also for :hg:`clone`.
+
+Usage
+-----
+
+Configure the repository as above, then just :hg:`pull`, :hg:`push`,
+etc. You should be asked for the password only once (per every username
+and remote repository prefix or url combination).
+"""
+import urllib2
+
+from mercurial import httpconnection, url, util
from mercurial.i18n import _
-try:
- from mercurial.url import passwordmgr
-except:
- from mercurial.httprepo import passwordmgr
-from mercurial.httprepo import httprepository
-from mercurial import mail
-from urllib2 import AbstractBasicAuthHandler, AbstractDigestAuthHandler
+# mercurial.demandimport incompatibility workaround, otherwise
+# gnomekeyring, one of the possible keyring backends, would not to work.
+from mercurial.demandimport import ignore
+if 'gobject._gobject' not in ignore:
+ ignore.append('gobject._gobject')
+pykeyring = __import__('keyring')
-# mercurial.demandimport incompatibility workaround,
-# would cause gnomekeyring, one of the possible
-# keyring backends, not to work.
-from mercurial.demandimport import ignore
-if "gobject._gobject" not in ignore:
- ignore.append("gobject._gobject")
-import keyring
-from urlparse import urlparse
-import urllib2
-import smtplib, socket
-import os
+class passwordstore(object):
+ """Helper object handling python keyring usage.
-KEYRING_SERVICE = "Mercurial"
+ Controls how passwords are saved and read and the way they are keyed
+ in the keyring.
+ """
+ KEYRING_SERVICE = 'Mercurial'
-############################################################
+ @staticmethod
+ def _formatkey(uri, username):
+ """Creates a key for http passwords."""
+ return '%s@@%s' % (username, uri)
-def monkeypatch_method(cls,fname=None):
- def decorator(func):
- local_fname = fname
- if local_fname is None:
- local_fname = func.__name__
- setattr(func, "orig", getattr(cls, local_fname, None))
- setattr(cls, local_fname, func)
- return func
- return decorator
+ def getpassword(self, uri, username):
+ """Reads the password from the keyring."""
+ return pykeyring.get_password(self.KEYRING_SERVICE,
+ self._formatkey(uri, username)) # pragma: no cover
-############################################################
+ def setpassword(self, uri, username, password):
+ """Saves the password in the keyring."""
+ pykeyring.set_password(self.KEYRING_SERVICE,
+ self._formatkey(uri, username), password) # pragma: no cover
-class PasswordStore(object):
+ def clearpassword(self, uri, username):
+ """Clears a user's password for an uri."""
+ self.setpassword(uri, username, '') # pragma: no cover
+
+password_store = passwordstore()
+
+
+def canonicalurl(uri):
+ """Strips query and fragment from uri.
+
+ Example:
+
+ >>> url = 'https://hg.example.com/repos/module?cmd=capabilities'
+ >>> canonicalurl(url)
+ 'https://hg.example.com/repos/module'
"""
- Helper object handling keyring usage (password save&restore,
- the way passwords are keyed in the keyring).
+ u = util.url(uri)
+ u.query = None
+ u.fragment = None
+ return str(u)
+
+
+def expandprefix(prefix, uri):
+ """Expands auth.prefix.
+
+ Examples:
+
+ >>> prefix = '*'
+ >>> url = 'https://hg.example.com/repo'
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com/repo'
+ >>> prefix = None
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com/repo'
+ >>> prefix= 'hg.example.com'
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com'
+ >>> expandprefix(prefix, 'example.com')
+ 'http://hg.example.com'
+ >>> prefix= 'https://hg.example.com'
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com'
+ """
+ if not prefix or prefix == '*':
+ return uri
+ if util.hasscheme(prefix):
+ return prefix
+ try:
+ scheme, hostpath = uri.split('://')
+ except ValueError:
+ scheme = 'http'
+ return '%s://%s' % (scheme, prefix)
+
+
+class requesthistory(object):
+ """Stores information about different requests.
+
+ Use it to store and compare request information.
+
+ Examples:
+
+ >>> from urllib2 import Request
+ >>> uri = 'https://hg.example.com/'
+ >>> r = Request(uri)
+ >>> h = requesthistory()
+ >>> h == (uri, None, r)
+ False
+ >>> h.update(uri, None, r)
+ >>> h == (uri, None, r)
+ True
+ >>> h == (uri, 'auth', r)
+ False
+ >>> r.add_header('User-agent', 'mercurial/proto-1.0')
+ >>> h == (uri, None, r)
+ False
+ >>> h.update(uri)
+ >>> h == (uri, None, None)
+ True
"""
def __init__(self):
- self.cache = dict()
- def get_http_password(self, url, username):
- return keyring.get_password(KEYRING_SERVICE,
- self._format_http_key(url, username))
- def set_http_password(self, url, username, password):
- keyring.set_password(KEYRING_SERVICE,
- self._format_http_key(url, username),
- password)
- def clear_http_password(self, url, username):
- self.set_http_password(url, username, "")
- def _format_http_key(self, url, username):
- return "%s@@%s" % (username, url)
- def get_smtp_password(self, machine, port, username):
- return keyring.get_password(
- KEYRING_SERVICE,
- self._format_smtp_key(machine, port, username))
- def set_smtp_password(self, machine, port, username, password):
- keyring.set_password(
- KEYRING_SERVICE,
- self._format_smtp_key(machine, port, username),
- password)
- def clear_smtp_password(self, machine, port, username):
- self.set_smtp_password(url, username, "")
- def _format_smtp_key(self, machine, port, username):
- return "%s@@%s:%s" % (username, machine, str(port))
+ self.uri = self.realm = self.headers = None
-password_store = PasswordStore()
+ @staticmethod
+ def getheaders(request):
+ """Extracts headers from a Request object."""
+ try:
+ headers = request.header_items()
+ except AttributeError:
+ headers = None
+ return headers
-############################################################
+ def update(self, uri, realm=None, request=None):
+ """Updates requesthistory instance."""
+ self.uri = uri
+ self.realm = realm
+ self.headers = self.getheaders(request)
-def _debug(ui, msg):
- ui.debug("[HgKeyring] " + msg + "\n")
+ def __eq__(self, data):
+ """Compares if requesthistory instance and a data tuple are equal.
-def _debug_reply(ui, msg, url, user, pwd):
- _debug(ui, "%s. Url: %s, user: %s, passwd: %s" % (
- msg, url, user, pwd and '*' * len(pwd) or 'not set'))
+ The tuple must have three elements: (uri, realm, request)
+ """
+ return (self.uri == data[0] and self.realm == data[1]
+ and self.headers == self.getheaders(data[2]))
-############################################################
+class httppasswordhandler(urllib2.HTTPPasswordMgrWithDefaultRealm):
+ """Actual implementation of password handling.
-class HTTPPasswordHandler(object):
+ An instance of this class can be used as passwordmgr in mercurial.url.
"""
- Actual implementation of password handling (user prompting,
- configuration file searching, keyring save&restore).
+ def __init__(self, ui):
+ urllib2.HTTPPasswordMgrWithDefaultRealm.__init__(self)
+ self.pwdcache = {}
+ self.req = None
+ self.reqhistory = requesthistory()
+ self.ui = ui
- Object of this class is bound as passwordmgr attribute.
- """
- def __init__(self):
- self.pwd_cache = {}
- self.last_reply = None
+ def _writedebug(self, msg, uri=None, user=None, pwd=None):
+ """Writes debugging message to stdout.
- def find_auth(self, pwmgr, realm, authuri, req):
+ If one or all of uri, user or pwd are set they are appended
+ to the debug message on a new line.
"""
- Actual implementation of find_user_password - different
- ways of obtaining the username and password.
+ extramsg = []
+ if uri:
+ extramsg.append('url: %s' % util.hidepassword(uri))
+ if user:
+ extramsg.append('user: %s' % user)
+ if pwd:
+ extramsg.append('password: %s' % '***')
+ self.ui.debug('%s\n' % msg)
+ if len(extramsg):
+ self.ui.debug('\t%s\n' % ', '.join(extramsg))
+
+ def _debugbadauth(self, uri, realm):
+ """Print debugging information after bad authentication."""
+ self._writedebug(
+ 'detected bad authentication, cached passwords not used')
+ self._writedebug('current request:')
+ self._writedebug('\turi: %s' % uri)
+ if self.req:
+ self._writedebug('\trequest headers:')
+ for key, value in self.req.header_items():
+ if key == 'Authorization':
+ value = '***'
+ self._writedebug('\t\t%s: %s' % (key, value))
+ self._writedebug('\trealm: %s' % realm)
+ self._writedebug('previous request:')
+ self._writedebug('\turi: %s' % self.reqhistory.uri)
+ if self.reqhistory.headers:
+ self._writedebug('\trequest headers:')
+ for key, value in self.reqhistory.headers:
+ if key == 'Authorization':
+ value = '***'
+ self._writedebug('\t\t%s: %s' % (key, value))
+ self._writedebug('\trealm: %s' % self.reqhistory.realm)
+
+ def setrequest(self, request):
+ """Stores a Request instance for later analysis."""
+ self.req = request
+
+ def find_stored_password(self, authuri):
+ return self.find_user_password(None, authuri) # pragma: no cover
+
+ def find_user_password(self, realm, authuri):
+ """keyring-based implementation of username/password query.
+
+ Passwords are saved in GNOME Keyring, KDE KWallet, OS X Keychain
+ or other platform specific storage and keyed by the repository
+ url.
"""
- ui = pwmgr.ui
-
# If we are called again just after identical previous
# request, then the previously returned auth must have been
# wrong. So we note this to force password prompt (and avoid
# reusing bad password indifinitely).
- after_bad_auth = (self.last_reply \
- and (self.last_reply['realm'] == realm) \
- and (self.last_reply['authuri'] == authuri) \
- and (self.last_reply['req'] == req))
- if after_bad_auth:
- _debug(ui, _("Working after bad authentication, cached passwords not used %s") % str(self.last_reply))
-
- # Strip arguments to get actual remote repository url.
- base_url = self.canonical_url(authuri)
-
- # Extracting possible username (or password)
- # stored directly in repository url
+ afterbadauth = self.reqhistory == (authuri, realm, self.req)
+ if afterbadauth:
+ self._debugbadauth(authuri, realm)
+ baseurl = canonicalurl(authuri)
+ # Extracting possible username (and password if available)
+ # stored directly in repository url.
user, pwd = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
- pwmgr, realm, authuri)
+ self, realm, authuri)
if user and pwd:
- _debug_reply(ui, _("Auth data found in repository URL"),
- base_url, user, pwd)
- self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
+ self._writedebug('auth data found in repository url', baseurl,
+ user, pwd)
+ self.reqhistory.update(authuri, realm, self.req)
return user, pwd
-
- # Loading .hg/hgrc [auth] section contents. If prefix is given,
- # it will be used as a key to lookup password in the keyring.
- auth_user, pwd, prefix_url = self.load_hgrc_auth(ui, base_url, user)
- if prefix_url:
- keyring_url = prefix_url
- else:
- keyring_url = base_url
- _debug(ui, _("Keyring URL: %s") % keyring_url)
-
+ if user:
+ self._writedebug('stripped username "%s" from url' % user)
+ cleanurl = util.removeauth(baseurl)
+ group, authuser, pwd, prefix = self.loadauthconf(cleanurl, user)
+ keyringurl = expandprefix(prefix, cleanurl)
+ self._writedebug('keyring url: %s' % keyringurl)
# Checking the memory cache (there may be many http calls per command)
- cache_key = (realm, keyring_url)
- if not after_bad_auth:
- cached_auth = self.pwd_cache.get(cache_key)
- if cached_auth:
- user, pwd = cached_auth
- _debug_reply(ui, _("Cached auth data found"),
- base_url, user, pwd)
- self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
+ cachekey = (realm, keyringurl)
+ if not afterbadauth:
+ cachedauth = self.pwdcache.get(cachekey)
+ if cachedauth:
+ user, pwd = cachedauth
+ self._writedebug('cached auth data found', baseurl, user, pwd)
+ self.reqhistory.update(authuri, realm, self.req)
return user, pwd
-
- if auth_user:
- if user and (user != auth_user):
- raise util.Abort(_('mercurial_keyring: username for %s specified both in repository path (%s) and in .hg/hgrc/[auth] (%s). Please, leave only one of those' % (base_url, user, auth_user)))
- user = auth_user
+ if authuser:
+ if user and (user == authuser):
+ msg = 'keyring: username %s for %s specified both in '
+ msg += 'url/paths and in auth - please, leave only one of those'
+ raise util.Abort(_(msg % (user, keyringurl)))
+ user = authuser
+ self._writedebug('using auth.%s.* for authentication' % group)
if pwd:
- self.pwd_cache[cache_key] = user, pwd
- _debug_reply(ui, _("Auth data set in .hg/hgrc"),
- base_url, user, pwd)
- self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
+ self.pwdcache[cachekey] = user, pwd
+ self.reqhistory.update(authuri, realm, self.req)
+ return user, pwd
+ # Loading password from keyring. Only if username is known (so
+ # we know the key) and we are not after failure (so we don't
+ # reuse the bad password).
+ if user and not afterbadauth:
+ self._writedebug('looking for password for user %s and url %s'
+ % (user, keyringurl))
+ pwd = password_store.getpassword(keyringurl, user)
+ if pwd:
+ self.pwdcache[cachekey] = user, pwd
+ self._writedebug('password found on keyring', baseurl, user,
+ pwd)
+ self.reqhistory.update(authuri, realm, self.req)
return user, pwd
else:
- _debug(ui, _("Username found in .hg/hgrc: %s") % user)
-
- # Loading password from keyring.
- # Only if username is known (so we know the key) and we are
- # not after failure (so we don't reuse the bad password).
- if user and not after_bad_auth:
- _debug(ui, _("Looking for password for user %s and url %s") % (user, keyring_url))
- pwd = password_store.get_http_password(keyring_url, user)
- if pwd:
- self.pwd_cache[cache_key] = user, pwd
- _debug_reply(ui, _("Keyring password found"),
- base_url, user, pwd)
- self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
- return user, pwd
- else:
- _debug(ui, _("Password not present in the keyring"))
-
+ self._writedebug('password not present in keyring')
# Is the username permanently set?
- fixed_user = (user and True or False)
-
+ fixeduser = (user and True or False)
# Last resort: interactive prompt
- if not ui.interactive():
- raise util.Abort(_('mercurial_keyring: http authorization required but program used in non-interactive mode'))
-
- if not fixed_user:
- ui.status(_("Username not specified in .hg/hgrc. Keyring will not be used.\n"))
-
- ui.write(_("http authorization required\n"))
- ui.status(_("realm: %s\n") % realm)
- if fixed_user:
- ui.write(_("user: %s (fixed in .hg/hgrc)\n" % user))
+ if not self.ui.interactive():
+ msg = 'keyring: http authorization required but program '
+ msg += 'used in non-interactive mode'
+ raise util.Abort(_(msg))
+ if not fixeduser:
+ msg = 'username not set in configuration - keyring is not used\n'
+ self.ui.status(_(msg))
+ self.ui.write(_('http authorization required\n'))
+ self.ui.status(_('realm: %s\n') % realm)
+ if fixeduser:
+ self.ui.write(_('user: %s (fixed in configuration)\n') % user)
else:
- user = ui.prompt(_("user:"), default=None)
- pwd = ui.getpass(_("password: "))
-
- if fixed_user:
- # Saving password to the keyring.
- # It is done only if username is permanently set.
- # Otherwise we won't be able to find the password so it
- # does not make much sense to preserve it
- _debug(ui, _("Saving password for %s to keyring") % user)
- password_store.set_http_password(keyring_url, user, pwd)
-
+ user = self.ui.prompt(_('user:'), default=None)
+ pwd = self.ui.getpass(_('password: '))
+ if fixeduser:
+ # Saving password to the keyring. It is done only if the
+ # username is permanently set. Otherwise we won't be able to
+ # find the password so it does not make much sense to
+ # preserve it.
+ self._writedebug('saving password for %s to keyring' % user)
+ password_store.setpassword(keyringurl, user, pwd)
# Saving password to the memory cache
- self.pwd_cache[cache_key] = user, pwd
-
- _debug_reply(ui, _("Manually entered password"),
- base_url, user, pwd)
- self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
+ self.pwdcache[cachekey] = user, pwd
+ self._writedebug('manually entered password', baseurl, user, pwd)
+ self.reqhistory.update(authuri, realm, self.req)
return user, pwd
- def load_hgrc_auth(self, ui, base_url, user):
+ def loadauthconf(self, uri, user):
+ """Loading auth section contents from local configuration.
+
+ Returns (group, username, password, prefix) tuple (every element
+ can be None).
"""
- Loading [auth] section contents from local .hgrc
+ authinfo = httpconnection.readauthforuri(self.ui, uri, user)
+ if authinfo:
+ group, auth = authinfo
+ username = auth.get('username')
+ pwd = auth.get('password')
+ prefix = auth.get('prefix')
+ else:
+ group = username = pwd = prefix = None
+ return group, username, pwd, prefix
- Returns (username, password, prefix) tuple (every
- element can be None)
- """
- # Theoretically 3 lines below should do:
- #auth_token = self.readauthtoken(base_url)
- #if auth_token:
- # user, pwd = auth.get('username'), auth.get('password')
+def uisetup(ui):
+ url.pwmgrclass = httppasswordhandler
- # Unfortunately they do not work, readauthtoken always return
- # None. Why? Because ui (self.ui of passwordmgr) describes the
- # *remote* repository, so does *not* contain any option from
- # local .hg/hgrc.
-
- # TODO: mercurial 1.4.2 is claimed to resolve this problem
- # (thanks to: http://hg.xavamedia.nl/mercurial/crew/rev/fb45c1e4396f)
- # so since this version workaround implemented below should
- # not be necessary. As it will take some time until people
- # migrate to >= 1.4.2, it would be best to implement
- # workaround conditionally.
-
- # Workaround: we recreate the repository object
- repo_root = ui.config("bundle", "mainreporoot")
-
- from mercurial.ui import ui as _ui
- local_ui = _ui(ui)
- if repo_root:
- local_ui.readconfig(os.path.join(repo_root, ".hg", "hgrc"))
- try:
- local_passwordmgr = passwordmgr(local_ui)
- auth_token = local_passwordmgr.readauthtoken(base_url)
- except AttributeError:
- try:
- # hg 1.8
- import mercurial.url
- readauthforuri = mercurial.url.readauthforuri
- except (ImportError, AttributeError):
- # hg 1.9
- import mercurial.httpconnection
- readauthforuri = mercurial.httpconnection.readauthforuri
- if readauthforuri.func_code.co_argcount == 3:
- # Since hg.0593e8f81c71
- res = readauthforuri(local_ui, base_url, user)
- else:
- res = readauthforuri(local_ui, base_url)
- if res:
- group, auth_token = res
- else:
- auth_token = None
- if auth_token:
- username = auth_token.get('username')
- password = auth_token.get('password')
- prefix = auth_token.get('prefix')
- shortest_url = self.shortest_url(base_url, prefix)
- return username, password, shortest_url
-
- return None, None, None
-
- def shortest_url(self, base_url, prefix):
- if not prefix or prefix == '*':
- return base_url
- scheme, hostpath = base_url.split('://', 1)
- p = prefix.split('://', 1)
- if len(p) > 1:
- prefix_host_path = p[1]
- else:
- prefix_host_path = prefix
- shortest_url = scheme + '://' + prefix_host_path
- return shortest_url
-
- def canonical_url(self, authuri):
- """
- Strips query params from url. Used to convert urls like
- https://repo.machine.com/repos/apps/module?pairs=0000000000000000000000000000000000000000-0000000000000000000000000000000000000000&cmd=between
- to
- https://repo.machine.com/repos/apps/module
- """
- parsed_url = urlparse(authuri)
- return "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc,
- parsed_url.path)
-
-############################################################
-
- at monkeypatch_method(passwordmgr)
-def find_user_password(self, realm, authuri):
- """
- keyring-based implementation of username/password query
- for HTTP(S) connections
-
- Passwords are saved in gnome keyring, OSX/Chain or other platform
- specific storage and keyed by the repository url
- """
- # Extend object attributes
- if not hasattr(self, '_pwd_handler'):
- self._pwd_handler = HTTPPasswordHandler()
-
- if hasattr(self, '_http_req'):
- req = self._http_req
- else:
- req = None
-
- return self._pwd_handler.find_auth(self, realm, authuri, req)
-
- at monkeypatch_method(AbstractBasicAuthHandler, "http_error_auth_reqed")
-def basic_http_error_auth_reqed(self, authreq, host, req, headers):
- self.passwd._http_req = req
- try:
- return basic_http_error_auth_reqed.orig(self, authreq, host, req, headers)
- finally:
- self.passwd._http_req = None
-
- at monkeypatch_method(AbstractDigestAuthHandler, "http_error_auth_reqed")
-def digest_http_error_auth_reqed(self, authreq, host, req, headers):
- self.passwd._http_req = req
- try:
- return digest_http_error_auth_reqed.orig(self, authreq, host, req, headers)
- finally:
- self.passwd._http_req = None
-
-############################################################
-
-def try_smtp_login(ui, smtp_obj, username, password):
- """
- Attempts smtp login on smtp_obj (smtplib.SMTP) using username and
- password.
-
- Returns:
- - True if login succeeded
- - False if login failed due to the wrong credentials
-
- Throws Abort exception if login failed for any other reason.
-
- Immediately returns False if password is empty
- """
- if not password:
- return False
- try:
- ui.note(_('(authenticating to mail server as %s)\n') %
- (username))
- smtp_obj.login(username, password)
- return True
- except smtplib.SMTPException, inst:
- if inst.smtp_code == 535:
- ui.status(_("SMTP login failed: %s\n\n") % inst.smtp_error)
- return False
- else:
- raise util.Abort(inst)
-
-def keyring_supported_smtp(ui, username):
- """
- keyring-integrated replacement for mercurial.mail._smtp
- Used only when configuration file contains username, but
- does not contain the password.
-
- Most of the routine below is copied as-is from
- mercurial.mail._smtp. The only changed part is
- marked with #>>>>> and #<<<<< markers
- """
- local_hostname = ui.config('smtp', 'local_hostname')
- s = smtplib.SMTP(local_hostname=local_hostname)
- mailhost = ui.config('smtp', 'host')
- if not mailhost:
- raise util.Abort(_('no [smtp]host in hgrc - cannot send mail'))
- mailport = int(ui.config('smtp', 'port', 25))
- ui.note(_('sending mail: smtp host %s, port %s\n') %
- (mailhost, mailport))
- s.connect(host=mailhost, port=mailport)
- if ui.configbool('smtp', 'tls'):
- if not hasattr(socket, 'ssl'):
- raise util.Abort(_("can't use TLS: Python SSL support "
- "not installed"))
- ui.note(_('(using tls)\n'))
- s.ehlo()
- s.starttls()
- s.ehlo()
-
- #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
- stored = password = password_store.get_smtp_password(
- mailhost, mailport, username)
- # No need to check whether password was found as try_smtp_login
- # just returns False if it is absent.
- while not try_smtp_login(ui, s, username, password):
- password = ui.getpass(_("Password for %s on %s:%d: ") % (username, mailhost, mailport))
-
- if stored != password:
- password_store.set_smtp_password(
- mailhost, mailport, username, password)
- #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
-
- def send(sender, recipients, msg):
- try:
- return s.sendmail(sender, recipients, msg)
- except smtplib.SMTPRecipientsRefused, inst:
- recipients = [r[1] for r in inst.recipients.values()]
- raise util.Abort('\n' + '\n'.join(recipients))
- except smtplib.SMTPException, inst:
- raise util.Abort(inst)
-
- return send
-
-############################################################
-
-orig_smtp = mail._smtp
-
- at monkeypatch_method(mail)
-def _smtp(ui):
- """
- build an smtp connection and return a function to send email
-
- This is the monkeypatched version of _smtp(ui) function from
- mercurial/mail.py. It calls the original unless username
- without password is given in the configuration.
- """
- username = ui.config('smtp', 'username')
- password = ui.config('smtp', 'password')
-
- if username and not password:
- return keyring_supported_smtp(ui, username)
- else:
- return orig_smtp(ui)
+testedwith = '2.3'
diff --git a/tests/hghave.py b/tests/hghave.py
--- a/tests/hghave.py
+++ b/tests/hghave.py
@@ -107,6 +107,13 @@ def has_inotify():
os.unlink(name)
return True
+def has_keyring():
+ try:
+ import keyring
+ return True
+ except ImportError:
+ return False
+
def has_fifo():
if getattr(os, "mkfifo", None) is None:
return False
@@ -289,6 +296,7 @@ checks = {
"hardlink": (has_hardlink, "hardlinks"),
"icasefs": (has_icasefs, "case insensitive file system"),
"inotify": (has_inotify, "inotify extension support"),
+ "keyring": (has_keyring, "python keyring library"),
"killdaemons": (has_killdaemons, 'killdaemons.py support'),
"lsprof": (has_lsprof, "python lsprof module"),
"mtn": (has_mtn, "monotone client (>= 1.0)"),
diff --git a/tests/test-keyring.py b/tests/test-keyring.py
new file mode 100644
--- /dev/null
+++ b/tests/test-keyring.py
@@ -0,0 +1,234 @@
+"""Unit tests and doctests for the keyring extension."""
+import os
+from urllib2 import Request
+import sys
+
+from mercurial import ui, util
+
+try:
+ import keyring
+except ImportError:
+ # missing feature: python keyring library
+ sys.exit(80)
+
+from hgext import keyring
+
+
+class passwordstorefake(keyring.passwordstore):
+ """A passwordstore fake using an internal cache."""
+ def __init__(self):
+ self.cache = {}
+
+ def getpassword(self, uri, username):
+ testclient.debug('reading password from keyring for %s\n' % uri)
+ cache_key = self._formatkey(uri, username)
+ try:
+ password = self.cache[cache_key]
+ except KeyError:
+ password = ''
+ return password
+
+ def setpassword(self, uri, username, password):
+ cache_key = self._formatkey(uri, username)
+ self.cache[cache_key] = password
+ testclient.debug('stored username %s and password %s\n'
+ % (username, password))
+
+
+keyring.password_store = passwordstorefake()
+
+
+class uifake(ui.ui):
+ """A ui fake class for authentication tests."""
+ def __init__(self, user, password, interactive):
+ """Creates a new ui instance.
+
+ Set user and password to the desired values.
+ interactive is a boolean to control the interactive shell.
+ """
+ self._user = user
+ self._password = password
+ self._interactive = interactive
+ super(uifake, self).__init__()
+
+ def interactive(self):
+ """Returns True if the ui instance is interactive."""
+ return self._interactive
+
+ def prompt(self, msg, default='y'):
+ """"Prompt the user for a username.
+
+ Because this is a SUT the method returns always the username set
+ at instantiation.
+ """
+ return self._user
+
+ def getpass(self, prompt=None, default=None):
+ """"Prompt the user for a password.
+
+ Because this is a SUT the method returns always the password set
+ at instantiation.
+ """
+ return self._password
+
+
+class testclient(object):
+ """Test client for keyring extension."""
+ def __init__(self, user, password=None, interactive=True):
+ """Creates a test client.
+
+ You must set a username. If no password is set a default
+ password is used. The interactive shell is enabled by default.
+ """
+ self.user = user
+ if password is None:
+ self.password = 'secret'
+ else:
+ self.password = password
+ self.ui = uifake(self.user, self.password, interactive=interactive)
+ self.pwhandler = keyring.httppasswordhandler(self.ui)
+
+ @staticmethod
+ def debug(msg):
+ sys.stdout.write('[test] %s' % msg)
+
+ def testuri(self, uri, msg=None, headers=None, realm=None, debug=True):
+ """Tests an uri with username and password.
+
+ Use msg to print a message when the test starts.
+ Add additional headers using the headers kwarg.
+ Set the realm using the realm kwarg.
+ Set debug to False to disable keyring debug output.
+ """
+ if headers is None:
+ headers = {}
+ if msg is not None:
+ self.debug(msg + '\n')
+ if debug:
+ debug_orig = self.ui.config('ui', 'debug')
+ self.ui.setconfig('ui', 'debug', 'True')
+ self.debug('testing user %s at %s\n' % (self.user, uri))
+ request = Request(uri, headers=headers)
+ if headers:
+ self.debug('request headers:\n')
+ for key, value in headers.items():
+ self.debug('\t%s: %s\n' % (key, value))
+ self.pwhandler.setrequest(request)
+ try:
+ uriobj = util.url(uri)
+ if uriobj.user:
+ self.pwhandler.add_password(realm, uri, uriobj.user,
+ uriobj.passwd)
+ user, pwd = self.pwhandler.find_user_password(realm, uri)
+ assert user == self.user, 'user "%s" did not match "%s"' \
+ % (user, self.user)
+ assert pwd == self.password, 'password "%s" did not match "%s"' \
+ % (pwd, self.password)
+ except util.Abort, err:
+ self.debug('abort: %s\n' % err)
+ except AssertionError, err:
+ self.debug('warning: %s\n' % err)
+ sys.stdout.write('\n')
+ if debug:
+ self.ui.setconfig('ui', 'debug', debug_orig)
+
+
+t = testclient('alice')
+t.testuri('https://hg.example.com/repo', msg='ask for password',
+ headers={'Authorization': 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==',
+ 'User-agent': 'mercurial/proto-1.0'})
+t.testuri('https://hg.example.com/repo',
+ msg='ignore password cache because of identical request',
+ headers={'Authorization': 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==',
+ 'User-agent': 'mercurial/proto-1.0'})
+t.testuri('https://alice@hg.example.com/repo',
+ msg='read username from URI, use cached password')
+t.testuri('https://alice:secret@hg.example.com/repo',
+ msg='read username and password from URI, cache is not used')
+t.testuri('https://hg.example.com/repo', msg='use cached password')
+
+# add auth section to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'w')
+hgrc.write('[auth]\n')
+hgrc.write('myremote.prefix = hg.example.com\n')
+hgrc.write('myremote.username = bob\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo', msg='read password from keyring')
+t.testuri('https://hg.example.com/repo/module?cmd=capabilities',
+ msg='use cached password and strip off query params')
+
+pwd = 'cookies'
+# add password to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.password = %s\n' % pwd)
+hgrc.close()
+
+t = testclient('bob', pwd)
+t.testuri('https://bob@hg.example.com/repo',
+ msg='aborts because username is set in url and .hgrc')
+t.testuri('https://hg.example.com/repo',
+ msg='password is read from .hgrc')
+t.testuri('https://hg.example.com/repo?cmd=capabilities',
+ msg='use cached auth data')
+t.testuri('https://clara@hg.example.com/repo',
+ msg='store password for clara on keyring as user bob')
+
+# delete password from .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.password =\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo',
+ msg='username is taken from .hgrc and password is read from keyring')
+
+t = testclient('clara', pwd)
+t.testuri('https://clara@hg.example.com/repo',
+ msg='read password for clara on keyring')
+
+# add catch all prefix to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.prefix = *\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo',
+ msg='read password from keyring, use full URI because of catch all prefix')
+
+# add not matching prefix to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.prefix = code.example.com\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo',
+ msg='will prompt for password because prefix does not match')
+
+t = testclient('bob', interactive=False)
+t.testuri('https://hg.example.com/repo',
+ msg='will abort because terminal is non-interactive', debug=False)
+
+# test (simulated) clone operation
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo/?cmd=capabilities',
+ msg='test with different http headers')
+t.testuri('https://hg.example.com/repo/?cmd=batch',
+ headers={'x-hgarg-1': 'cmds=heads+%3Bknown+nodes%3D'})
+revs = ('0000000000000000000000000000000000000000',
+ 'b066b1ec09fb7b604b548375170e78bc008bb6b2')
+t.testuri('https://hg.example.com/repo/?cmd=getbundle',
+ headers={'x-hgarg-1': 'common=%s&heads=%s' % revs})
+t.testuri('https://hg.example.com/repo/?cmd=listkeys',
+ headers={'x-hgarg-1': 'namespace=phases'})
+t.testuri('https://hg.example.com/repo/?cmd=listkeys',
+ headers={'x-hgarg-1': 'namespace=bookmarks'})
+
+# this is hack to make sure no escape characters are inserted into the
+# output
+if 'TERM' in os.environ:
+ del os.environ['TERM']
+
+import doctest
+doctest.testmod(keyring)
diff --git a/tests/test-keyring.py.out b/tests/test-keyring.py.out
new file mode 100644
--- /dev/null
+++ b/tests/test-keyring.py.out
@@ -0,0 +1,192 @@
+[test] ask for password
+[test] testing user alice at https://hg.example.com/repo
+[test] request headers:
+[test] Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
+[test] User-agent: mercurial/proto-1.0
+keyring url: https://hg.example.com/repo
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo, user: alice, password: ***
+
+[test] ignore password cache because of identical request
+[test] testing user alice at https://hg.example.com/repo
+[test] request headers:
+[test] Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
+[test] User-agent: mercurial/proto-1.0
+detected bad authentication, cached passwords not used
+current request:
+ uri: https://hg.example.com/repo
+ request headers:
+ Authorization: ***
+ User-agent: mercurial/proto-1.0
+ realm: None
+previous request:
+ uri: https://hg.example.com/repo
+ request headers:
+ Authorization: ***
+ User-agent: mercurial/proto-1.0
+ realm: None
+keyring url: https://hg.example.com/repo
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo, user: alice, password: ***
+
+[test] read username from URI, use cached password
+[test] testing user alice at https://alice@hg.example.com/repo
+stripped username "alice" from url
+keyring url: https://hg.example.com/repo
+cached auth data found
+ url: https://alice@hg.example.com/repo, user: alice, password: ***
+
+[test] read username and password from URI, cache is not used
+[test] testing user alice at https://alice:secret@hg.example.com/repo
+auth data found in repository url
+ url: https://alice:***@hg.example.com/repo, user: alice, password: ***
+
+[test] use cached password
+[test] testing user alice at https://hg.example.com/repo
+keyring url: https://hg.example.com/repo
+cached auth data found
+ url: https://hg.example.com/repo, user: alice, password: ***
+
+[test] read password from keyring
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com
+using auth.myremote.* for authentication
+looking for password for user bob and url https://hg.example.com
+[test] reading password from keyring for https://hg.example.com
+password not present in keyring
+http authorization required
+realm: None
+user: bob (fixed in configuration)
+saving password for bob to keyring
+[test] stored username bob and password secret
+manually entered password
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] use cached password and strip off query params
+[test] testing user bob at https://hg.example.com/repo/module?cmd=capabilities
+keyring url: https://hg.example.com
+cached auth data found
+ url: https://hg.example.com/repo/module, user: bob, password: ***
+
+[test] aborts because username is set in url and .hgrc
+[test] testing user bob at https://bob@hg.example.com/repo
+stripped username "bob" from url
+keyring url: https://hg.example.com
+[test] abort: keyring: username bob for https://hg.example.com specified both in url/paths and in auth - please, leave only one of those
+
+[test] password is read from .hgrc
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com
+using auth.myremote.* for authentication
+
+[test] use cached auth data
+[test] testing user bob at https://hg.example.com/repo?cmd=capabilities
+keyring url: https://hg.example.com
+cached auth data found
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] store password for clara on keyring as user bob
+[test] testing user bob at https://clara@hg.example.com/repo
+stripped username "clara" from url
+keyring url: https://hg.example.com/repo
+looking for password for user clara and url https://hg.example.com/repo
+[test] reading password from keyring for https://hg.example.com/repo
+password not present in keyring
+http authorization required
+realm: None
+user: clara (fixed in configuration)
+saving password for clara to keyring
+[test] stored username clara and password cookies
+manually entered password
+ url: https://clara@hg.example.com/repo, user: clara, password: ***
+[test] warning: user "clara" did not match "bob"
+
+[test] username is taken from .hgrc and password is read from keyring
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com
+using auth.myremote.* for authentication
+looking for password for user bob and url https://hg.example.com
+[test] reading password from keyring for https://hg.example.com
+password found on keyring
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] read password for clara on keyring
+[test] testing user clara at https://clara@hg.example.com/repo
+stripped username "clara" from url
+keyring url: https://hg.example.com/repo
+looking for password for user clara and url https://hg.example.com/repo
+[test] reading password from keyring for https://hg.example.com/repo
+password found on keyring
+ url: https://clara@hg.example.com/repo, user: clara, password: ***
+
+[test] read password from keyring, use full URI because of catch all prefix
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com/repo
+using auth.myremote.* for authentication
+looking for password for user bob and url https://hg.example.com/repo
+[test] reading password from keyring for https://hg.example.com/repo
+password not present in keyring
+http authorization required
+realm: None
+user: bob (fixed in configuration)
+saving password for bob to keyring
+[test] stored username bob and password secret
+manually entered password
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] will prompt for password because prefix does not match
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com/repo
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] will abort because terminal is non-interactive
+[test] testing user bob at https://hg.example.com/repo
+[test] abort: keyring: http authorization required but program used in non-interactive mode
+
+[test] test with different http headers
+[test] testing user bob at https://hg.example.com/repo/?cmd=capabilities
+keyring url: https://hg.example.com/repo/
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=batch
+[test] request headers:
+[test] x-hgarg-1: cmds=heads+%3Bknown+nodes%3D
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=getbundle
+[test] request headers:
+[test] x-hgarg-1: common=0000000000000000000000000000000000000000&heads=b066b1ec09fb7b604b548375170e78bc008bb6b2
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=listkeys
+[test] request headers:
+[test] x-hgarg-1: namespace=phases
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=listkeys
+[test] request headers:
+[test] x-hgarg-1: namespace=bookmarks
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
diff --git a/tests/test-keyring.t b/tests/test-keyring.t
new file mode 100644
--- /dev/null
+++ b/tests/test-keyring.t
@@ -0,0 +1,79 @@
+ $ "$TESTDIR/hghave" serve || exit 80
+ $ "$TESTDIR/hghave" keyring || exit 80
+
+ $ hg init test
+ $ cd test
+ $ echo foo>foo
+ $ mkdir foo.d foo.d/bAr.hg.d foo.d/baR.d.hg
+ $ echo foo>foo.d/foo
+ $ echo bar>foo.d/bAr.hg.d/BaR
+ $ echo bar>foo.d/baR.d.hg/bAR
+ $ hg commit -A -m 1
+ adding foo
+ adding foo.d/bAr.hg.d/BaR
+ adding foo.d/baR.d.hg/bAR
+ adding foo.d/foo
+ $ hg phase -fpr 0:tip
+
+test http authentication with keyring extension
+
+ $ cat << EOT > userpass.py
+ > import base64
+ > from mercurial.hgweb import common
+ > def perform_authentication(hgweb, req, op):
+ > auth = req.env.get('HTTP_AUTHORIZATION')
+ > if not auth:
+ > raise common.ErrorResponse(common.HTTP_UNAUTHORIZED, 'who',
+ > [('WWW-Authenticate', 'Basic Realm="mercurial"')])
+ > authinfo = base64.b64decode(auth.split()[1]).split(':', 1)
+ > if authinfo != ['user', 'pass']:
+ > raise common.ErrorResponse(common.HTTP_FORBIDDEN, 'no')
+ > def extsetup():
+ > common.permhooks.insert(0, perform_authentication)
+ > EOT
+ $ hg --config extensions.x=userpass.py serve -p $HGPORT -d --pid-file=pid \
+ > --config server.preferuncompressed=True
+ $ cat pid >> $DAEMON_PIDS
+
+ $ echo '[extensions]' >> .hg/hgrc
+ $ echo 'keyring=' >> .hg/hgrc
+ $ hg id http://localhost:$HGPORT/
+ abort: keyring: http authorization required but program used in non-interactive mode
+ [255]
+ $ hg id http://user@localhost:$HGPORT/
+ abort: keyring: http authorization required but program used in non-interactive mode
+ [255]
+ $ hg id http://user:pass@localhost:$HGPORT/
+ 8b6053c928fe
+ $ echo '[auth]' >> .hg/hgrc
+ $ echo 'l.schemes=http' >> .hg/hgrc
+ $ echo 'l.prefix=*' >> .hg/hgrc
+ $ echo 'l.username=user' >> .hg/hgrc
+ $ echo 'l.password=pass' >> .hg/hgrc
+ $ hg id http://localhost:$HGPORT/
+ 8b6053c928fe
+ $ hg id http://user@localhost:$HGPORT/
+ abort: keyring: username user for http://localhost:$HGPORT/ specified both in url/paths and in auth - please, leave only one of those
+ [255]
+ $ hg id http://user:pass@localhost:$HGPORT/
+ 8b6053c928fe
+ $ hg id http://user2@localhost:$HGPORT/
+ abort: keyring: http authorization required but program used in non-interactive mode
+ [255]
+ $ hg clone http://user:pass@localhost:$HGPORT/ dest
+ streaming all changes
+ 6 files to transfer, 606 bytes of data
+ transferred 606 bytes in * seconds (* KB/sec) (glob)
+ updating to branch default
+ 4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+ $ cd dest
+ $ cp ../.hg/hgrc .hg
+ $ hg pull http://localhost:$HGPORT/
+ pulling from http://localhost:$HGPORT/
+ searching for changes
+ no changes found
+ $ hg push http://localhost:$HGPORT/
+ pushing to http://localhost:$HGPORT/
+ searching for changes
+ no changes found
+ [1]
More information about the Mercurial-devel
mailing list