diff --git a/.gitignore b/.gitignore index 964f82e7..a6d30c82 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -src/.bin \ No newline at end of file +.coverage +.python-version + +# Directories +src/.bin +.cache/ +**/__pycache__ +.pytest_cache +**/.stack-work +.tox diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..3f9bbd30 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,263 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS,.git,.tox,venv + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + + +[MESSAGES CONTROL] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# +# W0511: FIXME/TODO lines in code +disable=W0511 + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". +files-output=no + +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=8 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[BASIC] + +# List of builtins function names that should not be used, separated by a comma +bad-functions=map,filter,apply,input + +# Regular expression which should only match correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression which should only match correct module level names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression which should only match correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression which should only match correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct instance attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct attribute names in class +# bodies +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression which should only match correct list comprehension / +# generator expression variable names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=SQLObject + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST,acl_users,aq_parent + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the beginning of the name of dummy variables +# (i.e. not used). +dummy-variables-rgx=_$|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=100 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/.travis.yml b/.travis.yml index 09b196d4..e698f721 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,36 +1,64 @@ +--- # Use new container infrastructure to enable caching sudo: false -# Choose a lightweight base image; we provide our own build tools. -language: c +# Python for easy testing of python version +language: python -# GHC depends on GMP. You can add other dependencies here as well. +matrix: + include: + # Tested and fails: python <= 2.6.x, python <= 3.1.x + # Tested to work but not in this matrix: 3.2.6, 3.3.7 + - env: ARGS="python" + python: "2.7" + - env: ARGS="python" + python: "3.4" + - env: ARGS="python" + python: "3.5" + - env: ARGS="python" + python: "3.6" + + # The different haskell configurations to test. + # You could also do things like change flags or + # use --stack-yaml to point to a different file. + - env: ARGS="" + python: "2.7" + - env: ARGS="--resolver lts-2" + python: "2.7" + - env: ARGS="--resolver lts-3" + python: "2.7" + - env: ARGS="--resolver lts" + python: "2.7" + - env: ARGS="--resolver nightly" + python: "2.7" + +# # GHC depends on GMP. You can add other dependencies here as well. addons: apt: packages: - - libgmp-dev - -# The different configurations we want to test. You could also do things like -# change flags or use --stack-yaml to point to a different file. -env: -- ARGS="" -- ARGS="--resolver lts-2" -- ARGS="--resolver lts-3" -- ARGS="--resolver lts" -- ARGS="--resolver nightly" + - libgmp-dev -before_install: -# Download and unpack the stack executable -- mkdir -p ~/.local/bin -- export PATH=$HOME/.local/bin:$PATH -- travis_retry curl -L https://www.stackage.org/stack/linux-x86_64 | tar xz --wildcards --strip-components=1 -C ~/.local/bin '*/stack' +# Download and unpack the stack executable for haskell, else pip deps. +install: + - if [ "$ARGS" = "python" ]; then + pip install tox; + else + mkdir -p ~/.local/bin; + export PATH=$HOME/.local/bin:$PATH; + travis_retry curl -L https://www.stackage.org/stack/linux-x86_64 | tar xz --wildcards --strip-components=1 -C ~/.local/bin '*/stack'; + fi # This line does all of the work: installs GHC if necessary, build the library, # executables, and test suites, and runs the test suites. --no-terminal works # around some quirks in Travis's terminal implementation. -script: stack $ARGS --no-terminal --install-ghc test --haddock +script: + - if [ "$ARGS" = "python" ]; then + tox; + else + stack $ARGS --no-terminal --install-ghc test --haddock; + fi # Caching so the next build will be fast too. cache: directories: - - $HOME/.stack + - $HOME/.stack diff --git a/README.md b/README.md index 668726cf..b9b24164 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # Informative git prompt for zsh -[![Build Status](https://travis-ci.org/olivierverdier/zsh-git-prompt.svg)](https://travis-ci.org/olivierverdier/zsh-git-prompt) +[![Build Status](https://travis-ci.org/starcraftman/zsh-git-prompt.svg?branch=master)](https://travis-ci.org/starcraftman/zsh-git-prompt) A `zsh` prompt that displays information about the current git repository. In particular the branch name, difference with remote branch, number of files staged, changed, etc. (an original idea from this [blog post][]). +## Active Fork + +This is an active fork of olivierverdier/zsh-git-prompt + +The original maintainer is inactive, I aim to maintain and extend the original with new features. + +I do not write Haskell, so I can only ensure the `zshrc.sh` and python version are working. Please contribute PRs for Haskell parity. See issue #5 . + +A summary of all changes can be found at [Fork Status](https://github.com/starcraftman/zsh-git-prompt/wiki) + ## Examples The prompt may look like the following: @@ -36,31 +46,38 @@ The symbols are as follows: ### Local Status Symbols -|Symbol|Meaning -|------|------| -|✔ | repository clean -|●n | there are `n` staged files -|✖n | there are `n` unmerged files -|✚n | there are `n` changed but *unstaged* files -|… | there are some untracked files - +Symbol | Meaning +------ | ------- +✔ | repository clean +●n | there are `n` staged files +✖n | there are `n` unmerged files +✚n | there are `n` changed but *unstaged* files +…n | there are `n` untracked files +⚑n | there are `n` stashes on the repo ### Branch Tracking Symbols -Symbol | Meaning --------|------- -↑n | ahead of remote by `n` commits -↓n | behind remote by `n` commits -↓m↑n | branches diverged, other by `m` commits, yours by `n` commits +Symbol | Meaning +------- | ------- +↑·n | ahead of remote by `n` commits +↓·n | behind remote by `n` commits +↓·m↑·n | branches diverged, other by `m` commits, yours by `n` commits + + +### Branch States -### Branch Symbol +- **master|✔** -- On a branch (master), clean +- **:3adh57m|✔** -- Checked out a hash +- **dev|MERGING|✖1** -- Doing a merge onto dev, 1 conflict +- **:h2x78q0|REBASE 1/3|✖2** -- Doing a rebase, on first or 3 commits, 2 conflicts -When the branch name starts with a colon `:`, it means it’s actually a hash, not a branch (although it should be pretty clear, unless you name your branches like hashes :-) +When the branch name starts with a colon `:`, it means it’s actually a hash, not a branch. +It should be pretty clear, unless you name your branches like hashes :-) ## Install 1. Clone this repository somewhere on your hard drive. -2. Source the file `zshrc.sh` from your `~/.zshrc` config file, and +1. Source the file `zshrc.sh` from your `~/.zshrc` config file, and configure your prompt. So, somewhere in `~/.zshrc`, you should have: ```sh @@ -68,26 +85,59 @@ When the branch name starts with a colon `:`, it means it’s actually a hash, n # an example prompt PROMPT='%B%m%~%b$(git_super_status) %# ' ``` -3. Go in a git repository and test it! + +1. Go in a git repository and test it! ### Haskell (optional) There is now a Haskell implementation as well, which can be four to six times faster than the Python one. The reason is not that Haskell is faster in itself (although it is), but that this implementation calls `git` only once. To install, do the following: 1. Make sure [Haskell's stack](http://docs.haskellstack.org/en/stable/README.html#how-to-install) is installed on your system -2. `cd` to this folder -2. Run `stack setup` to install the Haskell compiler, if it is not already there -3. Run `stack build && stack install` (don't worry, the executable is only “installed” in this folder, not on your system) -4. Define the variable `GIT_PROMPT_EXECUTABLE="haskell"` somewhere in - your `.zshrc` +1. `cd` to this folder +1. Run `stack setup` to install the Haskell compiler, if it is not already there +1. Run `stack build && stack install` (don't worry, the executable is only “installed” in this folder, not on your system) +1. Define the variable `GIT_PROMPT_EXECUTABLE="haskell"` somewhere in your `.zshrc` + +## Customization + +- Define the variable `ZSH_THEME_GIT_PROMPT_CACHE=1` in order to enable caching. + +- Define the variable `ZSH_GIT_PROMPT_SHOW_UPSTREAM=1` in order to see the remote branch you are tracking. + +- Define the variable `ZSH_GIT_PROMPT_SHOW_UPSTREAM=2` to show the remote as above but omit the remote branch when its name is equal to the local branch. -## Customisation +Demo: + +![upstream example](https://user-images.githubusercontent.com/470400/40869339-52ae782c-65e7-11e8-89a9-2e053b3f8198.png) + +- By default, python version invokes `python`. To force a specific python interpreter: `ZSH_GIT_PROMPT_PYBIN=/usr/bin/python2.7`. - You may redefine the function `git_super_status` (after the `source` statement) to adapt it to your needs (to change the order in which the information is displayed). -- Define the variable `ZSH_THEME_GIT_PROMPT_CACHE` in order to enable caching. -- You may also change a number of variables (which name start with `ZSH_THEME_GIT_PROMPT_`) to change the appearance of the prompt. Take a look in the file `zshrc.sh` to see how the function `git_super_status` is defined, and what variables are available. + +- To modify the symbols/colors of the theme, simply redefine the variables at bottom of the +the `zshrc.sh` after sourcing. This could be in your `~/.zshrc` or sourced elsewhere. +These are the defaults: + +```sh + ZSH_THEME_GIT_PROMPT_PREFIX="[" + ZSH_THEME_GIT_PROMPT_SUFFIX="]" + ZSH_THEME_GIT_PROMPT_HASH_PREFIX=":" + ZSH_THEME_GIT_PROMPT_SEPARATOR="|" + ZSH_THEME_GIT_PROMPT_BRANCH="%{$fg_bold[magenta]%}" + ZSH_THEME_GIT_PROMPT_STAGED="%{$fg[red]%}%{●%G%}" + ZSH_THEME_GIT_PROMPT_CONFLICTS="%{$fg[red]%}%{✖%G%}" + ZSH_THEME_GIT_PROMPT_CHANGED="%{$fg[blue]%}%{✚%G%}" + ZSH_THEME_GIT_PROMPT_BEHIND="%{↓·%2G%}" + ZSH_THEME_GIT_PROMPT_AHEAD="%{↑·%2G%}" + ZSH_THEME_GIT_PROMPT_STASHED="%{$fg_bold[blue]%}%{⚑%G%}" + ZSH_THEME_GIT_PROMPT_UNTRACKED="%{$fg[cyan]%}%{…%G%}" + ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[green]%}%{✔%G%}" + ZSH_THEME_GIT_PROMPT_LOCAL=" L" + # The remote branch will be shown between these two + ZSH_THEME_GIT_PROMPT_UPSTREAM_FRONT=" {%{$fg_bold[blue]%}" + ZSH_THEME_GIT_PROMPT_UPSTREAM_END="%{${reset_color}%}}" +``` **Enjoy!** - [blog post]: http://sebastiancelis.com/2009/nov/16/zsh-prompt-git-users/ - +[blog post]: http://sebastiancelis.com/2009/nov/16/zsh-prompt-git-users/ diff --git a/gitstatus.py b/gitstatus.py index d944fd4e..34b3550d 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -1,65 +1,244 @@ #!/usr/bin/env python -from __future__ import print_function +# -*- coding: utf-8 -*- +""" +Retrieve information about the git repository in CWD. -# change this symbol to whatever you prefer -prehash = ':' +Invoked by ./zshrc.sh automatically. +""" +import os +import subprocess as sub +import sys -from subprocess import Popen, PIPE +# Denotes no upstream set, impossible branch name per git ref spec +SYM_NOUPSTREAM = '..' +# This symbol appears before hashes when detached +SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') + + +def find_git_root(): + """ + Find the nearest enclosing git root (i.e. the path to .git). + + Returns: The path to the .git project root + + Raises: + IOError: There is no `.git` folder in the current folder hierarchy + """ + working_d = os.getcwd() + while working_d != '/': + git_d = os.path.join(working_d, '.git') + if os.path.exists(git_d): + return git_d + working_d = os.path.dirname(working_d) + + raise IOError("No git dir in folder hierarchy.") + + +# Example contents of worktree `.git` file, worktree is w1 +# gitdir: /tmp/zsh-git-prompt/.git/worktrees/w1 +def git_paths(git_root): + """ + Determine the location of the following files: + head_file: The HEAD file for this tree, always lists branch ref else the current hash. + stash_file: The path to global stash log + merge_file: A MERGE_HEAD file for this tree + rebase_dir: The rebase directory for this tree + This function takes into account if we are currently in a worktree. + Paths will be absolute to location in the ORIGINAL repository. + + Returns: (head_file, stash_file, merge_file, rebase_dir) + """ + if os.path.isdir(git_root): + tree_d = git_root + else: # worktree + with open(git_root) as fin: + tree_d = fin.read().split(": ")[1].strip() + + git_root = tree_d + while os.path.basename(git_root) != '.git': + git_root = os.path.dirname(git_root) + + stash_file = os.path.join(git_root, 'logs', 'refs', 'stash') + head_file = os.path.join(tree_d, 'HEAD') + merge_file = os.path.join(tree_d, 'MERGE_HEAD') + rebase_dir = os.path.join(tree_d, 'rebase-apply') + + return head_file, stash_file, merge_file, rebase_dir + + +def parse_branch(branch, head_file): + """ + Determine the current state of HEAD (on a branch or checked out on hash). + Determine if the branch has an upstream set. + Determine if the branch is local only. + + Args: + branch: The branch line of porcelain + head_file: The path to the main HEAD file + + Returns: A tuple of following ... + branch: Set to the actual branch name or the hash we are on + upstream: Set to the upstream branch if tracked else SYM_NOUPSTREAM + local: 1 IFF the branch has no upstream and is not checked out hash + """ + branch = branch[3:] + if ' [' in branch: + branch = branch[:branch.rindex(' [')] + + upstream = SYM_NOUPSTREAM + local = 1 + if '...' in branch: + branch, upstream = branch.split('...') + local = 0 + elif 'no branch' in branch: + with open(head_file) as fin: + branch = SYM_PREHASH + fin.read().strip()[:7] + local = 0 + elif branch.startswith('Initial commit') or branch.startswith('No commits yet'): + branch = branch.split(' ')[-1] + + return branch, upstream, local + + +def parse_ahead_behind(branch): + """ + Parse how far ahead and/or behind the branch and remote are. + + Args: + branch: The branch line of porcelain + + Returns: (# commits behind, # commits ahead) + """ + ahead, behind = 0, 0 + if branch[-1] == ']': + for part in branch[branch.rindex('[') + 1:-1].split(','): + if 'ahead' in part: + ahead = int(part.replace('ahead ', '')) + elif 'behind' in part: + behind = int(part.replace('behind ', '')) + + return ahead, behind + + +def parse_stats(lines): + """ + Computes and returns the following numbers describing the current git state: + number of staged files + number of conflicts + number of changed files + number of untracked files + + Returns: (# staged, # conflicts, # changed, # untracked) + """ + staged, conflicts, changed, untracked = 0, 0, 0, 0 + + # See status format in docs: https://git-scm.com/docs/git-status + for line in lines: + if line[0:2] == '??': + untracked += 1 + continue + if line[0:2] in ['AA', 'AU', 'DD', 'DU', 'UA', 'UD', 'UU']: + conflicts += 1 + continue + if line[0] in ['A', 'C', 'D', 'M', 'R']: + staged += 1 + if line[1] in ['C', 'D', 'M', 'R']: + changed += 1 + + return staged, conflicts, changed, untracked + + +def stash_count(stash_file): + """ + Determine the number of stashes on the repository by looking at the stash log. + + Args: + stash_file: The path to the stash log + + Returns: The number of stashes + """ + try: + with open(stash_file) as fin: + stashes = len(fin.readlines()) + except IOError: + stashes = 0 + + return stashes + + +def rebase_progress(rebase_dir): + """ + Determine the rebase status of this repostitory and return it. + + Args: + rebase_dir: The path to the rebasing directory + + Returns: + - "0": No active rebase + - "1/4": Rebase in progress, commit 1 of 4 + """ + try: + with open(os.path.join(rebase_dir, 'next')) as next_file,\ + open(os.path.join(rebase_dir, 'last')) as last_file: + rebase = next_file.read().strip() + '/' + last_file.read().strip() + except IOError: + rebase = '0' + + return rebase + + +def current_git_status(lines): + """ + Parse git status procelain output and return the formatted text that + represents the current status of the respoistory. + + Returns: The formatted message representing the git repository + + Raises: + IOError: There is no `.git` folder in the current folder hierarchy + """ + head_file, stash_file, merge_file, rebase_dir = git_paths(find_git_root()) + branch, upstream, local = parse_branch(lines[0], head_file) + remote = parse_ahead_behind(lines[0]) + stats = parse_stats(lines[1:]) + stashes = stash_count(stash_file) + merge = int(os.path.isfile(merge_file)) + rebase = rebase_progress(rebase_dir) + + values = [str(x) for x in (branch,) + remote + stats + + (stashes, local, upstream, merge, rebase)] + + return ' '.join(values) + + +def main(): + """ + This program can be run two ways: + 1) `./gitstatus.py` + Will wait on subprocess to execute below git status command. + + 2) `git status --branch --porcelain | ./gitstatus.py` + Will read stdin and parse it. + """ + if not sys.stdin.isatty(): + lines = [line.rstrip() for line in sys.stdin.readlines()] + err = u'\n'.join(lines) + else: + proc = sub.Popen(['git', 'status', '--branch', '--porcelain'], + stdout=sub.PIPE, stderr=sub.PIPE) + out, err = proc.communicate() + err = err.decode('utf-8', errors='ignore').strip() + lines = out.decode('utf-8', errors='ignore').splitlines() + + if err.lower().startswith('fatal: not a git repository'): + return + + try: + sys.stdout.write(current_git_status(lines)) + sys.stdout.flush() + except IOError: # pragma: no cover + pass -import sys -gitsym = Popen(['git', 'symbolic-ref', 'HEAD'], stdout=PIPE, stderr=PIPE) -branch, error = gitsym.communicate() - -error_string = error.decode('utf-8') - -if 'fatal: Not a git repository' in error_string: - sys.exit(0) - -branch = branch.decode("utf-8").strip()[11:] - -res, err = Popen(['git','diff','--name-status'], stdout=PIPE, stderr=PIPE).communicate() -err_string = err.decode('utf-8') -if 'fatal' in err_string: - sys.exit(0) -changed_files = [namestat[0] for namestat in res.decode("utf-8").splitlines()] -staged_files = [namestat[0] for namestat in Popen(['git','diff', '--staged','--name-status'], stdout=PIPE).communicate()[0].splitlines()] -nb_changed = len(changed_files) - changed_files.count('U') -nb_U = staged_files.count('U') -nb_staged = len(staged_files) - nb_U -staged = str(nb_staged) -conflicts = str(nb_U) -changed = str(nb_changed) -nb_untracked = len([0 for status in Popen(['git','status','--porcelain',],stdout=PIPE).communicate()[0].decode("utf-8").splitlines() if status.startswith('??')]) -untracked = str(nb_untracked) - -ahead, behind = 0,0 - -if not branch: # not on any branch - branch = prehash + Popen(['git','rev-parse','--short','HEAD'], stdout=PIPE).communicate()[0].decode("utf-8")[:-1] -else: - remote_name = Popen(['git','config','branch.%s.remote' % branch], stdout=PIPE).communicate()[0].decode("utf-8").strip() - if remote_name: - merge_name = Popen(['git','config','branch.%s.merge' % branch], stdout=PIPE).communicate()[0].decode("utf-8").strip() - if remote_name == '.': # local - remote_ref = merge_name - else: - remote_ref = 'refs/remotes/%s/%s' % (remote_name, merge_name[11:]) - revgit = Popen(['git', 'rev-list', '--left-right', '%s...HEAD' % remote_ref],stdout=PIPE, stderr=PIPE) - revlist = revgit.communicate()[0] - if revgit.poll(): # fallback to local - revlist = Popen(['git', 'rev-list', '--left-right', '%s...HEAD' % merge_name],stdout=PIPE, stderr=PIPE).communicate()[0] - behead = revlist.decode("utf-8").splitlines() - ahead = len([x for x in behead if x[0]=='>']) - behind = len(behead) - ahead - -out = ' '.join([ - branch, - str(ahead), - str(behind), - staged, - conflicts, - changed, - untracked, - ]) -print(out, end='') +if __name__ == "__main__": + main() diff --git a/test_gitstatus.py b/test_gitstatus.py new file mode 100644 index 00000000..8ca65b97 --- /dev/null +++ b/test_gitstatus.py @@ -0,0 +1,980 @@ +""" +Test module for gitstatus + +Fixtures used to to setup git repo scenarios on the fly. +Tests are short and at the end of this file. +""" +from __future__ import absolute_import, print_function +import os +import re +import shlex +import shutil +import subprocess as sub +import tempfile + +import pytest + +import gitstatus + +GIT_STATUS = os.path.join(os.path.dirname(__file__), 'gitstatus.py') + + +def run_gitstatus(): + """ + Helper to simply run gitstatus in the current directory. + + Returns: + The output of gitstatus.py in the CWD. + """ + return sub.check_output(['python', GIT_STATUS]).decode('utf-8', errors='ignore') + + +@pytest.yield_fixture(scope="function") +def empty_working_directory(): + """ + Run a test inside an empty temporary directory. + """ + cwd = os.getcwd() + try: + folder = tempfile.mkdtemp() + os.chdir(folder) + + yield + finally: + os.chdir(cwd) + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + + +@pytest.yield_fixture(scope="function") +def git_repo_initial_commit(): + """ + Create a fake git repo with the following properties: + - No commits beyond initialization. + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_find_git_root(): + """ + Create a fake git repo with the following properties: + - 1 commit + - nested folders called, first/second/third + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + ] + try: + subs = os.path.join(folder, 'd_one', 'd_two', 'd_three') + os.makedirs(subs) + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_with_worktree(): + """ + Create a fake git repo with the following properties: + - main repo has 3 commits + - upstream repo has 3 commits + - main repo has upstream set and is has diverged by 1 commit each way + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_tree = folder + "_worktree" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:Second line", + "git add first", + "git commit -m 'second commit'", + "git branch tree", + "git checkout tree", + "first:third line", + "git add first", + "git commit -m 'third commit'", + "git checkout master", + "git worktree add --detach %s tree" % (folder_tree), + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + os.chdir(folder_tree) + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_tree) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_parse_stats(): + """ + Create a fake git repo with the following properties: + - upstream set to another local git repo + - 3 staged files (1 changed, 2 additions) + - 1 changed file unstaged + - 2 untracked files + - 1 stashed change set + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_up = folder + "_upstream" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "second:A single line", + "third:A single line", + "touch untracked1 untracked2", + "git add first", + "git commit -m 'first commit'", + "first:Changes to stash", + "git stash", + "first:Changes to stage", + "git add first second third", + "first:Changes but unstaged", + "cp -R %s %s" % (folder, folder_up), + "git remote add -f up %s" % folder_up, + "git branch --set-upstream-to=up/master", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_parse_stats_only_conflicts(): + """ + Create a fake git repo with the following properties: + - upstream set to another local git repo + - edit the same file and create a merge conflict + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_up = folder + "_upstream" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line\nsecond line\third line", + "git add first", + "git commit -m 'first commit'", + "first:fourth line\nfifth line\n", + "git add first", + "git commit -m 'second commit'", + "cp -R %s %s" % (folder, folder_up), + "git reset --hard HEAD~1", + "first:ninth line\ntenth line\n", + "git add first", + "git commit -m 'new second commit'", + "git remote add -f up %s" % folder_up, + "git branch --set-upstream-to=up/master", + "git fetch up", + "git merge up/master", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + try: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + except sub.CalledProcessError: + pass + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_branch_on_hash(): + """ + Create a fake git repo with the following properties: + - 3 commits made + - yield when on checkout hash + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:A second line", + "git add first", + "git commit -m 'second commit'", + "git checkout HEAD~1", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_branch_on_master(): + """ + Create a fake git repo with the following properties: + - 3 commits made + - yield when on checkout hash + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:A second line", + "git add first", + "git commit -m 'second commit'", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_branch_local_only(): + """ + Create a fake git repo with the following properties: + - 1 commit + - no upstream copy or set value + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_remote_ahead(): + """ + Create a fake git repo with the following properties: + - main repo has 3 commits + - upstream repo has 2 commits + - main repo has upstream set and is AHEAD by 1 commit + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_up = folder + "_upstream" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:Second line", + "git add first", + "git commit -m 'second commit'", + "cp -R %s %s" % (folder, folder_up), + "first:third line", + "git add first", + "git commit -m 'third commit'", + "git remote add -f up %s" % folder_up, + "git branch --set-upstream-to=up/master", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_remote_behind(): + """ + Create a fake git repo with the following properties: + - main repo has 2 commits + - upstream repo has 3 commits + - main repo has upstream set and is BEHIND by 1 commit + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_up = folder + "_upstream" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:Second line", + "git add first", + "git commit -m 'second commit'", + "first:third line", + "git add first", + "git commit -m 'third commit'", + "cp -R %s %s" % (folder, folder_up), + "git remote add -f up %s" % folder_up, + "git branch --set-upstream-to=up/master", + "git reset --hard HEAD~1", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_remote_diverged(): + """ + Create a fake git repo with the following properties: + - main repo has 3 commits + - upstream repo has 4 commits + - main repo has upstream set and is has diverged 2 behind, 1 ahead + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_up = folder + "_upstream" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:Second line", + "git add first", + "git commit -m 'second commit'", + "first:third line", + "git add first", + "git commit -m 'third commit'", + "first:fourth line", + "git add first", + "git commit -m 'fourth commit'", + "cp -R %s %s" % (folder, folder_up), + "git remote add -f up %s" % folder_up, + "git branch --set-upstream-to=up/master", + "git reset --hard HEAD~2", + "first:different third line", + "git add first", + "git commit -m 'different third commit'", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_in_merge(): + """ + Create a fake git repo with the following properties: + - master branch with 2 commits + - dev branch that has 2 commits, last one differs from master + - dev branch is merging master into it + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "git branch dev", + "first:the second master line here", + "git add first", + "git commit -m 'second master commit'", + "git checkout dev", + "first:Second line for dev", + "git add first", + "git commit -m 'second dev commit'", + "git merge master", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + proc = sub.Popen(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + proc.wait() + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_in_rebase(): + """ + Create a fake git repo with the following properties: + - master branch with 3 commits + - dev branch that has 3 commits, last two differ from master + - dev is rebasing master, 2 commits need resolving + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "git branch dev", + "first:the second master line here", + "git add first", + "git commit -m 'second master commit'", + "first:there is also a third master", + "git add first", + "git commit -m 'third master commit'", + "git checkout dev", + "first:Second line", + "git add first", + "git commit -m 'second dev commit'", + "first:Third line\nForuth line", + "git add first", + "git commit -m 'third dev commit'", + "git rebase master", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + proc = sub.Popen(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + proc.wait() + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +@pytest.yield_fixture(scope="function") +def git_repo_upstream_gone(): + """ + Create a fake git repo with the following properties: + - create a repo with 2 commits and a 'dev' branch + - copy repo to create an upstream + - set 'dev' branch to track 'up/dev' + - delete upstream dev + """ + cwd = os.getcwd() + folder = tempfile.mkdtemp() + folder_up = folder + "_upstream" + cmds = [ + "git init", + "git config user.email 'you@example.com'", + "git config user.name 'Your Name'", + "first:A single line", + "git add first", + "git commit -m 'first commit'", + "first:Second line", + "git add first", + "git commit -m 'second commit'", + "cp -R %s %s" % (folder, folder_up), + "git remote add -f up %s" % folder_up, + "git branch dev", + "git checkout dev", + "git push -u up dev", + "git fetch up", + "git push up :dev", + ] + try: + os.chdir(folder) + + for cmd in cmds: + if re.match(r'\S+:', cmd): + assert len(cmd.split(":")) == 2 + fname, text = cmd.split(":") + with open(os.path.join(folder, fname), 'a') as fout: + fout.write(text + '\n') + else: + with open(os.devnull, 'w') as devnull: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +# ---------- +# Unit Tests +# ---------- +def test_find_git_root(git_repo_find_git_root): + """ A unit test for gitstatus. """ + expect = os.path.join(os.getcwd(), '.git') + sub_d = os.path.join(os.getcwd(), 'd_one', 'd_two', 'd_three') + assert os.path.isdir(sub_d) + os.chdir(sub_d) + assert gitstatus.find_git_root() == expect + + +def test_find_git_root_fail(empty_working_directory): + """ A unit test for gitstatus. """ + with pytest.raises(IOError): + gitstatus.find_git_root() + + +def test_git_paths_in_normal_repo(git_repo_initial_commit): + """ A unit test for gitstatus. """ + head_file, stash_file, merge_file, rebase_dir = gitstatus.git_paths(gitstatus.find_git_root()) + assert head_file == os.path.join(os.getcwd(), '.git', 'HEAD') + assert stash_file == os.path.join(os.getcwd(), '.git', 'logs', 'refs', 'stash') + assert merge_file == os.path.join(os.getcwd(), '.git', 'MERGE_HEAD') + assert rebase_dir == os.path.join(os.getcwd(), '.git', 'rebase-apply') + + +def test_git_paths_in_working_tree(git_repo_with_worktree): + """ A unit test for gitstatus. """ + repo_root = os.getcwd().replace('_worktree', '') + tree_root = os.path.join(repo_root, '.git', 'worktrees', + os.path.basename(repo_root) + '_worktree') + head_file, stash_file, merge_file, rebase_dir = gitstatus.git_paths(gitstatus.find_git_root()) + assert head_file == os.path.join(tree_root, 'HEAD') + assert stash_file == os.path.join(repo_root, '.git', 'logs', 'refs', 'stash') + assert merge_file == os.path.join(tree_root, 'MERGE_HEAD') + assert rebase_dir == os.path.join(tree_root, 'rebase-apply') + + +def test_parse_stats(): + """ A unit test for gitstatus. """ + status_input = """?? untracked1 +?? untracked2 +?? untracked3 +AA conflicts1 +AU conflicts2 +DD conflicts3 +DU conflicts4 +UA conflicts5 +UD conflicts6 +UD conflicts7 +A_ staged1 +C_ staged2 +D_ staged3 +M_ staged4 +R_ staged5 +_C changed1 +_D changed2 +_M changed3 +_R changed4""" + assert gitstatus.parse_stats(status_input.splitlines()) == (5, 7, 4, 3) + + +def test_parse_ahead_behind_only_ahead(): + """ A unit test for gitstatus. """ + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 2]") == (2, 0) + + +def test_parse_ahead_behind_only_behind(): + """ A unit test for gitstatus. """ + assert gitstatus.parse_ahead_behind("## master...up/master [behind 1]") == (0, 1) + + +def test_parse_ahead_behind_both(): + """ A unit test for gitstatus. """ + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 2, behind 1]") == (2, 1) + + +def test_parse_branch_on_local_branch(): + """ A unit test for gitstatus. """ + branch_line = "## master" + assert gitstatus.parse_branch(branch_line, None) == ('master', '..', 1) + + +def test_parse_branch_has_upstream(): + """ A unit test for gitstatus. """ + branch_line = "## master...up/master [ahead 2, behind 1]" + assert gitstatus.parse_branch(branch_line, None) == ('master', 'up/master', 0) + + +def test_parse_branch_out_on_hash(git_repo_branch_on_hash): + """ A unit test for gitstatus. """ + actual_hash = sub.check_output(shlex.split('git rev-parse --short HEAD')) + actual_hash = actual_hash.decode('utf-8', errors='ignore').strip() + head_file = os.path.join(os.getcwd(), '.git', 'HEAD') + branch_line = "## HEAD (no branch)" + assert gitstatus.parse_branch(branch_line, head_file) == (':' + actual_hash, '..', 0) + + +def test_stash_count_one_stash(git_repo_parse_stats): + """ A unit test for gitstatus. """ + stash_file = os.path.join(os.getcwd(), '.git', 'logs', 'refs', 'stash') + assert gitstatus.stash_count(stash_file) == 1 + + +def test_stash_count_no_stash(git_repo_initial_commit): + """ A unit test for gitstatus. """ + stash_file = os.path.join(os.getcwd(), 'logs', 'refs', 'stash') + assert gitstatus.stash_count(stash_file) == 0 + + +def test_rebase_progress_active_rebase(git_repo_in_rebase): + rebase_dir = os.path.join(os.getcwd(), '.git', 'rebase-apply') + assert gitstatus.rebase_progress(rebase_dir) == '1/2' + + +def test_rebase_progress_no_rebase(git_repo_initial_commit): + rebase_dir = os.path.join(os.getcwd(), '.git', 'rebase-apply') + assert gitstatus.rebase_progress(rebase_dir) == '0' + + +# ---------------- +# Functional Tests +# ---------------- +def test_gitstatus_no_repo(empty_working_directory): + """ A unit test for gitstatus. """ + assert run_gitstatus() == '' + + +def test_gitstatus_initial_commit(git_repo_initial_commit): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0 0'.format(gitstatus.SYM_NOUPSTREAM) + + +def test_gitstatus_local_branch(git_repo_branch_on_master): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0 0'.format(gitstatus.SYM_NOUPSTREAM) + + +def test_gitstatus_on_hash(git_repo_branch_on_hash): + """ A unit test for gitstatus. """ + actual_hash = sub.check_output(shlex.split('git rev-parse --short HEAD')) + actual_hash = actual_hash.decode('utf-8', errors='ignore').strip() + assert run_gitstatus() == ':{} 0 0 0 0 0 0 0 0 {} 0 0'.format(actual_hash, + gitstatus.SYM_NOUPSTREAM) + + +def test_gitstatus_parse_stats_no_conflicts(git_repo_parse_stats): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0 up/master 0 0' + + +def test_gitstatus_parse_stats_only_conflicts(git_repo_parse_stats_only_conflicts): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0 up/master 1 0' + + +def test_gitstatus_remote_ahead(git_repo_remote_ahead): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0 up/master 0 0' + + +def test_gitstatus_remote_behind(git_repo_remote_behind): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0 up/master 0 0' + + +def test_gitstatus_remote_diverged(git_repo_remote_diverged): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 1 2 0 0 0 0 0 0 up/master 0 0' + + +def test_gitstatus_stdin(git_repo_parse_stats): + """ A unit test for gitstatus. """ + std_input = sub.check_output(['git', 'status', '--branch', '--porcelain']) + with tempfile.TemporaryFile() as finput: + finput.write(std_input) + finput.seek(0) + out = sub.check_output(['python', GIT_STATUS], stdin=finput).decode('utf-8') + assert out == 'master 0 0 3 0 1 2 1 0 up/master 0 0' + + +def test_gitstatus_merging(git_repo_in_merge): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'dev 0 0 0 1 0 0 0 1 .. 1 0' + + +def test_gitstatus_rebasing(git_repo_in_rebase): + """ A unit test for gitstatus. """ + actual_hash = sub.check_output(shlex.split('git rev-parse --short HEAD')) + actual_hash = actual_hash.decode('utf-8', errors='ignore').strip() + assert run_gitstatus() == ':{} 0 0 0 1 0 0 0 0 .. 0 1/2'.format(actual_hash) + + +def test_gitstatus_upstream_gone(git_repo_upstream_gone): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'dev 0 0 0 0 0 0 0 0 up/dev 0 0' diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..7994cb7d --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +;tox documentation: https://testrun.org/tox/latest/config.html + +[tox] +; No plans to ever work with python < 3.5, depend on asyncio +; Ideally install python versions with pyenv and set to pyenv local +envlist = py, pylint, flake8 +skip_missing_interpreters = True +skipsdist = True + +[testenv] +commands = python -m pytest {posargs} --cov=gitstatus --cov-report term-missing +deps = + pytest + pytest-cov + mock + +[testenv:flake8] +commands = python -m flake8 +deps = flake8 + +[testenv:pylint] +commands = python -m pylint --rcfile=.pylintrc ./gitstatus.py +deps = pylint + +[flake8] +exclude = .tox,*.egg*,build,docs,dist,venv,z_scratch +; Ignore some warnings, comma list +ignore = F403 +max-complexity = 10 +max-line-length = 100 +statistics = 1 + +[pep8] +exclude = .tox,*.egg*,build,docs,dist,venv +; Ignore some warnings, comma list +;ignore = +statistics = 1 + +[pytest] +addopts = -s -vvvvv -rEfsxX --showlocals +norecursedirs = .eggs .git .tox build dist venv py3 + +;vim:set et sw=2 ts=4: diff --git a/zshrc.sh b/zshrc.sh index d4010c1e..4c1c9b3e 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -1,108 +1,153 @@ # To install source this file from your .zshrc file # see documentation at http://linux.die.net/man/1/zshexpn -# A: finds the absolute path, even if this is symlinked -# h: equivalent to dirname -export __GIT_PROMPT_DIR=${0:A:h} - -export GIT_PROMPT_EXECUTABLE=${GIT_PROMPT_EXECUTABLE:-"python"} - -# Initialize colors. -autoload -U colors -colors - -# Allow for functions in the prompt. -setopt PROMPT_SUBST - -autoload -U add-zsh-hook - -add-zsh-hook chpwd chpwd_update_git_vars -add-zsh-hook preexec preexec_update_git_vars -add-zsh-hook precmd precmd_update_git_vars - -## Function definitions -function preexec_update_git_vars() { +preexec_update_git_vars() { case "$2" in - git*|hub*|gh*|stg*) + git*|hub*|gh*|stg*) __EXECUTED_GIT_COMMAND=1 ;; esac } -function precmd_update_git_vars() { +precmd_update_git_vars() { if [ -n "$__EXECUTED_GIT_COMMAND" ] || [ ! -n "$ZSH_THEME_GIT_PROMPT_CACHE" ]; then update_current_git_vars unset __EXECUTED_GIT_COMMAND fi } -function chpwd_update_git_vars() { +chpwd_update_git_vars() { update_current_git_vars } -function update_current_git_vars() { +update_current_git_vars() { unset __CURRENT_GIT_STATUS - if [[ "$GIT_PROMPT_EXECUTABLE" == "python" ]]; then - local gitstatus="$__GIT_PROMPT_DIR/gitstatus.py" - _GIT_STATUS=`python ${gitstatus} 2>/dev/null` + if [ "$GIT_PROMPT_EXECUTABLE" = "python" ]; then + local py_bin=${ZSH_GIT_PROMPT_PYBIN:-"python"} + __GIT_CMD=$(git status --porcelain --branch &> /dev/null 2>&1 | ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX $py_bin "$__GIT_PROMPT_DIR/gitstatus.py") + else + __GIT_CMD=$(git status --porcelain --branch &> /dev/null | $__GIT_PROMPT_DIR/src/.bin/gitstatus) fi - if [[ "$GIT_PROMPT_EXECUTABLE" == "haskell" ]]; then - _GIT_STATUS=`git status --porcelain --branch &> /dev/null | $__GIT_PROMPT_DIR/src/.bin/gitstatus` - fi - __CURRENT_GIT_STATUS=("${(@s: :)_GIT_STATUS}") - GIT_BRANCH=$__CURRENT_GIT_STATUS[1] - GIT_AHEAD=$__CURRENT_GIT_STATUS[2] - GIT_BEHIND=$__CURRENT_GIT_STATUS[3] - GIT_STAGED=$__CURRENT_GIT_STATUS[4] - GIT_CONFLICTS=$__CURRENT_GIT_STATUS[5] - GIT_CHANGED=$__CURRENT_GIT_STATUS[6] - GIT_UNTRACKED=$__CURRENT_GIT_STATUS[7] + __CURRENT_GIT_STATUS=("${(@s: :)__GIT_CMD}") + unset __GIT_CMD + + GIT_BRANCH=$__CURRENT_GIT_STATUS[1] + GIT_AHEAD=$__CURRENT_GIT_STATUS[2] + GIT_BEHIND=$__CURRENT_GIT_STATUS[3] + GIT_STAGED=$__CURRENT_GIT_STATUS[4] + GIT_CONFLICTS=$__CURRENT_GIT_STATUS[5] + GIT_CHANGED=$__CURRENT_GIT_STATUS[6] + GIT_UNTRACKED=$__CURRENT_GIT_STATUS[7] + GIT_STASHED=$__CURRENT_GIT_STATUS[8] + GIT_LOCAL_ONLY=$__CURRENT_GIT_STATUS[9] + GIT_UPSTREAM=$__CURRENT_GIT_STATUS[10] + GIT_MERGING=$__CURRENT_GIT_STATUS[11] + GIT_REBASE=$__CURRENT_GIT_STATUS[12] } - git_super_status() { - precmd_update_git_vars + precmd_update_git_vars + if [ -n "$__CURRENT_GIT_STATUS" ]; then - STATUS="$ZSH_THEME_GIT_PROMPT_PREFIX$ZSH_THEME_GIT_PROMPT_BRANCH$GIT_BRANCH%{${reset_color}%}" - if [ "$GIT_BEHIND" -ne "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_BEHIND$GIT_BEHIND%{${reset_color}%}" - fi - if [ "$GIT_AHEAD" -ne "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_AHEAD$GIT_AHEAD%{${reset_color}%}" - fi - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_SEPARATOR" - if [ "$GIT_STAGED" -ne "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_STAGED$GIT_STAGED%{${reset_color}%}" - fi - if [ "$GIT_CONFLICTS" -ne "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CONFLICTS$GIT_CONFLICTS%{${reset_color}%}" - fi - if [ "$GIT_CHANGED" -ne "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CHANGED$GIT_CHANGED%{${reset_color}%}" - fi - if [ "$GIT_UNTRACKED" -ne "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_UNTRACKED%{${reset_color}%}" - fi - if [ "$GIT_CHANGED" -eq "0" ] && [ "$GIT_CONFLICTS" -eq "0" ] && [ "$GIT_STAGED" -eq "0" ] && [ "$GIT_UNTRACKED" -eq "0" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CLEAN" - fi - STATUS="$STATUS%{${reset_color}%}$ZSH_THEME_GIT_PROMPT_SUFFIX" - echo "$STATUS" - fi + local STATUS="$ZSH_THEME_GIT_PROMPT_PREFIX$ZSH_THEME_GIT_PROMPT_BRANCH$GIT_BRANCH%{${reset_color}%}" + local clean=1 + + if [ -n "$GIT_REBASE" ] && [ "$GIT_REBASE" != "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_REBASE$GIT_REBASE%{${reset_color}%}" + elif [ "$GIT_MERGING" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_MERGING%{${reset_color}%}" + fi + + if [ "$GIT_LOCAL_ONLY" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_LOCAL%{${reset_color}%}" + elif [ "$ZSH_GIT_PROMPT_SHOW_UPSTREAM" -gt "0" ] && [ -n "$GIT_UPSTREAM" ] && [ "$GIT_UPSTREAM" != ".." ]; then + local parts=( "${(s:/:)GIT_UPSTREAM}" ) + if [ "$ZSH_GIT_PROMPT_SHOW_UPSTREAM" -eq "2" ] && [ "$parts[2]" = "$GIT_BRANCH" ]; then + GIT_UPSTREAM="$parts[1]" + fi + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_UPSTREAM_FRONT$GIT_UPSTREAM$ZSH_THEME_GIT_PROMPT_UPSTREAM_END%{${reset_color}%}" + fi + + if [ "$GIT_BEHIND" -ne "0" ] || [ "$GIT_AHEAD" -ne "0" ]; then + STATUS="$STATUS " + fi + if [ "$GIT_BEHIND" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_BEHIND$GIT_BEHIND%{${reset_color}%}" + fi + if [ "$GIT_AHEAD" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_AHEAD$GIT_AHEAD%{${reset_color}%}" + fi + + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_SEPARATOR" + + if [ "$GIT_STAGED" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_STAGED$GIT_STAGED%{${reset_color}%}" + clean=0 + fi + if [ "$GIT_CONFLICTS" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CONFLICTS$GIT_CONFLICTS%{${reset_color}%}" + clean=0 + fi + if [ "$GIT_CHANGED" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CHANGED$GIT_CHANGED%{${reset_color}%}" + clean=0 + fi + if [ "$GIT_UNTRACKED" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_UNTRACKED$GIT_UNTRACKED%{${reset_color}%}" + clean=0 + fi + if [ "$GIT_STASHED" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_STASHED$GIT_STASHED%{${reset_color}%}" + clean=0 + fi + if [ "$clean" -eq "1" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CLEAN%{${reset_color}%}" + fi + + echo "%{${reset_color}%}$STATUS$ZSH_THEME_GIT_PROMPT_SUFFIX%{${reset_color}%}" + fi } -# Default values for the appearance of the prompt. Configure at will. -ZSH_THEME_GIT_PROMPT_PREFIX="(" -ZSH_THEME_GIT_PROMPT_SUFFIX=")" +# Always has path to this directory +# A: finds the absolute path, even if this is symlinked +# h: equivalent to dirname +export __GIT_PROMPT_DIR=${0:A:h} +export GIT_PROMPT_EXECUTABLE=${GIT_PROMPT_EXECUTABLE:-"python"} + +# Load required modules +autoload -U add-zsh-hook +autoload -U colors +colors + +# Allow for functions in the prompt +setopt PROMPT_SUBST + +# Hooks to make the prompt +add-zsh-hook chpwd chpwd_update_git_vars +add-zsh-hook preexec preexec_update_git_vars +add-zsh-hook precmd precmd_update_git_vars + +# Default values for the appearance of the prompt. +# The theme is identical to magicmonty/bash-git-prompt +ZSH_THEME_GIT_PROMPT_PREFIX="[" +ZSH_THEME_GIT_PROMPT_SUFFIX="]" +ZSH_THEME_GIT_PROMPT_HASH_PREFIX=":" ZSH_THEME_GIT_PROMPT_SEPARATOR="|" ZSH_THEME_GIT_PROMPT_BRANCH="%{$fg_bold[magenta]%}" ZSH_THEME_GIT_PROMPT_STAGED="%{$fg[red]%}%{●%G%}" ZSH_THEME_GIT_PROMPT_CONFLICTS="%{$fg[red]%}%{✖%G%}" ZSH_THEME_GIT_PROMPT_CHANGED="%{$fg[blue]%}%{✚%G%}" -ZSH_THEME_GIT_PROMPT_BEHIND="%{↓%G%}" -ZSH_THEME_GIT_PROMPT_AHEAD="%{↑%G%}" -ZSH_THEME_GIT_PROMPT_UNTRACKED="%{…%G%}" +ZSH_THEME_GIT_PROMPT_BEHIND="%{↓·%2G%}" +ZSH_THEME_GIT_PROMPT_AHEAD="%{↑·%2G%}" +ZSH_THEME_GIT_PROMPT_STASHED="%{$fg_bold[blue]%}%{⚑%G%}" +ZSH_THEME_GIT_PROMPT_UNTRACKED="%{$fg[cyan]%}%{…%G%}" ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[green]%}%{✔%G%}" - - +ZSH_THEME_GIT_PROMPT_LOCAL=" L" +# The remote branch will be shown between these two +ZSH_THEME_GIT_PROMPT_UPSTREAM_FRONT=" {%{$fg[blue]%}" +ZSH_THEME_GIT_PROMPT_UPSTREAM_END="%{${reset_color}%}}" +ZSH_THEME_GIT_PROMPT_MERGING="%{$fg_bold[magenta]%}|MERGING%{${reset_color}%}" +ZSH_THEME_GIT_PROMPT_REBASE="%{$fg_bold[magenta]%}|REBASE%{${reset_color}%} " + +# vim: set filetype=zsh: