Allows extraction of information about remotes.
[stgit] / stgit / git.py
index 2c73bb8..038aaac 100644 (file)
@@ -22,6 +22,7 @@ import sys, os, popen2, re, gitmergeonefile
 
 from stgit import basedir
 from stgit.utils import *
+from stgit.config import config
 
 # git exception class
 class GitException(Exception):
@@ -32,6 +33,38 @@ class GitException(Exception):
 #
 # Classes
 #
+
+class Person:
+    """An author, committer, etc."""
+    def __init__(self, name = None, email = None, date = '',
+                 desc = None):
+        if name or email or date:
+            assert not desc
+            self.name = name
+            self.email = email
+            self.date = date
+        elif desc:
+            assert not (name or email or date)
+            def parse_desc(s):
+                m = re.match(r'^(.+)<(.+)>(.*)$', s)
+                assert m
+                return [x.strip() or None for x in m.groups()]
+            self.name, self.email, self.date = parse_desc(desc)
+    def set_name(self, val):
+        if val:
+            self.name = val
+    def set_email(self, val):
+        if val:
+            self.email = val
+    def set_date(self, val):
+        if val:
+            self.date = val
+    def __str__(self):
+        if self.name and self.email:
+            return '%s <%s>' % (self.name, self.email)
+        else:
+            raise GitException, 'not enough identity data'
+
 class Commit:
     """Handle the commit objects
     """
@@ -59,7 +92,11 @@ class Commit:
         return self.__tree
 
     def get_parent(self):
-        return self.get_parents()[0]
+        parents = self.get_parents()
+        if parents:
+            return parents[0]
+        else:
+            return None
 
     def get_parents(self):
         return _output_lines('git-rev-list --parents --max-count=1 %s'
@@ -74,6 +111,9 @@ class Commit:
     def get_log(self):
         return self.__log
 
+    def __str__(self):
+        return self.get_id_hash()
+
 # dictionary of Commit objects, used to avoid multiple calls to git
 __commits = dict()
 
@@ -174,9 +214,13 @@ def __run(cmd, args=None):
     return 0
 
 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
-                  noexclude = True):
+                  noexclude = True, verbose = False):
     """Returns a list of pairs - [status, filename]
     """
+    if verbose and sys.stdout.isatty():
+        print 'Checking for changes in the working directory...',
+        sys.stdout.flush()
+
     refresh_index()
 
     if not files:
@@ -213,12 +257,15 @@ def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
         if fs[1] not in conflicts:
             cache_files.append(fs)
 
+    if verbose and sys.stdout.isatty():
+        print 'done'
+
     return cache_files
 
-def local_changes():
+def local_changes(verbose = True):
     """Return true if there are local changes in the tree
     """
-    return len(__tree_status()) != 0
+    return len(__tree_status(verbose = verbose)) != 0
 
 # HEAD value cached
 __head = None
@@ -350,6 +397,11 @@ def rename_branch(from_name, to_name):
     rename(os.path.join(basedir.get(), 'refs', 'heads'),
            from_name, to_name)
 
+    reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
+    if os.path.exists(reflog_dir) \
+           and os.path.exists(os.path.join(reflog_dir, from_name)):
+        rename(reflog_dir, from_name, to_name)
+
 def add(names):
     """Add the files or recursively add the directory contents
     """
@@ -387,13 +439,67 @@ def rm(files, force = False):
         if files:
             __run('git-update-index --force-remove --', files)
 
+# Persons caching
+__user = None
+__author = None
+__committer = None
+
+def user():
+    """Return the user information.
+    """
+    global __user
+    if not __user:
+        name=config.get('user.name')
+        email=config.get('user.email')
+        if name and email:
+            __user = Person(name, email)
+        else:
+            raise GitException, 'unknown user details'
+    return __user;
+
+def author():
+    """Return the author information.
+    """
+    global __author
+    if not __author:
+        try:
+            # the environment variables take priority over config
+            try:
+                date = os.environ['GIT_AUTHOR_DATE']
+            except KeyError:
+                date = ''
+            __author = Person(os.environ['GIT_AUTHOR_NAME'],
+                              os.environ['GIT_AUTHOR_EMAIL'],
+                              date)
+        except KeyError:
+            __author = user()
+    return __author
+
+def committer():
+    """Return the author information.
+    """
+    global __committer
+    if not __committer:
+        try:
+            # the environment variables take priority over config
+            try:
+                date = os.environ['GIT_COMMITTER_DATE']
+            except KeyError:
+                date = ''
+            __committer = Person(os.environ['GIT_COMMITTER_NAME'],
+                                 os.environ['GIT_COMMITTER_EMAIL'],
+                                 date)
+        except KeyError:
+            __committer = user()
+    return __committer
+
 def update_cache(files = None, force = False):
     """Update the cache information for the given files
     """
     if not files:
         files = []
 
-    cache_files = __tree_status(files)
+    cache_files = __tree_status(files, verbose = False)
 
     # everything is up-to-date
     if len(cache_files) == 0:
@@ -495,13 +601,27 @@ def apply_diff(rev1, rev2, check_index = True, files = None):
 
     return True
 
-def merge(base, head1, head2):
+def merge(base, head1, head2, recursive = False):
     """Perform a 3-way merge between base, head1 and head2 into the
     local tree
     """
     refresh_index()
