diff options
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.md | 183 | ||||
-rw-r--r-- | powerline_gitstatus/__init__.py | 1 | ||||
-rw-r--r-- | powerline_gitstatus/segments.py | 199 | ||||
-rw-r--r-- | screenshot.png | bin | 0 -> 13238 bytes | |||
-rw-r--r-- | setup.py | 21 |
7 files changed, 430 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcdae01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.egg-info +*.pyc +build/* +dist/* +build_howto.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 - 2018 Jasper N. Brouwer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ecaf16 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +Powerline Gitstatus +=================== + +A [Powerline][1] segment for showing the status of a Git working copy. + +By [Jasper N. Brouwer][2]. + +It will show the branch-name, or the commit hash if in detached head state. + +It will also show the number of commits behind, commits ahead, staged files, +unmerged files (conflicts), changed files, untracked files and stashed files +if that number is greater than zero. + +![screenshot][4] + +Glossary +-------- +- ``: branch name or commit hash +- `★`: most recent tag (if enabled) +- `↓`: n commits behind +- `↑`: n commits ahead +- `●`: n staged files +- `✖`: n unmerged files (conflicts) +- `✚`: n changed files +- `…`: n untracked files +- `⚑`: n stashed files + +Requirements +------------ + +The Gitstatus segment requires [git][5]! Preferably, but not limited to, version 1.8.5 or higher. + +Version 1.8.5 will enable the usage of the `-C` parameter, which is more performant and accurate. + +Installation +------------ + +### On Debian/Ubuntu + +On a recent enough Debian (at least Stretch with backports enabled) or Ubuntu (at least 18.10) there is an official package available. + +```txt +apt install powerline-gitstatus +``` + +This command will also instruct your package manager to install Powerline, if it's not already available. + +Powerline will be automatically configured to use the Gitstatus highlight groups and add the segment to the default +shell theme. + +### Using pip + +```txt +pip install powerline-gitstatus +``` + +Configuration +------------- + +The Gitstatus segment uses a couple of custom highlight groups. You'll need to define those groups in your colorscheme, +for example in `.config/powerline/colorschemes/default.json`: + +```json +{ + "groups": { + "gitstatus": { "fg": "gray8", "bg": "gray2", "attrs": [] }, + "gitstatus_branch": { "fg": "gray8", "bg": "gray2", "attrs": [] }, + "gitstatus_branch_clean": { "fg": "green", "bg": "gray2", "attrs": [] }, + "gitstatus_branch_dirty": { "fg": "gray8", "bg": "gray2", "attrs": [] }, + "gitstatus_branch_detached": { "fg": "mediumpurple", "bg": "gray2", "attrs": [] }, + "gitstatus_tag": { "fg": "darkcyan", "bg": "gray2", "attrs": [] }, + "gitstatus_behind": { "fg": "gray10", "bg": "gray2", "attrs": [] }, + "gitstatus_ahead": { "fg": "gray10", "bg": "gray2", "attrs": [] }, + "gitstatus_staged": { "fg": "green", "bg": "gray2", "attrs": [] }, + "gitstatus_unmerged": { "fg": "brightred", "bg": "gray2", "attrs": [] }, + "gitstatus_changed": { "fg": "mediumorange", "bg": "gray2", "attrs": [] }, + "gitstatus_untracked": { "fg": "brightestorange", "bg": "gray2", "attrs": [] }, + "gitstatus_stashed": { "fg": "darkblue", "bg": "gray2", "attrs": [] }, + "gitstatus:divider": { "fg": "gray8", "bg": "gray2", "attrs": [] } + } +} +``` + +Then you can activate the Gitstatus segment by adding it to your segment configuration, +for example in `.config/powerline/themes/shell/default.json`: + +```json +{ + "function": "powerline_gitstatus.gitstatus", + "priority": 40 +} +``` + +The Gitstatus segment will use the `-C` argument by default, but this requires git 1.8.5 or higher. + +If you cannot meet that requirement, you'll have to disable the usage of `-C`. +Do this by passing `false` to the `use_dash_c` argument, for example in `.config/powerline/themes/shell/__main__.json`: + +```json +"gitstatus": { + "args": { + "use_dash_c": false + } +} +``` + +Optionally, a tag description for the current branch may be displayed using the `show_tag` option. Valid values for this +argument are: + + * `last` : shows the most recent tag + * `annotated` : shows the most recent annotated tag + * `contains` : shows the closest tag that comes after the current commit + * `exact` : shows a tag only if it matches the current commit + +You can enable this by passing one of these to the `show_tag` argument, for example in `.config/powerline/themes/shell/__main__.json`: + +```json +"gitstatus": { + "args": { + "show_tag": "exact" + } +} +``` +Git is executed an additional time to find this tag, so it is disabled by default. + +Note: before v1.3.0, the behavior when the value is `True` was `last`. As of v1.3.0 onwards, `True` behaves as `exact`. + +Optionally the format in which Gitstatus shows information can be customized. +This allows to use a different symbol or remove a fragment if desired. You can +customize string formats for _branch_, _tag_, _behind_, _ahead_, _staged_, _unmerged_, +_changed_, _untracked_ and _stash_ fragments with the following arguments in a +theme configuration file, for example `.config/powerline/themes/shell/__main__.json`: + +```json +"gitstatus": { + "args": { + "formats": { + "branch": "\ue0a0 {}", + "tag": " ★ {}", + "behind": " ↓ {}", + "ahead": " ↑ {}", + "staged": " ● {}", + "unmerged": " ✖ {}", + "changed": " ✚ {}", + "untracked": " … {}", + "stashed": " ⚑ {}" + } + } +} +``` + +By default, when in detached head state (current revision is not a branch tip), Gitstatus shows a short commit hash in +place of the branch name. This can be replaced with a description of the closest reachable ref using the +`detached_head_style` argument, for example in `.config/powerline/themes/shell/__main__.json`: + +```json +"gitstatus": { + "args": { + "detached_head_style": "ref" + } +} +``` + +By default, if your local branch has untracked files but no other changes, the branch status will be highlighted as dirty in the segment. You can disable this behavior by setting the `untracked_not_dirty` argument to `true`, for example in `.config/powerline/themes/shell/__main__.json`: + +```json +"gitstatus": { + "args": { + "untracked_not_dirty": true + } +} +``` + +License +------- + +Licensed under [the MIT License][3]. + +[1]: https://powerline.readthedocs.org/en/master/ +[2]: https://github.com/jaspernbrouwer +[3]: https://github.com/jaspernbrouwer/powerline-gitstatus/blob/master/LICENSE +[4]: https://github.com/jaspernbrouwer/powerline-gitstatus/blob/master/screenshot.png +[5]: https://git-scm.com/ diff --git a/powerline_gitstatus/__init__.py b/powerline_gitstatus/__init__.py new file mode 100644 index 0000000..9dd7283 --- /dev/null +++ b/powerline_gitstatus/__init__.py @@ -0,0 +1 @@ +from .segments import gitstatus diff --git a/powerline_gitstatus/segments.py b/powerline_gitstatus/segments.py new file mode 100644 index 0000000..cdd0a01 --- /dev/null +++ b/powerline_gitstatus/segments.py @@ -0,0 +1,199 @@ +# vim:fileencoding=utf-8:noet + +from powerline.segments import Segment, with_docstring +from powerline.theme import requires_segment_info +from subprocess import PIPE, Popen +import os, re, string + + +@requires_segment_info +class GitStatusSegment(Segment): + + def execute(self, pl, command): + pl.debug('Executing command: %s' % ' '.join(command)) + + git_env = os.environ.copy() + git_env['LC_ALL'] = 'C' + + proc = Popen(command, stdout=PIPE, stderr=PIPE, env=git_env) + out, err = [item.decode('utf-8') for item in proc.communicate()] + + if out: + pl.debug('Command output: %s' % out.strip(string.whitespace)) + if err: + pl.debug('Command errors: %s' % err.strip(string.whitespace)) + + return (out.splitlines(), err.splitlines()) + + def get_base_command(self, cwd, use_dash_c): + if use_dash_c: + return ['git', '-c', 'core.fsmonitor=', '-C', cwd] + + while cwd and cwd != os.sep: + gitdir = os.path.join(cwd, '.git') + + if os.path.isdir(gitdir): + return ['git', '-c', 'core.fsmonitor=', '--git-dir=%s' % gitdir, '--work-tree=%s' % cwd] + + cwd = os.path.dirname(cwd) + + return None + + def parse_branch(self, line): + if not line: + return ('', False, 0, 0) + + if line.startswith('## '): + line = line[3:] + + match = re.search('^Initial commit on (.+)$', line) + if match is not None: + return (match.group(1), False, 0, 0) + + match = re.search('^(.+) \(no branch\)$', line) + if match is not None: + return (match.group(1), True, 0, 0) + + match = re.search('^(.+?)\.\.\.', line) + if match is not None: + branch = match.group(1) + + match = re.search('\[ahead (\d+), behind (\d+)\]$', line) + if match is not None: + return (branch, False, int(match.group(2)), int(match.group(1))) + match = re.search('\[ahead (\d+)\]$', line) + if match is not None: + return (branch, False, 0, int(match.group(1))) + match = re.search('\[behind (\d+)\]$', line) + if match is not None: + return (branch, False, int(match.group(1)), 0) + + return (branch, False, 0, 0) + + return (line, False, 0, 0) + + def parse_status(self, lines): + staged = len([True for l in lines if l[0] in 'MRC' or (l[0] == 'D' and l[1] != 'D') or (l[0] == 'A' and l[1] != 'A')]) + unmerged = len([True for l in lines if l[0] == 'U' or l[1] == 'U' or (l[0] == 'A' and l[1] == 'A') or (l[0] == 'D' and l[1] == 'D')]) + changed = len([True for l in lines if l[1] == 'M' or (l[1] == 'D' and l[0] != 'D')]) + untracked = len([True for l in lines if l[0] == '?']) + + return (staged, unmerged, changed, untracked) + + def build_segments(self, formats, branch, detached, tag, behind, ahead, staged, unmerged, changed, untracked, stashed, untracked_not_dirty): + if detached: + branch_group = 'gitstatus_branch_detached' + elif staged or unmerged or changed or (untracked and not untracked_not_dirty): + branch_group = 'gitstatus_branch_dirty' + else: + branch_group = 'gitstatus_branch_clean' + + segments = [ + {'contents': formats.get('branch', u'\ue0a0 {}').format(branch), 'highlight_groups': [branch_group, 'gitstatus_branch', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'} + ] + + if tag: + segments.append({'contents': formats.get('tag', u' \u2605 {}').format(tag), 'highlight_groups': ['gitstatus_tag', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if behind: + segments.append({'contents': formats.get('behind', ' ↓ {}').format(behind), 'highlight_groups': ['gitstatus_behind', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if ahead: + segments.append({'contents': formats.get('ahead', ' ↑ {}').format(ahead), 'highlight_groups': ['gitstatus_ahead', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if staged: + segments.append({'contents': formats.get('staged', ' ● {}').format(staged), 'highlight_groups': ['gitstatus_staged', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if unmerged: + segments.append({'contents': formats.get('unmerged', ' ✖ {}').format(unmerged), 'highlight_groups': ['gitstatus_unmerged', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if changed: + segments.append({'contents': formats.get('changed', ' ✚ {}').format(changed), 'highlight_groups': ['gitstatus_changed', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if untracked: + segments.append({'contents': formats.get('untracked', ' … {}').format(untracked), 'highlight_groups': ['gitstatus_untracked', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + if stashed: + segments.append({'contents': formats.get('stashed', ' ⚑ {}').format(stashed), 'highlight_groups': ['gitstatus_stashed', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) + + return segments + + def __call__(self, pl, segment_info, use_dash_c=True, show_tag=False, formats={}, detached_head_style='revision', untracked_not_dirty=False): + pl.debug('Running gitstatus %s -C' % ('with' if use_dash_c else 'without')) + + cwd = segment_info['getcwd']() + + if not cwd: + return + + base = self.get_base_command(cwd, use_dash_c) + + if not base: + return + + status, err = self.execute(pl, base + ['status', '--branch', '--porcelain']) + + if err and ('error' in err[0] or 'fatal' in err[0]): + return + + branch, detached, behind, ahead = self.parse_branch(status.pop(0)) + + if not branch: + return + + if branch == 'HEAD': + if detached_head_style == 'revision': + branch = self.execute(pl, base + ['rev-parse', '--short', 'HEAD'])[0][0] + elif detached_head_style == 'ref': + branch = self.execute(pl, base + ['describe', '--contains', '--all'])[0][0] + + staged, unmerged, changed, untracked = self.parse_status(status) + + stashed = len(self.execute(pl, base + ['stash', 'list', '--no-decorate'])[0]) + + if not show_tag: + tag, err = [''], False + elif show_tag == 'contains': + tag, err = self.execute(pl, base + ['describe', '--contains']) + elif show_tag == 'last': + tag, err = self.execute(pl, base + ['describe', '--tags']) + elif show_tag == 'annotated': + tag, err = self.execute(pl, base + ['describe']) + else: + tag, err = self.execute(pl, base + ['describe', '--tags', '--exact-match', '--abbrev=0']) + + if err and ('error' in err[0] or 'fatal' in err[0] or 'Could not get sha1 for HEAD' in err[0]): + tag = '' + else: + tag = tag[0] + + return self.build_segments(formats, branch, detached, tag, behind, ahead, staged, unmerged, changed, untracked, stashed, untracked_not_dirty) + + +gitstatus = with_docstring(GitStatusSegment(), +'''Return the status of a Git working copy. + +It will show the branch-name, or the commit hash if in detached head state. + +It will also show the number of commits behind, commits ahead, staged files, +unmerged files (conflicts), changed files, untracked files and stashed files +if that number is greater than zero. + +:param bool use_dash_c: + Call git with ``-C``, which is more performant and accurate, but requires git 1.8.5 or higher. + Otherwise it will traverse the current working directory up towards the root until it finds a ``.git`` directory, then use ``--git-dir`` and ``--work-tree``. + True by default. + +:param bool show_tag: + Show tag description. Valid options are``contains``, ``last``, ``annotated`` and ``exact``. A value of True behaves the same as ``exact``, which only displays a tag when it's assigned to the currently checked-out revision. + False by default, because it needs to execute git an additional time. + +:param dict formats: + A string-to-string dictionary for customizing Git status formats. Valid keys include ``branch``, ``tag``, ``ahead``, ``behind``, ``staged``, ``unmerged``, ``changes``, ``untracked``, and ``stashed``. + Empty dictionary by default, which means the default formats are used. + +:param detached_head_style: + Display style when in detached HEAD state. Valid values are ``revision``, which shows the current revision id, and ``ref``, which shows the closest reachable ref object. + The default is ``revision``. + +:param untracked_not_dirty: + Untracked files alone will not mark the git branch status as dirty. + False by default. + +Divider highlight group used: ``gitstatus:divider``. + +Highlight groups used: ``gitstatus_branch_detached``, ``gitstatus_branch_dirty``, ``gitstatus_branch_clean``, ``gitstatus_branch``, ``gitstatus_tag``, ``gitstatus_behind``, ``gitstatus_ahead``, ``gitstatus_staged``, ``gitstatus_unmerged``, ``gitstatus_changed``, ``gitstatus_untracked``, ``gitstatus_stashed``, ``gitstatus``. +''') diff --git a/screenshot.png b/screenshot.png Binary files differnew file mode 100644 index 0000000..1e44f51 --- /dev/null +++ b/screenshot.png diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b4e10d2 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +# vim:fileencoding=utf-8:noet + +from setuptools import setup + +setup( + name = 'powerline-gitstatus', + description = 'A Powerline segment for showing the status of a Git working copy', + version = '1.3.1', + keywords = 'powerline git status prompt', + license = 'MIT', + author = 'Jasper N. Brouwer', + author_email = 'jasper@nerdsweide.nl', + url = 'https://github.com/jaspernbrouwer/powerline-gitstatus', + packages = ['powerline_gitstatus'], + classifiers = [ + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Topic :: Terminals' + ] +) |