diff options
author | hut <hut@lepus.uberspace.de> | 2016-02-28 22:06:28 +0100 |
---|---|---|
committer | hut <hut@lepus.uberspace.de> | 2016-02-28 22:06:28 +0100 |
commit | 2de7ea308fb88f34a181de34c35c1f18fbd5c7aa (patch) | |
tree | 42316333b0c9a5f95f15ddf8ca3140c42e7ce5cf /ranger | |
parent | cb2b20166657d0716f4aefd777fa8f7dd80e8d3e (diff) | |
parent | 4b9a296eeea704bd8adc12d06ef27d7b01be1c75 (diff) | |
download | ranger-2de7ea308fb88f34a181de34c35c1f18fbd5c7aa.tar.gz |
Merge branch 'master' of https://github.com/nfnty/ranger
Diffstat (limited to 'ranger')
-rw-r--r-- | ranger/colorschemes/default.py | 5 | ||||
-rwxr-xr-x | ranger/config/commands.py | 137 | ||||
-rw-r--r-- | ranger/container/directory.py | 53 | ||||
-rw-r--r-- | ranger/container/fsobject.py | 90 | ||||
-rw-r--r-- | ranger/ext/vcs/__init__.py | 10 | ||||
-rw-r--r-- | ranger/ext/vcs/bzr.py | 335 | ||||
-rw-r--r-- | ranger/ext/vcs/git.py | 414 | ||||
-rw-r--r-- | ranger/ext/vcs/hg.py | 358 | ||||
-rw-r--r-- | ranger/ext/vcs/svn.py | 352 | ||||
-rw-r--r-- | ranger/ext/vcs/vcs.py | 732 | ||||
-rw-r--r-- | ranger/gui/context.py | 4 | ||||
-rw-r--r-- | ranger/gui/ui.py | 15 | ||||
-rw-r--r-- | ranger/gui/widgets/__init__.py | 36 | ||||
-rw-r--r-- | ranger/gui/widgets/browsercolumn.py | 40 | ||||
-rw-r--r-- | ranger/gui/widgets/statusbar.py | 36 |
15 files changed, 1071 insertions, 1546 deletions
diff --git a/ranger/colorschemes/default.py b/ranger/colorschemes/default.py index 80fa9e39..e5dbf3a6 100644 --- a/ranger/colorschemes/default.py +++ b/ranger/colorschemes/default.py @@ -100,6 +100,9 @@ class Default(ColorScheme): if context.vcscommit: fg = yellow attr &= ~bold + if context.vcsdate: + fg = cyan + attr &= ~bold if context.text: @@ -137,7 +140,7 @@ class Default(ColorScheme): elif context.vcsremote and not context.selected: attr &= ~bold - if context.vcssync: + if context.vcssync or context.vcsnone: fg = green elif context.vcsbehind: fg = red diff --git a/ranger/config/commands.py b/ranger/config/commands.py index f734d64a..3e7af7b5 100755 --- a/ranger/config/commands.py +++ b/ranger/config/commands.py @@ -1320,127 +1320,68 @@ class grep(Command): action.extend(f.path for f in self.fm.thistab.get_selection()) self.fm.execute_command(action, flags='p') - -# Version control commands -# -------------------------------- -class stage(Command): - """ - :stage - - Stage selected files for the corresponding version control system +class flat(Command): """ - def execute(self): - from ranger.ext.vcs import VcsError - - filelist = [f.path for f in self.fm.thistab.get_selection()] - self.fm.thisdir.vcs_outdated = True -# for f in self.fm.thistab.get_selection(): -# f.vcs_outdated = True - - try: - self.fm.thisdir.vcs.add(filelist) - except VcsError: - self.fm.notify("Could not stage files.") - - self.fm.reload_cwd() + :flat <level> + Flattens the directory view up to the specified level. -class unstage(Command): + -1 fully flattened + 0 remove flattened view """ - :unstage - Unstage selected files for the corresponding version control system - """ def execute(self): - from ranger.ext.vcs import VcsError - - filelist = [f.path for f in self.fm.thistab.get_selection()] - self.fm.thisdir.vcs_outdated = True -# for f in self.fm.thistab.get_selection(): -# f.vcs_outdated = True - try: - self.fm.thisdir.vcs.reset(filelist) - except VcsError: - self.fm.notify("Could not unstage files.") - - self.fm.reload_cwd() + level = self.rest(1) + level = int(level) + except ValueError: + level = self.quantifier + if level < -1: + self.fm.notify("Need an integer number (-1, 0, 1, ...)", bad=True) + self.fm.thisdir.unload() + self.fm.thisdir.flat = level + self.fm.thisdir.load_content() +# Version control commands +# -------------------------------- -class diff(Command): +class stage(Command): """ - :diff + :stage - Displays a diff of selected files against the last committed version + Stage selected files for the corresponding version control system """ def execute(self): from ranger.ext.vcs import VcsError - import tempfile - - L = self.fm.thistab.get_selection() - if len(L) == 0: return - - filelist = [f.path for f in L] - vcs = L[0].vcs - - diff = vcs.get_raw_diff(filelist=filelist) - if len(diff.strip()) > 0: - tmp = tempfile.NamedTemporaryFile() - tmp.write(diff.encode('utf-8')) - tmp.flush() - pager = os.environ.get('PAGER', ranger.DEFAULT_PAGER) - self.fm.run([pager, tmp.name]) + if self.fm.thisdir.vcs and self.fm.thisdir.vcs.track: + filelist = [f.path for f in self.fm.thistab.get_selection()] + try: + self.fm.thisdir.vcs.action_add(filelist) + except VcsError as error: + self.fm.notify('Unable to stage files: {0:s}'.format(str(error))) + self.fm.ui.vcsthread.wakeup(self.fm.thisdir) else: - raise Exception("diff is empty") - + self.fm.notify('Unable to stage files: Not in repository') -class log(Command): +class unstage(Command): """ - :log + :unstage - Displays the log of the current repo or files + Unstage selected files for the corresponding version control system """ def execute(self): from ranger.ext.vcs import VcsError - import tempfile - - L = self.fm.thistab.get_selection() - if len(L) == 0: return - - filelist = [f.path for f in L] - vcs = L[0].vcs - - log = vcs.get_raw_log(filelist=filelist) - tmp = tempfile.NamedTemporaryFile() - tmp.write(log.encode('utf-8')) - tmp.flush() - - pager = os.environ.get('PAGER', ranger.DEFAULT_PAGER) - self.fm.run([pager, tmp.name]) - -class flat(Command): - """ - :flat <level> - - Flattens the directory view up to the specified level. - - -1 fully flattened - 0 remove flattened view - """ - - def execute(self): - try: - level = self.rest(1) - level = int(level) - except ValueError: - level = self.quantifier - if level < -1: - self.fm.notify("Need an integer number (-1, 0, 1, ...)", bad=True) - self.fm.thisdir.unload() - self.fm.thisdir.flat = level - self.fm.thisdir.load_content() + if self.fm.thisdir.vcs and self.fm.thisdir.vcs.track: + filelist = [f.path for f in self.fm.thistab.get_selection()] + try: + self.fm.thisdir.vcs.action_reset(filelist) + except VcsError as error: + self.fm.notify('Unable to unstage files: {0:s}'.format(str(error))) + self.fm.ui.vcsthread.wakeup(self.fm.thisdir) + else: + self.fm.notify('Unable to unstage files: Not in repository') # Metadata commands # -------------------------------- diff --git a/ranger/container/directory.py b/ranger/container/directory.py index 52b494d5..8ace0d85 100644 --- a/ranger/container/directory.py +++ b/ranger/container/directory.py @@ -18,6 +18,7 @@ from ranger.ext.accumulator import Accumulator from ranger.ext.lazy_property import lazy_property from ranger.ext.human_readable import human_readable from ranger.container.settings import LocalSettings +from ranger.ext.vcs import Vcs def sort_by_basename(path): """returns path.relative_path (for sorting)""" @@ -109,6 +110,7 @@ class Directory(FileSystemObject, Accumulator, Loadable): content_outdated = False content_loaded = False + vcs = None has_vcschild = False _cumulative_size_calculated = False @@ -145,6 +147,9 @@ class Directory(FileSystemObject, Accumulator, Loadable): self.settings = LocalSettings(path, self.settings) + if self.settings.vcs_aware: + self.vcs = Vcs(self) + self.use() def request_resort(self): @@ -229,7 +234,7 @@ class Directory(FileSystemObject, Accumulator, Loadable): filters.append(lambda file: temporary_filter_search(file.basename)) self.files = [f for f in self.files_all if accept_file(f, filters)] - + # A fix for corner cases when the user invokes show_hidden on a # directory that contains only hidden directories and hidden files. if self.files and not self.pointed_obj: @@ -301,10 +306,7 @@ class Directory(FileSystemObject, Accumulator, Loadable): files = [] disk_usage = 0 - if self.settings.vcs_aware: - self.has_vcschild = False - self.load_vcs(None) - + has_vcschild = False for name in filenames: try: file_lstat = os_lstat(name) @@ -318,34 +320,39 @@ class Directory(FileSystemObject, Accumulator, Loadable): stats = None is_a_dir = False if is_a_dir: - if self.flat: + try: + item = self.fm.get_directory(name) + item.load_if_outdated() + except: item = Directory(name, preload=stats, path_is_abs=True, - basename_is_rel_to=basename_is_rel_to) + basename_is_rel_to=basename_is_rel_to) item.load() else: - try: - item = self.fm.get_directory(name) - item.load_if_outdated() - except: - item = Directory(name, preload=stats, path_is_abs=True) - item.load() + if self.flat: + item.relative_path = os.path.relpath(item.path, self.path) + else: + item.relative_path = item.basename + item.relative_path_lower = item.relative_path.lower() + if item.vcs and item.vcs.track: + if item.vcs.is_root_pointer: + has_vcschild = True + else: + item.vcsstatus = item.vcs.rootvcs.status_subpath( + os.path.join(self.realpath, item.basename), is_directory=True) else: item = File(name, preload=stats, path_is_abs=True, basename_is_rel_to=basename_is_rel_to) item.load() disk_usage += item.size - - # Load vcs data - if self.settings.vcs_aware: - item.load_vcs(self) - if item.vcs_enabled: - self.has_vcschild = True + if self.vcs and self.vcs.track: + item.vcsstatus = self.vcs.rootvcs.status_subpath( + os.path.join(self.realpath, item.basename)) files.append(item) self.percent = 100 * len(files) // len(filenames) yield + self.has_vcschild = has_vcschild self.disk_usage = disk_usage - self.vcs_outdated = False self.filenames = filenames self.files_all = files @@ -378,6 +385,8 @@ class Directory(FileSystemObject, Accumulator, Loadable): finally: self.loading = False self.fm.signal_emit("finished_loading_dir", directory=self) + if self.vcs: + self.fm.ui.vcsthread.wakeup(self) def unload(self): self.loading = False @@ -416,7 +425,6 @@ class Directory(FileSystemObject, Accumulator, Loadable): pass self.load_generator = None - def sort(self): """Sort the contained files""" if self.files_all is None: @@ -582,7 +590,8 @@ class Directory(FileSystemObject, Accumulator, Loadable): def load_content_if_outdated(self, *a, **k): """Load the contents of the directory if outdated""" - if self.load_content_once(*a, **k): return True + if self.load_content_once(*a, **k): + return True if self.files_all is None or self.content_outdated: self.load_content(*a, **k) diff --git a/ranger/container/fsobject.py b/ranger/container/fsobject.py index aa848b7a..1daf6d70 100644 --- a/ranger/container/fsobject.py +++ b/ranger/container/fsobject.py @@ -73,16 +73,8 @@ class FileSystemObject(FileManagerAware, SettingsAware): size = 0 - (vcs, - vcsfilestatus, - vcsremotestatus, - vcsbranch, - vcshead) = (None,) * 5 - - vcs_outdated = False - vcs_enabled = False - - basename_is_rel_to = None + vcsstatus = None + vcsremotestatus = None _linemode = DEFAULT_LINEMODE linemode_dict = dict( @@ -94,12 +86,10 @@ class FileSystemObject(FileManagerAware, SettingsAware): if not path_is_abs: path = abspath(path) self.path = path - self.basename_is_rel_to = basename_is_rel_to + self.basename = basename(path) if basename_is_rel_to == None: - self.basename = basename(path) self.relative_path = self.basename else: - self.basename = basename(path) self.relative_path = relpath(path, basename_is_rel_to) self.relative_path_lower = self.relative_path.lower() self.extension = splitext(self.basename)[1].lstrip(extsep) or None @@ -241,78 +231,6 @@ class FileSystemObject(FileManagerAware, SettingsAware): return None # it is impossible to get the link destination return self.path - def load_vcs(self, parent): - """ - Reads data regarding the version control system the object is on. - Does not load content specific data. - """ - from ranger.ext.vcs import Vcs, VcsError - - vcs = Vcs(self.path) - - # Not under vcs - if vcs.root == None: - return - - # Already know about the right vcs - elif self.vcs and abspath(vcs.root) == abspath(self.vcs.root): - self.vcs.update() - - # Need new Vcs object and self.path is the root - elif self.vcs == None and abspath(vcs.root) == abspath(self.path): - self.vcs = vcs - self.vcs_outdated = True - - # Otherwise, find the root, and try to get the Vcs object from there - else: - rootdir = self.fm.get_directory(vcs.root) - rootdir.load_if_outdated() - - # Get the Vcs object from rootdir - rootdir.load_vcs(None) - self.vcs = rootdir.vcs - if rootdir.vcs_outdated: - self.vcs_outdated = True - - if self.vcs: - if self.vcs.vcsname == 'git': - backend_state = self.settings.vcs_backend_git - elif self.vcs.vcsname == 'hg': - backend_state = self.settings.vcs_backend_hg - elif self.vcs.vcsname == 'bzr': - backend_state = self.settings.vcs_backend_bzr - elif self.vcs.vcsname == 'svn': - backend_state = self.settings.vcs_backend_svn - else: - backend_state = 'disabled' - - self.vcs_enabled = backend_state in set(['enabled', 'local']) - if self.vcs_enabled: - try: - if self.vcs_outdated or (parent and parent.vcs_outdated): - self.vcs_outdated = False - # this caches the file status for get_file_status(): - self.vcs.get_status() - self.vcsbranch = self.vcs.get_branch() - self.vcshead = self.vcs.get_info(self.vcs.HEAD) - if self.path == self.vcs.root and \ - backend_state == 'enabled': - self.vcsremotestatus = \ - self.vcs.get_remote_status() - elif parent: - self.vcsbranch = parent.vcsbranch - self.vcshead = parent.vcshead - self.vcsfilestatus = self.vcs.get_file_status(self.path) - except VcsError as err: - self.vcsbranch = None - self.vcshead = None - self.vcsremotestatus = 'unknown' - self.vcsfilestatus = 'unknown' - self.fm.notify("Can not load vcs data on %s: %s" % - (self.path, err), bad=True) - else: - self.vcs_enabled = False - def load(self): """Loads information about the directory itself. @@ -411,8 +329,6 @@ class FileSystemObject(FileManagerAware, SettingsAware): real_ctime = None if not self.stat or self.stat.st_ctime != real_ctime: self.load() - if self.settings.vcs_aware: - self.vcs_outdated = True return True return False diff --git a/ranger/ext/vcs/__init__.py b/ranger/ext/vcs/__init__.py index 3f8e8138..26a32800 100644 --- a/ranger/ext/vcs/__init__.py +++ b/ranger/ext/vcs/__init__.py @@ -1,12 +1,8 @@ -# -*- coding: utf-8 -*- # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. -# Author: Abdó Roig-Maranges <abdo.roig@gmail.com>, 2011 -# -# vcs - a python module to handle various version control systems -import os +"""VCS Extension""" -from .vcs import VcsError, Vcs +from .vcs import Vcs, VcsError, VcsThread -# vim: expandtab:shiftwidth=4:tabstop=4:softtabstop=4:textwidth=80 +__all__ = ['Vcs', 'VcsError', 'VcsThread'] diff --git a/ranger/ext/vcs/bzr.py b/ranger/ext/vcs/bzr.py index 2a52cf02..5decc8b1 100644 --- a/ranger/ext/vcs/bzr.py +++ b/ranger/ext/vcs/bzr.py @@ -1,270 +1,141 @@ -# -*- coding: utf-8 -*- # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. -# Author: Abdó Roig-Maranges <abdo.roig@gmail.com>, 2012 -# -# vcs - a python module to handle various version control systems +"""GNU Bazaar module""" + +from datetime import datetime import os import re -import shutil -from datetime import datetime from .vcs import Vcs, VcsError class Bzr(Vcs): - vcsname = 'bzr' - HEAD="last:1" - - # Auxiliar stuff - #--------------------------- - - def _bzr(self, path, args, silent=True, catchout=False, bytes=False): - return self._vcs(path, 'bzr', args, silent=silent, catchout=catchout, bytes=bytes) - - - def _has_head(self): - """Checks whether repo has head""" - rnum = self._bzr(self.path, ['revno'], catchout=True) - return rnum != '0' - + """VCS implementation for GNU Bazaar""" + HEAD = 'last:1' - def _sanitize_rev(self, rev): - if rev == None: return None - rev = rev.strip() - if len(rev) == 0: return None + _status_translations = ( + ('+ -R', 'K NM', 'staged'), + (' -', 'D', 'deleted'), + ('?', ' ', 'untracked'), + ) - return rev + # Generic + def _remote_url(self): + """Remote url""" + try: + return self._run(['config', 'parent_location']).rstrip('\n') or None + except VcsError: + return None def _log(self, refspec=None, filelist=None): - """Gets a list of dicts containing revision info, for the revisions matching refspec""" - args = ['log', '-n0', '--show-ids'] - if refspec: args = args + ["-r", refspec] + """Returns an array of dicts containing revision info for refspec""" + args = ['log', '--log-format', 'long', '--levels', '0', '--show-ids'] + if refspec: + args += ['--revision', refspec] + if filelist: + args += ['--'] + filelist - if filelist: args = args + filelist - - raw = self._bzr(self.path, args, catchout=True, silent=True) - L = re.findall('-+$(.*?)^-', raw + '\n---', re.MULTILINE | re.DOTALL) + try: + output = self._run(args) + except VcsError: + return None + entries = re.findall(r'-+\n(.+?)\n(?:-|\Z)', output, re.MULTILINE | re.DOTALL) log = [] - for t in L: - t = t.strip() - if len(t) == 0: continue - - dt = {} - m = re.search('^revno:\s*([0-9]+)\s*$', t, re.MULTILINE) - if m: dt['short'] = m.group(1).strip() - m = re.search('^revision-id:\s*(.+)\s*$', t, re.MULTILINE) - if m: dt['revid'] = m.group(1).strip() - m = re.search('^committer:\s*(.+)\s*$', t, re.MULTILINE) - if m: dt['author'] = m.group(1).strip() - m = re.search('^timestamp:\s*(.+)\s*$', t, re.MULTILINE) - if m: dt['date'] = datetime.strptime(m.group(1).strip(), '%a %Y-%m-%d %H:%M:%S %z') - m = re.search('^message:\s*^(.+)$', t, re.MULTILINE) - if m: dt['summary'] = m.group(1).strip() - log.append(dt) + for entry in entries: + new = {} + try: + new['short'] = re.search(r'^revno: ([0-9]+)', entry, re.MULTILINE).group(1) + new['revid'] = re.search(r'^revision-id: (.+)$', entry, re.MULTILINE).group(1) + new['author'] = re.search(r'^committer: (.+)$', entry, re.MULTILINE).group(1) + new['date'] = datetime.strptime( + re.search(r'^timestamp: (.+)$', entry, re.MULTILINE).group(1), + '%a %Y-%m-%d %H:%M:%S %z' + ) + new['summary'] = re.search(r'^message:\n (.+)$', entry, re.MULTILINE).group(1) + except AttributeError: + return None + log.append(new) return log - - def _bzr_file_status(self, st): - st = st.strip() - if st in "AM": return 'staged' - elif st in "D": return 'deleted' - elif st in "?": return 'untracked' - else: return 'unknown' - - - - # Repo creation - #--------------------------- - - def init(self): - """Initializes a repo in current path""" - self._bzr(self.path, ['init']) - self.update() - - - def clone(self, src): - """Clones a repo from src""" - path = os.path.dirname(self.path) - name = os.path.basename(self.path) - try: - os.rmdir(self.path) - except OSError: - raise VcsError("Can't clone to %s. It is not an empty directory" % self.path) - - self._bzr(path, ['branch', src, name]) - self.update() - - + def _status_translate(self, code): + """Translate status code""" + for code_x, code_y, status in self._status_translations: + if code[0] in code_x and code[1] in code_y: + return status + return 'unknown' # Action Interface - #--------------------------- - - def commit(self, message): - """Commits with a given message""" - self._bzr(self.path, ['commit', '-m', message]) - - - def add(self, filelist=None): - """Adds files to the index, preparing for commit""" - if filelist != None: self._bzr(self.path, ['add'] + filelist) - else: self._bzr(self.path, ['add']) - - - def reset(self, filelist=None): - """Removes files from the index""" - if filelist != None: self._bzr(self.path, ['remove', '--keep', '--new'] + filelist) - else: self._bzr(self.path, ['remove', '--keep', '--new']) - - - def pull(self): - """Pulls a git repo""" - self._bzr(self.path, ['pull']) - - - def push(self): - """Pushes a git repo""" - self._bzr(self.path, ['push']) - - - def checkout(self, rev): - """Checks out a branch or revision""" - self._bzr(self.path, ['update', '-r', rev]) - - - def extract_file(self, rev, name, dest): - """Extracts a file from a given revision and stores it in dest dir""" - if rev == self.INDEX: - shutil.copyfile(os.path.join(self.path, name), dest) - else: - out = self._bzr(self.path, ['cat', '--r', rev, name], catchout=True, bytes=True) - with open(dest, 'wb') as fd: fd.write(out) + def action_add(self, filelist=None): + args = ['add'] + if filelist: + args += ['--'] + filelist + self._run(args, catchout=False) + def action_reset(self, filelist=None): + args = ['remove', '--keep', '--new'] + if filelist: + args += ['--'] + filelist + self._run(args, catchout=False) # Data Interface - #--------------------------- - - def get_status_allfiles(self): - """Returns a dict indexed by files not in sync their status as values. - Paths are given relative to the root. Strips trailing '/' from dirs.""" - raw = self._bzr(self.path, ['status', '--short', '--no-classify'], catchout=True, bytes=True) - L = re.findall('^(..)\s*(.*?)\s*$', raw.decode('utf-8'), re.MULTILINE) - ret = {} - for st, p in L: - sta = self._bzr_file_status(st) - ret[os.path.normpath(p.strip())] = sta - return ret - - - def get_ignore_allfiles(self): - """Returns a set of all the ignored files in the repo. Strips trailing '/' from dirs.""" - raw = self._bzr(self.path, ['ls', '--ignored'], catchout=True) - return set(os.path.normpath(p) for p in raw.split('\n')) - - - # TODO: slow due to net access - def get_remote_status(self): - """Checks the status of the repo regarding sync state with remote branch""" - if self.get_remote() == None: - return "none" - - ahead = behind = True - try: - self._bzr(self.path, ['missing', '--mine-only'], silent=True) - except: - ahead = False - - try: - self._bzr(self.path, ['missing', '--theirs-only'], silent=True) - except: - behind = False - if ahead and behind: return "diverged" - elif ahead and not behind: return "ahead" - elif not ahead and behind: return "behind" - elif not ahead and not behind: return "sync" + def data_status_root(self): + statuses = set() + # Paths with status + output = self._run(['status', '--short', '--no-classify']).rstrip('\n') + if not output: + return 'sync' + for line in output.split('\n'): + statuses.add(self._status_translate(line[:2])) - def get_branch(self): - """Returns the current named branch, if this makes sense for the backend. None otherwise""" - branch = self._bzr(self.path, ['nick'], catchout=True) - return branch or None + for status in self.DIRSTATUSES: + if status in statuses: + return status + return 'sync' + def data_status_subpaths(self): + statuses = {} - def get_log(self, filelist=None, maxres=None): - """Get the entire log for the current HEAD""" - if not self._has_head(): return [] - return self._log(refspec=None, filelist=filelist) + # Ignored + output = self._run(['ls', '--null', '--ignored']).rstrip('\x00') + if output: + for path in output.split('\x00'): + statuses[path] = 'ignored' + # Paths with status + output = self._run(['status', '--short', '--no-classify']).rstrip('\n') + for line in output.split('\n'): + statuses[os.path.normpath(line[4:])] = self._status_translate(line[:2]) - def get_raw_log(self, filelist=None): - """Gets the raw log as a string""" - if not self._has_head(): return [] - args = ['log'] - if filelist: args = args + filelist - return self._bzr(self.path, args, catchout=True) + return statuses + def data_status_remote(self): + if not self._remote_url(): + return 'none' + return 'unknown' - def get_raw_diff(self, refspec=None, filelist=None): - """Gets the raw diff as a string""" - args = ['diff', '--git'] - if refspec: args = args + [refspec] - if filelist: args = args + filelist - return self._bzr(self.path, args, catchout=True) - - - def get_remote(self): - """Returns the url for the remote repo attached to head""" - try: - remote = self._bzr(self.path, ['config', 'parent_location'], catchout=True) - except VcsError: - remote = "" - - return remote.strip() or None - - - def get_revision_id(self, rev=None): - """Get a canonical key for the revision rev""" - if rev == None: rev = self.HEAD - elif rev == self.INDEX: return None - rev = self._sanitize_rev(rev) + def data_branch(self): try: - L = self._log(refspec=rev) + return self._run(['nick']).rstrip('\n') or None except VcsError: - L = [] - if len(L) == 0: return None - else: return L[0]['revid'] - - - def get_info(self, rev=None): - """Gets info about the given revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - if rev == self.HEAD and not self._has_head(): return [] - - L = self._log(refspec=rev) - if len(L) == 0: - raise VcsError("Revision %s does not exist" % rev) - elif len(L) > 1: - raise VcsError("More than one instance of revision %s ?!?" % rev) + return None + + def data_info(self, rev=None): + if rev is None: + rev = self.HEAD + + log = self._log(refspec=rev) + if not log: + if rev == self.HEAD: + return None + else: + raise VcsError('Revision {0:s} does not exist'.format(rev)) + elif len(log) == 1: + return log[0] else: - return L[0] - - - def get_files(self, rev=None): - """Gets a list of files in revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - - if rev: - if rev == self.INDEX: raw = self._bzr(self.path, ["ls"], catchout=True) - else: raw = self._bzr(self.path, ['ls', '--R', '-V', '-r', rev], catchout=True) - return raw.split('\n') - else: - return [] - -# vim: expandtab:shiftwidth=4:tabstop=4:softtabstop=4:textwidth=80 + raise VcsError('More than one instance of revision {0:s}'.format(rev)) diff --git a/ranger/ext/vcs/git.py b/ranger/ext/vcs/git.py index f4950822..8f4d9ff8 100644 --- a/ranger/ext/vcs/git.py +++ b/ranger/ext/vcs/git.py @@ -1,302 +1,194 @@ -# -*- coding: utf-8 -*- # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. -# Author: Abdó Roig-Maranges <abdo.roig@gmail.com>, 2011-2012 -# -# vcs - a python module to handle various version control systems +"""Git module""" + +from datetime import datetime +import json import os import re -import shutil -from datetime import datetime from .vcs import Vcs, VcsError class Git(Vcs): - vcsname = 'git' + """VCS implementation for Git""" + _status_translations = ( + ('MADRC', ' ', 'staged'), + (' MADRC', 'M', 'changed'), + (' MARC', 'D', 'deleted'), - # Auxiliar stuff - #--------------------------- + ('D', 'DU', 'conflict'), + ('A', 'AU', 'conflict'), + ('U', 'ADU', 'conflict'), - def _git(self, path, args, silent=True, catchout=False, bytes=False): - return self._vcs(path, 'git', args, silent=silent, catchout=catchout, bytes=bytes) - - - def _has_head(self): - """Checks whether repo has head""" - try: - self._git(self.path, ['rev-parse', 'HEAD'], silent=True) - except VcsError: - return False - return True + ('?', '?', 'untracked'), + ('!', '!', 'ignored'), + ) + # Generic def _head_ref(self): - """Gets HEAD's ref""" - ref = self._git(self.path, ['symbolic-ref', self.HEAD], catchout=True, silent=True) - return ref.strip() or None - + """Returns HEAD reference""" + return self._run(['symbolic-ref', self.HEAD]).rstrip('\n') or None def _remote_ref(self, ref): - """Gets remote ref associated to given ref""" - if ref == None: return None - remote = self._git(self.path, ['for-each-ref', '--format=%(upstream)', ref], catchout=True, silent=True) - return remote.strip() or None - - - def _sanitize_rev(self, rev): - if rev == None: return None - return rev.strip() - + """Returns remote reference associated to given ref""" + if ref is None: + return None + return self._run(['for-each-ref', '--format=%(upstream)', ref]).rstrip('\n') or None def _log(self, refspec=None, maxres=None, filelist=None): - """Gets a list of dicts containing revision info, for the revisions matching refspec""" - fmt = '--pretty=%h %H%nAuthor: %an <%ae>%nDate: %ct%nSubject: %s%n' - - args = ['--no-pager', 'log', fmt] - if refspec: args = args + ['-1', refspec] - elif maxres: args = args + ['-%d' % maxres] - - if filelist: args = args + ['--'] + filelist + """Returns an array of dicts containing revision info for refspec""" + args = [ + '--no-pager', 'log', + '--pretty={' + '%x00short%x00:%x00%h%x00,' + '%x00revid%x00:%x00%H%x00,' + '%x00author%x00:%x00%an <%ae>%x00,' + '%x00date%x00:%ct,' + '%x00summary%x00:%x00%s%x00' + '}' + ] + if refspec: + args += ['-1', refspec] + elif maxres: + args += ['-{0:d}'.format(maxres)] + if filelist: + args += ['--'] + filelist - raw = self._git(self.path, args, catchout=True) - L = re.findall('^\s*(\w*)\s*(\w*)\s*^Author:\s*(.*)\s*^Date:\s*(.*)\s*^Subject:\s*(.*)\s*', raw, re.MULTILINE) + try: + output = self._run(args).rstrip('\n') + except VcsError: + return None + if not output: + return None log = [] - for t in L: - dt = {} - dt['short'] = t[0].strip() - dt['revid'] = t[1].strip() - dt['author'] = t[2].strip() - m = re.match('\d+(\.\d+)?', t[3].strip()) - dt['date'] = datetime.fromtimestamp(float(m.group(0))) - dt['summary'] = t[4].strip() - log.append(dt) + for line in output\ + .replace('\\', '\\\\').replace('"', '\\"').replace('\x00', '"').split('\n'): + line = json.loads(line) + line['date'] = datetime.fromtimestamp(line['date']) + log.append(line) return log - - def _git_file_status(self, st): - if len(st) != 2: raise VcsError("Wrong git file status string: %s" % st) - X, Y = (st[0], st[1]) - if X in " " and Y in " " : return 'sync' - elif X in "MADRC" and Y in " " : return 'staged' - elif X in "MADRC " and Y in "M": return 'changed' - elif X in "MARC " and Y in "D": return 'deleted' - elif X in "U" or Y in "U": return 'conflict' - elif X in "A" and Y in "A": return 'conflict' - elif X in "D" and Y in "D": return 'conflict' - elif X in "?" and Y in "?": return 'untracked' - elif X in "!" and Y in "!": return 'ignored' - else: return 'unknown' - - - - # Repo creation - #--------------------------- - - def init(self): - """Initializes a repo in current path""" - self._git(self.path, ['init']) - self.update() - - - def clone(self, src): - """Clones a repo from src""" - name = os.path.basename(self.path) - path = os.path.dirname(self.path) - try: - os.rmdir(self.path) - except OSError: - raise VcsError("Can't clone to %s. It is not an empty directory" % self.path) - - self._git(path, ['clone', src, name]) - self.update() - - + def _status_translate(self, code): + """Translate status code""" + for code_x, code_y, status in self._status_translations: + if code[0] in code_x and code[1] in code_y: + return status + return 'unknown' # Action interface - #--------------------------- - - def commit(self, message): - """Commits with a given message""" - self._git(self.path, ['commit', '-m', message]) - - - def add(self, filelist=None): - """Adds files to the index, preparing for commit""" - if filelist != None: self._git(self.path, ['add', '-A'] + filelist) - else: self._git(self.path, ['add', '-A']) - - - def reset(self, filelist=None): - """Removes files from the index""" - if filelist != None: self._git(self.path, ['reset'] + filelist) - else: self._git(self.path, ['reset']) - - - def pull(self, br=None): - """Pulls from remote""" - if br: self._git(self.path, ['pull', br]) - else: self._git(self.path, ['pull']) - - - def push(self, br=None): - """Pushes to remote""" - if br: self._git(self.path, ['push', br]) - else: self._git(self.path, ['push']) - - - def checkout(self, rev): - """Checks out a branch or revision""" - self._git(self.path, ['checkout', self._sanitize_rev(rev)]) - - - def extract_file(self, rev, name, dest): - """Extracts a file from a given revision and stores it in dest dir""" - if rev == self.INDEX: - shutil.copyfile(os.path.join(self.path, name), dest) - else: - out = self._git(self.path, ['--no-pager', 'show', '%s:%s' % (self._sanitize_rev(rev), name)], - catchout=True, bytes=True) - with open(dest, 'wb') as fd: fd.write(out) + def action_add(self, filelist=None): + args = ['add', '--all'] + if filelist: + args += ['--'] + filelist + self._run(args, catchout=False) + def action_reset(self, filelist=None): + args = ['reset'] + if filelist: + args += ['--'] + filelist + self._run(args, catchout=False) # Data Interface - #--------------------------- - - def get_status_allfiles(self): - """Returns a dict indexed by files not in sync their status as values. - Paths are given relative to the root. Strips trailing '/' from dirs.""" - raw = self._git(self.path, ['status', '--porcelain'], catchout=True, bytes=True) - L = re.findall('^(..)\s*(.*?)\s*$', raw.decode('utf-8'), re.MULTILINE) - ret = {} - for st, p in L: - sta = self._git_file_status(st) - if 'R' in st: - m = re.match('^(.*)\->(.*)$', p) - if m: p = m.group(2).strip() - ret[os.path.normpath(p.strip())] = sta - return ret - - - def get_ignore_allfiles(self): - """Returns a set of all the ignored files in the repo. Strips trailing '/' from dirs.""" - raw = self._git(self.path, ['ls-files', '--others', '--directory', '-i', '--exclude-standard'], - catchout=True) - return set(os.path.normpath(p) for p in raw.split('\n')) - - - def get_remote_status(self): - """Checks the status of the repo regarding sync state with remote branch""" + + def data_status_root(self): + statuses = set() + + # Paths with status + skip = False + output = self._run(['status', '--porcelain', '-z']).rstrip('\x00') + if not output: + return 'sync' + for line in output.split('\x00'): + if skip: + skip = False + continue + statuses.add(self._status_translate(line[:2])) + if line.startswith('R'): + skip = True + + for status in self.DIRSTATUSES: + if status in statuses: + return status + return 'sync' + + def data_status_subpaths(self): + statuses = {} + + # Ignored directories + output = self._run([ + 'ls-files', '-z', '--others', '--directory', '--ignored', '--exclude-standard' + ]).rstrip('\x00') + if output: + for path in output.split('\x00'): + if path.endswith('/'): + statuses[os.path.normpath(path)] = 'ignored' + + # Empty directories + output = self._run( + ['ls-files', '-z', '--others', '--directory', '--exclude-standard']).rstrip('\x00') + if output: + for path in output.split('\x00'): + if path.endswith('/'): + statuses[os.path.normpath(path)] = 'none' + + # Paths with status + output = self._run(['status', '--porcelain', '-z', '--ignored']).rstrip('\x00') + if output: + skip = False + for line in output.split('\x00'): + if skip: + skip = False + continue + statuses[os.path.normpath(line[3:])] = self._status_translate(line[:2]) + if line.startswith('R'): + skip = True + + return statuses + + def data_status_remote(self): try: head = self._head_ref() remote = self._remote_ref(head) except VcsError: head = remote = None + if not head or not remote: + return 'none' + + output = self._run(['rev-list', '--left-right', '{0:s}...{1:s}'.format(remote, head)]) + ahead = re.search(r'^>', output, flags=re.MULTILINE) + behind = re.search(r'^<', output, flags=re.MULTILINE) + if ahead: + return 'diverged' if behind else 'ahead' + else: + return 'behind' if behind else 'sync' - if head and remote: - raw = self._git(self.path, ['rev-list', '--left-right', '%s...%s' % (remote, head)], catchout=True) - ahead = re.search("^>", raw, flags=re.MULTILINE) - behind = re.search("^<", raw, flags=re.MULTILINE) - - if ahead and behind: return "diverged" - elif ahead and not behind: return "ahead" - elif not ahead and behind: return "behind" - elif not ahead and not behind: return "sync" - else: return "none" - - - def get_branch(self): - """Returns the current named branch, if this makes sense for the backend. None otherwise""" + def data_branch(self): try: head = self._head_ref() except VcsError: head = None - - if head: - m = re.match('refs/heads/([^/]*)', head) - if m: return m.group(1).strip() - else: - return "detached" - - return None - - - def get_log(self, filelist=None, maxres=None): - """Get the entire log for the current HEAD""" - if not self._has_head(): return [] - return self._log(refspec=None, maxres=maxres, filelist=filelist) - - - def get_raw_log(self, filelist=None): - """Gets the raw log as a string""" - if not self._has_head(): return [] - args = ['log'] - if filelist: args = args + ['--'] + filelist - return self._git(self.path, args, catchout=True) - - - def get_raw_diff(self, refspec=None, filelist=None): - """Gets the raw diff as a string""" - args = ['diff'] - if refspec: args = args + [refspec] - if filelist: args = args + ['--'] + filelist - return self._git(self.path, args, catchout=True) - - - def get_remote(self): - """Returns the url for the remote repo attached to head""" - if self.is_repo(): - try: - ref = self._head_ref() - remote = self._remote_ref(ref) - except VcsError: - ref = remote = None - - if remote: - m = re.match('refs/remotes/([^/]*)/', remote) - if m: - url = self._git(self.path, ['config', '--get', 'remote.%s.url' % m.group(1)], catchout=True) - return url.strip() or None - return None - - - def get_revision_id(self, rev=None): - """Get a canonical key for the revision rev""" - if rev == None: rev = self.HEAD - elif rev == self.INDEX: return None - rev = self._sanitize_rev(rev) - - return self._sanitize_rev(self._git(self.path, ['rev-parse', rev], catchout=True)) - - - def get_info(self, rev=None): - """Gets info about the given revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - if rev == self.HEAD and not self._has_head(): return None - - L = self._log(refspec=rev) - if len(L) == 0: - raise VcsError("Revision %s does not exist" % rev) - elif len(L) > 1: - raise VcsError("More than one instance of revision %s ?!?" % rev) - else: - return L[0] - - - def get_files(self, rev=None): - """Gets a list of files in revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - - if rev: - if rev == self.INDEX: raw = self._git(self.path, ["ls-files"], catchout=True) - else: raw = self._git(self.path, ['ls-tree', '--name-only', '-r', rev], catchout=True) - return raw.split('\n') + if head is None: + return 'detached' + + match = re.match('refs/heads/([^/]+)', head) + return match.group(1) if match else None + + def data_info(self, rev=None): + if rev is None: + rev = self.HEAD + + log = self._log(refspec=rev) + if not log: + if rev == self.HEAD: + return None + else: + raise VcsError('Revision {0:s} does not exist'.format(rev)) + elif len(log) == 1: + return log[0] else: - return [] - -# vim: expandtab:shiftwidth=4:tabstop=4:softtabstop=4:textwidth=80 + raise VcsError('More than one instance of revision {0:s}'.format(rev)) diff --git a/ranger/ext/vcs/hg.py b/ranger/ext/vcs/hg.py index b8731dbf..cf15e35e 100644 --- a/ranger/ext/vcs/hg.py +++ b/ranger/ext/vcs/hg.py @@ -1,271 +1,135 @@ -# -*- coding: utf-8 -*- # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. -# Author: Abdó Roig-Maranges <abdo.roig@gmail.com>, 2011-2012 -# -# vcs - a python module to handle various version control systems -import os -import re -import shutil +"""Mercurial module""" + from datetime import datetime -try: - from configparser import RawConfigParser -except ImportError: - from ConfigParser import RawConfigParser +import json +import os from .vcs import Vcs, VcsError class Hg(Vcs): - vcsname = 'hg' + """VCS implementation for Mercurial""" HEAD = 'tip' - # Auxiliar stuff - #--------------------------- - - def _hg(self, path, args, silent=True, catchout=False, bytes=False): - return self._vcs(path, 'hg', args, silent=silent, catchout=catchout, bytes=bytes) - - def _has_head(self): - """Checks whether repo has head""" - rnum = self._hg(self.path, ['-q', 'identify', '--num', '-r', self.HEAD], catchout=True) - return rnum != '-1' - - - def _sanitize_rev(self, rev): - if rev == None: return None - rev = rev.strip() - if len(rev) == 0: return None - if rev[-1] == '+': rev = rev[:-1] - - try: - if int(rev) == 0: return None - except: - pass + _status_translations = ( + ('AR', 'staged'), + ('M', 'changed'), + ('!', 'deleted'), - return rev + ('?', 'untracked'), + ('I', 'ignored'), + ) + # Generic def _log(self, refspec=None, maxres=None, filelist=None): - fmt = "changeset: {rev}:{node}\ntag: {tags}\nuser: {author}\ndate: {date}\nsummary: {desc}\n" - args = ['log', '--template', fmt] + args = ['log', '--template', 'json'] + if refspec: + args += ['--limit', '1', '--rev', refspec] + elif maxres: + args += ['--limit', str(maxres)] + if filelist: + args += ['--'] + filelist - if refspec: args = args + ['--limit', '1', '-r', refspec] - elif maxres: args = args + ['--limit', str(maxres)] - - if filelist: args = args + filelist - - raw = self._hg(self.path, args, catchout=True) - L = re.findall('^changeset:\s*([0-9]*):([0-9a-zA-Z]*)\s*$\s*^tag:\s*(.*)\s*$\s*^user:\s*(.*)\s*$\s*^date:\s*(.*)$\s*^summary:\s*(.*)\s*$', raw, re.MULTILINE) + try: + output = self._run(args).rstrip('\n') + except VcsError: + return None + if not output: + return None log = [] - for t in L: - dt = {} - dt['short'] = t[0].strip() - dt['revid'] = self._sanitize_rev(t[1].strip()) - dt['author'] = t[3].strip() - m = re.match('\d+(\.\d+)?', t[4].strip()) - dt['date'] = datetime.fromtimestamp(float(m.group(0))) - dt['summary'] = t[5].strip() - log.append(dt) + for entry in json.loads(output): + new = {} + new['short'] = entry['rev'] + new['revid'] = entry['node'] + new['author'] = entry['user'] + new['date'] = datetime.fromtimestamp(entry['date'][0]) + new['summary'] = entry['desc'] + log.append(new) return log - - def _hg_file_status(self, st): - if len(st) != 1: raise VcsError("Wrong hg file status string: %s" % st) - if st in "ARM": return 'staged' - elif st in "!": return 'deleted' - elif st in "I": return 'ignored' - elif st in "?": return 'untracked' - elif st in "X": return 'conflict' - elif st in "C": return 'sync' - else: return 'unknown' - - - - # Repo creation - #--------------------------- - - def init(self): - """Initializes a repo in current path""" - self._hg(self.path, ['init']) - self.update() - - - def clone(self, src): - """Clones a repo from src""" - name = os.path.basename(self.path) - path = os.path.dirname(self.path) - try: - os.rmdir(self.path) - except OSError: - raise VcsError("Can't clone to %s. It is not an empty directory" % self.path) - - self._hg(path, ['clone', src, name]) - self.update() - - - - # Action Interface - #--------------------------- - - def commit(self, message): - """Commits with a given message""" - self._hg(self.path, ['commit', '-m', message]) - - - def add(self, filelist=None): - """Adds files to the index, preparing for commit""" - if filelist != None: self._hg(self.path, ['addremove'] + filelist) - else: self._hg(self.path, ['addremove']) - - - def reset(self, filelist=None): - """Removes files from the index""" - if filelist == None: filelist = self.get_status_allfiles().keys() - self._hg(self.path, ['forget'] + filelist) - - - def pull(self): - """Pulls a hg repo""" - self._hg(self.path, ['pull', '-u']) - - - def push(self): - """Pushes a hg repo""" - self._hg(self.path, ['push']) - - - def checkout(self, rev): - """Checks out a branch or revision""" - self._hg(self.path, ['update', rev]) - - - def extract_file(self, rev, name, dest): - """Extracts a file from a given revision and stores it in dest dir""" - if rev == self.INDEX: - shutil.copyfile(os.path.join(self.path, name), dest) - else: - self._hg(self.path, ['cat', '--rev', rev, '--output', dest, name]) - - - # Data Interface - #--------------------------- - - def get_status_allfiles(self): - """Returns a dict indexed by files not in sync their status as values. - Paths are given relative to the root. Strips trailing '/' from dirs.""" - raw = self._hg(self.path, ['status'], catchout=True, bytes=True) - L = re.findall('^(.)\s*(.*?)\s*$', raw.decode('utf-8'), re.MULTILINE) - ret = {} - for st, p in L: - # Detect conflict by the existence of .orig files - if st == '?' and re.match('^.*\.orig\s*$', p): st = 'X' - sta = self._hg_file_status(st) - ret[os.path.normpath(p.strip())] = sta - return ret - - - def get_ignore_allfiles(self): - """Returns a set of all the ignored files in the repo""" - raw = self._hg(self.path, ['status', '-i'], catchout=True, bytes=True) - L = re.findall('^I\s*(.*?)\s*$', raw.decode('utf-8'), re.MULTILINE) - return set(L) - - - def get_remote_status(self): - """Checks the status of the repo regarding sync state with remote branch""" - if self.get_remote() == None: - return "none" - - ahead = behind = True + def _remote_url(self): + """Remote url""" try: - self._hg(self.path, ['outgoing'], silent=True) - except: - ahead = False - - try: - self._hg(self.path, ['incoming'], silent=True) - except: - behind = False - - if ahead and behind: return "diverged" - elif ahead and not behind: return "ahead" - elif not ahead and behind: return "behind" - elif not ahead and not behind: return "sync" - - - def get_branch(self): - """Returns the current named branch, if this makes sense for the backend. None otherwise""" - branch = self._hg(self.path, ['branch'], catchout=True) - return branch or None - - - def get_log(self, filelist=None, maxres=None): - """Get the entire log for the current HEAD""" - if not self._has_head(): return [] - return self._log(refspec=None, maxres=maxres, filelist=filelist) - - - def get_raw_log(self, filelist=None): - """Gets the raw log as a string""" - if not self._has_head(): return [] - args = ['log'] - if filelist: args = args + filelist - return self._hg(self.path, args, catchout=True) - - - def get_raw_diff(self, refspec=None, filelist=None): - """Gets the raw diff as a string""" - args = ['diff', '--git'] - if refspec: args = args + [refspec] - if filelist: args = args + filelist - return self._hg(self.path, args, catchout=True) - - - def get_remote(self, rev=None): - """Returns the url for the remote repo attached to head""" - remote = self._hg(self.path, ['showconfig', 'paths.default'], catchout=True) - return remote or None - - - def get_revision_id(self, rev=None): - """Get a canonical key for the revision rev""" - if rev == None: rev = self.HEAD - elif rev == self.INDEX: return None - rev = self._sanitize_rev(rev) - - return self._sanitize_rev(self._hg(self.path, ['-q', 'identify', '--id', '-r', rev], catchout=True)) - - - def get_info(self, rev=None): - """Gets info about the given revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - if rev == self.HEAD and not self._has_head(): return None - - L = self._log(refspec=rev) - if len(L) == 0: - raise VcsError("Revision %s does not exist" % rev) - elif len(L) > 1: - raise VcsError("More than one instance of revision %s ?!?" % rev) + return self._run(['showconfig', 'paths.default']).rstrip('\n') or None + except VcsError: + return None + + def _status_translate(self, code): + """Translate status code""" + for code_x, status in self._status_translations: # pylint: disable=invalid-name + if code in code_x: + return status + return 'unknown' + + # Action interface + + def action_add(self, filelist=None): + args = ['add'] + if filelist: + args += ['--'] + filelist + self._run(args, catchout=False) + + def action_reset(self, filelist=None): + args = ['forget', '--'] + if filelist: + args += filelist else: - return L[0] - - - def get_files(self, rev=None): - """Gets a list of files in revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - - if rev: - if rev == self.INDEX: raw = self._hg(self.path, ['locate', "*"], catchout=True) - else: raw = self._hg(self.path, ['locate', '--rev', rev, "*"], catchout=True) - return raw.split('\n') + args += self.rootvcs.status_subpaths.keys() + self._run(args, catchout=False) + + # Data interface + + def data_status_root(self): + statuses = set() + + # Paths with status + for entry in json.loads(self._run(['status', '--all', '--template', 'json'])): + if entry['status'] == 'C': + continue + statuses.add(self._status_translate(entry['status'])) + + if statuses: + for status in self.DIRSTATUSES: + if status in statuses: + return status + return 'sync' + + def data_status_subpaths(self): + statuses = {} + + # Paths with status + for entry in json.loads(self._run(['status', '--all', '--template', 'json'])): + if entry['status'] == 'C': + continue + statuses[os.path.normpath(entry['path'])] = self._status_translate(entry['status']) + + return statuses + + def data_status_remote(self): + if self._remote_url() is None: + return 'none' + return 'unknown' + + def data_branch(self): + return self._run(['branch']).rstrip('\n') or None + + def data_info(self, rev=None): + if rev is None: + rev = self.HEAD + + log = self._log(refspec=rev) + if not log: + if rev == self.HEAD: + return None + else: + raise VcsError('Revision {0:s} does not exist'.format(rev)) + elif len(log) == 1: + return log[0] else: - return [] - - -# vim: expandtab:shiftwidth=4:tabstop=4:softtabstop=4:textwidth=80 + raise VcsError('More than one instance of revision {0:s}'.format(rev)) diff --git a/ranger/ext/vcs/svn.py b/ranger/ext/vcs/svn.py index 9bf8698c..1813f857 100644 --- a/ranger/ext/vcs/svn.py +++ b/ranger/ext/vcs/svn.py @@ -1,274 +1,148 @@ -# -*- coding: utf-8 -*- # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. -# Author: Abdó Roig-Maranges <abdo.roig@gmail.com>, 2011-2012 -# Ryan Burns <rdburns@gmail.com>, 2015 -# -# R. Burns start with Abdó's Hg module and modified it for Subversion. -# -# vcs - a python module to handle various version control systems -from __future__ import with_statement -import os -import re -import shutil -import logging +"""Subversion module""" + from datetime import datetime +import os +from xml.etree import ElementTree as etree + from .vcs import Vcs, VcsError -#logging.basicConfig(filename='rangersvn.log',level=logging.DEBUG, -# filemode='w') class SVN(Vcs): - vcsname = 'svn' - HEAD = 'HEAD' - # Auxiliar stuff - #--------------------------- - - def _svn(self, path, args, silent=True, catchout=False, bytes=False): - return self._vcs(path, 'svn', args, silent=silent, catchout=catchout, bytes=bytes) - - - def _has_head(self): - """Checks whether repo has head""" - return True - - - def _sanitize_rev(self, rev): - if rev == None: return None - rev = rev.strip() - if len(rev) == 0: return None - if rev[-1] == '+': rev = rev[:-1] - - try: - if int(rev) == 0: return None - except: - pass - - return rev - + """VCS implementation for Subversion""" + # Generic + _status_translations = ( + ('ADR', 'staged'), + ('C', 'conflict'), + ('I', 'ignored'), + ('M~', 'changed'), + ('X', 'none'), + ('?', 'untracked'), + ('!', 'deleted'), + ) def _log(self, refspec=None, maxres=None, filelist=None): - """ Retrieves log message and parses it. - """ - args = ['log'] + """Retrieves log message and parses it""" + args = ['log', '--xml'] - if refspec: args = args + ['--limit', '1', '-r', refspec] - elif maxres: args = args + ['--limit', str(maxres)] + if refspec: + args += ['--limit', '1', '--revision', refspec] + elif maxres: + args += ['--limit', str(maxres)] - if filelist: args = args + filelist - logging.debug('Running svn log') - logging.debug(args) + if filelist: + args += ['--'] + filelist - raw = self._svn(self.path, args, catchout=True) - logging.debug(raw) - L = re.findall(r"""^[-]*\s* # Dash line - r([0-9]*)\s\|\s # Revision [0] - (.*)\s\|\s # Author [1] - (.*?)\s # Date [2] - (.*?)\s # Time [3] - .*?\|\s*? # Dump rest of date string - ([0-9])+\sline(?:s)?\s* # Number of line(s) [4] - (.*)\s # Log Message [5] - [-]+\s* # Dash line - $""", raw, re.MULTILINE | re.VERBOSE) + try: + output = self._run(args).rstrip('\n') + except VcsError: + return None + if not output: + return None log = [] - for t in L: - logging.debug(t) - dt = {} - dt['short'] = t[0].strip() - dt['revid'] = t[0].strip() - dt['author'] = t[1].strip() - dt['date'] = datetime.strptime(t[2]+'T'+t[3], "%Y-%m-%dT%H:%M:%S") - dt['summary'] = t[5].strip() - log.append(dt) - logging.debug(log) + for entry in etree.fromstring(output).findall('./logentry'): + new = {} + new['short'] = entry.get('revision') + new['revid'] = entry.get('revision') + new['author'] = entry.find('./author').text + new['date'] = datetime.strptime( + entry.find('./date').text, + '%Y-%m-%dT%H:%M:%S.%fZ', + ) + new['summary'] = entry.find('./msg').text.split('\n')[0] + log.append(new) return log + def _status_translate(self, code): + """Translate status code""" + for code_x, status in self._status_translations: + if code in code_x: + return status + return 'unknown' - def _svn_file_status(self, st): - if len(st) != 1: raise VcsError("Wrong hg file status string: %s" % st) - if st in "A": return 'staged' - elif st in "D": return 'deleted' - elif st in "I": return 'ignored' - elif st in "?": return 'untracked' - elif st in "C": return 'conflict' - else: return 'unknown' - - - - # Repo creation - #--------------------------- - - def init(self): - """Initializes a repo in current path""" - self._svn(self.path, ['init']) - self.update() - - - def clone(self, src): - """Checks out SVN repo""" - name = os.path.basename(self.path) - path = os.path.dirname(self.path) + def _remote_url(self): + """Remote url""" try: - os.rmdir(self.path) - except OSError: - raise VcsError("Can't clone to %s. It is not an empty directory" % self.path) - - self._svn(path, ['co', src, name]) - self.update() - - + output = self._run(['info', '--xml']).rstrip('\n') + except VcsError: + return None + if not output: + return None + return etree.fromstring(output).find('./entry/url').text or None # Action Interface - #--------------------------- - - def commit(self, message): - """Commits with a given message""" - self._svn(self.path, ['commit', '-m', message]) - - - def add(self, filelist=None): - """Adds files to the index, preparing for commit""" - if filelist != None: self._svn(self.path, ['add'] + filelist) - else: self._svn(self.path, ['add']) - - - def reset(self, filelist=None): - """Equivalent to svn revert""" - if filelist == None: filelist = self.get_status_allfiles().keys() - self._svn(self.path, ['revert'] + filelist) + def action_add(self, filelist=None): + args = ['add'] + if filelist: + args += ['--'] + filelist + self._run(args, catchout=False) - def pull(self): - """Executes SVN Update""" - self._svn(self.path, ['update']) - - - def push(self): - """Push doesn't have an SVN analog.""" - raise NotImplementedError - - - def checkout(self, rev): - """Checks out a branch or revision""" - raise NotImplementedError - self._svn(self.path, ['update', rev]) - - - def extract_file(self, rev, name, dest): - """Extracts a file from a given revision and stores it in dest dir""" - if rev == self.INDEX: - shutil.copyfile(os.path.join(self.path, name), dest) + def action_reset(self, filelist=None): + args = ['revert', '--'] + if filelist: + args += filelist else: - file_contents = self._svn(self.path, ['cat', '-r', rev, name], catchout=True) - with open(dest, 'w') as f: - f.write(file_contents) - + args += self.rootvcs.status_subpaths.keys() + self._run(args, catchout=False) # Data Interface - #--------------------------- - def get_status_allfiles(self): - """Returns a dict indexed by files not in sync their status as values. - Paths are given relative to the root. Strips trailing '/' from dirs.""" - raw = self._svn(self.path, ['status'], catchout=True, bytes=True) -# logging.debug(raw) - L = re.findall(r'^(.)\s*(.*?)\s*$', raw.decode('utf-8'), re.MULTILINE) - ret = {} - for st, p in L: - # Detect conflict by the existence of .orig files - if st == '?' and re.match(r'^.*\.orig\s*$', p): st = 'X' - sta = self._svn_file_status(st) - ret[os.path.normpath(p.strip())] = sta - return ret - - - def get_ignore_allfiles(self): - """Returns a set of all the ignored files in the repo""" - raw = self._svn(self.path, ['status'], catchout=True, bytes=True) -# logging.debug(raw) - L = re.findall(r'^I\s*(.*?)\s*$', raw.decode('utf-8'), re.MULTILINE) - return set(L) - - - def get_remote_status(self): - """Checks the status of the repo regarding sync state with remote branch. - - I'm not sure this make sense for SVN so we're just going to return 'sync'""" + def data_status_root(self): + statuses = set() + + # Paths with status + output = self._run(['status']).rstrip('\n') + if not output: + return 'sync' + for line in output.split('\n'): + code = line[0] + if code == ' ': + continue + statuses.add(self._status_translate(code)) + + for status in self.DIRSTATUSES: + if status in statuses: + return status return 'sync' - def get_branch(self): - """Returns the current named branch, if this makes sense for the backend. None otherwise""" - return None - branch = self._svn(self.path, ['branch'], catchout=True) - return branch or None - + def data_status_subpaths(self): + statuses = {} - def get_log(self, filelist=None, maxres=None): - """Get the entire log for the current HEAD""" - if not self._has_head(): return [] - return self._log(refspec=None, maxres=maxres, filelist=filelist) + # Paths with status + output = self._run(['status']).rstrip('\n') + if output: + for line in output.split('\n'): + code, path = line[0], line[8:] + if code == ' ': + continue + statuses[os.path.normpath(path)] = self._status_translate(code) + return statuses - def get_raw_log(self, filelist=None): - """Gets the raw log as a string""" - if not self._has_head(): return [] - args = ['log'] - if filelist: args = args + filelist - return self._svn(self.path, args, catchout=True) - - - def get_raw_diff(self, refspec=None, filelist=None): - """Gets the raw diff as a string""" - args = ['diff', '--git'] - if refspec: args = args + [refspec] - if filelist: args = args + filelist - return self._svn(self.path, args, catchout=True) - - - def get_remote(self, rev=None): - """Returns the url for the remote repo attached to head""" - raw = self.get_info(rev=rev) - L = re.findall('URL: (.*)\n', raw.decode('utf-8')) - - return L[0][0] - - - def get_revision_id(self, rev=None): - """Get a canonical key for the revision rev. - - This is just returning the rev for SVN""" - if rev == None: rev = self.HEAD - elif rev == self.INDEX: return None - rev = self._sanitize_rev(rev) - return rev + def data_status_remote(self): + remote = self._remote_url() + if remote is None or remote.startswith('file://'): + return 'none' + return 'unknown' + def data_branch(self): + return None - def get_info(self, rev=None): - """Gets info about the given revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - if rev == self.HEAD and not self._has_head(): return None - logging.debug('refspec is ' + str(rev)) - L = self._log(refspec=rev) - logging.debug(len(L)) - if len(L) == 0: - raise VcsError("Revision %s does not exist" % rev) - elif len(L) > 1: - raise VcsError("More than one instance of revision %s ?!?" % rev) + def data_info(self, rev=None): + if rev is None: + rev = self.HEAD + + log = self._log(refspec=rev) + if not log: + if rev == self.HEAD: + return None + else: + raise VcsError('Revision {0:s} does not exist'.format(rev)) + elif len(log) == 1: + return log[0] else: - return L[0] - - - def get_files(self, rev=None): - """Gets a list of files in revision rev""" - if rev == None: rev = self.HEAD - rev = self._sanitize_rev(rev) - - raw = self._svn(self.path, ['ls', "-r", rev], catchout=True) - return raw.split('\n') - - -# vim: expandtab:shiftwidth=4:tabstop=4:softtabstop=4:textwidth=80 + raise VcsError('More than one instance of revision {0:s}'.format(rev)) diff --git a/ranger/ext/vcs/vcs.py b/ranger/ext/vcs/vcs.py index 3f7236f5..fd34c3fe 100644 --- a/ranger/ext/vcs/vcs.py +++ b/ranger/ext/vcs/vcs.py @@ -1,348 +1,488 @@ -# -*- coding: utf-8 -*- # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. -# Author: Abdó Roig-Maranges <abdo.roig@gmail.com>, 2011-2012 -# -# vcs - a python module to handle various version control systems + +"""VCS module""" import os import subprocess -from datetime import datetime +import threading +import time + +# Python2 compatibility +try: + import queue +except ImportError: + import Queue as queue # pylint: disable=import-error +try: + FileNotFoundError +except NameError: + FileNotFoundError = OSError # pylint: disable=redefined-builtin class VcsError(Exception): + """VCS exception""" pass -class Vcs(object): - """ This class represents a version controlled path, abstracting the usual - operations from the different supported backends. - - The backends are declared in te variable self.repo_types, and are derived - classes from Vcs with the following restrictions: +class Vcs(object): # pylint: disable=too-many-instance-attributes + """ + This class represents a version controlled path, abstracting the usual + operations from the different supported backends. - * do NOT implement __init__. Vcs takes care of this. + The backends are declared in REPOTYPES, and are derived + classes from Vcs with the following restrictions: - * do not create change internal state. All internal state should be - handled in Vcs + * Override ALL interface methods + * Only override interface methods + * Do NOT modify internal state. All internal state is handled by Vcs - Objects from backend classes should never be created directly. Instead - create objects of Vcs class. The initialization calls update, which takes - care of detecting the right Vcs backend to use and dynamically changes the - object type accordingly. - """ + """ - # These are abstracted revs, representing the current index (staged files), + # These are abstracted revisions, representing the current index (staged files), # the current head and nothing. Every backend should redefine them if the # version control has a similar concept, or implement _sanitize_rev method to # clean the rev before using them - INDEX = "INDEX" - HEAD = "HEAD" - NONE = "NONE" - vcsname = None - - # Possible status responses - FILE_STATUS = ['conflict', 'untracked', 'deleted', 'changed', 'staged', 'ignored', 'sync', 'none', 'unknown'] - REMOTE_STATUS = ['none', 'sync', 'behind', 'ahead', 'diverged', 'unknown'] - - - def __init__(self, path, vcstype=None): - # This is a bit hackish, but I need to import here to avoid circular imports - from .git import Git - from .hg import Hg - from .bzr import Bzr - from .svn import SVN - self.repo_types = {'git': Git, 'hg': Hg, 'bzr': Bzr, 'svn': SVN} - - self.path = os.path.expanduser(path) - self.status = {} - self.ignored = set() - self.root = None - - self.update(vcstype=vcstype) - - - # Auxiliar - #--------------------------- - - def _vcs(self, path, cmd, args, silent=False, catchout=False, bytes=False): - """Executes a vcs command""" - with open('/dev/null', 'w') as devnull: - if silent: out=devnull - else: out=None + INDEX = 'INDEX' + HEAD = 'HEAD' + NONE = 'NONE' + + # Backends + REPOTYPES = { + 'bzr': {'class': 'Bzr', 'setting': 'vcs_backend_bzr'}, + 'git': {'class': 'Git', 'setting': 'vcs_backend_git'}, + 'hg': {'class': 'Hg', 'setting': 'vcs_backend_hg'}, + 'svn': {'class': 'SVN', 'setting': 'vcs_backend_svn'}, + } + + # Possible directory statuses in order of importance + # statuses that should not be inherited from subpaths are disabled + DIRSTATUSES = ( + 'conflict', + 'untracked', + 'deleted', + 'changed', + 'staged', + # 'ignored', + 'sync', + # 'none', + 'unknown', + ) + + def __init__(self, dirobj): + self.obj = dirobj + self.path = dirobj.path + self.repotypes_settings = set( + repotype for repotype, values in self.REPOTYPES.items() + if getattr(dirobj.settings, values['setting']) in ('enabled', 'local') + ) + + self.root, self.repodir, self.repotype, self.links = self._find_root(self.path) + self.is_root = True if self.obj.path == self.root else False + self.is_root_link = True if self.obj.is_link and self.obj.realpath == self.root else False + self.is_root_pointer = self.is_root or self.is_root_link + self.in_repodir = False + self.rootvcs = None + self.track = False + + if self.root: + if self.is_root: + self.rootvcs = self + self.__class__ = globals()[self.REPOTYPES[self.repotype]['class'] + 'Root'] + + if not os.access(self.repodir, os.R_OK): + self.obj.vcsremotestatus = 'unknown' + self.obj.vcsstatus = 'unknown' + return + + self.track = True + else: + self.rootvcs = dirobj.fm.get_directory(self.root).vcs + if self.rootvcs is None or self.rootvcs.root is None: + return + self.rootvcs.links |= self.links + self.__class__ = globals()[self.REPOTYPES[self.repotype]['class']] + self.track = self.rootvcs.track + + if self.path == self.repodir or self.path.startswith(self.repodir + '/'): + self.in_repodir = True + self.track = False + + # Generic + + def _run(self, args, path=None, catchout=True, retbytes=False): + """Run a command""" + cmd = [self.repotype] + args + if path is None: + path = self.path + + with open(os.devnull, 'w') as devnull: try: if catchout: - raw = subprocess.check_output([cmd] + args, stderr=out, cwd=path) - if bytes: return raw - else: return raw.decode('utf-8', errors="ignore").strip() - else: - subprocess.check_call([cmd] + args, stderr=out, stdout=out, cwd=path) - except subprocess.CalledProcessError: - raise VcsError("%s error on %s. Command: %s" % (cmd, path, ' '.join([cmd] + args))) - - - def _path_contains(self, parent, path): - """Checks wether path is an object belonging to the subtree in parent""" - if parent == path: return True - parent = os.path.normpath(parent + '/') - path = os.path.normpath(path) - return os.path.commonprefix([parent, path]) == parent - - - # Object manipulation - #--------------------------- - # This may be a little hacky, but very useful. An instance of Vcs class changes its own class - # when calling update(), to match the right repo type. I can have the same object adapt to - # the path repo type, if this ever changes! - - def get_repo_type(self, path): - """Returns the right repo type for path. None if no repo present in path""" - for rn, rt in self.repo_types.items(): - if path and os.path.exists(os.path.join(path, '.%s' % rn)): return rt - return None - - - def get_root(self, path): - """Finds the repository root path. Otherwise returns none""" - curpath = os.path.abspath(path) - while curpath != '/': - if self.get_repo_type(curpath): return curpath - else: curpath = os.path.dirname(curpath) - return None - - - def update(self, vcstype=None): - """Updates the repo instance. Re-checks the repo and changes object class if repo type changes - If vcstype is given, uses that repo type, without autodetection""" - if os.path.exists(self.path): - self.root = self.get_root(self.path) - if vcstype: - if vcstype in self.repo_types: - ty = self.repo_types[vcstype] + output = subprocess.check_output(cmd, cwd=path, stderr=devnull) + return output if retbytes else output.decode('UTF-8') else: - raise VcsError("Unrecognized repo type %s" % vcstype) - else: - ty = self.get_repo_type(self.root) - if ty: - self.__class__ = ty - return - - self.__class__ = Vcs - - - # Repo creation - #--------------------------- - - def init(self, repotype): - """Initializes a repo in current path""" - if not repotype in self.repo_types: - raise VcsError("Unrecognized repo type %s" % repotype) - - if not os.path.exists(self.path): os.makedirs(self.path) - rt = self.repo_types[repotype] - try: - self.__class__ = rt - self.init() - except: - self.__class__ = Vcs - raise - - - def clone(self, repotype, src): - """Clones a repo from src""" - if not repotype in self.repo_types: - raise VcsError("Unrecognized repo type %s" % repotype) - - if not os.path.exists(self.path): os.makedirs(self.path) - rt = self.repo_types[repotype] - try: - self.__class__ = rt - self.clone(src) - except: - self.__class__ = Vcs - raise - + subprocess.check_call(cmd, cwd=path, stdout=devnull, stderr=devnull) + except (subprocess.CalledProcessError, FileNotFoundError): + raise VcsError('{0:s}: {1:s}'.format(str(cmd), path)) + + def _get_repotype(self, path): + """Get type for path""" + for repotype in self.repotypes_settings: + repodir = os.path.join(path, '.' + repotype) + if os.path.exists(repodir): + return (repodir, repotype) + return (None, None) + + def _find_root(self, path): + """Finds root path""" + links = set() + while True: + if os.path.islink(path): + links.add(path) + relpath = os.path.relpath(self.path, path) + path = os.path.realpath(path) + self.path = os.path.normpath(os.path.join(path, relpath)) + + repodir, repotype = self._get_repotype(path) + if repodir: + return (path, repodir, repotype, links) + + path_old = path + path = os.path.dirname(path) + if path == path_old: + break + + return (None, None, None, None) + + def reinit(self): + """Reinit""" + if not self.in_repodir: + if not self.track \ + or (not self.is_root_pointer and self._get_repotype(self.obj.realpath)[0]) \ + or not os.path.exists(self.repodir): + self.__init__(self.obj) # Action interface - #--------------------------- - - def commit(self, message): - """Commits with a given message""" - raise NotImplementedError - - def add(self, filelist): - """Adds files to the index, preparing for commit""" + def action_add(self, filelist): + """Adds files to the index""" raise NotImplementedError - - def reset(self, filelist): + def action_reset(self, filelist): """Removes files from the index""" raise NotImplementedError + # Data interface - def pull(self): - """Pulls from remote""" - raise NotImplementedError - - - def push(self): - """Pushes to remote""" - raise NotImplementedError - - - def checkout(self, rev): - """Checks out a branch or revision""" - raise NotImplementedError - - - def extract_file(self, rev, name, dest): - """Extracts a file from a given revision and stores it in dest dir""" - raise NotImplementedError - - - # Data - #--------------------------- - - def is_repo(self): - """Checks wether there is an initialized repo in self.path""" - return self.path and os.path.exists(self.path) and self.root != None - - - def is_tracking(self): - """Checks whether HEAD is tracking a remote repo""" - return self.get_remote(self.HEAD) != None - - - def get_file_status(self, path): - """Returns the status for a given path regarding the repo""" - - # if path is relative, join it with root. otherwise do nothing - path = os.path.join(self.root, path) - - # path is not in the repo - if not self._path_contains(self.root, path): - return "none" - - # check if prel or some parent of prel is ignored - prel = os.path.relpath(path, self.root) - while len(prel) > 0 and prel != '/' and prel != '.': - if prel in self.ignored: return "ignored" - prel, tail = os.path.split(prel) - - # check if prel or some parent of prel is listed in status - prel = os.path.relpath(path, self.root) - while len(prel) > 0 and prel != '/' and prel != '.': - if prel in self.status: return self.status[prel] - prel, tail = os.path.split(prel) - - # check if prel is a directory that contains some file in status - prel = os.path.relpath(path, self.root) - if os.path.isdir(path): - sts = set(st for p, st in self.status.items() - if self._path_contains(path, os.path.join(self.root, p))) - for st in self.FILE_STATUS: - if st in sts: return st - - # it seems prel is in sync - return "sync" - - - def get_status(self, path=None): - """Returns a dict with changed files under path and their status. - If path is None, returns all changed files""" - - self.status = self.get_status_allfiles() - self.ignored = self.get_ignore_allfiles() - if path: - path = os.path.join(self.root, path) - if os.path.commonprefix([self.root, path]) == self.root: - return dict((p, st) for p, st in self.status.items() if self._path_contains(path, os.path.join(self.root, p))) - else: - return {} - else: - return self.status - - - def get_status_allfiles(self): - """Returns a dict indexed by files not in sync their status as values. - Paths are given relative to the root. Strips trailing '/' from dirs.""" + def data_status_root(self): + """Returns status of self.root cheaply""" raise NotImplementedError - - def get_ignore_allfiles(self): - """Returns a set of all the ignored files in the repo. Strips trailing '/' from dirs.""" + def data_status_subpaths(self): + """ + Returns a dict indexed by subpaths not in sync with their status as values. + Paths are given relative to self.root + """ raise NotImplementedError - - def get_remote_status(self): - """Checks the status of the entire repo""" + def data_status_remote(self): + """ + Returns remote status of repository + One of ('sync', 'ahead', 'behind', 'diverged', 'none') + """ raise NotImplementedError - - def get_branch(self): + def data_branch(self): """Returns the current named branch, if this makes sense for the backend. None otherwise""" raise NotImplementedError - - def get_log(self): - """Get the entire log for the current HEAD""" - raise NotImplementedError - - - def get_raw_log(self, filelist=None): - """Gets the raw log as a string""" - raise NotImplementedError - - - def get_raw_diff(self, refspec=None, filelist=None): - """Gets the raw diff as a string""" - raise NotImplementedError - - - def get_remote(self): - """Returns the url for the remote repo attached to head""" - raise NotImplementedError - - - def get_revision_id(self, rev=None): - """Get a canonical key for the revision rev""" + def data_info(self, rev=None): + """Returns info string about revision rev. None in special cases""" raise NotImplementedError - def get_info(self, rev=None): - """Gets info about the given revision rev""" - raise NotImplementedError +class VcsRoot(Vcs): # pylint: disable=abstract-method + """Vcs root""" + rootinit = False + head = None + branch = None + updatetime = None + status_subpaths = None + def _status_root(self): + """Returns root status""" + if self.status_subpaths is None: + return 'none' - def get_files(self, rev=None): - """Gets a list of files in revision rev""" - raise NotImplementedError + statuses = set(status for path, status in self.status_subpaths.items()) + for status in self.DIRSTATUSES: + if status in statuses: + return status + return 'sync' + def init_root(self): + """Initialize root cheaply""" + try: + self.head = self.data_info(self.HEAD) + self.branch = self.data_branch() + self.obj.vcsremotestatus = self.data_status_remote() + self.obj.vcsstatus = self.data_status_root() + except VcsError: + return False + self.rootinit = True + return True + + def update_root(self): + """Update root state""" + try: + self.head = self.data_info(self.HEAD) + self.branch = self.data_branch() + self.status_subpaths = self.data_status_subpaths() + self.obj.vcsremotestatus = self.data_status_remote() + self.obj.vcsstatus = self._status_root() + except VcsError: + return False + self.rootinit = True + self.updatetime = time.time() + return True + + def _update_walk(self, path, purge): # pylint: disable=too-many-branches + """Update walk""" + for wroot, wdirs, _ in os.walk(path): + # Only update loaded directories + try: + wrootobj = self.obj.fm.directories[wroot] + except KeyError: + wdirs[:] = [] + continue + if not wrootobj.vcs.track: + wdirs[:] = [] + continue + + if wrootobj.content_loaded: + has_vcschild = False + for fsobj in wrootobj.files_all: + if purge: + if fsobj.is_directory: + fsobj.vcsstatus = None + fsobj.vcs.__init__(fsobj) + else: + fsobj.vcsstatus = None + continue + + if fsobj.is_directory: + fsobj.vcs.reinit() + if not fsobj.vcs.track: + continue + if fsobj.vcs.is_root_pointer: + has_vcschild = True + else: + fsobj.vcsstatus = self.status_subpath( + os.path.join(wrootobj.realpath, fsobj.basename), + is_directory=True, + ) + else: + fsobj.vcsstatus = self.status_subpath( + os.path.join(wrootobj.realpath, fsobj.basename)) + wrootobj.has_vcschild = has_vcschild + + # Remove dead directories + for wdir in list(wdirs): + try: + wdirobj = self.obj.fm.directories[os.path.join(wroot, wdir)] + except KeyError: + wdirs.remove(wdir) + continue + if not wdirobj.vcs.track or wdirobj.vcs.is_root_pointer: + wdirs.remove(wdir) + + def update_tree(self, purge=False): + """Update tree state""" + self._update_walk(self.path, purge) + for path in list(self.links): + self._update_walk(path, purge) + try: + dirobj = self.obj.fm.directories[path] + except KeyError: + self.links.remove(path) + continue + if purge: + dirobj.vcsstatus = None + dirobj.vcs.__init__(dirobj) + elif dirobj.vcs.path == self.path: + dirobj.vcsremotestatus = self.obj.vcsremotestatus + dirobj.vcsstatus = self.obj.vcsstatus + if purge: + self.__init__(self.obj) + + def check_outdated(self): + """Check if root is outdated""" + if self.updatetime is None: + return True + + for wroot, wdirs, _ in os.walk(self.path): + wrootobj = self.obj.fm.get_directory(wroot) + wrootobj.load_if_outdated() + if wroot != self.path and wrootobj.vcs.is_root_pointer: + wdirs[:] = [] + continue + + if wrootobj.stat and self.updatetime < wrootobj.stat.st_mtime: + return True + if wrootobj.files_all: + for wfile in wrootobj.files_all: + if wfile.stat and self.updatetime < wfile.stat.st_mtime: + return True + return False + + def status_subpath(self, path, is_directory=False): + """ + Returns the status of path + path needs to be self.obj.path or subpath thereof + """ + if self.status_subpaths is None: + return 'none' + + relpath = os.path.relpath(path, self.path) + + # check if relpath or its parents has a status + tmppath = relpath + while tmppath: + if tmppath in self.status_subpaths: + return self.status_subpaths[tmppath] + tmppath = os.path.dirname(tmppath) + + # check if path contains some file in status + if is_directory: + statuses = set(status for subpath, status in self.status_subpaths.items() + if subpath.startswith(relpath + '/')) + for status in self.DIRSTATUSES: + if status in statuses: + return status + return 'sync' + + +class VcsThread(threading.Thread): # pylint: disable=too-many-instance-attributes + """VCS thread""" + def __init__(self, ui): + super(VcsThread, self).__init__() + self.daemon = True + self.ui = ui # pylint: disable=invalid-name + self.queue = queue.Queue() + self.awoken = threading.Event() + self.timestamp = time.time() + self.redraw = False + self.roots = set() + + def _is_targeted(self, dirobj): + """Check if dirobj is targeted""" + if self.ui.browser.main_column and self.ui.browser.main_column.target == dirobj: + return True + return False + + def _update_subroots(self, fsobjs): + """Update subroots""" + if not fsobjs: + return False + + has_vcschild = False + for fsobj in fsobjs: + if not fsobj.is_directory or not fsobj.vcs or not fsobj.vcs.track: + continue + + rootvcs = fsobj.vcs.rootvcs + if fsobj.vcs.is_root_pointer: + has_vcschild = True + if not rootvcs.rootinit and not self._is_targeted(rootvcs.obj): + self.roots.add(rootvcs.path) + if not rootvcs.init_root(): + rootvcs.update_tree(purge=True) + self.redraw = True + if fsobj.is_link: + fsobj.vcsstatus = rootvcs.obj.vcsstatus + fsobj.vcsremotestatus = rootvcs.obj.vcsremotestatus + self.redraw = True + + return has_vcschild + + def _queue_process(self): # pylint: disable=too-many-branches + """Process queue""" + dirobjs = [] + paths = set() + self.roots.clear() + + while True: + try: + dirobjs.append(self.queue.get(block=False)) + except queue.Empty: + break + + for dirobj in dirobjs: + if dirobj.path in paths: + continue + paths.add(dirobj.path) + + dirobj.vcs.reinit() + if dirobj.vcs.track: + rootvcs = dirobj.vcs.rootvcs + if rootvcs.path not in self.roots and rootvcs.check_outdated(): + self.roots.add(rootvcs.path) + if rootvcs.update_root(): + rootvcs.update_tree() + else: + rootvcs.update_tree(purge=True) + self.redraw = True + + has_vcschild = self._update_subroots(dirobj.files_all) + + if dirobj.has_vcschild != has_vcschild: + dirobj.has_vcschild = has_vcschild + self.redraw = True + + def run(self): + while True: + self.awoken.wait() + self.awoken.clear() + + self._queue_process() + + if self.redraw: + self.redraw = False + for column in self.ui.browser.columns: + if column.target and column.target.is_directory: + column.need_redraw = True + self.ui.status.need_redraw = True + self.ui.redraw() + + def wakeup(self, dirobj): + """Wakeup thread""" + self.queue.put(dirobj) + self.awoken.set() + + +# Backend imports +from .bzr import Bzr # NOQA pylint: disable=wrong-import-position +from .git import Git # NOQA pylint: disable=wrong-import-position +from .hg import Hg # NOQA pylint: disable=wrong-import-position +from .svn import SVN # NOQA pylint: disable=wrong-import-position + + +class BzrRoot(VcsRoot, Bzr): + """Bzr root""" + pass - # I / O - #--------------------------- - def print_log(self, fmt): - log = self.log() - if fmt == "compact": - for dt in log: - print(self.format_revision_compact(dt)) - else: - raise Exception("Unknown format %s" % fmt) +class GitRoot(VcsRoot, Git): + """Git root""" + pass - def format_revision_compact(self, dt): - return "{0:<10}{1:<20}{2}".format(dt['revshort'], - dt['date'].strftime('%a %b %d, %Y'), - dt['summary']) +class HgRoot(VcsRoot, Hg): + """Hg root""" + pass - def format_revision_text(self, dt): - L = ["revision: %s:%s" % (dt['revshort'], dt['revhash']), - "date: %s" % dt['date'].strftime('%a %b %d, %Y'), - "time: %s" % dt['date'].strftime('%H:%M'), - "user: %s" % dt['author'], - "description: %s" % dt['summary']] - return '\n'.join(L) +class SVNRoot(VcsRoot, SVN): + """SVN root""" + pass diff --git a/ranger/gui/context.py b/ranger/gui/context.py index e5aef06c..ac597e5a 100644 --- a/ranger/gui/context.py +++ b/ranger/gui/context.py @@ -17,9 +17,9 @@ CONTEXT_KEYS = ['reset', 'error', 'badinfo', 'title', 'text', 'highlight', 'bars', 'quotes', 'tab', 'loaded', 'keybuffer', 'infostring', - 'vcsfile', 'vcsremote', 'vcsinfo', 'vcscommit', + 'vcsfile', 'vcsremote', 'vcsinfo', 'vcscommit', 'vcsdate', 'vcsconflict', 'vcschanged', 'vcsunknown', 'vcsignored', - 'vcsstaged', 'vcssync', 'vcsbehind', 'vcsahead', 'vcsdiverged'] + 'vcsstaged', 'vcssync', 'vcsnone', 'vcsbehind', 'vcsahead', 'vcsdiverged'] class Context(object): def __init__(self, keys): diff --git a/ranger/gui/ui.py b/ranger/gui/ui.py index 2eacbc4d..41b57c2c 100644 --- a/ranger/gui/ui.py +++ b/ranger/gui/ui.py @@ -5,10 +5,12 @@ import os import sys import curses import _curses +import threading from .displayable import DisplayableContainer from .mouse_event import MouseEvent from ranger.ext.keybinding_parser import KeyBuffer, KeyMaps, ALT_KEY +from ranger.ext.lazy_property import lazy_property MOUSEMASK = curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION @@ -40,6 +42,8 @@ class UI(DisplayableContainer): def __init__(self, env=None, fm=None): self.keybuffer = KeyBuffer() self.keymaps = KeyMaps(self.keybuffer) + self.redrawlock = threading.Event() + self.redrawlock.set() if fm is not None: self.fm = fm @@ -248,8 +252,18 @@ class UI(DisplayableContainer): self.pager.visible = False self.add_child(self.pager) + @lazy_property + def vcsthread(self): + """VCS thread""" + from ranger.ext.vcs import VcsThread + thread = VcsThread(self) + thread.start() + return thread + def redraw(self): """Redraw all widgets""" + self.redrawlock.wait() + self.redrawlock.clear() self.poke() # determine which widgets are shown @@ -264,6 +278,7 @@ class UI(DisplayableContainer): self.draw() self.finalize() + self.redrawlock.set() def redraw_window(self): """Redraw the window. This only calls self.win.redrawwin().""" diff --git a/ranger/gui/widgets/__init__.py b/ranger/gui/widgets/__init__.py index bd0f2337..f61e18eb 100644 --- a/ranger/gui/widgets/__init__.py +++ b/ranger/gui/widgets/__init__.py @@ -5,21 +5,25 @@ from ranger.gui.displayable import Displayable class Widget(Displayable): """A class for classification of widgets.""" - vcsfilestatus_symb = {'conflict': ('X', ["vcsconflict"]), - 'untracked': ('+', ["vcschanged"]), - 'deleted': ('-', ["vcschanged"]), - 'changed': ('*', ["vcschanged"]), - 'staged': ('*', ["vcsstaged"]), - 'ignored': ('·', ["vcsignored"]), - 'sync': ('√', ["vcssync"]), - 'none': (' ', []), - 'unknown': ('?', ["vcsunknown"])} + vcsstatus_symb = { + 'conflict': ('X', ['vcsconflict']), + 'untracked': ('+', ['vcschanged']), + 'deleted': ('-', ['vcschanged']), + 'changed': ('*', ['vcschanged']), + 'staged': ('*', ['vcsstaged']), + 'ignored': ('·', ['vcsignored']), + 'sync': ('√', ['vcssync']), + 'none': (' ', []), + 'unknown': ('?', ['vcsunknown']), + } - vcsremotestatus_symb = {'none': (' ', []), - 'sync': ('=', ["vcssync"]), - 'behind': ('<', ["vcsbehind"]), - 'ahead': ('>', ["vcsahead"]), - 'diverged': ('Y', ["vcsdiverged"]), - 'unknown': ('?', ["vcsunknown"])} + vcsremotestatus_symb = { + 'diverged': ('Y', ['vcsdiverged']), + 'ahead': ('>', ['vcsahead']), + 'behind': ('<', ['vcsbehind']), + 'sync': ('=', ['vcssync']), + 'none': ('⌂', ['vcsnone']), + 'unknown': ('?', ['vcsunknown']), + } - ellipsis = { False: '~', True: '…' } + ellipsis = {False: '~', True: '…'} diff --git a/ranger/gui/widgets/browsercolumn.py b/ranger/gui/widgets/browsercolumn.py index 52ef62b8..07830b31 100644 --- a/ranger/gui/widgets/browsercolumn.py +++ b/ranger/gui/widgets/browsercolumn.py @@ -150,8 +150,8 @@ class BrowserColumn(Pager): self.old_thisfile = self.target.pointed_obj if self.target.load_content_if_outdated() \ - or self.target.sort_if_outdated() \ - or self.last_redraw_time < self.target.last_update_time: + or self.target.sort_if_outdated() \ + or self.last_redraw_time < self.target.last_update_time: self.need_redraw = True if self.need_redraw: @@ -256,9 +256,9 @@ class BrowserColumn(Pager): metakey = hash(repr(sorted(metadata.items()))) if metadata else 0 key = (self.wid, selected_i == i, drawn.marked, self.main_column, - drawn.path in copied, tagged_marker, drawn.infostring, - drawn.vcsfilestatus, drawn.vcsremotestatus, self.fm.do_cut, - current_linemode.name, metakey) + drawn.path in copied, tagged_marker, drawn.infostring, + drawn.vcsstatus, drawn.vcsremotestatus, self.target.has_vcschild, + self.fm.do_cut, current_linemode.name, metakey) if key in drawn.display_data: self.execute_curses_batch(line, drawn.display_data[key]) @@ -372,25 +372,21 @@ class BrowserColumn(Pager): def _draw_vcsstring_display(self, drawn): vcsstring_display = [] - if self.settings.vcs_aware and (drawn.vcsfilestatus or \ - drawn.vcsremotestatus): - if drawn.vcsfilestatus: - vcsstr, vcscol = self.vcsfilestatus_symb[drawn.vcsfilestatus] - else: - vcsstr = " " - vcscol = [] - vcsstring_display.append([vcsstr, ['vcsfile'] + vcscol]) - + if (self.target.vcs and self.target.vcs.track) \ + or (drawn.is_directory and drawn.vcs and drawn.vcs.track): if drawn.vcsremotestatus: - vcsstr, vcscol = self.vcsremotestatus_symb[ - drawn.vcsremotestatus] - else: - - vcsstr = " " - vcscol = [] - vcsstring_display.append([vcsstr, ['vcsremote'] + vcscol]) + vcsstr, vcscol = self.vcsremotestatus_symb[drawn.vcsremotestatus] + vcsstring_display.append([vcsstr, ['vcsremote'] + vcscol]) + elif self.target.has_vcschild: + vcsstring_display.append([' ', []]) + if drawn.vcsstatus: + vcsstr, vcscol = self.vcsstatus_symb[drawn.vcsstatus] + vcsstring_display.append([vcsstr, ['vcsfile'] + vcscol]) + elif self.target.has_vcschild: + vcsstring_display.append([' ', []]) elif self.target.has_vcschild: - vcsstring_display.append([" ", []]) + vcsstring_display.append([' ', []]) + return vcsstring_display def _draw_directory_color(self, i, drawn, copied): diff --git a/ranger/gui/widgets/statusbar.py b/ranger/gui/widgets/statusbar.py index ee4df028..33aa4296 100644 --- a/ranger/gui/widgets/statusbar.py +++ b/ranger/gui/widgets/statusbar.py @@ -178,28 +178,32 @@ class StatusBar(Widget): left.add(target.infostring.replace(" ", "")) left.add_space() - left.add(strftime(self.timeformat, - localtime(stat.st_mtime)), 'mtime') - - if target.vcs: - if target.vcsbranch: - vcsinfo = '(%s: %s)' % (target.vcs.vcsname, target.vcsbranch) + left.add(strftime(self.timeformat, localtime(stat.st_mtime)), 'mtime') + + directory = target if target.is_directory else \ + target.fm.get_directory(os.path.dirname(target.path)) + if directory.vcs and directory.vcs.track: + if directory.vcs.rootvcs.branch: + vcsinfo = '({0:s}: {1:s})'.format( + directory.vcs.rootvcs.repotype, directory.vcs.rootvcs.branch) else: - vcsinfo = '(%s)' % (target.vcs.vcsname) - + vcsinfo = '({0:s})'.format(directory.vcs.rootvcs.repotype) left.add_space() left.add(vcsinfo, 'vcsinfo') - if target.vcsfilestatus: - left.add_space() - vcsstr, vcscol = self.vcsfilestatus_symb[target.vcsfilestatus] - left.add(vcsstr.strip(), 'vcsfile', *vcscol) - if target.vcsremotestatus: - vcsstr, vcscol = self.vcsremotestatus_symb[target.vcsremotestatus] + left.add_space() + if directory.vcs.rootvcs.obj.vcsremotestatus: + vcsstr, vcscol = self.vcsremotestatus_symb[ + directory.vcs.rootvcs.obj.vcsremotestatus] left.add(vcsstr.strip(), 'vcsremote', *vcscol) - if target.vcshead: + if target.vcsstatus: + vcsstr, vcscol = self.vcsstatus_symb[target.vcsstatus] + left.add(vcsstr.strip(), 'vcsfile', *vcscol) + if directory.vcs.rootvcs.head: + left.add_space() + left.add(directory.vcs.rootvcs.head['date'].strftime(self.timeformat), 'vcsdate') left.add_space() - left.add('%s' % target.vcshead['summary'], 'vcscommit') + left.add(directory.vcs.rootvcs.head['summary'], 'vcscommit') def _get_owner(self, target): uid = target.stat.st_uid |