-    if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
-        raise GitException, 'git-read-tree failed (local changes maybe?)'
+
+    if recursive:
+        # this operation tracks renames but it is slower (used in
+        # general when pushing or picking patches)
+        try:
+            # use _output() to mask the verbose prints of the tool
+            _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
+        except GitException:
+            pass
+    else:
+        # the fast case where we don't track renames (used when the
+        # distance between base and heads is small, i.e. folding or
+        # synchronising patches)
+        if __run('git-read-tree -u -m --aggressive',
+                 [base, head1, head2]) != 0:
+            raise GitException, 'git-read-tree failed (local changes maybe?)'
 
     # check the index for unmerged entries
     files = {}
@@ -524,6 +644,15 @@ def merge(base, head1, head2):
     # merge the unmerged files
     errors = False
     for path in files:
+        # remove additional files that might be generated for some
+        # newer versions of GIT
+        for suffix in [base, head1, head2]:
+            if not suffix:
+                continue
+            fname = path + '~' + suffix
+            if os.path.exists(fname):
+                os.remove(fname)
+
         stages = files[path]
         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
                                  stages['3'][1], path, stages['1'][0],
@@ -559,6 +688,8 @@ def status(files = None, modified = False, new = False, deleted = False,
         cache_files = [x for x in cache_files if x[0] in filestat]
 
     for fs in cache_files:
+        if files and not fs[1] in files:
+            continue
         if all:
             print '%s %s' % (fs[0], fs[1])
         else:
@@ -687,34 +818,49 @@ def pull(repository = 'origin', refspec = None):
     if refspec:
         args.append(refspec)
 
-    if __run('git-pull', args) != 0:
+    if __run(config.get('stgit.pullcmd'), args) != 0:
         raise GitException, 'Failed "git-pull %s"' % repository
 
-def apply_patch(filename = None, base = None):
+def repack():
+    """Repack all objects into a single pack
+    """
+    __run('git-repack -a -d -f')
+
+def apply_patch(filename = None, diff = None, base = None,
+                fail_dump = True):
     """Apply a patch onto the current or given index. There must not
     be any local changes in the tree, otherwise the command fails
     """
-    def __apply_patch():
+    if diff is None:
         if filename:
-            return __run('git-apply --index', [filename]) == 0
+            f = file(filename)
         else:
-            try:
-                _input('git-apply --index', sys.stdin)
-            except GitException:
-                return False
-            return True
+            f = sys.stdin
+        diff = f.read()
+        if filename:
+            f.close()
 
     if base:
         orig_head = get_head()
         switch(base)
     else:
-        refresh_index()         # needed since __apply_patch() doesn't do it
+        refresh_index()
 
-    if not __apply_patch():
+    try:
+        _input_str('git-apply --index', diff)
+    except GitException:
         if base:
             switch(orig_head)
-        raise GitException, 'Patch does not apply cleanly'
-    elif base:
+        if fail_dump:
+            # write the failed diff to a file
+            f = file('.stgit-failed.patch', 'w+')
+            f.write(diff)
+            f.close()
+            print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
+
+        raise
+
+    if base:
         top = commit(message = 'temporary commit used for applying a patch',
                      parents = [base])
         switch(orig_head)
@@ -735,3 +881,71 @@ def modifying_revs(files, base_rev):
     revs = [line.strip() for line in _output_lines(cmd + files)]
 
     return revs
+
+
+def refspec_localpart(refspec):
+    m = re.match('^[^:]*:([^:]*)$', refspec)
+    if m:
+        return m.group(1)
+    else:
+        raise GitException, 'Cannot parse refspec "%s"' % line
+
+def refspec_remotepart(refspec):
+    m = re.match('^([^:]*):[^:]*$', refspec)
+    if m:
+        return m.group(1)
+    else:
+        raise GitException, 'Cannot parse refspec "%s"' % line
+    
+
+def __remotes_from_config():
+    return config.sections_matching(r'remote\.(.*)\.url')
+
+def __remotes_from_dir(dir):
+    return os.listdir(os.path.join(basedir.get(), dir))
+
+def remotes_list():
+    """Return the list of remotes in the repository
+    """
+
+    return set(__remotes_from_config()) | \
+           set(__remotes_from_dir('remotes')) | \
+           set(__remotes_from_dir('branches'))
+
+def remotes_local_branches(remote):
+    """Returns the list of local branches fetched from given remote
+    """
+
+    branches = []
+    if remote in __remotes_from_config():
+        for line in config.getall('remote.%s.fetch' % remote):
+            branches.append(refspec_localpart(line))
+    elif remote in __remotes_from_dir('remotes'):
+        stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
+        for line in stream:
+            # Only consider Pull lines
+            m = re.match('^Pull: (.*)\n$', line)
+            branches.append(refspec_localpart(m.group(1)))
+        stream.close()
+    elif remote in __remotes_from_dir('branches'):
+        # old-style branches only declare one branch
+        branches.append('refs/heads/'+remote);
+    else:
+        raise GitException, 'Unknown remote "%s"' % remote
+
+    return branches
+
+def identify_remote(branchname):
+    """Return the name for the remote to pull the given branchname
+    from, or None if we believe it is a local branch.
+    """
+
+    for remote in remotes_list():
+        if branchname in remotes_local_branches(remote):
+            return remote
+
+    # FIXME: in the case of local branch we should maybe set remote to
+    # "." but are we even sure it is the only case left ?
+
+    # if we get here we've found nothing
+    return None