├── powerline_gitstatus ├── __init__.py └── segments.py ├── .gitignore ├── screenshot.png ├── pyproject.toml ├── LICENSE └── README.md /powerline_gitstatus/__init__.py: -------------------------------------------------------------------------------- 1 | from .segments import gitstatus 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | build/* 4 | dist/* 5 | build_howto.md 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaspernbrouwer/powerline-gitstatus/HEAD/screenshot.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "powerline-gitstatus" 7 | description = "A Powerline segment for showing the status of a Git working copy" 8 | version = "1.3.3" 9 | keywords = ["powerline git status prompt"] 10 | license = {text = "MIT License"} 11 | authors = [ 12 | {name = "Jasper N. Brouwer", email = "jasper@nerdsweide.nl"}, 13 | ] 14 | maintainers = [ 15 | {name ="Jérôme Charaoui", email = "jerome@riseup.net"}, 16 | ] 17 | classifiers = [ 18 | "Development Status :: 6 - Mature", 19 | "Environment :: Console", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Topic :: Terminals" 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/jaspernbrouwer/powerline-gitstatus" 27 | Repository = "https://github.com/jaspernbrouwer/powerline-gitstatus.git" 28 | Issues = "https://github.com/jaspernbrouwer/powerline-gitstatus" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - 2018 Jasper N. Brouwer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Powerline Gitstatus 2 | =================== 3 | 4 | A [Powerline][1] segment for showing the status of a Git working copy. 5 | 6 | By [Jasper N. Brouwer][2]. 7 | 8 | It will show the branch-name, or the commit hash if in detached head state. 9 | 10 | It will also show the number of commits behind, commits ahead, staged files, 11 | unmerged files (conflicts), changed files, untracked files and stashed files 12 | if that number is greater than zero. 13 | 14 | ![screenshot][4] 15 | 16 | Glossary 17 | -------- 18 | - ``: branch name or commit hash 19 | - `★`: most recent tag (if enabled) 20 | - `↓`: n commits behind 21 | - `↑`: n commits ahead 22 | - `●`: n staged files 23 | - `✖`: n unmerged files (conflicts) 24 | - `✚`: n changed files 25 | - `…`: n untracked files 26 | - `⚑`: n stashed files 27 | 28 | Requirements 29 | ------------ 30 | 31 | The Gitstatus segment requires [git][5]! Preferably, but not limited to, version 1.8.5 or higher. 32 | 33 | Version 1.8.5 will enable the usage of the `-C` parameter, which is more performant and accurate. 34 | 35 | Installation 36 | ------------ 37 | 38 | ### On Debian/Ubuntu 39 | 40 | On a recent enough Debian (at least Stretch with backports enabled) or Ubuntu (at least 18.10) there is an official package available. 41 | 42 | ```txt 43 | apt install powerline-gitstatus 44 | ``` 45 | 46 | This command will also instruct your package manager to install Powerline, if it's not already available. 47 | 48 | Powerline will be automatically configured to use the Gitstatus highlight groups and add the segment to the default 49 | shell theme. 50 | 51 | ### Using pip 52 | 53 | ```txt 54 | pip install powerline-gitstatus 55 | ``` 56 | 57 | Configuration 58 | ------------- 59 | 60 | The Gitstatus segment uses a couple of custom highlight groups. You'll need to define those groups in your colorscheme, 61 | for example in `.config/powerline/colorschemes/default.json`: 62 | 63 | ```json 64 | { 65 | "groups": { 66 | "gitstatus": { "fg": "gray8", "bg": "gray2", "attrs": [] }, 67 | "gitstatus_branch": { "fg": "gray8", "bg": "gray2", "attrs": [] }, 68 | "gitstatus_branch_clean": { "fg": "green", "bg": "gray2", "attrs": [] }, 69 | "gitstatus_branch_dirty": { "fg": "gray8", "bg": "gray2", "attrs": [] }, 70 | "gitstatus_branch_detached": { "fg": "mediumpurple", "bg": "gray2", "attrs": [] }, 71 | "gitstatus_tag": { "fg": "darkcyan", "bg": "gray2", "attrs": [] }, 72 | "gitstatus_behind": { "fg": "gray10", "bg": "gray2", "attrs": [] }, 73 | "gitstatus_ahead": { "fg": "gray10", "bg": "gray2", "attrs": [] }, 74 | "gitstatus_staged": { "fg": "green", "bg": "gray2", "attrs": [] }, 75 | "gitstatus_unmerged": { "fg": "brightred", "bg": "gray2", "attrs": [] }, 76 | "gitstatus_changed": { "fg": "mediumorange", "bg": "gray2", "attrs": [] }, 77 | "gitstatus_untracked": { "fg": "brightestorange", "bg": "gray2", "attrs": [] }, 78 | "gitstatus_stashed": { "fg": "darkblue", "bg": "gray2", "attrs": [] }, 79 | "gitstatus:divider": { "fg": "gray8", "bg": "gray2", "attrs": [] } 80 | } 81 | } 82 | ``` 83 | 84 | Then you can activate the Gitstatus segment by adding it to your segment configuration, 85 | for example in `.config/powerline/themes/shell/default.json`: 86 | 87 | ```json 88 | { 89 | "function": "powerline_gitstatus.gitstatus", 90 | "priority": 40 91 | } 92 | ``` 93 | 94 | The Gitstatus segment will use the `-C` argument by default, but this requires git 1.8.5 or higher. 95 | 96 | If you cannot meet that requirement, you'll have to disable the usage of `-C`. 97 | Do this by passing `false` to the `use_dash_c` argument, for example in `.config/powerline/themes/shell/__main__.json`: 98 | 99 | ```json 100 | "gitstatus": { 101 | "args": { 102 | "use_dash_c": false 103 | } 104 | } 105 | ``` 106 | 107 | It's strongly recommended to define the `trusted_paths` argument. This will 108 | restrict the locations where git commands will be invoked, limiting the 109 | exposure to remote code execution via malicious repositories. Navigating the 110 | shell to repositories outside these trusted paths will not display the segment. 111 | 112 | ```json 113 | "gitstatus": { 114 | "args": { 115 | "trusted_paths": [ 116 | "/home/foo/code", 117 | "/home/foo/projects" 118 | ] 119 | } 120 | } 121 | ``` 122 | 123 | Optionally, a tag description for the current branch may be displayed using the `show_tag` option. Valid values for this 124 | argument are: 125 | 126 | * `last` : shows the most recent tag 127 | * `annotated` : shows the most recent annotated tag 128 | * `contains` : shows the closest tag that comes after the current commit 129 | * `exact` : shows a tag only if it matches the current commit 130 | 131 | You can enable this by passing one of these to the `show_tag` argument, for example in `.config/powerline/themes/shell/__main__.json`: 132 | 133 | ```json 134 | "gitstatus": { 135 | "args": { 136 | "show_tag": "exact" 137 | } 138 | } 139 | ``` 140 | Git is executed an additional time to find this tag, so it is disabled by default. 141 | 142 | Note: before v1.3.0, the behavior when the value is `True` was `last`. As of v1.3.0 onwards, `True` behaves as `exact`. 143 | 144 | Optionally the format in which Gitstatus shows information can be customized. 145 | This allows to use a different symbol or remove a fragment if desired. You can 146 | customize string formats for _branch_, _tag_, _behind_, _ahead_, _staged_, _unmerged_, 147 | _changed_, _untracked_ and _stash_ fragments with the following arguments in a 148 | theme configuration file, for example `.config/powerline/themes/shell/__main__.json`: 149 | 150 | ```json 151 | "gitstatus": { 152 | "args": { 153 | "formats": { 154 | "branch": "\ue0a0 {}", 155 | "tag": " ★ {}", 156 | "behind": " ↓ {}", 157 | "ahead": " ↑ {}", 158 | "staged": " ● {}", 159 | "unmerged": " ✖ {}", 160 | "changed": " ✚ {}", 161 | "untracked": " … {}", 162 | "stashed": " ⚑ {}" 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | By default, when in detached head state (current revision is not a branch tip), Gitstatus shows a short commit hash in 169 | place of the branch name. This can be replaced with a description of the closest reachable ref using the 170 | `detached_head_style` argument, for example in `.config/powerline/themes/shell/__main__.json`: 171 | 172 | ```json 173 | "gitstatus": { 174 | "args": { 175 | "detached_head_style": "ref" 176 | } 177 | } 178 | ``` 179 | 180 | 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`: 181 | 182 | ```json 183 | "gitstatus": { 184 | "args": { 185 | "untracked_not_dirty": true 186 | } 187 | } 188 | ``` 189 | 190 | License 191 | ------- 192 | 193 | Licensed under [the MIT License][3]. 194 | 195 | [1]: https://powerline.readthedocs.org/en/master/ 196 | [2]: https://github.com/jaspernbrouwer 197 | [3]: https://github.com/jaspernbrouwer/powerline-gitstatus/blob/master/LICENSE 198 | [4]: https://github.com/jaspernbrouwer/powerline-gitstatus/blob/master/screenshot.png 199 | [5]: https://git-scm.com/ 200 | -------------------------------------------------------------------------------- /powerline_gitstatus/segments.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=utf-8:noet 2 | 3 | from powerline.segments import Segment, with_docstring 4 | from powerline.theme import requires_segment_info 5 | from subprocess import PIPE, Popen 6 | from pathlib import PurePath 7 | import os, re, string 8 | 9 | 10 | @requires_segment_info 11 | class GitStatusSegment(Segment): 12 | 13 | def execute(self, pl, command): 14 | pl.debug('Executing command: %s' % ' '.join(command)) 15 | 16 | git_env = os.environ.copy() 17 | git_env['LC_ALL'] = 'C' 18 | 19 | proc = Popen(command, stdout=PIPE, stderr=PIPE, env=git_env) 20 | out, err = [item.decode('utf-8') for item in proc.communicate()] 21 | 22 | if out: 23 | pl.debug('Command output: %s' % out.strip(string.whitespace)) 24 | if err: 25 | pl.debug('Command errors: %s' % err.strip(string.whitespace)) 26 | 27 | return (out.splitlines(), err.splitlines()) 28 | 29 | def get_base_command(self, cwd, use_dash_c): 30 | if use_dash_c: 31 | return ['git', '-c', 'core.fsmonitor=', '-C', cwd] 32 | 33 | while cwd and cwd != os.sep: 34 | gitdir = os.path.join(cwd, '.git') 35 | 36 | if os.path.isdir(gitdir): 37 | return ['git', '-c', 'core.fsmonitor=', '--git-dir=%s' % gitdir, '--work-tree=%s' % cwd] 38 | 39 | cwd = os.path.dirname(cwd) 40 | 41 | return None 42 | 43 | def parse_branch(self, line): 44 | if not line: 45 | return ('', False, 0, 0) 46 | 47 | if line.startswith('## '): 48 | line = line[3:] 49 | 50 | match = re.search(r'^Initial commit on (.+)$', line) 51 | if match is not None: 52 | return (match.group(1), False, 0, 0) 53 | 54 | match = re.search(r'^(.+) \(no branch\)$', line) 55 | if match is not None: 56 | return (match.group(1), True, 0, 0) 57 | 58 | match = re.search(r'^(.+?)\.\.\.', line) 59 | if match is not None: 60 | branch = match.group(1) 61 | 62 | match = re.search(r'\[ahead (\d+), behind (\d+)\]$', line) 63 | if match is not None: 64 | return (branch, False, int(match.group(2)), int(match.group(1))) 65 | match = re.search(r'\[ahead (\d+)\]$', line) 66 | if match is not None: 67 | return (branch, False, 0, int(match.group(1))) 68 | match = re.search(r'\[behind (\d+)\]$', line) 69 | if match is not None: 70 | return (branch, False, int(match.group(1)), 0) 71 | 72 | return (branch, False, 0, 0) 73 | 74 | return (line, False, 0, 0) 75 | 76 | def parse_status(self, lines): 77 | 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')]) 78 | 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')]) 79 | changed = len([True for l in lines if l[1] == 'M' or (l[1] == 'D' and l[0] != 'D')]) 80 | untracked = len([True for l in lines if l[0] == '?']) 81 | 82 | return (staged, unmerged, changed, untracked) 83 | 84 | def build_segments(self, formats, branch, detached, tag, behind, ahead, staged, unmerged, changed, untracked, stashed, untracked_not_dirty): 85 | if detached: 86 | branch_group = 'gitstatus_branch_detached' 87 | elif staged or unmerged or changed or (untracked and not untracked_not_dirty): 88 | branch_group = 'gitstatus_branch_dirty' 89 | else: 90 | branch_group = 'gitstatus_branch_clean' 91 | 92 | segments = [ 93 | {'contents': formats.get('branch', u'\ue0a0 {}').format(branch), 'highlight_groups': [branch_group, 'gitstatus_branch', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'} 94 | ] 95 | 96 | if tag: 97 | segments.append({'contents': formats.get('tag', u' \u2605 {}').format(tag), 'highlight_groups': ['gitstatus_tag', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 98 | if behind: 99 | segments.append({'contents': formats.get('behind', ' ↓ {}').format(behind), 'highlight_groups': ['gitstatus_behind', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 100 | if ahead: 101 | segments.append({'contents': formats.get('ahead', ' ↑ {}').format(ahead), 'highlight_groups': ['gitstatus_ahead', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 102 | if staged: 103 | segments.append({'contents': formats.get('staged', ' ● {}').format(staged), 'highlight_groups': ['gitstatus_staged', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 104 | if unmerged: 105 | segments.append({'contents': formats.get('unmerged', ' ✖ {}').format(unmerged), 'highlight_groups': ['gitstatus_unmerged', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 106 | if changed: 107 | segments.append({'contents': formats.get('changed', ' ✚ {}').format(changed), 'highlight_groups': ['gitstatus_changed', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 108 | if untracked: 109 | segments.append({'contents': formats.get('untracked', ' … {}').format(untracked), 'highlight_groups': ['gitstatus_untracked', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 110 | if stashed: 111 | segments.append({'contents': formats.get('stashed', ' ⚑ {}').format(stashed), 'highlight_groups': ['gitstatus_stashed', 'gitstatus'], 'divider_highlight_group': 'gitstatus:divider'}) 112 | 113 | return segments 114 | 115 | def path_is_trusted(self, cwd, trusted_paths, pl): 116 | for trusted_path in trusted_paths: 117 | cwd_path = PurePath(cwd) 118 | try: 119 | cwd_path.relative_to(trusted_path) 120 | return True 121 | except ValueError: 122 | pass 123 | return False 124 | 125 | def __call__(self, pl, segment_info, use_dash_c=True, show_tag=False, formats={}, detached_head_style='revision', untracked_not_dirty=False, trusted_paths=[]): 126 | cwd = segment_info['getcwd']() 127 | 128 | if not cwd: 129 | return 130 | 131 | if trusted_paths and not self.path_is_trusted(cwd, trusted_paths, pl): 132 | pl.debug("cwd not in trusted paths") 133 | return 134 | 135 | base = self.get_base_command(cwd, use_dash_c) 136 | 137 | if not base: 138 | return 139 | 140 | pl.debug('Running gitstatus %s -C' % ('with' if use_dash_c else 'without')) 141 | status, err = self.execute(pl, base + ['status', '--branch', '--porcelain']) 142 | 143 | if err and ('error' in err[0] or 'fatal' in err[0]): 144 | return 145 | 146 | branch, detached, behind, ahead = self.parse_branch(status.pop(0)) 147 | 148 | if not branch: 149 | return 150 | 151 | if branch == 'HEAD': 152 | if detached_head_style == 'revision': 153 | branch = self.execute(pl, base + ['rev-parse', '--short', 'HEAD'])[0][0] 154 | elif detached_head_style == 'ref': 155 | branch = self.execute(pl, base + ['describe', '--contains', '--all'])[0][0] 156 | 157 | staged, unmerged, changed, untracked = self.parse_status(status) 158 | 159 | stashed = len(self.execute(pl, base + ['stash', 'list', '--no-decorate'])[0]) 160 | 161 | if not show_tag: 162 | tag, err = [''], False 163 | elif show_tag == 'contains': 164 | tag, err = self.execute(pl, base + ['describe', '--contains']) 165 | elif show_tag == 'last': 166 | tag, err = self.execute(pl, base + ['describe', '--tags']) 167 | elif show_tag == 'annotated': 168 | tag, err = self.execute(pl, base + ['describe']) 169 | else: 170 | tag, err = self.execute(pl, base + ['describe', '--tags', '--exact-match', '--abbrev=0']) 171 | 172 | if err and ('error' in err[0] or 'fatal' in err[0] or 'Could not get sha1 for HEAD' in err[0]): 173 | tag = '' 174 | else: 175 | tag = tag[0] 176 | 177 | return self.build_segments(formats, branch, detached, tag, behind, ahead, staged, unmerged, changed, untracked, stashed, untracked_not_dirty) 178 | 179 | 180 | gitstatus = with_docstring(GitStatusSegment(), 181 | '''Return the status of a Git working copy. 182 | 183 | It will show the branch-name, or the commit hash if in detached head state. 184 | 185 | It will also show the number of commits behind, commits ahead, staged files, 186 | unmerged files (conflicts), changed files, untracked files and stashed files 187 | if that number is greater than zero. 188 | 189 | :param bool use_dash_c: 190 | Call git with ``-C``, which is more performant and accurate, but requires git 1.8.5 or higher. 191 | 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``. 192 | True by default. 193 | 194 | :param bool show_tag: 195 | 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. 196 | False by default, because it needs to execute git an additional time. 197 | 198 | :param dict formats: 199 | A string-to-string dictionary for customizing Git status formats. Valid keys include ``branch``, ``tag``, ``ahead``, ``behind``, ``staged``, ``unmerged``, ``changes``, ``untracked``, and ``stashed``. 200 | Empty dictionary by default, which means the default formats are used. 201 | 202 | :param detached_head_style: 203 | 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. 204 | The default is ``revision``. 205 | 206 | :param untracked_not_dirty: 207 | Untracked files alone will not mark the git branch status as dirty. 208 | False by default. 209 | 210 | Divider highlight group used: ``gitstatus:divider``. 211 | 212 | 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``. 213 | ''') 214 | --------------------------------------------------------------------------------